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