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:
@@ -15,6 +15,10 @@ import {
|
||||
pruneWorktrees,
|
||||
switchBranch,
|
||||
isDirtyWorktreeError,
|
||||
branchExists,
|
||||
currentBranch,
|
||||
listBranches,
|
||||
commitAll,
|
||||
} from '../src/core/git.js';
|
||||
|
||||
const dirs: string[] = [];
|
||||
@@ -96,7 +100,7 @@ describe('opérations git (repo tmp réel)', () => {
|
||||
const repo = makeTmpRepo();
|
||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
|
||||
dirs.push(wtPath);
|
||||
await addWorktree(repo, { path: wtPath, branch: 'feat', newBranch: true });
|
||||
await addWorktree(repo, { path: wtPath, branch: 'feat', mode: 'create' });
|
||||
|
||||
const list = await listWorktrees(repo);
|
||||
const wt = list.find((w) => resolve(w.path) === resolve(wtPath));
|
||||
@@ -136,9 +140,57 @@ describe('opérations git (repo tmp réel)', () => {
|
||||
it('prune retire un worktree dont le dossier a disparu', async () => {
|
||||
const repo = makeTmpRepo();
|
||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-gone`);
|
||||
await addWorktree(repo, { path: wtPath, branch: 'gone', newBranch: true });
|
||||
await addWorktree(repo, { path: wtPath, branch: 'gone', mode: 'create' });
|
||||
rmSync(wtPath, { recursive: true, force: true }); // suppression "à la main"
|
||||
await pruneWorktrees(repo);
|
||||
expect((await listWorktrees(repo)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('branche : existence, liste, courante, commit', () => {
|
||||
it('branchExists / currentBranch / listBranches', async () => {
|
||||
const repo = makeTmpRepo();
|
||||
expect(await currentBranch(repo)).toBe('main');
|
||||
expect(await branchExists(repo, 'main')).toEqual({ local: true, remote: false });
|
||||
expect(await branchExists(repo, 'nope')).toEqual({ local: false, remote: false });
|
||||
|
||||
execFileSync('git', ['branch', 'dev'], { cwd: repo });
|
||||
const b = await listBranches(repo);
|
||||
expect(b.local).toContain('main');
|
||||
expect(b.local).toContain('dev');
|
||||
expect(b.remote).toEqual([]);
|
||||
});
|
||||
|
||||
it('commitAll : add -A + commit, fait baisser dirtyCount à 0', async () => {
|
||||
const repo = makeTmpRepo();
|
||||
writeFileSync(join(repo, 'new.txt'), 'hello\n');
|
||||
expect((await worktreeStatus(repo)).dirtyCount).toBeGreaterThan(0);
|
||||
await commitAll(repo, 'add new.txt');
|
||||
expect((await worktreeStatus(repo)).dirtyCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addWorktree : résolution auto (créer / réutiliser)', () => {
|
||||
it('auto crée la branche si absente, la réutilise si présente', async () => {
|
||||
const repo = makeTmpRepo();
|
||||
// branche absente → création (renvoie "created")
|
||||
const wt1 = join(dirname(repo), `${basename(repo)}-wt-feat`);
|
||||
dirs.push(wt1);
|
||||
expect(await addWorktree(repo, { path: wt1, branch: 'feat', mode: 'auto' })).toBe('created');
|
||||
expect((await listWorktrees(repo)).find((w) => resolve(w.path) === resolve(wt1))?.branch).toBe('feat');
|
||||
|
||||
// la même branche existe désormais ; un AUTRE worktree dessus → checkout (renvoie "reused")
|
||||
await removeWorktree(repo, wt1, true); // libère la branche
|
||||
const wt2 = join(dirname(repo), `${basename(repo)}-wt-feat2`);
|
||||
dirs.push(wt2);
|
||||
expect(await addWorktree(repo, { path: wt2, branch: 'feat', mode: 'auto' })).toBe('reused');
|
||||
});
|
||||
|
||||
it('mode create échoue si la branche existe déjà', async () => {
|
||||
const repo = makeTmpRepo();
|
||||
execFileSync('git', ['branch', 'dup'], { cwd: repo });
|
||||
const wt = join(dirname(repo), `${basename(repo)}-wt-dup`);
|
||||
dirs.push(wt);
|
||||
await expect(addWorktree(repo, { path: wt, branch: 'dup', mode: 'create' })).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,7 +97,8 @@ describe('WorktreeManager', () => {
|
||||
|
||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-feat`);
|
||||
dirs.push(wtPath);
|
||||
const out = await wt.createWorktree(r.id, { branch: 'feat', newBranch: true, runHooks: true });
|
||||
const out = await wt.createWorktree(r.id, { branch: 'feat', mode: 'create', runHooks: true });
|
||||
expect(out.action).toBe('created');
|
||||
|
||||
expect(resolve(out.worktree.path)).toBe(resolve(wtPath));
|
||||
expect(out.worktree.branch).toBe('feat');
|
||||
@@ -113,7 +114,7 @@ describe('WorktreeManager', () => {
|
||||
it('createWorktree : branche invalide → 400', async () => {
|
||||
const repo = makeTmpRepo();
|
||||
const r = await wt.addRepo({ path: repo });
|
||||
await expect(wt.createWorktree(r.id, { branch: '../evil', newBranch: true })).rejects.toMatchObject({ statusCode: 400 });
|
||||
await expect(wt.createWorktree(r.id, { branch: '../evil', mode: 'create' })).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('startMainSession : session dans le checkout principal (branche actuelle, sans mutation git)', async () => {
|
||||
@@ -163,7 +164,7 @@ describe('WorktreeManager', () => {
|
||||
|
||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-x`);
|
||||
dirs.push(wtPath);
|
||||
await wt.createWorktree(r.id, { branch: 'x', newBranch: true, runHooks: false });
|
||||
await wt.createWorktree(r.id, { branch: 'x', mode: 'create', runHooks: false });
|
||||
writeFileSync(join(wtPath, 'scratch.txt'), 'wip\n'); // worktree sale
|
||||
|
||||
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'WORKTREE_DIRTY' });
|
||||
@@ -176,7 +177,7 @@ describe('WorktreeManager', () => {
|
||||
const r = await wt.addRepo({ path: repo });
|
||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-sess`);
|
||||
dirs.push(wtPath);
|
||||
await wt.createWorktree(r.id, { branch: 'sess', newBranch: true, runHooks: false });
|
||||
await wt.createWorktree(r.id, { branch: 'sess', mode: 'create', runHooks: false });
|
||||
|
||||
pty.spawn({ cwd: wtPath, command: 'bash' });
|
||||
const list = await wt.listRepoWorktrees(r.id, true);
|
||||
@@ -190,11 +191,49 @@ describe('WorktreeManager', () => {
|
||||
const r = await wt.addRepo({ path: repo });
|
||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-busy`);
|
||||
dirs.push(wtPath);
|
||||
await wt.createWorktree(r.id, { branch: 'busy', newBranch: true, runHooks: false });
|
||||
await wt.createWorktree(r.id, { branch: 'busy', mode: 'create', runHooks: false });
|
||||
pty.spawn({ cwd: wtPath, command: 'bash' });
|
||||
await expect(wt.deleteWorktree(r.id, wtPath, false)).rejects.toMatchObject({ statusCode: 409, code: 'SESSION_LIVE_IN_WORKTREE' });
|
||||
});
|
||||
|
||||
it('listRepoBranches : renvoie les branches locales (dont main)', async () => {
|
||||
const repo = makeTmpRepo();
|
||||
const r = await wt.addRepo({ path: repo });
|
||||
const b = await wt.listRepoBranches(r.id);
|
||||
expect(b.local).toContain('main');
|
||||
});
|
||||
|
||||
it('commitWorktree : arbre propre → 409 NOTHING_TO_COMMIT ; arbre sale → commit (dirty=0)', async () => {
|
||||
const repo = makeTmpRepo();
|
||||
const r = await wt.addRepo({ path: repo });
|
||||
await expect(wt.commitWorktree(r.id, repo, 'noop')).rejects.toMatchObject({ statusCode: 409, code: 'NOTHING_TO_COMMIT' });
|
||||
writeFileSync(join(repo, 'f.txt'), 'x\n');
|
||||
const w = await wt.commitWorktree(r.id, repo, 'add f');
|
||||
expect(w.git.dirtyCount).toBe(0);
|
||||
});
|
||||
|
||||
it('promoteWorktree : la branche du worktree devient le checkout principal, worktree supprimé, ancienne branche conservée', async () => {
|
||||
const repo = makeTmpRepo();
|
||||
const r = await wt.addRepo({ path: repo });
|
||||
const wtPath = join(dirname(repo), `${basename(repo)}-wt-prom`);
|
||||
dirs.push(wtPath);
|
||||
await wt.createWorktree(r.id, { branch: 'prom', mode: 'create', runHooks: false });
|
||||
|
||||
await wt.promoteWorktree(r.id, wtPath);
|
||||
|
||||
expect(execFileSync('git', ['branch', '--show-current'], { cwd: repo }).toString().trim()).toBe('prom');
|
||||
expect((await wt.listRepoWorktrees(r.id, true)).some((w) => resolve(w.path) === resolve(wtPath))).toBe(false);
|
||||
const branches = await wt.listRepoBranches(r.id);
|
||||
expect(branches.local).toContain('main'); // ancienne branche principale conservée
|
||||
expect(branches.local).toContain('prom');
|
||||
});
|
||||
|
||||
it('promoteWorktree : checkout principal refusé (400 IS_MAIN_WORKTREE)', async () => {
|
||||
const repo = makeTmpRepo();
|
||||
const r = await wt.addRepo({ path: repo });
|
||||
await expect(wt.promoteWorktree(r.id, repo)).rejects.toMatchObject({ statusCode: 400, code: 'IS_MAIN_WORKTREE' });
|
||||
});
|
||||
|
||||
it('addRepo renvoie hidden:false ; updateRepo({hidden}) bascule et émet repo_update', async () => {
|
||||
const repo = makeTmpRepo();
|
||||
const r = await wt.addRepo({ path: repo });
|
||||
|
||||
Reference in New Issue
Block a user