diff --git a/src/__tests__/branch-worktree-retire.test.ts b/src/__tests__/branch-worktree-retire.test.ts new file mode 100644 index 000000000..3f9844135 --- /dev/null +++ b/src/__tests__/branch-worktree-retire.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/e2e/init-replace-worktrees-1320.test.ts b/src/__tests__/e2e/init-replace-worktrees-1320.test.ts new file mode 100644 index 000000000..5de54eaa8 --- /dev/null +++ b/src/__tests__/e2e/init-replace-worktrees-1320.test.ts @@ -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); +}); diff --git a/src/__tests__/e2e/self-mode-worktrees-808.test.ts b/src/__tests__/e2e/self-mode-worktrees-808.test.ts index 520a9611b..adda120a9 100644 --- a/src/__tests__/e2e/self-mode-worktrees-808.test.ts +++ b/src/__tests__/e2e/self-mode-worktrees-808.test.ts @@ -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); @@ -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-'); diff --git a/src/init.ts b/src/init.ts index 8c5d3b027..6505647ee 100644 --- a/src/init.ts +++ b/src/init.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { readWorkspaceChildren } from './workspace-children.js'; import { saveLocalConfig, loadTeamConfig, saveLocalConfigForScope, loadLocalConfigForScope, loadStateForScope, saveStateForScope, resolveProjectDataHome } from './config.js'; import { codexTrustPolicy, deliveredHookFiles, describeUnappliedTeamHooks, hasTeamaiHooks, reconcileHooks, reconcileTeamHooksForConfig, reportCodexTrust, trustCodexForScope } from './hooks.js'; -import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward, DetachedTeamRepoError, pushRepoDirectly, autoPushViaMR, gitBlockTimeoutMs } from './utils/git.js'; +import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, getConfiguredRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward, DetachedTeamRepoError, pushRepoDirectly, autoPushViaMR, gitBlockTimeoutMs, createGitWithBlockTimeout } from './utils/git.js'; import { withTimeout } from './utils/async.js'; import { getProvider, detectProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js'; import { parseGenericGitExistingRemote } from './providers/git/repo-url.js'; @@ -12,6 +12,8 @@ import { probeSelfHostedGitLab } from './providers/gitlab/probe.js'; import { ensureDir, writeFile, writeFileAtomic, pathExists, expandHome, readFileSafe, remove } from './utils/fs.js'; import { queueOwner, sameQueueOwner, setAsideQueueOnModeSwitch } from './utils/pending-learnings.js'; import { dropAllSearchIndexes } from './utils/search-index.js'; +import { reportsBranch } from './utils/reports-branch.js'; +import { learningsBranch } from './utils/learnings-branch.js'; /** * A re-init that changes the install's kind or team repository keeps the data @@ -1701,7 +1703,9 @@ export async function init(options: GlobalOptions & { // it clones it now or reuses one an earlier init left: settle that install // now, as the config save below would, instead of leaving its config to run // against this clone should init stop first. + let replacedInstallSettled = false; const settleReplacedInstall = async (): Promise => { + if (replacedInstallSettled) return; const next: LocalConfig = { repo: { localPath, remote: repoInfo.httpsUrl }, username, @@ -1714,6 +1718,7 @@ export async function init(options: GlobalOptions & { ? !sameQueueOwner(queueOwner(existingLocalConfig), queueOwner(next)) : await pathExists(existingConfigPath); if (replacesAnother) await settleModeSwitch(existingLocalConfig, next, () => moveConfigAside(existingConfigPath, next)); + replacedInstallSettled = true; }; if (await pathExists(localPath)) { @@ -1724,14 +1729,19 @@ export async function init(options: GlobalOptions & { // kept serving the old clone's manifest). Compare ignoring credentials, // protocol, and .git suffix. const existingRemote = await getRemoteUrl(localPath); - if (existingRemote && !remotesMatch(existingRemote, repoInfo.httpsUrl)) { + // `git remote get-url` expands url.*.insteadOf. Compare the literal URL as + // well so a rewrite does not turn a same-repository re-init into a clone + // replacement (and consequently retire its queues and worktrees). + const configuredRemote = await getConfiguredRemoteUrl(localPath); + const sameRepo = [existingRemote, configuredRemote] + .some((remote) => remote !== null && remotesMatch(remote, repoInfo.httpsUrl)); + if (existingRemote && !sameRepo) { log.warn( `Existing clone at ${localPath} points at a different repo ` + `(${redactGitCredentials(existingRemote)}), not ${repoInfo.httpsUrl}.`, ); if (options.force) { log.info('Replacing it with a fresh clone (--force)'); - await remove(localPath); } else { const confirmed = await askConfirmation( 'Remove it and clone the requested repo? [y/N] ', @@ -1744,7 +1754,59 @@ export async function init(options: GlobalOptions & { process.exit(1); return; } - await remove(localPath); + } + const previous: LocalConfig = { + ...existingLocalConfig, + repo: { localPath, remote: existingRemote }, + username: existingLocalConfig?.username ?? username, + scope, + projectRoot, + additionalRoles: existingLocalConfig?.additionalRoles ?? [], + ...(scope === 'project' ? { dataHome: teamaiHome } : {}), + }; + const changesOwner = !sameQueueOwner( + queueOwner(previous), + queueOwner({ ...previous, repo: { localPath, remote: repoInfo.httpsUrl } }), + ); + try { + // Hold both branch locks from inspection through removal or relocation; + // otherwise a writer can mutate a checkout while its Git dir moves. + await reportsBranch.withLock(previous, () => learningsBranch.withLock(previous, async () => { + const reportsOwned = await reportsBranch.ownsCheckout(previous); + const learningsOwned = await learningsBranch.ownsCheckout(previous); + const ownedCheckoutIsDirty = + (reportsOwned && await reportsBranch.isDirty(previous)) || + (learningsOwned && await learningsBranch.isDirty(previous)); + if (changesOwner && ownedCheckoutIsDirty) { + await settleReplacedInstall(); + let aside = `${localPath}.previous`; + for (let n = 1; await pathExists(aside); n++) aside = `${localPath}.previous.${n}`; + await fs.promises.rename(localPath, aside); + log.warn(`Kept the old clone and its uncommitted side checkout(s) at ${aside}.`); + try { + await createGitWithBlockTimeout(aside).raw(['worktree', 'repair']); + } catch (e) { + throw new Error( + `The old clone was kept at ${aside}, but Git could not repair its worktree links. ` + + `Run \`git -C ${aside} worktree repair\` to recover them before retrying init. ` + + `Git reported: ${(e as Error).message}`, + { cause: e }, + ); + } + } else { + // A checkout not registered to this clone belongs to an older or + // different install. Leave it for the existing foreign-checkout + // handling instead of blocking replacement of the current clone. + if (reportsOwned) await reportsBranch.retireWhileLocked(previous); + if (learningsOwned) await learningsBranch.retireWhileLocked(previous); + await settleReplacedInstall(); + await remove(localPath); + } + })); + } catch (e) { + log.error((e as Error).message); + process.exit(1); + return; } } else { log.info(`Repo already exists at ${localPath}, using existing clone`); diff --git a/src/utils/branch-worktree.ts b/src/utils/branch-worktree.ts index 12b5cbf85..235983c71 100644 --- a/src/utils/branch-worktree.ts +++ b/src/utils/branch-worktree.ts @@ -359,6 +359,129 @@ async function registeredCheckoutImpl(spec: BranchWorktreeSpec, localConfig: Loc return checkout ?? null; } +/** Whether the managed path is registered to this repository for this branch. */ +async function ownsCheckoutImpl(spec: BranchWorktreeSpec, localConfig: LocalConfig): Promise { + if (!usesBranchWorktree(localConfig)) return false; + const wt = worktreePath(spec, localConfig); + if (!(await pathExists(wt))) return false; + const registered = await registeredCheckoutImpl(spec, localConfig); + return registered !== null && (await fse.realpath(registered)) === (await fse.realpath(wt)); +} + +/** + * Remove this repository's side-branch checkout before its dedicated clone is + * replaced. Push first so a clean checkout with local-only commits is not + * silently discarded; dirty or unprovable checkouts are left untouched. + */ +async function retireImpl( + spec: BranchWorktreeSpec, + localConfig: LocalConfig, + whileLocked: () => Promise = async () => {}, +): Promise { + if (!usesBranchWorktree(localConfig)) return; + await withLockImpl(spec, localConfig, async () => { + await retireWhileLockedImpl(spec, localConfig); + await whileLocked(); + }); +} + +/** Retire one checkout after its writer lock has already been acquired. */ +async function retireWhileLockedImpl(spec: BranchWorktreeSpec, localConfig: LocalConfig): Promise { + const wt = worktreePath(spec, localConfig); + if (await pathExists(wt)) { + await checkOwnerImpl(spec, localConfig); + const registered = await registeredCheckoutImpl(spec, localConfig); + if (registered === null || (await fse.realpath(registered)) !== (await fse.realpath(wt))) { + throw new ForeignCheckoutError( + `${wt} exists but is not registered to ${gitRoot(localConfig)} as its ${spec.branch} checkout. ` + + 'teamai left it and the current clone unchanged. Move it aside, or delete it if it holds nothing you need, ' + + 'then run init again.', + `the ${spec.branch} checkout cannot be shown to belong to this repository; see the warning above`, + ); + } + + const wtGit = createGitWithBlockTimeout(wt); + if (!(await wtGit.status()).isClean()) { + throw new CheckoutRefusedError( + `${wt} has uncommitted changes, so teamai left it and the current clone unchanged. ` + + 'Commit or move those changes, then run init again.', + `the ${spec.branch} checkout has uncommitted changes; see the warning above`, + ); + } + + const remoteExists = await remoteBranchExists(spec, wt); + if (remoteExists === null) { + throw new CheckoutRefusedError( + `Could not inspect origin/${spec.branch} before replacing the clone. ` + + 'The checkout and current clone were left unchanged; restore network access, then run init again.', + `the ${spec.branch} checkout could not be compared with origin; see the warning above`, + ); + } + + let localOnly = 1; + let remoteOnly = 0; + if (remoteExists) { + await fetchTrackingRef(wtGit, spec.branch); + [localOnly, remoteOnly] = (await wtGit.raw([ + 'rev-list', '--left-right', '--count', `HEAD...refs/remotes/origin/${spec.branch}`, + ])).trim().split(/\s+/).map(Number); + } + + if (localOnly > 0 && remoteOnly > 0) { + throw new CheckoutRefusedError( + `${wt} and origin/${spec.branch} have diverged, so teamai left the checkout and current clone unchanged. ` + + 'Reconcile and push the branch, then run init again.', + `the ${spec.branch} checkout has diverged from origin; see the warning above`, + ); + } + + if (localOnly > 0) { + try { + await wtGit.push(['--progress', 'origin', `HEAD:refs/heads/${spec.branch}`]); + } catch (e) { + throw new CheckoutRefusedError( + `Could not preserve ${wt} on origin before replacing the clone (${failureReason(e).trim()}). ` + + 'The checkout and current clone were left unchanged; push its commits or restore network access, then run init again.', + `the ${spec.branch} checkout could not be pushed; see the warning above`, + ); + } + } + + await createGitWithBlockTimeout(gitRoot(localConfig)).raw(['worktree', 'remove', wt]); + log.info(`Retired ${spec.branch} checkout at ${wt}`); + } +} + +/** Run while holding this branch's writer lock. */ +async function withLockImpl(spec: BranchWorktreeSpec, localConfig: LocalConfig, action: () => Promise): Promise { + const wt = worktreePath(spec, localConfig); + const lockPath = lockFilePath(spec, localConfig); + const locked = await acquireLock(lockPath); + if (!locked) { + throw new CheckoutRefusedError( + `${wt} is being written (${lockPath} is held), so teamai left it and the current clone unchanged. ` + + 'Run init again when that command finishes.', + `the ${spec.branch} checkout is busy; see the warning above`, + ); + } + try { + await action(); + } finally { + await releaseLock(lockPath); + } +} + +/** Whether this repository owns a side checkout with uncommitted changes. */ +async function dirtyImpl(spec: BranchWorktreeSpec, localConfig: LocalConfig): Promise { + if (!usesBranchWorktree(localConfig)) return false; + const wt = worktreePath(spec, localConfig); + if (!(await pathExists(wt))) return false; + await checkOwnerImpl(spec, localConfig); + const registered = await registeredCheckoutImpl(spec, localConfig); + if (registered === null || (await fse.realpath(registered)) !== (await fse.realpath(wt))) return false; + return !(await createGitWithBlockTimeout(wt).status()).isClean(); +} + /** * Self and git mode keep this checkout at the same path in the partition * (#808), so after a project switches mode, the checkout there may belong to @@ -1159,6 +1282,16 @@ export interface BranchWorktree { isForeignByFiles(localConfig: LocalConfig): Promise; /** Where this repository has the branch checked out, the old `.teamai/` place included; creates nothing. */ registeredCheckout(localConfig: LocalConfig): Promise; + /** Whether the managed checkout path is registered to this repository. */ + ownsCheckout(localConfig: LocalConfig): Promise; + /** Whether this repository owns a side checkout with uncommitted changes. */ + isDirty(localConfig: LocalConfig): Promise; + /** Run an action while holding this branch's writer lock. */ + withLock(localConfig: LocalConfig, action: () => Promise): Promise; + /** Retire this checkout while its writer lock is already held. */ + retireWhileLocked(localConfig: LocalConfig): Promise; + /** Push and remove this owned checkout, holding its writer lock through the optional replacement callback. */ + retire(localConfig: LocalConfig, whileLocked?: () => Promise): Promise; } /** @@ -1188,5 +1321,10 @@ export function createBranchWorktree(spec: BranchWorktreeSpec): BranchWorktree { checkOwner: (localConfig) => checkOwnerImpl(spec, localConfig), isForeignByFiles: (localConfig) => isForeignByFilesImpl(spec, localConfig), registeredCheckout: (localConfig) => registeredCheckoutImpl(spec, localConfig), + ownsCheckout: (localConfig) => ownsCheckoutImpl(spec, localConfig), + isDirty: (localConfig) => dirtyImpl(spec, localConfig), + withLock: (localConfig, action) => withLockImpl(spec, localConfig, action), + retireWhileLocked: (localConfig) => retireWhileLockedImpl(spec, localConfig), + retire: (localConfig, whileLocked) => retireImpl(spec, localConfig, whileLocked), }; } diff --git a/src/utils/git.ts b/src/utils/git.ts index 7f900392d..f51075cd9 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -337,6 +337,17 @@ export async function getRemoteUrl(localPath: string, remoteName = 'origin'): Pr } } +/** Read the literal configured remote URL without applying url.*.insteadOf rewrites. */ +export async function getConfiguredRemoteUrl(localPath: string, remoteName = 'origin'): Promise { + const git = createGit(localPath); + try { + const url = (await git.raw(['config', '--get', `remote.${remoteName}.url`])).trim(); + return url || null; + } catch { + return null; + } +} + /** * Strip embedded credentials from a git remote URL for safe display, e.g. * `https://oauth2:TOKEN@host/o/r.git` → `https://host/o/r.git`. Leaves URLs diff --git a/src/utils/reports-branch.ts b/src/utils/reports-branch.ts index 104fb8b3f..effbcd875 100644 --- a/src/utils/reports-branch.ts +++ b/src/utils/reports-branch.ts @@ -36,7 +36,7 @@ import { type LocalConfig, } from '../types.js'; -const reportsBranch = createBranchWorktree({ +export const reportsBranch = createBranchWorktree({ branch: REPORTS_BRANCH, worktreeDirname: REPORTS_WORKTREE_DIRNAME, lockFilename: REPORTS_LOCK_FILENAME,