| 1 | const { spawn } = require('child_process'); |
| 2 | const fs = require('fs'); |
| 3 | const path = require('path'); |
| 4 | const config = require('./config'); |
| 5 | const validate = require('./validate'); |
| 6 | |
| 7 | const ROOT = path.join(__dirname, '..'); |
| 8 | |
| 9 | function reposRoot() { |
| 10 | const r = config.get('repos.rootDir'); |
| 11 | return path.isAbsolute(r) ? r : path.join(ROOT, r); |
| 12 | } |
| 13 | |
| 14 | function repoPath(owner, name) { |
| 15 | return validate.safeJoin(reposRoot(), owner, name + '.git'); |
| 16 | } |
| 17 | |
| 18 | function exists(gitDir) { |
| 19 | try { return fs.statSync(path.join(gitDir, 'HEAD')).isFile(); } |
| 20 | catch { return false; } |
| 21 | } |
| 22 | |
| 23 | function bin() { return config.get('git.binPath') || 'git'; } |
| 24 | |
| 25 | function run(args, gitDir) { |
| 26 | return new Promise((resolve, reject) => { |
| 27 | const proc = spawn(bin(), ['--git-dir=' + gitDir, ...args]); |
| 28 | let out = ''; |
| 29 | let err = ''; |
| 30 | proc.stdout.on('data', d => { out += d; }); |
| 31 | proc.stderr.on('data', d => { err += d; }); |
| 32 | proc.on('error', reject); |
| 33 | proc.on('close', code => { |
| 34 | if (code !== 0) reject(new Error(err.trim() || 'git exit ' + code)); |
| 35 | else resolve(out); |
| 36 | }); |
| 37 | }); |
| 38 | } |
| 39 | |
| 40 | function init(gitDir) { |
| 41 | const branch = config.get('repos.defaultBranch') || 'main'; |
| 42 | return new Promise((resolve, reject) => { |
| 43 | const proc = spawn(bin(), ['init', '--bare', '--initial-branch=' + branch, gitDir]); |
| 44 | proc.on('error', reject); |
| 45 | proc.on('close', code => { |
| 46 | if (code !== 0) return reject(new Error('git init --bare failed')); |
| 47 | // Enable HTTP receive-pack so smart HTTP push works. |
| 48 | const cfg = spawn(bin(), ['--git-dir=' + gitDir, 'config', 'http.receivepack', 'true']); |
| 49 | cfg.on('close', () => resolve()); |
| 50 | cfg.on('error', () => resolve()); |
| 51 | }); |
| 52 | }); |
| 53 | } |
| 54 | |
| 55 | async function refs(gitDir) { |
| 56 | let headRef = null; |
| 57 | try { |
| 58 | const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim(); |
| 59 | const m = head.match(/^ref: refs\/heads\/(.+)$/); |
| 60 | if (m) headRef = m[1]; |
| 61 | } catch {} |
| 62 | let branches = []; |
| 63 | try { |
| 64 | const out = await run(['branch', '--format=%(refname:short)'], gitDir); |
| 65 | branches = out.split('\n').map(s => s.trim()).filter(Boolean); |
| 66 | } catch {} |
| 67 | // HEAD may point to a branch that does not exist yet (bare repo created |
| 68 | // before any push). Prefer an actual existing branch. |
| 69 | if (!headRef || !branches.includes(headRef)) { |
| 70 | if (branches.length) headRef = branches[0]; |
| 71 | else headRef = config.get('repos.defaultBranch') || 'main'; |
| 72 | } |
| 73 | return { default: headRef, branches }; |
| 74 | } |
| 75 | |
| 76 | async function commitCount(gitDir, ref) { |
| 77 | try { |
| 78 | const out = await run(['rev-list', '--count', ref], gitDir); |
| 79 | return parseInt(out.trim(), 10) || 0; |
| 80 | } catch { return 0; } |
| 81 | } |
| 82 | |
| 83 | const LOG_FORMAT = '%H%x09%an%x09%ae%x09%ai%x09%s'; |
| 84 | |
| 85 | async function log(gitDir, ref, limit, skip) { |
| 86 | const args = ['log', '--format=' + LOG_FORMAT]; |
| 87 | if (limit) args.push('-' + limit); |
| 88 | if (skip) args.push('--skip=' + skip); |
| 89 | args.push(ref); |
| 90 | const out = await run(args, gitDir); |
| 91 | return out.split('\n').filter(Boolean).map(line => { |
| 92 | const parts = line.split('\t'); |
| 93 | return { |
| 94 | hash: parts[0], |
| 95 | author: parts[1], |
| 96 | email: parts[2], |
| 97 | date: parts[3], |
| 98 | message: parts.slice(4).join('\t') |
| 99 | }; |
| 100 | }); |
| 101 | } |
| 102 | |
| 103 | async function tree(gitDir, ref, treePath) { |
| 104 | const spec = treePath ? `${ref}:${treePath}` : `${ref}:`; |
| 105 | const out = await run(['ls-tree', spec], gitDir); |
| 106 | return out.split('\n').filter(Boolean).map(line => { |
| 107 | const m = line.match(/^(\d+) (blob|tree) ([0-9a-f]+)\t(.+)$/); |
| 108 | return m ? { mode: m[1], type: m[2], hash: m[3], name: m[4] } : null; |
| 109 | }).filter(Boolean); |
| 110 | } |
| 111 | |
| 112 | function blobStream(gitDir, ref, filePath) { |
| 113 | return spawn(bin(), ['--git-dir=' + gitDir, 'cat-file', 'blob', `${ref}:${filePath}`]); |
| 114 | } |
| 115 | |
| 116 | function archiveStream(gitDir, ref, prefix) { |
| 117 | const args = ['--git-dir=' + gitDir, 'archive', '--format=zip']; |
| 118 | if (prefix) args.push('--prefix=' + prefix + '/'); |
| 119 | args.push(ref); |
| 120 | return spawn(bin(), args); |
| 121 | } |
| 122 | |
| 123 | function blobContent(gitDir, ref, filePath) { |
| 124 | return new Promise((resolve, reject) => { |
| 125 | const proc = spawn(bin(), ['--git-dir=' + gitDir, 'cat-file', 'blob', `${ref}:${filePath}`]); |
| 126 | const chunks = []; |
| 127 | proc.stdout.on('data', d => chunks.push(d)); |
| 128 | proc.on('error', reject); |
| 129 | proc.on('close', code => { |
| 130 | if (code !== 0) reject(new Error('object not found')); |
| 131 | else resolve(Buffer.concat(chunks)); |
| 132 | }); |
| 133 | }); |
| 134 | } |
| 135 | |
| 136 | async function objectSize(gitDir, objId) { |
| 137 | try { |
| 138 | const out = await run(['cat-file', '-s', objId], gitDir); |
| 139 | return parseInt(out.trim(), 10) || 0; |
| 140 | } catch { return 0; } |
| 141 | } |
| 142 | |
| 143 | async function commitInfo(gitDir, sha) { |
| 144 | const fmt = '%H%x09%an%x09%ae%x09%ai%x09%P%x09%s'; |
| 145 | const out = await run(['show', '--no-patch', '--format=' + fmt, sha], gitDir); |
| 146 | const line = out.split('\n')[0]; |
| 147 | const parts = line.split('\t'); |
| 148 | return { |
| 149 | hash: parts[0], |
| 150 | author: parts[1], |
| 151 | email: parts[2], |
| 152 | date: parts[3], |
| 153 | parents: parts[4] ? parts[4].split(' ').filter(Boolean) : [], |
| 154 | message: parts.slice(5).join('\t') |
| 155 | }; |
| 156 | } |
| 157 | |
| 158 | async function diff(gitDir, sha) { |
| 159 | return run(['diff-tree', '--no-commit-id', '-p', sha], gitDir); |
| 160 | } |
| 161 | |
| 162 | async function fileCount(gitDir, ref) { |
| 163 | try { |
| 164 | const out = await run(['ls-tree', '-r', '--name-only', ref], gitDir); |
| 165 | return out.split('\n').filter(Boolean).length; |
| 166 | } catch { return 0; } |
| 167 | } |
| 168 | |
| 169 | const LANG_EXT = { |
| 170 | js: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript', jsx: 'JavaScript', |
| 171 | ts: 'TypeScript', tsx: 'TypeScript', |
| 172 | py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust', java: 'Java', |
| 173 | c: 'C', h: 'C', cpp: 'C++', cc: 'C++', hpp: 'C++', hh: 'C++', |
| 174 | cs: 'C#', php: 'PHP', sh: 'Shell', bash: 'Shell', zsh: 'Shell', |
| 175 | ps1: 'PowerShell', bat: 'Batch', cmd: 'Batch', |
| 176 | html: 'HTML', htm: 'HTML', css: 'CSS', scss: 'SCSS', sass: 'Sass', less: 'Less', |
| 177 | md: 'Markdown', markdown: 'Markdown', |
| 178 | json: 'JSON', yml: 'YAML', yaml: 'YAML', |
| 179 | xml: 'XML', toml: 'TOML', ini: 'INI', conf: 'Config', |
| 180 | sql: 'SQL', |
| 181 | vue: 'Vue', svelte: 'Svelte', |
| 182 | swift: 'Swift', kt: 'Kotlin', dart: 'Dart', |
| 183 | ex: 'Elixir', exs: 'Elixir', erl: 'Erlang', |
| 184 | scala: 'Scala', clj: 'Clojure', elm: 'Elm', |
| 185 | lua: 'Lua', pl: 'Perl', r: 'R', |
| 186 | txt: 'Text', tex: 'TeX' |
| 187 | }; |
| 188 | |
| 189 | async function langStats(gitDir, ref) { |
| 190 | try { |
| 191 | const out = await run(['ls-tree', '-r', '-l', ref], gitDir); |
| 192 | const totals = {}; |
| 193 | let total = 0; |
| 194 | for (const line of out.split('\n')) { |
| 195 | const m = line.match(/^\d+ blob [0-9a-f]+\s+(\d+)\t(.+)$/); |
| 196 | if (!m) continue; |
| 197 | const size = parseInt(m[1], 10) || 0; |
| 198 | if (size === 0) continue; |
| 199 | const name = m[2]; |
| 200 | let ext; |
| 201 | if (/^Dockerfile/i.test(name.split('/').pop())) { ext = 'dockerfile'; } |
| 202 | else if (name.includes('.')) { ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase(); } |
| 203 | else continue; |
| 204 | const lang = LANG_EXT[ext]; |
| 205 | if (!lang) continue; |
| 206 | totals[lang] = (totals[lang] || 0) + size; |
| 207 | total += size; |
| 208 | } |
| 209 | if (total === 0) return []; |
| 210 | return Object.entries(totals) |
| 211 | .map(([lang, size]) => ({ lang, size, pct: (size / total) * 100 })) |
| 212 | .sort((a, b) => b.size - a.size); |
| 213 | } catch { return []; } |
| 214 | } |
| 215 | |
| 216 | module.exports = { |
| 217 | reposRoot, repoPath, exists, |
| 218 | init, refs, commitCount, log, tree, |
| 219 | blobStream, blobContent, objectSize, archiveStream, |
| 220 | commitInfo, diff, fileCount, langStats |
| 221 | }; |
| 222 | |