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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/command-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 10 additions & 11 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,31 +41,29 @@ 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.
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.
Expand Down
40 changes: 33 additions & 7 deletions internal/commands/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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{
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
92 changes: 90 additions & 2 deletions internal/commands/branch_mutation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}

Expand Down
12 changes: 7 additions & 5 deletions internal/commands/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")

Expand Down
Loading
Loading