Skip to content

Commit ee5e9e5

Browse files
committed
feat(rebase): add classic terminal interactive rebase mode (#58)
Add a gitGraphPlus.interactiveRebase.mode setting ("ui" | "classic", default "ui"). In classic mode the graph's interactive-rebase entry points post a runClassicRebase message instead of opening the GUI modal, and the extension runs `git rebase -i <base>` in a dedicated "Git Graph+ Rebase" integrated terminal so the user's own Git editor edits the rebase-todo. The existing .git file-watcher handles the refresh and conflict/continue banner. The mode is mirrored to the webview (init + config change), like the locale pattern. A pure buildClassicRebaseCommand validates the base as a hex hash (injection guard) and uses bare `git` on PATH, which works across PowerShell/bash/cmd. The "ui" default keeps existing behavior unchanged.
1 parent 2c99f75 commit ee5e9e5

12 files changed

Lines changed: 151 additions & 4 deletions

File tree

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,16 @@
409409
"minimum": 1,
410410
"markdownDescription": "Number of additional commits fetched each time the **Load more commits** button is clicked."
411411
},
412+
"gitGraphPlus.interactiveRebase.mode": {
413+
"type": "string",
414+
"enum": ["ui", "classic"],
415+
"enumDescriptions": [
416+
"Open the built-in graphical interactive-rebase editor",
417+
"Run git rebase -i in the integrated terminal using your configured Git editor"
418+
],
419+
"default": "ui",
420+
"markdownDescription": "How **Interactive Rebase** opens: the built-in GUI editor (`ui`), or the classic `git rebase -i` flow in the integrated terminal (`classic`), which uses your configured Git editor (e.g. vim, nano, `code --wait`)."
421+
},
412422
"gitGraphPlus.graphSortOrder": {
413423
"type": "string",
414424
"enum": [
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { normalizeInteractiveRebaseMode, buildClassicRebaseCommand } from '../classic-rebase';
3+
4+
describe('normalizeInteractiveRebaseMode', () => {
5+
it('returns "classic" only for the exact string', () => {
6+
expect(normalizeInteractiveRebaseMode('classic')).toBe('classic');
7+
});
8+
it('falls back to "ui" for anything else', () => {
9+
expect(normalizeInteractiveRebaseMode('ui')).toBe('ui');
10+
expect(normalizeInteractiveRebaseMode('CLASSIC')).toBe('ui');
11+
expect(normalizeInteractiveRebaseMode(undefined)).toBe('ui');
12+
expect(normalizeInteractiveRebaseMode(42)).toBe('ui');
13+
});
14+
});
15+
16+
describe('buildClassicRebaseCommand', () => {
17+
it('builds a bare-git rebase -i command for a valid hash', () => {
18+
expect(buildClassicRebaseCommand('a1b2c3d')).toBe('git rebase -i a1b2c3d');
19+
});
20+
it('accepts a full 40-char hash', () => {
21+
const full = '0123456789abcdef0123456789abcdef01234567';
22+
expect(buildClassicRebaseCommand(full)).toBe(`git rebase -i ${full}`);
23+
});
24+
it('returns null for non-hash input (injection guard)', () => {
25+
expect(buildClassicRebaseCommand('a1b2c3d; rm -rf /')).toBeNull();
26+
expect(buildClassicRebaseCommand('main')).toBeNull();
27+
expect(buildClassicRebaseCommand('')).toBeNull();
28+
});
29+
});

src/git/classic-rebase.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/** Which interactive-rebase experience the user gets. */
2+
export type InteractiveRebaseMode = 'ui' | 'classic';
3+
4+
/** Map a raw setting value to a valid mode, defaulting to the GUI. */
5+
export function normalizeInteractiveRebaseMode(raw: unknown): InteractiveRebaseMode {
6+
return raw === 'classic' ? 'classic' : 'ui';
7+
}
8+
9+
/**
10+
* Build the shell command for a classic `git rebase -i <base>` run in a
11+
* terminal. Returns null when `base` is not a commit hash - the value is
12+
* spliced into a shell command, so anything else is rejected as an injection
13+
* guard. `base` always comes from our own commit data in the normal path.
14+
*
15+
* Uses bare `git` (resolved on the terminal's PATH) rather than an absolute
16+
* binary path: a quoted, spaced path is parsed as a string literal in
17+
* PowerShell (VS Code's default Windows shell) and silently never runs. Bare
18+
* `git` works across PowerShell/bash/cmd, and a missing git surfaces a visible
19+
* "command not found" instead of a silent no-op.
20+
*/
21+
export function buildClassicRebaseCommand(base: string): string | null {
22+
if (!/^[0-9a-fA-F]{4,40}$/.test(base)) { return null; }
23+
return `git rebase -i ${base}`;
24+
}

src/panels/MainPanel.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { GitService, GitError } from '../git/git-service';
55
import { formatGitError, isAuthFailure, transportFromRemoteUrl } from '../git/git-error-formatter';
66
import { splitUpstreamRef } from '../git/git-parser';
77
import { samePath } from '../utils/path';
8-
import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount } from '../utils/config';
8+
import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount, readInteractiveRebaseMode } from '../utils/config';
9+
import { buildClassicRebaseCommand } from '../git/classic-rebase';
910
import { buildFullGraph } from '../git/git-graph-builder';
1011
import { compileBranchColorRules, makeBranchColorResolver } from '../git/branch-color-resolver';
1112
import { resolveGraphColors } from '../git/graph-colors';
@@ -213,6 +214,9 @@ export class MainPanel {
213214
const locale = localeSetting === 'auto' ? (vscode.env.language || 'en') : localeSetting;
214215
this.post({ type: 'setLocale', payload: { locale } });
215216
}
217+
if (e.affectsConfiguration('gitGraphPlus.interactiveRebase.mode')) {
218+
this.post({ type: 'setInteractiveRebaseMode', payload: { mode: readInteractiveRebaseMode() } });
219+
}
216220
if (e.affectsConfiguration('gitGraphPlus.defaults')) {
217221
this.post({ type: 'setDefaults', payload: this.readModalDefaults() });
218222
}
@@ -251,6 +255,7 @@ export class MainPanel {
251255
this.post({ type: 'setBadgeBarThickness', payload: { width: this.readBadgeBarWidth() } });
252256
this.post({ type: 'setGraphColors', payload: { colors: this.readGraphColors() } });
253257
this.post({ type: 'setLoadMoreCount', payload: { count: readLoadMoreCommitCount() } });
258+
this.post({ type: 'setInteractiveRebaseMode', payload: { mode: readInteractiveRebaseMode() } });
254259
void this.postCommitLinkRules();
255260

256261
this.panel.webview.onDidReceiveMessage(
@@ -958,6 +963,17 @@ export class MainPanel {
958963
await this.refreshAll();
959964
break;
960965
}
966+
case 'runClassicRebase': {
967+
const command = buildClassicRebaseCommand(message.payload.base);
968+
if (!command) { break; }
969+
const name = 'Git Graph+ Rebase';
970+
const terminal =
971+
vscode.window.terminals.find(t => t.name === name && t.exitStatus === undefined)
972+
?? vscode.window.createTerminal({ name, cwd: this.repoPath });
973+
terminal.show();
974+
terminal.sendText(command, true);
975+
break;
976+
}
961977
case 'getRebaseCommits': {
962978
const rebaseCommits = await this.gitService.getRebaseCommits(message.payload.base);
963979
this.post({ type: 'rebaseCommitsData', payload: { base: message.payload.base, commits: rebaseCommits } });

src/utils/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as vscode from 'vscode';
2+
import { normalizeInteractiveRebaseMode, type InteractiveRebaseMode } from '../git/classic-rebase';
23

34
/**
45
* Reads the `gitGraphPlus.timeout` setting (in seconds) and returns the
@@ -35,3 +36,14 @@ export function readInitialCommitCount(): number {
3536
export function readLoadMoreCommitCount(): number {
3637
return readPositiveIntSetting('loadMoreCommitCount', DEFAULT_LOAD_MORE_COMMIT_COUNT);
3738
}
39+
40+
/**
41+
* Reads `gitGraphPlus.interactiveRebase.mode` — whether interactive rebase
42+
* opens the GUI editor (`ui`, default) or runs classic `git rebase -i` in the
43+
* integrated terminal (`classic`).
44+
*/
45+
export function readInteractiveRebaseMode(): InteractiveRebaseMode {
46+
return normalizeInteractiveRebaseMode(
47+
vscode.workspace.getConfiguration('gitGraphPlus').get<string>('interactiveRebase.mode', 'ui'),
48+
);
49+
}

src/utils/message-bus.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export type WebviewMessage =
4949
| { type: 'continueRebase' }
5050
| { type: 'skipRebase' }
5151
| { type: 'interactiveRebase'; payload: { base: string; todos: Array<{ action: string; hash: string; subject: string; message?: string }>; squashCount?: number } }
52+
| { type: 'runClassicRebase'; payload: { base: string } }
5253
| { type: 'getRebaseCommits'; payload: { base: string } }
5354
| { type: 'reset'; payload: { ref: string; mode: 'soft' | 'mixed' | 'hard' } }
5455
| { type: 'push'; payload: { remote?: string; branch?: string; force?: 'with-lease' | 'force'; setUpstream?: boolean } }
@@ -151,6 +152,7 @@ export type ExtensionMessage =
151152
| { type: 'lfsData'; payload: { files: Array<{ oid: string; path: string }>; locks: Array<{ path: string; owner: string; id: string }> } }
152153
| { type: 'tagDetailsData'; payload: { name: string; hash: string; message?: string; isAnnotated: boolean } }
153154
| { type: 'setLocale'; payload: { locale: string; homeDir?: string } }
155+
| { type: 'setInteractiveRebaseMode'; payload: { mode: 'ui' | 'classic' } }
154156
| { type: 'setDefaults'; payload: ModalDefaults }
155157
| { type: 'setLoadMoreCount'; payload: { count: number } }
156158
| { type: 'setBadgeBarThickness'; payload: { width: number } }

webview-ui/src/App.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ import AmendModal from './components/modals/AmendModal.svelte';
113113
case 'setLoadMoreCount':
114114
uiStore.loadMoreCount = msg.payload.count;
115115
break;
116+
case 'setInteractiveRebaseMode':
117+
uiStore.interactiveRebaseMode = msg.payload.mode;
118+
break;
116119
case 'setGraphColors':
117120
graphColorsStore.set(msg.payload.colors);
118121
break;

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import { resolveDrop, dragRebaseMessage, dragMergeMessage } from '../../lib/utils/dragDrop';
3434
import { computeNavigationTarget, computeScrollTop, computeJumpTarget, isRowOffscreen, type ScrollAlign } from '../../lib/graph-navigation';
3535
import LinkifiedText from '../common/LinkifiedText.svelte';
36+
import { dispatchInteractiveRebase } from '../../lib/interactive-rebase';
3637
3738
3839
/**
@@ -694,6 +695,15 @@
694695
}
695696
}
696697
698+
// Route an interactive-rebase request to the GUI modal or the classic
699+
// terminal flow, per the gitGraphPlus.interactiveRebase.mode setting.
700+
function openInteractiveRebase(base: string) {
701+
dispatchInteractiveRebase(base, uiStore.interactiveRebaseMode, {
702+
openModal: (b) => { interactiveRebaseBase = b; },
703+
runClassic: (b) => vscode.postMessage({ type: 'runClassicRebase', payload: { base: b } }),
704+
});
705+
}
706+
697707
// Entry for "Interactive Rebase selected commits". `chain` is oldest→newest
698708
// (getSquashChain); base = parent of the oldest selected commit.
699709
function startSelectionRebase(chain: Commit[], candidates: string[]) {
@@ -705,7 +715,7 @@
705715
// stash before a no-op switch).
706716
const hasUncommitted = commitStore.commits.some(c => c.hash === 'UNCOMMITTED');
707717
if (!hasUncommitted) {
708-
interactiveRebaseBase = base;
718+
openInteractiveRebase(base);
709719
uiStore.exitMultiSelect();
710720
contextMenuHash = null;
711721
} else {
@@ -726,8 +736,9 @@
726736
const msg = event.data;
727737
if (!pendingRebaseBase) { return; }
728738
if (msg?.type === 'operationComplete' && msg.payload?.operation === 'checkout') {
729-
interactiveRebaseBase = pendingRebaseBase;
739+
const resumeBase = pendingRebaseBase;
730740
pendingRebaseBase = null;
741+
openInteractiveRebase(resumeBase);
731742
uiStore.exitMultiSelect();
732743
contextMenuHash = null;
733744
} else if (msg?.type === 'error') {
@@ -1047,7 +1058,7 @@
10471058
if (!isOnCurrentBranch) {
10481059
branchOps.push({ label: t('graph.rebaseTo', { branch: currentBranch }), action: () => { rebaseTarget = commit.hash; showRebaseModal = true; } });
10491060
}
1050-
branchOps.push({ label: t('graph.interactiveRebaseTo', { branch: currentBranch }), action: () => { interactiveRebaseBase = commit.hash; } });
1061+
branchOps.push({ label: t('graph.interactiveRebaseTo', { branch: currentBranch }), action: () => { openInteractiveRebase(commit.hash); } });
10511062
groups.push(branchOps);
10521063
10531064
// ── Reset ──
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { dispatchInteractiveRebase } from '../interactive-rebase';
3+
4+
describe('dispatchInteractiveRebase', () => {
5+
it('opens the modal in ui mode and does not run classic', () => {
6+
const openModal = vi.fn();
7+
const runClassic = vi.fn();
8+
dispatchInteractiveRebase('abc1234', 'ui', { openModal, runClassic });
9+
expect(openModal).toHaveBeenCalledWith('abc1234');
10+
expect(runClassic).not.toHaveBeenCalled();
11+
});
12+
13+
it('runs classic in classic mode and does not open the modal', () => {
14+
const openModal = vi.fn();
15+
const runClassic = vi.fn();
16+
dispatchInteractiveRebase('abc1234', 'classic', { openModal, runClassic });
17+
expect(runClassic).toHaveBeenCalledWith('abc1234');
18+
expect(openModal).not.toHaveBeenCalled();
19+
});
20+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { InteractiveRebaseMode } from './types';
2+
3+
/**
4+
* Route an interactive-rebase request to the GUI modal or the classic terminal
5+
* flow based on the user's mode. Keeps the branch logic out of the component so
6+
* it is unit-testable.
7+
*/
8+
export function dispatchInteractiveRebase(
9+
base: string,
10+
mode: InteractiveRebaseMode,
11+
handlers: { openModal: (base: string) => void; runClassic: (base: string) => void },
12+
): void {
13+
if (mode === 'classic') { handlers.runClassic(base); }
14+
else { handlers.openModal(base); }
15+
}

0 commit comments

Comments
 (0)