fix(worktree): use remote ancestry to adopt merged worktrees (#4005)

Night-ship rebase of #3716 by @rodboev onto latest main.

Selection now checks each worktree HEAD against local HEAD and remote-tracking refs without fetching. Adoption stays conservative when Git cannot prove ancestry.

Supersedes #3716. Refs #3698.
This commit is contained in:
Alex Newman
2026-09-10 22:29:20 -07:00
committed by GitHub
parent 177e583c07
commit 4b317b139c
2 changed files with 172 additions and 22 deletions
+47 -21
View File
@@ -37,6 +37,13 @@ export function formatAdoptionErrors(errors: AdoptionResult['errors']): string {
interface WorktreeEntry {
path: string;
branch: string | null;
head: string | null;
}
interface GitCommandResult {
status: number | null;
stdout: string;
error: Error | undefined;
}
const GIT_TIMEOUT_MS = 15000;
@@ -48,7 +55,7 @@ class DryRunRollback extends Error {
}
}
function gitCapture(cwd: string, args: string[]): string | null {
function gitRun(cwd: string, args: string[]): GitCommandResult {
const startTime = Date.now();
const r = spawnSync('git', ['-C', cwd, ...args], {
encoding: 'utf8',
@@ -66,16 +73,21 @@ function gitCapture(cwd: string, args: string[]): string | null {
error: r.error.message,
timedOut: r.error.name === 'ETIMEDOUT' || (r.status === null && r.signal === 'SIGTERM')
});
return null;
return { status: r.status, stdout: '', error: r.error };
}
if (r.status !== 0) {
logger.debug('GIT', `Git returned non-zero exit code ${r.status}: git -C ${cwd} ${args.join(' ')}`, {
stderr: r.stderr?.toString().trim()
});
return null;
return { status: r.status, stdout: '', error: undefined };
}
return (r.stdout ?? '').trim();
return { status: r.status, stdout: (r.stdout ?? '').trim(), error: undefined };
}
function gitCapture(cwd: string, args: string[]): string | null {
const result = gitRun(cwd, args);
return result.status === 0 ? result.stdout : null;
}
function resolveMainRepoPath(cwd: string): string | null {
@@ -100,33 +112,41 @@ function listWorktrees(mainRepo: string): WorktreeEntry[] {
let current: Partial<WorktreeEntry> = {};
for (const line of raw.split('\n')) {
if (line.startsWith('worktree ')) {
if (current.path) entries.push({ path: current.path, branch: current.branch ?? null });
current = { path: line.slice('worktree '.length).trim(), branch: null };
if (current.path) entries.push({ path: current.path, branch: current.branch ?? null, head: current.head ?? null });
current = { path: line.slice('worktree '.length).trim(), branch: null, head: null };
} else if (line.startsWith('HEAD ')) {
current.head = line.slice('HEAD '.length).trim() || null;
} else if (line.startsWith('branch ')) {
const refName = line.slice('branch '.length).trim();
current.branch = refName.startsWith('refs/heads/')
? refName.slice('refs/heads/'.length)
: refName;
} else if (line === '' && current.path) {
entries.push({ path: current.path, branch: current.branch ?? null });
entries.push({ path: current.path, branch: current.branch ?? null, head: current.head ?? null });
current = {};
}
}
if (current.path) entries.push({ path: current.path, branch: current.branch ?? null });
if (current.path) entries.push({ path: current.path, branch: current.branch ?? null, head: current.head ?? null });
return entries;
}
function listMergedBranches(mainRepo: string): Set<string> {
const raw = gitCapture(mainRepo, [
'branch',
'--merged',
'HEAD',
'--format=%(refname:short)'
]);
if (!raw) return new Set();
return new Set(
raw.split('\n').map(b => b.trim()).filter(b => b.length > 0)
);
function resolveCandidateOids(mainRepo: string): Set<string> {
const oids = new Set<string>();
for (const ref of ['HEAD', 'origin/HEAD', 'origin/main', 'origin/master']) {
const result = gitRun(mainRepo, ['rev-parse', '--verify', `${ref}^{commit}`]);
if (result.status === 0 && result.stdout) oids.add(result.stdout);
}
return oids;
}
export function hasProvenAncestry(mainRepo: string, worktreeHead: string, candidateOids: Set<string>): boolean {
for (const candidateOid of candidateOids) {
const result = gitRun(mainRepo, ['merge-base', '--is-ancestor', worktreeHead, candidateOid]);
if (result.status === 0 && !result.error) return true;
// Status 1 is a known negative. Spawn failures and other statuses remain
// conservative by simply leaving this worktree unselected.
}
return false;
}
export async function adoptMergedWorktrees(opts: {
@@ -178,8 +198,14 @@ export async function adoptMergedWorktrees(opts: {
if (opts.onlyBranch) {
targets = childWorktrees.filter(w => w.branch === opts.onlyBranch);
} else {
const merged = listMergedBranches(mainRepo);
targets = childWorktrees.filter(w => w.branch !== null && merged.has(w.branch));
const candidateOids = resolveCandidateOids(mainRepo);
targets = childWorktrees.filter(w =>
w.head !== null &&
// A branch at the current parent tip is a valid existing worktree;
// detached exact-tip checkouts are fresh inspection worktrees.
(w.branch !== null || !candidateOids.has(w.head)) &&
hasProvenAncestry(mainRepo, w.head, candidateOids)
);
}
result.mergedBranches = targets
+125 -1
View File
@@ -20,7 +20,7 @@ import { spawnSync } from 'child_process';
import { SessionStore } from '../../../src/services/sqlite/SessionStore.js';
import { openConfiguredSqliteDatabase } from '../../../src/services/sqlite/connection.js';
import { emitRemapProject, hasSyncLane } from '../../../src/services/sync/remap-outbox.js';
import { adoptMergedWorktrees } from '../../../src/services/infrastructure/WorktreeAdoption.js';
import { adoptMergedWorktrees, hasProvenAncestry } from '../../../src/services/infrastructure/WorktreeAdoption.js';
import { runOneTimeCwdRemap } from '../../../src/services/infrastructure/ProcessManager.js';
const ISO = '2026-07-09T00:00:00.000Z';
@@ -35,6 +35,15 @@ function git(cwd: string, ...args: string[]): void {
}
}
function gitOutput(cwd: string, ...args: string[]): string {
const r = spawnSync('git', ['-C', cwd, '-c', 'user.email=test@test', '-c', 'user.name=test', ...args], {
encoding: 'utf8',
timeout: 15000,
});
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
return r.stdout.trim();
}
interface OutboxRow {
op_uuid: string;
rev: string;
@@ -368,6 +377,121 @@ describe('mutation sites', () => {
}
});
it('adopts branched and detached worktrees proven by a remote commit while preserving negative space', async () => {
const bareOrigin = join(tempDir, 'origin.git');
const repo = join(tempDir, 'parent-repo');
const integration = join(tempDir, 'integration-repo');
const featureWorktree = join(tempDir, 'feature-wt');
const detachedWorktree = join(tempDir, 'detached-wt');
const historicalWorktree = join(tempDir, 'historical-wt');
const unmergedWorktree = join(tempDir, 'unmerged-wt');
mkdirSync(bareOrigin);
mkdirSync(repo);
git(bareOrigin, 'init', '--bare');
git(repo, 'init', '-b', 'main');
writeFileSync(join(repo, 'base.txt'), 'base\n');
git(repo, 'add', '.');
git(repo, 'commit', '-m', 'base');
git(repo, 'remote', 'add', 'origin', bareOrigin);
git(repo, 'push', '-u', 'origin', 'main');
git(tempDir, 'clone', '--branch', 'main', bareOrigin, integration);
git(repo, 'worktree', 'add', '-b', 'feature', featureWorktree);
writeFileSync(join(featureWorktree, 'feature.txt'), 'feature\n');
git(featureWorktree, 'add', '.');
git(featureWorktree, 'commit', '-m', 'feature');
git(repo, 'push', '-u', 'origin', 'feature');
git(repo, 'worktree', 'add', '--detach', detachedWorktree, 'feature');
git(repo, 'worktree', 'add', '--detach', historicalWorktree, 'HEAD');
git(repo, 'worktree', 'add', '-b', 'unmerged', unmergedWorktree);
writeFileSync(join(unmergedWorktree, 'unmerged.txt'), 'unmerged\n');
git(unmergedWorktree, 'add', '.');
git(unmergedWorktree, 'commit', '-m', 'unmerged');
git(integration, 'fetch', 'origin');
git(integration, 'merge', '--no-ff', 'origin/feature', '-m', 'merge feature');
git(integration, 'push', 'origin', 'main');
git(repo, 'fetch', 'origin');
try {
const dataDir = join(tempDir, 'data');
mkdirSync(dataDir);
const dbPath = join(dataDir, 'claude-mem.db');
const store = new SessionStore(dbPath);
for (const [memorySessionId, project] of [
['native', 'parent-repo'],
['feature', 'parent-repo/feature-wt'],
['detached', 'parent-repo/detached-wt'],
['historical', 'parent-repo/historical-wt'],
['unmerged', 'parent-repo/unmerged-wt'],
]) {
store.db.prepare(`
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
VALUES (?, ?, ?, ?, 1751234567000, 'active')
`).run(`sess-${memorySessionId}`, memorySessionId, project, ISO);
store.db.prepare(`
INSERT INTO session_summaries (memory_session_id, project, request, created_at, created_at_epoch, sync_rev, synced_at)
VALUES (?, ?, 'req', ?, 1751234567891, 1, 123)
`).run(memorySessionId, project, ISO);
}
store.close();
const result = await adoptMergedWorktrees({ repoPath: repo, dataDirectory: dataDir });
expect(result.scannedWorktrees).toBe(4);
expect(result.mergedBranches).toEqual(['feature']);
expect(result.adoptedSummaries).toBe(2);
const verify = openConfiguredSqliteDatabase(dbPath);
try {
const rows = verify.prepare(
'SELECT memory_session_id, project, merged_into_project FROM session_summaries ORDER BY memory_session_id'
).all() as Array<{ memory_session_id: string; project: string; merged_into_project: string | null }>;
expect(rows).toEqual([
{ memory_session_id: 'detached', project: 'parent-repo/detached-wt', merged_into_project: 'parent-repo' },
{ memory_session_id: 'feature', project: 'parent-repo/feature-wt', merged_into_project: 'parent-repo' },
{ memory_session_id: 'historical', project: 'parent-repo/historical-wt', merged_into_project: null },
{ memory_session_id: 'native', project: 'parent-repo', merged_into_project: null },
{ memory_session_id: 'unmerged', project: 'parent-repo/unmerged-wt', merged_into_project: null },
]);
} finally {
verify.close();
}
const override = await adoptMergedWorktrees({
repoPath: repo,
dataDirectory: dataDir,
onlyBranch: 'unmerged',
});
expect(override.mergedBranches).toEqual(['unmerged']);
expect(override.adoptedSummaries).toBe(1);
} finally {
for (const worktree of [featureWorktree, detachedWorktree, historicalWorktree, unmergedWorktree]) {
if (existsSync(worktree)) git(repo, 'worktree', 'remove', '--force', worktree);
}
}
}, 30000);
it('keeps failed merge-base probes unproven', () => {
const repo = join(tempDir, 'provenance-repo');
const unrelated = join(tempDir, 'unrelated-repo');
mkdirSync(repo);
mkdirSync(unrelated);
git(repo, 'init', '-b', 'main');
writeFileSync(join(repo, 'base.txt'), 'base\n');
git(repo, 'add', '.');
git(repo, 'commit', '-m', 'base');
git(unrelated, 'init', '-b', 'main');
writeFileSync(join(unrelated, 'other.txt'), 'other\n');
git(unrelated, 'add', '.');
git(unrelated, 'commit', '-m', 'unrelated');
const head = gitOutput(repo, 'rev-parse', 'HEAD');
const unrelatedHead = gitOutput(unrelated, 'rev-parse', 'HEAD');
expect(hasProvenAncestry(repo, head, new Set([unrelatedHead]))).toBe(false);
expect(hasProvenAncestry(repo, 'not-a-commit', new Set([head]))).toBe(false);
});
// ---------------------------------------------------------------------------
// (d) one-time cwd remap — the REAL site, own connection, real git repo
// classified from pending_messages.cwd.