Skip to content
Open
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
26 changes: 26 additions & 0 deletions webview-ui/src/lib/__tests__/autosquash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
59 changes: 43 additions & 16 deletions webview-ui/src/lib/autosquash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<hash>` 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! <subject>`) — 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 => {
Expand All @@ -54,38 +59,60 @@ 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<string, number>();
// 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<string, number>();

for (const todo of todos) {
const parsed = parsePrefix(todo.subject);
if (parsed) {
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.
Expand Down
Loading