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
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,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|rename|untrack|track|pop|restack|continue|abort|undo|submit|sync|up|down|top|bottom|pr`
- **Aliases** (`--aliases`): `git create|modify|absorb|reorder|move|fold|rename|untrack|track|pop|restack|continue|abort|undo|submit|sync|up|down|top|bottom|pr|get`
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 @@ -593,6 +593,32 @@ Flags:
- `-s` / `--stack` only show the current stack
- `-t` / `--trunk` check out the trunk of the current stack

### `git stack get <pr|url|branch>`

> Alias: `git get`

Pulls a pull request down from GitHub so you can work on it, by number (`418` or `#418`), URL or
branch name. Its branch and the rest of its stack are created locally where you don't have them,
fast forwarded where the remote is ahead, tracked as a stack and its branch is checked out.

```
❯ git get 418
✔ Pulled down a stack of 3 branches on main and checked out payments-retry-queue
created payments-db, payments-api and payments-retry-queue from the remote
```

A pull request in a stack on GitHub brings the whole stack (through `gh stack checkout`). One that
isn't, or a repository without stacked PRs, gets the pull request and the open ones it's based on,
down to the first base that has none, which becomes the trunk. If you already have the stack it's
just brought up to date.

Nothing is rebased or pushed, and a URL for another repository is refused. If the branch can't be
checked out (files in the way, or it's checked out in another worktree) the stack is still got
and you're told why. A branch that has diverged from the remote is left as it is with a
warning, the same as `sync` does. Pull requests from forks can't be got this way (`gh pr checkout`
checks one out without tracking it), and neither can a closed or merged one, or one based on a
branch that's already in another of your stacks.

### `git stack pr [branch]`

> Alias: `git pr`
Expand Down Expand Up @@ -712,7 +738,7 @@ git modify
```

It covers create (`--insert` too), modify, absorb, restack, reorder, move, fold, pop, untrack,
init (`track`), sync and a rename that stayed on your machine. Every branch they touched goes back
init (`track`), get, sync and a rename that stayed on your machine. Every branch they touched goes back
to where it was (a branch sync or pop deleted comes back, one create made goes away), the stack
metadata is put back exactly, and so is what you had checked out. After create, modify or absorb
the changes they committed come back as staged changes, and after a pop you're back on the popped
Expand Down
27 changes: 27 additions & 0 deletions cmd/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra"

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

Expand Down Expand Up @@ -286,3 +287,29 @@ func (c *cli) completeTrunks(cmd *cobra.Command, _ []string, toComplete string)
}
return filterPrefix(items, toComplete), cobra.ShellCompDirectiveNoFileComp
}

// completeOpenPRs offers the open pull requests the PR cache knows about,
// by number with their branch and title, newest first. It never refreshes
// the cache.
func (c *cli) completeOpenPRs(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
a, repo, err := c.completionApp(ctx)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
prs, _, _ := a.CachedPRs(repo)
var items []cobra.Completion
for _, pr := range prs {
if pr.Fork || (pr.State != forge.StateOpen && pr.State != forge.StateDraft) {
continue
}
items = append(items, cobra.CompletionWithDesc(strconv.Itoa(pr.Number), pr.Head+" · "+pr.Title))
}
return filterPrefix(items, toComplete), cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveKeepOrder
}
97 changes: 97 additions & 0 deletions cmd/get.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package cmd

import (
"fmt"
"strings"

"github.com/spf13/cobra"

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

func newGetCmd(c *cli) *cobra.Command {
cmd := &cobra.Command{
Use: "get <pr|url|branch>",
Short: "Pull a pull request's stack down from GitHub and check it out",
Long: `Pulls a pull request down from GitHub so you can work on it: its branch and
the branches of its stack are created locally (or fast forwarded where the
remote is ahead), tracked as a stack, and its branch is checked out. Name it
by number (123 or #123), URL or branch name.

A pull request in a stack on GitHub brings the whole stack, through gh stack.
One that isn't comes with the open pull requests it's based on, down to the
first base that has none, which becomes the trunk. A stack you already have
is just brought up to date.

Nothing is rebased or pushed, and a branch that has diverged from the remote
is left alone with a warning. Pull requests from forks can't be got this way.`,
Example: ` # a colleague's pull request, by number
git get 418

# or by URL, or by branch name
git get https://github.com/acme/payments/pull/418
git get payments-retry-queue`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: c.completeOpenPRs,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
a, repo, err := c.app(ctx)
if err != nil {
return err
}
res, err := a.Get(ctx, repo, app.GetOptions{Target: args[0]})
rep := c.report()
if err == nil {
rep.Success("%s", getSummary(rep, res))
}
if len(res.Branches) == 0 {
return err
}
renderGet(rep, res)
return err
},
}
return cmd
}

// getSummary is the result line: what was got and what's checked out.
func getSummary(rep *ui.Reporter, res app.GetResult) string {
n := len(res.Branches)
switch res.Source {
case app.GetLocal:
return fmt.Sprintf("Checked out %s; its stack was already here", rep.Branch(res.Branch))
case app.GetPRs:
return fmt.Sprintf("Tracking %d %s on %s and checked out %s", n, branchNoun(n), rep.Branch(res.Trunk), rep.Branch(res.Branch))
default:
return fmt.Sprintf("Pulled down a stack of %d %s on %s and checked out %s", n, branchNoun(n), rep.Branch(res.Trunk), rep.Branch(res.Branch))
}
}

// renderGet prints what get did underneath the result line.
func renderGet(rep *ui.Reporter, res app.GetResult) {
if res.Source == app.GetPRs {
rep.Info("the pull request isn't in a stack on GitHub, so it was tracked as a stack of its own")
}
if len(res.Created) > 0 {
names := make([]string, len(res.Created))
for i, name := range res.Created {
names[i] = rep.Branch(name)
}
rep.Info("created %s from the remote", joinAnd(names))
}
for _, u := range res.Updated {
rep.Info("%s fast forwarded to %s", rep.Branch(u.Name), rep.SHA(u.To))
}
for _, n := range res.NotUpdated {
if !n.Failed() {
renderBlocked(rep, n)
}
}
if len(res.NeedsRestack) > 0 {
rep.Warn("%s %s not on the branch below; run git stack restack", strings.Join(res.NeedsRestack, ", "), isAre(len(res.NeedsRestack)))
}
for _, n := range res.Notices {
rep.Warn("%s", n)
}
}
42 changes: 42 additions & 0 deletions cmd/get_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cmd

import (
"strings"
"testing"

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

func TestGetCommand(t *testing.T) {
gittest.Isolate(t)
dir := gittest.InitRepo(t)
gittest.Run(t, dir, "switch", "-q", "-c", "feat-a")
gittest.Commit(t, dir, "a.txt", "a", "feat: a")
gittest.InitRemote(t, dir)
gittest.Run(t, dir, "switch", "-q", "main")
gittest.Run(t, dir, "branch", "-q", "-D", "feat-a")

f := fakeGh(t, dir)
f.On("gh", "stack", "checkout").Fail(2, "PR #7 is not part of a stack on GitHub")
f.On("gh", "pr", "view", "7").Reply(`{"number":7,"url":"u/7","title":"A","state":"OPEN","isDraft":false,"isCrossRepository":false,"headRefName":"feat-a","baseRefName":"main"}`)
run := func(args ...string) (string, string, error) {
t.Helper()
return runWith(t, f, append([]string{"--cwd", dir}, args...)...)
}

out, errOut, err := run("get", "#7")
if err != nil || !strings.Contains(out, "ok: Tracking 1 branch on main and checked out feat-a") ||
!strings.Contains(out, "isn't in a stack on GitHub") || !strings.Contains(out, "created feat-a from the remote") {
t.Errorf("get: %q %q %v", out, errOut, err)
}
if cur := gittest.Run(t, dir, "branch", "--show-current"); cur != "feat-a" {
t.Errorf("checked out %s", cur)
}
out, _, err = run("get", "feat-a")
if err != nil || !strings.Contains(out, "ok: Checked out feat-a; its stack was already here") {
t.Errorf("again: %q %v", out, err)
}
if _, _, err = run("get"); err == nil {
t.Error("no target should fail")
}
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ func newRootCmd(c *cli) *cobra.Command {
newTopCmd(c),
newBottomCmd(c),
newCheckoutCmd(c),
newGetCmd(c),
newLogCmd(c),
newPRCmd(c),
newInstallCmd(c),
Expand Down
24 changes: 24 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,30 @@ bottom branch lands on trunk, overshooting clamps silently, merged branches are
also check `worktreepath` before switching so you get told which worktree has the branch
rather than a confusing git error.

**`get` lets gh stack find the stack, and does lone pull requests itself.** `gh stack checkout
<pr>` already asks GitHub which stack a pull request is in, creates the missing branches and
records the stack, so `get` hands it any target that isn't tracked here. It has gaps we fill:
a branch that already exists locally is used as it is, never updated, so `get` fetches first and
then fast forwards the stack's branches with sync's own code (diverged ones get sync's notice);
and a pull request in no stack on GitHub (or a repository without stacked PRs) is exit 2 or 9, so
`get` looks the pull request up through the forge, walks down the open pull requests it's based
on to the first base without one (asking for each base's open pull request by branch, since the
list of recent pull requests sync uses stops at 500), creates those branches from the remote and records them the way
`init` does. A pull request number nothing here records is looked up first and its branch checked against
the graph, because a stack git-stack tracked itself has no PR records until gh stack submits it.
A target that's already tracked here never reaches gh stack, since its composition
check would refuse a stack reordered locally but not yet submitted. Once branches have moved, a failed
checkout is a partial result rather than an error, so undo still records it. That includes gh stack
failing after it saved the stack (it creates the branches and saves before it checks out): `get`
finds the target in the newly saved stack and carries on, and its own checkout reports the failure.
A pull request looked up through the forge is refused when closed, merged or from a fork before
gh stack sees it, as gh stack would take a merged one still in a stack with open ones. The lone pull
request path checks every branch is on the remote before creating any, and deletes the ones it
created if tracking them fails. A URL is checked
against the repository the forge works out from the remotes, as its number means a different
pull request anywhere else. Forks are refused rather than
guessed at: their branches aren't on our remote, and submit couldn't push them back anyway.

**`create` from trunk runs `gh stack init`, from the top runs `gh stack add`, and we
always commit natively.** This sidesteps both `add` quirks above. From the middle of a
stack you get a `not_at_top` error with next steps (unless you pass `--insert`, see below);
Expand Down
3 changes: 2 additions & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ path is resolved with `git rev-parse`; anything outside a repository returns `no
| `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 |
| `stack_abort` | Give up an interrupted restack and put every moved branch back (and, after a reorder or move, the old stack order) | | destructive |
| `stack_undo` | Undo the newest command run in this worktree that changed local state (create, modify, absorb, restack, reorder, move, fold, pop, untrack, init, sync, a local rename); call again to step further back, up to 20. After create, modify or absorb the changes come back staged (`staged: true`). A successful `stack_submit` or `stack_merge` clears the history. Refuses with `invalid_args` when nothing is left, when a branch moved since (`force` throws that away; uncommitted changes always stay) or when local changes are in the way, with nothing changed | `list` (the history with what each would put back, changing nothing), `force` (only with the user's say so) | destructive |
| `stack_undo` | Undo the newest command run in this worktree that changed local state (create, modify, absorb, restack, reorder, move, fold, pop, untrack, init, get, sync, a local rename); call again to step further back, up to 20. After create, modify or absorb the changes come back staged (`staged: true`). A successful `stack_submit` or `stack_merge` clears the history. Refuses with `invalid_args` when nothing is left, when a branch moved since (`force` throws that away; uncommitted changes always stay) or when local changes are in the way, with nothing changed | `list` (the history with what each would put back, changing nothing), `force` (only with the user's say so) | destructive |
| `stack_restack` | Local rebase of the stack onto its parents, bottom branch onto the local trunk included; only checks out a branch to resolve a conflict through one git rebase | `scope` = `all`/`upstack`/`downstack`/`only`, `branch`, `continue`, `abort`, `stage_all` | destructive |
| `stack_navigate` | `up`/`down`/`top`/`bottom` with `steps`, or `branch` | `direction`, `steps`, `branch` | idempotent |
| `stack_get` | Pull a pull request and its stack down from GitHub and check out its branch: the whole stack when it's in one on GitHub, else the open pull requests it's based on as a new stack; a stack already here is only brought up to date. Nothing is rebased or pushed | `target` (number, `#123`, URL or branch) | open world (fetches and asks GitHub) |
| `stack_submit` | Push and create/update chained PRs (with `down`, only up to the current branch); ready for review by default; when a PR of the stack has merged since the last sync it syncs first (`merged` names the branches, `sync` says what happened) | `down`, `branch`, `draft`, `publish`, `dry_run`, `pull_requests` `{branch: {title, body}}`, `use_ai`, `force` | destructive, open-world |
| `stack_sync` | Fetch, update trunk and restack every stack (checked out or not, all worktrees), never pushing; merged branches deleted per `stack.sync.prune` | `prune` (force deletion) | destructive, open-world |
| `stack_merge` | Merge the stack's PRs up to a branch into trunk, all or nothing, then sync; refuses while a check on any of them failed or is still running | `branch` (default current), `method` = `merge`/`squash`/`rebase`, `no_sync`, `force` (skip the checks check; only with the user's say so) | destructive, open-world |
Expand Down
Loading
Loading