Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ No Makefile, no code generation, no external linter config. Standard Go toolchai

- `cmd/`: One Cobra command per file. Each exports `<Name>Cmd(cfg *config.Config)` with logic in `run<Name>()`.
- `internal/git/`: `Ops` interface wrapping git CLI. `MockOps` for tests. Use `ForWorktree(path)` for scoped execution, `CommonDir()` for shared storage, and `GitDir()` for native per-worktree state. Never use production `os.Chdir` or `SetOps` to switch context.
- `internal/github/`: `ClientOps` interface (18 methods) for GitHub API. `MockClient` for tests. Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`); merges use the async merge API (`/repos/{owner}/{repo}/pulls/{n}/merge-async`) with an explicit `merge_action` (`direct_merge` or `merge_queue`) chosen from the base branch's merge-queue detection. `merge_action` is optional — omitting it (or sending `default`) lets the server auto-route (merge queue if one is configured, else direct merge) — but the CLI sends it explicitly so a wrong detection fails loudly instead of silently merging directly.
- `internal/github/`: `ClientOps` interface (19 methods) for GitHub API. `MockClient` for tests. Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`); merges use the async merge API (`/repos/{owner}/{repo}/pulls/{n}/merge-async`) with an explicit `merge_action` (`direct_merge` or `merge_queue`) chosen from the base branch's merge-queue detection. `merge_action` is optional — omitting it (or sending `default`) lets the server auto-route (merge queue if one is configured, else direct merge) — but the CLI sends it explicitly so a wrong detection fails loudly instead of silently merging directly.
- `internal/config/`: `Config` struct passed to all commands. Holds I/O, colors, and test hooks (`SelectFn`, `ConfirmFn`, `InputFn`, `GitHubClientOverride`).
- `internal/stack/`: Shared catalog (`<common-dir>/gh-stack`, JSON), conservative legacy migration, atomic saves, and short catalog locks.
- `internal/worktree/`: Origin/owner identities, cleanliness preflight, scoped operations, and touched-ref recovery.
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ internal/
gitops.go # Ops interface, including scoped worktree execution
mock_ops.go # MockOps. Each method has a corresponding *Fn field.
github/ # github.ClientOps interface + real Client
client_interface.go # ClientOps interface (18 methods)
client_interface.go # ClientOps interface (19 methods)
mock_client.go # MockClient. Uses function-pointer fields for testing.
stack/ # common-directory catalog, JSON schema, migration, locking
schema.json # JSON Schema for the stack file format
Expand Down Expand Up @@ -114,7 +114,7 @@ if errors.As(err, &exitErr) { ... }

- **`git.Ops`** (`internal/git/gitops.go`): wraps git CLI calls. Package-level functions (e.g., `git.CurrentBranch()`) delegate to a swappable package-level `ops` variable. `git.ForWorktree(path)` returns an explicitly scoped executor for HEAD/index/working-file operations. Never switch production context with `os.Chdir` or `git.SetOps`; reserve `SetOps` for tests. `GitDir()` remains per-worktree; `CommonDir()` is repository-wide.
- **Scoped Git errors:** `ForWorktree(path)` returns `(Ops, error)` and no executor for invalid contexts. Each scoped operation rechecks directory identity. `BranchExists`, `HasStagedChanges`, `IsRebaseInProgress`, and `IsCherryPickInProgress` return `(bool, error)`; callers must handle lookup failures before mutating Git or recovery state, not treat them as absence.
- **`github.ClientOps`** (`internal/github/client_interface.go`): 18 methods for GitHub API (PRs, stacks, merges). Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`): `ListStacks`, `FindStackForPR`, `GetStack`, `CreateStack`, `AddToStack` (delta append), `Unstack`. Async stack merges use `RepoMergeConfig` (GraphQL: allowed merge methods + viewer's default), `BaseBranchUsesMergeQueue` (GraphQL: detects a base-branch merge queue to select the explicit `merge_action`), `MergeStackAsync`, and `GetAsyncMergeResult` (`/repos/{owner}/{repo}/pulls/{n}/merge-async`). Injected via `cfg.GitHubClientOverride` in tests.
- **`github.ClientOps`** (`internal/github/client_interface.go`): 19 methods for GitHub API (PRs, stacks, merges, repository default branch). Stack operations use the public Stacks REST API (`/repos/{owner}/{repo}/stacks`): `ListStacks`, `FindStackForPR`, `GetStack`, `CreateStack`, `AddToStack` (delta append), `Unstack`. Async stack merges use `RepoMergeConfig` (GraphQL: allowed merge methods + viewer's default), `BaseBranchUsesMergeQueue` (GraphQL: detects a base-branch merge queue to select the explicit `merge_action`), `MergeStackAsync`, and `GetAsyncMergeResult` (`/repos/{owner}/{repo}/pulls/{n}/merge-async`). Injected via `cfg.GitHubClientOverride` in tests.
- **`config.Config`** (`internal/config/config.go`): Central configuration passed to all commands. Holds I/O streams, color functions, and test hook fields (`SelectFn`, `ConfirmFn`, `InputFn`, `RepoOverride`).

### Stack file
Expand Down
6 changes: 3 additions & 3 deletions cmd/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,7 @@ func TestAdd_InitializesStackWithExplicitBranch(t *testing.T) {
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "unstacked", nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
IsRerereEnabledFn: func() (bool, error) { return true, nil },
BranchExistsFn: func(name string) (bool, error) { return name == "main" && trunkExists, nil },
RevParseFn: func(ref string) (string, error) {
Expand Down Expand Up @@ -815,7 +815,7 @@ func TestAdd_InitializesStackWithPromptedBranch(t *testing.T) {
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
IsRerereEnabledFn: func() (bool, error) { return true, nil },
CreateBranchFn: func(name, base string) error {
createdBranch = name
Expand Down Expand Up @@ -856,7 +856,7 @@ func TestAdd_InitializesGeneratedBranchAndCommits(t *testing.T) {
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
CurrentBranchFn: func() (string, error) { return currentBranch, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
IsRerereEnabledFn: func() (bool, error) { return true, nil },
BranchExistsFn: func(name string) (bool, error) { return name == "main", nil },
CreateBranchFn: func(name, base string) error {
Expand Down
2 changes: 1 addition & 1 deletion cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func runInit(cfg *config.Config, opts *initOptions) error {
}

if trunk == "" {
trunk, err = git.DefaultBranch()
trunk, err = resolveDefaultBranch(cfg, "")
if err != nil {
cfg.Errorf("unable to determine default branch\nUse -b to specify the trunk branch")
return ErrNotInStack
Expand Down
44 changes: 22 additions & 22 deletions cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func TestInit_CreatesStackWithCorrectTrunk(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()
Expand Down Expand Up @@ -151,7 +151,7 @@ func TestInit_RestoresMissingLocalTrunkWhenTagResolves(t *testing.T) {

restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "renamed-branch", nil },
IsRerereEnabledFn: func() (bool, error) { return true, nil },
BranchExistsFn: func(name string) (bool, error) {
Expand Down Expand Up @@ -205,7 +205,7 @@ func TestInit_AdoptExistingBranches(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) (bool, error) { return true, nil },
})
Expand All @@ -231,7 +231,7 @@ func TestInit_ExplicitBranchNamesUsedVerbatim(t *testing.T) {
var created []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
CreateBranchFn: func(name, base string) error {
created = append(created, name)
Expand All @@ -257,7 +257,7 @@ func TestInit_AdoptFlagShowsDeprecationWarning(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) (bool, error) { return true, nil },
})
Expand All @@ -276,7 +276,7 @@ func TestInit_RerereAlreadyEnabled(t *testing.T) {
enableRerereCalled := false
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
IsRerereEnabledFn: func() (bool, error) { return true, nil },
EnableRerereFn: func() error {
Expand Down Expand Up @@ -308,7 +308,7 @@ func TestInit_RefuseIfBranchAlreadyInStack(t *testing.T) {

restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "feature-1", nil },
})
defer restore()
Expand All @@ -326,7 +326,7 @@ func TestInit_AdoptNonexistentBranch_CreatesIt(t *testing.T) {
var created []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) (bool, error) { return false, nil },
CreateBranchFn: func(name, base string) error {
Expand All @@ -350,7 +350,7 @@ func TestInit_MultipleBranches_CreatesAll(t *testing.T) {
var created []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
CreateBranchFn: func(name, base string) error {
created = append(created, name)
Expand All @@ -375,7 +375,7 @@ func TestInit_AdoptWithExistingOpenPR(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) (bool, error) { return true, nil },
})
Expand Down Expand Up @@ -425,7 +425,7 @@ func TestInit_AdoptIgnoresClosedAndMergedPRs(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) (bool, error) { return true, nil },
})
Expand Down Expand Up @@ -466,7 +466,7 @@ func TestInit_ImplicitAdopt_AllExist(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) (bool, error) { return true, nil },
})
Expand Down Expand Up @@ -501,7 +501,7 @@ func TestInit_ImplicitAdopt_AllMissing(t *testing.T) {
var created []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
CreateBranchFn: func(name, base string) error {
created = append(created, name)
Expand All @@ -528,7 +528,7 @@ func TestInit_ImplicitAdopt_Mixed(t *testing.T) {
var created []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(name string) (bool, error) { return existing[name], nil },
CreateBranchFn: func(name, base string) error {
Expand Down Expand Up @@ -556,7 +556,7 @@ func TestInit_WhatsNext_Fresh(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
CreateBranchFn: func(name, base string) error { return nil },
})
Expand All @@ -581,7 +581,7 @@ func TestInit_WhatsNext_AdoptedWithPRs(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) (bool, error) { return true, nil },
})
Expand Down Expand Up @@ -614,7 +614,7 @@ func TestInit_WhatsNext_AdoptedNoPRs(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(string) (bool, error) { return true, nil },
})
Expand All @@ -639,7 +639,7 @@ func TestInit_WhatsNext_MixedWithPR(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
BranchExistsFn: func(name string) (bool, error) { return name == "existing", nil },
CreateBranchFn: func(name, base string) error { return nil },
Expand Down Expand Up @@ -670,7 +670,7 @@ func TestInit_Interactive_OnTrunk(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
})
defer restore()
Expand All @@ -689,7 +689,7 @@ func TestInit_Interactive_OnFeatureBranch_UseCurrent(t *testing.T) {
gitDir := t.TempDir()
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "feat/auth", nil },
BranchExistsFn: func(name string) (bool, error) { return name == "feat/auth", nil },
})
Expand Down Expand Up @@ -732,7 +732,7 @@ func TestInit_TwoPassValidation_NoBranchCreatedOnError(t *testing.T) {
var created []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
CreateBranchFn: func(name, base string) error {
created = append(created, name)
Expand All @@ -756,7 +756,7 @@ func TestInit_TwoPassValidation_InvalidRefName(t *testing.T) {
var created []string
restore := git.SetOps(&git.MockOps{
GitDirFn: func() (string, error) { return gitDir, nil },
DefaultBranchFn: func() (string, error) { return "main", nil },
DefaultBranchFn: func(string) (string, error) { return "main", nil },
CurrentBranchFn: func() (string, error) { return "main", nil },
ValidateRefNameFn: func(name string) error {
if name == "invalid..name" {
Expand Down
2 changes: 1 addition & 1 deletion cmd/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ func runLinkCreateOrUpdate(cfg *config.Config, client github.ClientOps, opts *li
// given, default to the repository's default branch (like `gh stack init`).
bottomBase := opts.base
if bottomBase == "" {
base, err := git.DefaultBranch()
base, err := resolveDefaultBranch(cfg, opts.remote)
if err != nil {
cfg.Errorf("unable to determine default branch\nUse --base to specify the base branch")
return ErrSilent
Expand Down
Loading
Loading