Skip to content

Commit

Permalink
WIP: new command: gs commit pick
Browse files Browse the repository at this point in the history
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, not much different from 'git cherry-pick'.
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.

TODO:

- [ ] How to --continue/--abort/--skip on conflict or empty commit
- [ ] Repository.CherryPick operation
- [ ] Doc website update

Resolves #372
  • Loading branch information
abhinav committed Dec 29, 2024
1 parent b33a07a commit bcf10c9
Show file tree
Hide file tree
Showing 10 changed files with 877 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .changes/unreleased/Added-20241228-193338.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: Added
body: >-
New 'commit pick' command allows cherry-picking commits
and updating the upstack branches, all with one command.
Run this without any arguments to pick a commit interactively.
time: 2024-12-28T19:33:38.719477-06:00
1 change: 1 addition & 0 deletions commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ package main
type commitCmd struct {
Create commitCreateCmd `cmd:"" aliases:"c" help:"Create a new commit"`
Amend commitAmendCmd `cmd:"" aliases:"a" help:"Amend the current commit"`
Pick commitPickCmd `cmd:"" aliases:"p" help:"Cherry-pick a commit"`
Split commitSplitCmd `cmd:"" aliases:"sp" help:"Split the current commit"`
}
179 changes: 179 additions & 0 deletions commit_pick.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package main

import (
"cmp"
"context"
"fmt"

"github.com/charmbracelet/log"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
"go.abhg.dev/gs/internal/ui"
"go.abhg.dev/gs/internal/ui/widget"
)

type commitPickCmd struct {
Commit string `arg:"" optional:"" help:"Commit to cherry-pick"`
// TODO: Support multiple commits similarly to git cherry-pick.

Edit bool `short:"e" help:"Edit the commit message before committing."`
From string `placeholder:"NAME" predictor:"trackedBranches" help:"Branch whose upstack commits will be considered."`
}

func (*commitPickCmd) Help() string {
return text.Dedent(`
Apply the changes introduced by a commit to the current branch
and restack the upstack branches.
If a commit is not specified, a prompt will allow picking
from commits of upstack branches of the current branch.
Use the --from option to pick a commit from a different branch
or its upstack.
`)
}

func (cmd *commitPickCmd) Run(
ctx context.Context,
log *log.Logger,
view ui.View,
repo *git.Repository,
store *state.Store,
svc *spice.Service,
) (err error) {
var commit git.Hash
if cmd.Commit == "" {
if !ui.Interactive(view) {
return fmt.Errorf("no commit specified: %w", errNoPrompt)
}

commit, err = cmd.commitPrompt(ctx, log, view, repo, store, svc)
if err != nil {
return fmt.Errorf("prompt for commit: %w", err)
}
} else {
commit, err = repo.PeelToCommit(ctx, cmd.Commit)
if err != nil {
return fmt.Errorf("peel to commit: %w", err)
}
}

log.Debugf("Cherry-picking: %v", commit)
err = repo.CherryPick(ctx, git.CherryPickRequest{
Commits: []git.Hash{commit},
Edit: cmd.Edit,
// If you selected an empty commit,
// you probably want to retain that.
// This still won't allow for no-op cherry-picks.
AllowEmpty: true,
})
if err != nil {
return fmt.Errorf("cherry-pick: %w", err)
}

// TODO: cherry-pick the commit
// TODO: handle --continue/--abort
// TODO: upstack restack
return nil
}

func (cmd *commitPickCmd) commitPrompt(
ctx context.Context,
log *log.Logger,
view ui.View,
repo *git.Repository,
store *state.Store,
svc *spice.Service,
) (git.Hash, error) {
currentBranch, err := repo.CurrentBranch(ctx)
if err != nil {
// TODO: allow for cherry-pick onto non-branch HEAD.
return "", fmt.Errorf("determine current branch: %w", err)
}
cmd.From = cmp.Or(cmd.From, currentBranch)

upstack, err := svc.ListUpstack(ctx, cmd.From)
if err != nil {
return "", fmt.Errorf("list upstack branches: %w", err)
}

var totalCommits int
branches := make([]widget.CommitPickBranch, 0, len(upstack))
shortToLongHash := make(map[git.Hash]git.Hash)
for _, name := range upstack {
if name == store.Trunk() {
continue
}

// TODO: build commit list for each branch concurrently
b, err := svc.LookupBranch(ctx, name)
if err != nil {
log.Warn("Could not look up branch. Skipping.",
"branch", name, "error", err)
continue
}

// If doing a --from=$other,
// where $other is downstack from current,
// we don't want to list commits for current branch,
// so add an empty entry for it.
if name == currentBranch {
// Don't list the current branch's commits.
branches = append(branches, widget.CommitPickBranch{
Branch: name,
Base: b.Base,
})
continue
}

commits, err := repo.ListCommitsDetails(ctx,
git.CommitRangeFrom(b.Head).
ExcludeFrom(b.BaseHash).
FirstParent())
if err != nil {
log.Warn("Could not list commits for branch. Skipping.",
"branch", name, "error", err)
}

commitSummaries := make([]widget.CommitSummary, len(commits))
for i, c := range commits {
commitSummaries[i] = widget.CommitSummary{
ShortHash: c.ShortHash,
Subject: c.Subject,
AuthorDate: c.AuthorDate,
}
shortToLongHash[c.ShortHash] = c.Hash
}

branches = append(branches, widget.CommitPickBranch{
Branch: name,
Base: b.Base,
Commits: commitSummaries,
})
totalCommits += len(commitSummaries)
}

if totalCommits == 0 {
log.Warn("Please provide a commit hash to cherry pick from.")
return "", fmt.Errorf("upstack of %v does not have any commits to cherry-pick", cmd.From)
}

msg := fmt.Sprintf("Selected commit will be cherry-picked into %v", currentBranch)
var selected git.Hash
prompt := widget.NewCommitPick().
WithTitle("Pick a commit").
WithDescription(msg).
WithBranches(branches...).
WithValue(&selected)
if err := ui.Run(view, prompt); err != nil {
return "", err
}

if long, ok := shortToLongHash[selected]; ok {
// This will always be true but it doesn't hurt
// to be defensive here.
selected = long
}
return selected, nil
}
75 changes: 75 additions & 0 deletions internal/git/cherry_pick.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package git

import (
"context"
"fmt"
)

// CherryPickInterruptedError indicates that a cherry-pick
// could not be applied successfully because of conflicts
// or because it would introduce an empty change.
//
// Once these conflicts are resolved, the cherry-pick can be continued.
type CherryPickInterruptedError struct {
// Commit is the hash of the commit that could not be applied.
Commit Hash
}

func (e *CherryPickInterruptedError) Error() string {
return fmt.Sprintf("cherry-pick %v interrupted", e.Commit)
}

// CherryPickEmpty specifies how to handle cherry-picked commits
// that would result in no changes to the current HEAD.
type CherryPickEmpty int

const (
// CherryPickEmptyStop stops the cherry-pick operation
// if a commit would have no effect on the current HEAD.
// The user must resolve the issue, and then continue the operation.
//
// This is the default.
CherryPickEmptyStop CherryPickEmpty = iota

// CherryPickEmptyKeep keeps empty commits and their messages.
//
// AllowEmpty is assumed true if this is used.
CherryPickEmptyKeep

// CherryPickEmptyDrop ignores commits that have no effect
// on the current HEAD.
CherryPickEmptyDrop
)

// CherryPickRequest is a request to cherry-pick one or more commits
// into the current HEAD.
type CherryPickRequest struct {
// Commits to cherry-pick. Must be non-empty.
Commits []Hash

// Edit allows editing the commit message(s)
// before committing to the current HEAD.
Edit bool

// OnEmpty indicates how to handle empty cherry-picks:
// those that would have no effect on the current tree.
OnEmpty CherryPickEmpty

// AllowEmpty enables cherry-picking of empty commits.
// Without this, cherry-pick will fail if the target commit is empty
// (has the same tree hash as its parent).
//
// Commits that are empty after merging into current tree
// are not covered by this option.
AllowEmpty bool
}

// TODO: --allow-empty by default?

// CherryPick cherry-picks one or more commits into the current HEAD.
//
// Returns [CherryPickInterruptedError] if a commit could not be applied cleanly.
func (r *Repository) CherryPick(ctx context.Context, req CherryPickRequest) error {
panic("TODO")
// TODO: to detect failed apply, git rev-parse CHERRY_PICK_HEAD.
}
8 changes: 8 additions & 0 deletions internal/ui/widget/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ func (s CommitSummaryStyle) Faint(f bool) CommitSummaryStyle {
return s
}

// Bold returns a copy of the style with bold set to true on all fields.
func (s CommitSummaryStyle) Bold(b bool) CommitSummaryStyle {
s.Hash = s.Hash.Bold(b)
s.Subject = s.Subject.Bold(b)
s.Time = s.Time.Bold(b)
return s
}

// DefaultCommitSummaryStyle is the default style
// for rendering a CommitSummary.
var DefaultCommitSummaryStyle = CommitSummaryStyle{
Expand Down
Loading

0 comments on commit bcf10c9

Please sign in to comment.