fix(git): scope the dirty-main squash guard to staged changes only

The previous guard rejected any uncommitted tracked change (git status
--untracked-files=no), which also caught unstaged edits like a ktx.yaml that
setup writes during the flow and commits only after the context build — so the
guard wrongly blocked setup context builds and ingest (8 local-bundle-ingest
cases failed with 'uncommitted changes (ktx.yaml)').

The actual hazard is the index, not the working tree: 'git commit' captures only
staged changes, and the auto_commit:false residue is staged by 'git merge
--squash'. Narrow the check to 'git diff --cached' so only pre-existing staged
changes are refused; unstaged working-tree edits proceed untouched (never
committed by the squash). Adds a regression test that an unstaged tracked change
does not block the merge and is neither committed nor discarded.
This commit is contained in:
Andrey Avtomonov 2026-06-09 14:55:08 +02:00
parent 9ac37166f5
commit 852ac8a836
4 changed files with 45 additions and 13 deletions

View file

@ -496,6 +496,36 @@ describe('GitService', () => {
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
});
it('allows the squash when main has only unstaged (not staged) changes', async () => {
// e.g. setup writes ktx.yaml during the flow and commits it only after the context
// build, leaving it modified-but-unstaged. `git commit` never captures unstaged changes,
// so the squash must proceed and leave them untouched.
const { commitHash: baseSha } = await writeAndCommit('config.yaml', 'a: 1\n');
const parent = await realpath(join(tempDir, '..'));
const wtDir = join(parent, `wt-${Date.now()}-unstaged`);
await service.addWorktree(wtDir, 'session/unstaged', baseSha);
const scoped = service.forWorktree(wtDir);
await writeFile(join(wtDir, 'from-branch.yaml'), 'b: 1\n', 'utf-8');
await scoped.commitFile('from-branch.yaml', 'wip', 'System User', 'system@example.com');
// Modify a tracked file on main WITHOUT staging it.
await writeFile(join(tempDir, 'config.yaml'), 'a: 2\n', 'utf-8');
const result = await service.squashMergeIntoMain('session/unstaged', 'System User', 'system@example.com', 'msg');
expect(result.ok).toBe(true);
if (!result.ok || !('squashSha' in result)) {
throw new Error('expected the squash to land');
}
expect(result.touchedPaths).toEqual(['from-branch.yaml']);
// The branch landed, and the unstaged edit was neither committed nor discarded.
await expect(readFile(join(tempDir, 'from-branch.yaml'), 'utf-8')).resolves.toBe('b: 1\n');
await expect(readFile(join(tempDir, 'config.yaml'), 'utf-8')).resolves.toBe('a: 2\n');
await service.removeWorktree(wtDir).catch(() => undefined);
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
});
it('stageSquashMergeIntoMain also refuses on a dirty main', async () => {
const { commitHash: baseSha } = await writeAndCommit('seed.md', 'seed');
const parent = await realpath(join(tempDir, '..'));