- POST /api/v1/projects : crée <root>/<name> (mkdir non récursif, 409 si existant), git init optionnel, puis lance une session dedans. Anti-traversal sur le nom (core/project.ts). - Web : NewProjectModal (racine préremplie scanRoots[0], case git init) + bouton dans SessionsListView. - SessionContextBar : badge du groupe (cliquable) + repos couverts avec leur branche dans l'en-tête de session ; session simple = repo+branche déduits du cwd. - 14 tests (unitaires + route).
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
// 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, WorktreeBranchAction, WorktreeBranchMode } 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<string> {
|
|
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());
|
|
}
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
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('..');
|
|
}
|
|
|
|
/** 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<void> {
|
|
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<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;
|
|
}
|
|
}
|
|
|
|
/** Branche courante (nom court) du checkout en `repoPath`, ou null si HEAD détaché. */
|
|
export async function currentBranch(repoPath: string): Promise<string | null> {
|
|
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<boolean> => {
|
|
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<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 };
|
|
}
|
|
|
|
/** 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<string | undefined> {
|
|
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<WorktreeBranchAction> {
|
|
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<void> {
|
|
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 <branche>`. */
|
|
export async function push(repoPath: string): Promise<void> {
|
|
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 <branch>` (échoue
|
|
* si la branche existe) ; sinon `git switch <branch>` (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<void> {
|
|
await git(repoPath, opts.create ? ['switch', '-c', opts.branch] : ['switch', opts.branch]);
|
|
}
|
|
|
|
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);
|
|
}
|