mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-04 10:52:13 +02:00
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:
parent
36ee12ff06
commit
f446d207ba
5 changed files with 135 additions and 10 deletions
|
|
@ -449,8 +449,8 @@ describe('GitService', () => {
|
|||
|
||||
const result = await service.stageSquashMergeIntoMain('session/stage-conflict');
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error('unreachable');
|
||||
if (result.ok || !('conflict' in result)) {
|
||||
throw new Error('expected a conflict');
|
||||
}
|
||||
expect(result.conflictPaths).toContain('conflict.md');
|
||||
expect(await service.revParseHead()).toBe(mainHead);
|
||||
|
|
@ -460,6 +460,67 @@ describe('GitService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('refuses to squash into a dirty main worktree', () => {
|
||||
it('reports dirty without committing, merging, or discarding pre-existing staged changes', async () => {
|
||||
const { commitHash: baseSha } = await writeAndCommit('seed.md', 'seed');
|
||||
const parent = await realpath(join(tempDir, '..'));
|
||||
const wtDir = join(parent, `wt-${Date.now()}-dirty`);
|
||||
await service.addWorktree(wtDir, 'session/dirty', 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');
|
||||
|
||||
// Residue from a prior auto_commit:false run: a staged change on main.
|
||||
await writeFile(join(tempDir, 'pending.md'), 'pending work\n', 'utf-8');
|
||||
await createSimpleGit(tempDir).add('pending.md');
|
||||
|
||||
const result = await service.squashMergeIntoMain('session/dirty', 'System User', 'system@example.com', 'msg');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok || !('dirty' in result)) {
|
||||
throw new Error('expected a dirty refusal');
|
||||
}
|
||||
expect(result.dirtyPaths).toContain('pending.md');
|
||||
|
||||
// Main is untouched: HEAD unchanged, branch not merged, pending change preserved.
|
||||
expect(await service.revParseHead()).toBe(baseSha);
|
||||
await expect(readFile(join(tempDir, 'pending.md'), 'utf-8')).resolves.toBe('pending work\n');
|
||||
const staged = await createSimpleGit(tempDir).raw(['diff', '--cached', '--name-only']);
|
||||
expect(staged).toContain('pending.md');
|
||||
const branchFileLanded = await stat(join(tempDir, 'from-branch.yaml'))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(branchFileLanded).toBe(false);
|
||||
|
||||
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, '..'));
|
||||
const wtDir = join(parent, `wt-${Date.now()}-dirty-stage`);
|
||||
await service.addWorktree(wtDir, 'session/dirty-stage', 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');
|
||||
|
||||
await writeFile(join(tempDir, 'pending.md'), 'pending work\n', 'utf-8');
|
||||
await createSimpleGit(tempDir).add('pending.md');
|
||||
|
||||
const result = await service.stageSquashMergeIntoMain('session/dirty-stage');
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok || !('dirty' in result)) {
|
||||
throw new Error('expected a dirty refusal');
|
||||
}
|
||||
expect(result.dirtyPaths).toContain('pending.md');
|
||||
expect(await service.revParseHead()).toBe(baseSha);
|
||||
|
||||
await service.removeWorktree(wtDir).catch(() => undefined);
|
||||
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('squashMergeIntoMain', () => {
|
||||
it('merges a session branch as one commit on main, returning the new SHA + touched paths', async () => {
|
||||
const { commitHash: baseSha } = await writeAndCommit('seed.md', 'seed');
|
||||
|
|
@ -544,8 +605,8 @@ describe('GitService', () => {
|
|||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error('unreachable');
|
||||
if (result.ok || !('conflict' in result)) {
|
||||
throw new Error('expected a conflict');
|
||||
}
|
||||
expect(result.conflict).toBe(true);
|
||||
expect(result.conflictPaths).toContain('shared.yaml');
|
||||
|
|
@ -576,8 +637,8 @@ describe('GitService', () => {
|
|||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error('unreachable');
|
||||
if (result.ok || !('conflict' in result)) {
|
||||
throw new Error('expected a conflict');
|
||||
}
|
||||
expect(result.conflict).toBe(true);
|
||||
expect(result.conflictPaths).toEqual(['knowledge.md']);
|
||||
|
|
|
|||
|
|
@ -365,6 +365,24 @@ describe('MemoryAgentService.ingest — session-branch orchestration', () => {
|
|||
expect(result.actions).toEqual([]);
|
||||
});
|
||||
|
||||
it('dirty-target path: rolls back DB and does not land when main has uncommitted changes', async () => {
|
||||
const mocks = buildMocks();
|
||||
mocks.gitService.squashMergeIntoMain.mockResolvedValue({
|
||||
ok: false,
|
||||
dirty: true,
|
||||
dirtyPaths: ['pending.md'],
|
||||
});
|
||||
const svc = buildService(mocks);
|
||||
|
||||
const result = await svc.ingest(baseInput);
|
||||
|
||||
// Treated as a not-landed abort: rolled back, no commit, no message-enhancement job.
|
||||
expect(mocks.sessionWorktreeService.cleanup).toHaveBeenCalledWith(expect.any(Object), 'conflict', expect.any(Object));
|
||||
expect(mocks.configService.enqueueCommitMessageJobForExternalCommit).not.toHaveBeenCalled();
|
||||
expect(result.commitHash).toBeNull();
|
||||
expect(result.actions).toEqual([]);
|
||||
});
|
||||
|
||||
it('crash path: post-loop step throws → cleanup(crash), commitHash=null', async () => {
|
||||
const mocks = buildMocks();
|
||||
// Force the cross-ref reconciler to throw, escaping into the outer try/catch and
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue