Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,10 @@ merging the PRs on GitHub.

gh stack can only add branches at the top of a stack, so `git create --insert` does the middle
of a stack itself, the same way a reorder does. Reordering a stack, moving branches onto another
stack, folding a branch into the one below it, dropping one from a stack and adopting branches you
made by hand are `git stack reorder`, `git stack move`, `git stack fold`, `git stack pop` and
`git stack init`, so nothing needs gh stack directly any more.
stack, folding a branch into the one below it, dropping one from a stack, adopting branches you made
by hand and letting go of them again are `git stack reorder`, `git stack move`, `git stack fold`,
`git stack pop`, `git stack init` and `git stack untrack`, so nothing needs gh stack directly any
more.

## Install

Expand Down Expand Up @@ -145,7 +146,7 @@ Claude Code and Codex if you have them installed**. Each of those is on by defau
change before it happens, and `--uninstall` removes what it recorded installing and nothing else
(that record lives in `git config --global stack.managed*`).

- **Aliases** (`--aliases`): `git create|modify|absorb|reorder|move|fold|track|pop|restack|continue|abort|submit|sync|up|down|top|bottom|pr`
- **Aliases** (`--aliases`): `git create|modify|absorb|reorder|move|fold|untrack|track|pop|restack|continue|abort|submit|sync|up|down|top|bottom|pr`
and the short ones `git c|m|ab|rs|ss|u|d|t|b|co`, written with `git config --global`. Anything that
clashes with a git builtin or an installed command is skipped, and an alias you already have is
only replaced if you pass `--force` or say yes when asked.
Expand Down Expand Up @@ -472,6 +473,33 @@ branch has an open PR, the next `git ss` closes it with a comment saying why.
#412 will be closed at the next submit
```

### `git stack untrack [branch]`

> Alias: `git untrack`

The opposite of `git track`: stops tracking a branch (the one you're on unless you name one) and
every branch above it. gh stack keeps a stack as a single line, so taking a branch out of the
middle can't leave a gap above it. The branches themselves aren't touched at all, no commit, ref or
checkout changes, and a stack left with nothing in it goes away.

With `--only` just that branch comes out and the branches above it close the gap: they now sit on
its parent and get restacked there, which drops its commits from them. A conflict stops like any
restack, and `git abort` puts everything back, the untracked branch included.

PRs are left open either way. If GitHub already had the stack, the next `git ss` regroups what's
left of it. `git track` with the same branches tracks them again (as a stack of their own).

```
# on billing-webhook-alerts, in the middle of its stack
❯ git untrack --only
✔ Untracked billing-webhook-alerts
✔ Restacked billing-webhook-retries onto billing-webhook-schema
git stack init billing-webhook-alerts tracks it again
```

Flags:
- `--only` untrack just this branch; the branches above it move down onto its parent

### `git stack submit`

> Aliases: `git submit`, `git ss`
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ func newRootCmd(c *cli) *cobra.Command {
newReorderCmd(c),
newMoveCmd(c),
newFoldCmd(c),
newUntrackCmd(c),
newInitCmd(c),
newPopCmd(c),
newRestackCmd(c),
Expand Down
68 changes: 68 additions & 0 deletions cmd/untrack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package cmd

import (
"strings"

"github.com/spf13/cobra"

"github.com/DomBlack/git-stack/pkg/app"
)

func newUntrackCmd(c *cli) *cobra.Command {
var only bool
cmd := &cobra.Command{
Use: "untrack [branch]",
Short: "Stop tracking a branch, and the ones above it, as part of a stack",
Long: `Takes a branch (the current one unless named) out of its stack, together with
every branch above it, since gh stack keeps a stack as a single line and it
can't have a gap. The branches themselves are left exactly as they are: no
commit, ref or checkout changes. A stack left empty goes away.

With --only just that branch is untracked, and the branches above it close the
gap: they now sit on its parent and are restacked there, which drops its
commits from them. Conflicts stop like a restack and "git stack abort" puts
everything back.

Pull requests stay open. A stack GitHub already knows is regrouped at the next
submit. "git stack init" tracks the branches again.`,
Example: ` # stop tracking this branch and everything above it
git untrack

# drop one branch from the middle of its stack
git untrack feat/experiment --only`,
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return c.completeStackedBranches(cmd, args, toComplete)
},
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
a, repo, err := c.app(ctx)
if err != nil {
return err
}
o := app.UntrackOptions{Only: only}
if len(args) > 0 {
o.Branch = args[0]
}
rep := c.report()
res, err := a.Untrack(ctx, repo, o)
renderNotices(rep, res.Notices)
if err != nil {
return err
}
rep.Success("Untracked %s", strings.Join(res.Untracked, ", "))
if len(res.Restacked) > 0 {
rep.Success("Restacked %s onto %s", strings.Join(res.Restacked, ", "), rep.Branch(res.Parent))
}
for _, step := range res.NextSteps {
rep.Info("%s tracks %s again", step, pluralWord(len(res.Untracked), "it", "them"))
}
return nil
},
}
cmd.Flags().BoolVar(&only, "only", false, "untrack just this branch; the branches above it move down onto its parent")
return cmd
}
29 changes: 29 additions & 0 deletions cmd/untrack_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package cmd

import (
"strings"
"testing"

"github.com/DomBlack/git-stack/pkg/git/gittest"
)

func TestUntrackCommand(t *testing.T) {
dir, run := stackOf3(t)
out, errOut, err := run("untrack", "a", "--only")
if err != nil || !strings.Contains(out, "ok: Untracked a") || !strings.Contains(out, "Restacked b, c onto main") || !strings.Contains(out, "git stack init a tracks it again") {
t.Errorf("untrack --only: %q %q %v", out, errOut, err)
}
if _, err := gittest.RunErr(t, dir, "cat-file", "-e", "b:a.txt"); err == nil {
t.Error("b still has a's file")
}
out, errOut, err = run("untrack", "b")
if err != nil || !strings.Contains(out, "ok: Untracked b, c") || !strings.Contains(out, "git stack init b c tracks them again") {
t.Errorf("untrack: %q %q %v", out, errOut, err)
}
if _, _, err = run("untrack"); err == nil || !strings.Contains(err.Error(), "c is not in a stack") {
t.Errorf("untracked: %v", err)
}
if _, _, err = run("untrack", "main"); err == nil || !strings.Contains(err.Error(), "trunk") {
t.Errorf("trunk: %v", err)
}
}
10 changes: 10 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,16 @@ because deleting a branch on GitHub closes every open PR based on it, and it's t
that moves the PR above onto the surviving branch; a remote branch something is still based on is
kept and its delete stays queued.

**Untrack is init's inverse, as a reshape.** A stack is a single line, so untracking a branch takes
everything above it too and needs no restack: the edit drops those entries and touches no ref. With
`--only` the branches above close the gap instead, and it runs like `move --only` without the
destination: their parent changes, the base fixing keeps the untracked branch's commits out of
them, and one segment restacks them onto its parent. The untracked branch is in no stack
afterwards, so restoring the snapshot can't pick its entry up from the graph the way it does for a
moved branch; the snapshot names it, with its head and base, and abort puts it back. Either way a
stack GitHub knows is queued for dissolving like any reshape, and no pull request is closed, since
the branches still exist and may be tracked again.

**Merged branches are deleted by default.** `stack.sync.prune` decides: `always` (default)
deletes branches whose PRs merged or closed, `ask` prompts on a terminal and keeps with a
notice otherwise, `never` keeps. `-d` and `-f` delete regardless. A branch checked out in
Expand Down
1 change: 1 addition & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ path is resolved with `git rev-parse`; anything outside a repository returns `no
| `stack_reorder` | Change the order of the current stack's branches and restack them, checking nothing out; the next `stack_submit` dissolves the stale stack on GitHub and creates it again in the new order; a branch checked out with changes in another worktree refuses the call with nothing changed | `order` (required: every branch of the stack once, bottom to top) | destructive |
| `stack_move` | Move a branch, and unless `only` is set everything above it, onto the top of another stack or onto a trunk as a new stack, then restack; moves within one stack are `stack_reorder`'s job | `onto` (required: a trunk or another stack's top branch), `source` (default current), `only` | destructive |
| `stack_fold` | Merge the current branch into the branch below it, one branch and one PR; the parent survives and is checked out, or with `keep` the current branch takes the parent's place; nothing is rebased, and the next `stack_submit` closes the PR of the branch that went | `keep` | destructive |
| `stack_untrack` | Stop tracking a branch and every branch above it, changing no commit, ref or checkout; with `only` just that branch, and the branches above are restacked onto its parent; pull requests stay open and the next `stack_submit` regroups a stack GitHub knows; `next_steps` says how to track them again | `branch` (default current), `only` | destructive |
| `stack_init` | Track branches that already exist as a new stack on a trunk; only the metadata changes, and `needsRestack` lists the branches that aren't on their parent | `branches` (bottom to top, default current), `base` | destructive (it writes the metadata, though no branch moves) |
| `stack_pop` | Delete the current branch and take it out of its stack, leaving its changes staged on its parent; the branches above are restacked onto the parent first, and its open PR is closed by the next `stack_submit`; refuses with nothing changed when tracked files have changes, the branch isn't on its parent or the parent is checked out in another worktree | | destructive |
| `stack_continue` | Finish an interrupted restack, modify, reorder, move or pop once the conflicts are resolved and git added | `stage_all` | destructive |
Expand Down
119 changes: 119 additions & 0 deletions pkg/app/untrack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package app

import (
"context"
"errors"
"fmt"
"slices"
"strings"

"github.com/DomBlack/git-stack/pkg/git"
"github.com/DomBlack/git-stack/pkg/stack"
)

// UntrackOptions stops tracking a branch.
type UntrackOptions struct {
// Branch is the branch to untrack; the current branch when empty.
Branch string
// Only untracks Branch alone: the branches above it stay in the stack,
// now on Branch's parent, and are restacked there.
Only bool
}

// UntrackResult reports what happened.
type UntrackResult struct {
Branch string `json:"branch"`
// Untracked are the branches no longer in a stack, bottom to top
// (Branch first).
Untracked []string `json:"untracked"`
// Parent is where the branches above Branch sit now, with Only and
// branches above it.
Parent string `json:"parent,omitempty"`
Restacked []string `json:"restacked,omitempty"`
// NextSteps say how to track the branches again.
NextSteps []string `json:"nextSteps,omitempty"`
Notices []string `json:"notices,omitempty"`
}

const untrackCommand = "git stack untrack"

// Untrack takes a branch, and unless Only everything above it, out of its
// stack without touching the branches themselves: a stack is a single line,
// so it can't keep a gap. With Only the branches above close the gap and
// are restacked onto the untracked branch's parent, dropping its commits.
// A stack GitHub knows is queued to be dissolved at the next submit; no
// pull request is closed.
func (a *App) Untrack(ctx context.Context, repo git.Repo, o UntrackOptions) (UntrackResult, error) {
if err := a.noRebaseActive(ctx, repo); err != nil {
return UntrackResult{}, err
}
graph, err := a.d.Meta.Load(ctx, repo)
if err != nil {
return UntrackResult{}, err
}
name := o.Branch
if name == "" {
name, err = a.d.Git.CurrentBranch(ctx, repo)
if err != nil {
if errors.Is(err, git.ErrDetached) {
return UntrackResult{}, stack.New(stack.KindInvalidArgs, "HEAD is detached; check out a stacked branch or name one")
}
return UntrackResult{}, err
}
}
s, idx, ok := graph.StackOf(name)
if !ok {
// With no stacks left on it the graph no longer knows the trunk;
// the repository's default branch is one all the same.
def, _, _ := a.d.Git.DefaultBranch(ctx, repo)
if graph.IsTrunk(name) || name == def {
return UntrackResult{}, stack.Newf(stack.KindInvalidArgs, "%s is a trunk; untrack works on stacked branches", name)
}
exists, err := a.d.Git.BranchExists(ctx, repo, name)
if err != nil {
return UntrackResult{}, err
}
if !exists {
return UntrackResult{}, stack.Newf(stack.KindInvalidArgs, "no branch %s", name)
}
return UntrackResult{}, stack.Newf(stack.KindNotInStack, "%s is not in a stack", name)
}
if err := noMergedBranches(s); err != nil {
return UntrackResult{}, err
}
end := len(s.Branches)
if o.Only {
end = idx + 1
}
parent, _ := graph.Parent(name)
above := slices.Clone(s.Names()[end:])
res := UntrackResult{Branch: name, Untracked: slices.Clone(s.Names()[idx:end])}
res.NextSteps = []string{"git stack init " + strings.Join(res.Untracked, " ")}
bottom := s.Bottom()
e := reshapeEdit{
touched: []string{bottom},
relink: []string{bottom},
dropped: res.Untracked,
edit: func(g *stack.Graph) error {
s, i, ok := g.StackOf(name)
if !ok {
return stack.Newf(stack.KindInvalidArgs, "%s is no longer in a stack", name)
}
end := len(s.Branches)
if o.Only {
end = i + 1
}
s.Branches = slices.Delete(s.Branches, i, end)
return nil
},
}
if len(above) > 0 {
res.Parent = parent
e.segments = []restackSegment{{Parent: parent, Branches: above}}
}
headline := fmt.Sprintf("Restacking %s onto %s", joinNames(above), parent)
rr, err := a.reshape(ctx, repo, untrackCommand, headline, e)
res.Restacked = movedNames(rr)
res.Notices = rr.Notices
return res, err
}
Loading
Loading