Skip to content

feat: add driver abstraction for PR stacking tool support#60

Open
nvandessel wants to merge 8 commits intomainfrom
feature/frond-graphite
Open

feat: add driver abstraction for PR stacking tool support#60
nvandessel wants to merge 8 commits intomainfrom
feature/frond-graphite

Conversation

@nvandessel
Copy link
Copy Markdown
Owner

Summary

  • Introduces a Driver interface that abstracts all git/PR operations (branch creation, push, rebase, PR state queries), enabling frond to work with different stacking tools interchangeably
  • Adds native (git+gh) and graphite (gt CLI) driver implementations, plus a mock driver for testing
  • Adds frond init command for configuring driver selection per-repo (stored in frond.json)
  • Refactors all commands (new, push, sync, status, track, untrack) to use the driver interface instead of directly calling git/gh
  • Parses gt submit output directly for PR numbers instead of shelling out to gh — no unnecessary gh dependency in the Graphite driver
  • Simplifies test suite by using the mock driver instead of complex git repo setup

Test plan

  • go build ./... passes
  • go test ./... — all tests pass including new parsePRNumber table-driven tests
  • go vet ./... — no issues
  • golangci-lint run — 0 issues
  • gofmt -l . — no formatting issues
  • Manual: frond init creates/updates frond.json with driver field
  • Manual: frond push works with native driver (existing behavior)
  • Manual: frond push works with graphite driver (requires gt installed)

🤖 Generated with Claude Code

@codecov-commenter
Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

@nvandessel nvandessel marked this pull request as draft February 27, 2026 18:01
@nvandessel
Copy link
Copy Markdown
Owner Author

converting to draft until I test with graphite. I think this is a good foundation though.

@nvandessel
Copy link
Copy Markdown
Owner Author

@greptile review

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Feb 27, 2026

Greptile Summary

This PR introduces a clean Driver interface that decouples frond's DAG/state layer from specific git/PR tooling, adding native (git+gh), graphite (gt CLI), and mock implementations, plus a new frond init command for per-repo driver configuration. The abstraction is well-designed and the test suite is meaningfully simplified by switching to the in-memory mock driver.

Key observations:

  • PR number persistence bug: In cmd/push.go, the PR number from drv.Push() is only written to state when result.Created == true. With the Graphite driver, gt submit submits the entire stack — if a sibling branch's PR was silently created during a prior push of another branch, a later frond push on that branch will receive (updated) from gt submit, setting result.Created = false, and the PR number is never persisted. This leaves the branch permanently without a recorded PR in frond's state, breaking frond status --fetch and frond sync retargeting for it. The fix is to also save when br.PR == nil.
  • driverOverride concurrency: The package-level driverOverride variable in cmd/helpers.go is not mutex-protected; while safe today (no t.Parallel() calls), it is a latent data race if tests are ever parallelised.
  • The Graphite.Rebase() hardcoding Branch: "stack" in RebaseConflictError is harmless (sync.go uses its loop variable instead), but adding a comment would clarify intent.
  • The parseSubmitResult regex and PR number extraction via LastIndex("/") correctly handles both app.graphite.com and app.graphite.dev URL formats, as well as GitHub PR URLs.
  • The frond init flow — validate driver → lock → read/create state → write driver name — is correctly ordered.

Confidence Score: 3/5

  • Mostly safe to merge for the native driver path; the Graphite driver has a concrete state-persistence bug that should be fixed before shipping it to users.
  • The native driver path and all existing tests pass cleanly, and the abstraction itself is well-structured. However, there is a confirmed logic bug in cmd/push.go where PR numbers returned as "updated" by the Graphite driver are silently dropped from state, which would make the Graphite integration unreliable in real use. This is not caught by the current test suite because the mock driver's default PushFn always returns Created=true when ExistingPR is nil, meaning the failing scenario is untested.
  • cmd/push.go (PR number persistence condition) and internal/driver/graphite.go (implicit full-stack submit interaction with push.go's state-write guard)

Important Files Changed

Filename Overview
internal/driver/driver.go Defines the Driver interface, PushOpts/PushResult types, RebaseConflictError, PR state constants, and the Resolve factory. Interface is clean and well-documented; PR state constants and error types are properly exported.
internal/driver/graphite.go Embeds Native and overrides CreateBranch/Push/Rebase for gt CLI. The PR number is correctly parsed from gt submit output via submitLineRe and parseSubmitResult. However, when ExistingPR is non-nil the gt submit output is always discarded (minor clarity issue), and the Rebase implementation hardcodes Branch:"stack" in RebaseConflictError (harmless since sync.go uses its own loop variable).
internal/driver/mock.go Stateful in-memory mock with override hooks for all driver methods. Well-structured; correctly models branch creation, checkout, and PR state. StackComments flag correctly controls SupportsStackComments() for driver-specific test scenarios.
cmd/push.go Correctly delegates push/PR operations to the driver. Critical bug: PR number is only saved to state when result.Created==true. With the Graphite driver, gt submit can output "(updated)" for a branch whose PR was created implicitly by a prior stack submission, causing the PR number to be permanently lost from frond's state.
cmd/helpers.go Adds driverOverride global for test injection and resolveDriver() helper. Works correctly for current sequential tests but driverOverride is unprotected against concurrent access — a latent race if any future tests use t.Parallel().
cmd/init.go New frond init command validates driver availability, acquires lock, and persists driver name to state. Correctly normalises "native" to "" for storage and uses drv.Name() (returns "native") in output. State preservation across re-init is verified by tests.
cmd/sync.go Correctly refactored to use driver interface for fetch, rebase, PR state queries, and retargeting. Conflict handling via driver.RebaseConflictError works correctly since conflictBranch is set from the loop variable (not conflictErr.Branch), making the "stack" sentinel in Graphite's error irrelevant.
cmd/cmd_test.go Significantly simplified by replacing real git repos and fake-gh PATH manipulation with the mock driver. New tests for init command, sync with merged PRs, rebase conflicts, and driver resolution are thorough. withFakeGH() correctly scoped to only tests that need the gh comment API.

Sequence Diagram

sequenceDiagram
    participant User
    participant frond CLI
    participant resolveDriver
    participant Native Driver
    participant Graphite Driver
    participant git/gh CLIs
    participant gt CLI

    User->>frond CLI: frond init --driver graphite
    frond CLI->>resolveDriver: Resolve("graphite")
    resolveDriver->>gt CLI: exec.LookPath("gt")
    gt CLI-->>resolveDriver: found
    resolveDriver-->>frond CLI: *Graphite
    frond CLI->>frond CLI: state.Write (driver: "graphite")

    User->>frond CLI: frond push
    frond CLI->>resolveDriver: resolveDriver(state)
    alt driver == "" or "native"
        resolveDriver-->>frond CLI: *Native
        frond CLI->>Native Driver: Push(PushOpts)
        Native Driver->>git/gh CLIs: git push + gh pr create/edit
        git/gh CLIs-->>Native Driver: PRNumber, Created
        Native Driver-->>frond CLI: PushResult
    else driver == "graphite"
        resolveDriver-->>frond CLI: *Graphite
        frond CLI->>Graphite Driver: Push(PushOpts)
        Graphite Driver->>gt CLI: gt submit --no-interactive --no-edit
        gt CLI-->>Graphite Driver: "branch: https://.../42 (created)"
        Graphite Driver->>Graphite Driver: parseSubmitResult(out, branch)
        Graphite Driver-->>frond CLI: PushResult{PRNumber:42, Created:true}
    end
    frond CLI->>frond CLI: state.Write (if Created)

    User->>frond CLI: frond sync
    frond CLI->>resolveDriver: resolveDriver(state)
    frond CLI->>Native Driver: Fetch / PRState / Rebase / RetargetPR
    Note over Native Driver,gt CLI: Graphite driver uses gt restack instead of git rebase
Loading

Comments Outside Diff (1)

  1. cmd/helpers.go, line 30 (link)

    driverOverride not mutex-protected

    driverOverride is a package-level variable mutated by tests (set in setupTestEnv, cleared in cleanup). No current test calls t.Parallel(), but if any are parallelised in the future this will introduce a data race. Wrapping with a sync.Mutex (or using atomic.Value) would make the override safe to use in parallel tests:

    var (
        driverOverrideMu sync.Mutex
        driverOverride   driver.Driver
    )
    
    func resolveDriver(st *state.State) (driver.Driver, error) {
        driverOverrideMu.Lock()
        ovr := driverOverride
        driverOverrideMu.Unlock()
        if ovr != nil {
            return ovr, nil
        }
        return driver.Resolve(st.Driver)
    }

Last reviewed commit: b4a8e5e

Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

18 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

@nvandessel
Copy link
Copy Markdown
Owner Author

Code review

Found 1 issue:

  1. Graphite.Push silently drops opts.Body -- the --body flag works with the native driver but is a no-op with Graphite. PushOpts.Body is populated from cmd/push.go but never passed to gt submit. Graphite CLI supports --description for this. Users running frond push --body "..." with the Graphite driver will have their PR description silently discarded.

if opts.Draft {
args = append(args, "--draft")
}
if opts.Title != "" && opts.ExistingPR == nil {
args = append(args, "--title", opts.Title)
}
out, err := runGT(ctx, args...)
if err != nil {
return nil, fmt.Errorf("gt submit: %s: %w", out, err)

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

nvandessel and others added 6 commits February 27, 2026 19:23
Introduce a Driver interface that abstracts all branch/PR/git operations,
enabling frond to delegate to different stacking tools (native git+gh,
Graphite, etc.) while managing the DAG layer on top.

Key changes:
- internal/driver/: Driver interface, Native (git+gh), Graphite (gt),
  and Mock implementations with PushOpts/PushResult/RebaseConflictError
- internal/state: Add Driver field, export GitCommonDir for testability
- cmd/: Refactor all commands (new, push, sync, status, track, untrack)
  to use driver instead of direct git/gh imports
- cmd/init.go: New `frond init [--driver]` command
- cmd/cmd_test.go: Full rewrite using mock driver (no git/gh subprocess)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix Graphite.Rebase discarding caller's context (used background ctx)
- Remove unnecessary local variable in Native.Push
- Add tests: sync merged-PR path, sync rebase conflict (ExitError code 2),
  resolveDriver with state/override, Resolve("graphite") skip-if-not-installed
- cmd coverage: 78.4% -> 86.2%

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace lookupPRByBranch (which shelled out to `gh pr view`) with
parsePRNumber that extracts PR numbers directly from `gt submit` output.
This removes the unnecessary `gh` CLI dependency from the Graphite driver.

Also: tighten Mock.Checkout to validate branch existence, delete unused
fakegt test double, add table-driven tests for parsePRNumber.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Pass opts.Body as --description to gt submit (was silently dropped)
- Return created/updated status from parseSubmitResult instead of
  hardcoding Created: true (uses the captured group from gt output)
- Add PR state constants to driver package, replace magic string
  "MERGED" in sync.go (reverts regression from PR #20's fix)
- Add NewNative() constructor with gh.Available() check, matching
  the pattern established by NewGraphite() for gt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Build a fakegt test double (env-var-controlled) via TestMain and
prepend it to PATH, then test the Graphite driver end-to-end in
isolation without needing the real gt CLI or GitHub.

Tests cover: NewGraphite, CreateBranch (with real temp git repo),
Push (created/updated/existing PR paths), --title/--description/
--draft flag forwarding, Push failure, Rebase success, and Rebase
conflict detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Graphite manages its own stack visualization on PRs, so frond should
skip posting stack comments when using the Graphite driver. Add
SupportsStackComments() to the Driver interface (native=true,
graphite=false) and guard updateStackComments/updateMergedComments
calls in push and sync commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@nvandessel nvandessel force-pushed the feature/frond-graphite branch from 82a59e8 to da84d19 Compare February 28, 2026 03:33
@nvandessel
Copy link
Copy Markdown
Owner Author

@greptileai review

Graphite.CreateBranch was bypassing the driver abstraction by calling
git.Checkout directly. Use g.Checkout (inherited from Native) to stay
consistent with the driver pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@nvandessel nvandessel self-assigned this Mar 8, 2026
@nvandessel nvandessel marked this pull request as ready for review March 8, 2026 01:14
Comment on lines +108 to 114
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)
}
}
}
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)
}
}

Comment on lines +61 to +63
// For existing PRs, return the existing number.
if opts.ExistingPR != nil {
return &PushResult{PRNumber: *opts.ExistingPR, Created: false}, nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

(updated) output status is silently ignored when ExistingPR is set

When opts.ExistingPR != nil, gt submit is still executed and may report that the stack has changed (e.g., a previously-draft PR is now ready), but the result is discarded in favour of the stored PR number. While this is benign today (the PR number is stable), a comment explaining the deliberate choice to skip parsing would help future readers understand why the output is intentionally thrown away here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants