diff --git a/packages/context/src/core/git.service.test.ts b/packages/context/src/core/git.service.test.ts index 14e93495..ba1d9e0f 100644 --- a/packages/context/src/core/git.service.test.ts +++ b/packages/context/src/core/git.service.test.ts @@ -379,5 +379,37 @@ describe('GitService', () => { await service.removeWorktree(wtDir).catch(() => undefined); await rm(wtDir, { recursive: true, force: true }).catch(() => undefined); }); + + it('reports untracked files that would be overwritten by the squash merge', async () => { + const { commitHash: baseSha } = await writeAndCommit('seed.md', 'seed'); + const parent = await realpath(join(tempDir, '..')); + const wtDir = join(parent, `wt-${Date.now()}-untracked`); + await service.addWorktree(wtDir, 'session/untracked', baseSha); + + const scoped = service.forWorktree(wtDir); + await writeFile(join(wtDir, 'knowledge.md'), 'session version\n', 'utf-8'); + await scoped.commitFile('knowledge.md', 'session write', 'System User', 'system@example.com'); + await writeFile(join(tempDir, 'knowledge.md'), 'untracked local version\n', 'utf-8'); + + const result = await service.squashMergeIntoMain( + 'session/untracked', + 'System User', + 'system@example.com', + 'Memory capture: 1 file [chat=untracked]', + ); + + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error('unreachable'); + } + expect(result.conflict).toBe(true); + expect(result.conflictPaths).toEqual(['knowledge.md']); + + const status = await (service as unknown as { git: import('simple-git').SimpleGit }).git.status(); + expect(status.not_added).toContain('knowledge.md'); + + await service.removeWorktree(wtDir).catch(() => undefined); + await rm(wtDir, { recursive: true, force: true }).catch(() => undefined); + }); }); }); diff --git a/packages/context/src/core/git.service.ts b/packages/context/src/core/git.service.ts index 6539f9fd..8d05a089 100644 --- a/packages/context/src/core/git.service.ts +++ b/packages/context/src/core/git.service.ts @@ -31,6 +31,40 @@ export type SquashMergeResult = | { ok: true; squashSha: string; touchedPaths: string[] } | { ok: false; conflict: true; conflictPaths: string[] }; +function mergeErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function extractUntrackedOverwritePaths(message: string): string[] { + const marker = 'The following untracked working tree files would be overwritten by merge:'; + const markerIndex = message.indexOf(marker); + if (markerIndex === -1) { + return []; + } + + const afterMarker = message.slice(markerIndex + marker.length); + const abortIndex = afterMarker.indexOf('Please move or remove them before you merge.'); + const pathBlock = abortIndex === -1 ? afterMarker : afterMarker.slice(0, abortIndex); + return pathBlock + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && line !== 'Aborting') + .map((line) => line.replace(/^"(.+)"$/, '$1')); +} + +function mergeConflictPaths(unmergedPaths: string[], mergeError: unknown): string[] { + const paths = new Set(unmergedPaths); + if (mergeError !== null) { + for (const path of extractUntrackedOverwritePaths(mergeErrorMessage(mergeError))) { + paths.add(path); + } + } + return [...paths]; +} + export class GitService { private static readonly mutationQueues = new Map>(); @@ -639,10 +673,11 @@ export class GitService { } const unmergedOut = await this.git.raw(['diff', '--name-only', '--diff-filter=U']).catch(() => ''); - const conflictPaths = unmergedOut + const unmergedPaths = unmergedOut .split('\n') .map((l) => l.trim()) .filter(Boolean); + const conflictPaths = mergeConflictPaths(unmergedPaths, mergeError); if (conflictPaths.length > 0 || mergeError !== null) { // `merge --abort` only works for an in-progress merge; squash sets MERGE_MSG but not @@ -651,7 +686,7 @@ export class GitService { await this.git.raw(['reset', '--hard', 'HEAD']).catch(() => undefined); this.logger.warn( `squashMergeIntoMain: conflict merging ${branch} — aborted. conflictPaths=${conflictPaths.join(',')}` + - (mergeError ? ` error=${mergeError instanceof Error ? mergeError.message : String(mergeError)}` : ''), + (mergeError ? ` error=${mergeErrorMessage(mergeError)}` : ''), ); return { ok: false, conflict: true, conflictPaths }; }