Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/git/__tests__/git-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <path>` 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 -- <path>', 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[][] = [];
Expand Down
37 changes: 33 additions & 4 deletions src/git/git-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1762,8 +1762,26 @@ export class GitService {

async getConflictFiles(): Promise<string[]> {
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
// "<mode> <oid> <stage>\t<path>" (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<string>();
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 [];
Expand Down Expand Up @@ -1791,8 +1809,19 @@ export class GitService {
}

async continueOperation(): Promise<void> {
// 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 -- <path>` instead of a blanket
// `add -A`. For a conflicted submodule gitlink that is exactly the manual
// `git add <submodule>` 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;
Expand Down