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
71 changes: 62 additions & 9 deletions cmd/av/adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ var adoptFlags struct {
Parent string
DryRun bool
RemoteBranchName string
Include string
}

// adoptIncludeValues are the valid values for the --include flag.
var adoptIncludeValues = []string{"ancestors"}

var adoptCmd = &cobra.Command{
Use: "adopt",
Short: "Adopt branches that are not managed by av",
Expand All @@ -42,10 +46,22 @@ the parent.

If you want to adopt branches on the remote repository, use --remote $BRANCH_NAME to adopt branches.
The command will adopt the stack of pull requests starting from the specified branch name.

By default --remote opens an interactive picker. Pass --include ancestors to skip the picker and
non-interactively adopt the named branch together with its ancestor branches up to the trunk.
`),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
if adoptFlags.Include != "" {
if adoptFlags.RemoteBranchName == "" {
return errors.New("--include can only be used together with --remote")
}
if !slices.Contains(adoptIncludeValues, adoptFlags.Include) {
return errors.New("invalid value for --include; must be one of " +
strings.Join(adoptIncludeValues, ", "))
}
}
repo, err := getRepo(ctx)
if err != nil {
return err
Expand All @@ -71,10 +87,11 @@ The command will adopt the stack of pull requests starting from the specified br
return err
}
return uiutils.RunBubbleTea(&remoteAdoptViewModel{
repo: repo,
db: db,
ghClient: client,
branchName: adoptFlags.RemoteBranchName,
repo: repo,
db: db,
ghClient: client,
branchName: adoptFlags.RemoteBranchName,
nonInteractive: adoptFlags.Include != "",
})
}
return uiutils.RunBubbleTea(&adoptViewModel{
Expand Down Expand Up @@ -262,10 +279,11 @@ func (vm *adoptViewModel) ExitError() error {
}

type remoteAdoptViewModel struct {
repo *git.Repo
db meta.DB
ghClient *gh.Client
branchName string
repo *git.Repo
db meta.DB
ghClient *gh.Client
branchName string
nonInteractive bool

uiutils.BaseStackedView
}
Expand Down Expand Up @@ -311,6 +329,26 @@ func (vm *remoteAdoptViewModel) initTreeSelector(prs []actions.RemotePRInfo) tea
tea.Quit,
)
}
if vm.nonInteractive {
// --include ancestors: adopt the named branch and its ancestors without
// prompting. adoptionTargets already holds that chain (the open, not-yet
// adopted PRs walked up from the named branch to the trunk).
if adoptFlags.DryRun {
parents := make(map[string]string, len(prs))
for _, prInfo := range prs {
parents[prInfo.Name] = prInfo.Parent.Name
}
lines := []string{"Would adopt:"}
for _, target := range adoptionTargets {
lines = append(lines, fmt.Sprintf(" %s (parent: %s)", target.Short(), parents[target.Short()]))
}
return tea.Batch(
vm.AddView(uiutils.SimpleMessageView{Message: colors.SuccessStyle.Render(strings.Join(lines, "\n"))}),
tea.Quit,
)
}
Comment on lines +336 to +349

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C3 'PullRequestState(Merged|Closed)|Parent\.Trunk|prMap\[' cmd/av/adopt.go

Repository: aviator-co/av

Length of output: 665


🏁 Script executed:

#!/bin/bash
sed -n '330,450p' cmd/av/adopt.go

Repository: aviator-co/av

Length of output: 3934


🏁 Script executed:

#!/bin/bash
sed -n '250,340p' cmd/av/adopt.go

Repository: aviator-co/av

Length of output: 2620


🏁 Script executed:

#!/bin/bash
rg -n "type RemotePRInfo|NewGetRemoteStackedPRModel|GetRemoteStackedPRModel|type AdoptingBranch|type BranchState" -S .

Repository: aviator-co/av

Length of output: 982


🏁 Script executed:

#!/bin/bash
sed -n '1,220p' internal/actions/get_remote_stacked_pr.go && printf '\n---\n' && sed -n '1,120p' internal/actions/adopt_branches.go && printf '\n---\n' && sed -n '1,120p' internal/meta/branchstate.go

Repository: aviator-co/av

Length of output: 9163


Dry-run should use the same reparent walk as adoption. prInfo.Parent.Name can still point to a merged/closed PR, so Would adopt may show a parent that differs from the branch actually adopted after initAdoption walks up to an open ancestor or trunk.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/av/adopt.go` around lines 336 - 349, The dry-run preview in adopt.go is
using prInfo.Parent.Name directly, so it can report a stale parent instead of
the same open ancestor/trunk chosen by the real adoption flow. Update the
dry-run block in the adoption command to reuse the same parent-resolution logic
as initAdoption/adopt (the reparent walk) when building the “Would adopt” lines,
so the displayed parent matches what actual adoption would use.

return vm.initGitFetch(prs, adoptionTargets)
}
var lastNode *stackutils.StackTreeNode
for _, prInfo := range prs {
node := &stackutils.StackTreeNode{
Expand Down Expand Up @@ -360,10 +398,13 @@ func (vm *remoteAdoptViewModel) initGitFetch(prs []actions.RemotePRInfo, chosenT
tea.Quit,
)
}
remote := vm.repo.GetRemoteName()
refspecs := []string{}
for _, target := range chosenTargets {
// Directly clone as a local branch.
// Clone as a local branch, and create the remote-tracking ref so we can
// set upstream tracking after adoption.
refspecs = append(refspecs, fmt.Sprintf("refs/heads/%s:refs/heads/%s", target.Short(), target.Short()))
refspecs = append(refspecs, fmt.Sprintf("refs/heads/%s:refs/remotes/%s/%s", target.Short(), remote, target.Short()))
}
return vm.AddView(
actions.NewGitFetchModel(vm.repo, refspecs, func() tea.Cmd {
Expand Down Expand Up @@ -405,9 +446,11 @@ func (vm *remoteAdoptViewModel) initAdoption(prs []actions.RemotePRInfo, chosenT
vm.db,
branches,
func() tea.Cmd {
remote := vm.repo.GetRemoteName()
hasChild := make(map[string]bool)
for _, ab := range branches {
hasChild[ab.Parent.Name] = true
_ = vm.repo.BranchSetUpstream(context.Background(), ab.Name, remote)
}
for _, ab := range branches {
if !hasChild[ab.Name] {
Expand Down Expand Up @@ -442,6 +485,10 @@ func init() {
&adoptFlags.RemoteBranchName, "remote", "",
"adopt branches from remote pull requests, starting from the specified branch",
)
adoptCmd.Flags().StringVar(
&adoptFlags.Include, "include", "",
"with --remote, non-interactively adopt the named branch and related branches\n(ancestors)",
)
Comment on lines +488 to +491

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To improve the CLI user experience, we can register a shell completion function for the new --include flag. Since the only valid value currently is ancestors (defined in adoptIncludeValues), we can provide this directly to Cobra's shell completion mechanism.

	adoptCmd.Flags().StringVar(
		&adoptFlags.Include, "include", "",
		"with --remote, non-interactively adopt the named branch and related branches\n(ancestors)",
	)
	_ = adoptCmd.RegisterFlagCompletionFunc(
		"include",
		func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
			return adoptIncludeValues, cobra.ShellCompDirectiveNoFileComp
		},
	)


_ = adoptCmd.RegisterFlagCompletionFunc(
"parent",
Expand All @@ -450,4 +497,10 @@ func init() {
return branches, cobra.ShellCompDirectiveNoFileComp
},
)
_ = adoptCmd.RegisterFlagCompletionFunc(
"include",
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return adoptIncludeValues, cobra.ShellCompDirectiveNoFileComp
},
)
}
13 changes: 12 additions & 1 deletion docs/av-adopt.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ av-adopt - Adopt branches that are not managed by `av`
## SYNOPSIS

```synopsis
av adopt [--dry-run] [--parent=<parent> | --remote=<branch>]
av adopt [--dry-run] [--parent=<parent> | --remote=<branch> [--include=ancestors]]
```

## DESCRIPTION
Expand Down Expand Up @@ -43,6 +43,12 @@ You can also adopt branches from a remote repository by using the
`--remote` option. This option fetches the specified remote branch and adopts
it along with its parent branches.

By default `--remote` opens an interactive picker to choose which branches to
adopt. Pass `--include=ancestors` to skip the picker and non-interactively adopt
the named branch together with its ancestor branches up to the trunk. This is
useful for scripts and agents that want to mirror a remote stack locally without
a prompt. Combine with `--dry-run` to print the adoption plan without applying it.

## OPTIONS

`--parent=<parent>`
Expand All @@ -54,6 +60,11 @@ it along with its parent branches.
`--remote=<branch>`
: Specify a remote branch to adopt from.

`--include=<set>`
: With `--remote`, non-interactively adopt the named branch and a related set of
branches instead of opening the picker. The only supported value is `ancestors`,
which adopts the named branch and its ancestors up to the trunk.

## SEE ALSO

`av-orphan`(1) for orphaning a branch.
92 changes: 62 additions & 30 deletions e2e_tests/mock_ghgql_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

Expand Down Expand Up @@ -71,48 +72,60 @@ func (s *mockGitHubServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
return
}
if strings.Contains(req.Query, "pullRequest(number") {
s.t.Logf("Received PR-by-number query: %s", req.Variables)
if err := json.NewEncoder(w).Encode(s.handlePRByNumberQuery(req)); err != nil {
s.t.Logf("Failed to encode response: %v", err)
w.WriteHeader(http.StatusInternalServerError)
}
return
}

s.t.Logf("Received unexpected query: %s", req.Query)
w.WriteHeader(http.StatusInternalServerError)
}

func (s *mockGitHubServer) prToGQL(pr mockPR) map[string]any {
gqlpr := map[string]any{
"id": pr.ID,
"number": pr.Number,
"headRefName": pr.HeadRefName,
"baseRefName": pr.BaseRefName,
"isDraft": pr.IsDraft,
"permalink": fmt.Sprintf("https://github.invalid/mock/mock/pulls/%d", pr.Number),
"state": pr.State,
"title": pr.Title,
"body": pr.Body,
"author": map[string]string{"login": "mock-user"},
"createdAt": "2026-01-01T00:00:00Z",
}
if pr.MergeCommitOID != "" {
gqlpr["mergeCommit"] = map[string]string{"oid": pr.MergeCommitOID}
}
if pr.ClosedCommitOID != "" {
gqlpr["timelineItems"] = map[string]any{
"nodes": []any{
map[string]any{
"__typename": "ClosedEvent",
"closer": map[string]any{
"__typename": "Commit",
"oid": pr.ClosedCommitOID,
},
},
},
}
}
return gqlpr
}

func (s *mockGitHubServer) handlePRQuery(req graphqlRequest) graphqlResponse {
headRefName := req.Variables["headRefName"].(string)
var prs []any
for _, pr := range s.pulls {
if pr.HeadRefName != headRefName {
continue
}
gqlpr := map[string]any{
"id": pr.ID,
"number": pr.Number,
"headRefName": pr.HeadRefName,
"baseRefName": pr.BaseRefName,
"isDraft": pr.IsDraft,
"permalink": fmt.Sprintf("https://github.invalid/mock/mock/pulls/%d", pr.Number),
"state": pr.State,
"title": pr.Title,
"body": pr.Body,
"author": map[string]string{"login": "mock-user"},
"createdAt": "2026-01-01T00:00:00Z",
}
if pr.MergeCommitOID != "" {
gqlpr["mergeCommit"] = map[string]string{"oid": pr.MergeCommitOID}
}
if pr.ClosedCommitOID != "" {
gqlpr["timelineItems"] = map[string]any{
"nodes": []any{
map[string]any{
"__typename": "ClosedEvent",
"closer": map[string]any{
"__typename": "Commit",
"oid": pr.ClosedCommitOID,
},
},
},
}
}
prs = append(prs, gqlpr)
prs = append(prs, s.prToGQL(pr))
}
return graphqlResponse{
Data: map[string]any{
Expand All @@ -124,3 +137,22 @@ func (s *mockGitHubServer) handlePRQuery(req graphqlRequest) graphqlResponse {
},
}
}

func (s *mockGitHubServer) handlePRByNumberQuery(req graphqlRequest) graphqlResponse {
// JSON numbers decode to float64.
number := int(req.Variables["number"].(float64))
var pr any
for _, candidate := range s.pulls {
if candidate.Number == number {
pr = s.prToGQL(candidate)
break
}
}
return graphqlResponse{
Data: map[string]any{
"repository": map[string]any{
"pullRequest": pr,
},
},
}
}
38 changes: 38 additions & 0 deletions e2e_tests/testdata/script/adopt_remote_include_ancestors.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Test non-interactive remote adoption with --include ancestors.
#
# Remote stack: main -> feat-a (PR1) -> feat-b (PR2)
#
# `av adopt --remote feat-b --include ancestors` should adopt feat-b and its
# ancestor feat-a without opening the interactive picker. Here we use --dry-run
# to assert the adoption plan.

# --include requires --remote.
! exec av adopt --include ancestors
stderr '--include can only be used together with --remote'

# --include only accepts known values.
! exec av adopt --remote feat-b --include cousins
stderr 'invalid value for --include'

# Model the remote PR stack: each PR body carries av stack metadata so the
# walk can climb from feat-b up to the trunk.
mock-pull feat-a 1 OPEN base=main body=$WORK/feat-a-body.txt
mock-pull feat-b 2 OPEN base=feat-a body=$WORK/feat-b-body.txt

exec av adopt --remote feat-b --include ancestors --dry-run
stdout 'Would adopt:'
stdout 'feat-b \(parent: feat-a\)'
stdout 'feat-a \(parent: main\)'

-- feat-a-body.txt --
<!-- av pr metadata
```
{"parent":"main","trunk":"main"}
```
-->
-- feat-b-body.txt --
<!-- av pr metadata
```
{"parent":"feat-a","parentPull":1,"trunk":"main"}
```
-->
Loading