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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,35 @@ Flags:
- `--rebase` rebase the commits onto trunk as they are
- `--squash` squash each PR into one commit


### `git stack doctor`

Checks that git-stack has everything it needs and, inside a repository, that your stacks are in
good shape. Each check has the checks that only make sense once it passes hanging off it, so a
missing gh doesn't also report a missing login:

- git, and that it's 2.40 or later
- gh, its login, the gh-stack extension and that it's v0.3.0 or later
- git-stack's git aliases (one you set to something else yourself is listed, never replaced) and
whether a newer git-stack release is out
- gh stack's stack file, and that no worktree still keeps its own from gh-stack 0.2 (the cause of
gh stack's `cannot migrate stack catalogs` error)
- no restack waiting to be finished, every tracked branch still exists, and no branch with no
commits of its own sits under others (submit would stop there)

Every problem comes with what to do about it, and it exits non-zero while anything fails. Outside a
repository it runs the checks that don't need one.

```sh
git stack doctor
git stack doctor --fix # install or upgrade gh-stack, set missing aliases, update
# git-stack, move per worktree stack files into the shared one,
# forget branches that are gone; then check again
```

Flags:
- `--fix` fix what can be fixed automatically, then check again

## Configuration

Everything is plain `git config`, so set it globally or per repo as you like.
Expand Down
99 changes: 99 additions & 0 deletions cmd/doctor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package cmd

import (
"context"
"fmt"
"runtime"

"github.com/spf13/cobra"

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

func newDoctorCmd(c *cli) *cobra.Command {
var fix bool
cmd := &cobra.Command{
Use: "doctor",
Short: "Check that git-stack has what it needs, and fix what it can",
Long: `Runs a list of checks, each with the checks that only make sense once it
passes: git and its version, gh and its login, the gh-stack extension and its
version, git-stack's own aliases and whether a newer release is out, and
inside a repository gh stack's stack file and the stacks
themselves (an interrupted restack, tracked branches that are gone, an empty
branch under others that submit would stop at).

Every problem says what to do about it. With --fix, the ones git-stack can
put right itself are fixed (installing or upgrading gh-stack, setting the
missing aliases, updating git-stack, moving stack files gh-stack 0.2 kept per
worktree into the shared one, forgetting branches that are gone) and everything is checked again. It exits non-zero while
anything still fails.`,
Example: ` # see what's wrong
git stack doctor

# and fix what can be fixed
git stack doctor --fix`,
Args: cobra.NoArgs,
ValidArgsFunction: completeNothing,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
a, repo, err := c.doctorApp(ctx)
if err != nil {
return err
}
target, err := c.updatePath()
if err != nil {
return err
}
info := c.currentVersion()
extra := []doctor.Provider{
gitconfig.New(c.runtime().Git),
&update.Doctor{
Client: c.updaterClient(info),
Options: update.Options{Current: info, Target: target, GOOS: runtime.GOOS, GOARCH: runtime.GOARCH},
AfterUpdate: func(ctx context.Context, exe string) error {
return refreshInstall(ctx, c, exe)
},
},
}
res, err := a.Doctor(ctx, repo, app.DoctorOptions{Fix: fix, Extra: extra})
if err != nil {
return err
}
rep := c.report()
rep.Print(ui.RenderDoctor(res.Checks, rep.Styles(), rep.Links()))
return doctorOutcome(rep, res)
},
}
cmd.Flags().BoolVar(&fix, "fix", false, "fix what can be fixed automatically, then check again")
return cmd
}

// doctorOutcome prints the result line, and is an error while a check
// still fails.
func doctorOutcome(rep *ui.Reporter, res app.DoctorResult) error {
fixed := ""
if res.Fixed > 0 {
fixed = fmt.Sprintf(", %d fixed", res.Fixed)
}
if res.Failed > 0 {
e := stack.Newf(stack.KindPartial, "%d %s failed%s", res.Failed, pluralWord(res.Failed, "check", "checks"), fixed)
if res.Fixable > 0 {
e = e.WithSteps("git stack doctor --fix fixes what it can")
}
return e
}
if res.Warned > 0 {
rep.Partial("Checked git-stack's setup: %d %s%s", res.Warned, pluralWord(res.Warned, "warning", "warnings"), fixed)
if res.Fixable > 0 {
rep.NextStep("git stack doctor --fix fixes what it can")
}
return nil
}
rep.Success("Checked git-stack's setup: everything is in order%s", fixed)
return nil
}
14 changes: 14 additions & 0 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"context"
"os"
"runtime"

"github.com/spf13/cobra"

Expand All @@ -11,10 +12,13 @@ import (
"github.com/DomBlack/git-stack/pkg/backend/ghstack"
"github.com/DomBlack/git-stack/pkg/cache"
"github.com/DomBlack/git-stack/pkg/config"
"github.com/DomBlack/git-stack/pkg/doctor"
"github.com/DomBlack/git-stack/pkg/exec"
"github.com/DomBlack/git-stack/pkg/forge/github"
"github.com/DomBlack/git-stack/pkg/git"
"github.com/DomBlack/git-stack/pkg/gitconfig"
mcpserver "github.com/DomBlack/git-stack/pkg/mcp"
"github.com/DomBlack/git-stack/pkg/update"
"github.com/DomBlack/git-stack/pkg/version"
)

Expand Down Expand Up @@ -50,6 +54,15 @@ agents such as Claude Code and Codex can drive git-stack. Register it with
Cwd: cwd,
Git: g,
Log: log,
NewBareApp: func(context.Context) (*app.App, error) {
return app.New(app.Deps{Git: g, Log: log, Doctors: []doctor.Provider{ghstack.New(runner, g)}}), nil
},
Doctors: []doctor.Provider{
gitconfig.New(g),
// No AfterUpdate: the server reports a newer release but
// never replaces the binary it runs as.
&update.Doctor{Client: update.New(version.Current()), Options: update.Options{Current: version.Current(), GOOS: runtime.GOOS, GOARCH: runtime.GOARCH}},
},
NewApp: func(ctx context.Context, repo git.Repo) (*app.App, error) {
cfg, err := config.Load(ctx, g, repo)
if err != nil {
Expand All @@ -63,6 +76,7 @@ agents such as Claude Code and Codex can drive git-stack. Register it with
Command: cfg.AICommand, Model: cfg.AIModel, ExtraPrompt: cfg.AIExtraPrompt, Timeout: cfg.AITimeout,
}, log),
Cache: cache.New(repo), Config: cfg, Log: log,
Doctors: []doctor.Provider{backend},
}), nil
},
})
Expand Down
23 changes: 23 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/DomBlack/git-stack/pkg/backend/ghstack"
"github.com/DomBlack/git-stack/pkg/cache"
"github.com/DomBlack/git-stack/pkg/config"
"github.com/DomBlack/git-stack/pkg/doctor"
"github.com/DomBlack/git-stack/pkg/exec"
"github.com/DomBlack/git-stack/pkg/forge/github"
"github.com/DomBlack/git-stack/pkg/git"
Expand Down Expand Up @@ -196,6 +197,7 @@ func (c *cli) app(ctx context.Context) (*app.App, git.Repo, error) {
Config: cfg,
Log: rt.Log,
Progress: rt.Report.Step,
Doctors: []doctor.Provider{backend},
}
if rt.Interactive {
deps.Prompter = ui.Prompter{In: rt.Streams.In, Out: rt.Streams.Err, Ctx: ctx, Wait: rt.Report.Waiting}
Expand All @@ -206,6 +208,26 @@ func (c *cli) app(ctx context.Context) (*app.App, git.Repo, error) {
return a, repo, nil
}

// doctorApp wires the use cases for git stack doctor, which also runs
// outside a repository: then repo is nil and only the checks that don't
// need one run.
func (c *cli) doctorApp(ctx context.Context) (*app.App, *git.Repo, error) {
rt := c.runtime()
repo, err := rt.Repo(ctx)
if err != nil {
if se, ok := errors.AsType[*stack.Error](err); !ok || se.Kind != stack.KindNotRepo {
return nil, nil, err
}
Comment on lines +218 to +220
backend := ghstack.New(rt.Runner, rt.Git)
return app.New(app.Deps{Git: rt.Git, Log: rt.Log, Progress: rt.Report.Step, Doctors: []doctor.Provider{backend}}), nil, nil
}
a, repo, err := c.app(ctx)
if err != nil {
return nil, nil, err
}
return a, &repo, nil
}

// completionApp wires a read-only App for shell completion: metadata comes
// from the backend's local file and only git may run.
func (c *cli) completionApp(ctx context.Context) (*app.App, git.Repo, error) {
Expand Down Expand Up @@ -348,6 +370,7 @@ func newRootCmd(c *cli) *cobra.Command {
newCompletionCmd(c),
newVersionCmd(c),
newUpdateCmd(c),
newDoctorCmd(c),
)
c.ownTerminal(root)
return root
Expand Down
44 changes: 29 additions & 15 deletions cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/DomBlack/git-stack/pkg/stack"
"github.com/DomBlack/git-stack/pkg/ui"
"github.com/DomBlack/git-stack/pkg/update"
"github.com/DomBlack/git-stack/pkg/version"
)

func newUpdateCmd(c *cli) *cobra.Command {
Expand Down Expand Up @@ -43,21 +44,11 @@ the new release without prompting. Only the changes are printed.`,
ValidArgsFunction: completeNothing,
RunE: func(cmd *cobra.Command, _ []string) error {
info := c.currentVersion()
target := c.updateTarget
if target == "" {
exe, err := os.Executable()
if err != nil {
return err
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
target = exe
}
client := c.updateClient
if client == nil {
client = update.New(info)
target, err := c.updatePath()
if err != nil {
return err
}
client := c.updaterClient(info)
rep := c.report()
opts := update.Options{
Current: info, Target: target, GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, Check: check, Force: force,
Expand All @@ -67,7 +58,7 @@ the new release without prompting. Only the changes are printed.`,
notes []update.ReleaseNote
notesErr error
)
err := rep.Step(cmd.Context(), app.PhaseUpdate, "Checking for updates", func(ctx context.Context) error {
err = rep.Step(cmd.Context(), app.PhaseUpdate, "Checking for updates", func(ctx context.Context) error {
var err error
plan, err = client.Plan(ctx, opts)
if err != nil {
Expand Down Expand Up @@ -127,6 +118,29 @@ the new release without prompting. Only the changes are printed.`,
return cmd
}

// updatePath is the binary update replaces: this one, symlinks resolved.
func (c *cli) updatePath() (string, error) {
if c.updateTarget != "" {
return c.updateTarget, nil
}
exe, err := os.Executable()
if err != nil {
return "", err
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return exe, nil
}

// updaterClient is the release source, unless a test injected one.
func (c *cli) updaterClient(info version.Info) *update.Client {
if c.updateClient != nil {
return c.updateClient
}
return update.New(info)
}

// maxNotes is how many releases update lists before "and N more".
const maxNotes = 10

Expand Down
1 change: 1 addition & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ path is resolved with `git rev-parse`; anything outside a repository returns `no
| `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_rename` | Give the current branch a new name, keeping its place, commits and base; a pushed branch is renamed on GitHub first (keeping its PR and retargeting PRs based on it, `remote` in the result) and nothing changes if GitHub refuses; `local` renames only this repository and leaves the PR on the old name | `name` (required), `local` | destructive, open-world |
| `stack_doctor` | Check git-stack's setup and the repository's stacks: git, gh and its login, the gh-stack extension and its version (v0.3.0 or later), gh stack's stack file (files still kept per worktree by gh-stack 0.2), git-stack's aliases and whether a newer release is out (reported only; updating is `git stack update`), an interrupted restack, tracked branches that are gone, empty branches under others; outside a repository (no `repo_path`) only the checks that need none. `checks` is flat, each with `parent`, `status` (`ok`, `warn`, `fail`, `skipped`, `fixed`), `summary`, `details`, `next_steps` and `fix` when it can put it right itself; `fix_error` says why a fix didn't work | `fix` (run the fixes, then check again) | open-world |
| `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
1 change: 1 addition & 0 deletions docs/style.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ read as section markers rather than confetti because they never show up anywhere
| merge | 🔀 | Merging 3 pull requests into main… |
| rename | 🏷️ | Renaming feat-a on origin… |
| refresh | 📥 | Fetching pull requests… (only when the PR cache is stale) |
| doctor | 🩺 | Checking git-stack's setup…, then Fixing: gh extension upgrade stack… |
| install | 🔧 | (install prints a report, no spinner) |

## Spinners
Expand Down
6 changes: 6 additions & 0 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/DomBlack/git-stack/pkg/ai"
"github.com/DomBlack/git-stack/pkg/cache"
"github.com/DomBlack/git-stack/pkg/config"
"github.com/DomBlack/git-stack/pkg/doctor"
"github.com/DomBlack/git-stack/pkg/forge"
"github.com/DomBlack/git-stack/pkg/git"
"github.com/DomBlack/git-stack/pkg/stack"
Expand All @@ -27,6 +28,9 @@ type Deps struct {
Config *config.Config
Log *slog.Logger
Prompter Prompter
// Doctors contribute checks to Doctor beyond the ones the use cases
// make themselves (the backend checks gh and gh-stack).
Doctors []doctor.Provider
// Progress, when set, wraps slow operations (backend calls, AI calls) so
// the CLI can show a headline and spinner. It must run fn and return its
// error. The phase says what kind of work it is; the message is the
Expand Down Expand Up @@ -56,6 +60,8 @@ const (
// PhaseRefresh: fetching pull request state from the forge for a command
// that only reads it (log, submit's look at which PRs exist).
PhaseRefresh Phase = "refresh"
// PhaseDoctor: checking (and fixing) git-stack's setup.
PhaseDoctor Phase = "doctor"
)

// Prompter asks the user a question. It is nil under --no-interactive and in
Expand Down
Loading
Loading