From 9d7caab50c52431fd707720bfdd8eca6ac4b3d42 Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Fri, 6 Sep 2024 05:26:01 -0700 Subject: [PATCH] WIP: new command: gs commit pick Allows cherry-picking commits into the current branch and restacks the upstack. Two modes of usage: gs commit pick 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: - [ ] --continue/--abort/--skip flags? - [ ] Doc website update Resolves #372 --- .../unreleased/Added-20241228-193338.yaml | 6 + commit.go | 1 + commit_pick.go | 184 +++++++++ doc/includes/cli-reference.md | 36 +- doc/includes/cli-shorthands.md | 1 + doc/src/cli/config.md | 15 + internal/git/cherry_pick.go | 164 ++++++++ internal/ui/widget/commit.go | 8 + internal/ui/widget/commit_pick.go | 356 ++++++++++++++++++ internal/ui/widget/commit_pick_test.go | 79 ++++ .../testdata/script/commit_pick/basic.txt | 83 ++++ .../commit_pick/branch_without_commit.txt | 53 +++ .../script/commit_pick/preselected.txt | 37 ++ rebase_continue.go | 2 +- 14 files changed, 1022 insertions(+), 3 deletions(-) create mode 100644 .changes/unreleased/Added-20241228-193338.yaml create mode 100644 commit_pick.go create mode 100644 internal/git/cherry_pick.go create mode 100644 internal/ui/widget/commit_pick.go create mode 100644 internal/ui/widget/commit_pick_test.go create mode 100644 internal/ui/widget/testdata/script/commit_pick/basic.txt create mode 100644 internal/ui/widget/testdata/script/commit_pick/branch_without_commit.txt create mode 100644 internal/ui/widget/testdata/script/commit_pick/preselected.txt diff --git a/.changes/unreleased/Added-20241228-193338.yaml b/.changes/unreleased/Added-20241228-193338.yaml new file mode 100644 index 00000000..8b5116cb --- /dev/null +++ b/.changes/unreleased/Added-20241228-193338.yaml @@ -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 diff --git a/commit.go b/commit.go index 1e64ef79..3ef8fb00 100644 --- a/commit.go +++ b/commit.go @@ -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"` } diff --git a/commit_pick.go b/commit_pick.go new file mode 100644 index 00000000..2e8f6c0d --- /dev/null +++ b/commit_pick.go @@ -0,0 +1,184 @@ +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 `default:"false" negatable:"" config:"commitPick.edit" help:"Whether to open an editor to edit the commit message."` + 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. + + By default, commit messages for cherry-picked commits will be used verbatim. + Supply --edit to open an editor and change the commit message, + or set the spice.commitPick.edit configuration option to true + to always open an editor for cherry picks. + `) +} + +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 +} diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index dfc993fa..5c2f9011 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -12,7 +12,7 @@ gs (git-spice) is a command line tool for stacking Git branches. * `-C`, `--dir=DIR`: Change to DIR before doing anything * `--[no-]prompt`: Whether to prompt for missing information -**Configuration**: [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid) +**Configuration**: [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl) ## Shell @@ -820,6 +820,38 @@ followed by 'gs upstack restack'. * `--no-edit`: Don't edit the commit message * `--no-verify`: Bypass pre-commit and commit-msg hooks. +### gs commit pick + +``` +gs commit (c) pick (p) [] [flags] +``` + +Cherry-pick a commit + +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. + +By default, commit messages for cherry-picked commits will be used verbatim. +Supply --edit to open an editor and change the commit message, +or set the spice.commitPick.edit configuration option to true +to always open an editor for cherry picks. + +**Arguments** + +* `commit`: Commit to cherry-pick + +**Flags** + +* `--[no-]edit` ([:material-wrench:{ .middle title="spice.commitPick.edit" }](/cli/config.md#spicecommitpickedit)): Whether to open an editor to edit the commit message. +* `--from=NAME`: Branch whose upstack commits will be considered. + +**Configuration**: [spice.commitPick.edit](/cli/config.md#spicecommitpickedit) + ### gs commit split ``` @@ -863,7 +895,7 @@ and use --edit to override it. **Flags** -* `--[no-]edit` ([:material-wrench:{ .middle title="spice.rebaseContinue.edit" }](/cli/config.md#spicerebasecontinueedit)): Whehter to open an editor to edit the commit message. +* `--[no-]edit` ([:material-wrench:{ .middle title="spice.rebaseContinue.edit" }](/cli/config.md#spicerebasecontinueedit)): Whether to open an editor to edit the commit message. **Configuration**: [spice.rebaseContinue.edit](/cli/config.md#spicerebasecontinueedit) diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md index c243d1c8..d7b49a9a 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -14,6 +14,7 @@ | gs buntr | [gs branch untrack](/cli/reference.md#gs-branch-untrack) | | gs ca | [gs commit amend](/cli/reference.md#gs-commit-amend) | | gs cc | [gs commit create](/cli/reference.md#gs-commit-create) | +| gs cp | [gs commit pick](/cli/reference.md#gs-commit-pick) | | gs csp | [gs commit split](/cli/reference.md#gs-commit-split) | | gs dse | [gs downstack edit](/cli/reference.md#gs-downstack-edit) | | gs dss | [gs downstack submit](/cli/reference.md#gs-downstack-submit) | diff --git a/doc/src/cli/config.md b/doc/src/cli/config.md index 2da67e51..966da977 100644 --- a/doc/src/cli/config.md +++ b/doc/src/cli/config.md @@ -54,6 +54,21 @@ and use the `--commit` flag to commit changes when needed. - `true` (default) - `false` +### spice.commitPick.edit + + + +Whether $$gs commit pick$$ should open an editor to modify commit messages +of cherry-picked commits before committing them. + +If set to true, opt-out with the `--no-edit` flag. +If set to false, opt-in with the `--edit` flag. + +**Accepted values:** + +- `true` +- `false` (default) + ### spice.forge.github.apiUrl URL at which the GitHub API is available. diff --git a/internal/git/cherry_pick.go b/internal/git/cherry_pick.go new file mode 100644 index 00000000..0ee50e85 --- /dev/null +++ b/internal/git/cherry_pick.go @@ -0,0 +1,164 @@ +package git + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" +) + +// 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 + + // Err is the original error that was reported. + Err error +} + +func (e *CherryPickInterruptedError) Error() string { + return fmt.Sprintf("cherry-pick %v interrupted", e.Commit) +} + +func (e *CherryPickInterruptedError) Unwrap() error { + return e.Err +} + +// 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 +} + +// 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 { + if len(req.Commits) == 0 { + return errors.New("no commits specified") + } + + args := []string{"cherry-pick"} + if req.Edit { + args = append(args, "--edit") + } + if req.AllowEmpty { + args = append(args, "--allow-empty") + } + switch req.OnEmpty { + case CherryPickEmptyStop: + // default; do nothing + case CherryPickEmptyKeep: + args = append(args, "--empty=keep") + case CherryPickEmptyDrop: + args = append(args, "--empty=drop") + default: + return fmt.Errorf("unkonwn OnEmpty: %v", req.OnEmpty) + } + + cmd := r.gitCmd(ctx, args...) + if req.Edit { + cmd.Stdin(os.Stdin).Stdout(os.Stdout) + } + + return r.handleCherryPickError(ctx, "cherry-pick", cmd.Run(r.exec)) +} + +// CherryPickContinue continues a series of cherry-pick operations. +// +// Returns [CherryPickInterruptedError] if a commit could not be applied cleanly. +func (r *Repository) CherryPickContinue(ctx context.Context) error { + cmd := r.gitCmd(ctx, "cherry-pick", "--continue").Stdin(os.Stdin).Stdout(os.Stdout) + return r.handleCherryPickError(ctx, "cherry-pick continue", cmd.Run(r.exec)) +} + +// CherryPickSkip skips the current commit in a cherry-pick operation +// and continues the remaining ones. +// +// Returns [CherryPickInterruptedError] if a commit could not be applied cleanly. +func (r *Repository) CherryPickSkip(ctx context.Context) error { + cmd := r.gitCmd(ctx, "cherry-pick", "--skip").Stdin(os.Stdin).Stdout(os.Stdout) + return r.handleCherryPickError(ctx, "cherry-pick skip", cmd.Run(r.exec)) +} + +// CherryPickAbort aborts the current cherry-pick operations +// and reverts to the state before the cherry-pick. +func (r *Repository) CherryPickAbort(ctx context.Context) error { + cmd := r.gitCmd(ctx, "cherry-pick", "--abort").Stdin(os.Stdin).Stdout(os.Stdout) + if err := cmd.Run(r.exec); err != nil { + return fmt.Errorf("cherry-pick abort: %w", err) + } + return nil +} + +func (r *Repository) handleCherryPickError(ctx context.Context, name string, err error) error { + if err != nil { + return nil + } + + origErr := err + if exitErr := new(exec.ExitError); !errors.As(err, &exitErr) { + return fmt.Errorf("%s: %w", name, err) + } + + commit, err := r.PeelToCommit(ctx, "CHERRY_PICK_HEAD") + if err != nil { + if errors.Is(err, ErrNotExist) { + // Not inside a cherry-pick. + return fmt.Errorf("not inside a cherry pick: %w", origErr) + } + return errors.Join( + fmt.Errorf("resolve CHERRY_PICK_HEAD: %w", err), + fmt.Errorf("%s: %w", name, err), + ) + } + + return &CherryPickInterruptedError{ + Commit: commit, + Err: origErr, + } +} diff --git a/internal/ui/widget/commit.go b/internal/ui/widget/commit.go index 6df35f3b..352db914 100644 --- a/internal/ui/widget/commit.go +++ b/internal/ui/widget/commit.go @@ -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{ diff --git a/internal/ui/widget/commit_pick.go b/internal/ui/widget/commit_pick.go new file mode 100644 index 00000000..b1661665 --- /dev/null +++ b/internal/ui/widget/commit_pick.go @@ -0,0 +1,356 @@ +package widget + +import ( + "errors" + "maps" + "slices" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/ui" + "go.abhg.dev/gs/internal/ui/fliptree" +) + +// TODO: support multi-select + +// CommitPickKeyMap defines the key mappings for the commit pick widget. +type CommitPickKeyMap struct { + Up key.Binding + Down key.Binding + Accept key.Binding +} + +// DefaultCommitPickKeyMap is the default key map for the commit pick widget. +var DefaultCommitPickKeyMap = CommitPickKeyMap{ + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("up", "go up"), + ), + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("down", "go down"), + ), + Accept: key.NewBinding( + key.WithKeys("enter", "tab"), + key.WithHelp("enter/tab", "accept"), + ), +} + +// CommitPickStyle defines the visual style of the commit pick widget. +type CommitPickStyle struct { + Branch lipgloss.Style + CursorStyle lipgloss.Style + + LogCommitStyle CommitSummaryStyle +} + +// DefaultCommitPickStyle is the default style for the commit pick widget. +var DefaultCommitPickStyle = CommitPickStyle{ + Branch: ui.NewStyle().Bold(true), + CursorStyle: ui.NewStyle(). + Foreground(ui.Yellow). + Bold(true). + SetString("▶"), + LogCommitStyle: DefaultCommitSummaryStyle, +} + +// CommitPickBranch is a single branch shown in the commit pick widget. +type CommitPickBranch struct { + // Branch is the name of the branch. + Branch string + + // Base is the base branch that this branch is based on. + // This will be used to create a tree view. + // If no base is specified, the branch is shown as a root. + Base string + + // Commits in the branch that we can select from. + Commits []CommitSummary +} + +type commitPickBranch struct { + Name string + Base int // index in CommitPick.branches or -1 + Aboves []int // index in CommitPick.branches + Commits []int // index in CommitPick.commits +} + +type commitPickCommit struct { + Summary CommitSummary + Branch int // index in CommitPick.branches +} + +// CommitPick is a widget that allows users to pick out a commit +// from a list of branches and commits. +type CommitPick struct { + KeyMap CommitPickKeyMap + Style CommitPickStyle + + title string + desc string + + // Original list of branches provided to WithBranches. + // Is turned into branches, commits, and commitOrder at Init() time. + input []CommitPickBranch + + branches []commitPickBranch + commits []commitPickCommit + roots []int // indexes in branches of root branches (no base) + + // Indexes in commits, ordered by how they're presented. + // This is depth-first by branch, and then in-order per-branch. + order []int + cursor int // index of cursor in order + + accepted bool + value *git.Hash + err error +} + +var _ ui.Field = (*CommitPick)(nil) + +// NewCommitPick initializes a new CommitPick widget. +// Use WithBranches to add branch information. +func NewCommitPick() *CommitPick { + return &CommitPick{ + KeyMap: DefaultCommitPickKeyMap, + Style: DefaultCommitPickStyle, + value: new(git.Hash), + } +} + +// Title returns the title of the field. +func (c *CommitPick) Title() string { return c.title } + +// Description provides an optional description for the field. +func (c *CommitPick) Description() string { return c.desc } + +// Err returns an error if the widget has already failed. +func (c *CommitPick) Err() error { return c.err } + +// WithBranches adds branches with commits for a user to select from. +func (c *CommitPick) WithBranches(branches ...CommitPickBranch) *CommitPick { + c.input = branches + return c +} + +// WithTitle changes the title of the widget. +func (c *CommitPick) WithTitle(title string) *CommitPick { + c.title = title + return c +} + +// WithDescription changes the description of the widget. +func (c *CommitPick) WithDescription(desc string) *CommitPick { + c.desc = desc + return c +} + +// WithValue specifies the variable to which the selected commit hash +// will be written. +func (c *CommitPick) WithValue(value *git.Hash) *CommitPick { + c.value = value + return c +} + +// UnmarshalValue unmarshals a commit hash from an external source. +// This is used by [ui.RobotView] to supply the value in tests. +func (c *CommitPick) UnmarshalValue(unmarshal func(any) error) error { + var hash git.Hash + if err := unmarshal(&hash); err != nil { + return err + } + *c.value = hash + return nil +} + +// Init initializes the widget. This is called by Bubble Tea. +// With* functions may not be used once this is called. +func (c *CommitPick) Init() tea.Cmd { + if len(c.input) == 0 { + c.err = errors.New("no branches provided") + return tea.Quit + } + + // First pass: initialize objects. + branches := make([]commitPickBranch, 0, len(c.input)) + branchIdxByName := make(map[string]int, len(c.input)) + var commits []commitPickCommit + for _, b := range c.input { + idx := len(branches) + branch := commitPickBranch{ + Name: b.Branch, + Base: -1, + } + branchIdxByName[b.Branch] = idx + for _, commit := range b.Commits { + branch.Commits = append( + branch.Commits, len(commits), + ) + commits = append(commits, commitPickCommit{ + Summary: commit, + Branch: idx, + }) + } + branches = append(branches, branch) + } + + if len(commits) == 0 { + c.err = errors.New("no commits found") + return tea.Quit + } + + // Second pass: connect Bases and Aboves. + rootSet := make(map[int]struct{}) + for idx, b := range c.input { + if b.Base == "" { + rootSet[idx] = struct{}{} + continue + } + + baseIdx, ok := branchIdxByName[b.Base] + if !ok { + // Base is not a known branch. + // That's fine, add an empty entry for it. + baseIdx = len(branches) + branches = append(branches, commitPickBranch{ + Name: b.Base, + Base: -1, + }) + branchIdxByName[b.Base] = baseIdx + rootSet[baseIdx] = struct{}{} + } + + branches[idx].Base = baseIdx + branches[baseIdx].Aboves = append(branches[baseIdx].Aboves, idx) + } + + // Finally, using this information, + // traverse the branches in depth-first order + // to match the order in which the tree will render them. + // This will be used for the commit ordering. + roots := slices.Sorted(maps.Keys(rootSet)) + + commitOrder := make([]int, 0, len(commits)) + var visitBranch func(int) + visitBranch = func(idx int) { + for _, aboveIdx := range branches[idx].Aboves { + visitBranch(aboveIdx) + } + + for _, commitIdx := range branches[idx].Commits { + // If the current (default) value matches the hash, + // move the cursor to it. + if commits[commitIdx].Summary.ShortHash == *c.value { + c.cursor = len(commitOrder) + } + + commitOrder = append(commitOrder, commitIdx) + } + } + for _, root := range roots { + visitBranch(root) + } + + c.branches = branches + c.commits = commits + c.order = commitOrder + c.roots = roots + return nil +} + +// Update receives a UI message and updates the widget's internal state. +func (c *CommitPick) Update(msg tea.Msg) tea.Cmd { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return nil + } + + // TODO: do we want to support filtering? + + switch { + case key.Matches(keyMsg, c.KeyMap.Up): + c.moveCursor(false /* backwards */) + case key.Matches(keyMsg, c.KeyMap.Down): + c.moveCursor(true /* forwards */) + case key.Matches(keyMsg, c.KeyMap.Accept): + c.accepted = true + commitIdx := c.order[c.cursor] + *c.value = c.commits[commitIdx].Summary.ShortHash + return ui.AcceptField + } + + return nil +} + +func (c *CommitPick) moveCursor(forwards bool) { + delta := 1 + if !forwards { + delta = -1 + } + + c.cursor += delta + if c.cursor < 0 { + c.cursor = len(c.order) - 1 + } else if c.cursor >= len(c.order) { + c.cursor = 0 + } +} + +// Render renders the widget to a writer. +func (c *CommitPick) Render(w ui.Writer) { + if c.accepted { + w.WriteString(c.value.String()) + return + } + + if c.title != "" { + w.WriteString("\n") + } + + _ = fliptree.Write(w, fliptree.Graph[commitPickBranch]{ + Values: c.branches, + Roots: c.roots, + Edges: func(b commitPickBranch) []int { return b.Aboves }, + View: func(b commitPickBranch) string { + var o strings.Builder + o.WriteString(c.Style.Branch.Render(b.Name)) + + focusedCommitIdx := c.order[c.cursor] + focusedBranchIdx := c.commits[focusedCommitIdx].Branch + + for _, commitIdx := range b.Commits { + commit := c.commits[commitIdx] + + o.WriteString(" ") + o.WriteString("\n") + + cursor := " " + summaryStyle := c.Style.LogCommitStyle + // Three levels of visibility for commits: + // 1. focused on commit + // 2. not focused on commit, + // but focused on commit in same branch + // 3. focused on a different branch + switch { + case focusedCommitIdx == commitIdx: + summaryStyle = summaryStyle.Bold(true) + cursor = c.Style.CursorStyle.String() + case focusedBranchIdx == commit.Branch: + // default style is good enough + default: + summaryStyle = summaryStyle.Faint(true) + } + + o.WriteString(cursor) + o.WriteString(" ") + commit.Summary.Render(&o, summaryStyle) + } + + return o.String() + }, + }, fliptree.Options[commitPickBranch]{}) +} diff --git a/internal/ui/widget/commit_pick_test.go b/internal/ui/widget/commit_pick_test.go new file mode 100644 index 00000000..cf49f7ca --- /dev/null +++ b/internal/ui/widget/commit_pick_test.go @@ -0,0 +1,79 @@ +package widget + +import ( + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/rogpeppe/go-internal/testscript" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/ui" + "go.abhg.dev/gs/internal/ui/uitest" + "go.abhg.dev/testing/stub" +) + +// Expected files: +// +// - branches: JSON list of input branches and their commits +// - want: commit hash to expect as selected +// - desc (optional): widget description +// - give (optional): initial value of the hash +func TestCommitPick(t *testing.T) { + stub.Func(&_timeNow, time.Date(2024, 12, 11, 10, 9, 8, 7, time.UTC)) + + uitest.RunScripts(t, func(t testing.TB, ts *testscript.TestScript, view ui.InteractiveView) { + var input []CommitPickBranch + require.NoError(t, + json.Unmarshal([]byte(ts.ReadFile("branches")), &input), + "read 'branches' file") + + want := git.Hash(strings.TrimSpace(ts.ReadFile("want"))) + + var desc string + if _, err := os.Stat(ts.MkAbs("desc")); err == nil { + desc = strings.TrimSpace(ts.ReadFile("desc")) + } + + var give git.Hash + if _, err := os.Stat(ts.MkAbs("give")); err == nil { + give = git.Hash(strings.TrimSpace(ts.ReadFile("give"))) + } + + got := give + widget := NewCommitPick(). + WithTitle("Select a commit"). + WithBranches(input...). + WithDescription(desc). + WithValue(&got) + + require.NoError(t, ui.Run(view, widget)) + assert.Equal(t, want, got) + }, &uitest.RunScriptsOptions{Update: *UpdateFixtures}, "testdata/script/commit_pick") +} + +func TestCommitPickErrors(t *testing.T) { + t.Run("NoBranches", func(t *testing.T) { + view := uitest.NewEmulatorView(nil) + defer func() { _ = view.Close() }() + + err := ui.Run(view, NewCommitPick()) + require.Error(t, err) + assert.ErrorContains(t, err, "no branches provided") + }) + + t.Run("NoCommits", func(t *testing.T) { + view := uitest.NewEmulatorView(nil) + defer func() { _ = view.Close() }() + + err := ui.Run(view, NewCommitPick().WithBranches( + CommitPickBranch{Branch: "foo"}, + CommitPickBranch{Branch: "bar"}, + )) + require.Error(t, err) + assert.ErrorContains(t, err, "no commits found") + }) +} diff --git a/internal/ui/widget/testdata/script/commit_pick/basic.txt b/internal/ui/widget/testdata/script/commit_pick/basic.txt new file mode 100644 index 00000000..032a45e9 --- /dev/null +++ b/internal/ui/widget/testdata/script/commit_pick/basic.txt @@ -0,0 +1,83 @@ +init + +await Select a commit +snapshot +cmp stdout prompt + +feed +await +snapshot +cmp stdout commit_2 + +feed -r 2 +await +snapshot +cmp stdout commit_3 + +feed + +-- branches -- +[ + { + "branch": "feat1", + "base": "main", + "commits": [ + { + "shortHash": "abc", + "subject": "feat: add feature", + "authorDate": "2024-12-10T22:26:23Z" + }, + { + "shortHash": "def", + "subject": "refac: unrelated change", + "authorDate": "2024-12-10T22:33:44Z" + } + ] + }, + {"branch": "main"}, + { + "branch": "feat2", + "base": "feat1", + "commits": [ + { + "shortHash": "ghi", + "subject": "feat: add another feature", + "authorDate": "2024-12-10T22:45:44Z" + } + ] + } +] +-- want -- +def +-- desc -- +Pick a commit to cherry-pick +-- prompt -- +Select a commit: + ┏━□ feat2 + ┃ ▶ ghi feat: add another feature (11 hours ago) +┏━┻□ feat1 +┃ abc feat: add feature (11 hours ago) +┃ def refac: unrelated change (11 hours ago) +main + +Pick a commit to cherry-pick +-- commit_2 -- +Select a commit: + ┏━□ feat2 + ┃ ghi feat: add another feature (11 hours ago) +┏━┻□ feat1 +┃ ▶ abc feat: add feature (11 hours ago) +┃ def refac: unrelated change (11 hours ago) +main + +Pick a commit to cherry-pick +-- commit_3 -- +Select a commit: + ┏━□ feat2 + ┃ ghi feat: add another feature (11 hours ago) +┏━┻□ feat1 +┃ abc feat: add feature (11 hours ago) +┃ ▶ def refac: unrelated change (11 hours ago) +main + +Pick a commit to cherry-pick diff --git a/internal/ui/widget/testdata/script/commit_pick/branch_without_commit.txt b/internal/ui/widget/testdata/script/commit_pick/branch_without_commit.txt new file mode 100644 index 00000000..8a448be2 --- /dev/null +++ b/internal/ui/widget/testdata/script/commit_pick/branch_without_commit.txt @@ -0,0 +1,53 @@ +init + +await Select a commit +snapshot +cmp stdout prompt + +feed -r 2 +feed + +-- branches -- +[ + { + "branch": "feat1", + "base": "main", + "commits": [ + { + "shortHash": "ghi", + "subject": "feat: add feature", + "authorDate": "2024-12-10T22:45:44Z" + } + ] + }, + {"branch": "feat2", "base": "feat1"}, + { + "branch": "feat3", + "base": "feat2", + "commits": [ + { + "shortHash": "abc", + "subject": "feat: add another feature", + "authorDate": "2024-12-09T16:44:23Z" + }, + { + "shortHash": "def", + "subject": "refac: unrelated change", + "authorDate": "2024-12-10T16:52:44Z" + } + ] + } +] + +-- want -- +ghi + +-- prompt -- +Select a commit: + ┏━□ feat3 + ┃ ▶ abc feat: add another feature (1 day ago) + ┃ def refac: unrelated change (17 hours ago) + ┏━┻□ feat2 +┏━┻□ feat1 +┃ ghi feat: add feature (11 hours ago) +main diff --git a/internal/ui/widget/testdata/script/commit_pick/preselected.txt b/internal/ui/widget/testdata/script/commit_pick/preselected.txt new file mode 100644 index 00000000..45af6871 --- /dev/null +++ b/internal/ui/widget/testdata/script/commit_pick/preselected.txt @@ -0,0 +1,37 @@ +init + +await Select a commit +snapshot +cmp stdout prompt + +feed + +-- want -- +abc +-- give -- +def +-- branches -- +[ + { + "branch": "feat1", + "base": "main", + "commits": [ + { + "shortHash": "abc", + "subject": "feat: add another feature", + "authorDate": "2024-12-09T16:44:23Z" + }, + { + "shortHash": "def", + "subject": "refac: unrelated change", + "authorDate": "2024-12-10T16:52:44Z" + } + ] + } +] +-- prompt -- +Select a commit: +┏━□ feat1 +┃ abc feat: add another feature (1 day ago) +┃ ▶ def refac: unrelated change (17 hours ago) +main diff --git a/rebase_continue.go b/rebase_continue.go index 79e94948..74e1a562 100644 --- a/rebase_continue.go +++ b/rebase_continue.go @@ -15,7 +15,7 @@ import ( ) type rebaseContinueCmd struct { - Edit bool `default:"true" negatable:"" config:"rebaseContinue.edit" help:"Whehter to open an editor to edit the commit message."` + Edit bool `default:"true" negatable:"" config:"rebaseContinue.edit" help:"Whether to open an editor to edit the commit message."` } func (*rebaseContinueCmd) Help() string {