P3-A: worktrees multi-repo & cycle de vie (backend)
Enregistrement de repos et gestion de leurs worktrees git, source de vérité = git (worktrees dérivés + cache court par repo), corrélation worktree ↔ sessions par cwd, mutations sérialisées par repo. - shared: RepoSummary, PostCreateHook, WorktreeSummary, WorktreeGitStatus ; messages WS repo_update/worktree_update/*_removed ; topic sub 'worktrees' (+ parseClientMessage) ; DTOs REST repos/worktrees. - db: migration id:3 (table repos). - core/git.ts: couche git sûre (execFile, jamais de shell, -- avant chemins, GIT_OPTIONAL_LOCKS=0), parseWorktreePorcelain, list/add/remove/prune/status/ ahead-behind, validation branche + chemin. - core/claude-trust.ts: pré-trust atomique de ~/.claude.json (spike S3). - core/worktree-manager.ts: repos CRUD, create (worktree add + pré-trust + hooks post-create + startSession optionnel), adopt, delete (garde-fous 409 dirty / 400 main / 409 session live), prune ; events. - routes/repos.ts + routes/worktrees.ts, câblage app.ts, gateway topic worktrees. - tests: git (repos tmp réels), claude-trust, worktree-manager (146 verts).
This commit is contained in:
205
packages/server/src/core/git.ts
Normal file
205
packages/server/src/core/git.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// 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 } from 'node:child_process';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import type { WorktreeGitStatus } from '@arboretum/shared';
|
||||
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
const GIT_MAX_BUFFER = 8 * 1024 * 1024;
|
||||
|
||||
interface GitError extends Error {
|
||||
stderr?: string;
|
||||
code?: number | string;
|
||||
}
|
||||
|
||||
function git(cwd: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolveP, reject) => {
|
||||
execFile(
|
||||
'git',
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
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());
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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<ParsedWorktree> | 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('..');
|
||||
}
|
||||
|
||||
/** true si `path` est la racine d'un dépôt git (main worktree) accessible. */
|
||||
export async function isRepo(path: string): Promise<boolean> {
|
||||
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<string | null> {
|
||||
try {
|
||||
const ref = (await git(repoPath, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])).trim();
|
||||
return ref.replace(/^refs\/remotes\/origin\//, '') || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listWorktrees(repoPath: string): Promise<ParsedWorktree[]> {
|
||||
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<WorktreeGitStatus> {
|
||||
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;
|
||||
try {
|
||||
const status = await git(worktreePath, ['status', '--porcelain=v1', '--untracked-files=all']);
|
||||
dirtyCount = status.split('\n').filter((l) => l.trim() !== '').length;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { ahead, behind, dirtyCount, upstream };
|
||||
}
|
||||
|
||||
export async function addWorktree(
|
||||
repoPath: string,
|
||||
opts: { path: string; branch: string; newBranch: boolean; baseRef?: string },
|
||||
): Promise<void> {
|
||||
const args = ['worktree', 'add'];
|
||||
if (opts.newBranch) args.push('-b', opts.branch);
|
||||
args.push('--', opts.path);
|
||||
if (opts.newBranch) {
|
||||
if (opts.baseRef) args.push(opts.baseRef);
|
||||
} else {
|
||||
args.push(opts.branch);
|
||||
}
|
||||
await git(repoPath, args);
|
||||
}
|
||||
|
||||
export async function removeWorktree(repoPath: string, worktreePath: string, force: boolean): Promise<void> {
|
||||
await git(repoPath, ['worktree', 'remove', ...(force ? ['--force'] : []), '--', worktreePath]);
|
||||
}
|
||||
|
||||
export async function pruneWorktrees(repoPath: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user