Skip to content

Commit ab09b1f

Browse files
committed
Add stash and cherry-pick support
Adds stash create/list/apply/pop/drop to @sproutgit/git with a collapsible stash panel in the staging view, and cherry-pick (with automatic abort + clear error on conflict, since conflict resolution UI is a separate issue) exposed via the commit graph's context menu. Also fixes a pre-existing bug in git status parsing: simple-git's `trimmed` client option trims the whole raw output blob, which silently ate the leading space of `git status --porcelain` whenever the first line was a plain unstaged modification (` M path`), shifting the parsed file path by one character. Disabled the client-level option and switched status/diff parsing to trailing-only trims. Closes #97
1 parent c174172 commit ab09b1f

19 files changed

Lines changed: 781 additions & 7 deletions

File tree

app/src/main/ipc/git.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
} from '@sproutgit/git/staging';
1616
import { fetchWorktree, pullWorktree, pushWorktreeBranch, getWorktreePushStatus } from '@sproutgit/git/remote';
1717
import { getDiffFiles, getDiffContent, getWorkingDiff } from '@sproutgit/git/diff';
18+
import { createStash, listStashes, applyStash, popStash, dropStash } from '@sproutgit/git/stash';
19+
import { cherryPickCommit } from '@sproutgit/git/cherry-pick';
1820
import { handle } from './handle.js';
1921
import { createWorktreeWithHooks, removeWorktreeWithHooks } from '../worktree-lifecycle.js';
2022

@@ -184,4 +186,36 @@ export function registerGitHandlers(configDb: ConfigDb): void {
184186
const result = await getWorkingDiff(args.worktreePath, args.file);
185187
return result.diff;
186188
});
189+
190+
// ── stash ─────────────────────────────────────────────────────────────────
191+
handle(IPC.GIT_STASH_CREATE, async (_e, args: { worktreePath: string; message?: string }) => {
192+
assertWorkingTreePath(args.worktreePath);
193+
return createStash(args.worktreePath, args.message);
194+
});
195+
196+
handle(IPC.GIT_STASH_LIST, async (_e, worktreePath: string) => {
197+
assertWorkingTreePath(worktreePath);
198+
return listStashes(worktreePath);
199+
});
200+
201+
handle(IPC.GIT_STASH_APPLY, async (_e, args: { worktreePath: string; ref: string }) => {
202+
assertWorkingTreePath(args.worktreePath);
203+
return applyStash(args.worktreePath, args.ref);
204+
});
205+
206+
handle(IPC.GIT_STASH_POP, async (_e, args: { worktreePath: string; ref: string }) => {
207+
assertWorkingTreePath(args.worktreePath);
208+
return popStash(args.worktreePath, args.ref);
209+
});
210+
211+
handle(IPC.GIT_STASH_DROP, async (_e, args: { worktreePath: string; ref: string }) => {
212+
assertWorkingTreePath(args.worktreePath);
213+
return dropStash(args.worktreePath, args.ref);
214+
});
215+
216+
// ── cherry-pick ───────────────────────────────────────────────────────────
217+
handle(IPC.GIT_CHERRY_PICK, async (_e, args: { worktreePath: string; sha: string }) => {
218+
assertWorkingTreePath(args.worktreePath);
219+
return cherryPickCommit(args.worktreePath, args.sha);
220+
});
187221
}

app/src/preload/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
DiffFileEntry,
1111
WorktreePushStatus,
1212
FetchSummary,
13+
StashListResult,
1314
DeviceCodeResponse,
1415
GitHubPollResult,
1516
GitHubAuthStatus,
@@ -174,6 +175,26 @@ const api = {
174175
getWorkingDiff: (worktreePath: string, file?: string): Promise<string> =>
175176
invoke(IPC.GIT_WORKING_DIFF, file ? { worktreePath, file } : { worktreePath }),
176177

178+
// ── Stash ────────────────────────────────────────────────────────────────
179+
createStash: (worktreePath: string, message?: string): Promise<void> =>
180+
invoke(IPC.GIT_STASH_CREATE, message ? { worktreePath, message } : { worktreePath }),
181+
182+
listStashes: (worktreePath: string): Promise<StashListResult> =>
183+
invoke(IPC.GIT_STASH_LIST, worktreePath),
184+
185+
applyStash: (worktreePath: string, ref: string): Promise<void> =>
186+
invoke(IPC.GIT_STASH_APPLY, { worktreePath, ref }),
187+
188+
popStash: (worktreePath: string, ref: string): Promise<void> =>
189+
invoke(IPC.GIT_STASH_POP, { worktreePath, ref }),
190+
191+
dropStash: (worktreePath: string, ref: string): Promise<void> =>
192+
invoke(IPC.GIT_STASH_DROP, { worktreePath, ref }),
193+
194+
// ── Cherry-pick ──────────────────────────────────────────────────────────
195+
cherryPick: (worktreePath: string, sha: string): Promise<void> =>
196+
invoke(IPC.GIT_CHERRY_PICK, { worktreePath, sha }),
197+
177198
// ── Terminal ──────────────────────────────────────────────────────────────
178199
createTerminal: (args: {
179200
cwd: string;

app/src/renderer/routes/workspace.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,18 @@ function WorkspaceInner() {
14081408
.catch((err: unknown) => toast(String(err), 'error'));
14091409
}
14101410
}}
1411+
onCherryPick={sha => {
1412+
if (activeWorktree) {
1413+
void api.cherryPick(activeWorktree.path, sha)
1414+
.then(() => {
1415+
toast('Cherry-pick applied', 'success');
1416+
void qc.invalidateQueries({ queryKey: qk.commits(gitRepoPath) });
1417+
void qc.invalidateQueries({ queryKey: qk.refs(gitRepoPath) });
1418+
if (activeWorktree) void qc.invalidateQueries({ queryKey: qk.worktreeStatus(activeWorktree.path) });
1419+
})
1420+
.catch((err: unknown) => toast(String(err), 'error'));
1421+
}
1422+
}}
14111423
issueTrackerPatterns={issueTrackerPatterns}
14121424
/>
14131425
</div>
@@ -1451,6 +1463,11 @@ function WorkspaceInner() {
14511463
const settings = await loadCommitMessageGeneratorSettings();
14521464
return api.generateCommitMessage({ workspacePath, worktreePath: p, settings });
14531465
}}
1466+
listStashes={p => api.listStashes(p)}
1467+
createStash={(p, message) => api.createStash(p, message)}
1468+
applyStash={(p, ref) => api.applyStash(p, ref)}
1469+
popStash={(p, ref) => api.popStash(p, ref)}
1470+
dropStash={(p, ref) => api.dropStash(p, ref)}
14541471
onCommit={() => {
14551472
toast('Committed', 'success');
14561473
setStagingRefresh(n => n + 1);
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { gotoHash, createTestRepo, closeAndCleanup, monitorErrors, waitForToast } from '../helpers.js';
2+
import { execSync } from 'child_process';
3+
import { writeFileSync } from 'fs';
4+
import { join } from 'path';
5+
6+
describe('stash workflow', () => {
7+
let testRepo: string;
8+
9+
beforeEach(() => {
10+
testRepo = createTestRepo('stash');
11+
});
12+
13+
afterEach(async () => {
14+
await closeAndCleanup(testRepo);
15+
});
16+
17+
it('stashes uncommitted changes and pops them back', async () => {
18+
const assertNoErrors = monitorErrors();
19+
20+
const defaultBranch = execSync('git symbolic-ref --short HEAD', { cwd: testRepo }).toString().trim();
21+
22+
await gotoHash(`/workspace?path=${encodeURIComponent(testRepo)}`);
23+
await expect($(`[data-testid="worktree-item"][data-branch="${defaultBranch}"]`)).toBeDisplayed();
24+
25+
const worktreePath = join(testRepo, '.sproutgit', 'worktrees', defaultBranch);
26+
writeFileSync(join(worktreePath, 'README.md'), '# test\nlocal change\n');
27+
28+
// Switch to the staging tab.
29+
await $('//*[contains(@class,"sg-tab") and contains(.,"Changes")]').click();
30+
await expect($('//*[contains(@class,"sg-file-row") and contains(.,"README.md")]')).toBeDisplayed();
31+
32+
// Expand the stash panel and create a stash.
33+
await $('[data-testid="btn-toggle-stash-panel"]').click();
34+
await $('[data-testid="input-stash-message"]').setValue('wip changes');
35+
await $('[data-testid="btn-create-stash"]').click();
36+
await waitForToast('success');
37+
38+
// The working tree is now clean and one stash entry exists.
39+
await expect($('//*[contains(@class,"sg-file-row") and contains(.,"README.md")]')).not.toBeDisplayed();
40+
await expect($('[data-testid="stash-row"]')).toBeDisplayed();
41+
expect(await $('[data-testid="stash-row"]').getText()).toContain('wip changes');
42+
43+
// Pop it back.
44+
await $('[data-testid="btn-pop-stash"]').click();
45+
await waitForToast('success');
46+
47+
await expect($('[data-testid="stash-row"]')).not.toBeDisplayed();
48+
await expect($('//*[contains(@class,"sg-file-row") and contains(.,"README.md")]')).toBeDisplayed();
49+
50+
await assertNoErrors();
51+
});
52+
});
53+
54+
describe('cherry-pick workflow', () => {
55+
let testRepo: string;
56+
57+
beforeEach(() => {
58+
testRepo = createTestRepo('cherry-pick');
59+
});
60+
61+
afterEach(async () => {
62+
await closeAndCleanup(testRepo);
63+
});
64+
65+
it('cherry-picks a commit from another branch onto the active branch via the graph context menu', async () => {
66+
const assertNoErrors = monitorErrors();
67+
68+
const defaultBranch = execSync('git symbolic-ref --short HEAD', { cwd: testRepo }).toString().trim();
69+
70+
// Create a second branch with a commit to cherry-pick, then return to default.
71+
execSync('git checkout -b feature', { cwd: testRepo });
72+
writeFileSync(join(testRepo, 'feature.txt'), 'feature content\n');
73+
execSync('git add feature.txt', { cwd: testRepo });
74+
execSync('git commit -m "add feature file"', { cwd: testRepo });
75+
execSync(`git checkout ${defaultBranch}`, { cwd: testRepo });
76+
77+
await gotoHash(`/workspace?path=${encodeURIComponent(testRepo)}`);
78+
await expect($(`[data-testid="worktree-item"][data-branch="${defaultBranch}"]`)).toBeDisplayed();
79+
80+
// Right-click the "add feature file" commit row in the graph.
81+
// CDP pointer actions don't reliably generate a contextmenu event in
82+
// Electron, so dispatch it directly (same approach as the worktree
83+
// sidebar's context-menu test).
84+
await expect($('//*[contains(@class,"commit-row") and contains(.,"add feature file")]')).toBeDisplayed();
85+
await browser.execute(() => {
86+
const rows = Array.from(document.querySelectorAll('.commit-row'));
87+
const el = rows.find(r => r.textContent?.includes('add feature file')) as HTMLElement | undefined;
88+
if (!el) throw new Error('commit row for "add feature file" not found');
89+
const rect = el.getBoundingClientRect();
90+
el.dispatchEvent(new MouseEvent('contextmenu', {
91+
bubbles: true,
92+
cancelable: true,
93+
button: 2,
94+
clientX: rect.left + rect.width / 2,
95+
clientY: rect.top + rect.height / 2,
96+
}));
97+
});
98+
99+
await expect($('[data-testid="context-menu"]')).toBeDisplayed();
100+
await $('[data-testid="context-menu"]')
101+
.$('.//button[contains(.,"Cherry-pick")]')
102+
.click();
103+
104+
await waitForToast('success');
105+
106+
// The default branch's worktree should now contain feature.txt.
107+
const worktreePath = join(testRepo, '.sproutgit', 'worktrees', defaultBranch);
108+
const log = execSync('git log --format=%s -1', { cwd: worktreePath }).toString().trim();
109+
expect(log).toBe('add feature file');
110+
111+
await assertNoErrors();
112+
});
113+
});

packages/git/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
"./remote": "./src/remote.ts",
1414
"./config": "./src/config.ts",
1515
"./init": "./src/init.ts",
16-
"./diff": "./src/diff.ts"
16+
"./diff": "./src/diff.ts",
17+
"./stash": "./src/stash.ts",
18+
"./cherry-pick": "./src/cherry-pick.ts"
1719
},
1820
"scripts": {
1921
"typecheck": "tsgo --noEmit",
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { execSync } from 'node:child_process';
3+
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
4+
import { join } from 'node:path';
5+
import { tmpdir } from 'node:os';
6+
import { cherryPickCommit, CherryPickConflictError } from '../cherry-pick.js';
7+
8+
function initTestRepo(): string {
9+
const dir = realpathSync.native(mkdtempSync(join(tmpdir(), 'sg-cherry-pick-test-')));
10+
11+
execSync('git init -b main', { cwd: dir, stdio: 'ignore' });
12+
execSync('git config user.email "test@sproutgit.test"', { cwd: dir, stdio: 'ignore' });
13+
execSync('git config user.name "SproutGit Test"', { cwd: dir, stdio: 'ignore' });
14+
15+
writeFileSync(join(dir, 'README.md'), '# Test Repo\n');
16+
execSync('git add .', { cwd: dir, stdio: 'ignore' });
17+
execSync('git commit -m "initial commit"', { cwd: dir, stdio: 'ignore' });
18+
19+
return dir;
20+
}
21+
22+
function commitHash(repoPath: string): string {
23+
return execSync('git rev-parse HEAD', { cwd: repoPath }).toString().trim();
24+
}
25+
26+
describe('cherryPickCommit', () => {
27+
let repoPath: string;
28+
29+
beforeEach(() => {
30+
repoPath = initTestRepo();
31+
});
32+
33+
afterEach(() => {
34+
rmSync(repoPath, { recursive: true, force: true });
35+
});
36+
37+
it('applies a clean cherry-pick from a branch onto main', async () => {
38+
execSync('git checkout -b feature', { cwd: repoPath, stdio: 'ignore' });
39+
writeFileSync(join(repoPath, 'feature.txt'), 'feature content\n');
40+
execSync('git add .', { cwd: repoPath, stdio: 'ignore' });
41+
execSync('git commit -m "add feature file"', { cwd: repoPath, stdio: 'ignore' });
42+
const featureSha = commitHash(repoPath);
43+
44+
execSync('git checkout main', { cwd: repoPath, stdio: 'ignore' });
45+
46+
await cherryPickCommit(repoPath, featureSha);
47+
48+
const log = execSync('git log --format=%s -1', { cwd: repoPath }).toString().trim();
49+
expect(log).toBe('add feature file');
50+
51+
const status = execSync('git status --porcelain', { cwd: repoPath }).toString();
52+
expect(status.trim()).toBe('');
53+
});
54+
55+
it('aborts and throws CherryPickConflictError when the cherry-pick conflicts', async () => {
56+
execSync('git checkout -b feature', { cwd: repoPath, stdio: 'ignore' });
57+
writeFileSync(join(repoPath, 'README.md'), '# Test Repo\nfeature change\n');
58+
execSync('git add .', { cwd: repoPath, stdio: 'ignore' });
59+
execSync('git commit -m "conflicting change"', { cwd: repoPath, stdio: 'ignore' });
60+
const featureSha = commitHash(repoPath);
61+
62+
execSync('git checkout main', { cwd: repoPath, stdio: 'ignore' });
63+
writeFileSync(join(repoPath, 'README.md'), '# Test Repo\nmain change\n');
64+
execSync('git add .', { cwd: repoPath, stdio: 'ignore' });
65+
execSync('git commit -m "main change"', { cwd: repoPath, stdio: 'ignore' });
66+
67+
await expect(cherryPickCommit(repoPath, featureSha)).rejects.toThrow(CherryPickConflictError);
68+
69+
// The cherry-pick must be fully rolled back — no in-progress state, no
70+
// conflict markers left behind, working tree clean.
71+
const status = execSync('git status --porcelain', { cwd: repoPath }).toString();
72+
expect(status.trim()).toBe('');
73+
74+
const content = execSync('cat README.md', { cwd: repoPath }).toString();
75+
expect(content).toBe('# Test Repo\nmain change\n');
76+
77+
const log = execSync('git log --format=%s -1', { cwd: repoPath }).toString().trim();
78+
expect(log).toBe('main change');
79+
});
80+
81+
it('propagates non-conflict errors (e.g. an unknown sha) without swallowing them', async () => {
82+
await expect(cherryPickCommit(repoPath, '0'.repeat(40))).rejects.toSatisfy(
83+
(err: unknown) => err instanceof Error && !(err instanceof CherryPickConflictError)
84+
);
85+
});
86+
});

0 commit comments

Comments
 (0)