Skip to content
Merged
175 changes: 175 additions & 0 deletions src/__tests__/branch-worktree-retire.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { reportsBranch } from '../utils/reports-branch.js';
import { learningsBranch } from '../utils/learnings-branch.js';
import { acquireLock, releaseLock } from '../update.js';
import { LEARNINGS_LOCK_FILENAME, REPORTS_LOCK_FILENAME, type LocalConfig } from '../types.js';

const GIT_ENV = {
GIT_AUTHOR_NAME: 'TeamAI CI',
GIT_AUTHOR_EMAIL: 'ci@teamai.test',
GIT_COMMITTER_NAME: 'TeamAI CI',
GIT_COMMITTER_EMAIL: 'ci@teamai.test',
};

function git(args: string[], cwd: string): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
env: { ...process.env, ...GIT_ENV },
}).trim();
}

describe('retiring a branch worktree before replacing its clone', () => {
let sandbox: string;
let remote: string;
let clone: string;
let checkout: string;
let config: LocalConfig;

beforeEach(() => {
sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-retire-wt-'));
remote = path.join(sandbox, 'remote.git');
clone = path.join(sandbox, 'team-repo');
checkout = path.join(sandbox, 'reports-wt');
const seed = path.join(sandbox, 'seed');

fs.mkdirSync(seed);
git(['init', '-q', '--bare', remote], sandbox);
git(['init', '-q', '-b', 'main'], seed);
fs.writeFileSync(path.join(seed, 'README.md'), 'fixture\n');
git(['add', '.'], seed);
git(['commit', '-qm', 'seed'], seed);
git(['remote', 'add', 'origin', remote], seed);
git(['push', '-q', '-u', 'origin', 'main'], seed);
git(['checkout', '-q', '--orphan', 'teamai-reports'], seed);
git(['rm', '-qrf', '.'], seed);
fs.writeFileSync(path.join(seed, '.gitignore'), 'reports-wt/\n');
git(['add', '.gitignore'], seed);
git(['commit', '-qm', 'reports'], seed);
git(['push', '-q', '-u', 'origin', 'teamai-reports'], seed);

git(['clone', '-q', remote, clone], sandbox);
git(['fetch', '-q', 'origin', 'teamai-reports:teamai-reports'], clone);
git(['worktree', 'add', '-q', checkout, 'teamai-reports'], clone);
config = {
repo: { localPath: clone, remote },
username: 'tester',
scope: 'user',
additionalRoles: [],
};
});

afterEach(() => fs.rmSync(sandbox, { recursive: true, force: true }));

it('pushes a local-only commit before removing the owned checkout', async () => {
fs.writeFileSync(path.join(checkout, 'report.txt'), 'pending\n');
git(['add', 'report.txt'], checkout);
git(['commit', '-qm', 'pending report'], checkout);
const localHead = git(['rev-parse', 'HEAD'], checkout);

await reportsBranch.retire(config);

expect(fs.existsSync(checkout)).toBe(false);
expect(git(['rev-parse', 'refs/heads/teamai-reports'], remote)).toBe(localHead);
});

it('removes a clean checkout whose commits are already preserved on origin', async () => {
const other = path.join(sandbox, 'other');
git(['clone', '-q', '-b', 'teamai-reports', remote, other], sandbox);
fs.writeFileSync(path.join(other, 'remote-report.txt'), 'published elsewhere\n');
git(['add', 'remote-report.txt'], other);
git(['commit', '-qm', 'remote report'], other);
git(['push', '-q', 'origin', 'teamai-reports'], other);
const remoteHead = git(['rev-parse', 'refs/heads/teamai-reports'], remote);

await reportsBranch.retire(config);

expect(fs.existsSync(checkout)).toBe(false);
expect(git(['rev-parse', 'refs/heads/teamai-reports'], remote)).toBe(remoteHead);
});

it('recognizes its checkout through a symlinked parent path', async () => {
const alias = path.join(sandbox, 'alias');
fs.symlinkSync(sandbox, alias, process.platform === 'win32' ? 'junction' : 'dir');

await reportsBranch.retire({
...config,
repo: { ...config.repo, localPath: path.join(alias, path.basename(clone)) },
});

expect(fs.existsSync(checkout)).toBe(false);
});

it('keeps a clean checkout when local and origin have diverged', async () => {
fs.writeFileSync(path.join(checkout, 'local-report.txt'), 'local only\n');
git(['add', 'local-report.txt'], checkout);
git(['commit', '-qm', 'local report'], checkout);

const other = path.join(sandbox, 'other');
git(['clone', '-q', '-b', 'teamai-reports', remote, other], sandbox);
fs.writeFileSync(path.join(other, 'remote-report.txt'), 'remote only\n');
git(['add', 'remote-report.txt'], other);
git(['commit', '-qm', 'remote report'], other);
git(['push', '-q', 'origin', 'teamai-reports'], other);

await expect(reportsBranch.retire(config)).rejects.toThrow('have diverged');

expect(fs.existsSync(checkout)).toBe(true);
expect(fs.existsSync(clone)).toBe(true);
});

it('holds both writer locks through replacement even when a checkout does not exist', async () => {
git(['worktree', 'remove', checkout], clone);
const reportsLockPath = path.join(path.dirname(checkout), REPORTS_LOCK_FILENAME);
const learningsLockPath = path.join(path.dirname(checkout), LEARNINGS_LOCK_FILENAME);

await reportsBranch.retire(config, () =>
learningsBranch.retire(config, async () => {
expect(await acquireLock(reportsLockPath)).toBe(false);
expect(await acquireLock(learningsLockPath)).toBe(false);
expect(fs.existsSync(clone)).toBe(true);
}),
);

expect(await acquireLock(reportsLockPath)).toBe(true);
expect(await acquireLock(learningsLockPath)).toBe(true);
await releaseLock(learningsLockPath);
await releaseLock(reportsLockPath);
});

it('keeps the checkout and clone when it has uncommitted work', async () => {
fs.writeFileSync(path.join(checkout, 'pending.txt'), 'not committed\n');

await expect(reportsBranch.retire(config)).rejects.toThrow('has uncommitted changes');

expect(fs.existsSync(checkout)).toBe(true);
expect(fs.existsSync(clone)).toBe(true);
});

it('reports an owned dirty checkout before replacement', async () => {
fs.writeFileSync(path.join(checkout, 'pending.txt'), 'not committed\n');

expect(await reportsBranch.isDirty(config)).toBe(true);
});

it('does not remove an unregistered directory at the managed path', async () => {
git(['worktree', 'remove', checkout], clone);
fs.mkdirSync(checkout);
fs.writeFileSync(path.join(checkout, 'keep.txt'), 'unknown owner\n');

await expect(reportsBranch.retire(config)).rejects.toThrow('is not registered');

expect(fs.readFileSync(path.join(checkout, 'keep.txt'), 'utf8')).toBe('unknown owner\n');
});

it('does not claim an unregistered directory at the managed path', async () => {
git(['worktree', 'remove', checkout], clone);
fs.mkdirSync(checkout);

expect(await reportsBranch.ownsCheckout(config)).toBe(false);
});
});
140 changes: 140 additions & 0 deletions src/__tests__/e2e/init-replace-worktrees-1320.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const CLI = path.join(ROOT, 'dist', 'index.js');
const URL_A = 'https://git.example.test/team/a.git';
const URL_B = 'https://git.example.test/team/b.git';
const GIT_ENV = {
GIT_AUTHOR_NAME: 'TeamAI CI',
GIT_AUTHOR_EMAIL: 'ci@teamai.test',
GIT_COMMITTER_NAME: 'TeamAI CI',
GIT_COMMITTER_EMAIL: 'ci@teamai.test',
};

function git(args: string[], cwd: string, home?: string): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
env: { ...process.env, ...GIT_ENV, ...(home ? { HOME: home, USERPROFILE: home } : {}) },
}).trim();
}

function makeRemote(sandbox: string, name: string, url: string): string {
const seed = path.join(sandbox, `${name}-seed`);
const remote = path.join(sandbox, `${name}.git`);
fs.mkdirSync(seed);
fs.writeFileSync(path.join(seed, 'teamai.yaml'), [
`team: ${name}`,
`repo: ${url}`,
'provider: git',
'reviewers: []',
'',
].join('\n'));
git(['init', '-q', '-b', 'main'], seed);
git(['add', '.'], seed);
git(['commit', '-qm', 'seed'], seed);
git(['clone', '-q', '--bare', seed, remote], sandbox);
return remote;
}

function runCLI(args: string[], cwd: string, home: string): Promise<{ code: number | null; output: string }> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [CLI, ...args], {
cwd,
env: { ...process.env, ...GIT_ENV, HOME: home, USERPROFILE: home, FORCE_COLOR: '0' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout.on('data', (data: Buffer) => { output += data.toString(); });
child.stderr.on('data', (data: Buffer) => { output += data.toString(); });
child.on('close', (code) => resolve({ code, output }));
});
}

describe('init replaces a clone without orphaning its side worktrees (#1320)', () => {
let sandbox: string;
let home: string;
let cwd: string;
let remoteA: string;

beforeAll(() => {
if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`);
sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-init-replace-'));
home = path.join(sandbox, 'home');
cwd = path.join(sandbox, 'cwd');
fs.mkdirSync(home);
fs.mkdirSync(cwd);
fs.mkdirSync(path.join(home, '.claude'));
remoteA = makeRemote(sandbox, 'a', URL_A);
const remoteB = makeRemote(sandbox, 'b', URL_B);
git(['config', '--global', `url.${remoteA}.insteadOf`, URL_A], sandbox, home);
git(['config', '--global', `url.${remoteB}.insteadOf`, URL_B], sandbox, home);
});

afterAll(() => fs.rmSync(sandbox, { recursive: true, force: true }));

it('pushes and removes the old worktrees before --force clones the new repo', async () => {
const first = await runCLI(['init', URL_A, '--provider', 'git', '--scope', 'user', '--agent', 'claude', '--force'], cwd, home);
expect(first.code, first.output).toBe(0);

const clone = path.join(home, '.teamai', 'team-repo');
const other = path.join(sandbox, 'other-member');
git(['clone', '-q', '-b', 'teamai-reports', remoteA, other], sandbox);
fs.writeFileSync(path.join(other, 'published-report.txt'), 'already preserved\n');
git(['add', 'published-report.txt'], other);
git(['commit', '-qm', 'publish report'], other);
git(['push', '-q', 'origin', 'teamai-reports'], other);
const reportsRemoteHead = git(['rev-parse', 'refs/heads/teamai-reports'], remoteA);

const learnings = path.join(home, '.teamai', 'learnings-wt');
git(['branch', 'teamai-learnings'], clone);
git(['worktree', 'add', '-q', learnings, 'teamai-learnings'], clone);
fs.writeFileSync(path.join(learnings, 'pending.md'), 'preserve me\n');
git(['add', 'pending.md'], learnings);
git(['commit', '-qm', 'pending learning'], learnings);

const second = await runCLI(['init', URL_B, '--provider', 'git', '--scope', 'user', '--agent', 'claude', '--force'], cwd, home);
expect(second.code, second.output).toBe(0);
expect(second.output).toContain('Retired teamai-learnings checkout');
expect(second.output).toMatch(/teamai initialized successfully/i);
expect(git(['remote', 'get-url', 'origin'], clone)).toContain('b.git');
expect(git(['rev-parse', 'refs/heads/teamai-reports'], remoteA)).toBe(reportsRemoteHead);
expect(fs.existsSync(learnings)).toBe(false);
}, 120_000);

it('keeps the backing clone when a side checkout has uncommitted work', async () => {
const dirtyHome = path.join(sandbox, 'dirty-home');
fs.mkdirSync(dirtyHome);
fs.mkdirSync(path.join(dirtyHome, '.claude'));
git(['config', '--global', `url.${remoteA}.insteadOf`, URL_A], sandbox, dirtyHome);
const remoteB = path.join(sandbox, 'b.git');
git(['config', '--global', `url.${remoteB}.insteadOf`, URL_B], sandbox, dirtyHome);

const first = await runCLI(['init', URL_A, '--provider', 'git', '--scope', 'user', '--agent', 'claude', '--force'], cwd, dirtyHome);
expect(first.code, first.output).toBe(0);

const clone = path.join(dirtyHome, '.teamai', 'team-repo');
const learnings = path.join(dirtyHome, '.teamai', 'learnings-wt');
git(['branch', 'teamai-learnings'], clone);
git(['worktree', 'add', '-q', learnings, 'teamai-learnings'], clone);
fs.writeFileSync(path.join(learnings, 'committed.md'), 'unpushed commit\n');
git(['add', 'committed.md'], learnings);
git(['commit', '-qm', 'local learning'], learnings);
const localHead = git(['rev-parse', 'HEAD'], learnings);
fs.writeFileSync(path.join(learnings, 'dirty.md'), 'uncommitted work\n');

const second = await runCLI(['init', URL_B, '--provider', 'git', '--scope', 'user', '--agent', 'claude', '--force'], cwd, dirtyHome);

expect(second.code, second.output).toBe(0);
expect(second.output).toContain('Kept the old clone and its uncommitted side checkout(s)');
expect(git(['remote', 'get-url', 'origin'], clone)).toContain('b.git');
expect(fs.existsSync(path.join(clone + '.previous', '.git'))).toBe(true);
expect(git(['rev-parse', 'HEAD'], learnings)).toBe(localHead);
expect(git(['status', '--porcelain'], learnings)).toContain('?? dirty.md');
}, 120_000);
});
25 changes: 21 additions & 4 deletions src/__tests__/e2e/self-mode-worktrees-808.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1534,19 +1534,31 @@ describe('a checkout an older git-mode install left (#808)', () => {
expect(initSame.output).not.toContain('Set aside');
const samePull = await runCLI(['pull'], projectRoot, home);
expect(samePull.code, samePull.output).toBe(0);
expect(partitionQueue(install)).toContain(name);
expect(partitionQueue(install)).not.toContain(name);
expect(published({ ...install, remote: teamA.bare })).toContain(`learnings/${name}`);
const indexed = (JSON.parse(fs.readFileSync(index, 'utf8')) as { entries: Array<{ filename: string }> }).entries;
expect(indexed.map((entry) => entry.filename)).toContain(name);

// Queue another Team A learning so the A -> B switch still exercises the
// set-aside boundary after the same-repo pull published the first one.
const queuedForSwitch = await withPushesRejected({ ...install, remote: teamA.bare }, () => runCLI(
['contribute', '--title', 'team-a-switch-note', '--file', note(install, 'team-a-switch', 'Set aside for Team A')],
projectRoot,
home,
));
expect(queuedForSwitch.output).toContain('Saved locally');
const [switchName] = partitionQueue(install).filter((f) => f.startsWith('team-a-switch-note-'));
expect(switchName, partitionQueue(install).join('\n')).toBeDefined();

const initB = await runCLI(['init', teamB.url, '--scope', 'project', '--force'], projectRoot, home);
expect(initB.code, initB.output).toBe(0);
const switched = await runCLI(['pull'], projectRoot, home);
expect(switched.code, switched.output).toBe(0);
const partition = partitionOf(install);
const aside = path.join(partition, 'pending-learnings.git-git.example.com-team-team-a');
expect(fs.readFileSync(path.join(aside, name), 'utf8')).toContain('Note for team A');
expect(fs.readFileSync(path.join(aside, switchName), 'utf8')).toContain('Set aside for Team A');
expect(initB.output).toContain(`Set aside 1 queued learning(s) from the previous git install in ${aside}`);
expect(partitionQueue(install)).not.toContain(name);
expect(partitionQueue(install)).not.toContain(switchName);
expect(fs.existsSync(index)).toBe(false);

const pull = await runCLI(['pull'], projectRoot, home);
Expand Down Expand Up @@ -1758,13 +1770,18 @@ describe('a checkout an older git-mode install left (#808)', () => {
// The new clone sits where team A's did, and has no registration of the old checkout.
expect(git(['remote', 'get-url', 'origin'], path.join(partition, 'team-repo')).trim()).toBe(teamB.url);

const refusal = `${checkout} is a teamai-learnings checkout teamai cannot show to be ${path.join(partition, 'team-repo')}'s`;
const contribute = await runCLI(
['contribute', '--title', 'team-b-note', '--file', note(install, 'team-b', 'Note for team B')],
projectRoot,
home,
);
// Replacing the clone preserves the dirty checkout with its backing clone,
// so later commands correctly identify that preserved clone in the warning.
const preservedClone = path.join(partition, 'team-repo.previous');
const refusal = `${checkout} is a teamai-learnings checkout of ${preservedClone}, ` +
`not of ${path.join(partition, 'team-repo')}, left by an install in another mode.`;
expect(contribute.output.split(refusal).length - 1, contribute.output).toBe(1);
expect(fs.existsSync(path.join(preservedClone, '.git'))).toBe(true);
expect(contribute.output).toContain('Saved locally');
expect(fs.readFileSync(draft, 'utf8')).toBe('# Team A draft nobody committed\n');
expect(git(['log', '--all', '--name-only', '--format='], teamB.bare)).not.toContain('team-a-');
Expand Down
Loading
Loading