From bf4e22d0e45dab25eaaefb83d997862a316dd099 Mon Sep 17 00:00:00 2001 From: Stephen Metcalfe Date: Sun, 27 Sep 2026 00:10:44 +0200 Subject: [PATCH 1/3] fix: bind branch safety to origin write target --- docs/command-guide.md | 8 ++ docs/roadmap.md | 19 ++--- internal/commands/branch.go | 40 ++++++++-- internal/commands/branch_mutation_test.go | 92 ++++++++++++++++++++++- internal/commands/coverage_test.go | 12 +-- internal/git/repository.go | 51 +++++++++++++ internal/git/repository_test.go | 70 +++++++++++++++++ 7 files changed, 266 insertions(+), 26 deletions(-) diff --git a/docs/command-guide.md b/docs/command-guide.md index c288d26..455691f 100644 --- a/docs/command-guide.md +++ b/docs/command-guide.md @@ -320,6 +320,14 @@ authenticated transport is authoritative for that write. It refuses already-tracked branches because direct `git push` is clearer for an ordinary update. +For origin writes, a selected `--repo` or `GHA_REPOSITORY` must match the +checkout's GitHub `origin` push URL (or its origin URL when no push URL is +configured). GHA rejects a mismatch before changing local or +remote branches, including with `--force`. If no provider repository is +selected, provider-neutral Git writes such as `branch create --publish` remain +available for other origin types. Multiple origin push URLs cannot be checked +as one provider target. + ```bash # Inspect the target and push permission without changing Git or origin. gha branch publish feature/api --dry-run --format json diff --git a/docs/roadmap.md b/docs/roadmap.md index e7d1315..375ef75 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -20,7 +20,8 @@ confirmation boundaries, and report what actually happened. - **Releases:** published-release discovery, read-only notes, guarded release publication, and exact-ref tag publication. - **Branches:** inventory, safety inspection, create/publish/rename/delete - workflows, and read-only cleanup candidates. + workflows, read-only cleanup candidates, and provider safety checks bound to + the origin write target. - **Repository analysis:** offline worktree, history, storage, and large-file analysis. - **Agent guidance:** install, list, and uninstall for Codex, Claude Code, Pi, @@ -40,32 +41,26 @@ confirmation boundaries, and report what actually happened. Priorities are ordered by expected user value and prerequisite: -1. **Bind branch safety checks to the write target.** Before publishing, - renaming, or deleting a branch on `origin`, verify that the repository used - for provider permission and protection checks matches that checkout's - `origin`. Reject a mismatched `--repo` or configured repository instead of - checking one repository and writing to another. Cover each origin-writing - path with mismatch tests. -2. **Make branch dry runs validate the proposed operation.** Run the same +1. **Make branch dry runs validate the proposed operation.** Run the same read-only ref, collision, origin, and provider safety checks needed for branch create, rename, and delete before reporting a plan. A missing start ref or target branch, or an unsafe origin operation, must not appear as an executable `planned` result. Keep dry runs free of Git and provider writes. -3. **Report partial branch mutations.** If any branch write or checkout switch +2. **Report partial branch mutations.** If any branch write or checkout switch succeeds before a later step fails, return the `BranchMutation` result with each effect's completed or failed state and a nonzero exit code. Cover create with publish, origin rename, and combined local/origin delete failures so agents can identify the state that needs recovery. -4. **Show coverage and project status.** Publish the CI coverage result and its +3. **Show coverage and project status.** Publish the CI coverage result and its 95% pass/fail status in pull requests. Add README badges for coverage, CI, and the latest release; include prereleases during the alpha phase. Keep the existing license badge and avoid manually maintained values. -5. **Package-manager distribution.** Add maintained package-manager paths for +4. **Package-manager distribution.** Add maintained package-manager paths for released GHA binaries, starting with a Homebrew tap and assessing Linux options. Keep executable upgrades owned by the selected package manager and refresh agent skills only when their declared GHA capabilities are supported by the installed binary. -6. **Design cleanup actions.** Before adding writes to `branches cleanup`, +5. **Design cleanup actions.** Before adding writes to `branches cleanup`, define candidate selection, per-target confirmation, provider safety, and checked-out-branch transitions. The current command stays read-only. diff --git a/internal/commands/branch.go b/internal/commands/branch.go index 6c4e890..7c8dd95 100644 --- a/internal/commands/branch.go +++ b/internal/commands/branch.go @@ -20,7 +20,7 @@ func newBranchCmd(service *branch.Service, resolver *git.RepositoryResolver) *co Short: "Inspect and manage one branch", Long: "Inspect, create, publish, rename, or delete one branch. Use --dry-run to inspect a mutation before execution.", } - command.AddCommand(newBranchShowCmd(service, resolver), newBranchCreateCmd(), newBranchPublishCmd(service, resolver), newBranchRenameCmd(service, resolver), newBranchDeleteCmd(service, resolver)) + command.AddCommand(newBranchShowCmd(service, resolver), newBranchCreateCmd(resolver), newBranchPublishCmd(service, resolver), newBranchRenameCmd(service, resolver), newBranchDeleteCmd(service, resolver)) return command } @@ -72,7 +72,7 @@ them. Unavailable facts are reported explicitly.`, return command } -func newBranchCreateCmd() *cobra.Command { +func newBranchCreateCmd(resolver *git.RepositoryResolver) *cobra.Command { var format, path, from string var publish, dryRun bool command := &cobra.Command{ @@ -86,6 +86,15 @@ func newBranchCreateCmd() *cobra.Command { return err } result := newBranchMutation("create", args[0], "", from, dryRun, "planned", targetState(publish, "planned")) + if publish { + writeResolver := resolver + if writeResolver == nil { + writeResolver = git.NewRepositoryResolver("") + } + if err := writeResolver.ValidateOriginWriteAtPath(cmd.Context(), "", path); err != nil { + return renderCommandError(cmd, outputFormat, "branch_mutation_failed", err) + } + } if dryRun { return output.BranchMutation(cmd.OutOrStdout(), outputFormat, result) } @@ -144,7 +153,7 @@ publication succeeds. Add --dry-run to inspect the plan without pushing.`, }, } addMutationFlags(command, &format, &path, &dryRun) - command.Flags().StringVar(&repository, "repo", "", "Repository for origin push-permission checks (owner/repo)") + command.Flags().StringVar(&repository, "repo", "", "Repository for origin push-permission checks; must match origin push target (owner/repo)") return command } @@ -159,7 +168,7 @@ func prepareBranchPublication(cmd *cobra.Command, service *branch.Service, resol if resolver == nil { return nil, formatValue, fmt.Errorf("repository resolution is not configured") } - target, err := resolver.ResolveAtPath(cmd.Context(), repository, path) + target, err := resolver.ResolveOriginWriteAtPath(cmd.Context(), repository, path) if err != nil { return nil, formatValue, fmt.Errorf("resolve repository for origin publication: %w", err) } @@ -244,6 +253,11 @@ func newBranchRenameCmd(service *branch.Service, resolver *git.RepositoryResolve return err } result := newBranchMutation("rename", args[0], args[1], "", dryRun, "planned", targetState(origin, "planned")) + if origin { + if err := requireOriginWriteTarget(cmd, resolver, path, repository); err != nil { + return renderCommandError(cmd, outputFormat, "branch_mutation_failed", err) + } + } if dryRun { return output.BranchMutation(cmd.OutOrStdout(), outputFormat, result) } @@ -269,7 +283,7 @@ func newBranchRenameCmd(service *branch.Service, resolver *git.RepositoryResolve addMutationFlags(command, &format, &path, &dryRun) command.Flags().BoolVar(&origin, "origin", false, "Rename the branch on origin too") command.Flags().BoolVar(&force, "force", false, "Override origin branch safety guardrails") - command.Flags().StringVar(&repository, "repo", "", "Repository for origin safety signals (owner/repo)") + command.Flags().StringVar(&repository, "repo", "", "Repository for origin safety signals; must match origin push target (owner/repo)") return command } @@ -292,6 +306,11 @@ func newBranchDeleteCmd(service *branch.Service, resolver *git.RepositoryResolve } result := newBranchMutation("delete", args[0], "", "", dryRun, targetState(local, "planned"), targetState(origin, "planned")) writer := git.NewBranchWriter(path) + if origin { + if err := requireOriginWriteTarget(cmd, resolver, path, repository); err != nil { + return renderCommandError(cmd, outputFormat, "branch_mutation_failed", err) + } + } if dryRun { checkedOut, err := currentBranchDeleteSwitch(cmd.Context(), writer, args[0], local, "") if err != nil { @@ -337,7 +356,7 @@ func newBranchDeleteCmd(service *branch.Service, resolver *git.RepositoryResolve command.Flags().BoolVar(&local, "local", false, "Delete the local branch") command.Flags().BoolVar(&origin, "origin", false, "Delete the branch from origin") command.Flags().BoolVar(&force, "force", false, "Override Git and origin branch safety guardrails") - command.Flags().StringVar(&repository, "repo", "", "Repository for origin safety signals (owner/repo)") + command.Flags().StringVar(&repository, "repo", "", "Repository for origin safety signals; must match origin push target (owner/repo)") return command } @@ -387,7 +406,7 @@ func requireRemoteDestructionSafety(cmd *cobra.Command, service *branch.Service, if service == nil || resolver == nil { return "", fmt.Errorf("cannot verify origin branch safety; retry with --force only after independently verifying the branch") } - selected, err := resolver.ResolveAtPath(cmd.Context(), repository, path) + selected, err := resolver.ResolveOriginWriteAtPath(cmd.Context(), repository, path) if err != nil { return "", fmt.Errorf("cannot resolve repository for origin branch safety: %w; retry with --force only after independently verifying the branch", err) } @@ -413,3 +432,10 @@ func requireRemoteDestructionSafety(cmd *cobra.Command, service *branch.Service, } return safety.DefaultBranchName, nil } + +func requireOriginWriteTarget(cmd *cobra.Command, resolver *git.RepositoryResolver, path, repository string) error { + if resolver == nil { + resolver = git.NewRepositoryResolver("") + } + return resolver.ValidateOriginWriteAtPath(cmd.Context(), repository, path) +} diff --git a/internal/commands/branch_mutation_test.go b/internal/commands/branch_mutation_test.go index cf494b1..561ef54 100644 --- a/internal/commands/branch_mutation_test.go +++ b/internal/commands/branch_mutation_test.go @@ -19,7 +19,7 @@ import ( func TestBranchCreateDryRunReportsBothTargetsWithoutWriting(t *testing.T) { checkout, _ := mutationRepository(t) - command := newBranchCreateCmd() + command := newBranchCreateCmd(nil) var output bytes.Buffer command.SetOut(&output) command.SetArgs([]string{"feature", "--publish", "--dry-run", "--path", checkout, "--format", "json"}) @@ -36,13 +36,22 @@ func TestBranchCreateDryRunReportsBothTargetsWithoutWriting(t *testing.T) { func TestBranchCreatePublishesByDefaultWhenRequested(t *testing.T) { checkout, remote := mutationRepository(t) - command := newBranchCreateCmd() + command := newBranchCreateCmd(nil) command.SetArgs([]string{"feature", "--publish", "--path", checkout}) require.NoError(t, command.Execute()) assertBranchExists(t, checkout, "feature") assertBranchExists(t, remote, "feature") } +func TestBranchCreatePublishesToProviderNeutralOrigin(t *testing.T) { + checkout, remote := mutationRepository(t) + runMutationGit(t, checkout, "remote", "set-url", "origin", remote) + command := newBranchCreateCmd(nil) + command.SetArgs([]string{"feature", "--publish", "--path", checkout}) + require.NoError(t, command.Execute()) + assertBranchExists(t, remote, "feature") +} + func TestBranchPublishPreflightsAnUnpublishedLocalBranchWithoutWriting(t *testing.T) { checkout, remote := mutationRepository(t) runMutationGit(t, checkout, "branch", "feature") @@ -132,6 +141,82 @@ func TestBranchPublishRejectsAnAlreadyTrackedBranch(t *testing.T) { assertBranchExists(t, remote, "feature") } +func TestOriginBranchWritesRejectRepositoryMismatchBeforeMutation(t *testing.T) { + for _, test := range []struct { + name string + args []string + command func(*branch.Service, *git.RepositoryResolver) interface { + SetArgs([]string) + Execute() error + } + }{ + {"create publish configured", []string{"new", "--publish"}, func(_ *branch.Service, r *git.RepositoryResolver) interface { + SetArgs([]string) + Execute() error + } { + return newBranchCreateCmd(r) + }}, + {"publish explicit", []string{"feature", "--repo", "other/repo"}, func(s *branch.Service, r *git.RepositoryResolver) interface { + SetArgs([]string) + Execute() error + } { + return newBranchPublishCmd(s, r) + }}, + {"rename explicit", []string{"feature", "renamed", "--origin", "--repo", "other/repo"}, func(s *branch.Service, r *git.RepositoryResolver) interface { + SetArgs([]string) + Execute() error + } { + return newBranchRenameCmd(s, r) + }}, + {"rename forced configured", []string{"feature", "renamed", "--origin", "--force"}, func(s *branch.Service, r *git.RepositoryResolver) interface { + SetArgs([]string) + Execute() error + } { + return newBranchRenameCmd(s, r) + }}, + {"delete explicit", []string{"feature", "--local", "--origin", "--repo", "other/repo"}, func(s *branch.Service, r *git.RepositoryResolver) interface { + SetArgs([]string) + Execute() error + } { + return newBranchDeleteCmd(s, r) + }}, + {"delete forced configured", []string{"feature", "--local", "--origin", "--force"}, func(s *branch.Service, r *git.RepositoryResolver) interface { + SetArgs([]string) + Execute() error + } { + return newBranchDeleteCmd(s, r) + }}, + } { + t.Run(test.name, func(t *testing.T) { + checkout, remote := mutationRepository(t) + runMutationGit(t, checkout, "branch", "feature") + runMutationGit(t, checkout, "push", "origin", "feature") + service := branch.NewService(git.NewBranchLister(checkout), mutationSafetyProvider{}) + resolver := git.NewRepositoryResolver("other/repo") + command := test.command(service, resolver) + command.SetArgs(append(test.args, "--path", checkout)) + err := command.Execute() + require.ErrorContains(t, err, "does not match origin") + assertBranchExists(t, checkout, "feature") + assertBranchExists(t, remote, "feature") + assertBranchMissing(t, checkout, "new") + assertBranchMissing(t, checkout, "renamed") + assertBranchMissing(t, remote, "renamed") + }) + } +} + +func TestBranchPublishRejectsMismatchedOriginPushURL(t *testing.T) { + checkout, remote := mutationRepository(t) + runMutationGit(t, checkout, "branch", "feature") + runMutationGit(t, checkout, "config", "remote.origin.pushurl", "git@github.com:other/project.git") + service := branch.NewService(git.NewBranchLister(checkout), mutationSafetyProvider{}) + command := newBranchPublishCmd(service, git.NewRepositoryResolver("acme/project")) + command.SetArgs([]string{"feature", "--path", checkout, "--dry-run"}) + assert.ErrorContains(t, command.Execute(), "does not match origin") + assertBranchMissing(t, remote, "feature") +} + func TestBranchRenameOriginSafetyCanBeExplicitlyForced(t *testing.T) { checkout, remote := mutationRepository(t) runMutationGit(t, checkout, "branch", "feature") @@ -269,6 +354,9 @@ func mutationRepository(t *testing.T) (string, string) { runMutationGit(t, checkout, "remote", "add", "origin", remote) runMutationGit(t, checkout, "push", "-u", "origin", "main") runMutationGit(t, checkout, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main") + // Keep provider identity realistic while routing writes to the local bare fixture. + runMutationGit(t, checkout, "config", "url."+remote+".insteadOf", "git@github.com:acme/project.git") + runMutationGit(t, checkout, "remote", "set-url", "origin", "git@github.com:acme/project.git") return checkout, remote } diff --git a/internal/commands/coverage_test.go b/internal/commands/coverage_test.go index b38e412..06e6caf 100644 --- a/internal/commands/coverage_test.go +++ b/internal/commands/coverage_test.go @@ -182,7 +182,7 @@ func TestReleaseListingAndBranchCommandsValidateBeforeAnyWrite(t *testing.T) { err = command.Execute() assert.ErrorContains(t, err, "release listing is not configured") - branchCommand := newBranchCreateCmd() + branchCommand := newBranchCreateCmd(nil) branchCommand.SetArgs([]string{"feature", "--format", "unknown"}) err = branchCommand.Execute() assert.ErrorContains(t, err, "unsupported format") @@ -250,7 +250,7 @@ func TestCommandModesRenderBoundedResultsAndWriterFailures(t *testing.T) { require.NoError(t, command.Execute()) assert.Contains(t, rendered.String(), "schema_version") - command = newBranchCreateCmd() + command = newBranchCreateCmd(nil) command.SetOut(commandFailingWriter{}) command.SetArgs([]string{"feature", "--dry-run", "--format", "json"}) assert.ErrorContains(t, command.Execute(), "writer failed") @@ -283,12 +283,14 @@ func TestAgentAndBranchFailurePathsStayActionable(t *testing.T) { checkout, _ := mutationRepository(t) runMutationGit(t, checkout, "branch", "existing") - command := newBranchCreateCmd() + command := newBranchCreateCmd(nil) command.SetArgs([]string{"existing", "--path", checkout}) assert.ErrorContains(t, command.Execute(), "create local branch") - runMutationGit(t, checkout, "remote", "set-url", "origin", filepath.Join(t.TempDir(), "missing.git")) - command = newBranchCreateCmd() + missingRemote := filepath.Join(t.TempDir(), "missing.git") + runMutationGit(t, checkout, "config", "url."+missingRemote+".insteadOf", "git@github.com:acme/missing.git") + runMutationGit(t, checkout, "remote", "set-url", "origin", "git@github.com:acme/missing.git") + command = newBranchCreateCmd(nil) command.SetArgs([]string{"unpublished", "--publish", "--path", checkout}) assert.ErrorContains(t, command.Execute(), "publish branch to origin") diff --git a/internal/git/repository.go b/internal/git/repository.go index 2406e84..60671c6 100644 --- a/internal/git/repository.go +++ b/internal/git/repository.go @@ -87,6 +87,57 @@ func (r *RepositoryResolver) ResolveAtPath(ctx context.Context, override, path s return repositoryFromOrigin(ctx, "", "could not determine the repository; pass --repo owner/repo, --path /path/to/checkout, or set GHA_REPOSITORY") } +// ResolveOriginWriteAtPath binds provider checks to the checkout's origin. +// An explicit or configured repository may select that same repository, but +// cannot redirect safety checks away from the Git remote being changed. +func (r *RepositoryResolver) ResolveOriginWriteAtPath(ctx context.Context, override, path string) (model.RepositoryRef, error) { + origin, err := repositoryFromOriginWriteTarget(ctx, path) + if err != nil { + return model.RepositoryRef{}, fmt.Errorf("resolve origin write target: %w", err) + } + selection := override + if selection == "" { + selection = r.defaultRepository + } + if selection != "" { + selected, err := ParseRepository(selection) + if err != nil { + return model.RepositoryRef{}, err + } + if !strings.EqualFold(selected.Owner, origin.Owner) || !strings.EqualFold(selected.Name, origin.Name) { + return model.RepositoryRef{}, fmt.Errorf("selected repository %s/%s does not match origin %s/%s", selected.Owner, selected.Name, origin.Owner, origin.Name) + } + } + return origin, nil +} + +func repositoryFromOriginWriteTarget(ctx context.Context, path string) (model.RepositoryRef, error) { + command := exec.CommandContext(ctx, "git", "config", "--get-all", "remote.origin.pushurl") + command.Dir = path + output, err := command.Output() + if err != nil { + if _, ok := err.(*exec.ExitError); !ok { + return model.RepositoryRef{}, fmt.Errorf("read origin push URL: %w", err) + } + return repositoryFromOrigin(ctx, path, "cannot determine the checkout's origin repository for a branch write") + } + pushURLs := nonemptyLines(string(output)) + if len(pushURLs) != 1 { + return model.RepositoryRef{}, fmt.Errorf("origin has %d push URLs; select one write target before using provider safety checks", len(pushURLs)) + } + return ParseRepositoryRemote(pushURLs[0]) +} + +// ValidateOriginWriteAtPath checks an optional provider selection before a Git +// write. Provider-neutral writes remain usable when no provider is selected. +func (r *RepositoryResolver) ValidateOriginWriteAtPath(ctx context.Context, override, path string) error { + if override == "" && r.defaultRepository == "" { + return nil + } + _, err := r.ResolveOriginWriteAtPath(ctx, override, path) + return err +} + func repositoryFromOrigin(ctx context.Context, path, message string) (model.RepositoryRef, error) { command := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url") command.Dir = path diff --git a/internal/git/repository_test.go b/internal/git/repository_test.go index 29c7105..55f3194 100644 --- a/internal/git/repository_test.go +++ b/internal/git/repository_test.go @@ -51,3 +51,73 @@ func TestParseRepositoryRemote(t *testing.T) { assert.Equal(t, model.RepositoryRef{Owner: "Raithlin", Name: "gha"}, target) } } + +func TestResolveOriginWriteAtPathRejectsMismatchedSelections(t *testing.T) { + checkout := t.TempDir() + require.NoError(t, exec.Command("git", "init", "--quiet", checkout).Run()) + require.NoError(t, exec.Command("git", "-C", checkout, "remote", "add", "origin", "git@github.com:Acme/project.git").Run()) + + for _, test := range []struct { + name string + configured string + override string + wantError bool + }{ + {"origin selected", "", "", false}, + {"matching configured", "acme/PROJECT", "", false}, + {"matching override", "other/repo", "acme/project", false}, + {"mismatched override", "", "other/repo", true}, + {"mismatched configured", "other/repo", "", true}, + } { + t.Run(test.name, func(t *testing.T) { + selected, err := NewRepositoryResolver(test.configured).ResolveOriginWriteAtPath(context.Background(), test.override, checkout) + if test.wantError { + assert.ErrorContains(t, err, "does not match origin") + return + } + require.NoError(t, err) + assert.Equal(t, model.RepositoryRef{Owner: "Acme", Name: "project"}, selected) + }) + } +} + +func TestValidateOriginWriteAtPathKeepsProviderNeutralWritesAndChecksSelections(t *testing.T) { + checkout := t.TempDir() + require.NoError(t, exec.Command("git", "init", "--quiet", checkout).Run()) + require.NoError(t, exec.Command("git", "-C", checkout, "remote", "add", "origin", "/tmp/local-origin.git").Run()) + ctx := context.Background() + require.NoError(t, NewRepositoryResolver("").ValidateOriginWriteAtPath(ctx, "", checkout)) + + require.NoError(t, exec.Command("git", "-C", checkout, "remote", "set-url", "origin", "https://github.com/acme/project.git").Run()) + require.NoError(t, NewRepositoryResolver("acme/project").ValidateOriginWriteAtPath(ctx, "", checkout)) + assert.ErrorContains(t, NewRepositoryResolver("other/repo").ValidateOriginWriteAtPath(ctx, "", checkout), "does not match origin") + assert.ErrorContains(t, NewRepositoryResolver("").ValidateOriginWriteAtPath(ctx, "other/repo", checkout), "does not match origin") +} + +func TestResolveOriginWriteAtPathRejectsMissingOriginAndInvalidSelection(t *testing.T) { + checkout := t.TempDir() + require.NoError(t, exec.Command("git", "init", "--quiet", checkout).Run()) + resolver := NewRepositoryResolver("acme/project") + _, err := resolver.ResolveOriginWriteAtPath(context.Background(), "", checkout) + assert.ErrorContains(t, err, "resolve origin write target") + require.NoError(t, exec.Command("git", "-C", checkout, "remote", "add", "origin", "https://github.com/acme/project.git").Run()) + _, err = resolver.ResolveOriginWriteAtPath(context.Background(), "invalid", checkout) + assert.ErrorContains(t, err, "invalid repository") +} + +func TestResolveOriginWriteAtPathUsesConfiguredPushURL(t *testing.T) { + checkout := t.TempDir() + require.NoError(t, exec.Command("git", "init", "--quiet", checkout).Run()) + require.NoError(t, exec.Command("git", "-C", checkout, "remote", "add", "origin", "https://github.com/acme/read.git").Run()) + require.NoError(t, exec.Command("git", "-C", checkout, "config", "remote.origin.pushurl", "git@github.com:acme/write.git").Run()) + ctx := context.Background() + selected, err := NewRepositoryResolver("acme/write").ResolveOriginWriteAtPath(ctx, "", checkout) + require.NoError(t, err) + assert.Equal(t, model.RepositoryRef{Owner: "acme", Name: "write"}, selected) + _, err = NewRepositoryResolver("acme/read").ResolveOriginWriteAtPath(ctx, "", checkout) + assert.ErrorContains(t, err, "does not match origin") + + require.NoError(t, exec.Command("git", "-C", checkout, "config", "--add", "remote.origin.pushurl", "git@github.com:acme/other.git").Run()) + _, err = NewRepositoryResolver("acme/write").ResolveOriginWriteAtPath(ctx, "", checkout) + assert.ErrorContains(t, err, "2 push URLs") +} From a08f95f808b8f6dc458bfce042c7c9f9a81af524 Mon Sep 17 00:00:00 2001 From: Stephen Metcalfe Date: Sun, 27 Sep 2026 00:13:16 +0200 Subject: [PATCH 2/3] docs: show gha update in quick start --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 84c9a4d..17c0df3 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,11 @@ gha --help gha capabilities --format json gha version --format json +# Preview and refresh agent guidance from the latest GHA release. +# This updates recorded agent installations, not the gha executable. +gha update --dry-run +gha update + # Inspect pull requests and local branches in the current repository. gha prs gha review 123 From c9369a3f3abfc5b1fcfdb373546b0dd0729a0b56 Mon Sep 17 00:00:00 2001 From: Stephen Metcalfe Date: Sun, 27 Sep 2026 00:16:03 +0200 Subject: [PATCH 3/3] docs: plan interactive install TUI --- docs/roadmap.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 375ef75..e116316 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -60,7 +60,11 @@ Priorities are ordered by expected user value and prerequisite: options. Keep executable upgrades owned by the selected package manager and refresh agent skills only when their declared GHA capabilities are supported by the installed binary. -5. **Design cleanup actions.** Before adding writes to `branches cleanup`, +5. **Build a proper TUI for `gha agent install`.** List detected and supported + coding agents, let the user select one or more without typing agent names, + and preview the destinations before applying changes. Keep `--agent` for + scripts and retain `--binary-only`. +6. **Design cleanup actions.** Before adding writes to `branches cleanup`, define candidate selection, per-target confirmation, provider safety, and checked-out-branch transitions. The current command stays read-only.