Skip to content

Commit b6bab57

Browse files
committed
feat: amend the last commit from the graph
Add an amend flow reachable from two entry points — right-clicking the uncommitted-changes row ("Amend '<branch/sha>'") or the most recent commit row ("Amend Commit"). Both open a shared AmendModal and reveal the SCM view (returning focus to the webview so the modal stays keyboard-interactive). The modal lets the user keep or edit the message, set the date to now (--date=now), reset the author (--reset-author), and exclude staged changes (--only). It shows a live staged-file count styled like the conflict-detection status (warning when 0), refreshed whenever the index changes, and warns in red when the commit is already pushed. Backend: GitService.amendCommit + amendCommit message/handler; openDiff gains a staged flag; openScmView gains returnFocus.
1 parent 8a97aa2 commit b6bab57

16 files changed

Lines changed: 541 additions & 47 deletions

File tree

l10n/bundle.l10n.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"pushing": "Pushing...",
1010
"pushFailed": "Push failed: {0}",
1111
"checkedOut": "Checked out {0}",
12+
"commitAmended": "Last commit amended",
1213
"checkoutFailed": "Checkout failed: {0}",
1314
"enterBranchName": "Enter branch name",
1415
"branchPlaceholder": "feature/my-branch",

l10n/bundle.l10n.ko.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"pushing": "Push 중...",
1010
"pushFailed": "Push 실패: {0}",
1111
"checkedOut": "{0}(으)로 checkout 완료",
12+
"commitAmended": "마지막 커밋을 amend 했습니다",
1213
"checkoutFailed": "Checkout 실패: {0}",
1314
"enterBranchName": "브랜치 이름 입력",
1415
"branchPlaceholder": "feature/my-branch",

l10n/bundle.l10n.zh-cn.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"pushing": "正在推送...",
1010
"pushFailed": "推送失败:{0}",
1111
"checkedOut": "已检出 {0}",
12+
"commitAmended": "已修正上一次提交",
1213
"checkoutFailed": "检出失败:{0}",
1314
"enterBranchName": "输入分支名称",
1415
"branchPlaceholder": "feature/my-branch",

src/git/__tests__/integration/mutations.integration.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,66 @@ describe('GitService integration — state mutations', () => {
134134
});
135135
});
136136

137+
describe('amendCommit', () => {
138+
it('rewrites the last commit message when editing', async () => {
139+
commit(repo.path, 'original message', { 'a.txt': 'a\n' });
140+
await svc.amendCommit({ message: 'amended message' });
141+
const tip = (await svc.log()).find(c => c.hash !== 'UNCOMMITTED')!;
142+
expect(tip.subject).toBe('amended message');
143+
});
144+
145+
it('folds staged changes into the last commit and keeps the message', async () => {
146+
commit(repo.path, 'base', { 'a.txt': 'a\n' });
147+
writeFileSync(join(repo.path, 'b.txt'), 'b\n');
148+
runGit(repo.path, ['add', 'b.txt']);
149+
await svc.amendCommit({ keepMessage: true });
150+
const files = (await svc.showCommitFiles('HEAD')).map(f => f.path);
151+
expect(files).toContain('b.txt');
152+
const tip = (await svc.log()).find(c => c.hash !== 'UNCOMMITTED')!;
153+
expect(tip.subject).toBe('base');
154+
});
155+
156+
it('leaves unstaged changes out of the amend', async () => {
157+
commit(repo.path, 'base', { 'a.txt': 'a\n' });
158+
writeFileSync(join(repo.path, 'unstaged.txt'), 'u\n'); // never staged
159+
await svc.amendCommit({ keepMessage: true });
160+
const files = (await svc.showCommitFiles('HEAD')).map(f => f.path);
161+
expect(files).not.toContain('unstaged.txt');
162+
});
163+
164+
it('resets the author date to now with resetDate', async () => {
165+
// helpers commit with GIT_AUTHOR_DATE=2024-01-01.
166+
commit(repo.path, 'old', { 'a.txt': 'a\n' });
167+
await svc.amendCommit({ keepMessage: true, resetDate: true });
168+
const tip = (await svc.log()).find(c => c.hash !== 'UNCOMMITTED')!;
169+
expect(tip.author.date.startsWith('2024-01-01')).toBe(false);
170+
});
171+
172+
it('resets the author identity with resetAuthor', async () => {
173+
// helpers author commits as author@example.com; repo config user is test@example.com.
174+
commit(repo.path, 'by someone else', { 'a.txt': 'a\n' });
175+
await svc.amendCommit({ keepMessage: true, resetAuthor: true });
176+
const tip = (await svc.log()).find(c => c.hash !== 'UNCOMMITTED')!;
177+
expect(tip.author.email).toBe('test@example.com');
178+
});
179+
180+
it('excludes staged changes when only is set (message/metadata only)', async () => {
181+
commit(repo.path, 'base', { 'a.txt': 'a\n' });
182+
writeFileSync(join(repo.path, 'staged.txt'), 's\n');
183+
runGit(repo.path, ['add', 'staged.txt']);
184+
await svc.amendCommit({ message: 'reworded', only: true });
185+
const files = (await svc.showCommitFiles('HEAD')).map(f => f.path);
186+
expect(files).not.toContain('staged.txt');
187+
const tip = (await svc.log()).find(c => c.hash !== 'UNCOMMITTED')!;
188+
expect(tip.subject).toBe('reworded');
189+
});
190+
191+
it('rejects an empty message when not keeping the message', async () => {
192+
commit(repo.path, 'base', { 'a.txt': 'a\n' });
193+
await expect(svc.amendCommit({ message: ' ' })).rejects.toThrow();
194+
});
195+
});
196+
137197
describe('tag CRUD', () => {
138198
it('createTag (lightweight) at HEAD', async () => {
139199
commit(repo.path, 'init');

src/git/git-service.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,6 +1241,40 @@ export class GitService {
12411241
await this.exec(['reset', `--${mode}`, ref]);
12421242
}
12431243

1244+
/**
1245+
* Amend the last commit (HEAD). Folds whatever is currently staged into HEAD
1246+
* (standard `git commit --amend`); unstaged changes are left untouched.
1247+
* - keepMessage → `--no-edit` (reuse the existing message)
1248+
* - otherwise the (required) message replaces it via `-m`
1249+
* - resetDate → `--date=now` (author date to now)
1250+
* - resetAuthor → `--reset-author` (author identity + date to the current user/now)
1251+
* - only → `--only` (amend message/metadata only; do NOT fold staged changes in)
1252+
*/
1253+
async amendCommit(options?: { message?: string; keepMessage?: boolean; resetDate?: boolean; resetAuthor?: boolean; only?: boolean }): Promise<void> {
1254+
const args = ['commit', '--amend'];
1255+
if (options?.only) {
1256+
args.push('--only');
1257+
}
1258+
if (options?.keepMessage) {
1259+
args.push('--no-edit');
1260+
} else {
1261+
const msg = (options?.message ?? '').trim();
1262+
if (!msg) {
1263+
throw new GitError('Commit message is required to amend', null, ['commit', '--amend']);
1264+
}
1265+
// A single -m value keeps embedded newlines verbatim (subject + body),
1266+
// and spawn passes it as one argv entry so no shell escaping is needed.
1267+
args.push('-m', msg);
1268+
}
1269+
if (options?.resetDate) {
1270+
args.push('--date=now');
1271+
}
1272+
if (options?.resetAuthor) {
1273+
args.push('--reset-author');
1274+
}
1275+
await this.exec(args);
1276+
}
1277+
12441278
async stageFile(filePath: string): Promise<void> {
12451279
this.assertSafePath(filePath, 'add');
12461280
await this.exec(['add', '--', filePath]);

src/panels/MainPanel.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,22 @@ export class MainPanel {
540540
}
541541
case 'openScmView': {
542542
await vscode.commands.executeCommand('workbench.view.scm');
543+
// When opened alongside the amend modal, return focus to the webview
544+
// so the modal stays keyboard-interactive (Escape to close, typing in
545+
// the message). Without this, focus stays in the SCM view.
546+
if (message.payload?.returnFocus) {
547+
this.panel.reveal(this.panel.viewColumn, false);
548+
}
549+
break;
550+
}
551+
case 'amendCommit': {
552+
await this.gitService.amendCommit(message.payload);
553+
this.post({
554+
type: 'operationComplete',
555+
payload: { operation: 'amendCommit', success: true },
556+
});
557+
vscode.window.showInformationMessage(vscode.l10n.t('commitAmended'));
558+
await this.refreshAll();
543559
break;
544560
}
545561
case 'fetch': {

src/utils/message-bus.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ export type WebviewMessage =
3636
| { type: 'removeRemote'; payload: { name: string } }
3737
| { type: 'openDiff'; payload: { file: string; commitHash?: string; ref1?: string; ref2?: string; staged?: boolean } }
3838
| { type: 'openFile'; payload: { file: string } }
39-
| { type: 'openScmView' }
39+
| { type: 'openScmView'; payload?: { returnFocus?: boolean } }
40+
| { type: 'amendCommit'; payload: { message?: string; keepMessage?: boolean; resetDate?: boolean; resetAuthor?: boolean; only?: boolean } }
4041
| { type: 'stashDrop'; payload: { index: number } }
4142
| { type: 'stashRename'; payload: { index: number; message: string } }
4243
| { type: 'worktreeAdd'; payload: { path: string; branch?: string; newBranch?: string } }

webview-ui/src/App.svelte

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import StashApplyModal from './components/modals/StashApplyModal.svelte';
2525
import StashRenameModal from './components/modals/StashRenameModal.svelte';
2626
import StashSaveModal from './components/modals/StashSaveModal.svelte';
27+
import AmendModal from './components/modals/AmendModal.svelte';
2728
import RenameBranchModal from './components/modals/RenameBranchModal.svelte';
2829
import DeleteRemoteBranchModal from './components/modals/DeleteRemoteBranchModal.svelte';
2930
import DeleteRemoteTagModal from './components/modals/DeleteRemoteTagModal.svelte';
@@ -543,6 +544,17 @@
543544
/>
544545
{/if}
545546

547+
{#if modalStore.amend.show}
548+
<AmendModal
549+
hash={modalStore.amend.hash}
550+
subject={modalStore.amend.subject}
551+
message={modalStore.amend.message}
552+
isPushed={modalStore.amend.isPushed}
553+
onClose={() => { modalStore.closeAmend(); }}
554+
onAmend={(opts) => { modalStore.closeAmend(); vscode.postMessage({ type: 'amendCommit', payload: opts }); }}
555+
/>
556+
{/if}
557+
546558
{#if modalStore.checkoutRemote.show}
547559
<CheckoutRemoteModal
548560
remoteName={modalStore.checkoutRemote.remoteName}

webview-ui/src/components/graph/CommitGraph.svelte

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,20 @@
644644
groups.push([{ label: t('graph.resetBranchToHere', { branch: currentBranch }), action: () => { resetTarget = commit.hash; resetMode = 'mixed'; showResetModal = true; } }]);
645645
}
646646
647+
// ── Amend (most recent commit / HEAD) ──
648+
if (isHead) {
649+
const fullMessage = commit.body ? `${commit.subject}\n\n${commit.body}` : commit.subject;
650+
const cur = branchStore.currentBranch;
651+
const isPushed = !!cur?.upstream && !cur?.upstreamGone && (cur?.ahead ?? 0) === 0;
652+
groups.push([{
653+
label: t('graph.amendCommit'),
654+
action: () => {
655+
modalStore.openAmend({ hash: commit.hash, subject: commit.subject, message: fullMessage, isPushed });
656+
vscode.postMessage({ type: 'openScmView', payload: { returnFocus: true } });
657+
},
658+
}]);
659+
}
660+
647661
// ── Commit operations ──
648662
groups.push([
649663
{
@@ -732,6 +746,30 @@
732746
contextMenu = { x: e.clientX, y: e.clientY, items };
733747
}
734748
749+
// Context menu for the uncommitted-changes row: amend the last commit.
750+
function onUncommittedContextMenu(e: MouseEvent) {
751+
e.preventDefault();
752+
const headCommit = commitStore.commits.find(c => c.refs.some(r => r.type === 'head'));
753+
if (!headCommit) return; // nothing to amend (empty repo / no HEAD loaded)
754+
const cur = branchStore.currentBranch;
755+
// HEAD is "pushed" when the branch tracks an existing upstream and is not ahead of it.
756+
const isPushed = !!cur?.upstream && !cur?.upstreamGone && (cur?.ahead ?? 0) === 0;
757+
const fullMessage = headCommit.body ? `${headCommit.subject}\n\n${headCommit.body}` : headCommit.subject;
758+
const ref = cur?.name ?? headCommit.abbreviatedHash;
759+
contextMenu = {
760+
x: e.clientX,
761+
y: e.clientY,
762+
items: [{
763+
label: t('graph.amendRef', { ref }),
764+
action: () => {
765+
contextMenu = null;
766+
modalStore.openAmend({ hash: headCommit.hash, subject: headCommit.subject, message: fullMessage, isPushed });
767+
vscode.postMessage({ type: 'openScmView', payload: { returnFocus: true } });
768+
},
769+
}],
770+
};
771+
}
772+
735773
function formatDate(dateStr: string): string {
736774
const d = new Date(dateStr);
737775
const year = d.getFullYear();
@@ -906,7 +944,7 @@
906944
}
907945
}
908946
}}
909-
oncontextmenu={(e) => { if (commit.hash !== 'UNCOMMITTED') onCommitContextMenu(e, commit); }}
947+
oncontextmenu={(e) => { if (commit.hash === 'UNCOMMITTED') onUncommittedContextMenu(e); else onCommitContextMenu(e, commit); }}
910948
use:tooltip={commit.hash === 'UNCOMMITTED' ? t('graph.clickToOpenScm') : ''}
911949
role="row"
912950
tabindex={0}

webview-ui/src/components/graph/__tests__/CommitGraph.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,60 @@ describe('CommitGraph smoke', () => {
104104
expect(uiStore.selectedCommitHash).toBeNull();
105105
});
106106

107+
it('right-clicking the UNCOMMITTED row opens an Amend menu, then the amend modal + SCM', async () => {
108+
const { modalStore } = await import('../../../lib/stores/modals.svelte');
109+
const head = makeCommit('h1', 'first');
110+
head.refs = [{ type: 'head', name: 'main' }];
111+
commitStore.setData(makeGraphData([
112+
makeCommit('UNCOMMITTED', 'Uncommitted changes'),
113+
head,
114+
]));
115+
branchStore.branches = [
116+
{ name: 'main', current: true, ahead: 1, behind: 0, hash: 'h1' },
117+
];
118+
const { container } = render(CommitGraph, {});
119+
await tick();
120+
globalThis.__postedMessages = [];
121+
const row = container.querySelectorAll<HTMLElement>('.commit-row')[0];
122+
await fireEvent.contextMenu(row, { clientX: 10, clientY: 10 });
123+
await tick();
124+
// The single menu item is "Amend '{ref}'" (ref = current branch); click it.
125+
const item = Array.from(container.querySelectorAll<HTMLElement>('*'))
126+
.find(el => el.children.length === 0 && /^amend 'main'$/i.test((el.textContent ?? '').trim()));
127+
expect(item).toBeTruthy();
128+
await fireEvent.click(item!);
129+
await tick();
130+
expect(modalStore.amend.show).toBe(true);
131+
expect(modalStore.amend.hash).toBe('h1');
132+
expect(globalThis.__postedMessages.some(m => (m.data as { type?: string }).type === 'openScmView')).toBe(true);
133+
modalStore.closeAmend();
134+
});
135+
136+
it('right-clicking the HEAD commit opens the amend modal + SCM', async () => {
137+
const { modalStore } = await import('../../../lib/stores/modals.svelte');
138+
const head = makeCommit('h2', 'latest', ['h1']);
139+
head.refs = [{ type: 'head', name: 'main' }];
140+
commitStore.setData(makeGraphData([head, makeCommit('h1', 'first')]));
141+
branchStore.branches = [
142+
{ name: 'main', current: true, ahead: 1, behind: 0, hash: 'h2' },
143+
];
144+
const { container } = render(CommitGraph, {});
145+
await tick();
146+
globalThis.__postedMessages = [];
147+
const row = container.querySelectorAll<HTMLElement>('.commit-row')[0]; // HEAD row
148+
await fireEvent.contextMenu(row, { clientX: 10, clientY: 10 });
149+
await tick();
150+
const item = Array.from(container.querySelectorAll<HTMLElement>('*'))
151+
.find(el => el.children.length === 0 && /^amend commit$/i.test((el.textContent ?? '').trim()));
152+
expect(item).toBeTruthy();
153+
await fireEvent.click(item!);
154+
await tick();
155+
expect(modalStore.amend.show).toBe(true);
156+
expect(modalStore.amend.hash).toBe('h2');
157+
expect(globalThis.__postedMessages.some(m => (m.data as { type?: string }).type === 'openScmView')).toBe(true);
158+
modalStore.closeAmend();
159+
});
160+
107161
it('does not crash on a branch-set fingerprint cache hit (same commits, same branch)', async () => {
108162
// First mount populates the cache.
109163
commitStore.setData(makeGraphData([

0 commit comments

Comments
 (0)