Skip to content
925 changes: 385 additions & 540 deletions cmd/cmd_test.go

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,22 @@ import (
"unicode"

"github.com/nvandessel/frond/internal/dag"
"github.com/nvandessel/frond/internal/driver"
"github.com/nvandessel/frond/internal/state"
)

// driverOverride is nil in production; tests set it to inject a mock driver.
var driverOverride driver.Driver

// resolveDriver returns the active driver. If driverOverride is set (tests),
// it is returned directly. Otherwise the driver is resolved from state.
func resolveDriver(st *state.State) (driver.Driver, error) {
if driverOverride != nil {
return driverOverride, nil
}
return driver.Resolve(st.Driver)
}

// validateBranchName checks that a branch name is safe to use with git commands.
func validateBranchName(name string) error {
if name == "" {
Expand Down
73 changes: 73 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package cmd

import (
"fmt"

"github.com/nvandessel/frond/internal/driver"
"github.com/nvandessel/frond/internal/state"
"github.com/spf13/cobra"
)

var initCmd = &cobra.Command{
Use: "init",
Short: "Initialize frond state with an optional driver",
Example: ` # Initialize with the default native driver
frond init

# Initialize with the Graphite driver
frond init --driver graphite`,
RunE: runInit,
}

func init() {
initCmd.Flags().String("driver", "", "Driver to use: native (default), graphite")
rootCmd.AddCommand(initCmd)
}

func runInit(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
driverName, _ := cmd.Flags().GetString("driver")

// Validate the driver is known and its CLI is available.
drv, err := driver.Resolve(driverName)
if err != nil {
return err
}

// Lock state.
unlock, err := state.Lock(ctx)
if err != nil {
return fmt.Errorf("acquiring lock: %w", err)
}
defer unlock()

// ReadOrInit creates state if needed.
s, err := state.ReadOrInit(ctx)
if err != nil {
return fmt.Errorf("reading state: %w", err)
}

// Set the driver if specified (or clear to native default).
if driverName == "native" {
driverName = ""
}
s.Driver = driverName

if err := state.Write(ctx, s); err != nil {
return fmt.Errorf("writing state: %w", err)
}

if jsonOut {
return printJSON(initResult{
Driver: drv.Name(),
Trunk: s.Trunk,
})
}
fmt.Printf("Initialized frond (driver: %s, trunk: %s)\n", drv.Name(), s.Trunk)
return nil
}

type initResult struct {
Driver string `json:"driver"`
Trunk string `json:"trunk"`
}
31 changes: 18 additions & 13 deletions cmd/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"strings"

"github.com/nvandessel/frond/internal/git"
"github.com/nvandessel/frond/internal/state"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -51,56 +50,62 @@ func runNew(cmd *cobra.Command, args []string) error {
return fmt.Errorf("reading state: %w", err)
}

// 3. Resolve driver
drv, err := resolveDriver(s)
if err != nil {
return err
}

// Check if branch already exists in git
exists, err := git.BranchExists(ctx, name)
exists, err := drv.BranchExists(ctx, name)
if err != nil {
return fmt.Errorf("checking branch existence: %w", err)
}
if exists {
return fmt.Errorf("branch '%s' already exists. Use 'frond track' to add it", name)
}

// 3. Resolve parent: --on flag -> current branch if tracked -> trunk
// 4. Resolve parent: --on flag -> current branch if tracked -> trunk
onFlag, _ := cmd.Flags().GetString("on")
parent := s.Trunk
if onFlag != "" {
parent = onFlag
} else {
current, err := git.CurrentBranch(ctx)
current, err := drv.CurrentBranch(ctx)
if err == nil {
if _, tracked := s.Branches[current]; tracked {
parent = current
}
}
}

// 4. Parse --after
// 5. Parse --after
afterFlag, _ := cmd.Flags().GetString("after")
var after []string
if afterFlag != "" {
after = strings.Split(afterFlag, ",")
}

// 5. Validate parent branch exists in git
parentExists, err := git.BranchExists(ctx, parent)
// 6. Validate parent branch exists in git
parentExists, err := drv.BranchExists(ctx, parent)
if err != nil {
return fmt.Errorf("checking parent branch: %w", err)
}
if !parentExists {
return fmt.Errorf("parent branch '%s' does not exist", parent)
}

// 6. Validate --after deps and check for cycles
// 7. Validate --after deps and check for cycles
if err := validateAfterDeps(s.Branches, name, after); err != nil {
return err
}

// 7. git.CreateBranch (also checks it out)
if err := git.CreateBranch(ctx, name, parent); err != nil {
// 8. Create branch (also checks it out)
if err := drv.CreateBranch(ctx, name, parent); err != nil {
return fmt.Errorf("creating branch: %w", err)
}

// 7. Write branch to state.Branches
// 9. Write branch to state.Branches
if after == nil {
after = []string{}
}
Expand All @@ -109,12 +114,12 @@ func runNew(cmd *cobra.Command, args []string) error {
After: after,
}

// 8. Write state
// 10. Write state
if err := state.Write(ctx, s); err != nil {
return fmt.Errorf("writing state: %w", err)
}

// 9. Output
// 11. Output
if jsonOut {
return printJSON(newResult{
Name: name,
Expand Down
108 changes: 46 additions & 62 deletions cmd/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import (
"strings"
"unicode"

"github.com/nvandessel/frond/internal/gh"
"github.com/nvandessel/frond/internal/git"
"github.com/nvandessel/frond/internal/driver"
"github.com/nvandessel/frond/internal/state"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -51,88 +50,73 @@ func humanizeTitle(branch string) string {
func runPush(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

// 1. Check gh is available.
if err := gh.Available(); err != nil {
return fmt.Errorf("gh CLI is required. Install: https://cli.github.com")
}

// 2. Get current branch.
branch, err := git.CurrentBranch(ctx)
if err != nil {
return fmt.Errorf("getting current branch: %w", err)
}

// 3. Lock state, defer unlock.
// 1. Lock state, defer unlock.
unlock, err := state.Lock(ctx)
if err != nil {
return fmt.Errorf("acquiring lock: %w", err)
}
defer unlock()

// 4. Read state (not ReadOrInit).
// 2. Read state (not ReadOrInit).
st, err := state.Read(ctx)
if err != nil {
return fmt.Errorf("reading state: %w", err)
}

// 3. Resolve driver.
drv, err := resolveDriver(st)
if err != nil {
return err
}

// 4. Get current branch.
branch, err := drv.CurrentBranch(ctx)
if err != nil {
return fmt.Errorf("getting current branch: %w", err)
}

// 5. Current branch must be tracked.
br, ok := st.Branches[branch]
if !ok {
return fmt.Errorf("current branch '%s' is not tracked", branch)
}

// 6. Push to origin.
if err := git.Push(ctx, branch); err != nil {
return fmt.Errorf("pushing to origin: %w", err)
// 6. Build push opts.
title, _ := cmd.Flags().GetString("title")
if title == "" {
title = humanizeTitle(branch)
}
body, _ := cmd.Flags().GetString("body")
draft, _ := cmd.Flags().GetBool("draft")

opts := driver.PushOpts{
Branch: branch,
Base: br.Parent,
Title: title,
Body: body,
Draft: draft,
ExistingPR: br.PR,
}

created := false
var prNumber int

// 7. If no PR exists, create one.
if br.PR == nil {
title, _ := cmd.Flags().GetString("title")
if title == "" {
title = humanizeTitle(branch)
}
body, _ := cmd.Flags().GetString("body")
draft, _ := cmd.Flags().GetBool("draft")

prNumber, err = gh.PRCreate(ctx, gh.PRCreateOpts{
Base: br.Parent,
Head: branch,
Title: title,
Body: body,
Draft: draft,
})
if err != nil {
return fmt.Errorf("creating PR: %w", err)
}
// 7. Push (creates or updates PR).
result, err := drv.Push(ctx, opts)
if err != nil {
return fmt.Errorf("pushing: %w", err)
}

br.PR = &prNumber
// 8. Write PR number to state if created.
if result.Created {
br.PR = &result.PRNumber
st.Branches[branch] = br
if err := state.Write(ctx, st); err != nil {
return fmt.Errorf("writing state: %w", err)
}
created = true
} else {
// 8. PR exists — check if base needs retargeting.
prNumber = *br.PR

info, err := gh.PRView(ctx, prNumber)
if err != nil {
return fmt.Errorf("viewing PR #%d: %w", prNumber, err)
}

if info.BaseRefName != br.Parent {
if err := gh.PREdit(ctx, prNumber, br.Parent); err != nil {
return fmt.Errorf("retargeting PR #%d: %w", prNumber, err)
}
}
}
Comment on lines +108 to 114
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PR number silently dropped for Graphite (updated) result

With the Graphite driver, gt submit always submits the entire stack. When a user first pushes branch B (downstream), gt submit creates PRs for both branch A and branch B — but frond only records the PR number for branch B (since that is opts.Branch). Later, when the user runs frond push on branch A, gt submit outputs (updated) for branch A because its PR already exists on GitHub. parseSubmitResult correctly extracts the PR number, but result.Created is false, so this block is skipped and the PR number is never persisted to state.

This leaves branch A's br.PR == nil permanently, so future frond status --fetch and frond sync operations cannot see or retarget the PR.

The condition should also save the number when frond doesn't yet have a record of the PR (br.PR == nil):

Suggested change
if result.Created {
br.PR = &result.PRNumber
st.Branches[branch] = br
if err := state.Write(ctx, st); err != nil {
return fmt.Errorf("writing state: %w", err)
}
created = true
} else {
// 8. PR exists — check if base needs retargeting.
prNumber = *br.PR
info, err := gh.PRView(ctx, prNumber)
if err != nil {
return fmt.Errorf("viewing PR #%d: %w", prNumber, err)
}
if info.BaseRefName != br.Parent {
if err := gh.PREdit(ctx, prNumber, br.Parent); err != nil {
return fmt.Errorf("retargeting PR #%d: %w", prNumber, err)
}
}
}
if result.Created || br.PR == nil {
br.PR = &result.PRNumber
st.Branches[branch] = br
if err := state.Write(ctx, st); err != nil {
return fmt.Errorf("writing state: %w", err)
}
}


// 9. Update stack comments on all PRs.
updateStackComments(ctx, st)
// 9. Update stack comments on all PRs (skip for drivers that manage their own).
if drv.SupportsStackComments() {
updateStackComments(ctx, st)
}

// 10. Check for unmet --after deps: warn if any are still tracked.
if len(br.After) > 0 {
Expand All @@ -151,15 +135,15 @@ func runPush(cmd *cobra.Command, args []string) error {
if jsonOut {
return printJSON(pushResult{
Branch: branch,
PR: prNumber,
Created: created,
PR: result.PRNumber,
Created: result.Created,
})
}
action := "updated"
if created {
if result.Created {
action = "created"
}
fmt.Printf("Pushed %s. PR #%d [%s]\n", branch, prNumber, action)
fmt.Printf("Pushed %s. PR #%d [%s]\n", branch, result.PRNumber, action)

return nil
}
Loading
Loading