fix(git): refuse squash-merge into a dirty main working tree

The auto_commit:false path (stageSquashMergeIntoMain) leaves main staged, but the
shared squash helper assumed a clean target. A later ingest/memory run merging
into that dirty index would 'git commit' the prior run's staged files under the
new run's commit (contamination), and conflict cleanup's 'reset --hard HEAD'
would discard them (data loss).

Guard applySquashToIndex: if the target worktree has uncommitted tracked changes,
refuse before merging and return a 'dirty' result (untracked/gitignored files are
ignored — the squash never commits them). Callers surface it cleanly: the bundle
runner fails the run with an actionable message; the memory agent rolls back its
eager DB writes (like a conflict) so the DB never gets ahead of main. Main is
left untouched in every case.
This commit is contained in:
Andrey Avtomonov 2026-06-09 14:27:53 +02:00
parent 36ee12ff06
commit f446d207ba
5 changed files with 135 additions and 10 deletions

View file

@ -27,13 +27,21 @@ export interface WorktreeEntry {
head: string | null;
}
/**
* `dirty` means the target (main) working tree had uncommitted tracked changes before the
* merge typically residue from a prior `auto_commit: false` run that was never committed.
* Squashing into it would either commit those unrelated changes or, on conflict cleanup,
* `reset --hard` them away, so the merge is refused and the tree is left untouched.
*/
export type SquashMergeResult =
| { ok: true; squashSha: string; touchedPaths: string[] }
| { ok: false; conflict: true; conflictPaths: string[] };
| { ok: false; conflict: true; conflictPaths: string[] }
| { ok: false; dirty: true; dirtyPaths: string[] };
export type StageSquashResult =
| { ok: true; touchedPaths: string[]; stagedTree: string }
| { ok: false; conflict: true; conflictPaths: string[] };
| { ok: false; conflict: true; conflictPaths: string[] }
| { ok: false; dirty: true; dirtyPaths: string[] };
function mergeErrorMessage(error: unknown): string {
if (error instanceof Error) {
@ -731,7 +739,28 @@ export class GitService {
*/
private async applySquashToIndex(
branch: string,
): Promise<{ ok: true; touchedPaths: string[] } | { ok: false; conflict: true; conflictPaths: string[] }> {
): Promise<
| { ok: true; touchedPaths: string[] }
| { ok: false; conflict: true; conflictPaths: string[] }
| { ok: false; dirty: true; dirtyPaths: string[] }
> {
// The squash flow commits the whole index and, on conflict, `reset --hard`s the tree, so
// it must start from a clean target. Refuse if the target has uncommitted tracked changes
// (e.g. residue from a prior `auto_commit: false` run) rather than committing them under
// this run's message or discarding them. Untracked files (gitignored .ktx state, stray
// files) are not committed by the squash and are intentionally ignored here.
const dirtyPaths = (await this.git.raw(['status', '--porcelain', '--untracked-files=no']))
.split('\n')
.map((line) => line.slice(3).trim())
.filter(Boolean);
if (dirtyPaths.length > 0) {
this.logger.warn(
`applySquashToIndex: target worktree has ${dirtyPaths.length} uncommitted change(s) ` +
`(${dirtyPaths.slice(0, 5).join(', ')}); refusing to squash ${branch} to avoid contaminating or discarding them`,
);
return { ok: false, dirty: true, dirtyPaths };
}
// Diff of HEAD..branch (two dots) lists commits/files reachable from `branch` that
// aren't on HEAD — i.e. exactly what the squash would apply. Three dots (HEAD...branch)
// is symmetric difference and would mis-classify cases where main moved ahead.

View file

@ -2698,6 +2698,13 @@ export class IngestBundleRunner {
});
if (!squashResult.merge.ok) {
await this.deps.runs.markFailed(runRow.id);
if ('dirty' in squashResult.merge) {
throw new Error(
'The project working tree has uncommitted changes ' +
`(${squashResult.merge.dirtyPaths.slice(0, 5).join(', ')}); commit or discard them before ingesting ` +
'(this typically means a previous run with storage.git.auto_commit: false was left staged).',
);
}
throw new Error(`squash merge conflict: ${squashResult.merge.conflictPaths.join(', ')}`);
}
const touchedPaths = squashResult.merge.touchedPaths;

View file

@ -271,8 +271,18 @@ export class MemoryAgentService {
);
if (!mergeResult.ok) {
// Both conflict and dirty-target mean "not landed" — roll back the eager session
// writes so the DB doesn't get ahead of main, and leave main untouched.
sessionOutcome = 'conflict';
sessionConflictPaths = mergeResult.conflictPaths;
if ('dirty' in mergeResult) {
this.logger.warn(
`[memory-agent] chat=${chatId} not landed: project working tree has uncommitted changes ` +
`(${mergeResult.dirtyPaths.slice(0, 5).join(', ')}); commit or discard them and re-run ` +
'(a previous run with auto_commit disabled may have been left staged).',
);
} else {
sessionConflictPaths = mergeResult.conflictPaths;
}
await this.rollbackDbForAbortedSession(session, actions);
} else if (mergeResult.touchedPaths.length === 0) {
sessionOutcome = 'empty';