Skip to content

Harden worktree start point + push safety against PR #315-class wipes#318

Merged
subsetpark merged 7 commits into
mainfrom
worktree-start-point-planner
May 24, 2026
Merged

Harden worktree start point + push safety against PR #315-class wipes#318
subsetpark merged 7 commits into
mainfrom
worktree-start-point-planner

Conversation

@subsetpark
Copy link
Copy Markdown
Collaborator

@subsetpark subsetpark commented May 24, 2026

Summary

Three-patch fix for the bug class that caused PR #315 to be force-pushed empty and auto-closed by GitHub. Each commit is independently shippable and adds its own pure decision module + property tests.

  • Add --force-if-includes to session-end force-push (one-line) — the smallest possible safety lever. --force-with-lease alone passes whenever the local tracking ref matches actual remote, even when local is strictly behind on real content; --force-if-includes additionally requires the remote tip in the local branch's reflog, refusing pushes that would silently wipe commits the local clone never observed.
  • Plan worktree start point against fresh remote refs — new lib_core/start_point_plan.ml (pure) plus Worktree_setup.ensure_worktree fetch-before-create. Worktree.create no longer trusts the user's local refs/heads/<branch> when it's stale; the planner returns Reset_and_use_remote_tracking / Use_local_branch_unchanged / Create_new_branch_from_base or one of four refusal variants. Signature changes to (t, refusal) Result.t. Property tests SPP-1..9 (totality, no-silent-clobber, remote-authority, pre-emption order, determinism, label bounds, exhaustive variant reachability) plus a real-git integration test reproducing the exact Push-rejection loop guard + SSH transport auto-detect #315 stale-local state.
  • Plan pushes against worktree state, not just lease — new lib_core/push_plan.ml (pure) plus an extension to Push_reject_classify.rejection (new Local_state_unsafe variant, permanent). The planner takes worktree-HEAD-branch, branch-ref SHA, remote tracking SHA, two-way ancestry, and base..refs/heads/<branch> commits-ahead (the named branch, not HEAD) and returns either a push action or one of five refusal variants. Worktree.force_push_with_lease now consults it before invoking git; refusals route through the existing is_permanentneeds_intervention path. Property tests PPP-1..11 plus a real-git integration test covering branch-switched, local-missing-remote, and happy-path scenarios.

The architecture mirrors the existing Push_reject_classify / Rebase_decision / Patch_decision convention: pure decision logic in lib_core/, effectful handlers in lib/, one property-test file per pure module.

Test plan

  • dune build clean
  • dune runtest passes (full suite, including the new SPP-* and PPP-* property tests and two new real-git integration tests)
  • dune fmt produces no diff
  • Manual repro: in a scratch clone, set refs/heads/<branch> to the PR base (stale local), then start onton against that PR — confirm the activity log shows the fetch-then-reset path and the worktree HEAD matches the PR head, not the stale local ref
  • Manual repro: hand-corrupt a worktree so a session-end push would force-overwrite remote, confirm Push_refused with refuse_local_behind / refuse_branch_switched rather than a git push reaching the remote

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.


Summary by cubic

Hardened worktree creation and push safety to prevent silent branch wipes like PR #315. Adds pure planners for start-point and push, uses --force-if-includes, and permanently refuses unsafe local states before any git runs.

  • Bug Fixes

    • Use --force-with-lease + --force-if-includes to block pushes that would drop unseen remote commits.
    • Plan worktree start from fresh remote refs; reset to origin/<branch> when local is stale, create from base if new, and refuse local-ahead/diverged. Worktree_setup maps refusals to permanent Local_state_unsafe.
    • Plan pushes against the named branch and actual worktree state; refuse if branch switched, missing refs, or local behind/diverged, and map refusals to permanent Local_state_unsafe.
    • Treat Worktree_setup.Refused distinctly from Missing across callers to avoid ambiguous failures and escalate permanently.
  • Migration

    • Worktree.create now returns (t, Start_point_plan.refusal) Result.t.
    • Worktree_setup.ensure_worktree now returns Worktree_setup.ensure_result (Path | Missing | Refused).
    • Worktree.S adds fetch_origin_branch; Worktree_setup.ensure_worktree calls it before create.
    • Push_reject_classify.rejection adds Local_state_unsafe (permanent).
    • Requires Git ≥ 2.30 for --force-if-includes.

Written for commit 593c5e5. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • New Features

    • Added deterministic planners to validate worktree creation and push decisions, and safer push execution paths that classify outcomes.
  • API Changes

    • Worktree creation now surfaces structured refusal results instead of only success.
    • Fetch-by-branch added to the public interface.
  • Tests

    • Added extensive property-based and integration tests covering start-point and push planning, rejection classifications, and end-to-end flows.

Review Change Stack

@vercel
Copy link
Copy Markdown

vercel Bot commented May 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
onton Ready Ready Preview, Comment May 24, 2026 6:24pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 24, 2026

📝 Walkthrough

Walkthrough

This PR adds pure planners (Start_point_plan, Push_plan), wires them into worktree creation and push flows, introduces git subprocess helpers and best-effort fetch, surfaces structured refusals, updates rejection classification, and adds property and integration tests.

Changes

Pure Planning Modules and Integration

Layer / File(s) Summary
Start_point_plan planner
lib_core/start_point_plan.mli, lib_core/start_point_plan.ml
Pure module deciding reset/use-local/create actions or refusal reasons for worktree start points; exports plan and short_label.
Push_plan planner
lib_core/push_plan.mli, lib_core/push_plan.ml
Pure module deciding Initial_push vs Force_push_if_includes or refusals based on worktree/branch/ref/ancestry/commits-ahead inputs; exports plan, short_label, and mapping to rejection classification.
Rejection classification integration
lib_core/push_reject_classify.mli, lib_core/push_reject_classify.ml
Adds Local_state_unsafe { reason } rejection variant, updates short_label, detail_excerpt, and is_permanent.
Git subprocess helpers and planner inputs
lib/worktree.ml (helpers)
Adds run_git_exit_code, read_repo_ref_sha, compute_repo_ancestry, and fetch_origin_branch to collect planner inputs (best-effort fetch and ancestry probes).
Worktree.create refactored with Start_point_plan
lib/worktree.ml (create flow)
Adds execute_start_point_action, refactors create to precompute SHAs/ancestry, call Start_point_plan.plan, return (t, refusal) Result.t on refusal, execute planned git actions, and handle concurrent-creation fallback.
Worktree.force_push_with_lease refactored with Push_plan
lib/worktree.ml (push flow)
Introduces gather_push_plan_inputs to measure commits-ahead on refs/heads/<branch> and other inputs, drives Push_plan.plan, maps refusals to push_result/rejections, executes git push -u or force push with --force-with-lease --force-if-includes, and classifies results.
Worktree API: interface updates
lib/worktree.mli, lib/worktree.ml
create now returns (t, Start_point_plan.refusal) Result.t; module type S adds fetch_origin_branch (branch-based best-effort fetch) and replaces the old path-based fetch_origin; classify_push_result docs updated for --force-if-includes; make functor wired accordingly.
Worktree_setup: pre-fetch and refusal handling
lib/worktree_setup.ml
ensure_worktree best-effort fetches refs/remotes/origin/<branch> under fetch mutex before planning, treats W.create outcomes as Created/Refused/Raised, logs planner labels, updates orchestrator on refusal, and supports concurrent-adoption on Raised.
Start_point_plan property tests
test/test_start_point_plan_properties.ml
QCheck2 properties validating totality, pre-emption, reset/create/use-local selection, determinism, label constraints, reachability, and remote-authority semantics.
Push_plan property tests
test/test_push_plan_properties.ml
QCheck2 properties asserting totality, refusal classification (branch-switch, local-missing/diverged, zero-commits), initial vs force push selection, worktree-missing pre-emption, determinism, reachability, permanence, and short_label constraints.
Push_reject_classify property tests updated
test/test_push_reject_classify_properties.ml
Adds tests and updates to cover the new Local_state_unsafe variant and its properties.
Worktree.create integration tests
test/test_worktree_start_point_integration.ml
End-to-end tests driving Worktree.create against real git repos covering all Start_point_plan arms.
Worktree.force_push_with_lease integration tests
test/test_push_plan_integration.ml
Integration tests exercising force_push_with_lease with scenarios for branch-switched and local-missing-remote refusals and a successful push, verifying remote state behavior.
Test infrastructure and doubles
test/dune, test/test_dependency_injection_functors.ml
Adds Dune stanzas for new tests and extends Fake_worktree with fetch_origin_branch stub.
Documentation: worktree_parser
lib_core/worktree_parser.ml
Updates classify_push_result comment to reflect --force-if-includes usage.

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • flowglad/onton#290: Modifies lib/worktree.ml and Worktree.S interface; related to changes here that refactor create and fetch_origin* wiring.
  • flowglad/onton#315: Related to push rejection handling and orchestrator escalation that consume structured push rejections produced by the new Push_plan.

"I'm a rabbit with a tiny plan,
I hop through branches, SHA by SHA,
I fetch, I probe, then I declare —
'Push or pause; don't clobber there!'" 🐇✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main change: hardening worktree start point and push safety against a specific bug class (PR #315-class wipes).
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-start-point-planner

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@flowglad-review-service flowglad-review-service Bot left a comment

Choose a reason for hiding this comment

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

Code Review: 3 comments

Comment thread lib/worktree_setup.ml
Comment thread lib/worktree.ml Outdated
Comment thread test/test_push_plan_properties.ml
@flowglad-review-service
Copy link
Copy Markdown

Review Summary

This is a behavior-class patch fixing the PR-#315 wipe bug. The pure decision modules (Start_point_plan, Push_plan) and their property tests are well-structured. Two correctness issues were found:

  1. must-fix — planner refusals silently become Session_worktree_missing: When Start_point_plan returns a permanent refusal (Local_diverged_from_remote, Local_has_unpushed_commits), ensure_worktree sets created = false and falls into the existing false, _ recovery arm that tries find_for_branch then returns None. The callers map None to Session_worktree_missing, which triggers a reconstruction retry loop rather than routing to needs_intervention as the PR claims. Permanent refusals must be distinguished from transient worktree-missing cases.

  2. must-fix — single-probe ancestry in gather_push_plan_inputs conflates "local behind" with "push would wipe commits": The Push_plan.Local_missing_remote classification uses only one --is-ancestor probe (is-remote-ancestor-of-local). Exit 1 from that probe covers both "local is strictly behind remote" (safe, rebase needed) and "truly diverged" (push would wipe commits). This produces false-positive permanent Local_missing_remote_commits refusals for agents in a normal post-rebase state. The compute_repo_ancestry function in the same file correctly uses two probes; gather_push_plan_inputs should do the same.

One should-fix comment flags a coverage gap in PPP-2's generator that lets a head-matching inversion bug pass undetected.

Severity Count
Must-fix 2
Should-fix 1
Note 0

Variant: convergence-v2

Candidates: 3 | Posted: 3 | Suppressed: 0


3 comments posted · Model: claude-sonnet-4-6 (sonnet) · Tokens: 38563 in / 4727 out · Cache: 174917 created / 727402 read · Review mode: agentic · Turns: 15 · Tool calls: 20 · Forced submit: yes · Tool mix: read_file=11, search_codebase=8, find_references=1

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@lib_core/push_reject_classify.ml`:
- Around line 110-111: The Local_state_unsafe arm of detail_excerpt currently
returns reason verbatim which can exceed the documented excerpt bound; update it
to strip and then truncate the reason to the canonical excerpt length (use the
existing excerpt bound constant or helper, e.g., max_detail_excerpt or an
excerpt helper used elsewhere) and return None if the stripped result is empty,
otherwise return the truncated excerpt so Local_state_unsafe always respects the
documented bound.

In `@test/test_push_plan_integration.ml`:
- Around line 58-59: The git_capture helper currently ignores the subprocess
exit status; update git_capture to capture the result of Unix.close_process_in
ic into a value (e.g., status), check that it equals Unix.WEXITED 0, and if not
raise a test failure (failwith or OUnit assert) including the non-zero status
and the Buffer.contents buf to aid debugging; reference the git_capture
function, the call to Unix.close_process_in, and Buffer.contents buf when making
the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c853aa00-a626-418f-a0b8-d873de787b7c

📥 Commits

Reviewing files that changed from the base of the PR and between b9f08ce and db790fe.

📒 Files selected for processing (17)
  • lib/worktree.ml
  • lib/worktree.mli
  • lib/worktree_setup.ml
  • lib_core/push_plan.ml
  • lib_core/push_plan.mli
  • lib_core/push_reject_classify.ml
  • lib_core/push_reject_classify.mli
  • lib_core/start_point_plan.ml
  • lib_core/start_point_plan.mli
  • lib_core/worktree_parser.ml
  • test/dune
  • test/test_dependency_injection_functors.ml
  • test/test_push_plan_integration.ml
  • test/test_push_plan_properties.ml
  • test/test_push_reject_classify_properties.ml
  • test/test_start_point_plan_properties.ml
  • test/test_worktree_start_point_integration.ml

Comment thread lib_core/push_reject_classify.ml Outdated
Comment thread test/test_push_plan_integration.ml Outdated
Copy link
Copy Markdown

@flowglad-review-service flowglad-review-service Bot left a comment

Choose a reason for hiding this comment

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

Code Review: 1 comment

Comment thread test/test_push_plan_integration.ml
@flowglad-review-service
Copy link
Copy Markdown

Review Summary

One must-fix finding in the new integration test. The scenario_local_missing_remote scenario is misnamed and actually exercises Local_diverged_from_remote (the test's own assertion on line 177 confirms this with \"refuse_local_diverged\"). The true Local_missing_remote / refuse_local_behind planner arm is never covered, leaving a correctness regression in that path undetectable by the integration suite. Fix: drop the post-reset "trivial" commit so local stays a strict ancestor of remote. The three previously-filed outstanding comments are not re-raised.

Severity Count
Must-fix 1
Should-fix 0
Note 0

Variant: convergence-v2

Candidates: 1 | Posted: 1 | Suppressed: 0


1 comment posted · Model: claude-sonnet-4-6 (sonnet) · Tokens: 41314 in / 3472 out · Cache: 172432 created / 723741 read · Review mode: agentic · Turns: 15 · Tool calls: 18 · Forced submit: yes · Tool mix: read_file=12, search_codebase=6

Copy link
Copy Markdown

@flowglad-review-service flowglad-review-service Bot left a comment

Choose a reason for hiding this comment

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

Code Review: 1 comment

Comment thread lib/worktree_setup.ml
@flowglad-review-service
Copy link
Copy Markdown

Review Summary

One must-fix: the Refused arm in worktree_setup.ml writes Session_push_failed (Some Local_state_unsafe) to the orchestrator and then returns None, but all callers unconditionally overwrite that state with Session_worktree_missing when they receive None. This silently converts a permanent refusal into a reconstruction loop, bypassing the needs_intervention escalation path that is_permanent would have triggered.

Severity Count
Must-fix 1
Should-fix 0
Note 0

Variant: convergence-v2

Candidates: 1 | Posted: 1 | Suppressed: 0


1 comment posted · Model: claude-sonnet-4-6 (sonnet) · Tokens: 41304 in / 3395 out · Cache: 168657 created / 738041 read · Review mode: agentic · Turns: 15 · Tool calls: 16 · Forced submit: yes · Tool mix: read_file=12, search_codebase=4

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
test/test_push_plan_properties.ml (1)

385-399: ⚡ Quick win

PPP-10 can pass even if required refusal mappings regress to None.

Because None -> true, this property won’t fail if Branch_ref_missing, Branch_switched, Local_missing_remote_commits, or Local_diverged_from_remote_commits stop mapping to Local_state_unsafe.

✅ Stronger property shape
-      List.for_all refusals ~f:(fun r ->
-          match PP.to_push_reject_classify_rejection r with
-          | None -> true
-          | Some rej -> Push_reject_classify.is_permanent rej))
+      List.for_all refusals ~f:(fun r ->
+          match r, PP.to_push_reject_classify_rejection r with
+          | (PP.No_commits_ahead_of_base | PP.Worktree_missing), None -> true
+          | ( PP.Branch_ref_missing _ | PP.Branch_switched _
+            | PP.Local_missing_remote_commits _
+            | PP.Local_diverged_from_remote_commits _ ),
+            Some rej ->
+              Push_reject_classify.is_permanent rej
+          | _ -> false))
🤖 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 `@test/test_push_plan_properties.ml` around lines 385 - 399, The property
currently treats a None result from PP.to_push_reject_classify_rejection as
success, letting regressions slip through; update the test in
test_push_plan_properties.ml so that for each refusal in refusals you assert
PP.to_push_reject_classify_rejection r returns Some rej and that
Push_reject_classify.is_permanent rej is true (i.e., replace the permissive None
-> true logic with a check that rejects are present and permanent), referencing
PP.to_push_reject_classify_rejection and Push_reject_classify.is_permanent to
locate the code to change.
lib_core/push_plan.ml (1)

73-93: ⚡ Quick win

Derive Local_state_unsafe.reason from short_label to prevent label drift.

to_push_reject_classify_rejection duplicates refusal labels as string literals. If short_label changes later, these can silently diverge from the documented contract.

♻️ Proposed refactor
+let refusal_reason (r : refusal) = short_label (Refuse r)
+
 let to_push_reject_classify_rejection (r : refusal) :
     Push_reject_classify.rejection option =
   match r with
   | No_commits_ahead_of_base | Worktree_missing -> None
-  | Branch_ref_missing _ ->
-      Some
-        (Push_reject_classify.Local_state_unsafe
-           { reason = "refuse_ref_missing" })
-  | Branch_switched _ ->
-      Some
-        (Push_reject_classify.Local_state_unsafe
-           { reason = "refuse_branch_switched" })
-  | Local_missing_remote_commits _ ->
-      Some
-        (Push_reject_classify.Local_state_unsafe
-           { reason = "refuse_local_behind" })
-  | Local_diverged_from_remote_commits _ ->
-      Some
-        (Push_reject_classify.Local_state_unsafe
-           { reason = "refuse_local_diverged" })
+  | ( Branch_ref_missing _ | Branch_switched _ | Local_missing_remote_commits _
+    | Local_diverged_from_remote_commits _ ) ->
+      Some
+        (Push_reject_classify.Local_state_unsafe
+           { reason = refusal_reason r })
🤖 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 `@lib_core/push_plan.ml` around lines 73 - 93, The function
to_push_reject_classify_rejection emits hard-coded reason strings for several
refusal variants; change it to derive Local_state_unsafe.{ reason = ... } from
the canonical short_label for the given refusal instead of string literals.
Concretely, in to_push_reject_classify_rejection (matching on the refusal type
'r'), replace occurrences like { reason = "refuse_ref_missing" } /
"refuse_branch_switched" / "refuse_local_behind" / "refuse_local_diverged" with
{ reason = short_label r } (or call whatever existing
short_label/refusal_short_label function exists) so the reason always follows
the refusal's short_label; keep the same match arms and return type
Push_reject_classify.rejection option.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@lib_core/push_plan.mli`:
- Around line 28-32: Update the top-level documentation list of refusals that
map to the planner-only state by adding Branch_ref_missing to the existing
entries (Branch_switched, Local_missing_remote_commits,
Local_diverged_from_remote_commits) so the text and the Local_state_unsafe
mapping match to_push_reject_classify_rejection and the later API docs; locate
the paragraph mentioning Local_state_unsafe in push_plan.mli and include the
symbol Branch_ref_missing in that list wording.

In `@lib/worktree_setup.ml`:
- Around line 52-57: When detecting a repo-root checkout conflict (the
W.is_checked_out_in_repo_root br branch) do not just log and return None; treat
it as the same permanent local-state refusal as W.create refusals by returning a
Push_reject_classify.Local_state_unsafe with the same reason constructed by
start_point_refusal_rejection (so the Session_push_failed path is taken). Apply
the same change to the other similar branch around the 154-165 area so both
repo-root checks produce the same Push_reject_classify.Local_state_unsafe
outcome instead of being treated as missing worktree/retriable.

---

Nitpick comments:
In `@lib_core/push_plan.ml`:
- Around line 73-93: The function to_push_reject_classify_rejection emits
hard-coded reason strings for several refusal variants; change it to derive
Local_state_unsafe.{ reason = ... } from the canonical short_label for the given
refusal instead of string literals. Concretely, in
to_push_reject_classify_rejection (matching on the refusal type 'r'), replace
occurrences like { reason = "refuse_ref_missing" } / "refuse_branch_switched" /
"refuse_local_behind" / "refuse_local_diverged" with { reason = short_label r }
(or call whatever existing short_label/refusal_short_label function exists) so
the reason always follows the refusal's short_label; keep the same match arms
and return type Push_reject_classify.rejection option.

In `@test/test_push_plan_properties.ml`:
- Around line 385-399: The property currently treats a None result from
PP.to_push_reject_classify_rejection as success, letting regressions slip
through; update the test in test_push_plan_properties.ml so that for each
refusal in refusals you assert PP.to_push_reject_classify_rejection r returns
Some rej and that Push_reject_classify.is_permanent rej is true (i.e., replace
the permissive None -> true logic with a check that rejects are present and
permanent), referencing PP.to_push_reject_classify_rejection and
Push_reject_classify.is_permanent to locate the code to change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 92babcb1-2f9d-48a2-beba-74cced4905ec

📥 Commits

Reviewing files that changed from the base of the PR and between db790fe and 07814dc.

📒 Files selected for processing (8)
  • lib/worktree.ml
  • lib/worktree_setup.ml
  • lib_core/push_plan.ml
  • lib_core/push_plan.mli
  • lib_core/push_reject_classify.ml
  • test/test_push_plan_integration.ml
  • test/test_push_plan_properties.ml
  • test/test_push_reject_classify_properties.ml

Comment thread lib_core/push_plan.mli Outdated
Comment thread lib/worktree_setup.ml
subsetpark and others added 5 commits May 24, 2026 14:22
--force-with-lease alone only verifies that the remote tracking ref
matches actual remote — once a background fetch refreshes that tracking
ref, the lease passes even when local is strictly behind, silently
wiping remote commits. PR #315 was force-pushed empty and auto-closed
this way.

--force-if-includes additionally requires that the remote tip is in the
local branch's reflog, which refuses pushes that would lose commits the
local clone never observed. Requires git ≥ 2.30 (already a prerequisite
for the worktree features onton relies on).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
[Worktree.create] used to take the local branch ref as authoritative
when it existed in the user's working clone. The user's
[refs/heads/<branch>] could be stale (left at the PR's base, behind the
real PR head), so the worktree would start missing every PR commit —
exactly the state that wiped PR #315 when the session pushed it.

Split the decision into a pure module [Start_point_plan] in lib_core/
that takes observed local/remote-ref SHAs and a precomputed two-way
ancestry, returns one of [Reset_and_use_remote_tracking],
[Use_local_branch_unchanged], [Create_new_branch_from_base], or a
[refusal] for the diverged/local-ahead cases. The effectful side in
[Worktree.create] gathers the inputs, calls the planner, executes the
chosen action via [git worktree add -B/-b]/[add], and returns
[(t, refusal) Result.t]. [Worktree_setup.ensure_worktree] runs a new
[fetch_origin_branch] before [create] so the planner observes a fresh
remote ref.

Property tests (SPP-1..9) cover totality, determinism, the
"no-silent-clobber" invariant, remote authority, label bounds, and
exhaustive variant reachability. An integration test exercises every
arm against real git fixtures, including a direct reproduction of the
#315 stale-local-ref state.

Refusals are logged through the existing activity-log channel and
return [None] from [ensure_worktree], so the next scheduling tick sees
the agent idle. Future commits will route refusals through
[needs_intervention] explicitly via [Push_reject_classify].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
[Worktree.force_push_with_lease] used to gate on
[git rev-list --count <base>..HEAD] (worktree HEAD) and then push the
named branch — so an agent that [git switch]ed to a different local
branch mid-session could pass the gate with commits the push command
would never upload. Combined with --force-with-lease's blind spot for
local commit loss (the lease passes whenever the local tracking ref
matches actual remote, even if local strictly lacks remote commits),
this put PR #315 in the position where a push of a stale branch wiped
its remote.

New pure module [Push_plan] in lib_core/ takes the observed worktree
HEAD, branch ref SHA, remote tracking SHA, two-way ancestry, and
[base..refs/heads/<branch>] commit count, and returns
[Push (Force_push_if_includes | Initial_push)] or
[Refuse (No_commits_ahead_of_base | Worktree_missing |
Branch_ref_missing | Branch_switched | Local_missing_remote_commits)].
The named branch — never HEAD — is what gets measured and pushed.

[Push_reject_classify.rejection] gains a [Local_state_unsafe] variant
(permanent), and [Push_plan.to_push_reject_classify_rejection] maps the
three "supervisor said no" refusals onto it so the orchestrator's
existing [is_permanent] → [needs_intervention] escalation applies
without new state.

Property tests (PPP-1..11) cover totality, branch-switch refusal,
local-missing-remote refusal, zero-commits skip, initial-push,
happy-path, worktree-missing pre-emption, determinism, exhaustive
variant reachability, refusal-to-rejection permanence, and label
bounds. PRC tests extend their existing variant scans for
Local_state_unsafe. An integration test drives real-git fixtures for
the branch-switched and local-missing-remote refusal arms plus the
happy-path force-push, asserting nothing reaches the remote when the
planner refuses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@flowglad-review-service
Copy link
Copy Markdown

Review Summary

Round 4 delta review — no new issues found in this push.

The changes in this push are clean. Key observations:

  • worktree_setup.ml Refused callers: runner_fiber_impl.ml:675 and session_driver.ml:175 correctly return \Failedwithout re-callingapply_session_resultensure_worktreealready appliedSession_push_failed(withLocal_state_unsafe) before returning Refused`. No double-apply occurs.

  • PPP-2b added: prop_matching_head_branch_not_switched is newly present in this push, directly providing the complementary property the Turn 1 outstanding comment requested.

  • gather_push_plan_inputs two-probe ancestry: the Local_missing_remote vs Local_diverged_from_remote distinction now uses two --is-ancestor probes, correctly handling the case the Turn 1 worktree.ml:714 comment flagged.

  • Unknown ancestry + remote present → Force_push_if_includes: deliberate conservative-allow, protected by --force-if-includes; correct.

  • gen_ancestry with oneof_array: QCheck2.Gen.oneof_array takes 'a array not 'a Gen.t array, so bare variant values are the correct usage.

Four outstanding comments from prior rounds remain live on the PR thread but were not re-introduced by this push.

Severity Count
Must-fix 0
Should-fix 0
Note 0

Variant: convergence-v2

Candidates: 0 | Posted: 0 | Suppressed: 0


0 comments posted · Model: claude-sonnet-4-6 (sonnet) · Tokens: 45181 in / 2837 out · Cache: 191820 created / 746396 read · Review mode: agentic · Turns: 15 · Tool calls: 15 · Forced submit: yes · Tool mix: read_file=13, search_codebase=2

@subsetpark subsetpark merged commit 27d5757 into main May 24, 2026
8 checks passed
@subsetpark subsetpark deleted the worktree-start-point-planner branch May 24, 2026 18:33
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