diff --git a/src/git/__tests__/git-service.test.ts b/src/git/__tests__/git-service.test.ts index 923a6109..a1c86da7 100644 --- a/src/git/__tests__/git-service.test.ts +++ b/src/git/__tests__/git-service.test.ts @@ -1061,6 +1061,73 @@ describe('GitService', () => { }); }); + // Regression coverage for #94: a rebase that conflicts on a submodule + // gitlink leaves three mode-160000 entries for one path in the index. The + // conflict must be listed (so the UI can stage it) and resolved with + // `git add -- ` in the superproject, exactly like the manual command. + describe('gitlink (submodule) conflicts (#94)', () => { + const GITLINK_STAGES = + '160000 aaaa000000000000000000000000000000000000 1\tlibs/lib\n' + + '160000 bbbb000000000000000000000000000000000000 2\tlibs/lib\n' + + '160000 cccc000000000000000000000000000000000000 3\tlibs/lib\n'; + + it('lists a conflicted gitlink from the unmerged index entries', async () => { + const calls: string[][] = []; + mockExec(service, async (args) => { calls.push(args); return GITLINK_STAGES; }); + + expect(await service.getConflictFiles()).toEqual(['libs/lib']); + expect(calls[0]).toEqual(['ls-files', '--unmerged']); + }); + + it('lists mixed file and gitlink conflicts once each', async () => { + mockExec(service, async () => + '100644 aaaa000000000000000000000000000000000000 1\tsrc/a.ts\n' + + '100644 bbbb000000000000000000000000000000000000 3\tsrc/a.ts\n' + + GITLINK_STAGES); + + expect(await service.getConflictFiles()).toEqual(['src/a.ts', 'libs/lib']); + }); + + it('stageFile stages a gitlink path with git add -- ', async () => { + const calls: string[][] = []; + mockExec(service, async (args) => { calls.push(args); return ''; }); + + await service.stageFile('libs/lib'); + expect(calls[0]).toEqual(['add', '--', 'libs/lib']); + }); + + it('continueOperation stages only the unmerged gitlink path before continuing', async () => { + const calls: string[][] = []; + mockExec(service, async (args) => { + calls.push(args); + return args[0] === 'ls-files' ? GITLINK_STAGES : ''; + }); + vi.spyOn(service, 'getOperationState').mockResolvedValue({ type: 'rebase' }); + + await service.continueOperation(); + + expect(calls).toEqual([ + ['ls-files', '--unmerged'], + ['add', '--', 'libs/lib'], + ['rebase', '--continue'], + ]); + }); + + it('continueOperation keeps the blanket stage when nothing is unmerged', async () => { + const calls: string[][] = []; + mockExec(service, async (args) => { calls.push(args); return ''; }); + vi.spyOn(service, 'getOperationState').mockResolvedValue({ type: 'rebase' }); + + await service.continueOperation(); + + expect(calls).toEqual([ + ['ls-files', '--unmerged'], + ['add', '-A'], + ['rebase', '--continue'], + ]); + }); + }); + describe('addRemote URL validation', () => { it('accepts https URL', async () => { const calls: string[][] = []; diff --git a/src/git/git-service.ts b/src/git/git-service.ts index 71e65760..50c93a00 100644 --- a/src/git/git-service.ts +++ b/src/git/git-service.ts @@ -1762,8 +1762,26 @@ export class GitService { async getConflictFiles(): Promise { try { - const raw = await this.exec(['diff', '--name-only', '--diff-filter=U']); - return raw.trim().split('\n').filter(Boolean); + // Read the unmerged entries straight from the index. A gitlink conflict + // (submodule pointer) is three mode-160000 stage entries for one path; + // `ls-files --unmerged` always reports them, whereas `git diff + // --diff-filter=U` is a working-tree diff and can drop entries based on + // diff/submodule configuration (#94). Records are + // " \t" (paths are literal: exec runs git with + // core.quotePath=false), so collapse the stage entries to one path each. + const raw = await this.exec(['ls-files', '--unmerged']); + const paths: string[] = []; + const seen = new Set(); + for (const line of raw.split('\n')) { + const tab = line.indexOf('\t'); + if (tab < 0) continue; + const path = line.slice(tab + 1); + if (path && !seen.has(path)) { + seen.add(path); + paths.push(path); + } + } + return paths; } catch (err) { console.warn('Git Graph+: failed to get conflict files:', err instanceof Error ? err.message : err); return []; @@ -1791,8 +1809,19 @@ export class GitService { } async continueOperation(): Promise { - // Stage all resolved conflict files before continuing - await this.exec(['add', '-A']); + // Stage the paths that are still unmerged — the ones the user resolved by + // hand — with an explicit `git add -- ` instead of a blanket + // `add -A`. For a conflicted submodule gitlink that is exactly the manual + // `git add ` that works in the superproject (#94), and it keeps + // unrelated working-tree edits out of the rebase/cherry-pick commit. + // Nothing unmerged (e.g. an `edit` pause or a squash) keeps the previous + // blanket staging so edits made during the pause are still included. + const unresolved = await this.getConflictFiles(); + if (unresolved.length > 0) { + await this.exec(['add', '--', ...unresolved]); + } else { + await this.exec(['add', '-A']); + } const state = await this.getOperationState(); switch (state.type) { case 'merge': await this.exec(['commit', '--no-edit']); break;