Skip to content

Commit bc3b8ed

Browse files
authored
feat: gs commit pick (experimental) (#536)
Allows cherry-picking commits into the current branch and restacks the upstack. Two modes of usage: gs commit pick <commit> gs commit pick In the first mode, same as 'git cherry-pick <commit>', except it also restacks the upstack branches afterwards. In latter form, presents a visualization of commits in upstack branches to allow selecting one. --from=other can be used to view branches and commits from elsewhere. Limitations: - Does not support cherry-picking merge commits - Does not support cherry-picking commits that would cause a conflict Resolves #372
1 parent 4300917 commit bc3b8ed

35 files changed

+2258
-87
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
kind: Added
2+
body: >-
3+
Add experimental 'gs commit pick' to cherry-pick a commit
4+
and update upstack branches in one go.
5+
Opt into it with `git config --global spice.experiment.commitPick true`.
6+
The command presents an interactive prompt when invoked without any arguments.
7+
![demo of gs commit pick](https://media.githubusercontent.com/media/abhinav/git-spice/refs/heads/main/doc/tapes/20251018-commit-pick.gif)
8+
time: 2024-12-28T19:33:38.719477-06:00

DESIGN.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,24 @@
1313
and add a link from the old entry to the new one.
1414
-->
1515

16+
## 2025-09-01: gs commit pick conflict behavior
17+
18+
`gs commit pick` attempts to match `git cherry-pick` behavior for handling
19+
conflicts and working tree state:
20+
21+
- **Staged changes**:
22+
Cherry-pick fails if any files are staged,
23+
regardless of whether they conflict with the cherry-picked commit
24+
- **Unstaged changes to modified files**:
25+
Cherry-pick fails if unstaged changes
26+
exist in files that the cherry-picked commit also modifies
27+
- **Unstaged changes to unrelated files**:
28+
Cherry-pick succeeds, unstaged changes are preserved
29+
- **Untracked files**:
30+
Cherry-pick succeeds, untracked files are preserved
31+
- **Conflicts**: Cherry-pick fails with conflict markers,
32+
user must run `git cherry-pick --continue` after resolution
33+
1634
## 2025-07-12: Domain-specific handlers for command logic
1735

1836
Multiple commands now share common business logic

commit.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,7 @@ type commitCmd struct {
77

88
Fixup commitFixupCmd `cmd:"" aliases:"f" experiment:"commitFixup" help:"Fixup a commit below the current commit"`
99
// TODO: When fixup is stabilized, add a 'released:' tag here.
10+
11+
Pick commitPickCmd `cmd:"" aliases:"p" experiment:"commitPick" help:"Cherry-pick a commit"`
12+
// TODO: When pick is stabilized, add a 'released:' tag here.
1013
}

commit_pick.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package main
2+
3+
import (
4+
"cmp"
5+
"context"
6+
"errors"
7+
"fmt"
8+
9+
"go.abhg.dev/gs/internal/git"
10+
"go.abhg.dev/gs/internal/handler/cherrypick"
11+
"go.abhg.dev/gs/internal/silog"
12+
"go.abhg.dev/gs/internal/sliceutil"
13+
"go.abhg.dev/gs/internal/spice"
14+
"go.abhg.dev/gs/internal/text"
15+
"go.abhg.dev/gs/internal/ui"
16+
"go.abhg.dev/gs/internal/ui/widget"
17+
)
18+
19+
type commitPickCmd struct {
20+
cherrypick.Options
21+
22+
Commit string `arg:"" optional:"" help:"Commit to cherry-pick"`
23+
From string `placeholder:"NAME" predictor:"trackedBranches" help:"Branch whose upstack commits will be considered."`
24+
}
25+
26+
func (*commitPickCmd) Help() string {
27+
return text.Dedent(`
28+
Apply the changes introduced by a commit to the current branch
29+
and restack the upstack branches.
30+
31+
If a commit is not specified, a prompt will allow picking
32+
from commits of upstack branches of the current branch.
33+
Use the --from option to pick a commit from a different branch
34+
or its upstack.
35+
36+
If it's not possible to cherry-pick the requested commit
37+
without causing a conflict, the command will fail.
38+
If the requested commit is a merge commit,
39+
the command will fail.
40+
41+
This command requires at least Git 2.45.
42+
`)
43+
}
44+
45+
type CherryPickHandler interface {
46+
CherryPickCommit(ctx context.Context, req *cherrypick.Request) error
47+
}
48+
49+
func (cmd *commitPickCmd) Run(
50+
ctx context.Context,
51+
log *silog.Logger,
52+
view ui.View,
53+
repo *git.Repository,
54+
wt *git.Worktree,
55+
svc *spice.Service,
56+
cherryPickHandler CherryPickHandler,
57+
) (err error) {
58+
branch, err := wt.CurrentBranch(ctx)
59+
if err != nil {
60+
if errors.Is(err, git.ErrDetachedHead) {
61+
return errors.New("cannot cherry-pick onto detached HEAD")
62+
}
63+
return fmt.Errorf("determine current branch: %w", err)
64+
}
65+
cmd.From = cmp.Or(cmd.From, branch)
66+
67+
var commit git.Hash
68+
if cmd.Commit == "" {
69+
if !ui.Interactive(view) {
70+
return fmt.Errorf("no commit specified: %w", errNoPrompt)
71+
}
72+
73+
commit, err = cmd.commitPrompt(ctx, log, view, repo, svc, branch)
74+
if err != nil {
75+
return fmt.Errorf("prompt for commit: %w", err)
76+
}
77+
} else {
78+
commit, err = repo.PeelToCommit(ctx, cmd.Commit)
79+
if err != nil {
80+
return fmt.Errorf("peel to commit: %w", err)
81+
}
82+
}
83+
84+
if branch == svc.Trunk() {
85+
if !ui.Interactive(view) {
86+
log.Warnf("You are about to cherry-pick commit %v on the trunk branch (%v).", commit.Short(), svc.Trunk())
87+
} else {
88+
var pickOnTrunk bool
89+
prompt := ui.NewList[bool]().
90+
WithTitle("Do you want to cherry-pick on trunk?").
91+
WithDescription(fmt.Sprintf("You are about to cherry-pick commit %v on the trunk branch (%v). "+
92+
"This is usually not what you want to do.", commit.Short(), svc.Trunk())).
93+
WithItems(
94+
ui.ListItem[bool]{
95+
Title: "Yes",
96+
Description: func(bool) string {
97+
return fmt.Sprintf("Cherry-pick commit %v on trunk", commit.Short())
98+
},
99+
Value: true,
100+
},
101+
ui.ListItem[bool]{
102+
Title: "No",
103+
Description: func(bool) string {
104+
return "Abort the operation"
105+
},
106+
Value: false,
107+
},
108+
).
109+
WithValue(&pickOnTrunk)
110+
111+
if err := ui.Run(view, prompt); err != nil {
112+
return fmt.Errorf("prompt: %w", err)
113+
}
114+
115+
if !pickOnTrunk {
116+
return errors.New("operation aborted")
117+
}
118+
}
119+
}
120+
121+
log.Debugf("Cherry-picking: %v", commit)
122+
return cherryPickHandler.CherryPickCommit(ctx, &cherrypick.Request{
123+
Commit: commit,
124+
Branch: branch,
125+
Options: &cmd.Options,
126+
})
127+
}
128+
129+
func (cmd *commitPickCmd) commitPrompt(
130+
ctx context.Context,
131+
log *silog.Logger,
132+
view ui.View,
133+
repo *git.Repository,
134+
svc *spice.Service,
135+
currentBranch string,
136+
) (git.Hash, error) {
137+
graph, err := svc.BranchGraph(ctx, nil)
138+
if err != nil {
139+
return "", fmt.Errorf("load branch graph: %w", err)
140+
}
141+
142+
var totalCommits int
143+
shortToLongHash := make(map[git.Hash]git.Hash)
144+
var branches []widget.CommitPickBranch
145+
for name := range graph.Upstack(cmd.From) {
146+
if name == graph.Trunk() {
147+
continue
148+
}
149+
150+
// TODO: build commit list for each branch concurrently
151+
b, ok := graph.Lookup(name)
152+
if !ok {
153+
continue // not really possible once past trunk
154+
}
155+
156+
// If doing a --from=$other,
157+
// where $other is downstack from current,
158+
// we don't want to list commits for current branch,
159+
// so add an empty entry for it.
160+
if name == currentBranch {
161+
branches = append(branches, widget.CommitPickBranch{
162+
Branch: name,
163+
Base: b.Base,
164+
})
165+
continue
166+
}
167+
168+
// TODO: parallel fetching?
169+
commits, err := sliceutil.CollectErr(repo.ListCommitsDetails(ctx,
170+
git.CommitRangeFrom(b.Head).
171+
ExcludeFrom(b.BaseHash).
172+
FirstParent()))
173+
if err != nil {
174+
log.Warn("Could not list commits for branch. Skipping.",
175+
"branch", name, "error", err)
176+
continue
177+
}
178+
179+
commitSummaries := make([]widget.CommitSummary, len(commits))
180+
for i, c := range commits {
181+
commitSummaries[i] = widget.CommitSummary{
182+
ShortHash: c.ShortHash,
183+
Subject: c.Subject,
184+
AuthorDate: c.AuthorDate,
185+
}
186+
shortToLongHash[c.ShortHash] = c.Hash
187+
}
188+
189+
branches = append(branches, widget.CommitPickBranch{
190+
Branch: name,
191+
Base: b.Base,
192+
Commits: commitSummaries,
193+
})
194+
totalCommits += len(commitSummaries)
195+
}
196+
197+
if totalCommits == 0 {
198+
log.Warn("Please provide a commit hash to cherry pick from.")
199+
return "", fmt.Errorf("upstack of %v does not have any commits to cherry-pick", cmd.From)
200+
}
201+
202+
msg := fmt.Sprintf("Selected commit will be cherry-picked into %v", currentBranch)
203+
var selected git.Hash
204+
prompt := widget.NewCommitPick().
205+
WithTitle("Pick a commit").
206+
WithDescription(msg).
207+
WithBranches(branches...).
208+
WithValue(&selected)
209+
if err := ui.Run(view, prompt); err != nil {
210+
return "", err
211+
}
212+
213+
if long, ok := shortToLongHash[selected]; ok {
214+
// This will always be true but it doesn't hurt
215+
// to be defensive here.
216+
selected = long
217+
}
218+
return selected, nil
219+
}

doc/includes/cli-reference.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,39 @@ This command requires at least Git 2.45.
10571057

10581058
* `commit`: The commit to fixup. Must be reachable from the HEAD commit.
10591059

1060+
### gs commit pick
1061+
1062+
```
1063+
gs commit (c) pick (p) [<commit>] [flags]
1064+
```
1065+
1066+
<span class="mdx-badge mdx-badge--experiment"><span class="mdx-badge__icon">:material-test-tube:{ title="Experimental" }</span><span class="mdx-badge__text">[commitPick](/cli/experiments.md#commitpick)</span></span>
1067+
1068+
Cherry-pick a commit
1069+
1070+
Apply the changes introduced by a commit to the current branch
1071+
and restack the upstack branches.
1072+
1073+
If a commit is not specified, a prompt will allow picking
1074+
from commits of upstack branches of the current branch.
1075+
Use the --from option to pick a commit from a different branch
1076+
or its upstack.
1077+
1078+
If it's not possible to cherry-pick the requested commit
1079+
without causing a conflict, the command will fail.
1080+
If the requested commit is a merge commit,
1081+
the command will fail.
1082+
1083+
This command requires at least Git 2.45.
1084+
1085+
**Arguments**
1086+
1087+
* `commit`: Commit to cherry-pick
1088+
1089+
**Flags**
1090+
1091+
* `--from=NAME`: Branch whose upstack commits will be considered.
1092+
10601093
## Rebase
10611094

10621095
### gs rebase continue
@@ -1083,7 +1116,7 @@ and use --edit to override it.
10831116

10841117
**Flags**
10851118

1086-
* `--[no-]edit` ([:material-wrench:{ .middle title="spice.rebaseContinue.edit" }](/cli/config.md#spicerebasecontinueedit)): Whehter to open an editor to edit the commit message.
1119+
* `--[no-]edit` ([:material-wrench:{ .middle title="spice.rebaseContinue.edit" }](/cli/config.md#spicerebasecontinueedit)): Whether to open an editor to edit the commit message.
10871120

10881121
**Configuration**: [spice.rebaseContinue.edit](/cli/config.md#spicerebasecontinueedit)
10891122

doc/includes/cli-shorthands.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
| gs ca | [gs commit amend](/cli/reference.md#gs-commit-amend) |
1717
| gs cc | [gs commit create](/cli/reference.md#gs-commit-create) |
1818
| gs cf | [gs commit fixup](/cli/reference.md#gs-commit-fixup) |
19+
| gs cp | [gs commit pick](/cli/reference.md#gs-commit-pick) |
1920
| gs csp | [gs commit split](/cli/reference.md#gs-commit-split) |
2021
| gs dse | [gs downstack edit](/cli/reference.md#gs-downstack-edit) |
2122
| gs dss | [gs downstack submit](/cli/reference.md#gs-downstack-submit) |

doc/src/cli/experiments.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,13 @@ Enables the $$gs commit fixup$$ command.
4444
This command acts like `gs commit amend`,
4545
but is able to amend any commit in the current branch,
4646
or downstack from it -- except those that are already on main.
47+
48+
### commitPick
49+
50+
**Added**: <!-- gs:version unreleased -->
51+
<!-- TODO: **Removed**: -->
52+
53+
Enables the $$gs commit pick$$ command.
54+
This command is a stack-aware variant of `git cherry-pick`.
55+
It will automatically restack upstack branches
56+
after cherry-picking a commit.

doc/tapes/20251018-commit-pick.gif

Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Demo of 'commit pick' for cherry-picking commits from upstack branches.
2+
3+
Output "20251018-commit-pick.gif"
4+
Hide
5+
6+
# Setup
7+
Type "cd $(mktemp -d) && git init && git commit --allow-empty -m 'Initial commit'" Enter
8+
Type "gs repo init && git config spice.experiment.commitPick true" Enter
9+
Type "mkdir unicorn && touch unicorn/{horn,magic} && git add unicorn && gs branch create unicorn -m 'Add unicorns'" Enter
10+
Type "mkdir dragon && touch dragon/{wings,fire} && git add dragon && gs branch create dragon -m 'Deploy dragonfire'" Enter
11+
Type "mkdir phoenix && touch phoenix/{rebirth,flames} && git add phoenix && gs branch create phoenix -m 'Implement phoenix support'" Enter
12+
Type "clear" Enter
13+
Show
14+
15+
Sleep 0.5s
16+
17+
Type "gs ll" Enter Sleep 1s
18+
Type "touch unicorn/sparkle" Enter Sleep 0.5s
19+
Type "git add unicorn/sparkle" Enter Sleep 0.5s
20+
Type "gs cc -m 'Add sparkle ability'" Enter Sleep 1s
21+
Type "gs ll" Enter Sleep 2s
22+
23+
Type "# oops, that belongs to unicorn" Enter Sleep 0.5s
24+
Type "gs branch checkout unicorn" Enter Sleep 2s
25+
26+
Type "gs commit pick" Enter Sleep 2s
27+
Down Sleep 0.5s Down Sleep 0.5s
28+
Up Sleep 0.5s Up Sleep 0.5s
29+
Enter Sleep 2s
30+
31+
Type "gs ll" Enter Sleep 3s

internal/git/commit.go

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,9 @@ type CommitTreeRequest struct {
5757
Parents []Hash
5858

5959
// Author and Committer sign the commit.
60-
// If Committer is nil, Author is used for both.
6160
//
62-
// If both are nil, the current user is used.
63-
// Note that current user may not be available in all contexts.
64-
// Prefer to set Author and Committer explicitly.
61+
// The current user is used for anything that is nil.
62+
// The current user may not be available in all contexts.
6563
Author, Committer *Signature
6664

6765
// GPGSign indicates whether to GPG sign the commit.
@@ -76,9 +74,6 @@ func (r *Repository) CommitTree(ctx context.Context, req CommitTreeRequest) (Has
7674
if req.Message == "" {
7775
return ZeroHash, errors.New("empty commit message")
7876
}
79-
if req.Committer == nil {
80-
req.Committer = req.Author
81-
}
8277

8378
args := make([]string, 0, 2+2*len(req.Parents))
8479
args = append(args, "commit-tree")

0 commit comments

Comments
 (0)