From bffa35cf682866b6cf36e7ae7859f1b31940cbc4 Mon Sep 17 00:00:00 2001 From: Jonathan Boiser Date: Sun, 2 Feb 2025 20:36:21 -0800 Subject: [PATCH] feat(branch): add `gs branch squash` (#564) Adds a `gs branch squash` command that squashes the commits of the current branch and restacks the upstack branches on top of the squashed commit. To avoid breaking anything, the branch ref isn't updated until the squashed commit has been completed. Resolves #558 --------- Co-authored-by: Abhinav Gupta --- .../unreleased/Added-20250202-192729.yaml | 3 + branch.go | 1 + branch_checkout.go | 2 +- branch_squash.go | 143 ++++++++++++++++++ doc/includes/cli-reference.md | 21 ++- doc/includes/cli-shorthands.md | 1 + internal/git/branch.go | 3 +- internal/git/commit.go | 27 ++++ testdata/script/branch_squash.txt | 75 +++++++++ testdata/script/branch_squash_err.txt | 52 +++++++ testdata/script/branch_squash_message.txt | 47 ++++++ 11 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 .changes/unreleased/Added-20250202-192729.yaml create mode 100644 branch_squash.go create mode 100644 testdata/script/branch_squash.txt create mode 100644 testdata/script/branch_squash_err.txt create mode 100644 testdata/script/branch_squash_message.txt diff --git a/.changes/unreleased/Added-20250202-192729.yaml b/.changes/unreleased/Added-20250202-192729.yaml new file mode 100644 index 00000000..238c37c4 --- /dev/null +++ b/.changes/unreleased/Added-20250202-192729.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'branch squash: Squashses all commits in the current branch into a single commit and restacks the upstack branches.' +time: 2025-02-02T19:27:29.806629-08:00 diff --git a/branch.go b/branch.go index c5c942a5..8109877b 100644 --- a/branch.go +++ b/branch.go @@ -21,6 +21,7 @@ type branchCmd struct { Delete branchDeleteCmd `cmd:"" aliases:"d,rm" help:"Delete branches"` Fold branchFoldCmd `cmd:"" aliases:"fo" help:"Merge a branch into its base"` Split branchSplitCmd `cmd:"" aliases:"sp" help:"Split a branch on commits"` + Squash branchSquashCmd `cmd:"" aliases:"sq" help:"Squash a branch into one commit"` // Mutation Edit branchEditCmd `cmd:"" aliases:"e" help:"Edit the commits in a branch"` diff --git a/branch_checkout.go b/branch_checkout.go index 277e9831..a2cc7903 100644 --- a/branch_checkout.go +++ b/branch_checkout.go @@ -22,7 +22,7 @@ type branchCheckoutCmd struct { checkoutOptions Untracked bool `short:"u" config:"branchCheckout.showUntracked" help:"Show untracked branches if one isn't supplied"` - Branch string `arg:"" optional:"" help:"Name of the branch to delete" predictor:"branches"` + Branch string `arg:"" optional:"" help:"Name of the branch to checkout" predictor:"branches"` } func (*branchCheckoutCmd) Help() string { diff --git a/branch_squash.go b/branch_squash.go new file mode 100644 index 00000000..2769ecc7 --- /dev/null +++ b/branch_squash.go @@ -0,0 +1,143 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + + "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" +) + +type branchSquashCmd struct { + NoVerify bool `help:"Bypass pre-commit and commit-msg hooks."` + + Message string `short:"m" placeholder:"MSG" help:"Use the given message as the commit message."` +} + +func (*branchSquashCmd) Help() string { + return text.Dedent(` + Squash all commits in the current branch into a single commit + and restack upstack branches. + + An editor will open to edit the commit message of the squashed commit. + Use the -m/--message flag to specify a commit message without editing. + `) +} + +func (cmd *branchSquashCmd) Run( + ctx context.Context, + log *log.Logger, + view ui.View, + repo *git.Repository, + store *state.Store, + svc *spice.Service, +) (err error) { + branchName, err := repo.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + + if branchName == store.Trunk() { + return fmt.Errorf("cannot squash the trunk branch") + } + + branch, err := svc.LookupBranch(ctx, branchName) + if err != nil { + return fmt.Errorf("lookup branch %q: %w", branchName, err) + } + + if err := svc.VerifyRestacked(ctx, branchName); err != nil { + var restackErr *spice.BranchNeedsRestackError + if errors.As(err, &restackErr) { + return fmt.Errorf("branch %v needs to be restacked before it can be squashed", branchName) + } + return fmt.Errorf("verify restacked: %w", err) + } + + // If no message was specified, + // combine the commit messages of all commits in the branch + // to form the initial commit message for the squashed commit. + var commitTemplate string + if cmd.Message == "" { + commitMessages, err := repo.CommitMessageRange(ctx, branch.Head.String(), branch.BaseHash.String()) + if err != nil { + return fmt.Errorf("get commit messages: %w", err) + } + + var sb strings.Builder + sb.WriteString("The original commit messages were:\n\n") + for i, msg := range commitMessages { + if i > 0 { + sb.WriteString("\n") + } + + fmt.Fprintf(&sb, "%v\n", msg) + } + + commitTemplate = sb.String() + } + + // Detach the HEAD so that we don't mess with the current branch + // until the operation is confirmed successful. + if err := repo.DetachHead(ctx, branchName); err != nil { + return fmt.Errorf("detach HEAD: %w", err) + } + var reattachedHead bool + defer func() { + // Reattach the HEAD to the original branch + // if we return early before the operation is complete. + if !reattachedHead { + if cerr := repo.Checkout(ctx, branchName); cerr != nil { + log.Error("could not check out original branch", + "branch", branchName, + "error", cerr) + err = errors.Join(err, cerr) + } + } + }() + + // Perform a soft reset to the base commit. + // The working tree and index will remain unchanged, + // so the contents of the head commit will be staged. + if err := repo.Reset(ctx, branch.BaseHash.String(), git.ResetOptions{ + Mode: git.ResetSoft, + }); err != nil { + return fmt.Errorf("reset to base commit: %w", err) + } + + if err := repo.Commit(ctx, git.CommitRequest{ + Message: cmd.Message, + Template: commitTemplate, + NoVerify: cmd.NoVerify, + }); err != nil { + return fmt.Errorf("commit squashed changes: %w", err) + } + + headHash, err := repo.Head(ctx) + if err != nil { + return fmt.Errorf("get HEAD hash: %w", err) + } + + if err := repo.SetRef(ctx, git.SetRefRequest{ + Ref: "refs/heads/" + branchName, + Hash: headHash, + // Ensure that another tree didn't update the branch + // while we weren't looking. + OldHash: branch.Head, + }); err != nil { + return fmt.Errorf("update branch ref: %w", err) + } + + if cerr := repo.Checkout(ctx, branchName); cerr != nil { + return fmt.Errorf("checkout branch: %w", cerr) + } + reattachedHead = true + + return (&upstackRestackCmd{}).Run(ctx, log, repo, store, svc) +} diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index d5429a8d..e48fcaa0 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -484,7 +484,7 @@ Use -u/--untracked to show untracked branches in the prompt. **Arguments** -* `branch`: Name of the branch to delete +* `branch`: Name of the branch to checkout **Flags** @@ -642,6 +642,25 @@ For example: * `--at=COMMIT:NAME,...`: Commits to split the branch at. * `--branch=NAME`: Branch to split commits of. +### gs branch squash + +``` +gs branch (b) squash (sq) [flags] +``` + +Squash a branch into one commit + +Squash all commits in the current branch into a single commit +and restack upstack branches. + +An editor will open to edit the commit message of the squashed commit. +Use the -m/--message flag to specify a commit message without editing. + +**Flags** + +* `--no-verify`: Bypass pre-commit and commit-msg hooks. +* `-m`, `--message=MSG`: Use the given message as the commit message. + ### gs branch edit ``` diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md index c243d1c8..a650eeb4 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -10,6 +10,7 @@ | gs brn | [gs branch rename](/cli/reference.md#gs-branch-rename) | | gs bs | [gs branch submit](/cli/reference.md#gs-branch-submit) | | gs bsp | [gs branch split](/cli/reference.md#gs-branch-split) | +| gs bsq | [gs branch squash](/cli/reference.md#gs-branch-squash) | | gs btr | [gs branch track](/cli/reference.md#gs-branch-track) | | gs buntr | [gs branch untrack](/cli/reference.md#gs-branch-untrack) | | gs ca | [gs commit amend](/cli/reference.md#gs-commit-amend) | diff --git a/internal/git/branch.go b/internal/git/branch.go index 74b2f953..e41f2b83 100644 --- a/internal/git/branch.go +++ b/internal/git/branch.go @@ -131,7 +131,8 @@ func (r *Repository) BranchExists(ctx context.Context, branch string) bool { } // DetachHead detaches the HEAD from the current branch -// while staying at the same commit. +// and points it to the specified commitish (if provided). +// Otherwise, it stays at the current commit. func (r *Repository) DetachHead(ctx context.Context, commitish string) error { args := []string{"checkout", "--detach"} if len(commitish) > 0 { diff --git a/internal/git/commit.go b/internal/git/commit.go index 422a01bb..ad042ffd 100644 --- a/internal/git/commit.go +++ b/internal/git/commit.go @@ -109,6 +109,16 @@ type CommitRequest struct { // as the commit message. ReuseMessage string + // Template is the commit message template. + // + // If Message is empty, this fills the initial commit message + // when the user is editing the commit message. + // + // Note that if the user does not edit the message, + // the commit will be aborted. + // Therefore, do not use this as a default message. + Template string + // All stages all changes before committing. All bool @@ -138,6 +148,23 @@ func (r *Repository) Commit(ctx context.Context, req CommitRequest) error { if req.Message != "" { args = append(args, "-m", req.Message) } + if req.Template != "" { + f, err := os.CreateTemp("", "commit-template-") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + defer func() { _ = os.Remove(f.Name()) }() + + if _, err := f.WriteString(req.Template); err != nil { + return fmt.Errorf("write temp file: %w", err) + } + + if err := f.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + + args = append(args, "--template", f.Name()) + } if req.Amend { args = append(args, "--amend") } diff --git a/testdata/script/branch_squash.txt b/testdata/script/branch_squash.txt new file mode 100644 index 00000000..b0a25913 --- /dev/null +++ b/testdata/script/branch_squash.txt @@ -0,0 +1,75 @@ +# Squashing a branch into one commit with 'squash' + +as 'Test ' +at '2025-02-02T20:00:01Z' + +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +# Create a branch with two commits +git add feature1.txt +gs branch create feature1 -m 'First message in squashed branch' +git add feature2.txt +git commit -m 'Second message in squashed branch' + +# Create another branch +git add feature3.txt +gs branch create feature3 -m 'First message in rebased branch' + +git graph --branches +cmp stdout $WORK/golden/graph-before.txt + +# Go back to branch that will be squashed +gs down + +mkdir $WORK/output +env EDITOR=mockedit +env MOCKEDIT_RECORD=$WORK/output/initial-msg.txt +env MOCKEDIT_GIVE=$WORK/input/squashed-msg.txt +gs branch squash + +git graph --branches +cmp stdout $WORK/golden/graph.txt +cmp $WORK/output/initial-msg.txt $WORK/golden/initial-msg.txt + +-- repo/dirty.txt -- +Dirty +-- repo/feature1.txt -- +Feature 1 +-- repo/feature2.txt -- +Feature 2 +-- repo/feature3.txt -- +Feature 3 +-- golden/graph-before.txt -- +* a956270 (HEAD -> feature3) First message in rebased branch +* adfd1b6 (feature1) Second message in squashed branch +* 3143d6f First message in squashed branch +* 2b5f0cb (main) Initial commit +-- golden/graph.txt -- +* c012e99 (feature3) First message in rebased branch +* 67f0d51 (HEAD -> feature1) Squashed commit message +* 2b5f0cb (main) Initial commit +-- input/squashed-msg.txt -- +Squashed commit message + +This contains features 1 and 2. +-- golden/initial-msg.txt -- +The original commit messages were: + +Second message in squashed branch + +First message in squashed branch + +# Please enter the commit message for your changes. Lines starting +# with '#' will be ignored, and an empty message aborts the commit. +# +# HEAD detached from refs/heads/feature1 +# Changes to be committed: +# new file: feature1.txt +# new file: feature2.txt +# +# Untracked files: +# dirty.txt +# diff --git a/testdata/script/branch_squash_err.txt b/testdata/script/branch_squash_err.txt new file mode 100644 index 00000000..828fbe74 --- /dev/null +++ b/testdata/script/branch_squash_err.txt @@ -0,0 +1,52 @@ +# Common errors with 'branch squash'. + +as 'Test ' +at '2025-02-02T20:18:19Z' + +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo init + +! gs branch squash +stderr 'cannot squash the trunk branch' + +# feat1 -> feat2, with feat1 diverging +git add feat1.txt +gs bc -m feat1 +git add feat2.txt +gs bc -m feat2 +git add feat2-pt2.txt +gs cc -m 'feat2 part 2' +gs down +git add feat1-pt2.txt +git commit -m 'feat1 part 2' +gs up + +# branch is not restacked +! gs branch squash +stderr 'branch feat2 needs to be restacked' +gs stack restack # fix it + +# abort the commit +git diff --quiet # verify no changes +git graph --branches +cmp stdout $WORK/golden/abort-before.txt +env EDITOR=mockedit MOCKEDIT_GIVE=$WORK/input/empty.txt +! gs branch squash +stderr 'empty commit message' +# must still be in the branch +git graph --branches +cmp stdout $WORK/golden/abort-before.txt + +-- repo/feat1.txt -- +-- repo/feat1-pt2.txt -- +-- repo/feat2.txt -- +-- repo/feat2-pt2.txt -- +-- input/empty.txt -- +-- golden/abort-before.txt -- +* 01fbf9b (HEAD -> feat2) feat2 part 2 +* 4b08080 feat2 +* 833ec52 (feat1) feat1 part 2 +* 48855c7 feat1 +* da2d8d1 (main) Initial commit diff --git a/testdata/script/branch_squash_message.txt b/testdata/script/branch_squash_message.txt new file mode 100644 index 00000000..a9fecbb3 --- /dev/null +++ b/testdata/script/branch_squash_message.txt @@ -0,0 +1,47 @@ +# Squashing a branch into one commit with 'squash' + +as 'Test ' +at '2024-03-30T14:59:32Z' + +cd repo +git init +git commit --allow-empty -m 'Initial commit' +gs repo i + +# Create a branch with two commits +git add feature1.txt +gs branch create feature-1 -m 'First message in squashed branch' +git add feature2.txt +git commit -m 'Second message in squashed branch' + +# Create another branch +git add feature3.txt +gs branch create feature2 -m 'First message in rebased branch' + +git graph --branches +cmp stdout $WORK/golden/graph-before.txt + +# Go back to branch that will be squashed +gs down +gs branch squash -m 'squash feature-1 into one commit' + +git graph --branches +cmp stdout $WORK/golden/graph.txt + +-- repo/dirty.txt -- +Dirty +-- repo/feature1.txt -- +Feature 1 +-- repo/feature2.txt -- +Feature 2 +-- repo/feature3.txt -- +Feature 3 +-- golden/graph-before.txt -- +* d805cb2 (HEAD -> feature2) First message in rebased branch +* 0239007 (feature-1) Second message in squashed branch +* 7ebfd80 First message in squashed branch +* 9bad92b (main) Initial commit +-- golden/graph.txt -- +* 8323247 (feature2) First message in rebased branch +* 3267250 (HEAD -> feature-1) squash feature-1 into one commit +* 9bad92b (main) Initial commit