Skip to content

feat(workspace): surface worktree staleness at create-time (refs #52)#53

Merged
chubes4 merged 1 commit into
mainfrom
fix/worktree-staleness-surface
Apr 24, 2026
Merged

feat(workspace): surface worktree staleness at create-time (refs #52)#53
chubes4 merged 1 commit into
mainfrom
fix/worktree-staleness-surface

Conversation

@chubes4
Copy link
Copy Markdown
Member

@chubes4 chubes4 commented Apr 24, 2026

Summary

worktree add now unconditionally fetches origin before creating the new checkout, then compares the materialized branch (or the local base it was cut from) against its origin counterpart and surfaces the behind-count in the ability response + CLI output. Agents see at creation time that they're about to cook on top of a 47-commit-stale base, instead of discovering it at PR-review time.

Refs #52 (does not close — this is PR 1 of 2; the --allow-stale gate + --rebase-base opt-in live in a follow-up).

What changed

Non-breaking and purely additive. No gating, no auto-rebase, no new required inputs. Default invocations behave identically except for a freshness line in CLI output when the probe has something meaningful to say.

New: WorktreeStalenessProbe

Pure helper under inc/Workspace/. Two public static methods plus parse utilities:

  • fetch( $repo_path ) — wraps git fetch --quiet origin, returns ['ok' => bool, 'error' => string?]. Never throws. Offline work stays possible; the caller just learns staleness data is untrustworthy.
  • behind_count( $repo_path, $ref, $upstream ) — returns int on success, null when no upstream is configured, WP_Error on unexpected git failure. Callers MUST distinguish null from 0 — 0 means "up to date", null means "cannot tell".
  • parse_count() + is_missing_upstream() — tolerant parsing of rev-list output and a heuristic for git's many ways of saying "no upstream configured" (unknown revision / ambiguous argument / bad revision / etc.).

Modified: Workspace::worktree_add()

Three tweaks:

  1. Unconditional fetch at the top (right after input validation, before show-ref). Moved out of the new-branch arm where it used to live. Cheap — shared object store. Fetch failure is recorded in the response as fetch_failed: true + fetch_error: <tail> but does not abort worktree creation.

  2. Post-creation staleness computation in two forms:

    • Existing-local-branch path: rev-list --count <branch>..@{upstream} from the new worktree. Surfaces stale_commits_behind + upstream (best-effort derived via rev-parse --abbrev-ref --symbolic-full-name). When no @{upstream} is configured, fields are silently omitted.
    • New-branch-off-local-base path: when the resolved base is NOT a remote-tracking ref, compare it against origin/<base>. Surfaces base_stale_commits_behind + base_upstream. When the origin counterpart doesn't exist, silently omitted.
  3. is_remote_tracking_ref() helper — recognizes both refs/remotes/origin/* (what resolve_default_base() returns for the default-base path) and the shorter origin/* form a caller might pass explicitly. Both are "already at-tip post-fetch"; comparing them against themselves would produce nonsense. Caught this in live testing before opening the PR — the first pass used str_starts_with($resolved_base, 'origin/') which missed the fully-qualified form.

Modified: WorkspaceAbilities

datamachine/workspace-worktree-add output schema gains six optional fields: fetch_failed, fetch_error, stale_commits_behind, upstream, base_stale_commits_behind, base_upstream. All optional — omitted when no meaningful value to report.

Modified: WorkspaceCommand

New render_worktree_freshness() runs after the bootstrap block:

Bootstrap: ok
  - submodules skipped (no .gitmodules)
  ✓ packages   ran: npm ci
  ✓ composer   ran: composer install --no-interaction --prefer-dist
Warning: Freshness: ⚠ 47 commits behind origin/main
  Rebase before opening a PR:
    git -C /Users/…/data-machine@fix-foo pull --rebase origin main

Priority order: fetch_failed → existing-branch staleness → base staleness → up-to-date-with-label → elide. The "elide when no signal available" case is deliberate — a default invocation off origin/HEAD with no explicit base has no comparison to make, and printing "up to date" there would be claiming something we can't actually vouch for.

Testing

Smoke suite: 85/85 passing

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

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

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

Covers: parse_count() tolerance, is_missing_upstream() heuristics across git phrasings, real-git fixtures for fetch success + fetch failure (repo with no origin), behind_count() returning int when behind, null when no upstream configured, null when upstream ref doesn't exist, 0 when at tip (distinct from null).

Live CLI tests on intelligence-chubes4 Studio site

Scenario --from Expected Actual
Default base (origin/HEAD) origin/main elide (already remote-tracking) ✓ elided
Local stale base feat/workspace-worktrees (36 behind origin/main, no origin counterpart) elide (no origin ref) ✓ elided
First-pass bug origin/main resolved to refs/remotes/origin/main should elide ✗ hit broken guard — fixed with is_remote_tracking_ref()

The first-pass bug is what motivated the is_remote_tracking_ref() helper. Without it, default invocations would have tried to compute rev-list refs/remotes/origin/main..origin/refs/remotes/origin/main and produced garbage output. Caught before commit, test added implicitly via the origin/ short-form guard staying intact.

Out of scope (follow-up PR)

AI assistance

  • AI assistance: Yes
  • Tool(s): Claude Code (Opus 4.7)
  • Used for: Drafted the full implementation (probe helper, Workspace integration, ability schema, CLI rendering, smoke test, PR body). Live-tested against data-machine-code on this Studio site — caught the refs/remotes/origin/* guard bug mid-session and added the is_remote_tracking_ref() helper before commit. Chris steered the scope (PR 1 = non-breaking additive, PR 2 = gating behavior change) and caught the original session-spawn mistake that had the work happening in the wrong checkout.

refs #52

`worktree add` now unconditionally fetches `origin` before creating the
new checkout and then compares the materialized branch (or the local
base it was cut from) against its origin counterpart, surfacing the
behind-count in the ability response and CLI output. Agents can decide
whether to rebase before cooking instead of discovering day-one merge
conflicts at PR-review time.

Non-breaking: no gating, no auto-rebase, all new response fields are
optional, CLI warns but never fails. The `--allow-stale` + `--rebase-base`
behavior change lives in a follow-up PR (option 2 in #52).

Changes:
- `WorktreeStalenessProbe` helper — fetch + behind-count + output parsing,
  returns structured results instead of throwing (offline work still
  possible)
- `Workspace::worktree_add()` — unconditional fetch at the top, post-creation
  staleness computation for both the existing-local-branch path and the
  new-branch-off-local-base path
- `is_remote_tracking_ref()` helper — recognizes `refs/remotes/origin/*`
  (what `resolve_default_base()` returns) AND `origin/*` short form as
  "already at-tip post-fetch"
- `WorkspaceAbilities` — output schema gains six optional fields:
  `fetch_failed`, `fetch_error`, `stale_commits_behind`, `upstream`,
  `base_stale_commits_behind`, `base_upstream`
- `WorkspaceCommand::render_worktree_freshness()` — renders a `Freshness:`
  block after bootstrap output. Elides the line entirely when no signal is
  available rather than print a misleading "up to date"
- `tests/smoke-worktree-staleness.php` — 23 assertions: parse_count,
  is_missing_upstream heuristics, real-git fixtures for fetch success,
  fetch failure (no origin), behind-count parsing, no-upstream → null,
  at-tip → 0
@chubes4 chubes4 merged commit 6a5ba52 into main Apr 24, 2026
@chubes4 chubes4 deleted the fix/worktree-staleness-surface branch April 24, 2026 17:02
chubes4 added a commit that referenced this pull request Apr 24, 2026
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).
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.

1 participant