// Couche git sûre : tout passe par execFile (JAMAIS de shell), arguments en tableau, `--` avant // les chemins/refs utilisateur. Fonctions pures sans état, prenant un cwd déjà validé par l'appelant. import { execFile, spawn } from 'node:child_process'; import { resolve, sep } from 'node:path'; import type { WorktreeGitStatus, WorktreeBranchAction, WorktreeBranchMode, FileChange } from '@arboretum/shared'; const GIT_TIMEOUT_MS = 10_000; // `push` peut dialoguer avec un remote (réseau) : on lui laisse une marge bien plus large. const GIT_PUSH_TIMEOUT_MS = 120_000; const GIT_MAX_BUFFER = 8 * 1024 * 1024; interface GitError extends Error { stderr?: string; code?: number | string; } function git(cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUT_MS): Promise { return new Promise((resolveP, reject) => { execFile( 'git', args, { cwd, timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER, // GIT_OPTIONAL_LOCKS=0 : pas de prise de verrou par `status` (perf + concurrence avec une // session claude active) ; LC_ALL=C : sortie stable pour le parsing. env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' }, }, (err, stdout, stderr) => { if (err) { (err as GitError).stderr = String(stderr); reject(err); } else { resolveP(stdout.toString()); } }, ); }); } /** * Variante tolérante : résout `{ stdout, code }` au lieu de rejeter sur code de sortie non nul. * Utile pour `git diff --no-index` (code 1 = « les fichiers diffèrent », pas une erreur). */ function gitRaw(cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUT_MS): Promise<{ stdout: string; code: number }> { return new Promise((resolveP) => { execFile( 'git', args, { cwd, timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' } }, (err, stdout) => { const code = err ? ((err as GitError).code as number) ?? 1 : 0; resolveP({ stdout: stdout.toString(), code: typeof code === 'number' ? code : 1 }); }, ); }); } export interface ParsedWorktree { path: string; head: string | null; branch: string | null; detached: boolean; locked: boolean; prunable: boolean; bare: boolean; } /** Parse la sortie `git worktree list --porcelain` (testable isolément, sans repo réel). */ export function parseWorktreePorcelain(stdout: string): ParsedWorktree[] { const out: ParsedWorktree[] = []; let cur: Partial | null = null; const flush = (): void => { if (cur?.path) { out.push({ path: cur.path, head: cur.head ?? null, branch: cur.branch ?? null, detached: cur.detached ?? false, locked: cur.locked ?? false, prunable: cur.prunable ?? false, bare: cur.bare ?? false, }); } cur = null; }; for (const line of stdout.split('\n')) { if (line === '') { flush(); continue; } const sp = line.indexOf(' '); const key = sp === -1 ? line : line.slice(0, sp); const val = sp === -1 ? '' : line.slice(sp + 1); switch (key) { case 'worktree': flush(); cur = { path: val }; break; case 'HEAD': if (cur) cur.head = val; break; case 'branch': if (cur) cur.branch = val.replace(/^refs\/heads\//, ''); break; case 'detached': if (cur) cur.detached = true; break; case 'bare': if (cur) cur.bare = true; break; case 'locked': if (cur) cur.locked = true; break; case 'prunable': if (cur) cur.prunable = true; break; } } flush(); return out; } /** Refuse les noms de branche dangereux (défense en profondeur ; git valide déjà côté lui). */ export function isValidBranchName(name: string): boolean { return ( /^[A-Za-z0-9._/-]+$/.test(name) && !name.startsWith('-') && !name.startsWith('/') && !name.endsWith('/') && !name.endsWith('.lock') && !name.includes('..') && !name.includes('//') ); } /** Chemin absolu sans segment `..` après résolution (évite l'échappement d'arborescence). */ export function isSafeAbsolutePath(p: string): boolean { return p.startsWith('/') && resolve(p) === p && !p.split(sep).includes('..'); } /** * Pathspec relatif sûr passé à git (`git add/restore/diff --

`) : non vide, non absolu, sans * segment `..`, ne commençant pas par `-` (anti-flag). Le `--` avant le pathspec reste obligatoire. */ export function isSafeRelativePath(p: string): boolean { if (p.length === 0 || p.startsWith('/') || p.startsWith('-')) return false; const parts = p.split(/[\\/]/); return !parts.includes('..') && !parts.includes('.git'); } /** Initialise un dépôt git dans `dir` (déjà créé et validé par l'appelant). `git init` est idempotent. */ export async function gitInit(dir: string): Promise { await git(dir, ['init']); } /** true si `path` est la racine d'un dépôt git (main worktree) accessible. */ export async function isRepo(path: string): Promise { try { const top = (await git(path, ['rev-parse', '--show-toplevel'])).trim(); return resolve(top) === resolve(path); } catch { return false; } } /** Branche par défaut (origin/HEAD) en nom court, ou null si indéterminée. */ export async function defaultBranch(repoPath: string): Promise { try { const ref = (await git(repoPath, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])).trim(); return ref.replace(/^refs\/remotes\/origin\//, '') || null; } catch { return null; } } /** Branche courante (nom court) du checkout en `repoPath`, ou null si HEAD détaché. */ export async function currentBranch(repoPath: string): Promise { try { const b = (await git(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); return b === 'HEAD' ? null : b; } catch { return null; } } /** Existence d'une branche, en local (`refs/heads`) et/ou suivie du remote (`refs/remotes/origin`). */ export async function branchExists(repoPath: string, branch: string): Promise<{ local: boolean; remote: boolean }> { const verify = async (ref: string): Promise => { try { await git(repoPath, ['rev-parse', '--verify', '--quiet', ref]); return true; } catch { return false; } }; const [local, remote] = await Promise.all([verify(`refs/heads/${branch}`), verify(`refs/remotes/origin/${branch}`)]); return { local, remote }; } /** Branches locales + suivies de `origin` (noms courts) + branche par défaut : pour un sélecteur de base. */ export async function listBranches(repoPath: string): Promise<{ local: string[]; remote: string[]; default: string | null }> { const local: string[] = []; const remote: string[] = []; try { const out = await git(repoPath, ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes/origin']); for (const line of out.split('\n')) { const name = line.trim(); if (!name) continue; if (name.startsWith('origin/')) { const short = name.slice('origin/'.length); if (short && short !== 'HEAD') remote.push(short); } else { local.push(name); } } } catch { /* dépôt sans refs encore (premier commit absent) → listes vides */ } return { local, remote, default: await defaultBranch(repoPath) }; } export async function listWorktrees(repoPath: string): Promise { return parseWorktreePorcelain(await git(repoPath, ['worktree', 'list', '--porcelain'])); } /** État git d'un worktree : ahead/behind vs upstream + nombre de fichiers modifiés. */ export async function worktreeStatus(worktreePath: string): Promise { let upstream: string | null = null; let ahead = 0; let behind = 0; try { upstream = (await git(worktreePath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])).trim() || null; } catch { upstream = null; } if (upstream) { try { // left = commits de l'upstream absents de HEAD (behind) ; right = HEAD non poussés (ahead). const out = (await git(worktreePath, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`])).trim(); const [left, right] = out.split(/\s+/); behind = Number(left) || 0; ahead = Number(right) || 0; } catch { /* refs inaccessibles : on laisse 0/0 */ } } let dirtyCount = 0; let stagedCount = 0; let unstagedCount = 0; let conflictCount = 0; try { // Un SEUL passage porcelain v2 -z : dirtyCount + compteurs fins (staged/unstaged/conflit). const out = await git(worktreePath, ['status', '--porcelain=v2', '-z', '--untracked-files=all']); const entries = parsePorcelainV2(out); dirtyCount = entries.length; for (const e of entries) { if (e.conflicted) conflictCount++; if (e.staged) stagedCount++; if (e.unstaged) unstagedCount++; } } catch { /* ignore */ } let lastCommitHash: string | null = null; let lastCommitSubject: string | null = null; try { const lc = await lastCommit(worktreePath); if (lc) { lastCommitHash = lc.hash; lastCommitSubject = lc.subject; } } catch { /* repo sans commit : on laisse null */ } return { ahead, behind, dirtyCount, upstream, stagedCount, unstagedCount, conflictCount, lastCommitHash, lastCommitSubject }; } // ---- P7 : statut détaillé / diff / staging / commit sélectif / fetch-pull (IDE worktree) ---- /** Entrée brute de `git status --porcelain=v2` (avant enrichissement numstat). */ export interface PorcelainV2Entry { path: string; indexStatus: string; worktreeStatus: string; staged: boolean; unstaged: boolean; untracked: boolean; conflicted: boolean; renamedFrom?: string; } /** * Parse `git status --porcelain=v2 -z --untracked-files=all` (testable sans repo réel). Format -z : * champs séparés par NUL ; une entrée de renommage (type `2`) consomme un champ supplémentaire * (l'ancien chemin). Les chemins ne sont jamais entre guillemets en mode -z (pas d'échappement). */ export function parsePorcelainV2(stdout: string): PorcelainV2Entry[] { const fields = stdout.split('\0'); if (fields.length && fields[fields.length - 1] === '') fields.pop(); const out: PorcelainV2Entry[] = []; for (let i = 0; i < fields.length; i++) { const f = fields[i]; if (!f) continue; const kind = f[0]; if (kind === '1') { const t = f.split(' '); const xy = t[1] ?? '..'; out.push(entryFromXy(t.slice(8).join(' '), xy, { conflicted: false })); } else if (kind === '2') { const t = f.split(' '); const xy = t[1] ?? '..'; const path = t.slice(9).join(' '); const renamedFrom = fields[++i]; // l'ancien chemin suit dans le champ NUL suivant out.push(entryFromXy(path, xy, { conflicted: false, ...(renamedFrom ? { renamedFrom } : {}) })); } else if (kind === 'u') { const t = f.split(' '); const xy = t[1] ?? '..'; out.push(entryFromXy(t.slice(10).join(' '), xy, { conflicted: true })); } else if (kind === '?') { out.push({ path: f.slice(2), indexStatus: '?', worktreeStatus: '?', staged: false, unstaged: true, untracked: true, conflicted: false }); } // '!' (ignored) : exclu de la liste des changements. } return out; } function entryFromXy(path: string, xy: string, opts: { conflicted: boolean; renamedFrom?: string }): PorcelainV2Entry { const indexStatus = xy[0] ?? '.'; const worktreeStatus = xy[1] ?? '.'; return { path, indexStatus, worktreeStatus, staged: !opts.conflicted && indexStatus !== '.', unstaged: opts.conflicted || worktreeStatus !== '.', untracked: false, conflicted: opts.conflicted, ...(opts.renamedFrom ? { renamedFrom: opts.renamedFrom } : {}), }; } /** * Parse `git diff --numstat -z` → map chemin → {insertions, deletions, binary}. Les fichiers * binaires sont marqués `'-'` par git. Les renommages ont un champ chemin vide suivi de * deux champs NUL (ancien, nouveau) ; on indexe par le nouveau chemin. */ export function parseNumstatZ(stdout: string): Map { const fields = stdout.split('\0'); if (fields.length && fields[fields.length - 1] === '') fields.pop(); const map = new Map(); for (let i = 0; i < fields.length; i++) { const f = fields[i]; if (!f) continue; const parts = f.split('\t'); if (parts.length < 3) continue; const addRaw = parts[0] ?? ''; const delRaw = parts[1] ?? ''; let path = parts[2] ?? ''; if (path === '') { // renommage : ancien chemin = champ suivant, nouveau chemin = champ d'après. i++; // saute l'ancien chemin path = fields[++i] ?? ''; if (path === '') continue; } const binary = addRaw === '-' || delRaw === '-'; map.set(path, { insertions: binary ? null : Number(addRaw) || 0, deletions: binary ? null : Number(delRaw) || 0, binary, }); } return map; } const MAX_CHANGES = 5000; /** Liste les fichiers modifiés d'un worktree (statut porcelain v2 enrichi des stats numstat). */ export async function listChanges(worktreePath: string): Promise<{ changes: FileChange[]; truncated: boolean }> { const [statusOut, unstagedOut, stagedOut] = await Promise.all([ git(worktreePath, ['status', '--porcelain=v2', '-z', '--untracked-files=all']), git(worktreePath, ['diff', '--numstat', '-z']), git(worktreePath, ['diff', '--numstat', '-z', '--cached']), ]); const entries = parsePorcelainV2(statusOut); const unstaged = parseNumstatZ(unstagedOut); const staged = parseNumstatZ(stagedOut); const truncated = entries.length > MAX_CHANGES; const slice = truncated ? entries.slice(0, MAX_CHANGES) : entries; const changes: FileChange[] = slice.map((e) => { const ns = unstaged.get(e.path) ?? staged.get(e.path); const binary = ns?.binary ?? false; // insertions/deletions = somme staged+unstaged quand disponible ; null pour binaire/untracked. const u = unstaged.get(e.path); const s = staged.get(e.path); const sum = (a: number | null | undefined, b: number | null | undefined): number | null => { if (a == null && b == null) return null; return (a ?? 0) + (b ?? 0); }; return { path: e.path, indexStatus: e.indexStatus, worktreeStatus: e.worktreeStatus, staged: e.staged, unstaged: e.unstaged, untracked: e.untracked, conflicted: e.conflicted, insertions: binary ? null : sum(u?.insertions, s?.insertions), deletions: binary ? null : sum(u?.deletions, s?.deletions), binary, ...(e.renamedFrom ? { renamedFrom: e.renamedFrom } : {}), }; }); return { changes, truncated }; } const MAX_DIFF_BYTES = 512 * 1024; /** Diff unifié d'un fichier. `staged` → diff de l'index ; `untracked` → diff vs /dev/null. */ export async function fileDiff( worktreePath: string, file: string, opts: { staged?: boolean; untracked?: boolean } = {}, ): Promise<{ diff: string; binary: boolean; tooLarge: boolean }> { let raw: string; if (opts.untracked) { // --no-index sort en code 1 quand les fichiers diffèrent : on tolère via gitRaw. const r = await gitRaw(worktreePath, ['diff', '--no-index', '--no-color', '--', '/dev/null', file]); raw = r.stdout; } else { raw = await git(worktreePath, ['diff', '--no-color', ...(opts.staged ? ['--cached'] : []), '--', file]); } const binary = /^Binary files .* differ$/m.test(raw) || raw.includes('GIT binary patch'); if (binary) return { diff: '', binary: true, tooLarge: false }; if (raw.length > MAX_DIFF_BYTES) return { diff: raw.slice(0, MAX_DIFF_BYTES), binary: false, tooLarge: true }; return { diff: raw, binary: false, tooLarge: false }; } /** Indexe des fichiers (`git add -- `). Chaque chemin validé par l'appelant (isSafeRelativePath). */ export async function stageFiles(worktreePath: string, files: string[]): Promise { if (files.length === 0) return; await git(worktreePath, ['add', '--', ...files]); } /** Désindexe des fichiers (`git restore --staged -- `). */ export async function unstageFiles(worktreePath: string, files: string[]): Promise { if (files.length === 0) return; await git(worktreePath, ['restore', '--staged', '--', ...files]); } /** Annule les modifications de l'arbre de travail de fichiers SUIVIS (`git restore -- `). */ export async function restoreFiles(worktreePath: string, files: string[]): Promise { if (files.length === 0) return; await git(worktreePath, ['restore', '--', ...files]); } /** Supprime des fichiers NON SUIVIS (`git clean -f -- `). Destructif : opt-in côté appelant. */ export async function cleanFiles(worktreePath: string, files: string[]): Promise { if (files.length === 0) return; await git(worktreePath, ['clean', '-f', '--', ...files]); } /** Commit de l'index uniquement (contraste avec `commitAll` = `git add -A` + commit). */ export async function commitStaged(worktreePath: string, message: string): Promise { await git(worktreePath, ['commit', '-m', message]); } /** Réécrit le dernier commit. L'appelant garantit qu'il n'est pas déjà poussé. */ export async function amendCommit(worktreePath: string, message?: string): Promise { await git(worktreePath, message ? ['commit', '--amend', '-m', message] : ['commit', '--amend', '--no-edit']); } /** `git fetch --all --prune` (réseau → timeout élargi). */ export async function fetchRemote(worktreePath: string): Promise { await git(worktreePath, ['fetch', '--all', '--prune'], GIT_PUSH_TIMEOUT_MS); } /** `git pull` : `ff-only` par défaut (jamais de merge surprise) ; `rebase` optionnel. */ export async function pull(worktreePath: string, mode: 'ff-only' | 'rebase' = 'ff-only'): Promise { const args = mode === 'rebase' ? ['-c', 'rebase.autoStash=false', 'pull', '--rebase'] : ['pull', '--ff-only']; await git(worktreePath, args, GIT_PUSH_TIMEOUT_MS); } /** * Répertoire git absolu d'un worktree (`git rev-parse --absolute-git-dir`). Pour le checkout * principal : `/.git` ; pour un worktree LIÉ : `/.git/worktrees/` (le `.git` du * worktree est un fichier pointeur). Sert au watcher FS pour surveiller le bon `HEAD`/`index`. */ export async function resolveGitDir(worktreePath: string): Promise { const r = await gitRaw(worktreePath, ['rev-parse', '--absolute-git-dir']); if (r.code !== 0) return null; return r.stdout.trim() || null; } /** Dernier commit (HEAD) : hash court + sujet. null si le dépôt n'a aucun commit. */ export async function lastCommit(worktreePath: string): Promise<{ hash: string; subject: string } | null> { const r = await gitRaw(worktreePath, ['log', '-1', '--format=%h%x00%s']); if (r.code !== 0) return null; const idx = r.stdout.indexOf('\0'); if (idx === -1) return null; return { hash: r.stdout.slice(0, idx), subject: r.stdout.slice(idx + 1).replace(/\n$/, '') }; } /** true si le HEAD courant n'est pas encore poussé (amend autorisé). Sans upstream → true. */ export async function isUnpushed(worktreePath: string): Promise { try { await git(worktreePath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']); } catch { return true; // pas d'upstream → rien n'est « partagé » } try { const out = (await git(worktreePath, ['rev-list', '--count', '@{u}..HEAD'])).trim(); return (Number(out) || 0) > 0; } catch { return true; } } /** Point de départ d'une branche créée : `baseRef` explicite, sinon la branche par défaut du dépôt * (locale ou suivie de `origin`), sinon undefined (git part alors du HEAD courant). */ async function resolveStartPoint(repoPath: string, baseRef?: string): Promise { if (baseRef) return baseRef; const def = await defaultBranch(repoPath); if (!def) return undefined; const { local, remote } = await branchExists(repoPath, def); if (local) return def; if (remote) return `origin/${def}`; return undefined; } /** * Crée un worktree en résolvant la branche selon `mode` (voir `WorktreeBranchMode`). Renvoie l'action * effective. En mode `auto`, on choisit checkout / suivi-remote / création selon l'existence réelle de * la branche : indispensable pour les groupes hétérogènes (branche présente dans certains dépôts seulement). */ export async function addWorktree( repoPath: string, opts: { path: string; branch: string; mode: WorktreeBranchMode; baseRef?: string }, ): Promise { const { local, remote } = await branchExists(repoPath, opts.branch); let action: WorktreeBranchAction; if (opts.mode === 'create') action = 'created'; else if (opts.mode === 'checkout') action = local ? 'reused' : 'tracked'; else action = local ? 'reused' : remote ? 'tracked' : 'created'; // auto const args = ['worktree', 'add']; if (action === 'reused') { args.push('--', opts.path, opts.branch); } else if (action === 'tracked') { args.push('--track', '-b', opts.branch, '--', opts.path, `origin/${opts.branch}`); } else { const start = await resolveStartPoint(repoPath, opts.baseRef); args.push('-b', opts.branch, '--', opts.path); if (start) args.push(start); } await git(repoPath, args); return action; } /** `git add -A` puis commit. L'appelant garantit qu'il y a quelque chose à committer. */ export async function commitAll(repoPath: string, message: string): Promise { await git(repoPath, ['add', '-A']); await git(repoPath, ['commit', '-m', message]); } /** Pousse la branche courante. Si aucun upstream n'est configuré : `git push -u origin `. */ export async function push(repoPath: string): Promise { let hasUpstream = false; try { await git(repoPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']); hasUpstream = true; } catch { hasUpstream = false; } if (hasUpstream) { await git(repoPath, ['push'], GIT_PUSH_TIMEOUT_MS); } else { const branch = await currentBranch(repoPath); if (!branch) throw new Error('cannot push a detached HEAD'); await git(repoPath, ['push', '-u', 'origin', branch], GIT_PUSH_TIMEOUT_MS); } } /** * Crée/bascule une branche dans le checkout (worktree) en `repoPath` : utilisé pour démarrer une * session sur la branche principale sans worktree dédié. `create` → `git switch -c ` (échoue * si la branche existe) ; sinon `git switch ` (branche existante). Pas de `--` : l'argument * est une réf (pas un pathspec) et le nom est déjà filtré en amont par `isValidBranchName` (anti-flag). */ export async function switchBranch(repoPath: string, opts: { branch: string; create: boolean }): Promise { await git(repoPath, opts.create ? ['switch', '-c', opts.branch] : ['switch', opts.branch]); } export async function removeWorktree(repoPath: string, worktreePath: string, force: boolean): Promise { await git(repoPath, ['worktree', 'remove', ...(force ? ['--force'] : []), '--', worktreePath]); } export async function pruneWorktrees(repoPath: string): Promise { await git(repoPath, ['worktree', 'prune']); } /** true si l'erreur git d'un `worktree remove` est due à des changements non sauvegardés. */ export function isDirtyWorktreeError(err: unknown): boolean { const msg = `${(err as GitError)?.stderr ?? ''} ${(err as Error)?.message ?? ''}`; return /contains modified or untracked files|is dirty|use --force/i.test(msg); } const GIT_CLONE_TIMEOUT_MS = 10 * 60_000; // 10 min : un clone réseau peut être long. export interface CloneProgress { phase: string; /** pourcentage 0-100 si git le rapporte, sinon null. */ percent: number | null; } /** * Clone un dépôt via `git clone --progress` (P12). `spawn` (et non execFile) pour streamer la * progression depuis stderr. `env` éphémère (cf. withGitAuth) porte l'auth : JAMAIS dans l'URL. * `--` sépare l'URL/dest des options. L'appelant valide `dest` (sous scanRoots, non existant). */ export function cloneRepo(opts: { url: string; dest: string; env?: NodeJS.ProcessEnv; branch?: string; onProgress?: (p: CloneProgress) => void; signal?: AbortSignal; }): Promise { return new Promise((resolveP, reject) => { const args = ['clone', '--progress']; if (opts.branch) args.push('--branch', opts.branch); args.push('--', opts.url, opts.dest); const child = spawn('git', args, { env: { ...(opts.env ?? process.env), GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' }, stdio: ['ignore', 'ignore', 'pipe'], timeout: GIT_CLONE_TIMEOUT_MS, ...(opts.signal ? { signal: opts.signal } : {}), }); let stderr = ''; child.stderr.on('data', (d: Buffer) => { const s = d.toString(); stderr += s; if (stderr.length > 64 * 1024) stderr = stderr.slice(-64 * 1024); // borne mémoire const m = /([A-Za-z][A-Za-z ]+):\s+(\d+)%/.exec(s); if (m && m[1] && m[2] && opts.onProgress) opts.onProgress({ phase: m[1].trim(), percent: Number(m[2]) }); }); child.on('error', (err) => reject(err)); child.on('close', (code) => { if (code === 0) resolveP(); else reject(new Error(stderr.trim().split('\n').pop() || `git clone exited with code ${code}`)); }); }); }