mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-09-20 04:23:02 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user