feat(worktrees): création intelligente (auto) + commit/push/promotion + grille agrandissable
- addWorktree résout la branche par dépôt (branchExists) : checkout si présente, suivi de origin/<b> si seulement remote, sinon création (-b) depuis la branche par défaut. Corrige le 'fatal: invalid reference' sur les groupes hétérogènes ; newBranch -> mode (déprécié, mappé en route).
- Nouvelles opérations git : commit (add -A), push (upstream auto), promotion « en principal » (la branche du worktree devient le checkout principal, sans merge ni conflit, ancienne branche conservée). Routes /worktrees/{commit,push,promote} auditées + GET /repos/:id/branches.
- Web : actions Commit/Push/Promouvoir sur WorktreeCard, sélecteur de branche de base (datalist) dans RepoSection et GroupSessionModal, agrandissement d'un terminal au choix dans TerminalGrid.
- Court-circuite la session de groupe quand aucun worktree n'a pu être créé (fini le masquage par « no resolvable worktree »).
- Tests : git.ts (branchExists/listBranches/commitAll/auto), worktree-manager (commit/promote/branches) ; acceptance P5 étend le scénario worktree de groupe sur branche neuve + commit + promotion.
This commit is contained in:
@@ -2,9 +2,11 @@
|
||||
// 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';
|
||||
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 {
|
||||
@@ -12,14 +14,14 @@ interface GitError extends Error {
|
||||
code?: number | string;
|
||||
}
|
||||
|
||||
function git(cwd: string, args: string[]): Promise<string> {
|
||||
function git(cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUT_MS): Promise<string> {
|
||||
return new Promise((resolveP, reject) => {
|
||||
execFile(
|
||||
'git',
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
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.
|
||||
@@ -140,6 +142,52 @@ export async function defaultBranch(repoPath: string): Promise<string | 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']));
|
||||
}
|
||||
@@ -175,19 +223,69 @@ export async function worktreeStatus(worktreePath: string): Promise<WorktreeGitS
|
||||
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; newBranch: boolean; baseRef?: string },
|
||||
): Promise<void> {
|
||||
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 (opts.newBranch) args.push('-b', opts.branch);
|
||||
args.push('--', opts.path);
|
||||
if (opts.newBranch) {
|
||||
if (opts.baseRef) args.push(opts.baseRef);
|
||||
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 {
|
||||
args.push(opts.branch);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user