diff --git a/webview-ui/src/lib/__tests__/autosquash.test.ts b/webview-ui/src/lib/__tests__/autosquash.test.ts index 33f06d7..07b2eac 100644 --- a/webview-ui/src/lib/__tests__/autosquash.test.ts +++ b/webview-ui/src/lib/__tests__/autosquash.test.ts @@ -21,6 +21,10 @@ describe('hasAutosquashTargets', () => { it('is true when a squash! commit is present', () => { expect(hasAutosquashTargets([pick('a', 'Add feature'), pick('b', 'squash! Add feature')])).toBe(true); }); + + it('is true when an amend! commit is present', () => { + expect(hasAutosquashTargets([pick('a', 'Add feature'), pick('b', 'amend! Add feature')])).toBe(true); + }); }); describe('applyAutosquash', () => { @@ -87,4 +91,26 @@ describe('applyAutosquash', () => { const result = applyAutosquash(todos); expect(result[0].action).toBe('pick'); }); + + it('folds an amend! commit in as fixup and rewords the target with its body', () => { + const todos = [ + pick('a', 'Add feature'), + pick('b', 'amend! Add feature', 'Rewritten feature message'), + ]; + const result = applyAutosquash(todos); + + expect(view(result)).toEqual(['reword:a', 'fixup:b']); + expect(result[0].newMessage).toBe('Rewritten feature message'); + }); + + it('folds an amend! commit with an empty body in as a plain fixup (no reword)', () => { + const todos = [ + pick('a', 'Add feature'), + pick('b', 'amend! Add feature'), + ]; + const result = applyAutosquash(todos); + + expect(view(result)).toEqual(['pick:a', 'fixup:b']); + expect(result[0].newMessage).toBeUndefined(); + }); }); diff --git a/webview-ui/src/lib/autosquash.ts b/webview-ui/src/lib/autosquash.ts index 433aa80..aef1418 100644 --- a/webview-ui/src/lib/autosquash.ts +++ b/webview-ui/src/lib/autosquash.ts @@ -15,33 +15,38 @@ export interface AutosquashTodo { newMessage?: string; } -const PREFIX_RE = /^(fixup|squash)! (.+)$/; +const PREFIX_RE = /^(fixup|squash|amend)! (.+)$/; interface Parsed { - kind: 'fixup' | 'squash'; - /** The subject the prefix points at (the text after `fixup! ` / `squash! `). */ + kind: 'fixup' | 'squash' | 'amend'; + /** The subject the prefix points at (the text after `fixup! `/`squash! `/`amend! `). */ target: string; } function parsePrefix(subject: string): Parsed | null { const m = PREFIX_RE.exec(subject); if (!m) return null; - return { kind: m[1] as 'fixup' | 'squash', target: m[2] }; + return { kind: m[1] as 'fixup' | 'squash' | 'amend', target: m[2] }; } -/** True if any todo is a `fixup!` / `squash!` commit that could be autosquashed. */ +/** True if any todo is a `fixup!` / `squash!` / `amend!` commit that could be autosquashed. */ export function hasAutosquashTargets(todos: AutosquashTodo[]): boolean { return todos.some(t => parsePrefix(t.subject) !== null); } /** - * Returns a new todo array with fixup!/squash! commits grouped under their - * targets. Non-matching commits keep their relative order. A fixup!/squash! - * with no preceding target is left as `pick` in place. + * Returns a new todo array with fixup!/squash!/amend! commits grouped under + * their targets. Non-matching commits keep their relative order. A prefix + * commit with no preceding target is left as `pick` in place. + * + * `amend!` commits are the `git commit --fixup=amend:` form: their + * changes fold into the target like a `fixup`, but the target is reworded with + * the amend commit's body (the text after `amend! `) — matching git's + * own `fixup -C` autosquash behaviour. */ export function applyAutosquash(todos: AutosquashTodo[]): AutosquashTodo[] { // Resolve each commit's "match key": the subject git would compare against. - // For a fixup!/squash! commit, that is the inner target subject; chaining + // For a prefix commit, that is the inner target subject; chaining // (`fixup! fixup! X`) collapses to the innermost subject so the whole chain // lands on the same target group. const matchKey = (subject: string): string => { @@ -54,12 +59,15 @@ export function applyAutosquash(todos: AutosquashTodo[]): AutosquashTodo[] { return s; }; - // Build the result by walking the original order. Each non-fixup commit - // anchors a group; matching fixup!/squash! commits attach to the nearest - // preceding group with the same key. + // Build the result by walking the original order. Each non-prefix commit + // anchors a group; matching prefix commits attach to the nearest preceding + // group with the same key. const result: AutosquashTodo[] = []; // Index in `result` of the last todo belonging to each group key. const groupEnd = new Map(); + // Index in `result` of the first (anchor) todo of each group key — needed to + // reword the target when an `amend!` commit attaches to it. + const groupStart = new Map(); for (const todo of todos) { const parsed = parsePrefix(todo.subject); @@ -67,25 +75,44 @@ export function applyAutosquash(todos: AutosquashTodo[]): AutosquashTodo[] { const key = matchKey(todo.subject); const insertAfter = groupEnd.get(key); if (insertAfter !== undefined) { - const placed: AutosquashTodo = { ...todo, action: parsed.kind, newMessage: undefined }; + const placed: AutosquashTodo = { + ...todo, + action: parsed.kind === 'amend' ? 'fixup' : parsed.kind, + newMessage: undefined, + }; result.splice(insertAfter + 1, 0, placed); - // Shift group-end indices that sit at/after the insertion point, then - // extend this group's end to the freshly placed member. A later chained - // fixup (`fixup! fixup! X`) resolves to the same key and attaches here. + // Shift group-end/start indices that sit at/after the insertion point, + // then extend this group's end to the freshly placed member. A later + // chained prefix commit resolves to the same key and attaches here. for (const [k, idx] of groupEnd) { if (idx > insertAfter) groupEnd.set(k, idx + 1); } + for (const [k, idx] of groupStart) { + if (idx > insertAfter) groupStart.set(k, idx + 1); + } groupEnd.set(key, insertAfter + 1); + + if (parsed.kind === 'amend') { + const targetIdx = groupStart.get(key); + if (targetIdx !== undefined) { + const newMessage = todo.body?.trim(); + if (newMessage) { + result[targetIdx] = { ...result[targetIdx], action: 'reword', newMessage }; + } + } + } continue; } // No preceding target — leave as pick where it is. result.push({ ...todo }); groupEnd.set(todo.subject, result.length - 1); + groupStart.set(todo.subject, result.length - 1); continue; } // Regular commit: anchors a group keyed by its subject. result.push({ ...todo }); groupEnd.set(todo.subject, result.length - 1); + groupStart.set(todo.subject, result.length - 1); } // Guard: the first todo can never be squash/fixup.