Skip to content

feat(workspace): gate stale worktree creation + opt-in rebase (closes #52)#54

Merged
chubes4 merged 1 commit into
mainfrom
feat/worktree-stale-gate
Apr 24, 2026
Merged

feat(workspace): gate stale worktree creation + opt-in rebase (closes #52)#54
chubes4 merged 1 commit into
mainfrom
feat/worktree-stale-gate

Conversation

@chubes4
Copy link
Copy Markdown
Member

@chubes4 chubes4 commented Apr 24, 2026

Summary

Builds on #53's non-breaking staleness signal. worktree add now refuses to materialize a worktree that would be more than datamachine_worktree_stale_threshold commits (default 50) behind upstream, tearing the half-cooked checkout down and returning a worktree_stale WP_Error with four concrete remediation options. Pass --allow-stale to opt in, or --rebase-base to auto-rebase onto the upstream tip before returning.

Closes #52 (this is option 2 from the issue — the behavior-change half).

Behavior change

Before: worktree add happily cooked on top of any local branch, no matter how stale. Agents discovered multi-hundred-commit drift at PR-review time when GitHub flagged merge conflicts.

After: by default, a worktree that would land >50 commits behind upstream is rolled back at create-time:

$ studio wp datamachine-code workspace worktree add data-machine-code stale-branch
Error: Worktree base is 51 commits behind origin/main (threshold: 50).
Options:
  - workspace git-pull data-machine-code --allow-primary-mutation  (refresh primary first)
  - worktree add … --from=origin/main  (cut from remote ref directly)
  - worktree add … --rebase-base  (auto-rebase onto upstream)
  - worktree add … --allow-stale  (proceed with known-stale base)

Threshold is filterable per-site / per-repo via datamachine_worktree_stale_threshold (default 50).

Rebase semantics

--rebase-base picks the right target per path:

  • Existing-local-branch path: rebase onto @{upstream}
  • New-branch-off-local-base path: rebase onto origin/<base>

Success clears the behind-count (worktree passes the gate cleanly). On conflict, the rebase is aborted — worktree stays at its pre-rebase HEAD, and rebase_succeeded: false + rebase_error: <tail> are surfaced. Critically, the gate still fires after a failed rebase. --rebase-base alone on a conflicting rebase is NOT a silent --allow-stale bypass; the agent has to acknowledge the conflict explicitly.

Fresh branches (no staleness to rebase) are a clean no-op — no rebase_attempted field, no noise in the output.

Changes

  • Workspace::worktree_add() — signature gains $allow_stale = false and $rebase_base = false. Rebase runs before the gate (success nullifies gate). Gate runs after the staleness probe, computes the filterable threshold, tears the worktree down with worktree remove --force and returns a worktree_stale WP_Error on violation.
  • effective_behind_count() helper — picks the one behind-count that matters for gating (existing-branch stale_commits_behind or new-branch-off-local-base base_stale_commits_behind, whichever is present).
  • try_rebase_worktree() helper — selects upstream target, runs the rebase, aborts on failure, zeroes the relevant behind-count on success.
  • WorkspaceAbilities — input schema gains allow_stale + rebase_base (both default false, both optional). Output schema gains gate_threshold, rebase_attempted, rebase_target, rebase_succeeded, rebase_error.
  • WorkspaceCommand — new --allow-stale + --rebase-base flags with full help blocks + examples. render_worktree_freshness() gains a rebase block that renders BEFORE staleness (success: Freshness: rebased onto <target>, failure: ⚠ rebase onto <target> failed — worktree stayed at pre-rebase HEAD + error tail).
  • tests/smoke-worktree-staleness.php — 7 new real-git-fixture assertions: branch 2 behind → 0 behind after rebase, consumer commit preserved on HEAD, conflicting rebase exits non-zero, rebase --abort restores pre-rebase SHA, behind-count preserved after abort.

Testing

Smoke suite: 92/92 passing

$ php tests/smoke-worktree-staleness.php
30 total, 30/30 passed

$ php tests/smoke-worktree-bootstrap.php
30/30 passed

$ php tests/smoke-worktree-handles.php
Result: 32/32 passed

Live CLI tests on intelligence-chubes4 Studio site

Fixture: local branch test/stale-branch-with-upstream tracking origin/main, 51 commits behind.

Scenario Flags Expected Actual
Gate fires on existing stale branch (none) WP_Error with guidance, worktree torn down ✓ error returned, ls confirms no dir left behind
--allow-stale bypasses gate --allow-stale Worktree created, ⚠ 51 commits behind origin/main warning ✓ created + warning surfaced
--rebase-base on stale branch --rebase-base Worktree at tip, Freshness: rebased onto @{upstream} ✓ rebased, 0 behind, HEAD at current main
--rebase-base on fresh branch --rebase-base --from=origin/main No-op, no rebase line ✓ elided (try_rebase_worktree returned null)

Conflict path validated via smoke fixture (scenario B in smoke-worktree-staleness.php) — real git conflict, git rebase --abort, pre-rebase SHA + behind-count both restored.

Not in this PR

  • Per-repo threshold overrides via repo config file. Filter is the lever for now; a config surface can land later if the filter approach turns out to be too coarse.
  • Rebase strategy flags (--rebase-merges, --autosquash etc.). Keep the rebase shape simple until someone needs more.

AI assistance

  • AI assistance: Yes
  • Tool(s): Claude Code (Opus 4.7)
  • Used for: Drafted the full implementation stacked on feat(workspace): surface worktree staleness at create-time (refs #52) #53 (two new helpers on Workspace, ability schema additions, CLI flags + rendering, 7 new smoke assertions). Live-tested all four CLI scenarios against a synthetic 51-commit-stale local branch on data-machine-code from this Studio site. Chris steered scope (PR 1 non-breaking additive, PR 2 gating behavior change) and caught the original kimaki session-spawn mistake on PR 1.

Closes #52

Builds on #53's non-breaking staleness signal. `worktree add` now refuses
to materialize a worktree that would be more than
`datamachine_worktree_stale_threshold` commits (default 50) behind its
upstream, tearing the half-cooked checkout down and returning a
`worktree_stale` WP_Error with remediation options. Pass `--allow-stale`
to opt in, or `--rebase-base` to auto-rebase onto the upstream tip before
returning. On rebase conflicts the rebase is aborted — `--rebase-base`
is not a silent bypass.

## Behavior change

Previously: `worktree add` happily cooked on top of any local branch, no
matter how stale. Agents discovered multi-hundred-commit drift at
PR-review time when GitHub flagged conflicts.

Now: by default, a worktree that would land >50 commits behind upstream
is rolled back at create-time with a WP_Error pointing at four concrete
remediation paths:

    Worktree base is 51 commits behind origin/main (threshold: 50).
    Options:
      - workspace git-pull data-machine-code --allow-primary-mutation
      - worktree add … --from=origin/main
      - worktree add … --rebase-base
      - worktree add … --allow-stale

Threshold is filterable per-site/per-repo via
`datamachine_worktree_stale_threshold`.

## Rebase semantics

`--rebase-base` picks the right target per path:
  - Existing-local-branch: rebase onto `@{upstream}`
  - New-branch-off-local-base: rebase onto `origin/<base>`

Success clears the behind-count (worktree passes the gate cleanly).
Failure aborts the rebase, the worktree stays at its pre-rebase HEAD,
and `rebase_succeeded: false` + `rebase_error: <tail>` are surfaced.
Critically, the staleness gate STILL fires after a failed rebase —
`--rebase-base` alone on a conflicting rebase isn't a silent
`--allow-stale` bypass.

## Changes

- `Workspace::worktree_add()` — new `$allow_stale` + `$rebase_base`
  params. Rebase runs before the gate (success nullifies gate). Gate
  runs after staleness probe, computes filterable threshold, tears the
  worktree down + returns WP_Error on violation.
- `effective_behind_count()` helper — picks the one behind-count that
  matters for gating (existing-branch or new-branch-off-local-base,
  whichever is present).
- `try_rebase_worktree()` helper — selects upstream target, runs rebase,
  aborts on failure, zeroes the relevant behind-count on success.
- `WorkspaceAbilities` — input schema gains `allow_stale` + `rebase_base`
  (both default false). Output schema gains `gate_threshold`,
  `rebase_attempted`, `rebase_target`, `rebase_succeeded`, `rebase_error`.
- `WorkspaceCommand` — new `--allow-stale` + `--rebase-base` flags with
  full help blocks + examples. `render_worktree_freshness()` gains a
  rebase block that renders BEFORE staleness (success: "rebased onto
  <target>", failure: "⚠ rebase onto <target> failed" + error tail).
- `tests/smoke-worktree-staleness.php` — 7 new real-git-fixture
  assertions for rebase success (branch 2 behind → 0 behind after
  rebase, consumer commit preserved) and rebase conflict handling
  (non-zero exit, abort restores pre-rebase SHA, behind-count preserved).
@chubes4 chubes4 merged commit c0cc6dd into main Apr 24, 2026
@chubes4 chubes4 deleted the feat/worktree-stale-gate branch April 24, 2026 17:11
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.

Worktree add doesn't check staleness — primary drift and stale local branches ship PRs with day-one merge conflicts

1 participant