-
Notifications
You must be signed in to change notification settings - Fork 21
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]>
- Loading branch information
Showing
11 changed files
with
372 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
# |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.