Skip to content

Commit 4bd0ea7

Browse files
committed
Address Copilot review feedback on stash/cherry-pick
- Stash now includes untracked files (git stash push -u) so an untracked-only change doesn't silently no-op while the UI reports success. - CherryPickConflictError carries an abortFailed flag and gives an accurate message (rather than claiming a clean rollback) when `cherry-pick --abort` itself fails. - StashPanel: block Enter-to-create while a stash op is pending; disable every stash row's actions while any stash mutation is in flight, not just the one that was clicked (apply/pop/drop aren't safely concurrent since they share the same working tree/index); show an explicit error state instead of rendering "No stashes" when the list query fails. The other flagged lines (packages/git/src/diff.ts, packages/git/src/staging.ts trailing-trim) were already superseded by main's independent fix for the same status-parsing bug, adopted during the merge — no longer applicable.
1 parent 95962c3 commit 4bd0ea7

5 files changed

Lines changed: 63 additions & 18 deletions

File tree

packages/git/src/__tests__/cherry-pick.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ describe('cherryPickCommit', () => {
6464
execSync('git add .', { cwd: repoPath, stdio: 'ignore' });
6565
execSync('git commit -m "main change"', { cwd: repoPath, stdio: 'ignore' });
6666

67-
await expect(cherryPickCommit(repoPath, featureSha)).rejects.toThrow(CherryPickConflictError);
67+
const error = await cherryPickCommit(repoPath, featureSha).catch((e: unknown) => e);
68+
expect(error).toBeInstanceOf(CherryPickConflictError);
69+
expect((error as CherryPickConflictError).abortFailed).toBe(false);
70+
expect((error as CherryPickConflictError).message).toContain('was rolled back');
6871

6972
// The cherry-pick must be fully rolled back — no in-progress state, no
7073
// conflict markers left behind, working tree clean.

packages/git/src/__tests__/stash.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,22 @@ describe('stash', () => {
103103
expect(content).not.toContain('dropped');
104104
});
105105

106+
it('includes untracked files in the stash (not just tracked modifications)', async () => {
107+
writeFileSync(join(repoPath, 'untracked.txt'), 'new file\n');
108+
109+
await createStash(repoPath, 'untracked file');
110+
111+
const status = execSync('git status --porcelain', { cwd: repoPath }).toString();
112+
expect(status.trim()).toBe('');
113+
114+
const result = await listStashes(repoPath);
115+
expect(result.stashes).toHaveLength(1);
116+
117+
await popStash(repoPath, 'stash@{0}');
118+
const content = execSync('cat untracked.txt', { cwd: repoPath }).toString();
119+
expect(content).toBe('new file\n');
120+
});
121+
106122
it('lists multiple stashes newest first, matching stash@{N} refs', async () => {
107123
writeFileSync(join(repoPath, 'README.md'), '# Test Repo\nfirst\n');
108124
await createStash(repoPath, 'first stash');

packages/git/src/cherry-pick.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import { gitForPath } from './client.js';
22

3-
/** Thrown when a cherry-pick stops due to conflicts and is rolled back. */
3+
/**
4+
* Thrown when a cherry-pick stops due to conflicts. `abortFailed` reflects
5+
* whether `git cherry-pick --abort` itself succeeded — if it didn't, the
6+
* worktree may still be mid-cherry-pick and needs manual attention, so the
7+
* message must not claim a clean rollback happened.
8+
*/
49
export class CherryPickConflictError extends Error {
5-
constructor(public readonly sha: string) {
6-
super(`Cherry-pick of ${sha.slice(0, 7)} stopped due to conflicts and was rolled back.`);
10+
constructor(public readonly sha: string, public readonly abortFailed = false) {
11+
super(
12+
abortFailed
13+
? `Cherry-pick of ${sha.slice(0, 7)} stopped due to conflicts, and the automatic rollback failed. Run "git cherry-pick --abort" manually to restore a clean state.`
14+
: `Cherry-pick of ${sha.slice(0, 7)} stopped due to conflicts and was rolled back.`
15+
);
716
this.name = 'CherryPickConflictError';
817
}
918
}
@@ -24,14 +33,13 @@ export async function cherryPickCommit(worktreePath: string, sha: string): Promi
2433
} catch (error) {
2534
if (!isCherryPickConflict(error)) throw error;
2635

36+
let abortFailed = false;
2737
try {
2838
await git.raw(['cherry-pick', '--abort']);
2939
} catch {
30-
// Best-effort: if abort itself fails, surface the original conflict
31-
// error below rather than the abort failure — the worktree may need
32-
// manual attention either way, but the conflict is the actionable part.
40+
abortFailed = true;
3341
}
34-
throw new CherryPickConflictError(sha);
42+
throw new CherryPickConflictError(sha, abortFailed);
3543
}
3644
}
3745

packages/git/src/stash.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@ import { gitForPath } from './client.js';
55
* Creates a stash of the current working-tree + index state.
66
* Uses `stash push` (not the deprecated `stash save`) so an optional
77
* message can be attached without relying on positional-arg parsing.
8+
* Includes untracked files (`-u`) — the UI's "changes to stash" check counts
9+
* untracked files too, so without `-u` a stash of an untracked-only change
10+
* would silently no-op ("No local changes to save") while still reporting
11+
* success.
812
*/
913
export async function createStash(worktreePath: string, message?: string): Promise<void> {
1014
const git = gitForPath(worktreePath);
11-
await git.stash(message ? ['push', '-m', message] : ['push']);
15+
const args = message ? ['push', '-u', '-m', message] : ['push', '-u'];
16+
await git.stash(args);
1217
}
1318

1419
/**

packages/ui/src/components/StashPanel.tsx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export function StashPanel({
4646
const [message, setMessage] = useState('');
4747
const stashKey = ['stashList', worktreePath, refreshSignal] as const;
4848

49-
const { data: stashes = [] } = useQuery({
49+
const { data: stashes = [], isError: stashesErrored } = useQuery({
5050
queryKey: stashKey,
5151
queryFn: async () => (await listStashes(worktreePath)).stashes,
5252
staleTime: 0,
@@ -97,6 +97,14 @@ export function StashPanel({
9797
onError: (err) => onToast?.(`Failed to drop stash: ${String(err)}`, 'error'),
9898
});
9999

100+
// Stash operations aren't safely parallelizable — apply/pop/drop all read
101+
// and rewrite the same working tree + index, so running two at once (even
102+
// against different stash entries) can race. Block every row's actions
103+
// while any of the four mutations is in flight, not just the one the user
104+
// clicked.
105+
const anyStashOpPending =
106+
createMutation.isPending || applyMutation.isPending || popMutation.isPending || dropMutation.isPending;
107+
100108
const sectionHdr = 'flex items-center justify-between px-[10px] py-[5px] text-[11px] font-semibold text-(--sg-text-faint) uppercase tracking-[0.04em] shrink-0 bg-(--sg-surface)';
101109
const iconBtn = 'inline-flex items-center justify-center p-[3px] bg-transparent border-none cursor-pointer text-(--sg-text-faint) rounded-[4px] transition-colors hover:text-(--sg-text) hover:bg-(--sg-surface-raised) disabled:opacity-40 disabled:cursor-not-allowed';
102110

@@ -124,13 +132,13 @@ export function StashPanel({
124132
placeholder="Stash message (optional)"
125133
value={message}
126134
onChange={e => setMessage(e.target.value)}
127-
onKeyDown={e => { if (e.key === 'Enter' && hasChangesToStash) createMutation.mutate(message); }}
135+
onKeyDown={e => { if (e.key === 'Enter' && hasChangesToStash && !anyStashOpPending) createMutation.mutate(message); }}
128136
data-testid="input-stash-message"
129137
/>
130138
<button
131139
className="sg-btn--primary inline-flex shrink-0 items-center gap-1 rounded bg-(--sg-primary) px-2 py-1 text-[11px] font-medium text-white hover:bg-(--sg-primary-hover) disabled:cursor-not-allowed disabled:opacity-40 border-none cursor-pointer transition-colors"
132140
onClick={() => createMutation.mutate(message)}
133-
disabled={!hasChangesToStash || createMutation.isPending}
141+
disabled={!hasChangesToStash || anyStashOpPending}
134142
title={hasChangesToStash ? 'Stash current changes' : 'No changes to stash'}
135143
data-testid="btn-create-stash"
136144
>
@@ -139,9 +147,11 @@ export function StashPanel({
139147
</div>
140148

141149
<div className="flex flex-col gap-0.5 max-h-40 overflow-y-auto">
142-
{stashes.length === 0 && (
150+
{stashesErrored ? (
151+
<p className="px-0.5 py-1 text-[11px] text-(--sg-danger)">Failed to load stashes.</p>
152+
) : stashes.length === 0 ? (
143153
<p className="px-0.5 py-1 text-[11px] text-(--sg-text-faint) italic">No stashes</p>
144-
)}
154+
) : null}
145155
{stashes.map(s => (
146156
<StashRow
147157
key={s.ref}
@@ -150,6 +160,7 @@ export function StashPanel({
150160
onApply={() => applyMutation.mutate(s.ref)}
151161
onPop={() => popMutation.mutate(s.ref)}
152162
onDrop={() => dropMutation.mutate(s.ref)}
163+
disabled={anyStashOpPending}
153164
applying={applyMutation.isPending && applyMutation.variables === s.ref}
154165
popping={popMutation.isPending && popMutation.variables === s.ref}
155166
dropping={dropMutation.isPending && dropMutation.variables === s.ref}
@@ -168,6 +179,7 @@ function StashRow({
168179
onApply,
169180
onPop,
170181
onDrop,
182+
disabled,
171183
applying,
172184
popping,
173185
dropping,
@@ -177,11 +189,12 @@ function StashRow({
177189
onApply: () => void;
178190
onPop: () => void;
179191
onDrop: () => void;
192+
/** True whenever *any* stash mutation (this row's or another's) is in flight. */
193+
disabled: boolean;
180194
applying: boolean;
181195
popping: boolean;
182196
dropping: boolean;
183197
}) {
184-
const busy = applying || popping || dropping;
185198
return (
186199
<div
187200
className="sg-stash-row flex items-center gap-1 text-[11px] py-1"
@@ -191,13 +204,13 @@ function StashRow({
191204
<span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap" title={entry.message}>
192205
{entry.ref}{entry.message}
193206
</span>
194-
<button className={iconBtnClass} onClick={onApply} disabled={busy} title="Apply (keep stash)" data-testid="btn-apply-stash">
207+
<button className={iconBtnClass} onClick={onApply} disabled={disabled} title="Apply (keep stash)" data-testid="btn-apply-stash">
195208
{applying ? <Spinner size="sm" /> : <ArchiveRestore size={12} />}
196209
</button>
197-
<button className={iconBtnClass} onClick={onPop} disabled={busy} title="Pop (apply and remove)" data-testid="btn-pop-stash">
210+
<button className={iconBtnClass} onClick={onPop} disabled={disabled} title="Pop (apply and remove)" data-testid="btn-pop-stash">
198211
{popping ? <Spinner size="sm" /> : <Check size={12} />}
199212
</button>
200-
<button className={iconBtnClass} onClick={onDrop} disabled={busy} title="Drop (delete without applying)" data-testid="btn-drop-stash">
213+
<button className={iconBtnClass} onClick={onDrop} disabled={disabled} title="Drop (delete without applying)" data-testid="btn-drop-stash">
201214
{dropping ? <Spinner size="sm" /> : <Trash2 size={12} />}
202215
</button>
203216
</div>

0 commit comments

Comments
 (0)