Skip to content

Commit

Permalink
feat(branch): add gs branch squash (#564)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
jonboiser and abhinav authored Feb 3, 2025
1 parent 3ab9e51 commit bffa35c
Show file tree
Hide file tree
Showing 11 changed files with 372 additions and 3 deletions.
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20250202-192729.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
2 changes: 1 addition & 1 deletion branch_checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
143 changes: 143 additions & 0 deletions branch_squash.go
Original file line number Diff line number Diff line change
@@ -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)
}
21 changes: 20 additions & 1 deletion doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down Expand Up @@ -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

```
Expand Down
1 change: 1 addition & 0 deletions doc/includes/cli-shorthands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
3 changes: 2 additions & 1 deletion internal/git/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions internal/git/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
}
Expand Down
75 changes: 75 additions & 0 deletions testdata/script/branch_squash.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Squashing a branch into one commit with 'squash'

as 'Test <[email protected]>'
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
#
52 changes: 52 additions & 0 deletions testdata/script/branch_squash_err.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Common errors with 'branch squash'.

as 'Test <[email protected]>'
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
Loading

0 comments on commit bffa35c

Please sign in to comment.