From 9aa66c85b589ba67bf51fe661cb55b3b7edb78cf Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 06:54:48 -0400 Subject: [PATCH 01/17] docs(plan): change 0523 implementation plan Docket-Plan-Path: docs/superpowers/plans/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg.md --- ...rts-a-behind-only-docket-copy-as-diverg.md | 2417 +++++++++++++++++ 1 file changed, 2417 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg.md diff --git a/docs/superpowers/plans/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg.md b/docs/superpowers/plans/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg.md new file mode 100644 index 000000000..04d51ad74 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg.md @@ -0,0 +1,2417 @@ + +> ↩ **[Change 0523 — Treat a behind-only .docket copy as healthy and make prepare fast-forward it in place](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0523-repository-check-reports-a-behind-only-docket-copy-as-diverg.md)** + +# Behind-Only .docket Is Healthy, and Prepare Fast-Forwards In Place — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. In this repository the plan is executed by `docket-build`. + +**Goal:** `docket repository check` treats a clean, behind-only local `.docket` copy as healthy and keys every local-copy finding on the real ancestry relationship; `docket repository prepare` fast-forwards `.docket` in place (nothing removed, compare-and-swap, no hooks, refuse rather than discard) and its attach path checks freshness; the primary-checkout finding is split by relationship; the Bash upgrade guide drops its extra `prepare` step. + +**Architecture:** One pure relationship type (`reposetup.SyncRelation`) carried in `reposetup.Facts`, computed by ONE app function (`syncRelationship`, moved out of `repository_prepare.go`) that both the check augmentation and the prepare augmentation call. The classifier and health findings key on that fact, never on tip equality. Two new `gitcli` primitives — `FastForwardCheckedOutBranch` (update-ref CAS, then `read-tree -u -m`, rolled back on refusal, hooks forced off) and `AdvanceBranchChecked` (update-ref CAS on an unattached branch) — replace prepare's remove/delete/re-add sequence and give prepare's attach path a fresh-attach action. The ancestry probe ignores replace refs and grafts. + +**Tech Stack:** Go (`internal/reposetup`, `internal/app`, `internal/gitcli`, `internal/bashupgrade`), real-git integration tests behind the `integration` build tag, POSIX shard runners under `tests/`. + +**Spec:** `docs/superpowers/specs/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg-design.md` (on the `docket` branch; read it from `/Users/homer/dev/docket/.docket/docs/superpowers/specs/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg-design.md`). + +## Global Constraints + +- A clean, behind-only local `.docket` copy is **healthy**: `check` exits 0 with **no finding** for it (no warning — a healthy state with any finding exits 1 via `CheckExit`). +- Fix `check`, not the writers: typed operations keep pushing from detached worktrees; nothing here makes them touch `.docket`. +- **One relationship computation.** `check`, `configure-tests`, and `prepare` share one function. It may be renamed and moved; it must never be copied. +- The primary checkout's health condition `CondPrimaryAtRemoteTip` is unchanged (HEAD must equal the tip); only the finding is split by relationship. +- `prepare` fast-forward properties (spec §3): nothing removed; CAS on the router's observed tip; no repository hooks run (forced empty hooks dir per command); refuse rather than discard; interruption never destroys state; idempotent. +- `ensureMetadataWorktree`'s contract for `init` and `migrate` does not change. `prepare` gets its own fresh-attach action. +- Do not add ancestry probes to `gatherRepoFacts` / `primaryAtTipPresence` (the shared base gather every operational command runs). Capturing the already-read primary HEAD there is allowed; computing a relationship there is not. +- Out of scope, unchanged: `init`'s refusal text, `repair`'s output, `migrate`/`init` attach behavior, self-healing a missing hooks-off config, `transaction.Engine.candidateReachable`, other `check` findings. +- Every printed remedy must be valid in the exact state that produced it and never destructive (learning `printed-remedy-state-validity`; `assertNoDestructiveCommand` in `internal/reposetup/health_test.go`). +- Docs describe current behavior only: no change or PR numbers in `docs/release/upgrading-from-bash.md`. +- Cross-references in maintained source anchor on symbol names or quoted clauses, never line numbers (AGENTS.md, `TestCommentAnchorStyle`). +- Every mutation probe and re-verification run uses `go test -count=1` (learning `cached-runner-serves-a-mutated-tree`), and every mutation is restored from a backup copy, never `git checkout --` (learning `mutation-restore-needs-a-backup-copy`). +- Integration tests run only through their shard prefixes: `go test -tags integration -count=1 -run '^' `. Never run `./internal/app/` integration tests without `-run`. + +## Review Focus + +1. **An ignored file sitting at a path the target newly tracks.** A person expects nothing lost. `read-tree -u -m` without `--exclude-per-directory` refuses to overwrite it, so the fast-forward refuses and rolls the branch back. (This is stricter than plain checkout's "ignored files are expendable" rule and satisfies "refuse rather than discard".) Pinned in Task 2, subtest "ignored file at a newly tracked path refuses and is kept". +2. **Stale index stat info** (a tracked file touched but unchanged, e.g. by an editor or `touch`). A person expects the fast-forward to still work. Pinned in Task 2, subtest "stale stat info on a changed path still fast-forwards" (the `update-index --refresh` step is load-bearing). +3. **An interrupted fast-forward** (branch moved, tree not yet updated). A person expects `check` to say dirty and `prepare` to refuse, never to report healthy and never to need `git worktree prune`. Pinned in Task 7, `TestIntegrationRepoInPlaceFFInterruptedReadsDirty`. +4. **A replace ref or graft that fakes ancestry.** A person expects local-only commits never to be fast-forwarded over. Pinned in Task 1 (both replace refs and the graft file). +5. **`.docket` on a detached HEAD or a different branch at execution time.** A person expects the primitive to refuse, never to move some other ref. Pinned in Task 2, subtest "HEAD not on the branch refuses". + +## File Structure + +- `internal/gitcli/push.go` (modify): `IsAncestor` delegates to a new shared `isAncestorIn`; new `IsAncestorIgnoringReplacements`. +- `internal/gitcli/exec.go` (modify): `runRequest.env` doc comment admits the graft-disabling override. +- `internal/gitcli/hooksoff.go` (modify): extract `emptyHooksDir` from `DisableWorktreeHooks`. +- `internal/gitcli/fastforward_inplace.go` (create): `FastForwardCheckedOutBranch`. +- `internal/gitcli/refadvance.go` (create): `AdvanceBranchChecked`. +- `internal/gitcli/ancestry_integration_test.go`, `fastforward_inplace_integration_test.go`, `refadvance_integration_test.go` (create): real-git tests, prefix `TestIntegrationRepo` (existing `tests/test_go_integration_gitcli_repo.sh` shard). +- `internal/reposetup/probe.go` (modify): `SyncRelation`, `Facts.LocalMetadataSync`, `Facts.PrimaryTipRelation`, `WorktreeFact.UnfinishedOperation`, `Synchronized` comment. +- `internal/reposetup/classify.go`, `health.go`, `healthconditions.go` (modify): relationship-keyed reasons and findings. +- `internal/reposetup/classify_test.go`, `health_test.go` (modify). +- `internal/app/repository_sync_relation.go` (create): `syncRelationship`, `applyLocalMetadataSync`. +- `internal/app/repository_check.go` (modify): `augmentCheckFacts`, `worktreeCleanState`; delete `synchronizedPresence`. +- `internal/app/repository_prepare.go` (modify): router on `f.LocalMetadataSync`, `observedTip`, in-place fast-forward, fresh attach; delete `prepareSync`, `prepareSyncRelationship`, `prepareFastForwardWorktree`. +- `internal/app/repository_facts.go` (modify): `setupContext.primaryHead`. +- `internal/app/repository_prepare_test.go`, `repository_check_test.go` (modify). +- `internal/app/reposynccheck_integration_test.go` (create): prefix `TestIntegrationRepoSyncCheck`. +- `internal/app/repoinplaceff_integration_test.go` (create): prefix `TestIntegrationRepoInPlaceFF`. +- `internal/app/repoprepare_integration_test.go` (modify): header comment only. +- `tests/test_go_integration_app_reposynccheck.sh`, `tests/test_go_integration_app_repoinplaceff.sh` (create) + rows in `tests/runtime-budgets.tsv`. +- `docs/release/upgrading-from-bash.md`, `internal/bashupgrade/registry_test.go` (modify). + +Task ordering note (learning `intermediate-task-state-buildable`): Task 4 changes the pure classifier only. Between Task 4 and Task 5 the `internal/bashupgrade` guide test (`tests/test_go_integration_bashupgrade.sh`) is expected red, because it still asserts the old false-conflict codes; Task 5 owns the guide and its test. Every other package stays green at every task boundary. + +--- + +### Task 1: Ancestry probe that ignores replace refs and grafts + +**Files:** +- Modify: `internal/gitcli/push.go` (`IsAncestor`) +- Modify: `internal/gitcli/exec.go` (`runRequest.env` doc comment) +- Create: `internal/gitcli/ancestry_integration_test.go` + +**Interfaces:** +- Produces: `func (c *Client) IsAncestorIgnoringReplacements(ctx context.Context, repo Repository, ancestor, descendant ObjectID) (bool, error)` and the unexported `func (c *Client) isAncestorIn(ctx context.Context, dir string, ancestor, descendant ObjectID, ignoreReplacements bool) (bool, error)` that Tasks 2 and 3 call with a worktree dir. + +- [ ] **Step 1: Write the failing test** + +Create `internal/gitcli/ancestry_integration_test.go`: + +```go +//go:build integration + +package gitcli + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestIntegrationRepoIsAncestorIgnoringReplacements proves the relationship probe +// ignores both history-rewrite mechanisms: a replace ref and the legacy graft file +// each make an unrelated orphan commit look like a descendant to plain IsAncestor, +// and IsAncestorIgnoringReplacements must still answer false. +func TestIntegrationRepoIsAncestorIgnoringReplacements(t *testing.T) { + ctx := context.Background() + newOrphan := func(t *testing.T, r *testRepos) (base, orphan ObjectID) { + t.Helper() + base = ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + tree := gitOut(t, r.Invocation, "rev-parse", "HEAD^{tree}") + orphan = ObjectID(strings.TrimSpace(gitOut(t, r.Invocation, + "-c", "user.name=t", "-c", "user.email=t@example.com", + "commit-tree", tree, "-m", "orphan"))) + return base, orphan + } + + t.Run("replace ref", func(t *testing.T) { + r := newMainModeRepos(t) + c := newRealClient(t) + base, orphan := newOrphan(t, r) + gitOut(t, r.Invocation, "replace", "--graft", string(orphan), string(base)) + repo := Repository{PrimaryWorktree: r.Invocation} + + plain, err := c.IsAncestor(ctx, repo, base, orphan) + if err != nil || !plain { + t.Fatalf("premise: plain IsAncestor under a replace graft = %v, %v; want true, nil", plain, err) + } + got, err := c.IsAncestorIgnoringReplacements(ctx, repo, base, orphan) + if err != nil || got { + t.Fatalf("IsAncestorIgnoringReplacements = %v, %v; want false, nil (replace refs ignored)", got, err) + } + }) + + t.Run("graft file", func(t *testing.T) { + r := newMainModeRepos(t) + c := newRealClient(t) + base, orphan := newOrphan(t, r) + grafts := filepath.Join(r.Invocation, ".git", "info", "grafts") + if err := os.MkdirAll(filepath.Dir(grafts), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(grafts, []byte(string(orphan)+" "+string(base)+"\n"), 0o644); err != nil { + t.Fatal(err) + } + repo := Repository{PrimaryWorktree: r.Invocation} + + plain, err := c.IsAncestor(ctx, repo, base, orphan) + if err != nil || !plain { + t.Fatalf("premise: plain IsAncestor under a graft file = %v, %v; want true, nil", plain, err) + } + got, err := c.IsAncestorIgnoringReplacements(ctx, repo, base, orphan) + if err != nil || got { + t.Fatalf("IsAncestorIgnoringReplacements = %v, %v; want false, nil (graft file ignored)", got, err) + } + }) + + t.Run("real ancestry still true", func(t *testing.T) { + r := newMainModeRepos(t) + c := newRealClient(t) + base := ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + writeWorktreeFile(t, r.Invocation, "next.txt", "next\n") + gitOut(t, r.Invocation, "add", "--", "next.txt") + gitOut(t, r.Invocation, "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "next") + next := ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + got, err := c.IsAncestorIgnoringReplacements(ctx, Repository{PrimaryWorktree: r.Invocation}, base, next) + if err != nil || !got { + t.Fatalf("real ancestry = %v, %v; want true, nil", got, err) + } + }) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationRepoIsAncestorIgnoringReplacements$' ./internal/gitcli/` +Expected: FAIL to compile — `c.IsAncestorIgnoringReplacements undefined`. + +- [ ] **Step 3: Implement** + +In `internal/gitcli/push.go`, replace the body of `IsAncestor` and add the two functions below it: + +```go +func (c *Client) IsAncestor(ctx context.Context, repo Repository, ancestor, descendant ObjectID) (bool, error) { + return c.isAncestorIn(ctx, repo.PrimaryWorktree, ancestor, descendant, false) +} + +// IsAncestorIgnoringReplacements is IsAncestor with Git's history rewrites switched +// off: replace refs are ignored (`--no-replace-objects`) and so is the legacy graft +// file (GIT_GRAFT_FILE set to the empty string, which Git opens as no file). A +// relationship probe that decides whether a fast-forward is safe uses it, so a +// replace ref or graft can never fake "behind" and strand local-only commits. +// Other callers keep IsAncestor's behavior. +func (c *Client) IsAncestorIgnoringReplacements(ctx context.Context, repo Repository, ancestor, descendant ObjectID) (bool, error) { + return c.isAncestorIn(ctx, repo.PrimaryWorktree, ancestor, descendant, true) +} + +// isAncestorIn runs `git merge-base --is-ancestor` in dir: exit 0 is true, exit 1 +// is false, and any other exit is a typed command-failed *Failure. +func (c *Client) isAncestorIn(ctx context.Context, dir string, ancestor, descendant ObjectID, ignoreReplacements bool) (bool, error) { + if err := validateObjectID(ancestor); err != nil { + return false, newFailure(isAncestorOp, KindInvalidRequest, "invalid ancestor id", err) + } + if err := validateObjectID(descendant); err != nil { + return false, newFailure(isAncestorOp, KindInvalidRequest, "invalid descendant id", err) + } + args := []string{"merge-base", "--is-ancestor", string(ancestor), string(descendant)} + var env []string + if ignoreReplacements { + args = append([]string{"--no-replace-objects"}, args...) + env = []string{"GIT_GRAFT_FILE="} + } + res, f := c.run(ctx, runRequest{op: isAncestorOp, dir: dir, args: args, env: env}) + if f != nil { + return false, f + } + switch res.exitCode { + case 0: + return true, nil + case 1: + return false, nil + default: + return false, newFailure(isAncestorOp, KindCommandFailed, "merge-base --is-ancestor failed: "+stderrExcerpt(res.stderr), nil).withExitCode(res.exitCode) + } +} +``` + +In `internal/gitcli/exec.go`, extend the `env` field comment on `runRequest`: after "used for the engine-clock commit dates (GIT_AUTHOR_DATE / GIT_COMMITTER_DATE)" add "and for the ancestry probe's graft-disabling GIT_GRAFT_FILE= (IsAncestorIgnoringReplacements)". Keep the "never carries repository redirection, config injection, or credentials" clause — an empty graft file disables a rewrite, it redirects nothing. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationRepoIsAncestorIgnoringReplacements$' ./internal/gitcli/ && go test -count=1 ./internal/gitcli/` +Expected: PASS. + +- [ ] **Step 5: Mutation-check the key** + +Copy `internal/gitcli/push.go` to `${TMPDIR:-/tmp}/push.go.bak`. Delete the `env = []string{"GIT_GRAFT_FILE="}` line; re-run the Step 4 integration command: the `graft file` subtest must FAIL. Restore with `cp "${TMPDIR:-/tmp}/push.go.bak" internal/gitcli/push.go`. Repeat removing only the `--no-replace-objects` prepend: the `replace ref` subtest must FAIL. Restore and confirm `git diff --stat internal/gitcli/push.go` shows only your Step 3 change. + +- [ ] **Step 6: Commit** + +```bash +git add internal/gitcli/push.go internal/gitcli/exec.go internal/gitcli/ancestry_integration_test.go +git commit -m "feat(gitcli): ancestry probe that ignores replace refs and grafts" +``` + +--- + +### Task 2: In-place fast-forward of a checked-out branch + +**Files:** +- Modify: `internal/gitcli/hooksoff.go` (extract `emptyHooksDir` from `DisableWorktreeHooks`) +- Create: `internal/gitcli/fastforward_inplace.go` +- Create: `internal/gitcli/fastforward_inplace_integration_test.go` + +**Interfaces:** +- Consumes: `isAncestorIn` (Task 1). +- Produces: + - `func (c *Client) emptyHooksDir(ctx context.Context, op Operation, worktreeDir string) (string, *Failure)` — resolves and creates `/docket/empty-hooks`, returns its absolute path. + - `func (c *Client) FastForwardCheckedOutBranch(ctx context.Context, worktreeDir string, branch RefName, expectedTip, target ObjectID) error` — Task 7 calls it with the `.docket` path, `refs/heads/docket`, the router's observed tip, and the pinned remote tip. + +Mechanism (decide it here, not later): (1) refuse unless `symbolic-ref HEAD` in the worktree is exactly `branch`; (2) refuse unless `target` descends from `expectedTip` (replace refs and grafts ignored); (3) compare-and-swap the branch ref `expectedTip → target` with `update-ref ` — if the branch moved, nothing changes; (4) `update-index -q --refresh` (exit 0 or 1 accepted); (5) `read-tree -u -m `, which updates index and files from the old tree to the new one and refuses on local changes or untracked/ignored files in the way; (6) on any read-tree refusal, compare-and-swap the branch back `target → expectedTip`. Every command carries `-c core.hooksPath=`, which also silences `reference-transaction` and `post-index-change`. An interruption between (3) and (5) leaves HEAD at target with the old index: `git status` reports staged changes, so it reads dirty — never clean, never a missing worktree. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/gitcli/fastforward_inplace_integration_test.go`: + +```go +//go:build integration + +package gitcli + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/danielhanold/docket/internal/testsupport" +) + +const ffIdent = "user.email=t@example.com" + +// newCheckedOutBranchFixture builds an invocation clone whose branch "meta" sits at +// base and is checked out in a linked worktree wt, plus one later commit on main +// (target) that descends from base, rewrites README.md, and adds target.txt. +func newCheckedOutBranchFixture(t *testing.T) (r *testRepos, wt string, base, target ObjectID) { + t.Helper() + r = newMainModeRepos(t) + base = ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + wt = filepath.Join(testsupport.TempDir(t), "meta-wt") + gitOut(t, r.Invocation, "worktree", "add", "-q", "-b", "meta", wt, string(base)) + writeWorktreeFile(t, r.Invocation, "README.md", "readme v2\n") + writeWorktreeFile(t, r.Invocation, "target.txt", "target\n") + gitOut(t, r.Invocation, "add", "--", "README.md", "target.txt") + gitOut(t, r.Invocation, "-c", "user.name=t", "-c", ffIdent, "commit", "-q", "-m", "target") + target = ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + return r, wt, base, target +} + +func metaTip(t *testing.T, r *testRepos) ObjectID { + t.Helper() + return ObjectID(gitOut(t, r.Invocation, "rev-parse", "refs/heads/meta")) +} + +// installHooks writes the named hooks into a fresh dir; each appends its own name to +// sentinel and exits with code. The dir is returned for core.hooksPath. +func installHooks(t *testing.T, sentinel string, code int, names ...string) string { + t.Helper() + dir := filepath.Join(testsupport.TempDir(t), "hooks") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, n := range names { + body := "#!/bin/sh\necho " + n + " >> '" + sentinel + "'\nexit " + strconv.Itoa(code) + "\n" + if err := os.WriteFile(filepath.Join(dir, n), []byte(body), 0o755); err != nil { + t.Fatal(err) + } + } + return dir +} + +func TestIntegrationRepoFastForwardCheckedOutBranch(t *testing.T) { + ctx := context.Background() + + t.Run("advances branch, index, and files in place", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + before, err := os.Stat(wt) + if err != nil { + t.Fatal(err) + } + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err != nil { + t.Fatalf("FastForwardCheckedOutBranch: %v", err) + } + if got := metaTip(t, r); got != target { + t.Fatalf("meta = %s, want %s", got, target) + } + if got := ObjectID(gitOut(t, wt, "rev-parse", "HEAD")); got != target { + t.Fatalf("worktree HEAD = %s, want %s", got, target) + } + if s := gitOut(t, wt, "status", "--porcelain", "--untracked-files=all"); s != "" { + t.Fatalf("worktree not clean after fast-forward:\n%s", s) + } + if b, _ := os.ReadFile(filepath.Join(wt, "target.txt")); string(b) != "target\n" { + t.Fatalf("target.txt = %q, want the target content", b) + } + after, err := os.Stat(wt) + if err != nil || !os.SameFile(before, after) { + t.Fatalf("worktree directory was replaced (err=%v)", err) + } + if log := gitOut(t, r.Invocation, "reflog", "show", "--format=%gs", "refs/heads/meta"); !strings.Contains(log, "docket: fast-forward") { + t.Fatalf("branch reflog lacks the fast-forward entry:\n%s", log) + } + }) + + t.Run("ignored file at an untouched path survives", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + appendExclude(t, r.Invocation, "*.local") + writeWorktreeFile(t, wt, "notes.local", "keep me\n") + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err != nil { + t.Fatalf("FastForwardCheckedOutBranch: %v", err) + } + if b, err := os.ReadFile(filepath.Join(wt, "notes.local")); err != nil || string(b) != "keep me\n" { + t.Fatalf("ignored file lost: %q, %v", b, err) + } + }) + + t.Run("ignored file at a newly tracked path refuses and is kept", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + appendExclude(t, r.Invocation, "target.txt") + writeWorktreeFile(t, wt, "target.txt", "mine\n") + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err == nil { + t.Fatal("fast-forward over an ignored file at a newly tracked path succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s after refusal, want rolled back to %s", got, base) + } + if b, _ := os.ReadFile(filepath.Join(wt, "target.txt")); string(b) != "mine\n" { + t.Fatalf("ignored file overwritten: %q", b) + } + }) + + t.Run("untracked file in the way refuses and rolls back", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + writeWorktreeFile(t, wt, "target.txt", "mine\n") + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err == nil { + t.Fatal("fast-forward over an untracked file succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s after refusal, want rolled back to %s", got, base) + } + if got := ObjectID(gitOut(t, wt, "rev-parse", "HEAD")); got != base { + t.Fatalf("worktree HEAD = %s after refusal, want %s", got, base) + } + if b, _ := os.ReadFile(filepath.Join(wt, "target.txt")); string(b) != "mine\n" { + t.Fatalf("untracked file changed: %q", b) + } + }) + + t.Run("stale stat info on a changed path still fast-forwards", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + future := time.Now().Add(2 * time.Hour) + if err := os.Chtimes(filepath.Join(wt, "README.md"), future, future); err != nil { + t.Fatal(err) + } + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err != nil { + t.Fatalf("FastForwardCheckedOutBranch with stale stat: %v", err) + } + if got := metaTip(t, r); got != target { + t.Fatalf("meta = %s, want %s", got, target) + } + }) + + t.Run("locked worktree fast-forwards and stays locked", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + gitOut(t, r.Invocation, "worktree", "lock", wt) + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err != nil { + t.Fatalf("FastForwardCheckedOutBranch on a locked worktree: %v", err) + } + if list := gitOut(t, r.Invocation, "worktree", "list", "--porcelain"); !strings.Contains(list, "\nlocked") { + t.Fatalf("lock lost:\n%s", list) + } + }) + + t.Run("repository hooks never run", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + sentinel := filepath.Join(testsupport.TempDir(t), "hook-ran") + hooks := installHooks(t, sentinel, 1, "reference-transaction", "post-index-change", "post-checkout", "post-merge") + gitOut(t, r.Invocation, "config", "core.hooksPath", hooks) + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err != nil { + t.Fatalf("FastForwardCheckedOutBranch with failing repo hooks: %v", err) + } + if b, err := os.ReadFile(sentinel); err == nil { + t.Fatalf("a repository hook ran: %q", b) + } + }) + + t.Run("stale expected tip refuses and changes nothing", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + writeWorktreeFile(t, wt, "local-only.txt", "local\n") + gitOut(t, wt, "add", "--", "local-only.txt") + gitOut(t, wt, "-c", "user.name=t", "-c", ffIdent, "commit", "-q", "-m", "local-only") + moved := metaTip(t, r) + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err == nil { + t.Fatal("fast-forward from a stale expected tip succeeded; want refusal") + } + if got := metaTip(t, r); got != moved { + t.Fatalf("meta = %s, want the local commit %s kept", got, moved) + } + if _, err := os.Stat(filepath.Join(wt, "local-only.txt")); err != nil { + t.Fatalf("local commit's file gone: %v", err) + } + }) + + t.Run("target not descending refuses", func(t *testing.T) { + r, wt, base, _ := newCheckedOutBranchFixture(t) + c := newRealClient(t) + tree := gitOut(t, r.Invocation, "rev-parse", "HEAD^{tree}") + orphan := ObjectID(gitOut(t, r.Invocation, "-c", "user.name=t", "-c", ffIdent, "commit-tree", tree, "-m", "orphan")) + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, orphan); err == nil { + t.Fatal("fast-forward to a non-descendant succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s, want untouched %s", got, base) + } + }) + + t.Run("HEAD not on the branch refuses", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + gitOut(t, wt, "checkout", "-q", "--detach") + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err == nil { + t.Fatal("fast-forward with a detached HEAD succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s, want untouched %s", got, base) + } + }) +} + +// appendExclude adds a pattern to the repository's shared info/exclude. +func appendExclude(t *testing.T, repoDir, pattern string) { + t.Helper() + p := filepath.Join(repoDir, ".git", "info", "exclude") + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if _, err := f.WriteString(pattern + "\n"); err != nil { + t.Fatal(err) + } +} +``` + +Add `"strconv"` to that file's imports (used by `installHooks`). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationRepoFastForwardCheckedOutBranch$' ./internal/gitcli/` +Expected: FAIL to compile — `c.FastForwardCheckedOutBranch undefined`. + +- [ ] **Step 3: Extract `emptyHooksDir`** + +In `internal/gitcli/hooksoff.go`, move the block of `DisableWorktreeHooks` from "Resolve the common git dir relative to the worktree" through `os.MkdirAll(empty, 0o755)` into: + +```go +// emptyHooksDir resolves the worktree's common git dir and returns docket's +// absolute, empty hooks directory under it (/docket/empty-hooks), creating +// it when absent. It is the one place that directory is named: DisableWorktreeHooks +// points a worktree's per-worktree core.hooksPath at it, and the in-place +// fast-forward primitives force it per command with `-c core.hooksPath=`. +func (c *Client) emptyHooksDir(ctx context.Context, op Operation, worktreeDir string) (string, *Failure) { + commonRes, f := c.run(ctx, runRequest{op: op, dir: worktreeDir, args: []string{"rev-parse", "--git-common-dir"}}) + if f != nil { + return "", f + } + if commonRes.exitCode != 0 { + return "", newFailure(op, KindCommandFailed, "cannot resolve git common dir: "+stderrExcerpt(commonRes.stderr), nil).withExitCode(commonRes.exitCode) + } + commonLines := stdoutLines(commonRes.stdout) + if len(commonLines) != 1 { + return "", newFailure(op, KindInvalidOutput, "unexpected git-common-dir output", nil) + } + common, err := resolveGitPath(commonLines[0], worktreeDir) + if err != nil { + return "", newFailure(op, KindInvalidOutput, "cannot canonicalize git common dir", err) + } + empty := filepath.Join(common, "docket", "empty-hooks") + if err := os.MkdirAll(empty, 0o755); err != nil { + return "", newFailure(op, KindCommandFailed, "cannot create empty hooks dir", err) + } + return empty, nil +} +``` + +and in `DisableWorktreeHooks` replace the moved block with: + +```go + empty, f := c.emptyHooksDir(ctx, disableHooksOp, worktreeDir) + if f != nil { + return f + } +``` + +Run `go test -count=1 ./internal/gitcli/ && go test -tags integration -count=1 -run '^TestIntegrationRepo' ./internal/gitcli/` — the existing hooks-off tests must stay green (pure refactor). + +- [ ] **Step 4: Implement `FastForwardCheckedOutBranch`** + +Create `internal/gitcli/fastforward_inplace.go`: + +```go +package gitcli + +import ( + "context" + "path/filepath" + "strings" +) + +const fastForwardCheckedOutOp Operation = "fast-forward-checked-out-branch" + +// fastForwardReflogMessage names the in-place fast-forward in the branch reflog. +const fastForwardReflogMessage = "docket: fast-forward" + +// FastForwardCheckedOutBranch advances branch — which must be the branch checked out +// at worktreeDir — from expectedTip to target IN PLACE. Nothing is removed: the +// worktree directory, its admin dir (registration, per-worktree config, lock), the +// branch, and its reflog all survive. The branch moves by compare-and-swap from +// expectedTip, so a commit made since the caller observed expectedTip makes it +// refuse with nothing changed. target must descend from expectedTip, judged with +// replace refs and grafts ignored. The tree moves with `read-tree -u -m`, which +// refuses rather than overwrite a local change or an untracked or ignored file in +// the way; on that refusal the branch is swapped back. Every command forces docket's +// empty hooks dir, so no repository hook runs even when the worktree's own hooks-off +// setting is missing. An interruption after the ref swap leaves HEAD at target with +// the old index, which `git status` reports as staged changes (dirty), never clean. +func (c *Client) FastForwardCheckedOutBranch(ctx context.Context, worktreeDir string, branch RefName, expectedTip, target ObjectID) error { + op := fastForwardCheckedOutOp + if !filepath.IsAbs(worktreeDir) { + return newFailure(op, KindInvalidRequest, "worktree path must be absolute", nil) + } + if err := validateRefName(branch); err != nil { + return newFailure(op, KindInvalidRequest, "invalid branch ref", err) + } + if _, ok := branchShortName(branch); !ok { + return newFailure(op, KindInvalidRequest, "branch must be fully qualified refs/heads/", nil) + } + if err := validateObjectID(expectedTip); err != nil { + return newFailure(op, KindInvalidRequest, "invalid expected tip id", err) + } + if err := validateObjectID(target); err != nil { + return newFailure(op, KindInvalidRequest, "invalid target id", err) + } + + sym, f := c.run(ctx, runRequest{op: op, dir: worktreeDir, args: []string{"symbolic-ref", "--quiet", "HEAD"}}) + if f != nil { + return f + } + if sym.exitCode != 0 || RefName(strings.TrimSpace(string(sym.stdout))) != branch { + return newFailure(op, KindInvalidRepository, "the worktree does not have the branch checked out", nil) + } + + descends, err := c.isAncestorIn(ctx, worktreeDir, expectedTip, target, true) + if err != nil { + return err + } + if !descends { + return newFailure(op, KindInvalidRepository, "the target does not descend from the expected tip", nil) + } + + hooks, f := c.emptyHooksDir(ctx, op, worktreeDir) + if f != nil { + return f + } + noHooks := "core.hooksPath=" + hooks + + cas, f := c.run(ctx, runRequest{op: op, dir: worktreeDir, + args: []string{"-c", noHooks, "update-ref", "-m", fastForwardReflogMessage, string(branch), string(target), string(expectedTip)}}) + if f != nil { + return f + } + if cas.exitCode != 0 { + return newFailure(op, KindInvalidRepository, "the branch is no longer at the expected tip; nothing changed: "+stderrExcerpt(cas.stderr), nil).withExitCode(cas.exitCode) + } + + refresh, f := c.run(ctx, runRequest{op: op, dir: worktreeDir, args: []string{"-c", noHooks, "update-index", "-q", "--refresh"}}) + if f == nil && refresh.exitCode > 1 { + f = newFailure(op, KindCommandFailed, "update-index --refresh failed: "+stderrExcerpt(refresh.stderr), nil).withExitCode(refresh.exitCode) + } + if f == nil { + rt, rf := c.run(ctx, runRequest{op: op, dir: worktreeDir, + args: []string{"-c", noHooks, "read-tree", "-u", "-m", string(expectedTip), string(target)}}) + switch { + case rf != nil: + f = rf + case rt.exitCode != 0: + f = newFailure(op, KindInvalidRepository, "the working tree update was refused: "+stderrExcerpt(rt.stderr), nil).withExitCode(rt.exitCode) + default: + return nil + } + } + + // The tree update did not complete: swap the branch back so nothing changed. + back, bf := c.run(ctx, runRequest{op: op, dir: worktreeDir, + args: []string{"-c", noHooks, "update-ref", "-m", fastForwardReflogMessage + " (rolled back)", string(branch), string(expectedTip), string(target)}}) + if bf != nil { + return newFailure(op, KindCommandFailed, f.Error()+"; rolling the branch back also failed: "+bf.Error(), nil) + } + if back.exitCode != 0 { + return newFailure(op, KindCommandFailed, f.Error()+"; rolling the branch back also failed: "+stderrExcerpt(back.stderr), nil).withExitCode(back.exitCode) + } + return f +} +``` + +If `*Failure` has no `Error()` method with that exact spelling, use the existing `stderrExcerpt`/message accessor the package already uses for composing failure text (read `internal/gitcli/types.go` for `Failure`'s fields). Keep the rule: a failed rollback reports BOTH the original refusal and the rollback failure. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationRepoFastForwardCheckedOutBranch$' ./internal/gitcli/ && go test -count=1 ./internal/gitcli/` +Expected: PASS. If the "ignored file at a newly tracked path" subtest shows Git overwriting the ignored file instead of refusing, STOP and report BLOCKED with the Git output: Review Focus line 1 depends on the refusal. + +- [ ] **Step 6: Mutation-check the keys** + +With a backup copy of `fastforward_inplace.go` in `${TMPDIR:-/tmp}`, apply each mutation, re-run the Step 5 integration command with `-count=1`, observe the named subtest go red, and restore from the backup: +1. Drop `string(expectedTip)` from the first `update-ref` (no CAS) → "stale expected tip refuses and changes nothing" red. +2. Drop both `"-c", noHooks,` pairs on `update-ref` and `read-tree` → "repository hooks never run" red. +3. Delete the `update-index --refresh` run (keep `f == nil`) → "stale stat info on a changed path still fast-forwards" red. +4. Replace the rollback with `return f` → "untracked file in the way refuses and rolls back" red. + +- [ ] **Step 7: Commit** + +```bash +git add internal/gitcli/hooksoff.go internal/gitcli/fastforward_inplace.go internal/gitcli/fastforward_inplace_integration_test.go +git commit -m "feat(gitcli): in-place compare-and-swap fast-forward of a checked-out branch" +``` + +--- + +### Task 3: Compare-and-swap advance of an unattached branch + +**Files:** +- Create: `internal/gitcli/refadvance.go` +- Create: `internal/gitcli/refadvance_integration_test.go` + +**Interfaces:** +- Consumes: `isAncestorIn` (Task 1), `emptyHooksDir` (Task 2), `ListWorktrees`. +- Produces: `func (c *Client) AdvanceBranchChecked(ctx context.Context, repo Repository, branch RefName, expectedTip, target ObjectID) error` — Task 8 calls it before attaching `.docket`. `expectedTip == target` is a CAS-verified no-op. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/gitcli/refadvance_integration_test.go`: + +```go +//go:build integration + +package gitcli + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// newUnattachedBranchFixture: branch "meta" at base (checked out nowhere) and a +// later commit on main (target) descending from base. +func newUnattachedBranchFixture(t *testing.T) (r *testRepos, base, target ObjectID) { + t.Helper() + r = newMainModeRepos(t) + base = ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + gitOut(t, r.Invocation, "branch", "meta", string(base)) + writeWorktreeFile(t, r.Invocation, "target.txt", "target\n") + gitOut(t, r.Invocation, "add", "--", "target.txt") + gitOut(t, r.Invocation, "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "target") + target = ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + return r, base, target +} + +func TestIntegrationRepoAdvanceBranchChecked(t *testing.T) { + ctx := context.Background() + + t.Run("advances an unattached branch", func(t *testing.T) { + r, base, target := newUnattachedBranchFixture(t) + c := newRealClient(t) + if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, target); err != nil { + t.Fatalf("AdvanceBranchChecked: %v", err) + } + if got := metaTip(t, r); got != target { + t.Fatalf("meta = %s, want %s", got, target) + } + }) + + t.Run("equal tips are a verified no-op", func(t *testing.T) { + r, base, _ := newUnattachedBranchFixture(t) + c := newRealClient(t) + if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, base); err != nil { + t.Fatalf("AdvanceBranchChecked equal tips: %v", err) + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s, want %s", got, base) + } + }) + + t.Run("stale expected tip refuses", func(t *testing.T) { + r, base, target := newUnattachedBranchFixture(t) + c := newRealClient(t) + if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", target, target); err == nil { + t.Fatal("advance from a stale expected tip succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s, want untouched %s", got, base) + } + }) + + t.Run("branch checked out in a worktree refuses", func(t *testing.T) { + r, base, target := newUnattachedBranchFixture(t) + c := newRealClient(t) + wt := filepath.Join(testsupport.TempDir(t), "holder") + gitOut(t, r.Invocation, "worktree", "add", "-q", wt, "meta") + if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, target); err == nil { + t.Fatal("advance of a checked-out branch succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s, want untouched %s", got, base) + } + }) + + t.Run("target not descending refuses", func(t *testing.T) { + r, base, _ := newUnattachedBranchFixture(t) + c := newRealClient(t) + tree := gitOut(t, r.Invocation, "rev-parse", "HEAD^{tree}") + orphan := ObjectID(gitOut(t, r.Invocation, "-c", "user.name=t", "-c", "user.email=t@example.com", "commit-tree", tree, "-m", "orphan")) + if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, orphan); err == nil { + t.Fatal("advance to a non-descendant succeeded; want refusal") + } + }) + + t.Run("repository hooks never run", func(t *testing.T) { + r, base, target := newUnattachedBranchFixture(t) + c := newRealClient(t) + sentinel := filepath.Join(testsupport.TempDir(t), "hook-ran") + hooks := installHooks(t, sentinel, 1, "reference-transaction") + gitOut(t, r.Invocation, "config", "core.hooksPath", hooks) + if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, target); err != nil { + t.Fatalf("AdvanceBranchChecked with a failing reference-transaction hook: %v", err) + } + if b, err := os.ReadFile(sentinel); err == nil { + t.Fatalf("a repository hook ran: %q", b) + } + }) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationRepoAdvanceBranchChecked$' ./internal/gitcli/` +Expected: FAIL to compile — `c.AdvanceBranchChecked undefined`. + +- [ ] **Step 3: Implement** + +Create `internal/gitcli/refadvance.go`: + +```go +package gitcli + +import "context" + +const advanceBranchOp Operation = "advance-branch" + +// AdvanceBranchChecked moves a branch that no worktree has checked out from +// expectedTip to target by compare-and-swap (`update-ref +// `), with repository hooks forced off. target must descend from +// expectedTip, judged with replace refs and grafts ignored. A branch some worktree +// holds is refused (moving it would strand that worktree's index), and a branch no +// longer at expectedTip is refused with nothing changed. expectedTip == target is a +// CAS-verified no-op. branch must be a fully qualified refs/heads/. +func (c *Client) AdvanceBranchChecked(ctx context.Context, repo Repository, branch RefName, expectedTip, target ObjectID) error { + op := advanceBranchOp + if err := validateRefName(branch); err != nil { + return newFailure(op, KindInvalidRequest, "invalid branch ref", err) + } + if _, ok := branchShortName(branch); !ok { + return newFailure(op, KindInvalidRequest, "branch must be fully qualified refs/heads/", nil) + } + if err := validateObjectID(expectedTip); err != nil { + return newFailure(op, KindInvalidRequest, "invalid expected tip id", err) + } + if err := validateObjectID(target); err != nil { + return newFailure(op, KindInvalidRequest, "invalid target id", err) + } + descends, err := c.isAncestorIn(ctx, repo.PrimaryWorktree, expectedTip, target, true) + if err != nil { + return err + } + if !descends { + return newFailure(op, KindInvalidRepository, "the target does not descend from the expected tip", nil) + } + infos, err := c.ListWorktrees(ctx, repo) + if err != nil { + return err + } + for _, wi := range infos { + if wi.Branch == branch { + return newFailure(op, KindInvalidRepository, "the branch is checked out in a worktree", nil) + } + } + hooks, f := c.emptyHooksDir(ctx, op, repo.PrimaryWorktree) + if f != nil { + return f + } + res, f := c.run(ctx, runRequest{op: op, dir: repo.PrimaryWorktree, + args: []string{"-c", "core.hooksPath=" + hooks, "update-ref", "-m", fastForwardReflogMessage, string(branch), string(target), string(expectedTip)}}) + if f != nil { + return f + } + if res.exitCode != 0 { + return newFailure(op, KindInvalidRepository, "the branch is no longer at the expected tip; nothing changed: "+stderrExcerpt(res.stderr), nil).withExitCode(res.exitCode) + } + return nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationRepoAdvanceBranchChecked$' ./internal/gitcli/` +Expected: PASS. + +- [ ] **Step 5: Mutation-check** (backup copy, `-count=1`, restore from backup): drop `string(expectedTip)` from `update-ref` → "stale expected tip refuses" red; delete the worktree-holder loop → "branch checked out in a worktree refuses" red. + +- [ ] **Step 6: Commit** + +```bash +git add internal/gitcli/refadvance.go internal/gitcli/refadvance_integration_test.go +git commit -m "feat(gitcli): compare-and-swap advance of an unattached branch" +``` + +--- + +### Task 4: Relationship-keyed classifier and local-copy health findings (pure) + +**Files:** +- Modify: `internal/reposetup/probe.go`, `classify.go`, `health.go`, `healthconditions.go` +- Test: `internal/reposetup/classify_test.go`, `health_test.go` + +**Interfaces:** +- Produces (Tasks 5–9 rely on these exact names): + - `type SyncRelation int` with `SyncUnknown` (zero), `SyncCurrent`, `SyncBehind`, `SyncAhead`, `SyncDiverged`; `func (r SyncRelation) Synchronized() Presence`. + - `Facts.LocalMetadataSync SyncRelation`. + - `WorktreeFact.UnfinishedOperation bool`. + - Reason token and finding code `local-metadata-ahead`; finding code `local-metadata-sync-unverified`. + - Exact texts (Task 5's parity test compares them with `prepare`'s): + - ahead message `The local docket branch is ahead of the remote docket branch (it carries commits the remote does not).`, remedy `Reconcile the local docket branch with the remote manually with a human before any repository operation.` + - dirty message (files) `The .docket metadata worktree has uncommitted or untracked changes.`; dirty message (unfinished operation) `The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect).` + +- [ ] **Step 1: Write the failing tests** + +In `internal/reposetup/classify_test.go`: +- In `healthyFacts()`, add `LocalMetadataSync: SyncCurrent,` after `LocalMetadata`. +- Replace the case `"ahead metadata worktree -> conflict metadata-worktree-dirty"` (it encodes the old false conflict) with the rows of the new test below, and change the `"diverged local metadata branch..."` case's mutation to `f.LocalMetadata = BranchFact{Presence: PresencePresent, Tip: "other9"}; f.LocalMetadataSync = SyncDiverged; f.DocketWorktree.Synchronized = PresenceAbsent`. +- Add: + +```go +// TestClassifyLocalMetadataRelationship pins every row of the spec's local-copy +// table by exact reason set: the relationship, never tip equality, decides. +func TestClassifyLocalMetadataRelationship(t *testing.T) { + rows := []struct { + name string + mutate func(*Facts) + wantState State + wantReasons []string + }{ + {"current clean", func(f *Facts) {}, StateHealthy, nil}, + {"behind clean", func(f *Facts) { + f.LocalMetadata.Tip = "meta-old" + f.LocalMetadataSync = SyncBehind + f.DocketWorktree.Synchronized = PresencePresent + }, StateHealthy, nil}, + {"behind dirty", func(f *Facts) { + f.LocalMetadata.Tip = "meta-old" + f.LocalMetadataSync = SyncBehind + f.DocketWorktree.Clean = PresenceAbsent + }, StateConflict, []string{"metadata-worktree-dirty"}}, + {"ahead clean", func(f *Facts) { + f.LocalMetadata.Tip = "meta-ahead" + f.LocalMetadataSync = SyncAhead + f.DocketWorktree.Synchronized = PresenceAbsent + }, StateConflict, []string{"local-metadata-ahead"}}, + {"ahead dirty", func(f *Facts) { + f.LocalMetadata.Tip = "meta-ahead" + f.LocalMetadataSync = SyncAhead + f.DocketWorktree.Synchronized = PresenceAbsent + f.DocketWorktree.Clean = PresenceAbsent + }, StateConflict, []string{"metadata-worktree-dirty", "local-metadata-ahead"}}, + {"diverged clean", func(f *Facts) { + f.LocalMetadata.Tip = "meta-div" + f.LocalMetadataSync = SyncDiverged + f.DocketWorktree.Synchronized = PresenceAbsent + }, StateConflict, []string{"local-metadata-diverged"}}, + {"unknown relation", func(f *Facts) { + f.LocalMetadata.Tip = "meta-x" + f.LocalMetadataSync = SyncUnknown + f.DocketWorktree.Synchronized = PresenceUnknown + }, StateConflict, []string{"postconditions-unmet"}}, + } + for _, row := range rows { + f := healthyFacts() + row.mutate(&f) + got := Classify(f) + if got.State != row.wantState || !reflect.DeepEqual(got.Reasons, row.wantReasons) { + t.Errorf("%s: Classify = %+v, want state %q reasons %v", row.name, got, row.wantState, row.wantReasons) + } + } +} + +func TestSyncRelationSynchronized(t *testing.T) { + want := map[SyncRelation]Presence{ + SyncUnknown: PresenceUnknown, SyncCurrent: PresencePresent, SyncBehind: PresencePresent, + SyncAhead: PresenceAbsent, SyncDiverged: PresenceAbsent, + } + for r, p := range want { + if got := r.Synchronized(); got != p { + t.Errorf("SyncRelation(%d).Synchronized() = %v, want %v", r, got, p) + } + } +} +``` + +(Import `reflect` if `classify_test.go` does not already.) + +In `internal/reposetup/health_test.go`: +- Add `"local-metadata-ahead"` to the reason lists in `TestHealthRemedyConflictNeverDestructive` and `TestHealthInitCommandAppearsOnlyInFresh`. +- Replace `TestHealthDirtyMessageNamesObservedAlternatives` (it asserts the deleted "not synchronized" variant) with: + +```go +// TestHealthDirtyMessageNamesTheCause: the dirty finding names an unfinished Git +// operation when that is the cause, and never mentions synchronization. +func TestHealthDirtyMessageNamesTheCause(t *testing.T) { + files := healthyFacts() + files.DocketWorktree.Clean = PresenceAbsent + op := healthyFacts() + op.DocketWorktree.Clean = PresenceAbsent + op.DocketWorktree.UnfinishedOperation = true + for name, tc := range map[string]struct { + f Facts + want string + }{ + "files": {files, "The .docket metadata worktree has uncommitted or untracked changes."}, + "operation": {op, "The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect)."}, + } { + var msg string + for _, fn := range EvaluateHealth(Classify(tc.f), tc.f, nil) { + if fn.Code == "metadata-worktree-dirty" { + msg = fn.Message + } + } + if msg != tc.want { + t.Errorf("%s: dirty message = %q, want %q", name, msg, tc.want) + } + if strings.Contains(msg, "synchroniz") { + t.Errorf("%s: dirty message still mentions synchronization: %q", name, msg) + } + } +} + +// TestHealthLocalMetadataRelationshipRows pins findings and exit per table row. +func TestHealthLocalMetadataRelationshipRows(t *testing.T) { + rows := []struct { + name string + mutate func(*Facts) + wantCodes []string + wantExit int + }{ + {"behind clean is healthy with no finding", func(f *Facts) { + f.LocalMetadata.Tip = "meta-old" + f.LocalMetadataSync = SyncBehind + }, nil, 0}, + {"behind dirty", func(f *Facts) { + f.LocalMetadataSync = SyncBehind + f.DocketWorktree.Clean = PresenceAbsent + }, []string{"metadata-worktree-dirty"}, 1}, + {"ahead", func(f *Facts) { + f.LocalMetadataSync = SyncAhead + f.DocketWorktree.Synchronized = PresenceAbsent + }, []string{"local-metadata-ahead"}, 1}, + {"ahead dirty", func(f *Facts) { + f.LocalMetadataSync = SyncAhead + f.DocketWorktree.Synchronized = PresenceAbsent + f.DocketWorktree.Clean = PresenceAbsent + }, []string{"metadata-worktree-dirty", "local-metadata-ahead"}, 1}, + {"diverged", func(f *Facts) { + f.LocalMetadataSync = SyncDiverged + f.DocketWorktree.Synchronized = PresenceAbsent + }, []string{"local-metadata-diverged"}, 1}, + {"unknown with both tips known warns unverified", func(f *Facts) { + f.LocalMetadata.Tip = "meta-x" + f.LocalMetadataSync = SyncUnknown + f.DocketWorktree.Synchronized = PresenceUnknown + }, []string{"postconditions-unmet", "local-metadata-sync-unverified"}, 1}, + } + for _, row := range rows { + f := healthyFacts() + row.mutate(&f) + c := Classify(f) + got := EvaluateHealth(c, f, nil) + if codes := findingCodes(got); !reflect.DeepEqual(codes, row.wantCodes) { + t.Errorf("%s: codes = %v, want %v", row.name, codes, row.wantCodes) + } + if exit := CheckExit(c, got); exit != row.wantExit { + t.Errorf("%s: exit = %d, want %d", row.name, exit, row.wantExit) + } + } + // A missing local tip is explained by local-metadata-unverified, never by the + // sync warning. + f := healthyFacts() + f.LocalMetadata = BranchFact{Presence: PresenceUnknown} + f.LocalMetadataSync = SyncUnknown + f.DocketWorktree.Synchronized = PresenceUnknown + if hasCode(EvaluateHealth(Classify(f), f, nil), "local-metadata-sync-unverified") { + t.Error("sync-unverified fired although the local tip is missing") + } +} +``` + +`findingCodes` returns `nil` for an empty slice? Check its definition in `health_test.go`; if it returns `[]string{}`, compare with `len(codes) == 0` for the nil-want row instead of `reflect.DeepEqual`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test -count=1 ./internal/reposetup/` +Expected: FAIL to compile — `SyncCurrent`, `LocalMetadataSync`, `UnfinishedOperation` undefined. + +- [ ] **Step 3: Implement** + +`internal/reposetup/probe.go` — add after `BranchFact`: + +```go +// SyncRelation is the ancestry relationship of a local tip to the remote tip it +// tracks. The zero value, SyncUnknown, is safe: an unproven relationship is never +// read as current or behind. +type SyncRelation int + +const ( + SyncUnknown SyncRelation = iota // a tip is missing or an ancestry probe failed + SyncCurrent // local == remote + SyncBehind // local is a strict ancestor of remote + SyncAhead // remote is a strict ancestor of local + SyncDiverged // neither is an ancestor of the other +) + +// Synchronized maps a relationship to the docket-worktree-synchronized fact: +// proven current or behind — the local copy holds nothing the remote lacks — is +// Present; proven ahead or diverged is Absent; an unproven relationship is Unknown. +func (r SyncRelation) Synchronized() Presence { + switch r { + case SyncCurrent, SyncBehind: + return PresencePresent + case SyncAhead, SyncDiverged: + return PresenceAbsent + default: + return PresenceUnknown + } +} +``` + +In `WorktreeFact`, change the `Synchronized` comment to `// proven current or behind the remote metadata tip: the local copy holds nothing the remote lacks` and add after `Clean`: + +```go + UnfinishedOperation bool // Clean is Absent because a merge, cherry-pick, revert, rebase, am, or bisect is unfinished +``` + +In `Facts`, add after `LocalMetadata`: + +```go + LocalMetadataSync SyncRelation // local docket tip vs remote docket tip; set only by the check/prepare augmentation +``` + +`internal/reposetup/classify.go` — replace the two conflict blocks for `metadata-worktree-dirty` and `local-metadata-diverged` with: + +```go + if f.RemoteMetadata.Presence == PresencePresent && f.DocketWorktree.Presence == PresencePresent && + f.DocketWorktree.Clean == PresenceAbsent { + conflict = append(conflict, "metadata-worktree-dirty") + } + if f.RemoteMetadata.Presence == PresencePresent && f.LocalMetadataSync == SyncAhead { + conflict = append(conflict, "local-metadata-ahead") + } + if f.RemoteMetadata.Presence == PresencePresent && f.LocalMetadataSync == SyncDiverged { + conflict = append(conflict, "local-metadata-diverged") + } +``` + +`internal/reposetup/health.go`: +- `categoryOf`: add `"local-metadata-ahead"` to the `catLocalWorktree` case beside `"local-metadata-diverged"`. +- `findingFor` `"metadata-worktree-dirty"`: replace the `msg` switch with + +```go + msg := "The .docket metadata worktree has uncommitted or untracked changes." + if f.DocketWorktree.UnfinishedOperation { + msg = "The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect)." + } +``` + + (Code, severity, ref, and remedy unchanged.) +- `findingFor`: add before `"local-metadata-diverged"`: + +```go + case "local-metadata-ahead": + return Finding{ + Code: "local-metadata-ahead", + Severity: SeverityError, + Message: "The local docket branch is ahead of the remote docket branch (it carries commits the remote does not).", + Remedy: "Reconcile the local docket branch with the remote manually with a human before any repository operation.", + } +``` + +- `reasonExplains`: `"metadata-worktree-dirty": {CondWorktreeClean},` and add `"local-metadata-ahead": {CondWorktreeSynchronized},` (diverged stays `{CondWorktreeSynchronized}`). +- `conditionFinding` `CondWorktreeSynchronized`: replace its body with + +```go + case CondWorktreeSynchronized: + if !worktreeInspectable(f) { + return nil + } + // Ahead and diverged are explained by their reasons. Unknown with both tips + // known means the ancestry probe failed: unverified, never guessed behind. A + // missing tip is explained by the local-metadata findings. + if f.DocketWorktree.Synchronized != PresenceUnknown || f.LocalMetadata.Tip == "" || f.RemoteMetadata.Tip == "" { + return nil + } + return &Finding{ + Code: "local-metadata-sync-unverified", + Severity: SeverityWarning, + Ref: ".docket", + Message: "Could not determine how the local docket branch relates to the remote docket branch (unverified, not proven diverged).", + Remedy: "Re-run `docket repository check` once local Git reads succeed.", + } +``` + +- Update the `CondWorktreeClean` comment in `conditionFinding` ("Absent is explained by the metadata-worktree-dirty reason") — still true; leave it. + +`internal/reposetup/healthconditions.go`: add a trailing comment on the constant: `CondWorktreeSynchronized HealthCondition = "docket-worktree-synchronized" // proven current or behind: the local copy holds nothing the remote lacks; Unknown never satisfies it`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -count=1 ./internal/reposetup/ && go build ./...` +Expected: PASS and a clean build. (`internal/app` still compiles: it never read the deleted message variants.) + +- [ ] **Step 5: Mutation-check** (backup `classify.go`, `-count=1`, restore from backup): change the ahead guard to append `"local-metadata-diverged"` → `TestClassifyLocalMetadataRelationship` "ahead clean" red; restore `|| f.DocketWorktree.Synchronized == PresenceAbsent` in the dirty guard → "ahead clean" red (dirty companion). + +- [ ] **Step 6: Commit** + +```bash +git add internal/reposetup/probe.go internal/reposetup/classify.go internal/reposetup/health.go internal/reposetup/healthconditions.go internal/reposetup/classify_test.go internal/reposetup/health_test.go +git commit -m "fix(reposetup): key local-copy health on the sync relationship, not tip equality" +``` + +--- + +### Task 5: Check and prepare share one relationship computation; upgrade guide follows + +**Files:** +- Create: `internal/app/repository_sync_relation.go` +- Modify: `internal/app/repository_check.go` (`augmentCheckFacts`; delete `synchronizedPresence`) +- Modify: `internal/app/repository_prepare.go` (delete `prepareSync`, its constants, and `prepareSyncRelationship`; `prepareAugment`, `prepareRoute`, `RunRepositoryPrepare`) +- Modify: `internal/app/repository_prepare_test.go`, `internal/app/repository_check_test.go` +- Create: `internal/app/reposynccheck_integration_test.go` +- Create: `tests/test_go_integration_app_reposynccheck.sh`; modify `tests/runtime-budgets.tsv` +- Modify: `docs/release/upgrading-from-bash.md`, `internal/bashupgrade/registry_test.go` + +**Interfaces:** +- Consumes: `IsAncestorIgnoringReplacements` (Task 1); `reposetup.SyncRelation`, `Facts.LocalMetadataSync` (Task 4). +- Produces: + - `func syncRelationship(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, localTip, remoteTip string) reposetup.SyncRelation` — the ONE computation (Task 9 reuses it for the primary checkout). + - `func applyLocalMetadataSync(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, f *reposetup.Facts)`. + - `func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, sc setupContext)` (no return value in this task). + - `func prepareRoute(f reposetup.Facts) prepareVerdict` — reads `f.LocalMetadataSync`. + +- [ ] **Step 1: Write the failing tests** + +(a) In `internal/app/repository_prepare_test.go`: add `LocalMetadataSync: reposetup.SyncCurrent,` to `preparableFacts()`. Then apply one mechanical rule to every call site: `prepareRoute(X, prepareSyncY)` becomes "set `X.LocalMetadataSync = reposetup.SyncY` on a local facts value, then `prepareRoute(X)`" (`prepareSyncUnknown`→`reposetup.SyncUnknown`, `prepareSyncCurrent`→`SyncCurrent`, `prepareSyncBehind`→`SyncBehind`, `prepareSyncAhead`→`SyncAhead`, `prepareSyncDiverged`→`SyncDiverged`). Where the call is `prepareRoute(preparableFacts(), …)`, introduce `f := preparableFacts()` first. This preserves the row each test pins (e.g. the local-unknown test still routes with `SyncUnknown`). Find every site with `grep -n "prepareRoute(" internal/app/*_test.go` — do not hand-list them. + +(b) Add a parity test to `internal/app/repository_check_test.go`: + +```go +// TestLocalMetadataRefusalsMatchPrepare: check's ahead/diverged findings and +// prepare's refusals for the same state carry the same code, message, and remedy, +// so the two commands never describe one state two ways. +func TestLocalMetadataRefusalsMatchPrepare(t *testing.T) { + for _, tc := range []struct { + rel reposetup.SyncRelation + reason string + }{ + {reposetup.SyncAhead, "local-metadata-ahead"}, + {reposetup.SyncDiverged, "local-metadata-diverged"}, + } { + f := preparableFacts() + f.LocalMetadataSync = tc.rel + f.DocketWorktree.Synchronized = reposetup.PresenceAbsent + v := prepareRoute(f) + if v.finding == nil { + t.Fatalf("%s: prepare produced no finding", tc.reason) + } + got := reposetup.EvaluateHealth(reposetup.Classification{State: reposetup.StateConflict, Reasons: []string{tc.reason}}, reposetup.Facts{}, nil) + if len(got) != 1 { + t.Fatalf("%s: check findings = %+v", tc.reason, got) + } + if got[0].Code != v.finding.Code || got[0].Message != v.finding.Message || got[0].Remedy != v.finding.Remedy { + t.Errorf("%s: check %+v != prepare %+v", tc.reason, got[0], *v.finding) + } + } +} +``` + +(c) Create `internal/app/reposynccheck_integration_test.go`: + +```go +//go:build integration + +package app + +import ( + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/reposetup" +) + +// --- local-copy relationship scenarios (TestIntegrationRepoSyncCheck shard) -------- +// +// Every typed metadata write pushes from a detached worktree and leaves the local +// .docket copy clean and behind. These prove check keys on the real ancestry +// relationship: behind is healthy with no finding; ahead, diverged, and dirty each +// report exactly their own finding. + +func checkCodes(res RepositoryCheckResult) map[string]bool { + got := map[string]bool{} + for _, f := range res.Findings { + got[f.Code] = true + } + return got +} + +func TestIntegrationRepoSyncCheckBehindIsHealthy(t *testing.T) { + r := newHealthyRepo(t) + oldTip := r.dotDocketHead(t) + newTip := r.advanceRemoteDocket(t, "notes/advance.txt", "advanced\n", "advance docket") + + res := r.runCheck(t) + if res.RepositoryState != string(reposetup.StateHealthy) || len(res.Findings) != 0 || res.CheckExitCode() != 0 { + t.Fatalf("behind-only .docket: state=%q findings=%+v exit=%d, want healthy, none, 0", + res.RepositoryState, res.Findings, res.CheckExitCode()) + } + // Positive evidence the copy really was behind (check is read-only). + if res.Revisions["local-metadata"] != oldTip || res.Revisions["remote-metadata"] != newTip { + t.Fatalf("revisions = %v, want local %s behind remote %s", res.Revisions, oldTip, newTip) + } + if head := r.dotDocketHead(t); head != oldTip { + t.Fatalf("check moved .docket from %s to %s", oldTip, head) + } + + ct := r.runConfigureTests(t) + if ct.Result == ResultInvalidState { + t.Fatalf("configure-tests refused a behind-only repository: %s", ct.HumanText()) + } +} + +func TestIntegrationRepoSyncCheckAheadReportsAheadOnly(t *testing.T) { + r := newHealthyRepo(t) + r.commitInDocket(t, "ahead.txt", "ahead\n", "local-only docket commit") + res := r.runCheck(t) + got := checkCodes(res) + if !got["local-metadata-ahead"] || got["local-metadata-diverged"] || got["metadata-worktree-dirty"] || res.CheckExitCode() != 1 { + t.Fatalf("ahead: findings=%+v exit=%d, want local-metadata-ahead only, exit 1", res.Findings, res.CheckExitCode()) + } +} + +func TestIntegrationRepoSyncCheckDivergedReportsDivergedOnly(t *testing.T) { + r := newHealthyRepo(t) + r.advanceRemoteDocket(t, "notes/remote.txt", "remote\n", "remote side") + r.commitInDocket(t, "local.txt", "local\n", "local side") + res := r.runCheck(t) + got := checkCodes(res) + if !got["local-metadata-diverged"] || got["local-metadata-ahead"] || got["metadata-worktree-dirty"] { + t.Fatalf("diverged: findings=%+v, want local-metadata-diverged only", res.Findings) + } +} + +func TestIntegrationRepoSyncCheckDirtyBehindReportsDirtyOnly(t *testing.T) { + r := newHealthyRepo(t) + r.advanceRemoteDocket(t, "notes/advance.txt", "advanced\n", "advance docket") + if err := os.WriteFile(filepath.Join(r.invocation, ".docket", "scratch.txt"), []byte("dirt\n"), 0o644); err != nil { + t.Fatal(err) + } + res := r.runCheck(t) + got := checkCodes(res) + if !got["metadata-worktree-dirty"] || got["local-metadata-diverged"] || got["local-metadata-ahead"] { + t.Fatalf("dirty+behind: findings=%+v, want metadata-worktree-dirty only", res.Findings) + } +} +``` + +(`newHealthyRepo`, `runCheck`, `runConfigureTests`, `advanceRemoteDocket`, `commitInDocket`, and `dotDocketHead` already exist in the `integration`-tagged files of package `app`.) + +(d) Create `tests/test_go_integration_app_reposynccheck.sh` by copying `tests/test_go_integration_app_repoprepare.sh` and changing only: the header comment (describe the local-copy relationship scenarios for `repository check`, prefix `^TestIntegrationRepoSyncCheck`, no change numbers needed beyond what the sibling headers use) and `SHARD_PREFIX="TestIntegrationRepoSyncCheck"`. Keep `# docket-suite: go` on line 2. Add to `tests/runtime-budgets.tsv`, in sorted position after `tests/test_go_integration_app_reposetup_race.sh`'s neighbors (keep the file's existing order convention): `tests/test_go_integration_app_reposynccheck.sh 30 parallel` (tab-separated). + +(e) Upgrade guide test — in `internal/bashupgrade/registry_test.go` replace `runRepairApply` and `runRepoConfirm` with: + +```go +// runRepairApply runs the block and checks the guide's claims: the repair is pushed +// to the docket branch, the output names `docket repository prepare`, which the guide +// calls optional, and the local .docket copy is left behind the remote (so the +// healthy check that follows proves a behind-only copy is healthy). +func runRepairApply(t *testing.T, c *upgradeCase, st *runState, body string) { + t.Helper() + before := gitRev(t, c, "docket") + r := runBlock(t, c, st, body) + after := gitRev(t, c, "docket") + if after == before { + t.Fatalf("guide says repair pushes to the docket branch; origin/docket did not move") + } + mustContain(t, "repair output", r.Stdout+r.Stderr, "docket repository prepare") + mustContain(t, "guide", st.Guide, repairPrepareOptionalProse) + local := strings.TrimSpace(c.mustGit(t, filepath.Join(st.Cwd, ".docket"), "rev-parse", "HEAD")) + if local == after { + t.Fatalf("the local .docket copy is already at the repaired tip; the guide's claim that prepare is optional is untested") + } +} + +// runRepoConfirm checks the guide's claim that `docket repository check` alone +// reports healthy right after the repair, with no `prepare` step in between. +func runRepoConfirm(t *testing.T, c *upgradeCase, st *runState, body string) { + t.Helper() + if strings.TrimSpace(body) != "docket repository check" { + t.Fatalf("repo-confirm block must be exactly `docket repository check`:\n%s", body) + } + r := runBlock(t, c, st, body) + mustContain(t, "repository check output", r.Stdout+r.Stderr, "repository check: no-op (healthy)") +} +``` + +and add the prose constant near `releaseVerifiedProse`: + +```go +// repairPrepareOptionalProse is the guide's statement that the prepare step repair +// mentions is optional. +const repairPrepareOptionalProse = "That step is optional: the next docket command brings the folder up to date by itself." +``` + +(Import `path/filepath` if the file does not already. If `c.mustGit` requires a directory inside the clone that `st.Cwd` is not, use the clone root field the helper already uses — read `upgradeCase` first.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test -count=1 ./internal/app/ -run 'TestRepositoryPrepare|TestLocalMetadataRefusalsMatchPrepare'` +Expected: FAIL to compile — `prepareRoute` called with one argument. + +- [ ] **Step 3: Implement** + +Create `internal/app/repository_sync_relation.go`: + +```go +package app + +import ( + "context" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/reposetup" +) + +// syncRelationship is the ONE ancestry computation behind `repository check`, +// `repository configure-tests`, and `repository prepare`: how a local tip relates to +// the remote tip it tracks. An empty tip or a probe error is the safe SyncUnknown — +// an unproven relationship is never read as current or behind, so it never +// fast-forwards and never reads healthy. The probe ignores replace refs and grafts +// (IsAncestorIgnoringReplacements), so neither can fake "behind" and strand +// local-only commits. +func syncRelationship(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, localTip, remoteTip string) reposetup.SyncRelation { + if localTip == "" || remoteTip == "" { + return reposetup.SyncUnknown + } + if localTip == remoteTip { + return reposetup.SyncCurrent + } + local, remote := gitcli.ObjectID(localTip), gitcli.ObjectID(remoteTip) + localBehind, err := git.IsAncestorIgnoringReplacements(ctx, repo, local, remote) + if err != nil { + return reposetup.SyncUnknown + } + remoteBehind, err := git.IsAncestorIgnoringReplacements(ctx, repo, remote, local) + if err != nil { + return reposetup.SyncUnknown + } + switch { + case localBehind: + return reposetup.SyncBehind + case remoteBehind: + return reposetup.SyncAhead + default: + return reposetup.SyncDiverged + } +} + +// applyLocalMetadataSync records how the local docket branch relates to the remote +// docket tip, and derives the .docket worktree's synchronized fact from it. Both the +// check augmentation (augmentCheckFacts) and the prepare augmentation (prepareAugment) +// call it, so their facts never disagree. It reads LocalMetadata and RemoteMetadata, +// which the caller has already resolved. +func applyLocalMetadataSync(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, f *reposetup.Facts) { + if f.LocalMetadata.Presence == reposetup.PresencePresent && f.RemoteMetadata.Presence == reposetup.PresencePresent { + f.LocalMetadataSync = syncRelationship(ctx, git, repo, f.LocalMetadata.Tip, f.RemoteMetadata.Tip) + } + if f.DocketWorktree.Presence == reposetup.PresencePresent { + f.DocketWorktree.Synchronized = f.LocalMetadataSync.Synchronized() + } +} +``` + +`internal/app/repository_check.go`: +- In `augmentCheckFacts`, delete `f.DocketWorktree.Synchronized = synchronizedPresence(f.LocalMetadata, f.RemoteMetadata)` from the worktree block and add, right after that block, `applyLocalMetadataSync(ctx, git, sc.repo, f)`. In the metadata-root comment, replace "the synchronizedPresence comparison's remote side" with "the remote side of syncRelationship". +- Change the worktree block's comment to "clean and hooks disabled; its synchronized fact comes from applyLocalMetadataSync". +- Delete `synchronizedPresence`. + +`internal/app/repository_prepare.go`: +- Delete the `prepareSync` type, its five constants, and `prepareSyncRelationship`. +- `prepareAugment`: drop the return value; delete the `Synchronized` line; end with `applyLocalMetadataSync(ctx, git, sc.repo, f)`. Delete the now-unused `metaTip` remote-side usage only if the compiler reports it unused (it still feeds the fetch; keep the fetch logic as is). Update its doc comment: it fills the facts, including `LocalMetadataSync`, and returns nothing. +- `prepareRoute(f reposetup.Facts) prepareVerdict`: replace `switch sync {` with `switch f.LocalMetadataSync {` and the cases `prepareSyncCurrent` → `reposetup.SyncCurrent`, etc. Update its doc comment ("plus the computed sync relationship" → "including the local/remote sync relationship in f.LocalMetadataSync"). +- `RunRepositoryPrepare`: replace the `sync := prepareSyncUnknown … sync = prepareAugment(...)` lines with + +```go + if facts.RemoteMetadata.Presence == reposetup.PresencePresent { + prepareAugment(ctx, d.Git, &facts, sc) + } + + verdict := prepareRoute(facts) +``` + +`docs/release/upgrading-from-bash.md` — replace the paragraph after the `repair-apply` block and the `repo-confirm` block with: + +```markdown +It then tells you to run `docket repository prepare` to bring your local `.docket` folder up to +date. That step is optional: the next docket command brings the folder up to date by itself. +Check the repository: + + +```sh +docket repository check +``` + +`docket repository check` now reports `healthy`. +``` + +(The sentence in the middle must match `repairPrepareOptionalProse` byte for byte, including the line break position only if `mustContain` compares raw text — it does, so keep "That step is optional: the next docket command brings the folder up to date by itself." on ONE line, re-wrapping the surrounding text instead.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run, in order: +1. `go build ./... && go vet ./internal/app/ ./internal/reposetup/` +2. `go test -count=1 ./internal/app/ -run 'TestRepositoryPrepare|TestLocalMetadataRefusalsMatchPrepare|TestRepositoryCheck'` +3. `bash tests/test_go_integration_app_reposynccheck.sh` +4. `bash tests/test_go_integration_app_repoprepare.sh && bash tests/test_go_integration_app_repocheck.sh` +5. `bash tests/test_go_integration_bashupgrade.sh` +6. `bash tests/test_go_integration_contract.sh` (proves the new shard is wired and every tagged test maps to exactly one runner) +Expected: all PASS. Time step 3 (`time bash …`); if it exceeds 20s solo, record it for Task 10's budget review. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/repository_sync_relation.go internal/app/repository_check.go internal/app/repository_prepare.go internal/app/repository_prepare_test.go internal/app/repository_check_test.go internal/app/reposynccheck_integration_test.go tests/test_go_integration_app_reposynccheck.sh tests/runtime-budgets.tsv docs/release/upgrading-from-bash.md internal/bashupgrade/registry_test.go +git commit -m "fix(check): a clean behind-only .docket copy is healthy; one sync computation for check and prepare" +``` + +--- + +### Task 6: "Clean" excludes an unfinished Git operation + +**Files:** +- Modify: `internal/app/repository_check.go` (`worktreeCleanPresence` → `worktreeCleanState`) +- Modify: `internal/app/repository_prepare.go` (`prepareAugment`, dirty refusal message) +- Modify: `internal/app/repository_check_test.go` (extend the parity test) +- Modify: `internal/app/reposynccheck_integration_test.go` + +**Interfaces:** +- Consumes: `gitcli.Client.WorktreeCheckoutState` (existing; `CheckoutState.OperationInProgress` covers MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD, BISECT_LOG, rebase-merge, rebase-apply — `am` uses rebase-apply); `WorktreeFact.UnfinishedOperation` (Task 4). +- Produces: `func worktreeCleanState(ctx context.Context, git *gitcli.Client, worktreeDir string) (reposetup.Presence, bool)` — the one clean probe for check and prepare; the bool is "an unfinished operation made it not clean". + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/app/reposynccheck_integration_test.go`: + +```go +// TestIntegrationRepoSyncCheckUnfinishedMergeIsDirty: a merge whose index equals HEAD +// (so `git status` lists nothing) is still not clean. Check reports it as dirty and +// names the operation; prepare refuses and keeps MERGE_HEAD. +func TestIntegrationRepoSyncCheckUnfinishedMergeIsDirty(t *testing.T) { + r := newHealthyRepo(t) + r.advanceRemoteDocket(t, "notes/advance.txt", "advanced\n", "advance docket") + dot := filepath.Join(r.invocation, ".docket") + runGit(t, dot, "fetch", "-q", "origin", "docket") + runGit(t, dot, "merge", "--no-ff", "--no-commit", "-s", "ours", "FETCH_HEAD") + if s := runGit(t, dot, "status", "--porcelain", "--untracked-files=all"); s != "" { + t.Fatalf("premise: the unfinished merge must leave status empty, got:\n%s", s) + } + headBefore := r.dotDocketHead(t) + mergeHead := runGit(t, dot, "rev-parse", "MERGE_HEAD") + + res := r.runCheck(t) + var msg string + for _, f := range res.Findings { + if f.Code == "metadata-worktree-dirty" { + msg = f.Message + } + } + if !strings.Contains(msg, "unfinished Git operation") { + t.Fatalf("check findings %+v, want metadata-worktree-dirty naming the unfinished operation", res.Findings) + } + + pr := runPrepareAt(t, r.invocation) + if pr.Disposition != PrepareDispositionRefused || !prepareFinding(pr, "metadata-worktree-dirty") { + t.Fatalf("prepare = %q %+v, want refused metadata-worktree-dirty", pr.Disposition, pr.Findings) + } + if got := runGit(t, dot, "rev-parse", "MERGE_HEAD"); got != mergeHead { + t.Fatalf("MERGE_HEAD = %q after prepare, want kept %q", got, mergeHead) + } + if got := r.dotDocketHead(t); got != headBefore { + t.Fatalf(".docket HEAD moved from %s to %s", headBefore, got) + } +} +``` + +(Add `"strings"` to the file's imports.) + +Extend `TestLocalMetadataRefusalsMatchPrepare` with a dirty-operation row: `f := preparableFacts(); f.DocketWorktree.Clean = reposetup.PresenceAbsent; f.DocketWorktree.UnfinishedOperation = true; v := prepareRoute(f)`; compare `v.finding.Message` with the `metadata-worktree-dirty` message `reposetup.EvaluateHealth` returns for `reposetup.Facts{DocketWorktree: reposetup.WorktreeFact{UnfinishedOperation: true}}` under reason `metadata-worktree-dirty`, and the same for `UnfinishedOperation: false`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test -count=1 ./internal/app/ -run TestLocalMetadataRefusalsMatchPrepare && go test -tags integration -count=1 -run '^TestIntegrationRepoSyncCheckUnfinishedMergeIsDirty$' ./internal/app/` +Expected: the parity row FAILS (prepare's message has no operation variant); the integration test FAILS (check reports healthy-or-other, prepare applies and discards MERGE_HEAD). + +- [ ] **Step 3: Implement** + +In `internal/app/repository_check.go`, replace `worktreeCleanPresence` with: + +```go +// worktreeCleanState is the ONE clean probe for the .docket worktree (check and +// prepare both call it). Clean means no uncommitted or untracked change AND no +// unfinished Git operation (merge, cherry-pick, revert, rebase, am, bisect): a merge +// whose index equals HEAD lists nothing in `git status`, yet a fast-forward would +// silently drop its MERGE_HEAD. The bool reports that an unfinished operation is +// what made it not clean. Any probe error is the safe Unknown. +func worktreeCleanState(ctx context.Context, git *gitcli.Client, worktreeDir string) (reposetup.Presence, bool) { + st, err := git.WorktreeCheckoutState(ctx, worktreeDir) + if err != nil { + return reposetup.PresenceUnknown, false + } + changes, err := git.ChangedPaths(ctx, worktreeDir) + if err != nil { + return reposetup.PresenceUnknown, false + } + if st.OperationInProgress { + return reposetup.PresenceAbsent, true + } + if len(changes) == 0 { + return reposetup.PresencePresent, false + } + return reposetup.PresenceAbsent, false +} +``` + +and in `augmentCheckFacts`: `f.DocketWorktree.Clean, f.DocketWorktree.UnfinishedOperation = worktreeCleanState(ctx, git, worktreeDir)`. + +In `internal/app/repository_prepare.go` `prepareAugment`: the same assignment with `filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName)`. In `prepareRoute`'s `case reposetup.PresenceAbsent:` of the clean switch, build the message: + +```go + msg := "The .docket metadata worktree has uncommitted or untracked changes." + if f.DocketWorktree.UnfinishedOperation { + msg = "The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect)." + } +``` + +and use `Message: msg` (remedy unchanged). + +A rebase or bisect in `.docket` usually leaves HEAD detached, which `docketWorktreeFact` already reports as foreign; no special case is added for that — `check` may now also report `metadata-worktree-dirty` beside `docket-dir-foreign` there, which is true. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -count=1 ./internal/app/ -run 'TestLocalMetadataRefusalsMatchPrepare|TestRepositoryPrepare' && bash tests/test_go_integration_app_reposynccheck.sh && bash tests/test_go_integration_app_repoprepare.sh && bash tests/test_go_integration_app_repocheck.sh` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/repository_check.go internal/app/repository_prepare.go internal/app/repository_check_test.go internal/app/reposynccheck_integration_test.go +git commit -m "fix(check): an unfinished Git operation in .docket is not clean" +``` + +--- + +### Task 7: Prepare fast-forwards `.docket` in place + +**Files:** +- Modify: `internal/app/repository_prepare.go` (`prepareVerdict.observedTip`, `prepareApplyVerdict`, `prepareExecute`, `prepareEffectFailure`; delete `prepareFastForwardWorktree`; refresh doc comments that describe the remove/re-add) +- Modify: `internal/app/repository_prepare_test.go` +- Modify: `internal/app/repoprepare_integration_test.go` (header comment only) +- Create: `internal/app/repoinplaceff_integration_test.go` +- Create: `tests/test_go_integration_app_repoinplaceff.sh`; modify `tests/runtime-budgets.tsv` + +**Interfaces:** +- Consumes: `FastForwardCheckedOutBranch` (Task 2). +- Produces: `prepareVerdict.observedTip string`; `func prepareApplyVerdict(action prepareAction, targetRev, observedTip string) prepareVerdict` (Task 8 passes the observed tip on the attach rows). + +- [ ] **Step 1: Write the failing tests** + +(a) In `TestRepositoryPrepareCleanBehindFastForwards` (`repository_prepare_test.go`), add after the target assert: + +```go + if v.observedTip != "m1" { + t.Errorf("observedTip = %q, want the router's local tip m1 (the compare-and-swap source)", v.observedTip) + } +``` + +(b) Create `internal/app/repoinplaceff_integration_test.go`: + +```go +//go:build integration + +package app + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/reposetup" + "github.com/danielhanold/docket/internal/testsupport" +) + +// --- in-place fast-forward scenarios (TestIntegrationRepoInPlaceFF shard) ---------- +// +// prepare fast-forwards a clean, behind .docket inside the existing worktree: +// nothing removed, compare-and-swap on the router's observed tip, no repository +// hook, refuse rather than discard. Each scenario starts from a check-healthy +// repository whose remote docket branch is one commit ahead of .docket. + +func newBehindHealthyRepo(t *testing.T) (r *initRepo, oldTip, newTip string) { + t.Helper() + r = newHealthyRepo(t) + oldTip = r.dotDocketHead(t) + newTip = r.advanceRemoteDocket(t, "notes/advance.txt", "advanced\n", "advance docket") + return r, oldTip, newTip +} + +func requireFastForwarded(t *testing.T, r *initRepo, newTip string) { + t.Helper() + res := runPrepareAt(t, r.invocation) + if res.Disposition != PrepareDispositionApplied { + t.Fatalf("prepare = %q (%s), want applied", res.Disposition, res.HumanText()) + } + if head := r.dotDocketHead(t); head != newTip { + t.Fatalf(".docket HEAD = %s, want %s", head, newTip) + } +} + +func requireCheckHealthy(t *testing.T, r *initRepo) { + t.Helper() + res := r.runCheck(t) + if res.RepositoryState != string(reposetup.StateHealthy) || res.CheckExitCode() != 0 { + t.Fatalf("check after prepare: state=%q findings=%+v exit=%d", res.RepositoryState, res.Findings, res.CheckExitCode()) + } +} + +func writeHook(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body), 0o755); err != nil { + t.Fatal(err) + } +} + +func TestIntegrationRepoInPlaceFFLockedWorktree(t *testing.T) { + r, _, newTip := newBehindHealthyRepo(t) + dot := filepath.Join(r.invocation, ".docket") + runGit(t, r.invocation, "worktree", "lock", dot) + requireFastForwarded(t, r, newTip) + if list := runGit(t, r.invocation, "worktree", "list", "--porcelain"); !strings.Contains(list, "\nlocked") { + t.Fatalf(".docket lock lost:\n%s", list) + } +} + +// TestIntegrationRepoInPlaceFFRepositoryHooksNeverRun installs failing, file-writing +// post-checkout and post-merge hooks through the repository's shared hooks path. +// A remove/re-add (or a porcelain merge) would run them. +func TestIntegrationRepoInPlaceFFRepositoryHooksNeverRun(t *testing.T) { + r, _, newTip := newBehindHealthyRepo(t) + hooks := filepath.Join(testsupport.TempDir(t), "shared-hooks") + sentinel := filepath.Join(testsupport.TempDir(t), "hook-ran") + for _, h := range []string{"post-checkout", "post-merge"} { + writeHook(t, hooks, h, "echo "+h+" >> '"+sentinel+"'\nexit 1\n") + } + runGit(t, r.invocation, "config", "core.hooksPath", hooks) + requireFastForwarded(t, r, newTip) + if b, err := os.ReadFile(sentinel); err == nil { + t.Fatalf("a repository hook ran: %q", b) + } + requireCheckHealthy(t, r) +} + +func TestIntegrationRepoInPlaceFFIgnoredFileAndDirectoryIdentity(t *testing.T) { + r, _, newTip := newBehindHealthyRepo(t) + dot := filepath.Join(r.invocation, ".docket") + exclude := filepath.Join(r.invocation, ".git", "info", "exclude") + f, err := os.OpenFile(exclude, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString("*.local\n"); err != nil { + t.Fatal(err) + } + f.Close() + sentinel := filepath.Join(dot, "sentinel.local") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatal(err) + } + before, err := os.Stat(dot) + if err != nil { + t.Fatal(err) + } + requireFastForwarded(t, r, newTip) + after, err := os.Stat(dot) + if err != nil || !os.SameFile(before, after) { + t.Fatalf(".docket directory was removed and re-created (err=%v)", err) + } + if b, err := os.ReadFile(sentinel); err != nil || string(b) != "keep\n" { + t.Fatalf("ignored file lost: %q, %v", b, err) + } + requireCheckHealthy(t, r) +} + +// TestIntegrationRepoInPlaceFFStaleObservedTipRefuses hands the execute step the +// tip the router saw, after a commit landed in .docket: it must refuse and keep it. +func TestIntegrationRepoInPlaceFFStaleObservedTipRefuses(t *testing.T) { + r, oldTip, newTip := newBehindHealthyRepo(t) + runGit(t, r.invocation, "fetch", "-q", "origin", "docket") + runGit(t, r.invocation, "cat-file", "-e", newTip+"^{commit}") // premise: target object is local + client := newGitClient(t) + _, sc, err := GatherSetupFacts(context.Background(), SetupDeps{Git: client, RepoDir: r.invocation}, true) + if err != nil { + t.Fatal(err) + } + moved := r.commitInDocket(t, "late.txt", "late\n", "commit after routing") + + v := prepareVerdict{disposition: PrepareDispositionApplied, action: prepareActionFastForward, targetRev: newTip, observedTip: oldTip} + if err := prepareExecute(context.Background(), client, sc, v); err == nil { + t.Fatal("fast-forward from a stale observed tip succeeded; want refusal") + } + if got := runGit(t, r.invocation, "rev-parse", "refs/heads/docket"); got != moved { + t.Fatalf("local docket = %s, want the late commit %s kept", got, moved) + } + if b := mustReadFile(t, filepath.Join(r.invocation, ".docket", "late.txt")); string(b) != "late\n" { + t.Fatalf("late.txt = %q", b) + } +} + +// TestIntegrationRepoInPlaceFFHooksOffMissing removes the per-worktree hooks-off +// setting; the forced empty hooks dir must still keep the shared hooks silent inside +// .docket. Git runs a hook from the worktree root, so each hook records $PWD, and the +// primary's own fetches (which do run reference-transaction) are told apart from the +// fast-forward. post-index-change is deliberately NOT installed: prepare's own +// `git status` clean probe inside .docket may write the index and would fire it +// before the fast-forward starts, which is not what this test measures. +func TestIntegrationRepoInPlaceFFHooksOffMissing(t *testing.T) { + r, _, newTip := newBehindHealthyRepo(t) + dot := filepath.Join(r.invocation, ".docket") + hooks := filepath.Join(testsupport.TempDir(t), "shared-hooks") + sentinel := filepath.Join(testsupport.TempDir(t), "hook-ran") + for _, h := range []string{"reference-transaction", "post-checkout", "post-merge"} { + writeHook(t, hooks, h, "echo \""+h+" $PWD\" >> '"+sentinel+"'\nexit 0\n") + } + runGit(t, r.invocation, "config", "core.hooksPath", hooks) + runGit(t, dot, "config", "--worktree", "--unset", "core.hooksPath") + + dotReal, err := filepath.EvalSymlinks(dot) + if err != nil { + t.Fatal(err) + } + inDocket := func(line string) bool { + return strings.HasSuffix(line, " "+dot) || strings.HasSuffix(line, " "+dotReal) + } + + // Premise: with hooks-off missing, a ref update inside .docket does run the hook. + runGit(t, dot, "update-ref", "refs/docket-test/premise", "HEAD") + runGit(t, dot, "update-ref", "-d", "refs/docket-test/premise") + premise, _ := os.ReadFile(sentinel) + ran := false + for _, line := range strings.Split(strings.TrimSpace(string(premise)), "\n") { + ran = ran || inDocket(line) + } + if !ran { + t.Fatalf("premise: the shared hook did not run inside .docket:\n%s", premise) + } + if err := os.Remove(sentinel); err != nil { + t.Fatal(err) + } + + requireFastForwarded(t, r, newTip) + b, _ := os.ReadFile(sentinel) + for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { + if inDocket(line) { + t.Fatalf("a repository hook ran inside .docket during prepare: %q", line) + } + } +} + +func TestIntegrationRepoInPlaceFFIdempotent(t *testing.T) { + r, _, newTip := newBehindHealthyRepo(t) + requireFastForwarded(t, r, newTip) + second := runPrepareAt(t, r.invocation) + if second.Disposition != PrepareDispositionNoOp { + t.Fatalf("second prepare = %q (%s), want no-op", second.Disposition, second.HumanText()) + } + hooksPath := runGit(t, filepath.Join(r.invocation, ".docket"), "config", "--worktree", "core.hooksPath") + if hooksPath == "" { + t.Fatal("per-worktree hooks-off setting was removed") + } + requireCheckHealthy(t, r) +} + +// TestIntegrationRepoInPlaceFFInterruptedReadsDirty simulates an interruption after +// the branch moved but before the tree did: it must read dirty, never clean, and +// prepare must refuse without removing anything. +func TestIntegrationRepoInPlaceFFInterruptedReadsDirty(t *testing.T) { + r, oldTip, newTip := newBehindHealthyRepo(t) + dot := filepath.Join(r.invocation, ".docket") + runGit(t, r.invocation, "fetch", "-q", "origin", "docket") + runGit(t, dot, "update-ref", "refs/heads/docket", newTip, oldTip) + + res := r.runCheck(t) + if !checkCodes(res)["metadata-worktree-dirty"] { + t.Fatalf("interrupted fast-forward: findings %+v, want metadata-worktree-dirty", res.Findings) + } + pr := runPrepareAt(t, r.invocation) + if pr.Disposition != PrepareDispositionRefused { + t.Fatalf("prepare = %q, want refused", pr.Disposition) + } + if _, err := os.Stat(dot); err != nil { + t.Fatalf(".docket removed: %v", err) + } +} +``` + +(c) Create `tests/test_go_integration_app_repoinplaceff.sh` from the `repoprepare` runner with `SHARD_PREFIX="TestIntegrationRepoInPlaceFF"` and a header describing the in-place fast-forward and attach-freshness scenarios. Add `tests/test_go_integration_app_repoinplaceff.sh 30 parallel` to `tests/runtime-budgets.tsv` (sorted position). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test -count=1 ./internal/app/ -run TestRepositoryPrepareCleanBehindFastForwards` +Expected: FAIL to compile — `v.observedTip undefined`. + +- [ ] **Step 3: Implement** + +In `internal/app/repository_prepare.go`: +- Add to `prepareVerdict`: `observedTip string // the local docket tip the router decided on; every branch move is a compare-and-swap from it ("" when no local branch exists)`. +- Change `prepareApplyVerdict` to `func prepareApplyVerdict(action prepareAction, targetRev, observedTip string) prepareVerdict` setting `observedTip`. Update callers: the attach row passes `""` for now (Task 8 replaces it); the behind row passes `f.LocalMetadata.Tip`. +- In `prepareExecute`, replace the fast-forward case with: + +```go + case prepareActionFastForward: + return git.FastForwardCheckedOutBranch(ctx, worktreePath, metaRef, gitcli.ObjectID(verdict.observedTip), target) +``` + + (Hooks-off is not re-applied: nothing removed it.) +- Delete `prepareFastForwardWorktree`. +- In `prepareEffectFailure`, for the fast-forward stage append to the human text: `" Nothing was removed. If `git -C .docket status` shows changes, finish or reset that update inside .docket with plain Git, then re-run `docket repository prepare`."` (one constant, `prepareFastForwardRecovery`). +- Refresh doc comments that describe the old sequence: the file header ("attaches or fast-forwards it idempotently" stays; add "in place"), `prepareExecute`'s comment, and `RunRepositoryPrepare`'s comment. Grep `remove\|re-add\|re-adding` in the file and fix every hit that describes prepare's fast-forward. + +In `internal/app/repoprepare_integration_test.go`, reword the header sentences that call the fast-forward "composed from the worktree primitives" / "remove/delete/re-add" to say it is an in-place compare-and-swap fast-forward whose properties are proven in the `TestIntegrationRepoInPlaceFF` shard. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go build ./... && go test -count=1 ./internal/app/ -run TestRepositoryPrepare && bash tests/test_go_integration_app_repoinplaceff.sh && bash tests/test_go_integration_app_repoprepare.sh && bash tests/test_go_integration_contract.sh` +Expected: PASS. Time the new shard solo; note it for Task 10. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/repository_prepare.go internal/app/repository_prepare_test.go internal/app/repoprepare_integration_test.go internal/app/repoinplaceff_integration_test.go tests/test_go_integration_app_repoinplaceff.sh tests/runtime-budgets.tsv +git commit -m "fix(prepare): fast-forward .docket in place with a compare-and-swap and no hooks" +``` + +--- + +### Task 8: Prepare's attach path checks freshness + +**Files:** +- Modify: `internal/app/repository_prepare.go` (`prepareAugment` returns the holder probe; `prepareRoute(f, heldElsewhere)`; new `prepareAttachRoute`, `prepareAttachFresh`, `prepareAheadFinding`, `prepareDivergedFinding`; `prepareExecute` attach case) +- Modify: `internal/app/repository_prepare_test.go`, `internal/app/repository_check_test.go` (call sites) +- Modify: `internal/app/repoinplaceff_integration_test.go` + +**Interfaces:** +- Consumes: `AdvanceBranchChecked` (Task 3); `gitcli.Client.AddBranchWorktree`, `AttachBranchWorktree`, `ListWorktrees`. +- Produces: + - `func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, sc setupContext) reposetup.Presence` — returns whether another worktree holds `refs/heads/docket`, probed only when `.docket` is absent and the local branch is present (otherwise `PresenceUnknown`, never consulted). + - `func prepareRoute(f reposetup.Facts, heldElsewhere reposetup.Presence) prepareVerdict`. + - `ensureMetadataWorktree` is unchanged and no longer called by prepare. + +Routing for an absent `.docket` (spec §5): + +| `LocalMetadata` / `LocalMetadataSync` | verdict | +|---|---| +| Absent | apply attach, observed `""` (create at target) | +| Unknown | local-unknown refusal | +| Present, Current | apply attach, observed = local tip | +| Present, Behind, held Absent | apply attach, observed = local tip | +| Present, Behind, held Present | refuse `docket-worktree-ambiguous-registration` | +| Present, Behind, held Unknown | local-unknown refusal | +| Present, Ahead | refuse `local-metadata-ahead` | +| Present, Diverged | refuse `local-metadata-diverged` | +| Present, Unknown | local-unknown refusal | + +- [ ] **Step 1: Write the failing tests** + +(a) Apply the mechanical call-site rule: every `prepareRoute(f)` becomes `prepareRoute(f, reposetup.PresenceAbsent)` (find them with grep, do not hand-list). Then add to `repository_prepare_test.go`: + +```go +// TestRepositoryPrepareAbsentWorktreeRoutesOnFreshness pins every row of the +// absent-.docket table: prepare never attaches a stale, ahead, or diverged branch. +func TestRepositoryPrepareAbsentWorktreeRoutesOnFreshness(t *testing.T) { + absent := func(local reposetup.BranchFact, rel reposetup.SyncRelation) reposetup.Facts { + f := preparableFacts() + f.DocketWorktree = reposetup.WorktreeFact{Presence: reposetup.PresenceAbsent} + f.LocalMetadata = local + f.LocalMetadataSync = rel + return f + } + present := func(tip string) reposetup.BranchFact { + return reposetup.BranchFact{Presence: reposetup.PresencePresent, Tip: tip} + } + rows := []struct { + name string + f reposetup.Facts + held reposetup.Presence + wantDisp string + wantAction prepareAction + wantObserved string + wantCode string + }{ + {"no local branch creates", absent(reposetup.BranchFact{Presence: reposetup.PresenceAbsent}, reposetup.SyncUnknown), reposetup.PresenceUnknown, PrepareDispositionApplied, prepareActionAttach, "", ""}, + {"local unknown refuses", absent(reposetup.BranchFact{}, reposetup.SyncUnknown), reposetup.PresenceUnknown, PrepareDispositionRefused, prepareActionNone, "", "prepare-local-state-unknown"}, + {"current attaches", absent(present("m9"), reposetup.SyncCurrent), reposetup.PresenceUnknown, PrepareDispositionApplied, prepareActionAttach, "m9", ""}, + {"behind free advances then attaches", absent(present("m1"), reposetup.SyncBehind), reposetup.PresenceAbsent, PrepareDispositionApplied, prepareActionAttach, "m1", ""}, + {"behind held elsewhere refuses", absent(present("m1"), reposetup.SyncBehind), reposetup.PresencePresent, PrepareDispositionRefused, prepareActionNone, "", "docket-worktree-ambiguous-registration"}, + {"behind holder unknown refuses", absent(present("m1"), reposetup.SyncBehind), reposetup.PresenceUnknown, PrepareDispositionRefused, prepareActionNone, "", "prepare-local-state-unknown"}, + {"ahead refuses", absent(present("mA"), reposetup.SyncAhead), reposetup.PresenceAbsent, PrepareDispositionRefused, prepareActionNone, "", "local-metadata-ahead"}, + {"diverged refuses", absent(present("mD"), reposetup.SyncDiverged), reposetup.PresenceAbsent, PrepareDispositionRefused, prepareActionNone, "", "local-metadata-diverged"}, + {"relation unknown refuses", absent(present("mX"), reposetup.SyncUnknown), reposetup.PresenceAbsent, PrepareDispositionRefused, prepareActionNone, "", "prepare-local-state-unknown"}, + } + for _, row := range rows { + v := prepareRoute(row.f, row.held) + if v.disposition != row.wantDisp || v.action != row.wantAction || v.observedTip != row.wantObserved { + t.Errorf("%s: verdict %q/%v observed %q, want %q/%v observed %q", row.name, v.disposition, v.action, v.observedTip, row.wantDisp, row.wantAction, row.wantObserved) + } + if row.wantCode != "" && (v.finding == nil || v.finding.Code != row.wantCode) { + t.Errorf("%s: finding %+v, want code %s", row.name, v.finding, row.wantCode) + } + if row.wantDisp == PrepareDispositionApplied && v.targetRev != "m9" { + t.Errorf("%s: targetRev = %q, want m9", row.name, v.targetRev) + } + } +} +``` + +Also update `TestRepositoryPrepareHealthyMissingWorktreeAttaches` to assert `v.observedTip == ""`. + +(b) Append to `internal/app/repoinplaceff_integration_test.go`: + +```go +// removeDocketWorktree deregisters a clean .docket, leaving the local branch. +func removeDocketWorktree(t *testing.T, r *initRepo) { + t.Helper() + runGit(t, r.invocation, "worktree", "remove", filepath.Join(r.invocation, ".docket")) +} + +func TestIntegrationRepoInPlaceFFAttachBehindAdvancesBranch(t *testing.T) { + r, _, newTip := newBehindHealthyRepo(t) + removeDocketWorktree(t, r) + res := runPrepareAt(t, r.invocation) + if res.Disposition != PrepareDispositionApplied { + t.Fatalf("prepare = %q (%s), want applied", res.Disposition, res.HumanText()) + } + if head := r.dotDocketHead(t); head != newTip { + t.Fatalf(".docket attached at %s, want the remote tip %s (never the stale local tip)", head, newTip) + } + if local := runGit(t, r.invocation, "rev-parse", "refs/heads/docket"); local != newTip { + t.Fatalf("local docket = %s, want %s", local, newTip) + } + requireCheckHealthy(t, r) +} + +func TestIntegrationRepoInPlaceFFAttachAheadRefuses(t *testing.T) { + r := newHealthyRepo(t) + aheadTip := r.commitInDocket(t, "ahead.txt", "ahead\n", "local-only docket commit") + removeDocketWorktree(t, r) + res := runPrepareAt(t, r.invocation) + if res.Disposition != PrepareDispositionRefused || !prepareFinding(res, "local-metadata-ahead") { + t.Fatalf("prepare = %q %+v, want refused local-metadata-ahead", res.Disposition, res.Findings) + } + if local := runGit(t, r.invocation, "rev-parse", "refs/heads/docket"); local != aheadTip { + t.Fatalf("local docket = %s, want untouched %s", local, aheadTip) + } + if _, err := os.Stat(filepath.Join(r.invocation, ".docket")); !os.IsNotExist(err) { + t.Fatalf(".docket attached on a refusal (err=%v)", err) + } +} + +func TestIntegrationRepoInPlaceFFAttachDivergedRefuses(t *testing.T) { + r := newHealthyRepo(t) + r.advanceRemoteDocket(t, "notes/remote.txt", "remote\n", "remote side") + localTip := r.commitInDocket(t, "local.txt", "local\n", "local side") + removeDocketWorktree(t, r) + res := runPrepareAt(t, r.invocation) + if res.Disposition != PrepareDispositionRefused || !prepareFinding(res, "local-metadata-diverged") { + t.Fatalf("prepare = %q %+v, want refused local-metadata-diverged", res.Disposition, res.Findings) + } + if local := runGit(t, r.invocation, "rev-parse", "refs/heads/docket"); local != localTip { + t.Fatalf("local docket = %s, want untouched %s", local, localTip) + } + if _, err := os.Stat(filepath.Join(r.invocation, ".docket")); !os.IsNotExist(err) { + t.Fatalf(".docket attached on a refusal (err=%v)", err) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test -count=1 ./internal/app/ -run TestRepositoryPrepare` +Expected: FAIL to compile — `prepareRoute` takes one argument. + +- [ ] **Step 3: Implement** + +In `internal/app/repository_prepare.go`: + +```go +// prepareAheadFinding and prepareDivergedFinding are prepare's refusals for a local +// docket branch carrying commits the remote lacks; the worktree row and the attach +// row share them. Their texts match check's findings (TestLocalMetadataRefusalsMatchPrepare). +func prepareAheadFinding() reposetup.Finding { + return reposetup.Finding{ + Code: string(FCLocalMetadataAhead), + Severity: reposetup.SeverityError, + Message: "The local docket branch is ahead of the remote docket branch (it carries commits the remote does not).", + Remedy: "Reconcile the local docket branch with the remote manually with a human before any repository operation.", + } +} + +func prepareDivergedFinding() reposetup.Finding { + return reposetup.Finding{ + Code: string(FCLocalMetadataDiverged), + Severity: reposetup.SeverityError, + Message: "The local docket branch has diverged from the remote docket branch.", + Remedy: "Reconcile the local and remote docket branches manually with a human before any repository operation.", + } +} + +// prepareAttachRoute decides the absent-.docket row by the local docket branch's +// relationship to the pinned remote tip, so prepare never attaches a stale, ahead, +// or diverged branch as-is and then reports healthy. A behind branch moves to the +// target first (a compare-and-swap from the observed tip); a behind branch another +// worktree holds is a forced, unsupported setup and refuses with nothing touched. +func prepareAttachRoute(f reposetup.Facts, heldElsewhere reposetup.Presence) prepareVerdict { + target := f.RemoteMetadata.Tip + switch f.LocalMetadata.Presence { + case reposetup.PresenceAbsent: + return prepareApplyVerdict(prepareActionAttach, target, "") + case reposetup.PresenceUnknown: + return prepareLocalUnknownVerdict() + } + switch f.LocalMetadataSync { + case reposetup.SyncCurrent: + return prepareApplyVerdict(prepareActionAttach, target, f.LocalMetadata.Tip) + case reposetup.SyncBehind: + switch heldElsewhere { + case reposetup.PresenceAbsent: + return prepareApplyVerdict(prepareActionAttach, target, f.LocalMetadata.Tip) + case reposetup.PresencePresent: + return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ + Code: string(FCDocketWorktreeAmbiguousRegistration), + Severity: reposetup.SeverityError, + Ref: docketWorktreeName, + Message: "The local docket branch is checked out in another worktree, so it cannot be moved to the remote tip and attached at .docket.", + Remedy: "Inspect the worktree holding the docket branch and resolve it manually with a human, then run `docket repository check`.", + }) + default: + return prepareLocalUnknownVerdict() + } + case reposetup.SyncAhead: + return prepareRefuseVerdict(reposetup.StateConflict, prepareAheadFinding()) + case reposetup.SyncDiverged: + return prepareRefuseVerdict(reposetup.StateConflict, prepareDivergedFinding()) + default: + return prepareLocalUnknownVerdict() + } +} +``` + +- `prepareRoute(f reposetup.Facts, heldElsewhere reposetup.Presence)`: the `case reposetup.PresenceAbsent:` of the worktree switch becomes `return prepareAttachRoute(f, heldElsewhere)`; the worktree row's ahead/diverged cases use `prepareAheadFinding()` / `prepareDivergedFinding()` (no duplicated literals). Document `heldElsewhere` in the doc comment. +- `prepareAugment` returns `reposetup.Presence`: after `applyLocalMetadataSync`, add + +```go + if f.DocketWorktree.Presence != reposetup.PresenceAbsent || f.LocalMetadata.Presence != reposetup.PresencePresent { + return reposetup.PresenceUnknown // not consulted outside the absent-.docket attach row + } + wts, err := git.ListWorktrees(ctx, sc.repo) + if err != nil { + return reposetup.PresenceUnknown + } + for _, wt := range wts { + if wt.Branch == metaRef { + return reposetup.PresencePresent + } + } + return reposetup.PresenceAbsent +``` + +- `RunRepositoryPrepare`: `held := reposetup.PresenceUnknown; if … { held = prepareAugment(ctx, d.Git, &facts, sc) }; verdict := prepareRoute(facts, held)`. +- `prepareExecute` attach case: + +```go + case prepareActionAttach: + if err := prepareAttachFresh(ctx, git, sc.repo, worktreePath, metaRef, verdict); err != nil { + return err + } + return git.DisableWorktreeHooks(ctx, worktreePath) +``` + +```go +// prepareAttachFresh is prepare's own attach; ensureMetadataWorktree stays init's and +// migrate's, with its contract unchanged. With no local docket branch it creates one +// at the target (git refuses if a branch appeared since routing). With one, it moves +// that branch from the observed tip to the target by compare-and-swap (a no-op that +// still verifies the tip when it is already current), then attaches it. +func prepareAttachFresh(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, worktreePath string, metaRef gitcli.RefName, v prepareVerdict) error { + target := gitcli.ObjectID(v.targetRev) + if v.observedTip == "" { + return git.AddBranchWorktree(ctx, repo, worktreePath, metaRef, target) + } + if err := git.AdvanceBranchChecked(ctx, repo, metaRef, gitcli.ObjectID(v.observedTip), target); err != nil { + return err + } + return git.AttachBranchWorktree(ctx, repo, worktreePath, metaRef) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go build ./... && go test -count=1 ./internal/app/ -run 'TestRepositoryPrepare|TestLocalMetadataRefusalsMatchPrepare' && bash tests/test_go_integration_app_repoinplaceff.sh && bash tests/test_go_integration_app_repoprepare.sh && bash tests/test_go_integration_app_reposetup.sh && bash tests/test_go_integration_app_repomigration.sh` +Expected: PASS (`reposetup`/`repomigration` prove init's and migrate's attach behavior is unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/repository_prepare.go internal/app/repository_prepare_test.go internal/app/repository_check_test.go internal/app/repoinplaceff_integration_test.go +git commit -m "fix(prepare): attach .docket only at the remote tip; refuse ahead or diverged branches" +``` + +--- + +### Task 9: Split the primary-checkout finding by relationship + +**Files:** +- Modify: `internal/reposetup/probe.go` (`Facts.PrimaryTipRelation`), `internal/reposetup/health.go` (`conditionFinding` `CondPrimaryAtRemoteTip`) +- Modify: `internal/reposetup/health_test.go` +- Modify: `internal/app/repository_facts.go` (`setupContext.primaryHead`, set in `primaryAtTipPresence`) +- Modify: `internal/app/repository_check.go` (`augmentCheckFacts`) +- Modify: `internal/app/reposynccheck_integration_test.go` + +**Interfaces:** +- Consumes: `syncRelationship` (Task 5), `reposetup.SyncRelation` (Task 4). +- Produces: `Facts.PrimaryTipRelation SyncRelation`; finding codes `primary-ahead-of-remote-tip`, `primary-diverged-from-remote-tip`. + +- [ ] **Step 1: Write the failing tests** + +`internal/reposetup/health_test.go`: + +```go +// TestHealthPrimaryTipFindingByRelationship: the primary-not-at-tip finding is chosen +// by relationship; every case stays non-healthy and every remedy works in its state. +func TestHealthPrimaryTipFindingByRelationship(t *testing.T) { + rows := []struct { + rel SyncRelation + wantCode string + remedyHas string + remedyLack string + }{ + {SyncBehind, "primary-behind-remote-tip", "sync-integration", ""}, + {SyncAhead, "primary-ahead-of-remote-tip", "Push the local commits", "sync-integration"}, + {SyncDiverged, "primary-diverged-from-remote-tip", "rebase or merge", "sync-integration"}, + {SyncUnknown, "primary-tip-unverified", "", "sync-integration"}, + } + for _, row := range rows { + f := healthyFacts() + f.PrimaryAtRemoteTip = PresenceAbsent + f.PrimaryTipRelation = row.rel + c := Classify(f) + if c.State == StateHealthy { + t.Fatalf("%s: a primary off the tip classified healthy", row.wantCode) + } + got := EvaluateHealth(c, f, nil) + var fnd *Finding + for i := range got { + if strings.HasPrefix(got[i].Code, "primary-") { + fnd = &got[i] + } + } + if fnd == nil || fnd.Code != row.wantCode { + t.Fatalf("relation %d: findings %v, want %s", row.rel, findingCodes(got), row.wantCode) + } + if row.remedyHas != "" && !strings.Contains(fnd.Remedy, row.remedyHas) { + t.Errorf("%s remedy %q lacks %q", row.wantCode, fnd.Remedy, row.remedyHas) + } + if row.remedyLack != "" && strings.Contains(fnd.Remedy, row.remedyLack) { + t.Errorf("%s remedy %q must not name %q (invalid in this state)", row.wantCode, fnd.Remedy, row.remedyLack) + } + assertNoDestructiveCommand(t, fnd.Remedy) + if CheckExit(c, got) != 1 { + t.Errorf("%s: exit != 1", row.wantCode) + } + } +} +``` + +Append to `internal/app/reposynccheck_integration_test.go`: + +```go +func TestIntegrationRepoSyncCheckPrimaryAhead(t *testing.T) { + r := newHealthyRepo(t) + writeRepoFile(t, r.invocation, "local-only.txt", "unpushed\n") + runGit(t, r.invocation, "add", "--", "local-only.txt") + runGit(t, r.invocation, "commit", "-q", "-m", "unpushed") + got := checkCodes(r.runCheck(t)) + if !got["primary-ahead-of-remote-tip"] || got["primary-behind-remote-tip"] { + t.Fatalf("primary with an unpushed commit: findings %v, want primary-ahead-of-remote-tip only", got) + } +} + +func TestIntegrationRepoSyncCheckPrimaryBehind(t *testing.T) { + r := newHealthyRepo(t) + runGit(t, r.writer, "fetch", "-q", "origin", "main") + runGit(t, r.writer, "checkout", "-q", "-B", "main", "origin/main") + writeRepoFile(t, r.writer, "remote-only.txt", "remote\n") + runGit(t, r.writer, "add", "--", "remote-only.txt") + runGit(t, r.writer, "commit", "-q", "-m", "remote advance") + runGit(t, r.writer, "push", "-q", "origin", "main") + got := checkCodes(r.runCheck(t)) + if !got["primary-behind-remote-tip"] || got["primary-ahead-of-remote-tip"] { + t.Fatalf("primary behind the remote: findings %v, want primary-behind-remote-tip only", got) + } +} +``` + +(The writer clone may need `gitIdentity(t, r.writer)`; `newInitRepo` already sets it.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test -count=1 ./internal/reposetup/ -run TestHealthPrimaryTipFindingByRelationship` +Expected: FAIL to compile — `PrimaryTipRelation` undefined. + +- [ ] **Step 3: Implement** + +`internal/reposetup/probe.go` — add after `PrimaryAtRemoteTip` in `Facts`: + +```go + PrimaryTipRelation SyncRelation // primary HEAD vs pinned integration tip; set only by check's augmentation, only when PrimaryAtRemoteTip is Absent +``` + +`internal/reposetup/health.go` — replace the `CondPrimaryAtRemoteTip` case: + +```go + case CondPrimaryAtRemoteTip: + if !integrationResolved(f) { + return nil + } + if f.PrimaryAtRemoteTip == PresenceAbsent { + switch f.PrimaryTipRelation { + case SyncBehind: + return &Finding{ + Code: "primary-behind-remote-tip", + Severity: SeverityError, + Message: "The primary worktree's HEAD is behind the pinned remote integration tip.", + Remedy: "Fetch and fast-forward the integration branch (e.g. `docket repository sync-integration`), preserving local work, then re-run `docket repository check`.", + } + case SyncAhead: + return &Finding{ + Code: "primary-ahead-of-remote-tip", + Severity: SeverityError, + Message: "The primary worktree's HEAD is ahead of the pinned remote integration tip (it carries commits the remote does not).", + Remedy: "Push the local commits (or move them to a branch) yourself, preserving them, then re-run `docket repository check`.", + } + case SyncDiverged: + return &Finding{ + Code: "primary-diverged-from-remote-tip", + Severity: SeverityError, + Message: "The primary worktree's HEAD has diverged from the pinned remote integration tip.", + Remedy: "Reconcile the primary checkout with the remote integration branch yourself (rebase or merge), preserving local work, then re-run `docket repository check`.", + } + } + } + // Unproven position, or HEAD off the tip with an unproven relationship. + return &Finding{ + Code: "primary-tip-unverified", + Severity: SeverityWarning, + Message: "The primary worktree's position against the remote integration tip could not be resolved (unverified).", + Remedy: "Re-run `docket repository check` once the local HEAD read succeeds.", + } +``` + +(`conditionCategory`'s default already places `primary-*` codes in the local-worktree bucket.) + +`internal/app/repository_facts.go`: add `primaryHead string // primary worktree HEAD read by primaryAtTipPresence, else ""` to `setupContext`; in `primaryAtTipPresence`, set `sc.primaryHead = string(wt.Head)` when the primary is found (before comparing). No ancestry probe here. + +`internal/app/repository_check.go` `augmentCheckFacts`, after `f.PrimaryOnIntegration = …`: + +```go + // The primary checkout's relationship to the pinned integration tip, computed + // only where it is reported and only when HEAD is not the tip, through the same + // syncRelationship the local docket copy uses. + if f.PrimaryAtRemoteTip == reposetup.PresenceAbsent && sc.sourceRevision != "" { + f.PrimaryTipRelation = syncRelationship(ctx, git, sc.repo, sc.primaryHead, sc.sourceRevision) + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -count=1 ./internal/reposetup/ ./internal/app/ -run 'TestHealth|TestClassify|TestRepositoryCheck' && bash tests/test_go_integration_app_reposynccheck.sh && bash tests/test_go_integration_app_repocheck.sh` +Expected: PASS. Then run a whole-repo grep for `primary-behind-remote-tip` and confirm every non-history hit (outside `docs/superpowers/plans/`, `docs/results/`) is consistent with the split. + +- [ ] **Step 5: Commit** + +```bash +git add internal/reposetup/probe.go internal/reposetup/health.go internal/reposetup/health_test.go internal/app/repository_facts.go internal/app/repository_check.go internal/app/reposynccheck_integration_test.go +git commit -m "fix(check): name a primary checkout ahead of or diverged from the remote tip honestly" +``` + +--- + +### Task 10: Mutation matrix, self-audit, and budget review + +**Files:** none changed unless a mutation stays green (then fix the guard in the owning file and commit it). + +- [ ] **Step 1: Mutation matrix (spec §8).** For each row: copy the file to `${TMPDIR:-/tmp}/.bak`, apply the mutation, run the named test with `-count=1`, confirm it FAILS, restore with `cp -f` from the backup, confirm `git status --porcelain` is empty. + +| Mutation | File | Must redden | +|---|---|---| +| Put tip equality back: in `syncRelationship`, `return reposetup.SyncDiverged` right after the `localTip == remoteTip` check | `internal/app/repository_sync_relation.go` | `go test -tags integration -count=1 -run '^TestIntegrationRepoSyncCheckBehindIsHealthy$' ./internal/app/` | +| Collapse ahead into diverged: `case remoteBehind: return reposetup.SyncDiverged` | same | `-run '^TestIntegrationRepoSyncCheckAheadReportsAheadOnly$'` | +| Drop the primary split: return the `primary-behind-remote-tip` finding for every Absent case | `internal/reposetup/health.go` | `-run '^TestIntegrationRepoSyncCheckPrimaryAhead$'` and `go test -count=1 -run TestHealthPrimaryTipFindingByRelationship ./internal/reposetup/` | +| Restore remove/re-add: in `prepareExecute`'s fast-forward case, call `git.RemoveWorktreeClean`, `git.DeleteLocalBranchChecked(…, observedTip)`, `git.AddBranchWorktree(…, target)`, `git.DisableWorktreeHooks` | `internal/app/repository_prepare.go` | `-run '^TestIntegrationRepoInPlaceFF(LockedWorktree|RepositoryHooksNeverRun|IgnoredFileAndDirectoryIdentity)$'` (all three) | +| Drop the compare-and-swap: remove `string(expectedTip)` from the first `update-ref` in `FastForwardCheckedOutBranch` | `internal/gitcli/fastforward_inplace.go` | `-run '^TestIntegrationRepoInPlaceFFStaleObservedTipRefuses$' ./internal/app/` | +| Drop the forced hooks path: remove the `"-c", noHooks,` pairs | same | `-run '^TestIntegrationRepoInPlaceFFHooksOffMissing$' ./internal/app/` | +| Drop attach routing: `prepareAttachRoute` returns `prepareApplyVerdict(prepareActionAttach, target, "")` for a present branch and `prepareAttachFresh` calls `ensureMetadataWorktree` | `internal/app/repository_prepare.go` | `-run '^TestIntegrationRepoInPlaceFFAttachBehindAdvancesBranch$'` | +| Drop the unfinished-operation check: ignore `st.OperationInProgress` in `worktreeCleanState` | `internal/app/repository_check.go` | `-run '^TestIntegrationRepoSyncCheckUnfinishedMergeIsDirty$'` | + +A mutation that leaves its test green is a defect in the test: fix the test (not the mutation) and re-run the row. + +- [ ] **Step 2: Self-audit the change's own additions** (learning `fix-reintroduces-its-own-defect-class`). Grep the branch diff (`git diff origin/main...HEAD -- '*.go'`) for any NEW tip-equality comparison used as a sync predicate (`.Tip ==`, `.Tip !=`, `== remoteTip`) outside `syncRelationship`'s own current check, and for any new `IsAncestor(` call that decides a fast-forward without `IgnoringReplacements`. Each hit is either justified in a comment or fixed. + +- [ ] **Step 3: Budget review.** Run each touched shard solo and record its wall time: `time bash tests/test_go_integration_app_reposynccheck.sh`, `…_repoinplaceff.sh`, `…_repoprepare.sh`, `…_repocheck.sh`, `…_gitcli_repo.sh`. If a new shard runs over 20s solo, set its `runtime-budgets.tsv` ceiling to the measured time × 1.5 rounded up to 5 (never above 30); if over 30s, split its scenarios into a second prefix shard instead of raising the ceiling (tests/README.md: never grow past the budget and bump the number). Commit any budget change with its measured numbers in the message. + +- [ ] **Step 4: Whole-suite gate.** The build role runs `build.test_command` (`go run ./cmd/docket development test`) once at the end. Read its budget report even when green: act on any `SERIAL CONFIRMED OVER BUDGET:` line for the shards above. From 74df91e30f79a6dec9bbecab5584a3dd067d5708 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 06:57:22 -0400 Subject: [PATCH 02/17] feat(gitcli): ancestry probe that ignores replace refs and grafts --- internal/gitcli/ancestry_integration_test.go | 82 ++++++++++++++++++++ internal/gitcli/exec.go | 5 +- internal/gitcli/push.go | 28 +++++-- 3 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 internal/gitcli/ancestry_integration_test.go diff --git a/internal/gitcli/ancestry_integration_test.go b/internal/gitcli/ancestry_integration_test.go new file mode 100644 index 000000000..85de4d0ab --- /dev/null +++ b/internal/gitcli/ancestry_integration_test.go @@ -0,0 +1,82 @@ +//go:build integration + +package gitcli + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestIntegrationRepoIsAncestorIgnoringReplacements proves the relationship probe +// ignores both history-rewrite mechanisms: a replace ref and the legacy graft file +// each make an unrelated orphan commit look like a descendant to plain IsAncestor, +// and IsAncestorIgnoringReplacements must still answer false. +func TestIntegrationRepoIsAncestorIgnoringReplacements(t *testing.T) { + ctx := context.Background() + newOrphan := func(t *testing.T, r *testRepos) (base, orphan ObjectID) { + t.Helper() + base = ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + tree := gitOut(t, r.Invocation, "rev-parse", "HEAD^{tree}") + orphan = ObjectID(strings.TrimSpace(gitOut(t, r.Invocation, + "-c", "user.name=t", "-c", "user.email=t@example.com", + "commit-tree", tree, "-m", "orphan"))) + return base, orphan + } + + t.Run("replace ref", func(t *testing.T) { + r := newMainModeRepos(t) + c := newRealClient(t) + base, orphan := newOrphan(t, r) + gitOut(t, r.Invocation, "replace", "--graft", string(orphan), string(base)) + repo := Repository{PrimaryWorktree: r.Invocation} + + plain, err := c.IsAncestor(ctx, repo, base, orphan) + if err != nil || !plain { + t.Fatalf("premise: plain IsAncestor under a replace graft = %v, %v; want true, nil", plain, err) + } + got, err := c.IsAncestorIgnoringReplacements(ctx, repo, base, orphan) + if err != nil || got { + t.Fatalf("IsAncestorIgnoringReplacements = %v, %v; want false, nil (replace refs ignored)", got, err) + } + }) + + t.Run("graft file", func(t *testing.T) { + r := newMainModeRepos(t) + c := newRealClient(t) + base, orphan := newOrphan(t, r) + grafts := filepath.Join(r.Invocation, ".git", "info", "grafts") + if err := os.MkdirAll(filepath.Dir(grafts), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(grafts, []byte(string(orphan)+" "+string(base)+"\n"), 0o644); err != nil { + t.Fatal(err) + } + repo := Repository{PrimaryWorktree: r.Invocation} + + plain, err := c.IsAncestor(ctx, repo, base, orphan) + if err != nil || !plain { + t.Fatalf("premise: plain IsAncestor under a graft file = %v, %v; want true, nil", plain, err) + } + got, err := c.IsAncestorIgnoringReplacements(ctx, repo, base, orphan) + if err != nil || got { + t.Fatalf("IsAncestorIgnoringReplacements = %v, %v; want false, nil (graft file ignored)", got, err) + } + }) + + t.Run("real ancestry still true", func(t *testing.T) { + r := newMainModeRepos(t) + c := newRealClient(t) + base := ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + writeWorktreeFile(t, r.Invocation, "next.txt", "next\n") + gitOut(t, r.Invocation, "add", "--", "next.txt") + gitOut(t, r.Invocation, "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "next") + next := ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + got, err := c.IsAncestorIgnoringReplacements(ctx, Repository{PrimaryWorktree: r.Invocation}, base, next) + if err != nil || !got { + t.Fatalf("real ancestry = %v, %v; want true, nil", got, err) + } + }) +} diff --git a/internal/gitcli/exec.go b/internal/gitcli/exec.go index 3e16e4ec3..5a90d93f0 100644 --- a/internal/gitcli/exec.go +++ b/internal/gitcli/exec.go @@ -36,8 +36,9 @@ type runRequest struct { // env is appended to the client's sanitized base environment for this one // command; a duplicate name overrides the base value (last wins). It is the // only per-command environment channel — used for the engine-clock commit - // dates (GIT_AUTHOR_DATE / GIT_COMMITTER_DATE) — and never carries repository - // redirection, config injection, or credentials. + // dates (GIT_AUTHOR_DATE / GIT_COMMITTER_DATE) and for the ancestry probe's + // graft-disabling GIT_GRAFT_FILE= (IsAncestorIgnoringReplacements) — and never + // carries repository redirection, config injection, or credentials. env []string } diff --git a/internal/gitcli/push.go b/internal/gitcli/push.go index 5af07bfc0..b9fd0777e 100644 --- a/internal/gitcli/push.go +++ b/internal/gitcli/push.go @@ -172,17 +172,35 @@ func (c *Client) PushCreateLease(ctx context.Context, repo Repository, remote Re // `git merge-base --is-ancestor `: exit 0 is true, exit 1 // is false, and any other exit is a typed command-failed *Failure. func (c *Client) IsAncestor(ctx context.Context, repo Repository, ancestor, descendant ObjectID) (bool, error) { + return c.isAncestorIn(ctx, repo.PrimaryWorktree, ancestor, descendant, false) +} + +// IsAncestorIgnoringReplacements is IsAncestor with Git's history rewrites switched +// off: replace refs are ignored (`--no-replace-objects`) and so is the legacy graft +// file (GIT_GRAFT_FILE set to the empty string, which Git opens as no file). A +// relationship probe that decides whether a fast-forward is safe uses it, so a +// replace ref or graft can never fake "behind" and strand local-only commits. +// Other callers keep IsAncestor's behavior. +func (c *Client) IsAncestorIgnoringReplacements(ctx context.Context, repo Repository, ancestor, descendant ObjectID) (bool, error) { + return c.isAncestorIn(ctx, repo.PrimaryWorktree, ancestor, descendant, true) +} + +// isAncestorIn runs `git merge-base --is-ancestor` in dir: exit 0 is true, exit 1 +// is false, and any other exit is a typed command-failed *Failure. +func (c *Client) isAncestorIn(ctx context.Context, dir string, ancestor, descendant ObjectID, ignoreReplacements bool) (bool, error) { if err := validateObjectID(ancestor); err != nil { return false, newFailure(isAncestorOp, KindInvalidRequest, "invalid ancestor id", err) } if err := validateObjectID(descendant); err != nil { return false, newFailure(isAncestorOp, KindInvalidRequest, "invalid descendant id", err) } - res, f := c.run(ctx, runRequest{ - op: isAncestorOp, - dir: repo.PrimaryWorktree, - args: []string{"merge-base", "--is-ancestor", string(ancestor), string(descendant)}, - }) + args := []string{"merge-base", "--is-ancestor", string(ancestor), string(descendant)} + var env []string + if ignoreReplacements { + args = append([]string{"--no-replace-objects"}, args...) + env = []string{"GIT_GRAFT_FILE="} + } + res, f := c.run(ctx, runRequest{op: isAncestorOp, dir: dir, args: args, env: env}) if f != nil { return false, f } From 0c56865d5a39af1ccde3e21f817609252c84ac2e Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 07:49:07 -0400 Subject: [PATCH 03/17] docs(plan): change 0523 accepts Git's ignored-file checkout rule in the in-place fast-forward --- ...rts-a-behind-only-docket-copy-as-diverg.md | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg.md b/docs/superpowers/plans/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg.md index 04d51ad74..76b7dd45a 100644 --- a/docs/superpowers/plans/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg.md +++ b/docs/superpowers/plans/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg.md @@ -31,7 +31,7 @@ ## Review Focus -1. **An ignored file sitting at a path the target newly tracks.** A person expects nothing lost. `read-tree -u -m` without `--exclude-per-directory` refuses to overwrite it, so the fast-forward refuses and rolls the branch back. (This is stricter than plain checkout's "ignored files are expendable" rule and satisfies "refuse rather than discard".) Pinned in Task 2, subtest "ignored file at a newly tracked path refuses and is kept". +1. **An ignored file sitting at a path the target newly tracks.** This follows Git's normal checkout rule, as spec §3 property 4 allows ("Ignored files survive, except at a path the target itself tracks, which is Git's normal checkout rule"): ignored files are expendable, so modern Git's `read-tree -u -m` (verified on git 2.55.0, like `merge --ff-only` and `checkout`) overwrites the file with the target's version and the fast-forward succeeds. Docket adds no stricter refusal (decided 2026-10-05 after the Task 2 halt). An ignored file at any path the target does not track survives, and an untracked, non-ignored file in the way still refuses. Pinned in Task 2, subtest "ignored file at a newly tracked path follows Git's checkout rule". 2. **Stale index stat info** (a tracked file touched but unchanged, e.g. by an editor or `touch`). A person expects the fast-forward to still work. Pinned in Task 2, subtest "stale stat info on a changed path still fast-forwards" (the `update-index --refresh` step is load-bearing). 3. **An interrupted fast-forward** (branch moved, tree not yet updated). A person expects `check` to say dirty and `prepare` to refuse, never to report healthy and never to need `git worktree prune`. Pinned in Task 7, `TestIntegrationRepoInPlaceFFInterruptedReadsDirty`. 4. **A replace ref or graft that fakes ancestry.** A person expects local-only commits never to be fast-forwarded over. Pinned in Task 1 (both replace refs and the graft file). @@ -249,7 +249,7 @@ git commit -m "feat(gitcli): ancestry probe that ignores replace refs and grafts - `func (c *Client) emptyHooksDir(ctx context.Context, op Operation, worktreeDir string) (string, *Failure)` — resolves and creates `/docket/empty-hooks`, returns its absolute path. - `func (c *Client) FastForwardCheckedOutBranch(ctx context.Context, worktreeDir string, branch RefName, expectedTip, target ObjectID) error` — Task 7 calls it with the `.docket` path, `refs/heads/docket`, the router's observed tip, and the pinned remote tip. -Mechanism (decide it here, not later): (1) refuse unless `symbolic-ref HEAD` in the worktree is exactly `branch`; (2) refuse unless `target` descends from `expectedTip` (replace refs and grafts ignored); (3) compare-and-swap the branch ref `expectedTip → target` with `update-ref ` — if the branch moved, nothing changes; (4) `update-index -q --refresh` (exit 0 or 1 accepted); (5) `read-tree -u -m `, which updates index and files from the old tree to the new one and refuses on local changes or untracked/ignored files in the way; (6) on any read-tree refusal, compare-and-swap the branch back `target → expectedTip`. Every command carries `-c core.hooksPath=`, which also silences `reference-transaction` and `post-index-change`. An interruption between (3) and (5) leaves HEAD at target with the old index: `git status` reports staged changes, so it reads dirty — never clean, never a missing worktree. +Mechanism (decide it here, not later): (1) refuse unless `symbolic-ref HEAD` in the worktree is exactly `branch`; (2) refuse unless `target` descends from `expectedTip` (replace refs and grafts ignored); (3) compare-and-swap the branch ref `expectedTip → target` with `update-ref ` — if the branch moved, nothing changes; (4) `update-index -q --refresh` (exit 0 or 1 accepted); (5) `read-tree -u -m `, which updates index and files from the old tree to the new one and refuses on local changes or untracked (non-ignored) files in the way — an ignored file at a path the target tracks is overwritten, Git's normal checkout rule (Review Focus 1); (6) on any read-tree refusal, compare-and-swap the branch back `target → expectedTip`. Every command carries `-c core.hooksPath=`, which also silences `reference-transaction` and `post-index-change`. An interruption between (3) and (5) leaves HEAD at target with the old index: `git status` reports staged changes, so it reads dirty — never clean, never a missing worktree. - [ ] **Step 1: Write the failing tests** @@ -359,19 +359,21 @@ func TestIntegrationRepoFastForwardCheckedOutBranch(t *testing.T) { } }) - t.Run("ignored file at a newly tracked path refuses and is kept", func(t *testing.T) { + // Git's normal checkout rule: an ignored file at a path the target tracks is + // expendable, so it is replaced by the target's version (spec §3 property 4). + t.Run("ignored file at a newly tracked path follows Git's checkout rule", func(t *testing.T) { r, wt, base, target := newCheckedOutBranchFixture(t) c := newRealClient(t) appendExclude(t, r.Invocation, "target.txt") writeWorktreeFile(t, wt, "target.txt", "mine\n") - if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err == nil { - t.Fatal("fast-forward over an ignored file at a newly tracked path succeeded; want refusal") + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err != nil { + t.Fatalf("FastForwardCheckedOutBranch: %v", err) } - if got := metaTip(t, r); got != base { - t.Fatalf("meta = %s after refusal, want rolled back to %s", got, base) + if got := metaTip(t, r); got != target { + t.Fatalf("meta = %s, want %s", got, target) } - if b, _ := os.ReadFile(filepath.Join(wt, "target.txt")); string(b) != "mine\n" { - t.Fatalf("ignored file overwritten: %q", b) + if b, _ := os.ReadFile(filepath.Join(wt, "target.txt")); string(b) == "mine\n" { + t.Fatalf("target.txt still holds the ignored content; want the target's tracked version") } }) @@ -573,8 +575,9 @@ const fastForwardReflogMessage = "docket: fast-forward" // expectedTip, so a commit made since the caller observed expectedTip makes it // refuse with nothing changed. target must descend from expectedTip, judged with // replace refs and grafts ignored. The tree moves with `read-tree -u -m`, which -// refuses rather than overwrite a local change or an untracked or ignored file in -// the way; on that refusal the branch is swapped back. Every command forces docket's +// refuses rather than overwrite a local change or an untracked file in the way; on +// that refusal the branch is swapped back. An ignored file at a path target tracks +// is overwritten, Git's normal checkout rule. Every command forces docket's // empty hooks dir, so no repository hook runs even when the worktree's own hooks-off // setting is missing. An interruption after the ref swap leaves HEAD at target with // the old index, which `git status` reports as staged changes (dirty), never clean. @@ -662,7 +665,7 @@ If `*Failure` has no `Error()` method with that exact spelling, use the existing - [ ] **Step 5: Run tests to verify they pass** Run: `go test -tags integration -count=1 -run '^TestIntegrationRepoFastForwardCheckedOutBranch$' ./internal/gitcli/ && go test -count=1 ./internal/gitcli/` -Expected: PASS. If the "ignored file at a newly tracked path" subtest shows Git overwriting the ignored file instead of refusing, STOP and report BLOCKED with the Git output: Review Focus line 1 depends on the refusal. +Expected: PASS. - [ ] **Step 6: Mutation-check the keys** From b28f960c6d4d6f9cb5380046d0f44ea2531aabac Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 07:59:23 -0400 Subject: [PATCH 04/17] feat(gitcli): in-place compare-and-swap fast-forward of a checked-out branch --- internal/gitcli/fastforward_inplace.go | 110 ++++++++ .../fastforward_inplace_integration_test.go | 241 ++++++++++++++++++ internal/gitcli/hooksoff.go | 61 +++-- 3 files changed, 386 insertions(+), 26 deletions(-) create mode 100644 internal/gitcli/fastforward_inplace.go create mode 100644 internal/gitcli/fastforward_inplace_integration_test.go diff --git a/internal/gitcli/fastforward_inplace.go b/internal/gitcli/fastforward_inplace.go new file mode 100644 index 000000000..3cd082467 --- /dev/null +++ b/internal/gitcli/fastforward_inplace.go @@ -0,0 +1,110 @@ +package gitcli + +import ( + "context" + "path/filepath" + "strings" +) + +const fastForwardCheckedOutOp Operation = "fast-forward-checked-out-branch" + +// fastForwardReflogMessage names the in-place fast-forward in the branch reflog. +const fastForwardReflogMessage = "docket: fast-forward" + +// FastForwardCheckedOutBranch advances branch — which must be the branch checked out +// at worktreeDir — from expectedTip to target IN PLACE. Nothing is removed: the +// worktree directory, its admin dir (registration, per-worktree config, lock), the +// branch, and its reflog all survive. The branch moves by compare-and-swap from +// expectedTip, so a commit made since the caller observed expectedTip makes it +// refuse with nothing changed. target must descend from expectedTip, judged with +// replace refs and grafts ignored. The tree moves with `read-tree -u -m`, which +// refuses rather than overwrite a local change or an untracked file in the way; on +// that refusal the branch is swapped back. An ignored file at a path target tracks +// is overwritten, Git's normal checkout rule. Every command forces docket's +// empty hooks dir, so no repository hook runs even when the worktree's own hooks-off +// setting is missing. An interruption after the ref swap leaves HEAD at target with +// the old index, which `git status` reports as staged changes (dirty), never clean. +func (c *Client) FastForwardCheckedOutBranch(ctx context.Context, worktreeDir string, branch RefName, expectedTip, target ObjectID) error { + op := fastForwardCheckedOutOp + if !filepath.IsAbs(worktreeDir) { + return newFailure(op, KindInvalidRequest, "worktree path must be absolute", nil) + } + if err := validateRefName(branch); err != nil { + return newFailure(op, KindInvalidRequest, "invalid branch ref", err) + } + if _, ok := branchShortName(branch); !ok { + return newFailure(op, KindInvalidRequest, "branch must be fully qualified refs/heads/", nil) + } + if err := validateObjectID(expectedTip); err != nil { + return newFailure(op, KindInvalidRequest, "invalid expected tip id", err) + } + if err := validateObjectID(target); err != nil { + return newFailure(op, KindInvalidRequest, "invalid target id", err) + } + + sym, f := c.run(ctx, runRequest{op: op, dir: worktreeDir, args: []string{"symbolic-ref", "--quiet", "HEAD"}}) + if f != nil { + return f + } + if sym.exitCode != 0 || RefName(strings.TrimSpace(string(sym.stdout))) != branch { + return newFailure(op, KindInvalidRepository, "the worktree does not have the branch checked out", nil) + } + + descends, err := c.isAncestorIn(ctx, worktreeDir, expectedTip, target, true) + if err != nil { + return err + } + if !descends { + return newFailure(op, KindInvalidRepository, "the target does not descend from the expected tip", nil) + } + + hooks, f := c.emptyHooksDir(ctx, op, worktreeDir) + if f != nil { + return f + } + noHooks := "core.hooksPath=" + hooks + + cas, f := c.run(ctx, runRequest{op: op, dir: worktreeDir, + args: []string{"-c", noHooks, "update-ref", "-m", fastForwardReflogMessage, string(branch), string(target), string(expectedTip)}}) + if f != nil { + return f + } + if cas.exitCode != 0 { + return newFailure(op, KindInvalidRepository, "the branch is no longer at the expected tip; nothing changed: "+stderrExcerpt(cas.stderr), nil).withExitCode(cas.exitCode) + } + + // Refresh stale stat info first: read-tree judges a tracked file whose stat + // differs from the index as modified, and would refuse over an untouched file. + // Exit 1 (some entries need updating) is accepted; read-tree still refuses a + // real local change. + refresh, f := c.run(ctx, runRequest{op: op, dir: worktreeDir, args: []string{"-c", noHooks, "update-index", "-q", "--refresh"}}) + if f == nil && refresh.exitCode > 1 { + f = newFailure(op, KindCommandFailed, "update-index --refresh failed: "+stderrExcerpt(refresh.stderr), nil).withExitCode(refresh.exitCode) + } + if f == nil { + rt, rf := c.run(ctx, runRequest{op: op, dir: worktreeDir, + args: []string{"-c", noHooks, "read-tree", "-u", "-m", string(expectedTip), string(target)}}) + switch { + case rf != nil: + f = rf + case rt.exitCode != 0: + f = newFailure(op, KindInvalidRepository, "the working tree update was refused: "+stderrExcerpt(rt.stderr), nil).withExitCode(rt.exitCode) + default: + return nil + } + } + + // The tree update did not complete: swap the branch back so nothing changed. The + // swap-back detaches from the caller's cancellation (it stays bounded by the + // per-command timeout), so a cancelled or timed-out caller still gets the branch + // restored rather than left ahead of its index. + back, bf := c.run(context.WithoutCancel(ctx), runRequest{op: op, dir: worktreeDir, + args: []string{"-c", noHooks, "update-ref", "-m", fastForwardReflogMessage + " (rolled back)", string(branch), string(expectedTip), string(target)}}) + if bf != nil { + return newFailure(op, KindCommandFailed, f.Error()+"; rolling the branch back also failed: "+bf.Error(), nil) + } + if back.exitCode != 0 { + return newFailure(op, KindCommandFailed, f.Error()+"; rolling the branch back also failed: "+stderrExcerpt(back.stderr), nil).withExitCode(back.exitCode) + } + return f +} diff --git a/internal/gitcli/fastforward_inplace_integration_test.go b/internal/gitcli/fastforward_inplace_integration_test.go new file mode 100644 index 000000000..fb472ebfe --- /dev/null +++ b/internal/gitcli/fastforward_inplace_integration_test.go @@ -0,0 +1,241 @@ +//go:build integration + +package gitcli + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/danielhanold/docket/internal/testsupport" +) + +const ffIdent = "user.email=t@example.com" + +// newCheckedOutBranchFixture builds an invocation clone whose branch "meta" sits at +// base and is checked out in a linked worktree wt, plus one later commit on main +// (target) that descends from base, rewrites README.md, and adds target.txt. +func newCheckedOutBranchFixture(t *testing.T) (r *testRepos, wt string, base, target ObjectID) { + t.Helper() + r = newMainModeRepos(t) + base = ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + wt = filepath.Join(testsupport.TempDir(t), "meta-wt") + gitOut(t, r.Invocation, "worktree", "add", "-q", "-b", "meta", wt, string(base)) + writeWorktreeFile(t, r.Invocation, "README.md", "readme v2\n") + writeWorktreeFile(t, r.Invocation, "target.txt", "target\n") + gitOut(t, r.Invocation, "add", "--", "README.md", "target.txt") + gitOut(t, r.Invocation, "-c", "user.name=t", "-c", ffIdent, "commit", "-q", "-m", "target") + target = ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + return r, wt, base, target +} + +func metaTip(t *testing.T, r *testRepos) ObjectID { + t.Helper() + return ObjectID(gitOut(t, r.Invocation, "rev-parse", "refs/heads/meta")) +} + +// installHooks writes the named hooks into a fresh dir; each appends its own name to +// sentinel and exits with code. The dir is returned for core.hooksPath. +func installHooks(t *testing.T, sentinel string, code int, names ...string) string { + t.Helper() + dir := filepath.Join(testsupport.TempDir(t), "hooks") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, n := range names { + body := "#!/bin/sh\necho " + n + " >> '" + sentinel + "'\nexit " + strconv.Itoa(code) + "\n" + if err := os.WriteFile(filepath.Join(dir, n), []byte(body), 0o755); err != nil { + t.Fatal(err) + } + } + return dir +} + +func TestIntegrationRepoFastForwardCheckedOutBranch(t *testing.T) { + ctx := context.Background() + + t.Run("advances branch, index, and files in place", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + before, err := os.Stat(wt) + if err != nil { + t.Fatal(err) + } + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err != nil { + t.Fatalf("FastForwardCheckedOutBranch: %v", err) + } + if got := metaTip(t, r); got != target { + t.Fatalf("meta = %s, want %s", got, target) + } + if got := ObjectID(gitOut(t, wt, "rev-parse", "HEAD")); got != target { + t.Fatalf("worktree HEAD = %s, want %s", got, target) + } + if s := gitOut(t, wt, "status", "--porcelain", "--untracked-files=all"); s != "" { + t.Fatalf("worktree not clean after fast-forward:\n%s", s) + } + if b, _ := os.ReadFile(filepath.Join(wt, "target.txt")); string(b) != "target\n" { + t.Fatalf("target.txt = %q, want the target content", b) + } + after, err := os.Stat(wt) + if err != nil || !os.SameFile(before, after) { + t.Fatalf("worktree directory was replaced (err=%v)", err) + } + if log := gitOut(t, r.Invocation, "reflog", "show", "--format=%gs", "refs/heads/meta"); !strings.Contains(log, "docket: fast-forward") { + t.Fatalf("branch reflog lacks the fast-forward entry:\n%s", log) + } + }) + + t.Run("ignored file at an untouched path survives", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + appendExclude(t, r.Invocation, "*.local") + writeWorktreeFile(t, wt, "notes.local", "keep me\n") + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err != nil { + t.Fatalf("FastForwardCheckedOutBranch: %v", err) + } + if b, err := os.ReadFile(filepath.Join(wt, "notes.local")); err != nil || string(b) != "keep me\n" { + t.Fatalf("ignored file lost: %q, %v", b, err) + } + }) + + // Git's normal checkout rule: an ignored file at a path the target tracks is + // expendable, so it is replaced by the target's version (spec §3 property 4). + t.Run("ignored file at a newly tracked path follows Git's checkout rule", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + appendExclude(t, r.Invocation, "target.txt") + writeWorktreeFile(t, wt, "target.txt", "mine\n") + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err != nil { + t.Fatalf("FastForwardCheckedOutBranch: %v", err) + } + if got := metaTip(t, r); got != target { + t.Fatalf("meta = %s, want %s", got, target) + } + if b, _ := os.ReadFile(filepath.Join(wt, "target.txt")); string(b) != "target\n" { + t.Fatalf("target.txt = %q; want the target's tracked version", b) + } + }) + + t.Run("untracked file in the way refuses and rolls back", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + writeWorktreeFile(t, wt, "target.txt", "mine\n") + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err == nil { + t.Fatal("fast-forward over an untracked file succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s after refusal, want rolled back to %s", got, base) + } + if got := ObjectID(gitOut(t, wt, "rev-parse", "HEAD")); got != base { + t.Fatalf("worktree HEAD = %s after refusal, want %s", got, base) + } + if b, _ := os.ReadFile(filepath.Join(wt, "target.txt")); string(b) != "mine\n" { + t.Fatalf("untracked file changed: %q", b) + } + }) + + t.Run("stale stat info on a changed path still fast-forwards", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + future := time.Now().Add(2 * time.Hour) + if err := os.Chtimes(filepath.Join(wt, "README.md"), future, future); err != nil { + t.Fatal(err) + } + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err != nil { + t.Fatalf("FastForwardCheckedOutBranch with stale stat: %v", err) + } + if got := metaTip(t, r); got != target { + t.Fatalf("meta = %s, want %s", got, target) + } + }) + + t.Run("locked worktree fast-forwards and stays locked", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + gitOut(t, r.Invocation, "worktree", "lock", wt) + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err != nil { + t.Fatalf("FastForwardCheckedOutBranch on a locked worktree: %v", err) + } + if list := gitOut(t, r.Invocation, "worktree", "list", "--porcelain"); !strings.Contains(list, "\nlocked") { + t.Fatalf("lock lost:\n%s", list) + } + }) + + t.Run("repository hooks never run", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + sentinel := filepath.Join(testsupport.TempDir(t), "hook-ran") + hooks := installHooks(t, sentinel, 1, "reference-transaction", "post-index-change", "post-checkout", "post-merge") + gitOut(t, r.Invocation, "config", "core.hooksPath", hooks) + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err != nil { + t.Fatalf("FastForwardCheckedOutBranch with failing repo hooks: %v", err) + } + if b, err := os.ReadFile(sentinel); err == nil { + t.Fatalf("a repository hook ran: %q", b) + } + }) + + t.Run("stale expected tip refuses and changes nothing", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + writeWorktreeFile(t, wt, "local-only.txt", "local\n") + gitOut(t, wt, "add", "--", "local-only.txt") + gitOut(t, wt, "-c", "user.name=t", "-c", ffIdent, "commit", "-q", "-m", "local-only") + moved := metaTip(t, r) + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err == nil { + t.Fatal("fast-forward from a stale expected tip succeeded; want refusal") + } + if got := metaTip(t, r); got != moved { + t.Fatalf("meta = %s, want the local commit %s kept", got, moved) + } + if _, err := os.Stat(filepath.Join(wt, "local-only.txt")); err != nil { + t.Fatalf("local commit's file gone: %v", err) + } + }) + + t.Run("target not descending refuses", func(t *testing.T) { + r, wt, base, _ := newCheckedOutBranchFixture(t) + c := newRealClient(t) + tree := gitOut(t, r.Invocation, "rev-parse", "HEAD^{tree}") + orphan := ObjectID(gitOut(t, r.Invocation, "-c", "user.name=t", "-c", ffIdent, "commit-tree", tree, "-m", "orphan")) + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, orphan); err == nil { + t.Fatal("fast-forward to a non-descendant succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s, want untouched %s", got, base) + } + }) + + t.Run("HEAD not on the branch refuses", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + gitOut(t, wt, "checkout", "-q", "--detach") + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err == nil { + t.Fatal("fast-forward with a detached HEAD succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s, want untouched %s", got, base) + } + }) +} + +// appendExclude adds a pattern to the repository's shared info/exclude. +func appendExclude(t *testing.T, repoDir, pattern string) { + t.Helper() + p := filepath.Join(repoDir, ".git", "info", "exclude") + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if _, err := f.WriteString(pattern + "\n"); err != nil { + t.Fatal(err) + } +} diff --git a/internal/gitcli/hooksoff.go b/internal/gitcli/hooksoff.go index f9fc2f197..ef63f6f4a 100644 --- a/internal/gitcli/hooksoff.go +++ b/internal/gitcli/hooksoff.go @@ -32,35 +32,10 @@ func (c *Client) DisableWorktreeHooks(ctx context.Context, worktreeDir string) e return newFailure(disableHooksOp, KindInvalidRequest, "worktree dir not found", err) } - // Resolve the common git dir relative to the worktree, then canonicalize it — - // the script's `cd "$WT" && cd "$(git rev-parse --git-common-dir)" && pwd -P`. - commonRes, f := c.run(ctx, runRequest{ - op: disableHooksOp, - dir: worktreeDir, - args: []string{"rev-parse", "--git-common-dir"}, - }) + empty, f := c.emptyHooksDir(ctx, disableHooksOp, worktreeDir) if f != nil { return f } - if commonRes.exitCode != 0 { - return newFailure(disableHooksOp, KindCommandFailed, "cannot resolve git common dir: "+stderrExcerpt(commonRes.stderr), nil).withExitCode(commonRes.exitCode) - } - commonLines := stdoutLines(commonRes.stdout) - if len(commonLines) != 1 { - return newFailure(disableHooksOp, KindInvalidOutput, "unexpected git-common-dir output", nil) - } - common, err := resolveGitPath(commonLines[0], worktreeDir) - if err != nil { - return newFailure(disableHooksOp, KindInvalidOutput, "cannot canonicalize git common dir", err) - } - - // Absolute, empty, docket-owned hooks dir under the common git dir — never - // tracked, never leaks into a commit, and a real (empty) dir avoids - // "hooksPath does not exist" surprises. - empty := filepath.Join(common, "docket", "empty-hooks") - if err := os.MkdirAll(empty, 0o755); err != nil { - return newFailure(disableHooksOp, KindCommandFailed, "cannot create empty hooks dir", err) - } if err := c.ensureWorktreeConfig(ctx, worktreeDir); err != nil { return err @@ -83,6 +58,40 @@ func (c *Client) DisableWorktreeHooks(ctx context.Context, worktreeDir string) e return nil } +// emptyHooksDir resolves the worktree's common git dir and returns docket's +// absolute, empty hooks directory under it (/docket/empty-hooks), creating +// it when absent. It is the one place that directory is named: DisableWorktreeHooks +// points a worktree's per-worktree core.hooksPath at it, and the in-place +// fast-forward primitives force it per command with `-c core.hooksPath=`. +// +// The common dir is resolved relative to the worktree, then canonicalized — the +// script's `cd "$WT" && cd "$(git rev-parse --git-common-dir)" && pwd -P`. The +// directory is absolute, empty, and docket-owned under the common git dir: never +// tracked, never leaks into a commit, and a real (empty) dir avoids "hooksPath does +// not exist" surprises. +func (c *Client) emptyHooksDir(ctx context.Context, op Operation, worktreeDir string) (string, *Failure) { + commonRes, f := c.run(ctx, runRequest{op: op, dir: worktreeDir, args: []string{"rev-parse", "--git-common-dir"}}) + if f != nil { + return "", f + } + if commonRes.exitCode != 0 { + return "", newFailure(op, KindCommandFailed, "cannot resolve git common dir: "+stderrExcerpt(commonRes.stderr), nil).withExitCode(commonRes.exitCode) + } + commonLines := stdoutLines(commonRes.stdout) + if len(commonLines) != 1 { + return "", newFailure(op, KindInvalidOutput, "unexpected git-common-dir output", nil) + } + common, err := resolveGitPath(commonLines[0], worktreeDir) + if err != nil { + return "", newFailure(op, KindInvalidOutput, "cannot canonicalize git common dir", err) + } + empty := filepath.Join(common, "docket", "empty-hooks") + if err := os.MkdirAll(empty, 0o755); err != nil { + return "", newFailure(op, KindCommandFailed, "cannot create empty hooks dir", err) + } + return empty, nil +} + // ensureWorktreeConfig enables extensions.worktreeConfig when it is not already // on, relocating any per-worktree-sensitive value out of the common config // first, and rolling the enable back if a needed relocation cannot be done From 3fe458d4b15fd04c2b743e2ad3e22cf156ee21ba Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 08:01:39 -0400 Subject: [PATCH 05/17] feat(gitcli): compare-and-swap advance of an unattached branch --- internal/gitcli/refadvance.go | 57 ++++++++++ .../gitcli/refadvance_integration_test.go | 103 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 internal/gitcli/refadvance.go create mode 100644 internal/gitcli/refadvance_integration_test.go diff --git a/internal/gitcli/refadvance.go b/internal/gitcli/refadvance.go new file mode 100644 index 000000000..ad78ebaa6 --- /dev/null +++ b/internal/gitcli/refadvance.go @@ -0,0 +1,57 @@ +package gitcli + +import "context" + +const advanceBranchOp Operation = "advance-branch" + +// AdvanceBranchChecked moves a branch that no worktree has checked out from +// expectedTip to target by compare-and-swap (`update-ref +// `), with repository hooks forced off. target must descend from +// expectedTip, judged with replace refs and grafts ignored. A branch some worktree +// holds is refused (moving it would strand that worktree's index), and a branch no +// longer at expectedTip is refused with nothing changed. expectedTip == target is a +// CAS-verified no-op. branch must be a fully qualified refs/heads/. +func (c *Client) AdvanceBranchChecked(ctx context.Context, repo Repository, branch RefName, expectedTip, target ObjectID) error { + op := advanceBranchOp + if err := validateRefName(branch); err != nil { + return newFailure(op, KindInvalidRequest, "invalid branch ref", err) + } + if _, ok := branchShortName(branch); !ok { + return newFailure(op, KindInvalidRequest, "branch must be fully qualified refs/heads/", nil) + } + if err := validateObjectID(expectedTip); err != nil { + return newFailure(op, KindInvalidRequest, "invalid expected tip id", err) + } + if err := validateObjectID(target); err != nil { + return newFailure(op, KindInvalidRequest, "invalid target id", err) + } + descends, err := c.isAncestorIn(ctx, repo.PrimaryWorktree, expectedTip, target, true) + if err != nil { + return err + } + if !descends { + return newFailure(op, KindInvalidRepository, "the target does not descend from the expected tip", nil) + } + infos, err := c.ListWorktrees(ctx, repo) + if err != nil { + return err + } + for _, wi := range infos { + if wi.Branch == branch { + return newFailure(op, KindInvalidRepository, "the branch is checked out in a worktree", nil) + } + } + hooks, f := c.emptyHooksDir(ctx, op, repo.PrimaryWorktree) + if f != nil { + return f + } + res, f := c.run(ctx, runRequest{op: op, dir: repo.PrimaryWorktree, + args: []string{"-c", "core.hooksPath=" + hooks, "update-ref", "-m", fastForwardReflogMessage, string(branch), string(target), string(expectedTip)}}) + if f != nil { + return f + } + if res.exitCode != 0 { + return newFailure(op, KindInvalidRepository, "the branch is no longer at the expected tip; nothing changed: "+stderrExcerpt(res.stderr), nil).withExitCode(res.exitCode) + } + return nil +} diff --git a/internal/gitcli/refadvance_integration_test.go b/internal/gitcli/refadvance_integration_test.go new file mode 100644 index 000000000..ac9bb18ef --- /dev/null +++ b/internal/gitcli/refadvance_integration_test.go @@ -0,0 +1,103 @@ +//go:build integration + +package gitcli + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// newUnattachedBranchFixture: branch "meta" at base (checked out nowhere) and a +// later commit on main (target) descending from base. +func newUnattachedBranchFixture(t *testing.T) (r *testRepos, base, target ObjectID) { + t.Helper() + r = newMainModeRepos(t) + base = ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + gitOut(t, r.Invocation, "branch", "meta", string(base)) + writeWorktreeFile(t, r.Invocation, "target.txt", "target\n") + gitOut(t, r.Invocation, "add", "--", "target.txt") + gitOut(t, r.Invocation, "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "target") + target = ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) + return r, base, target +} + +func TestIntegrationRepoAdvanceBranchChecked(t *testing.T) { + ctx := context.Background() + + t.Run("advances an unattached branch", func(t *testing.T) { + r, base, target := newUnattachedBranchFixture(t) + c := newRealClient(t) + if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, target); err != nil { + t.Fatalf("AdvanceBranchChecked: %v", err) + } + if got := metaTip(t, r); got != target { + t.Fatalf("meta = %s, want %s", got, target) + } + }) + + t.Run("equal tips are a verified no-op", func(t *testing.T) { + r, base, _ := newUnattachedBranchFixture(t) + c := newRealClient(t) + if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, base); err != nil { + t.Fatalf("AdvanceBranchChecked equal tips: %v", err) + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s, want %s", got, base) + } + }) + + t.Run("stale expected tip refuses", func(t *testing.T) { + r, base, target := newUnattachedBranchFixture(t) + c := newRealClient(t) + if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", target, target); err == nil { + t.Fatal("advance from a stale expected tip succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s, want untouched %s", got, base) + } + }) + + t.Run("branch checked out in a worktree refuses", func(t *testing.T) { + r, base, target := newUnattachedBranchFixture(t) + c := newRealClient(t) + wt := filepath.Join(testsupport.TempDir(t), "holder") + gitOut(t, r.Invocation, "worktree", "add", "-q", wt, "meta") + if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, target); err == nil { + t.Fatal("advance of a checked-out branch succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s, want untouched %s", got, base) + } + }) + + t.Run("target not descending refuses", func(t *testing.T) { + r, base, _ := newUnattachedBranchFixture(t) + c := newRealClient(t) + tree := gitOut(t, r.Invocation, "rev-parse", "HEAD^{tree}") + orphan := ObjectID(gitOut(t, r.Invocation, "-c", "user.name=t", "-c", "user.email=t@example.com", "commit-tree", tree, "-m", "orphan")) + if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, orphan); err == nil { + t.Fatal("advance to a non-descendant succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s, want untouched %s", got, base) + } + }) + + t.Run("repository hooks never run", func(t *testing.T) { + r, base, target := newUnattachedBranchFixture(t) + c := newRealClient(t) + sentinel := filepath.Join(testsupport.TempDir(t), "hook-ran") + hooks := installHooks(t, sentinel, 1, "reference-transaction") + gitOut(t, r.Invocation, "config", "core.hooksPath", hooks) + if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, target); err != nil { + t.Fatalf("AdvanceBranchChecked with a failing reference-transaction hook: %v", err) + } + if b, err := os.ReadFile(sentinel); err == nil { + t.Fatalf("a repository hook ran: %q", b) + } + }) +} From 2801aca4204d150c972067e65b4a98c8f9b56555 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 08:03:39 -0400 Subject: [PATCH 06/17] fix(reposetup): key local-copy health on the sync relationship, not tip equality --- internal/reposetup/classify.go | 8 +- internal/reposetup/classify_test.go | 79 +++++++++++++++--- internal/reposetup/health.go | 40 ++++++---- internal/reposetup/health_test.go | 106 +++++++++++++++++++++---- internal/reposetup/healthconditions.go | 2 +- internal/reposetup/probe.go | 41 ++++++++-- 6 files changed, 225 insertions(+), 51 deletions(-) diff --git a/internal/reposetup/classify.go b/internal/reposetup/classify.go index 12353e011..a2923589a 100644 --- a/internal/reposetup/classify.go +++ b/internal/reposetup/classify.go @@ -64,11 +64,13 @@ func Classify(f Facts) Classification { conflict = append(conflict, "docket-dir-foreign") } if f.RemoteMetadata.Presence == PresencePresent && f.DocketWorktree.Presence == PresencePresent && - (f.DocketWorktree.Clean == PresenceAbsent || f.DocketWorktree.Synchronized == PresenceAbsent) { + f.DocketWorktree.Clean == PresenceAbsent { conflict = append(conflict, "metadata-worktree-dirty") } - if f.RemoteMetadata.Presence == PresencePresent && f.LocalMetadata.Presence == PresencePresent && - f.LocalMetadata.Tip != "" && f.RemoteMetadata.Tip != "" && f.LocalMetadata.Tip != f.RemoteMetadata.Tip { + if f.RemoteMetadata.Presence == PresencePresent && f.LocalMetadataSync == SyncAhead { + conflict = append(conflict, "local-metadata-ahead") + } + if f.RemoteMetadata.Presence == PresencePresent && f.LocalMetadataSync == SyncDiverged { conflict = append(conflict, "local-metadata-diverged") } if f.SurfacesAuthorized && f.SurfacesAgree == PresenceAbsent { diff --git a/internal/reposetup/classify_test.go b/internal/reposetup/classify_test.go index 0d77b32e5..168e37e34 100644 --- a/internal/reposetup/classify_test.go +++ b/internal/reposetup/classify_test.go @@ -1,6 +1,7 @@ package reposetup import ( + "reflect" "testing" ) @@ -16,6 +17,7 @@ func healthyFacts() Facts { RemoteMetadata: BranchFact{Presence: PresencePresent, Tip: "meta0"}, MetadataRoot: RootParentless, LocalMetadata: BranchFact{Presence: PresencePresent, Tip: "meta0"}, + LocalMetadataSync: SyncCurrent, LiveSurface: PresenceAbsent, LegacyConfigKey: PresenceAbsent, CommittedIgnoreBlock: PresencePresent, @@ -194,17 +196,6 @@ func TestClassify(t *testing.T) { wantReason: "metadata-worktree-dirty", reasonsReq: true, }, - { - name: "ahead metadata worktree -> conflict metadata-worktree-dirty", - facts: func() Facts { - f := healthyFacts() - f.DocketWorktree.Synchronized = PresenceAbsent - return f - }(), - wantState: StateConflict, - wantReason: "metadata-worktree-dirty", - reasonsReq: true, - }, { name: "foreign .docket/ -> conflict docket-dir-foreign", facts: func() Facts { @@ -221,6 +212,8 @@ func TestClassify(t *testing.T) { facts: func() Facts { f := healthyFacts() f.LocalMetadata = BranchFact{Presence: PresencePresent, Tip: "other9"} + f.LocalMetadataSync = SyncDiverged + f.DocketWorktree.Synchronized = PresenceAbsent return f }(), wantState: StateConflict, @@ -351,3 +344,67 @@ func containsReason(rs []string, want string) bool { } return false } + +// TestClassifyLocalMetadataRelationship pins every row of the spec's local-copy +// table by exact reason set: the relationship, never tip equality, decides. +func TestClassifyLocalMetadataRelationship(t *testing.T) { + rows := []struct { + name string + mutate func(*Facts) + wantState State + wantReasons []string + }{ + {"current clean", func(f *Facts) {}, StateHealthy, nil}, + {"behind clean", func(f *Facts) { + f.LocalMetadata.Tip = "meta-old" + f.LocalMetadataSync = SyncBehind + f.DocketWorktree.Synchronized = PresencePresent + }, StateHealthy, nil}, + {"behind dirty", func(f *Facts) { + f.LocalMetadata.Tip = "meta-old" + f.LocalMetadataSync = SyncBehind + f.DocketWorktree.Clean = PresenceAbsent + }, StateConflict, []string{"metadata-worktree-dirty"}}, + {"ahead clean", func(f *Facts) { + f.LocalMetadata.Tip = "meta-ahead" + f.LocalMetadataSync = SyncAhead + f.DocketWorktree.Synchronized = PresenceAbsent + }, StateConflict, []string{"local-metadata-ahead"}}, + {"ahead dirty", func(f *Facts) { + f.LocalMetadata.Tip = "meta-ahead" + f.LocalMetadataSync = SyncAhead + f.DocketWorktree.Synchronized = PresenceAbsent + f.DocketWorktree.Clean = PresenceAbsent + }, StateConflict, []string{"metadata-worktree-dirty", "local-metadata-ahead"}}, + {"diverged clean", func(f *Facts) { + f.LocalMetadata.Tip = "meta-div" + f.LocalMetadataSync = SyncDiverged + f.DocketWorktree.Synchronized = PresenceAbsent + }, StateConflict, []string{"local-metadata-diverged"}}, + {"unknown relation", func(f *Facts) { + f.LocalMetadata.Tip = "meta-x" + f.LocalMetadataSync = SyncUnknown + f.DocketWorktree.Synchronized = PresenceUnknown + }, StateConflict, []string{"postconditions-unmet"}}, + } + for _, row := range rows { + f := healthyFacts() + row.mutate(&f) + got := Classify(f) + if got.State != row.wantState || !reflect.DeepEqual(got.Reasons, row.wantReasons) { + t.Errorf("%s: Classify = %+v, want state %q reasons %v", row.name, got, row.wantState, row.wantReasons) + } + } +} + +func TestSyncRelationSynchronized(t *testing.T) { + want := map[SyncRelation]Presence{ + SyncUnknown: PresenceUnknown, SyncCurrent: PresencePresent, SyncBehind: PresencePresent, + SyncAhead: PresenceAbsent, SyncDiverged: PresenceAbsent, + } + for r, p := range want { + if got := r.Synchronized(); got != p { + t.Errorf("SyncRelation(%d).Synchronized() = %v, want %v", r, got, p) + } + } +} diff --git a/internal/reposetup/health.go b/internal/reposetup/health.go index 2887ac6d8..eff80d3a0 100644 --- a/internal/reposetup/health.go +++ b/internal/reposetup/health.go @@ -69,7 +69,7 @@ func categoryOf(reason string) int { "metadata-seeded", "metadata-seeded-live-surface": return catIntegrationTree case "docket-dir-foreign", "metadata-worktree-dirty", - "local-metadata-diverged", "integration-pruned-attach-incomplete": + "local-metadata-ahead", "local-metadata-diverged", "integration-pruned-attach-incomplete": return catLocalWorktree case "surfaces-drift": return catSurface @@ -169,14 +169,9 @@ func findingFor(reason string, f Facts) Finding { Remedy: "Inspect the .docket path and resolve it manually with a human before any repository operation.", } case "metadata-worktree-dirty": - msg := "The .docket metadata worktree has uncommitted or unsynchronized changes." - switch { - case f.DocketWorktree.Clean == PresenceAbsent && f.DocketWorktree.Synchronized == PresenceAbsent: - msg = "The .docket metadata worktree has uncommitted changes and is not synchronized with the remote docket tip." - case f.DocketWorktree.Clean == PresenceAbsent: - msg = "The .docket metadata worktree has uncommitted or untracked changes." - case f.DocketWorktree.Synchronized == PresenceAbsent: - msg = "The .docket metadata worktree is not synchronized with the remote docket tip." + msg := "The .docket metadata worktree has uncommitted or untracked changes." + if f.DocketWorktree.UnfinishedOperation { + msg = "The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect)." } return Finding{ Code: "metadata-worktree-dirty", @@ -185,6 +180,13 @@ func findingFor(reason string, f Facts) Finding { Message: msg, Remedy: "Commit or inspect the changes in the .docket metadata worktree before any repository operation; leave them in place.", } + case "local-metadata-ahead": + return Finding{ + Code: "local-metadata-ahead", + Severity: SeverityError, + Message: "The local docket branch is ahead of the remote docket branch (it carries commits the remote does not).", + Remedy: "Reconcile the local docket branch with the remote manually with a human before any repository operation.", + } case "local-metadata-diverged": return Finding{ Code: "local-metadata-diverged", @@ -369,7 +371,8 @@ func CheckExit(c Classification, findings []Finding) int { var reasonExplains = map[string][]HealthCondition{ "metadata-root-foreign": {CondMetadataRootVerified}, "docket-dir-foreign": {CondWorktreeNotForeign, CondWorktreeRegistered, CondWorktreeClean, CondWorktreeSynchronized, CondWorktreeHooksOff}, - "metadata-worktree-dirty": {CondWorktreeClean, CondWorktreeSynchronized}, + "metadata-worktree-dirty": {CondWorktreeClean}, + "local-metadata-ahead": {CondWorktreeSynchronized}, "local-metadata-diverged": {CondWorktreeSynchronized}, "pending-review-paths": {CondNoPendingReviewPaths}, "metadata-seeded": {CondLiveSurfaceAbsent}, @@ -522,10 +525,19 @@ func conditionFinding(cond HealthCondition, f Facts) *Finding { if !worktreeInspectable(f) { return nil } - // Synchronization needs both branch tips; missing prerequisites are - // represented by the local-metadata finding. Absent is explained by - // the metadata-worktree-dirty reason. - return nil + // Ahead and diverged are explained by their reasons. Unknown with both tips + // known means the ancestry probe failed: unverified, never guessed behind. A + // missing tip is explained by the local-metadata findings. + if f.DocketWorktree.Synchronized != PresenceUnknown || f.LocalMetadata.Tip == "" || f.RemoteMetadata.Tip == "" { + return nil + } + return &Finding{ + Code: "local-metadata-sync-unverified", + Severity: SeverityWarning, + Ref: ".docket", + Message: "Could not determine how the local docket branch relates to the remote docket branch (unverified, not proven diverged).", + Remedy: "Re-run `docket repository check` once local Git reads succeed.", + } case CondWorktreeHooksOff: if !worktreeInspectable(f) { return nil diff --git a/internal/reposetup/health_test.go b/internal/reposetup/health_test.go index 2caa64ff8..00a33847e 100644 --- a/internal/reposetup/health_test.go +++ b/internal/reposetup/health_test.go @@ -318,7 +318,7 @@ func TestHealthRemedyPartial(t *testing.T) { func TestHealthRemedyConflictNeverDestructive(t *testing.T) { reasons := []string{ "metadata-root-foreign", "docket-dir-foreign", "metadata-worktree-dirty", - "local-metadata-diverged", "surfaces-drift", "postconditions-unmet", + "local-metadata-ahead", "local-metadata-diverged", "surfaces-drift", "postconditions-unmet", } for _, reason := range reasons { c := Classification{State: StateConflict, Reasons: []string{reason}} @@ -356,7 +356,7 @@ func TestHealthInitCommandAppearsOnlyInFresh(t *testing.T) { {State: StateNeedsReview, Reasons: []string{"pending-review-paths"}}, {State: StatePartial, Reasons: []string{"metadata-seeded"}}, {State: StateConflict, Reasons: []string{"metadata-root-foreign", "docket-dir-foreign", - "metadata-worktree-dirty", "local-metadata-diverged", "surfaces-drift", "postconditions-unmet"}}, + "metadata-worktree-dirty", "local-metadata-ahead", "local-metadata-diverged", "surfaces-drift", "postconditions-unmet"}}, {State: StateUnknown, Reasons: []string{"remote-configured-unknown", "remote-default-unknown", "remote-integration-unknown", "metadata-presence-unknown", "live-surface-unknown"}}, } @@ -797,23 +797,97 @@ func TestHealthUnauthorizedSurfacesNoFinding(t *testing.T) { } } -// TestHealthDirtyMessageNamesObservedAlternatives: the metadata-worktree-dirty -// finding identifies which alternative(s) were actually observed. -func TestHealthDirtyMessageNamesObservedAlternatives(t *testing.T) { - onlySync := healthyFacts() - onlySync.DocketWorktree.Synchronized = PresenceAbsent - got := EvaluateHealth(Classify(onlySync), onlySync, nil) - found := false - for _, fn := range got { - if fn.Code == "metadata-worktree-dirty" { - found = true - if !strings.Contains(fn.Message, "not synchronized") || strings.Contains(fn.Message, "uncommitted") { - t.Fatalf("message does not identify the observed alternative: %q", fn.Message) +// TestHealthDirtyMessageNamesTheCause: the dirty finding names an unfinished Git +// operation when that is the cause, and never mentions synchronization. +func TestHealthDirtyMessageNamesTheCause(t *testing.T) { + files := healthyFacts() + files.DocketWorktree.Clean = PresenceAbsent + op := healthyFacts() + op.DocketWorktree.Clean = PresenceAbsent + op.DocketWorktree.UnfinishedOperation = true + for name, tc := range map[string]struct { + f Facts + want string + }{ + "files": {files, "The .docket metadata worktree has uncommitted or untracked changes."}, + "operation": {op, "The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect)."}, + } { + var msg string + for _, fn := range EvaluateHealth(Classify(tc.f), tc.f, nil) { + if fn.Code == "metadata-worktree-dirty" { + msg = fn.Message + } + } + if msg != tc.want { + t.Errorf("%s: dirty message = %q, want %q", name, msg, tc.want) + } + if strings.Contains(msg, "synchroniz") { + t.Errorf("%s: dirty message still mentions synchronization: %q", name, msg) + } + } +} + +// TestHealthLocalMetadataRelationshipRows pins findings and exit per table row. +func TestHealthLocalMetadataRelationshipRows(t *testing.T) { + rows := []struct { + name string + mutate func(*Facts) + wantCodes []string + wantExit int + }{ + {"behind clean is healthy with no finding", func(f *Facts) { + f.LocalMetadata.Tip = "meta-old" + f.LocalMetadataSync = SyncBehind + }, nil, 0}, + {"behind dirty", func(f *Facts) { + f.LocalMetadataSync = SyncBehind + f.DocketWorktree.Clean = PresenceAbsent + }, []string{"metadata-worktree-dirty"}, 1}, + {"ahead", func(f *Facts) { + f.LocalMetadataSync = SyncAhead + f.DocketWorktree.Synchronized = PresenceAbsent + }, []string{"local-metadata-ahead"}, 1}, + {"ahead dirty", func(f *Facts) { + f.LocalMetadataSync = SyncAhead + f.DocketWorktree.Synchronized = PresenceAbsent + f.DocketWorktree.Clean = PresenceAbsent + }, []string{"metadata-worktree-dirty", "local-metadata-ahead"}, 1}, + {"diverged", func(f *Facts) { + f.LocalMetadataSync = SyncDiverged + f.DocketWorktree.Synchronized = PresenceAbsent + }, []string{"local-metadata-diverged"}, 1}, + {"unknown with both tips known warns unverified", func(f *Facts) { + f.LocalMetadata.Tip = "meta-x" + f.LocalMetadataSync = SyncUnknown + f.DocketWorktree.Synchronized = PresenceUnknown + }, []string{"postconditions-unmet", "local-metadata-sync-unverified"}, 1}, + } + for _, row := range rows { + f := healthyFacts() + row.mutate(&f) + c := Classify(f) + got := EvaluateHealth(c, f, nil) + codes := findingCodes(got) + // findingCodes returns an empty, non-nil slice; a nil want means none. + if len(row.wantCodes) == 0 { + if len(codes) != 0 { + t.Errorf("%s: codes = %v, want none", row.name, codes) } + } else if !reflect.DeepEqual(codes, row.wantCodes) { + t.Errorf("%s: codes = %v, want %v", row.name, codes, row.wantCodes) + } + if exit := CheckExit(c, got); exit != row.wantExit { + t.Errorf("%s: exit = %d, want %d", row.name, exit, row.wantExit) } } - if !found { - t.Fatalf("dirty finding missing: %v", findingCodes(got)) + // A missing local tip is explained by local-metadata-unverified, never by the + // sync warning. + f := healthyFacts() + f.LocalMetadata = BranchFact{Presence: PresenceUnknown} + f.LocalMetadataSync = SyncUnknown + f.DocketWorktree.Synchronized = PresenceUnknown + if hasCode(EvaluateHealth(Classify(f), f, nil), "local-metadata-sync-unverified") { + t.Error("sync-unverified fired although the local tip is missing") } } diff --git a/internal/reposetup/healthconditions.go b/internal/reposetup/healthconditions.go index b1aa07536..556af4783 100644 --- a/internal/reposetup/healthconditions.go +++ b/internal/reposetup/healthconditions.go @@ -22,7 +22,7 @@ const ( CondWorktreeRegistered HealthCondition = "docket-worktree-registered" CondWorktreeNotForeign HealthCondition = "docket-worktree-not-foreign" CondWorktreeClean HealthCondition = "docket-worktree-clean" - CondWorktreeSynchronized HealthCondition = "docket-worktree-synchronized" + CondWorktreeSynchronized HealthCondition = "docket-worktree-synchronized" // proven current or behind: the local copy holds nothing the remote lacks; Unknown never satisfies it CondWorktreeHooksOff HealthCondition = "docket-worktree-hooks-off" CondCommittedIgnoreValid HealthCondition = "committed-ignore-valid" CondLiveSurfaceAbsent HealthCondition = "live-surface-absent" diff --git a/internal/reposetup/probe.go b/internal/reposetup/probe.go index c5fea1923..66666695a 100644 --- a/internal/reposetup/probe.go +++ b/internal/reposetup/probe.go @@ -34,15 +34,43 @@ type BranchFact struct { Tip string // object id when Present, else "" } +// SyncRelation is the ancestry relationship of a local tip to the remote tip it +// tracks. The zero value, SyncUnknown, is safe: an unproven relationship is never +// read as current or behind. +type SyncRelation int + +const ( + SyncUnknown SyncRelation = iota // a tip is missing or an ancestry probe failed + SyncCurrent // local == remote + SyncBehind // local is a strict ancestor of remote + SyncAhead // remote is a strict ancestor of local + SyncDiverged // neither is an ancestor of the other +) + +// Synchronized maps a relationship to the docket-worktree-synchronized fact: +// proven current or behind — the local copy holds nothing the remote lacks — is +// Present; proven ahead or diverged is Absent; an unproven relationship is Unknown. +func (r SyncRelation) Synchronized() Presence { + switch r { + case SyncCurrent, SyncBehind: + return PresencePresent + case SyncAhead, SyncDiverged: + return PresenceAbsent + default: + return PresenceUnknown + } +} + // WorktreeFact carries the probed state of the persistent .docket/ metadata // worktree. type WorktreeFact struct { - Presence Presence // .docket/ path state: absent, or present-and-probed - Registered Presence // registered as a linked worktree of THIS repo on the metadata branch - Foreign bool // present but a foreign dir / escaping link / conflicting registration - Clean Presence - Synchronized Presence // local tip == remote metadata tip - HooksOff Presence + Presence Presence // .docket/ path state: absent, or present-and-probed + Registered Presence // registered as a linked worktree of THIS repo on the metadata branch + Foreign bool // present but a foreign dir / escaping link / conflicting registration + Clean Presence + UnfinishedOperation bool // Clean is Absent because a merge, cherry-pick, revert, rebase, am, or bisect is unfinished + Synchronized Presence // proven current or behind the remote metadata tip: the local copy holds nothing the remote lacks + HooksOff Presence } // Facts is the complete classifier input. Every field defaults to the safe @@ -55,6 +83,7 @@ type Facts struct { RemoteMetadata BranchFact // the remote `docket` branch MetadataRoot RootShape // meaningful only when RemoteMetadata is Present LocalMetadata BranchFact + LocalMetadataSync SyncRelation // local docket tip vs remote docket tip; set only by the check/prepare augmentation LiveSurface Presence // active dir or BOARD.md in the AUTHORITATIVE integration tree LegacyConfigKey Presence // top-level metadata_branch key in the pinned .docket.yml bytes CommittedIgnoreBlock Presence // managed block valid in the integration COMMIT tree From 4ba2df434aa09a733fec2099bec07bf2292a3918 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 08:07:56 -0400 Subject: [PATCH 07/17] fix(check): a clean behind-only .docket copy is healthy; one sync computation for check and prepare --- docs/release/upgrading-from-bash.md | 6 +- internal/app/repository_check.go | 25 ++---- internal/app/repository_check_test.go | 28 +++++++ internal/app/repository_prepare.go | 82 +++++------------- internal/app/repository_prepare_test.go | 48 +++++++---- internal/app/repository_sync_relation.go | 55 ++++++++++++ .../app/reposynccheck_integration_test.go | 84 +++++++++++++++++++ internal/bashupgrade/registry_test.go | 31 ++++--- tests/runtime-budgets.tsv | 1 + .../test_go_integration_app_reposynccheck.sh | 24 ++++++ 10 files changed, 270 insertions(+), 114 deletions(-) create mode 100644 internal/app/repository_sync_relation.go create mode 100644 internal/app/reposynccheck_integration_test.go create mode 100755 tests/test_go_integration_app_reposynccheck.sh diff --git a/docs/release/upgrading-from-bash.md b/docs/release/upgrading-from-bash.md index ed7e0c8f5..67af28ff7 100644 --- a/docs/release/upgrading-from-bash.md +++ b/docs/release/upgrading-from-bash.md @@ -196,12 +196,12 @@ docket repository repair --yes ``` It then tells you to run `docket repository prepare` to bring your local `.docket` folder up to -date. Until you do, `docket repository check` reports `metadata-worktree-dirty` and -`local-metadata-diverged`. Both clear after `prepare`. Run it and check again: +date. +That step is optional: the next docket command brings the folder up to date by itself. +Check the repository: ```sh -docket repository prepare docket repository check ``` diff --git a/internal/app/repository_check.go b/internal/app/repository_check.go index 9bc0be27c..9b0aaef6b 100644 --- a/internal/app/repository_check.go +++ b/internal/app/repository_check.go @@ -177,7 +177,7 @@ func augmentCheckFacts(ctx context.Context, git *gitcli.Client, f *reposetup.Fac // with no ownership proof (RootForeign), or unreadable evidence (RootUnknown). // The fetched tip is the single authority (learning // decide-and-act-on-the-same-copy): it becomes RemoteMetadata's reported - // revision and the synchronizedPresence comparison's remote side, so the + // revision and the remote side of syncRelationship, so the // reported tip is exactly the tip the ownership proof was computed at. if sc.metadataTip != "" { // The base gatherer proves the metadata branch PRESENT via ls-remote (its OID @@ -207,14 +207,15 @@ func augmentCheckFacts(ctx context.Context, git *gitcli.Client, f *reposetup.Fac f.LocalMetadata.Presence = reposetup.PresenceAbsent } // any other error leaves LocalMetadata Unknown - // The .docket metadata worktree: clean, synchronized with the remote tip, and - // hooks disabled. Probed only when the worktree is actually present. + // The .docket metadata worktree: clean and hooks disabled; its synchronized fact + // comes from applyLocalMetadataSync. Probed only when the worktree is actually + // present. if f.DocketWorktree.Presence == reposetup.PresencePresent { worktreeDir := filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName) f.DocketWorktree.Clean = worktreeCleanPresence(ctx, git, worktreeDir) - f.DocketWorktree.Synchronized = synchronizedPresence(f.LocalMetadata, f.RemoteMetadata) f.DocketWorktree.HooksOff = hooksOffPresence(ctx, git, worktreeDir) } + applyLocalMetadataSync(ctx, git, sc.repo, f) // A repository whose remote is fully migrated — a parentless docket seed with // the integration surface already pruned — but whose local .docket attachment @@ -270,22 +271,6 @@ func worktreeCleanPresence(ctx context.Context, git *gitcli.Client, worktreeDir return reposetup.PresenceAbsent } -// synchronizedPresence reports whether the local metadata tip equals the remote -// docket tip. Either tip unknown leaves the fact Unknown; equal is Present, -// unequal is Absent. -func synchronizedPresence(local, remote reposetup.BranchFact) reposetup.Presence { - if local.Presence != reposetup.PresencePresent || remote.Presence != reposetup.PresencePresent { - return reposetup.PresenceUnknown - } - if local.Tip == "" || remote.Tip == "" { - return reposetup.PresenceUnknown - } - if local.Tip == remote.Tip { - return reposetup.PresencePresent - } - return reposetup.PresenceAbsent -} - // hooksOffPresence reports whether the .docket worktree's hooks are disabled the // way init leaves them (a per-worktree core.hooksPath pointing at an existing // dir). A probe error is the safe Unknown. diff --git a/internal/app/repository_check_test.go b/internal/app/repository_check_test.go index 819f15001..be85cc68f 100644 --- a/internal/app/repository_check_test.go +++ b/internal/app/repository_check_test.go @@ -208,3 +208,31 @@ func TestRepositoryCheckPrintsCommittedIgnoreBlockFlushLeft(t *testing.T) { t.Fatalf("check text must carry the canonical block flush left (newline, then the block verbatim):\n%s", text) } } + +// TestLocalMetadataRefusalsMatchPrepare: check's ahead/diverged findings and +// prepare's refusals for the same state carry the same code, message, and remedy, +// so the two commands never describe one state two ways. +func TestLocalMetadataRefusalsMatchPrepare(t *testing.T) { + for _, tc := range []struct { + rel reposetup.SyncRelation + reason string + }{ + {reposetup.SyncAhead, "local-metadata-ahead"}, + {reposetup.SyncDiverged, "local-metadata-diverged"}, + } { + f := preparableFacts() + f.LocalMetadataSync = tc.rel + f.DocketWorktree.Synchronized = reposetup.PresenceAbsent + v := prepareRoute(f) + if v.finding == nil { + t.Fatalf("%s: prepare produced no finding", tc.reason) + } + got := reposetup.EvaluateHealth(reposetup.Classification{State: reposetup.StateConflict, Reasons: []string{tc.reason}}, reposetup.Facts{}, nil) + if len(got) != 1 { + t.Fatalf("%s: check findings = %+v", tc.reason, got) + } + if got[0].Code != v.finding.Code || got[0].Message != v.finding.Message || got[0].Remedy != v.finding.Remedy { + t.Errorf("%s: check %+v != prepare %+v", tc.reason, got[0], *v.finding) + } + } +} diff --git a/internal/app/repository_prepare.go b/internal/app/repository_prepare.go index 44e46348b..ca3824ce1 100644 --- a/internal/app/repository_prepare.go +++ b/internal/app/repository_prepare.go @@ -209,19 +209,6 @@ const ( prepareActionFastForward ) -// prepareSync is the ancestry relationship between the local metadata branch tip -// and the pinned remote metadata revision. The zero value is the SAFE -// prepareSyncUnknown — an unproven relationship never fast-forwards. -type prepareSync int - -const ( - prepareSyncUnknown prepareSync = iota - prepareSyncCurrent // local == remote - prepareSyncBehind // local is a strict ancestor of remote (fast-forwardable) - prepareSyncAhead // remote is a strict ancestor of local - prepareSyncDiverged // neither is an ancestor of the other -) - // prepareVerdict is prepareRoute's pure decision: the closed disposition, the // planned Git effect (attach/fast-forward/none), the pinned target revision that // effect keys on, the repository state to report, and the single finding a @@ -235,16 +222,16 @@ type prepareVerdict struct { finding *reposetup.Finding } -// prepareRoute maps the gathered (and locally augmented) facts plus the computed -// sync relationship to a verdict. It is pure so every disposition row is pinned by -// a unit test asserting both the disposition and its mechanism (finding code and -// remedy). The ladder is deliberately ordered and fail-closed: an unresolved +// prepareRoute maps the gathered (and locally augmented) facts, including the +// local/remote sync relationship in f.LocalMetadataSync, to a verdict. It is pure +// so every disposition row is pinned by a unit test asserting both the disposition +// and its mechanism (finding code and remedy). The ladder is deliberately ordered and fail-closed: an unresolved // required topology probe is an error before anything else; a foreign `.docket` // path refuses; no remote metadata is a fresh/legacy refusal naming the exact // remedy; a non-parentless or still-live remote is refused without a local touch; // only a proven-clean, registered, strictly-behind or current worktree fast- // forwards or is a no-op, and an absent worktree attaches. -func prepareRoute(f reposetup.Facts, sync prepareSync) prepareVerdict { +func prepareRoute(f reposetup.Facts) prepareVerdict { // 1. Fail-closed: a required topology probe that could not be resolved is an // error, never a fabricated absence that could authorize an attach. if u := prepareUnresolvedTopology(f); len(u) > 0 { @@ -364,19 +351,19 @@ func prepareRoute(f reposetup.Facts, sync prepareSync) prepareVerdict { // 6. Clean, registered worktree: synchronize by the pinned remote revision, // keyed on the ancestry relationship. Only a strictly-behind local fast- // forwards; ahead and diverged refuse without a touch. - switch sync { - case prepareSyncCurrent: + switch f.LocalMetadataSync { + case reposetup.SyncCurrent: return prepareVerdict{disposition: PrepareDispositionNoOp, action: prepareActionNone, state: reposetup.StateHealthy} - case prepareSyncBehind: + case reposetup.SyncBehind: return prepareApplyVerdict(prepareActionFastForward, f.RemoteMetadata.Tip) - case prepareSyncAhead: + case reposetup.SyncAhead: return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ Code: string(FCLocalMetadataAhead), Severity: reposetup.SeverityError, Message: "The local docket branch is ahead of the remote docket branch (it carries commits the remote does not).", Remedy: "Reconcile the local docket branch with the remote manually with a human before any repository operation.", }) - case prepareSyncDiverged: + case reposetup.SyncDiverged: return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ Code: string(FCLocalMetadataDiverged), Severity: reposetup.SeverityError, @@ -500,12 +487,11 @@ func RunRepositoryPrepare(ctx context.Context, d SetupDeps, o PrepareOptions) Re // metadata branch is proven present; a fresh/legacy repository is decided from // the base facts alone. Every probe maps its own error to the safe Unknown // value, never a false absence. - sync := prepareSyncUnknown if facts.RemoteMetadata.Presence == reposetup.PresencePresent { - sync = prepareAugment(ctx, d.Git, &facts, sc) + prepareAugment(ctx, d.Git, &facts, sc) } - verdict := prepareRoute(facts, sync) + verdict := prepareRoute(facts) notices := prepareNotices(sc) switch verdict.disposition { @@ -524,11 +510,12 @@ func RunRepositoryPrepare(ctx context.Context, d SetupDeps, o PrepareOptions) Re } // prepareAugment fills the local metadata/worktree/sync facts the base gatherer -// deliberately leaves unproven, and returns the ancestry relationship between the -// local metadata tip and the pinned remote metadata revision. Every probe maps its -// own error to the safe Unknown value so a probe that could not run can never let -// the router read healthy. -func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, sc setupContext) prepareSync { +// deliberately leaves unproven, including LocalMetadataSync (the ancestry +// relationship between the local metadata tip and the pinned remote metadata +// revision, computed by applyLocalMetadataSync), and returns nothing. Every probe +// maps its own error to the safe Unknown value so a probe that could not run can +// never let the router read healthy. +func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, sc setupContext) { metaRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) // Metadata root shape at the FETCHED remote docket tip (fetch it first so the @@ -569,40 +556,9 @@ func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, // The .docket worktree clean state, probed only when the worktree is present. if f.DocketWorktree.Presence == reposetup.PresencePresent { f.DocketWorktree.Clean = worktreeCleanPresence(ctx, git, filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName)) - f.DocketWorktree.Synchronized = synchronizedPresence(f.LocalMetadata, f.RemoteMetadata) } - return prepareSyncRelationship(ctx, git, sc.repo, f.LocalMetadata.Tip, metaTip) -} - -// prepareSyncRelationship computes the ancestry relationship between the local -// metadata tip and the pinned remote metadata revision. An empty tip or a probe -// error is the safe Unknown — an unproven relationship never fast-forwards. -func prepareSyncRelationship(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, localTip, remoteTip string) prepareSync { - if localTip == "" || remoteTip == "" { - return prepareSyncUnknown - } - if localTip == remoteTip { - return prepareSyncCurrent - } - local := gitcli.ObjectID(localTip) - remote := gitcli.ObjectID(remoteTip) - localBehind, err := git.IsAncestor(ctx, repo, local, remote) - if err != nil { - return prepareSyncUnknown - } - remoteBehind, err := git.IsAncestor(ctx, repo, remote, local) - if err != nil { - return prepareSyncUnknown - } - switch { - case localBehind: - return prepareSyncBehind - case remoteBehind: - return prepareSyncAhead - default: - return prepareSyncDiverged - } + applyLocalMetadataSync(ctx, git, sc.repo, f) } // prepareExecute performs the single planned idempotent Git effect. Attachment diff --git a/internal/app/repository_prepare_test.go b/internal/app/repository_prepare_test.go index 921faacb8..bbbb26871 100644 --- a/internal/app/repository_prepare_test.go +++ b/internal/app/repository_prepare_test.go @@ -33,6 +33,7 @@ func preparableFacts() reposetup.Facts { Clean: reposetup.PresencePresent, Synchronized: reposetup.PresencePresent, }, + LocalMetadataSync: reposetup.SyncCurrent, } } @@ -53,7 +54,8 @@ func TestRepositoryPrepareFreshRefusesWithInitRemedy(t *testing.T) { f.RemoteMetadata = reposetup.BranchFact{Presence: reposetup.PresenceAbsent} f.LiveSurface = reposetup.PresenceAbsent - v := prepareRoute(f, prepareSyncUnknown) + f.LocalMetadataSync = reposetup.SyncUnknown + v := prepareRoute(f) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -78,7 +80,8 @@ func TestRepositoryPrepareLegacyRefusesWithMigrateRemedy(t *testing.T) { f.RemoteMetadata = reposetup.BranchFact{Presence: reposetup.PresenceAbsent} f.LiveSurface = reposetup.PresencePresent - v := prepareRoute(f, prepareSyncUnknown) + f.LocalMetadataSync = reposetup.SyncUnknown + v := prepareRoute(f) if v.disposition != PrepareDispositionRefused || v.state != reposetup.StateLegacy { t.Fatalf("verdict = %q/%q, want refused/legacy", v.disposition, v.state) } @@ -98,7 +101,8 @@ func TestRepositoryPrepareHealthyMissingWorktreeAttaches(t *testing.T) { f.DocketWorktree = reposetup.WorktreeFact{Presence: reposetup.PresenceAbsent} f.LocalMetadata = reposetup.BranchFact{Presence: reposetup.PresenceAbsent} - v := prepareRoute(f, prepareSyncUnknown) + f.LocalMetadataSync = reposetup.SyncUnknown + v := prepareRoute(f) if v.disposition != PrepareDispositionApplied { t.Fatalf("disposition = %q, want applied", v.disposition) } @@ -117,7 +121,8 @@ func TestRepositoryPrepareCleanBehindFastForwards(t *testing.T) { f.LocalMetadata.Tip = "m1" // behind m9 f.DocketWorktree.Synchronized = reposetup.PresenceAbsent - v := prepareRoute(f, prepareSyncBehind) + f.LocalMetadataSync = reposetup.SyncBehind + v := prepareRoute(f) if v.disposition != PrepareDispositionApplied { t.Fatalf("disposition = %q, want applied", v.disposition) } @@ -132,7 +137,9 @@ func TestRepositoryPrepareCleanBehindFastForwards(t *testing.T) { // TestRepositoryPrepareCleanCurrentIsNoOp — a worktree already at the pinned // revision is a no-op with no planned effect. func TestRepositoryPrepareCleanCurrentIsNoOp(t *testing.T) { - v := prepareRoute(preparableFacts(), prepareSyncCurrent) + f := preparableFacts() + f.LocalMetadataSync = reposetup.SyncCurrent + v := prepareRoute(f) if v.disposition != PrepareDispositionNoOp { t.Fatalf("disposition = %q, want no-op", v.disposition) } @@ -150,7 +157,8 @@ func TestRepositoryPrepareDirtyRefuses(t *testing.T) { f := preparableFacts() f.DocketWorktree.Clean = reposetup.PresenceAbsent - v := prepareRoute(f, prepareSyncCurrent) + f.LocalMetadataSync = reposetup.SyncCurrent + v := prepareRoute(f) if v.disposition != PrepareDispositionRefused || v.action != prepareActionNone { t.Fatalf("verdict = %q/%v, want refused/none", v.disposition, v.action) } @@ -166,7 +174,8 @@ func TestRepositoryPrepareAheadRefuses(t *testing.T) { f.LocalMetadata.Tip = "mA" f.DocketWorktree.Synchronized = reposetup.PresenceAbsent - v := prepareRoute(f, prepareSyncAhead) + f.LocalMetadataSync = reposetup.SyncAhead + v := prepareRoute(f) if v.disposition != PrepareDispositionRefused || v.action != prepareActionNone { t.Fatalf("verdict = %q/%v, want refused/none", v.disposition, v.action) } @@ -181,7 +190,8 @@ func TestRepositoryPrepareDivergedRefuses(t *testing.T) { f.LocalMetadata.Tip = "mD" f.DocketWorktree.Synchronized = reposetup.PresenceAbsent - v := prepareRoute(f, prepareSyncDiverged) + f.LocalMetadataSync = reposetup.SyncDiverged + v := prepareRoute(f) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -196,7 +206,8 @@ func TestRepositoryPrepareForeignRefuses(t *testing.T) { f := preparableFacts() f.DocketWorktree.Foreign = true - v := prepareRoute(f, prepareSyncCurrent) + f.LocalMetadataSync = reposetup.SyncCurrent + v := prepareRoute(f) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -212,7 +223,8 @@ func TestRepositoryPrepareForeignRootRefusesAsForeign(t *testing.T) { f := preparableFacts() f.MetadataRoot = reposetup.RootForeign - v := prepareRoute(f, prepareSyncCurrent) + f.LocalMetadataSync = reposetup.SyncCurrent + v := prepareRoute(f) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -235,7 +247,8 @@ func TestRepositoryPrepareUnknownRootRefusesAsReachability(t *testing.T) { f := preparableFacts() f.MetadataRoot = reposetup.RootUnknown // RemoteMetadata stays PresencePresent - v := prepareRoute(f, prepareSyncCurrent) + f.LocalMetadataSync = reposetup.SyncCurrent + v := prepareRoute(f) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused (fail-closed, no mutation)", v.disposition) } @@ -268,7 +281,8 @@ func TestRepositoryPrepareAmbiguousRegistrationRefuses(t *testing.T) { f := preparableFacts() f.DocketWorktree.Registered = reposetup.PresenceUnknown - v := prepareRoute(f, prepareSyncCurrent) + f.LocalMetadataSync = reposetup.SyncCurrent + v := prepareRoute(f) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -280,7 +294,9 @@ func TestRepositoryPrepareAmbiguousRegistrationRefuses(t *testing.T) { // TestRepositoryPrepareProbeUnknownRefuses — a healthy topology whose local sync // relationship could not be proven refuses (never guesses a fast-forward). func TestRepositoryPrepareProbeUnknownRefuses(t *testing.T) { - v := prepareRoute(preparableFacts(), prepareSyncUnknown) + f := preparableFacts() + f.LocalMetadataSync = reposetup.SyncUnknown + v := prepareRoute(f) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -310,7 +326,8 @@ func TestRepositoryPrepareProbeErrorIsNotAbsence(t *testing.T) { t.Fatalf("RemoteMetadata.Presence = %v, want Unknown", f.RemoteMetadata.Presence) } - v := prepareRoute(f, prepareSyncUnknown) + f.LocalMetadataSync = reposetup.SyncUnknown + v := prepareRoute(f) if v.disposition != PrepareDispositionError { t.Errorf("disposition = %q, want error (an errored probe is never a clean absence)", v.disposition) } @@ -630,7 +647,8 @@ func TestRepositoryPrepareRepairMaxAttempts(t *testing.T) { func TestRepositoryPrepareResultOmitsContextOnRefusal(t *testing.T) { f := preparableFacts() f.RemoteMetadata = reposetup.BranchFact{Presence: reposetup.PresenceAbsent} - v := prepareRoute(f, prepareSyncUnknown) + f.LocalMetadataSync = reposetup.SyncUnknown + v := prepareRoute(f) res := prepareDiagnosisResult(ResultInvalidState, PrepareDispositionRefused, v, nil) if res.Context != nil { t.Errorf("a refusal must carry no context, got %+v", res.Context) diff --git a/internal/app/repository_sync_relation.go b/internal/app/repository_sync_relation.go new file mode 100644 index 000000000..b07608f68 --- /dev/null +++ b/internal/app/repository_sync_relation.go @@ -0,0 +1,55 @@ +package app + +import ( + "context" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/reposetup" +) + +// syncRelationship is the ONE ancestry computation behind `repository check`, +// `repository configure-tests`, and `repository prepare`: how a local tip relates to +// the remote tip it tracks. An empty tip or a probe error is the safe SyncUnknown — +// an unproven relationship is never read as current or behind, so it never +// fast-forwards and never reads healthy. The probe ignores replace refs and grafts +// (IsAncestorIgnoringReplacements), so neither can fake "behind" and strand +// local-only commits. +func syncRelationship(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, localTip, remoteTip string) reposetup.SyncRelation { + if localTip == "" || remoteTip == "" { + return reposetup.SyncUnknown + } + if localTip == remoteTip { + return reposetup.SyncCurrent + } + local, remote := gitcli.ObjectID(localTip), gitcli.ObjectID(remoteTip) + localBehind, err := git.IsAncestorIgnoringReplacements(ctx, repo, local, remote) + if err != nil { + return reposetup.SyncUnknown + } + remoteBehind, err := git.IsAncestorIgnoringReplacements(ctx, repo, remote, local) + if err != nil { + return reposetup.SyncUnknown + } + switch { + case localBehind: + return reposetup.SyncBehind + case remoteBehind: + return reposetup.SyncAhead + default: + return reposetup.SyncDiverged + } +} + +// applyLocalMetadataSync records how the local docket branch relates to the remote +// docket tip, and derives the .docket worktree's synchronized fact from it. Both the +// check augmentation (augmentCheckFacts) and the prepare augmentation (prepareAugment) +// call it, so their facts never disagree. It reads LocalMetadata and RemoteMetadata, +// which the caller has already resolved. +func applyLocalMetadataSync(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, f *reposetup.Facts) { + if f.LocalMetadata.Presence == reposetup.PresencePresent && f.RemoteMetadata.Presence == reposetup.PresencePresent { + f.LocalMetadataSync = syncRelationship(ctx, git, repo, f.LocalMetadata.Tip, f.RemoteMetadata.Tip) + } + if f.DocketWorktree.Presence == reposetup.PresencePresent { + f.DocketWorktree.Synchronized = f.LocalMetadataSync.Synchronized() + } +} diff --git a/internal/app/reposynccheck_integration_test.go b/internal/app/reposynccheck_integration_test.go new file mode 100644 index 000000000..bcbdfb751 --- /dev/null +++ b/internal/app/reposynccheck_integration_test.go @@ -0,0 +1,84 @@ +//go:build integration + +package app + +import ( + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/reposetup" +) + +// --- local-copy relationship scenarios (TestIntegrationRepoSyncCheck shard) -------- +// +// Every typed metadata write pushes from a detached worktree and leaves the local +// .docket copy clean and behind. These prove check keys on the real ancestry +// relationship: behind is healthy with no finding; ahead, diverged, and dirty each +// report exactly their own finding. + +func checkCodes(res RepositoryCheckResult) map[string]bool { + got := map[string]bool{} + for _, f := range res.Findings { + got[f.Code] = true + } + return got +} + +func TestIntegrationRepoSyncCheckBehindIsHealthy(t *testing.T) { + r := newHealthyRepo(t) + oldTip := r.dotDocketHead(t) + newTip := r.advanceRemoteDocket(t, "notes/advance.txt", "advanced\n", "advance docket") + + res := r.runCheck(t) + if res.RepositoryState != string(reposetup.StateHealthy) || len(res.Findings) != 0 || res.CheckExitCode() != 0 { + t.Fatalf("behind-only .docket: state=%q findings=%+v exit=%d, want healthy, none, 0", + res.RepositoryState, res.Findings, res.CheckExitCode()) + } + // Positive evidence the copy really was behind (check is read-only). + if res.Revisions["local-metadata"] != oldTip || res.Revisions["remote-metadata"] != newTip { + t.Fatalf("revisions = %v, want local %s behind remote %s", res.Revisions, oldTip, newTip) + } + if head := r.dotDocketHead(t); head != oldTip { + t.Fatalf("check moved .docket from %s to %s", oldTip, head) + } + + ct := r.runConfigureTests(t) + if ct.Result == ResultInvalidState { + t.Fatalf("configure-tests refused a behind-only repository: %s", ct.HumanText()) + } +} + +func TestIntegrationRepoSyncCheckAheadReportsAheadOnly(t *testing.T) { + r := newHealthyRepo(t) + r.commitInDocket(t, "ahead.txt", "ahead\n", "local-only docket commit") + res := r.runCheck(t) + got := checkCodes(res) + if !got["local-metadata-ahead"] || got["local-metadata-diverged"] || got["metadata-worktree-dirty"] || res.CheckExitCode() != 1 { + t.Fatalf("ahead: findings=%+v exit=%d, want local-metadata-ahead only, exit 1", res.Findings, res.CheckExitCode()) + } +} + +func TestIntegrationRepoSyncCheckDivergedReportsDivergedOnly(t *testing.T) { + r := newHealthyRepo(t) + r.advanceRemoteDocket(t, "notes/remote.txt", "remote\n", "remote side") + r.commitInDocket(t, "local.txt", "local\n", "local side") + res := r.runCheck(t) + got := checkCodes(res) + if !got["local-metadata-diverged"] || got["local-metadata-ahead"] || got["metadata-worktree-dirty"] { + t.Fatalf("diverged: findings=%+v, want local-metadata-diverged only", res.Findings) + } +} + +func TestIntegrationRepoSyncCheckDirtyBehindReportsDirtyOnly(t *testing.T) { + r := newHealthyRepo(t) + r.advanceRemoteDocket(t, "notes/advance.txt", "advanced\n", "advance docket") + if err := os.WriteFile(filepath.Join(r.invocation, ".docket", "scratch.txt"), []byte("dirt\n"), 0o644); err != nil { + t.Fatal(err) + } + res := r.runCheck(t) + got := checkCodes(res) + if !got["metadata-worktree-dirty"] || got["local-metadata-diverged"] || got["local-metadata-ahead"] { + t.Fatalf("dirty+behind: findings=%+v, want metadata-worktree-dirty only", res.Findings) + } +} diff --git a/internal/bashupgrade/registry_test.go b/internal/bashupgrade/registry_test.go index 09538cec5..bd5db19c4 100644 --- a/internal/bashupgrade/registry_test.go +++ b/internal/bashupgrade/registry_test.go @@ -379,6 +379,10 @@ func gitRev(t *testing.T, c *upgradeCase, ref string) string { // ---- section 3 and 4: the machine install ------------------------------------------ +// repairPrepareOptionalProse is the guide's statement that the prepare step repair +// mentions is optional. +const repairPrepareOptionalProse = "That step is optional: the next docket command brings the folder up to date by itself." + // releaseVerifiedProse is the guide's statement that the download and checksum // lines rest on the release verification, not on this test. const releaseVerifiedProse = "The download and checksum lines are checked by the release's own verification, not by\nthis guide's test." @@ -925,31 +929,32 @@ func observeRepairPreview(t *testing.T, c *upgradeCase, st *runState, body strin } // runRepairApply runs the block and checks the guide's claims: the repair is pushed -// to the docket branch, the output asks for `docket repository prepare`, and until -// then the check reports the two named findings. +// to the docket branch, the output names `docket repository prepare`, which the guide +// calls optional, and the local .docket copy is left behind the remote (so the +// healthy check that follows proves a behind-only copy is healthy). func runRepairApply(t *testing.T, c *upgradeCase, st *runState, body string) { t.Helper() before := gitRev(t, c, "docket") r := runBlock(t, c, st, body) - if gitRev(t, c, "docket") == before { + after := gitRev(t, c, "docket") + if after == before { t.Fatalf("guide says repair pushes to the docket branch; origin/docket did not move") } mustContain(t, "repair output", r.Stdout+r.Stderr, "docket repository prepare") - chk := c.run(t, st.Cwd, "docket", "--json", "repository", "check") - got := map[string]bool{} - for _, f := range findingCodes(t, chk.Stdout) { - got[f.Code] = true - } - for _, code := range []string{"metadata-worktree-dirty", "local-metadata-diverged"} { - mustContain(t, "guide", st.Guide, "`"+code+"`") - if !got[code] { - t.Errorf("guide says check reports %s before prepare; it reported %v", code, chk.Stdout) - } + mustContain(t, "guide", st.Guide, repairPrepareOptionalProse) + local := strings.TrimSpace(c.mustGit(t, filepath.Join(st.Cwd, ".docket"), "rev-parse", "HEAD")) + if local == after { + t.Fatalf("the local .docket copy is already at the repaired tip; the guide's claim that prepare is optional is untested") } } +// runRepoConfirm checks the guide's claim that `docket repository check` alone +// reports healthy right after the repair, with no `prepare` step in between. func runRepoConfirm(t *testing.T, c *upgradeCase, st *runState, body string) { t.Helper() + if strings.TrimSpace(body) != "docket repository check" { + t.Fatalf("repo-confirm block must be exactly `docket repository check`:\n%s", body) + } r := runBlock(t, c, st, body) mustContain(t, "repository check output", r.Stdout+r.Stderr, "repository check: no-op (healthy)") } diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index 11403b587..de7a8a5d2 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -42,6 +42,7 @@ tests/test_go_integration_app_reporecovery.sh 20 parallel tests/test_go_integration_app_reporepair.sh 30 parallel tests/test_go_integration_app_reposetup.sh 15 parallel tests/test_go_integration_app_reposetup_race.sh 25 parallel +tests/test_go_integration_app_reposynccheck.sh 30 parallel tests/test_go_integration_app_state.sh 50 parallel tests/test_go_integration_app_workflow.sh 35 parallel tests/test_go_integration_app_workflowlifecycle.sh 35 parallel diff --git a/tests/test_go_integration_app_reposynccheck.sh b/tests/test_go_integration_app_reposynccheck.sh new file mode 100755 index 000000000..6d2293ed1 --- /dev/null +++ b/tests/test_go_integration_app_reposynccheck.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_reposynccheck.sh — Go integration shard: the local +# .docket copy relationship scenarios for `repository check` (a clean behind-only copy +# is healthy with no finding and configure-tests accepts it; ahead, diverged, and +# dirty-behind each report exactly their own finding), behind the `integration` build +# tag, prefix ^TestIntegrationRepoSyncCheck. A new shard because no existing app +# prefix is a name-prefix of TestIntegrationRepoSyncCheck. Declarations only — +# execution and inspection live in tests/lib/go-integration-shard.sh; the +# completeness contract is tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationRepoSyncCheck" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" From 0df8d84828809466f60433bc5566b2097e2fa36a Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 08:10:00 -0400 Subject: [PATCH 08/17] fix(check): an unfinished Git operation in .docket is not clean --- internal/app/repository_check.go | 25 ++++++++---- internal/app/repository_check_test.go | 21 ++++++++++ internal/app/repository_prepare.go | 8 +++- .../app/reposynccheck_integration_test.go | 39 +++++++++++++++++++ 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/internal/app/repository_check.go b/internal/app/repository_check.go index 9b0aaef6b..4373f3cd8 100644 --- a/internal/app/repository_check.go +++ b/internal/app/repository_check.go @@ -212,7 +212,7 @@ func augmentCheckFacts(ctx context.Context, git *gitcli.Client, f *reposetup.Fac // present. if f.DocketWorktree.Presence == reposetup.PresencePresent { worktreeDir := filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName) - f.DocketWorktree.Clean = worktreeCleanPresence(ctx, git, worktreeDir) + f.DocketWorktree.Clean, f.DocketWorktree.UnfinishedOperation = worktreeCleanState(ctx, git, worktreeDir) f.DocketWorktree.HooksOff = hooksOffPresence(ctx, git, worktreeDir) } applyLocalMetadataSync(ctx, git, sc.repo, f) @@ -258,17 +258,28 @@ func augmentCheckFacts(ctx context.Context, git *gitcli.Client, f *reposetup.Fac } } -// worktreeCleanPresence reports whether the .docket worktree has no -// uncommitted/untracked change. A status read error is the safe Unknown. -func worktreeCleanPresence(ctx context.Context, git *gitcli.Client, worktreeDir string) reposetup.Presence { +// worktreeCleanState is the ONE clean probe for the .docket worktree (check and +// prepare both call it). Clean means no uncommitted or untracked change AND no +// unfinished Git operation (merge, cherry-pick, revert, rebase, am, bisect): a merge +// whose index equals HEAD lists nothing in `git status`, yet a fast-forward would +// silently drop its MERGE_HEAD. The bool reports that an unfinished operation is +// what made it not clean. Any probe error is the safe Unknown. +func worktreeCleanState(ctx context.Context, git *gitcli.Client, worktreeDir string) (reposetup.Presence, bool) { + st, err := git.WorktreeCheckoutState(ctx, worktreeDir) + if err != nil { + return reposetup.PresenceUnknown, false + } changes, err := git.ChangedPaths(ctx, worktreeDir) if err != nil { - return reposetup.PresenceUnknown + return reposetup.PresenceUnknown, false + } + if st.OperationInProgress { + return reposetup.PresenceAbsent, true } if len(changes) == 0 { - return reposetup.PresencePresent + return reposetup.PresencePresent, false } - return reposetup.PresenceAbsent + return reposetup.PresenceAbsent, false } // hooksOffPresence reports whether the .docket worktree's hooks are disabled the diff --git a/internal/app/repository_check_test.go b/internal/app/repository_check_test.go index be85cc68f..9c87c3c7e 100644 --- a/internal/app/repository_check_test.go +++ b/internal/app/repository_check_test.go @@ -235,4 +235,25 @@ func TestLocalMetadataRefusalsMatchPrepare(t *testing.T) { t.Errorf("%s: check %+v != prepare %+v", tc.reason, got[0], *v.finding) } } + + // A dirty worktree, with and without an unfinished Git operation: prepare's + // refusal message is check's metadata-worktree-dirty message for the same facts. + for _, op := range []bool{false, true} { + f := preparableFacts() + f.DocketWorktree.Clean = reposetup.PresenceAbsent + f.DocketWorktree.UnfinishedOperation = op + v := prepareRoute(f) + if v.finding == nil { + t.Fatalf("dirty (operation=%v): prepare produced no finding", op) + } + got := reposetup.EvaluateHealth( + reposetup.Classification{State: reposetup.StateConflict, Reasons: []string{"metadata-worktree-dirty"}}, + reposetup.Facts{DocketWorktree: reposetup.WorktreeFact{UnfinishedOperation: op}}, nil) + if len(got) != 1 { + t.Fatalf("dirty (operation=%v): check findings = %+v", op, got) + } + if got[0].Code != v.finding.Code || got[0].Message != v.finding.Message { + t.Errorf("dirty (operation=%v): check %+v != prepare %+v", op, got[0], *v.finding) + } + } } diff --git a/internal/app/repository_prepare.go b/internal/app/repository_prepare.go index ca3824ce1..6e3b64ecb 100644 --- a/internal/app/repository_prepare.go +++ b/internal/app/repository_prepare.go @@ -337,11 +337,15 @@ func prepareRoute(f reposetup.Facts) prepareVerdict { // resets, or stashes local content. switch f.DocketWorktree.Clean { case reposetup.PresenceAbsent: + msg := "The .docket metadata worktree has uncommitted or untracked changes." + if f.DocketWorktree.UnfinishedOperation { + msg = "The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect)." + } return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ Code: string(FCMetadataWorktreeDirty), Severity: reposetup.SeverityError, Ref: docketWorktreeName, - Message: "The .docket metadata worktree has uncommitted or untracked changes.", + Message: msg, Remedy: "Commit or set aside the changes in the .docket metadata worktree, then re-run.", }) case reposetup.PresenceUnknown: @@ -555,7 +559,7 @@ func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, // The .docket worktree clean state, probed only when the worktree is present. if f.DocketWorktree.Presence == reposetup.PresencePresent { - f.DocketWorktree.Clean = worktreeCleanPresence(ctx, git, filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName)) + f.DocketWorktree.Clean, f.DocketWorktree.UnfinishedOperation = worktreeCleanState(ctx, git, filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName)) } applyLocalMetadataSync(ctx, git, sc.repo, f) diff --git a/internal/app/reposynccheck_integration_test.go b/internal/app/reposynccheck_integration_test.go index bcbdfb751..915545ce1 100644 --- a/internal/app/reposynccheck_integration_test.go +++ b/internal/app/reposynccheck_integration_test.go @@ -5,6 +5,7 @@ package app import ( "os" "path/filepath" + "strings" "testing" "github.com/danielhanold/docket/internal/reposetup" @@ -82,3 +83,41 @@ func TestIntegrationRepoSyncCheckDirtyBehindReportsDirtyOnly(t *testing.T) { t.Fatalf("dirty+behind: findings=%+v, want metadata-worktree-dirty only", res.Findings) } } + +// TestIntegrationRepoSyncCheckUnfinishedMergeIsDirty: a merge whose index equals HEAD +// (so `git status` lists nothing) is still not clean. Check reports it as dirty and +// names the operation; prepare refuses and keeps MERGE_HEAD. +func TestIntegrationRepoSyncCheckUnfinishedMergeIsDirty(t *testing.T) { + r := newHealthyRepo(t) + r.advanceRemoteDocket(t, "notes/advance.txt", "advanced\n", "advance docket") + dot := filepath.Join(r.invocation, ".docket") + runGit(t, dot, "fetch", "-q", "origin", "docket") + runGit(t, dot, "merge", "--no-ff", "--no-commit", "-s", "ours", "FETCH_HEAD") + if s := runGit(t, dot, "status", "--porcelain", "--untracked-files=all"); s != "" { + t.Fatalf("premise: the unfinished merge must leave status empty, got:\n%s", s) + } + headBefore := r.dotDocketHead(t) + mergeHead := runGit(t, dot, "rev-parse", "MERGE_HEAD") + + res := r.runCheck(t) + var msg string + for _, f := range res.Findings { + if f.Code == "metadata-worktree-dirty" { + msg = f.Message + } + } + if !strings.Contains(msg, "unfinished Git operation") { + t.Fatalf("check findings %+v, want metadata-worktree-dirty naming the unfinished operation", res.Findings) + } + + pr := runPrepareAt(t, r.invocation) + if pr.Disposition != PrepareDispositionRefused || !prepareFinding(pr, "metadata-worktree-dirty") { + t.Fatalf("prepare = %q %+v, want refused metadata-worktree-dirty", pr.Disposition, pr.Findings) + } + if got := runGit(t, dot, "rev-parse", "MERGE_HEAD"); got != mergeHead { + t.Fatalf("MERGE_HEAD = %q after prepare, want kept %q", got, mergeHead) + } + if got := r.dotDocketHead(t); got != headBefore { + t.Fatalf(".docket HEAD moved from %s to %s", headBefore, got) + } +} From 61b48f9afbc73df9169017675ef24aba3bb613fa Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 08:13:33 -0400 Subject: [PATCH 09/17] fix(prepare): fast-forward .docket in place with a compare-and-swap and no hooks --- .../app/repoinplaceff_integration_test.go | 228 ++++++++++++++++++ internal/app/repoprepare_integration_test.go | 10 +- internal/app/repository_prepare.go | 66 +++-- internal/app/repository_prepare_test.go | 3 + tests/runtime-budgets.tsv | 1 + .../test_go_integration_app_repoinplaceff.sh | 27 +++ 6 files changed, 293 insertions(+), 42 deletions(-) create mode 100644 internal/app/repoinplaceff_integration_test.go create mode 100755 tests/test_go_integration_app_repoinplaceff.sh diff --git a/internal/app/repoinplaceff_integration_test.go b/internal/app/repoinplaceff_integration_test.go new file mode 100644 index 000000000..1b38753d6 --- /dev/null +++ b/internal/app/repoinplaceff_integration_test.go @@ -0,0 +1,228 @@ +//go:build integration + +package app + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/reposetup" + "github.com/danielhanold/docket/internal/testsupport" +) + +// --- in-place fast-forward scenarios (TestIntegrationRepoInPlaceFF shard) ---------- +// +// prepare fast-forwards a clean, behind .docket inside the existing worktree: +// nothing removed, compare-and-swap on the router's observed tip, no repository +// hook, refuse rather than discard. Each scenario starts from a check-healthy +// repository whose remote docket branch is one commit ahead of .docket. + +func newBehindHealthyRepo(t *testing.T) (r *initRepo, oldTip, newTip string) { + t.Helper() + r = newHealthyRepo(t) + oldTip = r.dotDocketHead(t) + newTip = r.advanceRemoteDocket(t, "notes/advance.txt", "advanced\n", "advance docket") + return r, oldTip, newTip +} + +func requireFastForwarded(t *testing.T, r *initRepo, newTip string) { + t.Helper() + res := runPrepareAt(t, r.invocation) + if res.Disposition != PrepareDispositionApplied { + t.Fatalf("prepare = %q (%s), want applied", res.Disposition, res.HumanText()) + } + if head := r.dotDocketHead(t); head != newTip { + t.Fatalf(".docket HEAD = %s, want %s", head, newTip) + } +} + +func requireCheckHealthy(t *testing.T, r *initRepo) { + t.Helper() + res := r.runCheck(t) + if res.RepositoryState != string(reposetup.StateHealthy) || res.CheckExitCode() != 0 { + t.Fatalf("check after prepare: state=%q findings=%+v exit=%d", res.RepositoryState, res.Findings, res.CheckExitCode()) + } +} + +func writeHook(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body), 0o755); err != nil { + t.Fatal(err) + } +} + +func TestIntegrationRepoInPlaceFFLockedWorktree(t *testing.T) { + r, _, newTip := newBehindHealthyRepo(t) + dot := filepath.Join(r.invocation, ".docket") + runGit(t, r.invocation, "worktree", "lock", dot) + requireFastForwarded(t, r, newTip) + if list := runGit(t, r.invocation, "worktree", "list", "--porcelain"); !strings.Contains(list, "\nlocked") { + t.Fatalf(".docket lock lost:\n%s", list) + } +} + +// TestIntegrationRepoInPlaceFFRepositoryHooksNeverRun installs failing, file-writing +// post-checkout and post-merge hooks through the repository's shared hooks path. +// A remove/re-add (or a porcelain merge) would run them. +func TestIntegrationRepoInPlaceFFRepositoryHooksNeverRun(t *testing.T) { + r, _, newTip := newBehindHealthyRepo(t) + hooks := filepath.Join(testsupport.TempDir(t), "shared-hooks") + sentinel := filepath.Join(testsupport.TempDir(t), "hook-ran") + for _, h := range []string{"post-checkout", "post-merge"} { + writeHook(t, hooks, h, "echo "+h+" >> '"+sentinel+"'\nexit 1\n") + } + runGit(t, r.invocation, "config", "core.hooksPath", hooks) + requireFastForwarded(t, r, newTip) + if b, err := os.ReadFile(sentinel); err == nil { + t.Fatalf("a repository hook ran: %q", b) + } + requireCheckHealthy(t, r) +} + +func TestIntegrationRepoInPlaceFFIgnoredFileAndDirectoryIdentity(t *testing.T) { + r, _, newTip := newBehindHealthyRepo(t) + dot := filepath.Join(r.invocation, ".docket") + exclude := filepath.Join(r.invocation, ".git", "info", "exclude") + f, err := os.OpenFile(exclude, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString("*.local\n"); err != nil { + t.Fatal(err) + } + f.Close() + sentinel := filepath.Join(dot, "sentinel.local") + if err := os.WriteFile(sentinel, []byte("keep\n"), 0o644); err != nil { + t.Fatal(err) + } + before, err := os.Stat(dot) + if err != nil { + t.Fatal(err) + } + requireFastForwarded(t, r, newTip) + after, err := os.Stat(dot) + if err != nil || !os.SameFile(before, after) { + t.Fatalf(".docket directory was removed and re-created (err=%v)", err) + } + if b, err := os.ReadFile(sentinel); err != nil || string(b) != "keep\n" { + t.Fatalf("ignored file lost: %q, %v", b, err) + } + requireCheckHealthy(t, r) +} + +// TestIntegrationRepoInPlaceFFStaleObservedTipRefuses hands the execute step the +// tip the router saw, after a commit landed in .docket: it must refuse and keep it. +func TestIntegrationRepoInPlaceFFStaleObservedTipRefuses(t *testing.T) { + r, oldTip, newTip := newBehindHealthyRepo(t) + runGit(t, r.invocation, "fetch", "-q", "origin", "docket") + runGit(t, r.invocation, "cat-file", "-e", newTip+"^{commit}") // premise: target object is local + client := newGitClient(t) + _, sc, err := GatherSetupFacts(context.Background(), SetupDeps{Git: client, RepoDir: r.invocation}, true) + if err != nil { + t.Fatal(err) + } + moved := r.commitInDocket(t, "late.txt", "late\n", "commit after routing") + + v := prepareVerdict{disposition: PrepareDispositionApplied, action: prepareActionFastForward, targetRev: newTip, observedTip: oldTip} + if err := prepareExecute(context.Background(), client, sc, v); err == nil { + t.Fatal("fast-forward from a stale observed tip succeeded; want refusal") + } + if got := runGit(t, r.invocation, "rev-parse", "refs/heads/docket"); got != moved { + t.Fatalf("local docket = %s, want the late commit %s kept", got, moved) + } + if b := mustReadFile(t, filepath.Join(r.invocation, ".docket", "late.txt")); string(b) != "late\n" { + t.Fatalf("late.txt = %q", b) + } +} + +// TestIntegrationRepoInPlaceFFHooksOffMissing removes the per-worktree hooks-off +// setting; the forced empty hooks dir must still keep the shared hooks silent inside +// .docket. Git runs a hook from the worktree root, so each hook records $PWD, and the +// primary's own fetches (which do run reference-transaction) are told apart from the +// fast-forward. post-index-change is deliberately NOT installed: prepare's own +// `git status` clean probe inside .docket may write the index and would fire it +// before the fast-forward starts, which is not what this test measures. +func TestIntegrationRepoInPlaceFFHooksOffMissing(t *testing.T) { + r, _, newTip := newBehindHealthyRepo(t) + dot := filepath.Join(r.invocation, ".docket") + hooks := filepath.Join(testsupport.TempDir(t), "shared-hooks") + sentinel := filepath.Join(testsupport.TempDir(t), "hook-ran") + for _, h := range []string{"reference-transaction", "post-checkout", "post-merge"} { + writeHook(t, hooks, h, "echo \""+h+" $PWD\" >> '"+sentinel+"'\nexit 0\n") + } + runGit(t, r.invocation, "config", "core.hooksPath", hooks) + runGit(t, dot, "config", "--worktree", "--unset", "core.hooksPath") + + dotReal, err := filepath.EvalSymlinks(dot) + if err != nil { + t.Fatal(err) + } + inDocket := func(line string) bool { + return strings.HasSuffix(line, " "+dot) || strings.HasSuffix(line, " "+dotReal) + } + + // Premise: with hooks-off missing, a ref update inside .docket does run the hook. + runGit(t, dot, "update-ref", "refs/docket-test/premise", "HEAD") + runGit(t, dot, "update-ref", "-d", "refs/docket-test/premise") + premise, _ := os.ReadFile(sentinel) + ran := false + for _, line := range strings.Split(strings.TrimSpace(string(premise)), "\n") { + ran = ran || inDocket(line) + } + if !ran { + t.Fatalf("premise: the shared hook did not run inside .docket:\n%s", premise) + } + if err := os.Remove(sentinel); err != nil { + t.Fatal(err) + } + + requireFastForwarded(t, r, newTip) + b, _ := os.ReadFile(sentinel) + for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { + if inDocket(line) { + t.Fatalf("a repository hook ran inside .docket during prepare: %q", line) + } + } +} + +func TestIntegrationRepoInPlaceFFIdempotent(t *testing.T) { + r, _, newTip := newBehindHealthyRepo(t) + requireFastForwarded(t, r, newTip) + second := runPrepareAt(t, r.invocation) + if second.Disposition != PrepareDispositionNoOp { + t.Fatalf("second prepare = %q (%s), want no-op", second.Disposition, second.HumanText()) + } + hooksPath := runGit(t, filepath.Join(r.invocation, ".docket"), "config", "--worktree", "core.hooksPath") + if hooksPath == "" { + t.Fatal("per-worktree hooks-off setting was removed") + } + requireCheckHealthy(t, r) +} + +// TestIntegrationRepoInPlaceFFInterruptedReadsDirty simulates an interruption after +// the branch moved but before the tree did: it must read dirty, never clean, and +// prepare must refuse without removing anything. +func TestIntegrationRepoInPlaceFFInterruptedReadsDirty(t *testing.T) { + r, oldTip, newTip := newBehindHealthyRepo(t) + dot := filepath.Join(r.invocation, ".docket") + runGit(t, r.invocation, "fetch", "-q", "origin", "docket") + runGit(t, dot, "update-ref", "refs/heads/docket", newTip, oldTip) + + res := r.runCheck(t) + if !checkCodes(res)["metadata-worktree-dirty"] { + t.Fatalf("interrupted fast-forward: findings %+v, want metadata-worktree-dirty", res.Findings) + } + pr := runPrepareAt(t, r.invocation) + if pr.Disposition != PrepareDispositionRefused { + t.Fatalf("prepare = %q, want refused", pr.Disposition) + } + if _, err := os.Stat(dot); err != nil { + t.Fatalf(".docket removed: %v", err) + } +} diff --git a/internal/app/repoprepare_integration_test.go b/internal/app/repoprepare_integration_test.go index 41c1f55d1..3137904d8 100644 --- a/internal/app/repoprepare_integration_test.go +++ b/internal/app/repoprepare_integration_test.go @@ -18,10 +18,12 @@ import ( // against the same bare-upstream + clone fixture the init/check/migrate shards use. // Prepare is the only operation that attaches or fast-forwards the local `.docket` // worktree, so its real-git correctness (worktree registration, the clean-behind -// fast-forward composed from the worktree primitives, and hooks disabling via the -// mechanism scripts/disable-worktree-hooks.sh documents) is provable only against real -// repositories — the clean-behind fast-forward in particular (prepareFastForward- -// Worktree) has no unit coverage of its multi-step remove/delete/re-add sequence. +// fast-forward to the pinned revision, and hooks disabling via the mechanism +// scripts/disable-worktree-hooks.sh documents) is provable only against real +// repositories. The clean-behind fast-forward is an in-place compare-and-swap +// fast-forward (gitcli's FastForwardCheckedOutBranch); its properties — nothing +// removed, a stale observed tip refused, no repository hook run — are proven in the +// TestIntegrationRepoInPlaceFF shard (repoinplaceff_integration_test.go). // // The crux these tests prove: the remote docket branch is a REAL MULTI-COMMIT chain // (an init-seed parentless root plus a descendant), not a single seed commit. The diff --git a/internal/app/repository_prepare.go b/internal/app/repository_prepare.go index 6e3b64ecb..d8e6f19ac 100644 --- a/internal/app/repository_prepare.go +++ b/internal/app/repository_prepare.go @@ -18,7 +18,7 @@ import ( // repository-preparation operation. It discovers the repository, pins the // authoritative topology, classifies once, requires the fixed Go-v1 docket-branch // topology, and — for a healthy remote whose local `.docket` checkout is absent or -// cleanly behind — attaches or fast-forwards it idempotently. It creates no +// cleanly behind — attaches it or fast-forwards it in place, idempotently. It creates no // planning record, makes no lifecycle decision, and pushes no remote mutation // (spec §Purpose and interface). Attachment and fast-forward are the only Git // effects, both keyed on the re-read remote metadata revision so a lost-response @@ -218,6 +218,7 @@ type prepareVerdict struct { disposition string action prepareAction targetRev string + observedTip string // the local docket tip the router decided on; every branch move is a compare-and-swap from it ("" when no local branch exists) state reposetup.State finding *reposetup.Finding } @@ -317,7 +318,7 @@ func prepareRoute(f reposetup.Facts) prepareVerdict { // presence is fail-closed. switch f.DocketWorktree.Presence { case reposetup.PresenceAbsent: - return prepareApplyVerdict(prepareActionAttach, f.RemoteMetadata.Tip) + return prepareApplyVerdict(prepareActionAttach, f.RemoteMetadata.Tip, "") case reposetup.PresenceUnknown: return prepareLocalUnknownVerdict() } @@ -359,7 +360,7 @@ func prepareRoute(f reposetup.Facts) prepareVerdict { case reposetup.SyncCurrent: return prepareVerdict{disposition: PrepareDispositionNoOp, action: prepareActionNone, state: reposetup.StateHealthy} case reposetup.SyncBehind: - return prepareApplyVerdict(prepareActionFastForward, f.RemoteMetadata.Tip) + return prepareApplyVerdict(prepareActionFastForward, f.RemoteMetadata.Tip, f.LocalMetadata.Tip) case reposetup.SyncAhead: return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ Code: string(FCLocalMetadataAhead), @@ -416,9 +417,10 @@ func prepareLocalUnknownVerdict() prepareVerdict { } // prepareApplyVerdict builds an applied verdict for a planned Git effect keyed on -// the pinned target revision. -func prepareApplyVerdict(action prepareAction, targetRev string) prepareVerdict { - return prepareVerdict{disposition: PrepareDispositionApplied, action: action, targetRev: targetRev, state: reposetup.StateHealthy} +// the pinned target revision, carrying the local tip the router observed so the +// effect moves the branch by compare-and-swap from exactly that tip. +func prepareApplyVerdict(action prepareAction, targetRev, observedTip string) prepareVerdict { + return prepareVerdict{disposition: PrepareDispositionApplied, action: action, targetRev: targetRev, observedTip: observedTip, state: reposetup.StateHealthy} } // prepareRefuseVerdict builds a refused verdict naming the classified state and a @@ -473,10 +475,11 @@ func buildPrepareContext(cfg config.Effective, sc setupContext, f reposetup.Fact // repository and origin, pins the topology and loads configuration, gathers the // classifier facts augmented with the local worktree/sync state, classifies once // through prepareRoute, and — for a healthy topology — attaches an absent local -// `.docket` worktree or fast-forwards a clean strictly-behind one to the pinned -// remote metadata revision. Attachment and fast-forward are the only mutations; -// both are idempotent and keyed on the re-read remote state, so a lost-response -// retry converges. It never initializes or migrates implicitly and never pushes. +// `.docket` worktree or fast-forwards a clean strictly-behind one in place to the +// pinned remote metadata revision. Attachment and fast-forward are the only +// mutations; both are idempotent and keyed on the re-read remote state, so a +// lost-response retry converges. It never initializes or migrates implicitly and +// never pushes. func RunRepositoryPrepare(ctx context.Context, d SetupDeps, o PrepareOptions) RepositoryPrepareResult { if o.RepoDir != "" { d.RepoDir = o.RepoDir @@ -568,8 +571,12 @@ func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, // prepareExecute performs the single planned idempotent Git effect. Attachment // materializes the local checkout of the already-valid remote topology; // fast-forward advances a clean strictly-behind worktree to the pinned remote -// revision. Both key on the re-read remote state so a lost-response retry -// converges by re-reading topology. +// revision in place (gitcli's FastForwardCheckedOutBranch): nothing is removed, the +// branch moves by compare-and-swap from the tip the router observed, and no +// repository hook runs. A commit made in `.docket` since routing, or a local change in +// the way, makes it refuse rather than discard. The worktree's hooks-off setting is +// not re-applied on that path because nothing removed it. Both effects key on the +// re-read remote state so a lost-response retry converges by re-reading topology. func prepareExecute(ctx context.Context, git *gitcli.Client, sc setupContext, verdict prepareVerdict) error { worktreePath := filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName) metaRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) @@ -581,36 +588,12 @@ func prepareExecute(ctx context.Context, git *gitcli.Client, sc setupContext, ve } return git.DisableWorktreeHooks(ctx, worktreePath) case prepareActionFastForward: - return prepareFastForwardWorktree(ctx, git, sc.repo, worktreePath, metaRef, target) + return git.FastForwardCheckedOutBranch(ctx, worktreePath, metaRef, gitcli.ObjectID(verdict.observedTip), target) default: return nil } } -// prepareFastForwardWorktree advances the clean, strictly-behind `.docket` -// worktree to the pinned remote metadata revision. The precondition (clean AND a -// strict ancestor of the target) is proven by the router, so removing the clean -// worktree, deleting the checked-out branch at its exact known tip, and re-adding -// the branch at the target loses no content and is a true fast-forward composed -// from the existing worktree primitives. It is idempotent: a re-run whose worktree -// is already absent re-attaches at the same target. -func prepareFastForwardWorktree(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, worktreePath string, metaRef gitcli.RefName, target gitcli.ObjectID) error { - oldTip, err := git.ResolveRef(ctx, repo, metaRef) - if err != nil { - return err - } - if err := git.RemoveWorktreeClean(ctx, repo, worktreePath); err != nil { - return err - } - if err := git.DeleteLocalBranchChecked(ctx, repo, metaRef, oldTip); err != nil { - return err - } - if err := git.AddBranchWorktree(ctx, repo, worktreePath, metaRef, target); err != nil { - return err - } - return git.DisableWorktreeHooks(ctx, worktreePath) -} - // prepareContextResult builds an applied/no-op result carrying the closed typed // context. The origin URL is a best-effort read: a failure leaves it empty and is // not fatal to preparation. @@ -650,13 +633,20 @@ func prepareDiagnosisResult(result Result, disposition string, verdict prepareVe return out } +// prepareFastForwardRecovery is appended to a failed in-place fast-forward's human +// text. The fast-forward removes nothing, and a refusal or interruption leaves at +// most an unfinished tree update that `git status` reports inside .docket. +const prepareFastForwardRecovery = " Nothing was removed. If `git -C .docket status` shows changes, finish or reset that update inside .docket with plain Git, then re-run `docket repository prepare`." + // prepareEffectFailure downgrades an applied verdict to an error when its Git // effect failed mid-sequence. The remote topology is untouched (prepare pushes // nothing), so a re-run re-reads and retries. func prepareEffectFailure(verdict prepareVerdict, err error, notices []string) RepositoryPrepareResult { stage := "attaching the .docket worktree" + recovery := "" if verdict.action == prepareActionFastForward { stage = "fast-forwarding the .docket worktree" + recovery = prepareFastForwardRecovery } out := RepositoryPrepareResult{ Envelope: NewEnvelope(OperationRepositoryPrepare, ResultExternalFailed), @@ -664,7 +654,7 @@ func prepareEffectFailure(verdict prepareVerdict, err error, notices []string) R RepositoryState: string(reposetup.StateNeedsReview), Notices: notices, } - out.human = fmt.Sprintf("repository prepare: %s while %s: %s", PrepareDispositionError, stage, err.Error()) + out.human = fmt.Sprintf("repository prepare: %s while %s: %s%s", PrepareDispositionError, stage, err.Error(), recovery) return out } diff --git a/internal/app/repository_prepare_test.go b/internal/app/repository_prepare_test.go index bbbb26871..ecf64217c 100644 --- a/internal/app/repository_prepare_test.go +++ b/internal/app/repository_prepare_test.go @@ -132,6 +132,9 @@ func TestRepositoryPrepareCleanBehindFastForwards(t *testing.T) { if v.targetRev != "m9" { t.Errorf("targetRev = %q, want the pinned remote metadata tip m9", v.targetRev) } + if v.observedTip != "m1" { + t.Errorf("observedTip = %q, want the router's local tip m1 (the compare-and-swap source)", v.observedTip) + } } // TestRepositoryPrepareCleanCurrentIsNoOp — a worktree already at the pinned diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index de7a8a5d2..8c526f38d 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -35,6 +35,7 @@ tests/test_go_integration_app_mixedbundle.sh 15 parallel tests/test_go_integration_app_buildstart.sh 10 parallel tests/test_go_integration_app_repocheck.sh 20 parallel tests/test_go_integration_app_repocontention.sh 15 parallel +tests/test_go_integration_app_repoinplaceff.sh 30 parallel tests/test_go_integration_app_repomigration.sh 20 parallel tests/test_go_integration_app_repoownership.sh 30 parallel tests/test_go_integration_app_repoprepare.sh 20 parallel diff --git a/tests/test_go_integration_app_repoinplaceff.sh b/tests/test_go_integration_app_repoinplaceff.sh new file mode 100755 index 000000000..e1deaa465 --- /dev/null +++ b/tests/test_go_integration_app_repoinplaceff.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_repoinplaceff.sh — Go integration shard: the in-place +# fast-forward and attach-freshness scenarios for `repository prepare` (a clean, behind +# .docket advances inside the existing worktree with nothing removed — lock, directory +# identity, ignored files, and the per-worktree hooks-off setting survive — by a +# compare-and-swap on the router's observed tip that refuses a stale tip, with no +# repository hook run even when hooks-off is missing; a re-run is a no-op; an +# interrupted fast-forward reads dirty and prepare refuses), behind the `integration` +# build tag, prefix ^TestIntegrationRepoInPlaceFF. A new shard because no existing app +# prefix is a name-prefix of TestIntegrationRepoInPlaceFF. Declarations only — +# execution and inspection live in tests/lib/go-integration-shard.sh; the +# completeness contract is tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/app" +SHARD_PREFIX="TestIntegrationRepoInPlaceFF" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" From 8b4b2419b89b0e9ca54bf3a613e3b0755660d721 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 08:17:43 -0400 Subject: [PATCH 10/17] fix(prepare): attach .docket only at the remote tip; refuse ahead or diverged branches --- .../app/repoinplaceff_integration_test.go | 55 +++++++ internal/app/repository_check_test.go | 4 +- internal/app/repository_prepare.go | 146 ++++++++++++++---- internal/app/repository_prepare_test.go | 79 ++++++++-- 4 files changed, 239 insertions(+), 45 deletions(-) diff --git a/internal/app/repoinplaceff_integration_test.go b/internal/app/repoinplaceff_integration_test.go index 1b38753d6..aa4076ad9 100644 --- a/internal/app/repoinplaceff_integration_test.go +++ b/internal/app/repoinplaceff_integration_test.go @@ -226,3 +226,58 @@ func TestIntegrationRepoInPlaceFFInterruptedReadsDirty(t *testing.T) { t.Fatalf(".docket removed: %v", err) } } + +// removeDocketWorktree deregisters a clean .docket, leaving the local branch. +func removeDocketWorktree(t *testing.T, r *initRepo) { + t.Helper() + runGit(t, r.invocation, "worktree", "remove", filepath.Join(r.invocation, ".docket")) +} + +func TestIntegrationRepoInPlaceFFAttachBehindAdvancesBranch(t *testing.T) { + r, _, newTip := newBehindHealthyRepo(t) + removeDocketWorktree(t, r) + res := runPrepareAt(t, r.invocation) + if res.Disposition != PrepareDispositionApplied { + t.Fatalf("prepare = %q (%s), want applied", res.Disposition, res.HumanText()) + } + if head := r.dotDocketHead(t); head != newTip { + t.Fatalf(".docket attached at %s, want the remote tip %s (never the stale local tip)", head, newTip) + } + if local := runGit(t, r.invocation, "rev-parse", "refs/heads/docket"); local != newTip { + t.Fatalf("local docket = %s, want %s", local, newTip) + } + requireCheckHealthy(t, r) +} + +func TestIntegrationRepoInPlaceFFAttachAheadRefuses(t *testing.T) { + r := newHealthyRepo(t) + aheadTip := r.commitInDocket(t, "ahead.txt", "ahead\n", "local-only docket commit") + removeDocketWorktree(t, r) + res := runPrepareAt(t, r.invocation) + if res.Disposition != PrepareDispositionRefused || !prepareFinding(res, "local-metadata-ahead") { + t.Fatalf("prepare = %q %+v, want refused local-metadata-ahead", res.Disposition, res.Findings) + } + if local := runGit(t, r.invocation, "rev-parse", "refs/heads/docket"); local != aheadTip { + t.Fatalf("local docket = %s, want untouched %s", local, aheadTip) + } + if _, err := os.Stat(filepath.Join(r.invocation, ".docket")); !os.IsNotExist(err) { + t.Fatalf(".docket attached on a refusal (err=%v)", err) + } +} + +func TestIntegrationRepoInPlaceFFAttachDivergedRefuses(t *testing.T) { + r := newHealthyRepo(t) + r.advanceRemoteDocket(t, "notes/remote.txt", "remote\n", "remote side") + localTip := r.commitInDocket(t, "local.txt", "local\n", "local side") + removeDocketWorktree(t, r) + res := runPrepareAt(t, r.invocation) + if res.Disposition != PrepareDispositionRefused || !prepareFinding(res, "local-metadata-diverged") { + t.Fatalf("prepare = %q %+v, want refused local-metadata-diverged", res.Disposition, res.Findings) + } + if local := runGit(t, r.invocation, "rev-parse", "refs/heads/docket"); local != localTip { + t.Fatalf("local docket = %s, want untouched %s", local, localTip) + } + if _, err := os.Stat(filepath.Join(r.invocation, ".docket")); !os.IsNotExist(err) { + t.Fatalf(".docket attached on a refusal (err=%v)", err) + } +} diff --git a/internal/app/repository_check_test.go b/internal/app/repository_check_test.go index 9c87c3c7e..0bb3a12fc 100644 --- a/internal/app/repository_check_test.go +++ b/internal/app/repository_check_test.go @@ -223,7 +223,7 @@ func TestLocalMetadataRefusalsMatchPrepare(t *testing.T) { f := preparableFacts() f.LocalMetadataSync = tc.rel f.DocketWorktree.Synchronized = reposetup.PresenceAbsent - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.finding == nil { t.Fatalf("%s: prepare produced no finding", tc.reason) } @@ -242,7 +242,7 @@ func TestLocalMetadataRefusalsMatchPrepare(t *testing.T) { f := preparableFacts() f.DocketWorktree.Clean = reposetup.PresenceAbsent f.DocketWorktree.UnfinishedOperation = op - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.finding == nil { t.Fatalf("dirty (operation=%v): prepare produced no finding", op) } diff --git a/internal/app/repository_prepare.go b/internal/app/repository_prepare.go index d8e6f19ac..320848409 100644 --- a/internal/app/repository_prepare.go +++ b/internal/app/repository_prepare.go @@ -231,8 +231,13 @@ type prepareVerdict struct { // path refuses; no remote metadata is a fresh/legacy refusal naming the exact // remedy; a non-parentless or still-live remote is refused without a local touch; // only a proven-clean, registered, strictly-behind or current worktree fast- -// forwards or is a no-op, and an absent worktree attaches. -func prepareRoute(f reposetup.Facts) prepareVerdict { +// forwards or is a no-op, and an absent worktree attaches only a local docket branch +// that is missing, current, or strictly behind (prepareAttachRoute). +// +// heldElsewhere says whether another worktree holds the local docket branch. It is +// consulted only on the absent-.docket attach row, where a behind branch must move +// before it attaches; prepareAugment probes it only for that row. +func prepareRoute(f reposetup.Facts, heldElsewhere reposetup.Presence) prepareVerdict { // 1. Fail-closed: a required topology probe that could not be resolved is an // error, never a fabricated absence that could authorize an attach. if u := prepareUnresolvedTopology(f); len(u) > 0 { @@ -314,11 +319,11 @@ func prepareRoute(f reposetup.Facts) prepareVerdict { }) } - // 5. Local `.docket` worktree. Absent is the idempotent attach; an unproven - // presence is fail-closed. + // 5. Local `.docket` worktree. Absent attaches at the remote tip, keyed on the + // local branch's freshness; an unproven presence is fail-closed. switch f.DocketWorktree.Presence { case reposetup.PresenceAbsent: - return prepareApplyVerdict(prepareActionAttach, f.RemoteMetadata.Tip, "") + return prepareAttachRoute(f, heldElsewhere) case reposetup.PresenceUnknown: return prepareLocalUnknownVerdict() } @@ -362,19 +367,70 @@ func prepareRoute(f reposetup.Facts) prepareVerdict { case reposetup.SyncBehind: return prepareApplyVerdict(prepareActionFastForward, f.RemoteMetadata.Tip, f.LocalMetadata.Tip) case reposetup.SyncAhead: - return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ - Code: string(FCLocalMetadataAhead), - Severity: reposetup.SeverityError, - Message: "The local docket branch is ahead of the remote docket branch (it carries commits the remote does not).", - Remedy: "Reconcile the local docket branch with the remote manually with a human before any repository operation.", - }) + return prepareRefuseVerdict(reposetup.StateConflict, prepareAheadFinding()) case reposetup.SyncDiverged: - return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ - Code: string(FCLocalMetadataDiverged), - Severity: reposetup.SeverityError, - Message: "The local docket branch has diverged from the remote docket branch.", - Remedy: "Reconcile the local and remote docket branches manually with a human before any repository operation.", - }) + return prepareRefuseVerdict(reposetup.StateConflict, prepareDivergedFinding()) + default: + return prepareLocalUnknownVerdict() + } +} + +// prepareAheadFinding and prepareDivergedFinding are prepare's refusals for a local +// docket branch carrying commits the remote lacks; the worktree row and the attach +// row share them. Their texts match check's findings (TestLocalMetadataRefusalsMatchPrepare). +func prepareAheadFinding() reposetup.Finding { + return reposetup.Finding{ + Code: string(FCLocalMetadataAhead), + Severity: reposetup.SeverityError, + Message: "The local docket branch is ahead of the remote docket branch (it carries commits the remote does not).", + Remedy: "Reconcile the local docket branch with the remote manually with a human before any repository operation.", + } +} + +func prepareDivergedFinding() reposetup.Finding { + return reposetup.Finding{ + Code: string(FCLocalMetadataDiverged), + Severity: reposetup.SeverityError, + Message: "The local docket branch has diverged from the remote docket branch.", + Remedy: "Reconcile the local and remote docket branches manually with a human before any repository operation.", + } +} + +// prepareAttachRoute decides the absent-.docket row by the local docket branch's +// relationship to the pinned remote tip, so prepare never attaches a stale, ahead, +// or diverged branch as-is and then reports healthy. A behind branch moves to the +// target first (a compare-and-swap from the observed tip); a behind branch another +// worktree holds is a forced, unsupported setup and refuses with nothing touched. +func prepareAttachRoute(f reposetup.Facts, heldElsewhere reposetup.Presence) prepareVerdict { + target := f.RemoteMetadata.Tip + switch f.LocalMetadata.Presence { + case reposetup.PresenceAbsent: + return prepareApplyVerdict(prepareActionAttach, target, "") + case reposetup.PresenceUnknown: + return prepareLocalUnknownVerdict() + } + switch f.LocalMetadataSync { + case reposetup.SyncCurrent: + return prepareApplyVerdict(prepareActionAttach, target, f.LocalMetadata.Tip) + case reposetup.SyncBehind: + switch heldElsewhere { + case reposetup.PresenceAbsent: + return prepareApplyVerdict(prepareActionAttach, target, f.LocalMetadata.Tip) + case reposetup.PresencePresent: + return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ + Code: string(FCDocketWorktreeAmbiguousRegistration), + Severity: reposetup.SeverityError, + Ref: docketWorktreeName, + Message: "The local docket branch is checked out in another worktree, so it cannot be moved to the remote tip and attached at .docket.", + Remedy: "Inspect the worktree holding the docket branch and resolve it manually with a human, then run `docket repository check`.", + }) + default: + return prepareLocalUnknownVerdict() + } + case reposetup.SyncAhead: + return prepareRefuseVerdict(reposetup.StateConflict, prepareAheadFinding()) + case reposetup.SyncDiverged: + return prepareRefuseVerdict(reposetup.StateConflict, prepareDivergedFinding()) default: return prepareLocalUnknownVerdict() } @@ -475,7 +531,8 @@ func buildPrepareContext(cfg config.Effective, sc setupContext, f reposetup.Fact // repository and origin, pins the topology and loads configuration, gathers the // classifier facts augmented with the local worktree/sync state, classifies once // through prepareRoute, and — for a healthy topology — attaches an absent local -// `.docket` worktree or fast-forwards a clean strictly-behind one in place to the +// `.docket` worktree at the pinned remote revision (moving a strictly-behind local +// docket branch there first) or fast-forwards a clean strictly-behind one in place to the // pinned remote metadata revision. Attachment and fast-forward are the only // mutations; both are idempotent and keyed on the re-read remote state, so a // lost-response retry converges. It never initializes or migrates implicitly and @@ -494,11 +551,12 @@ func RunRepositoryPrepare(ctx context.Context, d SetupDeps, o PrepareOptions) Re // metadata branch is proven present; a fresh/legacy repository is decided from // the base facts alone. Every probe maps its own error to the safe Unknown // value, never a false absence. + held := reposetup.PresenceUnknown if facts.RemoteMetadata.Presence == reposetup.PresencePresent { - prepareAugment(ctx, d.Git, &facts, sc) + held = prepareAugment(ctx, d.Git, &facts, sc) } - verdict := prepareRoute(facts) + verdict := prepareRoute(facts, held) notices := prepareNotices(sc) switch verdict.disposition { @@ -519,10 +577,12 @@ func RunRepositoryPrepare(ctx context.Context, d SetupDeps, o PrepareOptions) Re // prepareAugment fills the local metadata/worktree/sync facts the base gatherer // deliberately leaves unproven, including LocalMetadataSync (the ancestry // relationship between the local metadata tip and the pinned remote metadata -// revision, computed by applyLocalMetadataSync), and returns nothing. Every probe -// maps its own error to the safe Unknown value so a probe that could not run can -// never let the router read healthy. -func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, sc setupContext) { +// revision, computed by applyLocalMetadataSync). It returns whether another worktree +// holds the local docket branch, probed only when `.docket` is absent and the local +// branch is present — the one attach row that consults it; everywhere else it is +// PresenceUnknown and never read. Every probe maps its own error to the safe Unknown +// value so a probe that could not run can never let the router read healthy. +func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, sc setupContext) reposetup.Presence { metaRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) // Metadata root shape at the FETCHED remote docket tip (fetch it first so the @@ -566,10 +626,25 @@ func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, } applyLocalMetadataSync(ctx, git, sc.repo, f) + + if f.DocketWorktree.Presence != reposetup.PresenceAbsent || f.LocalMetadata.Presence != reposetup.PresencePresent { + return reposetup.PresenceUnknown // not consulted outside the absent-.docket attach row + } + wts, err := git.ListWorktrees(ctx, sc.repo) + if err != nil { + return reposetup.PresenceUnknown + } + for _, wt := range wts { + if wt.Branch == metaRef { + return reposetup.PresencePresent + } + } + return reposetup.PresenceAbsent } // prepareExecute performs the single planned idempotent Git effect. Attachment -// materializes the local checkout of the already-valid remote topology; +// materializes the local checkout of the already-valid remote topology at the pinned +// remote revision (prepareAttachFresh), never at a stale local tip; // fast-forward advances a clean strictly-behind worktree to the pinned remote // revision in place (gitcli's FastForwardCheckedOutBranch): nothing is removed, the // branch moves by compare-and-swap from the tip the router observed, and no @@ -580,20 +655,35 @@ func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, func prepareExecute(ctx context.Context, git *gitcli.Client, sc setupContext, verdict prepareVerdict) error { worktreePath := filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName) metaRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) - target := gitcli.ObjectID(verdict.targetRev) switch verdict.action { case prepareActionAttach: - if _, err := ensureMetadataWorktree(ctx, git, sc.repo, worktreePath, metaRef, target); err != nil { + if err := prepareAttachFresh(ctx, git, sc.repo, worktreePath, metaRef, verdict); err != nil { return err } return git.DisableWorktreeHooks(ctx, worktreePath) case prepareActionFastForward: - return git.FastForwardCheckedOutBranch(ctx, worktreePath, metaRef, gitcli.ObjectID(verdict.observedTip), target) + return git.FastForwardCheckedOutBranch(ctx, worktreePath, metaRef, gitcli.ObjectID(verdict.observedTip), gitcli.ObjectID(verdict.targetRev)) default: return nil } } +// prepareAttachFresh is prepare's own attach; ensureMetadataWorktree stays init's and +// migrate's, with its contract unchanged. With no local docket branch it creates one +// at the target (git refuses if a branch appeared since routing). With one, it moves +// that branch from the observed tip to the target by compare-and-swap (a no-op that +// still verifies the tip when it is already current), then attaches it. +func prepareAttachFresh(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, worktreePath string, metaRef gitcli.RefName, v prepareVerdict) error { + target := gitcli.ObjectID(v.targetRev) + if v.observedTip == "" { + return git.AddBranchWorktree(ctx, repo, worktreePath, metaRef, target) + } + if err := git.AdvanceBranchChecked(ctx, repo, metaRef, gitcli.ObjectID(v.observedTip), target); err != nil { + return err + } + return git.AttachBranchWorktree(ctx, repo, worktreePath, metaRef) +} + // prepareContextResult builds an applied/no-op result carrying the closed typed // context. The origin URL is a best-effort read: a failure leaves it empty and is // not fatal to preparation. diff --git a/internal/app/repository_prepare_test.go b/internal/app/repository_prepare_test.go index ecf64217c..4e6131123 100644 --- a/internal/app/repository_prepare_test.go +++ b/internal/app/repository_prepare_test.go @@ -55,7 +55,7 @@ func TestRepositoryPrepareFreshRefusesWithInitRemedy(t *testing.T) { f.LiveSurface = reposetup.PresenceAbsent f.LocalMetadataSync = reposetup.SyncUnknown - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -81,7 +81,7 @@ func TestRepositoryPrepareLegacyRefusesWithMigrateRemedy(t *testing.T) { f.LiveSurface = reposetup.PresencePresent f.LocalMetadataSync = reposetup.SyncUnknown - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionRefused || v.state != reposetup.StateLegacy { t.Fatalf("verdict = %q/%q, want refused/legacy", v.disposition, v.state) } @@ -102,7 +102,7 @@ func TestRepositoryPrepareHealthyMissingWorktreeAttaches(t *testing.T) { f.LocalMetadata = reposetup.BranchFact{Presence: reposetup.PresenceAbsent} f.LocalMetadataSync = reposetup.SyncUnknown - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionApplied { t.Fatalf("disposition = %q, want applied", v.disposition) } @@ -112,6 +112,55 @@ func TestRepositoryPrepareHealthyMissingWorktreeAttaches(t *testing.T) { if v.targetRev != "m9" { t.Errorf("targetRev = %q, want the pinned remote metadata tip m9", v.targetRev) } + if v.observedTip != "" { + t.Errorf("observedTip = %q, want empty (no local branch: create it at the target)", v.observedTip) + } +} + +// TestRepositoryPrepareAbsentWorktreeRoutesOnFreshness pins every row of the +// absent-.docket table: prepare never attaches a stale, ahead, or diverged branch. +func TestRepositoryPrepareAbsentWorktreeRoutesOnFreshness(t *testing.T) { + absent := func(local reposetup.BranchFact, rel reposetup.SyncRelation) reposetup.Facts { + f := preparableFacts() + f.DocketWorktree = reposetup.WorktreeFact{Presence: reposetup.PresenceAbsent} + f.LocalMetadata = local + f.LocalMetadataSync = rel + return f + } + present := func(tip string) reposetup.BranchFact { + return reposetup.BranchFact{Presence: reposetup.PresencePresent, Tip: tip} + } + rows := []struct { + name string + f reposetup.Facts + held reposetup.Presence + wantDisp string + wantAction prepareAction + wantObserved string + wantCode string + }{ + {"no local branch creates", absent(reposetup.BranchFact{Presence: reposetup.PresenceAbsent}, reposetup.SyncUnknown), reposetup.PresenceUnknown, PrepareDispositionApplied, prepareActionAttach, "", ""}, + {"local unknown refuses", absent(reposetup.BranchFact{}, reposetup.SyncUnknown), reposetup.PresenceUnknown, PrepareDispositionRefused, prepareActionNone, "", "prepare-local-state-unknown"}, + {"current attaches", absent(present("m9"), reposetup.SyncCurrent), reposetup.PresenceUnknown, PrepareDispositionApplied, prepareActionAttach, "m9", ""}, + {"behind free advances then attaches", absent(present("m1"), reposetup.SyncBehind), reposetup.PresenceAbsent, PrepareDispositionApplied, prepareActionAttach, "m1", ""}, + {"behind held elsewhere refuses", absent(present("m1"), reposetup.SyncBehind), reposetup.PresencePresent, PrepareDispositionRefused, prepareActionNone, "", "docket-worktree-ambiguous-registration"}, + {"behind holder unknown refuses", absent(present("m1"), reposetup.SyncBehind), reposetup.PresenceUnknown, PrepareDispositionRefused, prepareActionNone, "", "prepare-local-state-unknown"}, + {"ahead refuses", absent(present("mA"), reposetup.SyncAhead), reposetup.PresenceAbsent, PrepareDispositionRefused, prepareActionNone, "", "local-metadata-ahead"}, + {"diverged refuses", absent(present("mD"), reposetup.SyncDiverged), reposetup.PresenceAbsent, PrepareDispositionRefused, prepareActionNone, "", "local-metadata-diverged"}, + {"relation unknown refuses", absent(present("mX"), reposetup.SyncUnknown), reposetup.PresenceAbsent, PrepareDispositionRefused, prepareActionNone, "", "prepare-local-state-unknown"}, + } + for _, row := range rows { + v := prepareRoute(row.f, row.held) + if v.disposition != row.wantDisp || v.action != row.wantAction || v.observedTip != row.wantObserved { + t.Errorf("%s: verdict %q/%v observed %q, want %q/%v observed %q", row.name, v.disposition, v.action, v.observedTip, row.wantDisp, row.wantAction, row.wantObserved) + } + if row.wantCode != "" && (v.finding == nil || v.finding.Code != row.wantCode) { + t.Errorf("%s: finding %+v, want code %s", row.name, v.finding, row.wantCode) + } + if row.wantDisp == PrepareDispositionApplied && v.targetRev != "m9" { + t.Errorf("%s: targetRev = %q, want m9", row.name, v.targetRev) + } + } } // TestRepositoryPrepareCleanBehindFastForwards — a clean, strictly-behind worktree @@ -122,7 +171,7 @@ func TestRepositoryPrepareCleanBehindFastForwards(t *testing.T) { f.DocketWorktree.Synchronized = reposetup.PresenceAbsent f.LocalMetadataSync = reposetup.SyncBehind - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionApplied { t.Fatalf("disposition = %q, want applied", v.disposition) } @@ -142,7 +191,7 @@ func TestRepositoryPrepareCleanBehindFastForwards(t *testing.T) { func TestRepositoryPrepareCleanCurrentIsNoOp(t *testing.T) { f := preparableFacts() f.LocalMetadataSync = reposetup.SyncCurrent - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionNoOp { t.Fatalf("disposition = %q, want no-op", v.disposition) } @@ -161,7 +210,7 @@ func TestRepositoryPrepareDirtyRefuses(t *testing.T) { f.DocketWorktree.Clean = reposetup.PresenceAbsent f.LocalMetadataSync = reposetup.SyncCurrent - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionRefused || v.action != prepareActionNone { t.Fatalf("verdict = %q/%v, want refused/none", v.disposition, v.action) } @@ -178,7 +227,7 @@ func TestRepositoryPrepareAheadRefuses(t *testing.T) { f.DocketWorktree.Synchronized = reposetup.PresenceAbsent f.LocalMetadataSync = reposetup.SyncAhead - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionRefused || v.action != prepareActionNone { t.Fatalf("verdict = %q/%v, want refused/none", v.disposition, v.action) } @@ -194,7 +243,7 @@ func TestRepositoryPrepareDivergedRefuses(t *testing.T) { f.DocketWorktree.Synchronized = reposetup.PresenceAbsent f.LocalMetadataSync = reposetup.SyncDiverged - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -210,7 +259,7 @@ func TestRepositoryPrepareForeignRefuses(t *testing.T) { f.DocketWorktree.Foreign = true f.LocalMetadataSync = reposetup.SyncCurrent - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -227,7 +276,7 @@ func TestRepositoryPrepareForeignRootRefusesAsForeign(t *testing.T) { f.MetadataRoot = reposetup.RootForeign f.LocalMetadataSync = reposetup.SyncCurrent - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -251,7 +300,7 @@ func TestRepositoryPrepareUnknownRootRefusesAsReachability(t *testing.T) { f.MetadataRoot = reposetup.RootUnknown // RemoteMetadata stays PresencePresent f.LocalMetadataSync = reposetup.SyncCurrent - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused (fail-closed, no mutation)", v.disposition) } @@ -285,7 +334,7 @@ func TestRepositoryPrepareAmbiguousRegistrationRefuses(t *testing.T) { f.DocketWorktree.Registered = reposetup.PresenceUnknown f.LocalMetadataSync = reposetup.SyncCurrent - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -299,7 +348,7 @@ func TestRepositoryPrepareAmbiguousRegistrationRefuses(t *testing.T) { func TestRepositoryPrepareProbeUnknownRefuses(t *testing.T) { f := preparableFacts() f.LocalMetadataSync = reposetup.SyncUnknown - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -330,7 +379,7 @@ func TestRepositoryPrepareProbeErrorIsNotAbsence(t *testing.T) { } f.LocalMetadataSync = reposetup.SyncUnknown - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) if v.disposition != PrepareDispositionError { t.Errorf("disposition = %q, want error (an errored probe is never a clean absence)", v.disposition) } @@ -651,7 +700,7 @@ func TestRepositoryPrepareResultOmitsContextOnRefusal(t *testing.T) { f := preparableFacts() f.RemoteMetadata = reposetup.BranchFact{Presence: reposetup.PresenceAbsent} f.LocalMetadataSync = reposetup.SyncUnknown - v := prepareRoute(f) + v := prepareRoute(f, reposetup.PresenceAbsent) res := prepareDiagnosisResult(ResultInvalidState, PrepareDispositionRefused, v, nil) if res.Context != nil { t.Errorf("a refusal must carry no context, got %+v", res.Context) From 00d6edc5d2e63cc5a36455f1820751d9c2589506 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 08:20:12 -0400 Subject: [PATCH 11/17] fix(check): name a primary checkout ahead of or diverged from the remote tip honestly --- internal/app/repository_check.go | 6 +++ internal/app/repository_facts.go | 2 + .../app/reposynccheck_integration_test.go | 25 +++++++++++ internal/reposetup/health.go | 28 +++++++++--- internal/reposetup/health_test.go | 45 +++++++++++++++++++ internal/reposetup/probe.go | 3 +- 6 files changed, 103 insertions(+), 6 deletions(-) diff --git a/internal/app/repository_check.go b/internal/app/repository_check.go index 4373f3cd8..a1addd8a7 100644 --- a/internal/app/repository_check.go +++ b/internal/app/repository_check.go @@ -248,6 +248,12 @@ func augmentCheckFacts(ctx context.Context, git *gitcli.Client, f *reposetup.Fac // pending path too — the committed-ignore guard and the needs-review signal // share one authority. f.PrimaryOnIntegration = primaryOnIntegrationPresence(ctx, git, sc.repo, sc.integrationBranch) + // The primary checkout's relationship to the pinned integration tip, computed + // only where it is reported and only when HEAD is not the tip, through the same + // syncRelationship the local docket copy uses. + if f.PrimaryAtRemoteTip == reposetup.PresenceAbsent && sc.sourceRevision != "" { + f.PrimaryTipRelation = syncRelationship(ctx, git, sc.repo, sc.primaryHead, sc.sourceRevision) + } f.PendingReviewPaths = pendingReviewPaths(ctx, git, sc.repo, f.CommittedIgnoreBlock) // Authorized parent-facing surfaces: absent a drift probe (Task 11 owns the diff --git a/internal/app/repository_facts.go b/internal/app/repository_facts.go index 0cb1d9f59..5fd4a82ec 100644 --- a/internal/app/repository_facts.go +++ b/internal/app/repository_facts.go @@ -114,6 +114,7 @@ type setupContext struct { integrationBranch string sourceRevision string // pinned authoritative integration tip metadataTip string // remote docket tip when present, else "" + primaryHead string // primary worktree HEAD read by primaryAtTipPresence, else "" diagnostics []setupDiag } @@ -388,6 +389,7 @@ func primaryAtTipPresence(ctx context.Context, p setupProber, repo gitcli.Reposi if filepath.Clean(wt.Path) != filepath.Clean(repo.PrimaryWorktree) { continue } + sc.primaryHead = string(wt.Head) if string(wt.Head) == rev { return reposetup.PresencePresent } diff --git a/internal/app/reposynccheck_integration_test.go b/internal/app/reposynccheck_integration_test.go index 915545ce1..828132eb6 100644 --- a/internal/app/reposynccheck_integration_test.go +++ b/internal/app/reposynccheck_integration_test.go @@ -121,3 +121,28 @@ func TestIntegrationRepoSyncCheckUnfinishedMergeIsDirty(t *testing.T) { t.Fatalf(".docket HEAD moved from %s to %s", headBefore, got) } } + +func TestIntegrationRepoSyncCheckPrimaryAhead(t *testing.T) { + r := newHealthyRepo(t) + writeRepoFile(t, r.invocation, "local-only.txt", "unpushed\n") + runGit(t, r.invocation, "add", "--", "local-only.txt") + runGit(t, r.invocation, "commit", "-q", "-m", "unpushed") + got := checkCodes(r.runCheck(t)) + if !got["primary-ahead-of-remote-tip"] || got["primary-behind-remote-tip"] { + t.Fatalf("primary with an unpushed commit: findings %v, want primary-ahead-of-remote-tip only", got) + } +} + +func TestIntegrationRepoSyncCheckPrimaryBehind(t *testing.T) { + r := newHealthyRepo(t) + runGit(t, r.writer, "fetch", "-q", "origin", "main") + runGit(t, r.writer, "checkout", "-q", "-B", "main", "origin/main") + writeRepoFile(t, r.writer, "remote-only.txt", "remote\n") + runGit(t, r.writer, "add", "--", "remote-only.txt") + runGit(t, r.writer, "commit", "-q", "-m", "remote advance") + runGit(t, r.writer, "push", "-q", "origin", "main") + got := checkCodes(r.runCheck(t)) + if !got["primary-behind-remote-tip"] || got["primary-ahead-of-remote-tip"] { + t.Fatalf("primary behind the remote: findings %v, want primary-behind-remote-tip only", got) + } +} diff --git a/internal/reposetup/health.go b/internal/reposetup/health.go index eff80d3a0..bc7ece673 100644 --- a/internal/reposetup/health.go +++ b/internal/reposetup/health.go @@ -642,13 +642,31 @@ func conditionFinding(cond HealthCondition, f Facts) *Finding { return nil } if f.PrimaryAtRemoteTip == PresenceAbsent { - return &Finding{ - Code: "primary-behind-remote-tip", - Severity: SeverityError, - Message: "The primary worktree's HEAD does not equal the pinned remote integration tip.", - Remedy: "Fetch and fast-forward the integration branch (e.g. `docket repository sync-integration`), preserving local work, then re-run `docket repository check`.", + switch f.PrimaryTipRelation { + case SyncBehind: + return &Finding{ + Code: "primary-behind-remote-tip", + Severity: SeverityError, + Message: "The primary worktree's HEAD is behind the pinned remote integration tip.", + Remedy: "Fetch and fast-forward the integration branch (e.g. `docket repository sync-integration`), preserving local work, then re-run `docket repository check`.", + } + case SyncAhead: + return &Finding{ + Code: "primary-ahead-of-remote-tip", + Severity: SeverityError, + Message: "The primary worktree's HEAD is ahead of the pinned remote integration tip (it carries commits the remote does not).", + Remedy: "Push the local commits (or move them to a branch) yourself, preserving them, then re-run `docket repository check`.", + } + case SyncDiverged: + return &Finding{ + Code: "primary-diverged-from-remote-tip", + Severity: SeverityError, + Message: "The primary worktree's HEAD has diverged from the pinned remote integration tip.", + Remedy: "Reconcile the primary checkout with the remote integration branch yourself (rebase or merge), preserving local work, then re-run `docket repository check`.", + } } } + // Unproven position, or HEAD off the tip with an unproven relationship. return &Finding{ Code: "primary-tip-unverified", Severity: SeverityWarning, diff --git a/internal/reposetup/health_test.go b/internal/reposetup/health_test.go index 00a33847e..4495edd5a 100644 --- a/internal/reposetup/health_test.go +++ b/internal/reposetup/health_test.go @@ -1016,3 +1016,48 @@ func TestCommittedIgnoreRemediesCarryPasteReadyBlock(t *testing.T) { t.Errorf("want >= 7 distinct instructions, got %d: %v", len(instructions), instructions) } } + +// TestHealthPrimaryTipFindingByRelationship: the primary-not-at-tip finding is chosen +// by relationship; every case stays non-healthy and every remedy works in its state. +func TestHealthPrimaryTipFindingByRelationship(t *testing.T) { + rows := []struct { + rel SyncRelation + wantCode string + remedyHas string + remedyLack string + }{ + {SyncBehind, "primary-behind-remote-tip", "sync-integration", ""}, + {SyncAhead, "primary-ahead-of-remote-tip", "Push the local commits", "sync-integration"}, + {SyncDiverged, "primary-diverged-from-remote-tip", "rebase or merge", "sync-integration"}, + {SyncUnknown, "primary-tip-unverified", "", "sync-integration"}, + } + for _, row := range rows { + f := healthyFacts() + f.PrimaryAtRemoteTip = PresenceAbsent + f.PrimaryTipRelation = row.rel + c := Classify(f) + if c.State == StateHealthy { + t.Fatalf("%s: a primary off the tip classified healthy", row.wantCode) + } + got := EvaluateHealth(c, f, nil) + var fnd *Finding + for i := range got { + if strings.HasPrefix(got[i].Code, "primary-") { + fnd = &got[i] + } + } + if fnd == nil || fnd.Code != row.wantCode { + t.Fatalf("relation %d: findings %v, want %s", row.rel, findingCodes(got), row.wantCode) + } + if row.remedyHas != "" && !strings.Contains(fnd.Remedy, row.remedyHas) { + t.Errorf("%s remedy %q lacks %q", row.wantCode, fnd.Remedy, row.remedyHas) + } + if row.remedyLack != "" && strings.Contains(fnd.Remedy, row.remedyLack) { + t.Errorf("%s remedy %q must not name %q (invalid in this state)", row.wantCode, fnd.Remedy, row.remedyLack) + } + assertNoDestructiveCommand(t, fnd.Remedy) + if CheckExit(c, got) != 1 { + t.Errorf("%s: exit != 1", row.wantCode) + } + } +} diff --git a/internal/reposetup/probe.go b/internal/reposetup/probe.go index 66666695a..1c1602985 100644 --- a/internal/reposetup/probe.go +++ b/internal/reposetup/probe.go @@ -92,7 +92,8 @@ type Facts struct { PrimaryClean Presence PrimaryOnIntegration Presence PrimaryAtRemoteTip Presence - PendingReviewPaths []string // init-planned integration-worktree paths not yet committed + PrimaryTipRelation SyncRelation // primary HEAD vs pinned integration tip; set only by check's augmentation, only when PrimaryAtRemoteTip is Absent + PendingReviewPaths []string // init-planned integration-worktree paths not yet committed PartialPhase PartialPhase SurfacesAuthorized bool // agent_harnesses explicitly declared at repo/repo-local layer SurfacesAgree Presence // 0351 plan vs bytes+ownership record; only meaningful when authorized From f073103000af2da89d7b8c377ccfa198d589c50b Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 08:30:56 -0400 Subject: [PATCH 12/17] test(gitcli): split branch-tip movement tests into their own shard The gitcli repo shard (prefix ^TestIntegrationRepo, budget 30s) measured 36s solo on this branch (29s on main) after the new in-place fast-forward, checked-advance, and replace-blind ancestry tests landed. Per tests/README.md the shard is split rather than its ceiling raised: the five branch-tip movement tests move to prefix ^TestIntegrationBranchTip in a new tests/test_go_integration_gitcli_branchtip.sh shard. Measured solo after the split (two runs each): gitcli_repo 29s / 28s, gitcli_branchtip 10s / 9s. The branchtip ceiling is 15s (10s rounded up to the next multiple of 5 plus the 5s margin, per the runtime-budgets.tsv seeding rule). Also names the unfinished-merge and primary ahead/behind scenarios in the reposynccheck shard header. --- internal/gitcli/ancestry_integration_test.go | 4 ++-- .../fastforward_inplace_integration_test.go | 2 +- .../gitcli/fastforward_integration_test.go | 2 +- .../gitcli/refadvance_integration_test.go | 2 +- internal/gitcli/refdelete_integration_test.go | 2 +- tests/runtime-budgets.tsv | 1 + .../test_go_integration_app_reposynccheck.sh | 6 +++-- tests/test_go_integration_gitcli_branchtip.sh | 24 +++++++++++++++++++ 8 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 tests/test_go_integration_gitcli_branchtip.sh diff --git a/internal/gitcli/ancestry_integration_test.go b/internal/gitcli/ancestry_integration_test.go index 85de4d0ab..64b09184c 100644 --- a/internal/gitcli/ancestry_integration_test.go +++ b/internal/gitcli/ancestry_integration_test.go @@ -10,11 +10,11 @@ import ( "testing" ) -// TestIntegrationRepoIsAncestorIgnoringReplacements proves the relationship probe +// TestIntegrationBranchTipIsAncestorIgnoringReplacements proves the relationship probe // ignores both history-rewrite mechanisms: a replace ref and the legacy graft file // each make an unrelated orphan commit look like a descendant to plain IsAncestor, // and IsAncestorIgnoringReplacements must still answer false. -func TestIntegrationRepoIsAncestorIgnoringReplacements(t *testing.T) { +func TestIntegrationBranchTipIsAncestorIgnoringReplacements(t *testing.T) { ctx := context.Background() newOrphan := func(t *testing.T, r *testRepos) (base, orphan ObjectID) { t.Helper() diff --git a/internal/gitcli/fastforward_inplace_integration_test.go b/internal/gitcli/fastforward_inplace_integration_test.go index fb472ebfe..b0546d5e5 100644 --- a/internal/gitcli/fastforward_inplace_integration_test.go +++ b/internal/gitcli/fastforward_inplace_integration_test.go @@ -55,7 +55,7 @@ func installHooks(t *testing.T, sentinel string, code int, names ...string) stri return dir } -func TestIntegrationRepoFastForwardCheckedOutBranch(t *testing.T) { +func TestIntegrationBranchTipFastForwardCheckedOutBranch(t *testing.T) { ctx := context.Background() t.Run("advances branch, index, and files in place", func(t *testing.T) { diff --git a/internal/gitcli/fastforward_integration_test.go b/internal/gitcli/fastforward_integration_test.go index 961a08e6e..ca5683a58 100644 --- a/internal/gitcli/fastforward_integration_test.go +++ b/internal/gitcli/fastforward_integration_test.go @@ -9,7 +9,7 @@ import ( "testing" ) -func TestIntegrationRepoFastForwardWorktree(t *testing.T) { +func TestIntegrationBranchTipFastForwardWorktree(t *testing.T) { ctx := context.Background() t.Run("clean advance and already current", func(t *testing.T) { diff --git a/internal/gitcli/refadvance_integration_test.go b/internal/gitcli/refadvance_integration_test.go index ac9bb18ef..69777181b 100644 --- a/internal/gitcli/refadvance_integration_test.go +++ b/internal/gitcli/refadvance_integration_test.go @@ -25,7 +25,7 @@ func newUnattachedBranchFixture(t *testing.T) (r *testRepos, base, target Object return r, base, target } -func TestIntegrationRepoAdvanceBranchChecked(t *testing.T) { +func TestIntegrationBranchTipAdvanceBranchChecked(t *testing.T) { ctx := context.Background() t.Run("advances an unattached branch", func(t *testing.T) { diff --git a/internal/gitcli/refdelete_integration_test.go b/internal/gitcli/refdelete_integration_test.go index 3e1ed4a95..c2414030c 100644 --- a/internal/gitcli/refdelete_integration_test.go +++ b/internal/gitcli/refdelete_integration_test.go @@ -13,7 +13,7 @@ import ( // removes a branch whose tip matches exactly and which is checked out nowhere, // and refuses (leaving the branch intact) both when the tip has moved and when // the branch is checked out in a worktree. -func TestIntegrationRepoDeleteLocalBranchChecked(t *testing.T) { +func TestIntegrationBranchTipDeleteLocalBranchChecked(t *testing.T) { requireGit(t) ctx := context.Background() c := newRealClient(t) diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index 8c526f38d..f3a53f7bd 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -70,6 +70,7 @@ tests/test_go_integration_gitcli_history.sh 10 parallel tests/test_go_integration_gitcli_preserve.sh 15 parallel tests/test_go_integration_gitcli_process.sh 15 parallel tests/test_go_integration_gitcli_repo.sh 30 parallel +tests/test_go_integration_gitcli_branchtip.sh 15 parallel tests/test_go_integration_gitcli_stoppedcommit.sh 10 parallel tests/test_go_integration_gitcli_setuptree.sh 10 parallel tests/test_go_integration_gitcli_source.sh 10 parallel diff --git a/tests/test_go_integration_app_reposynccheck.sh b/tests/test_go_integration_app_reposynccheck.sh index 6d2293ed1..382990cfd 100755 --- a/tests/test_go_integration_app_reposynccheck.sh +++ b/tests/test_go_integration_app_reposynccheck.sh @@ -3,8 +3,10 @@ # tests/test_go_integration_app_reposynccheck.sh — Go integration shard: the local # .docket copy relationship scenarios for `repository check` (a clean behind-only copy # is healthy with no finding and configure-tests accepts it; ahead, diverged, and -# dirty-behind each report exactly their own finding), behind the `integration` build -# tag, prefix ^TestIntegrationRepoSyncCheck. A new shard because no existing app +# dirty-behind each report exactly their own finding; an unfinished merge in .docket +# reads dirty) and the primary checkout's ahead and behind positions against the +# remote integration tip, behind the `integration` build tag, prefix +# ^TestIntegrationRepoSyncCheck. A new shard because no existing app # prefix is a name-prefix of TestIntegrationRepoSyncCheck. Declarations only — # execution and inspection live in tests/lib/go-integration-shard.sh; the # completeness contract is tests/test_go_integration_contract.sh. diff --git a/tests/test_go_integration_gitcli_branchtip.sh b/tests/test_go_integration_gitcli_branchtip.sh new file mode 100644 index 000000000..220d680ca --- /dev/null +++ b/tests/test_go_integration_gitcli_branchtip.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_gitcli_branchtip.sh — Go integration shard (change +# 0333): the gitcli branch-tip movement tests (in-place and unattached +# compare-and-swap fast-forwards, the checked delete, and the replace-ref-blind +# ancestry probe that judges them), behind the `integration` build tag, prefix +# ^TestIntegrationBranchTip. Split out of the gitcli repo shard so neither grows +# past its runtime budget. Declarations only — execution and inspection live in +# tests/lib/go-integration-shard.sh; the completeness contract is +# tests/test_go_integration_contract.sh. +set -uo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO" || exit 1 +fail=0 +assert(){ if eval "$2"; then printf 'ok - %s\n' "$1"; else printf 'NOT OK - %s\n' "$1"; fail=1; fi; } + +SHARD_PKG="./internal/gitcli" +SHARD_PREFIX="TestIntegrationBranchTip" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" From c9eb3addb74961fac4220b72bf828eda129d9880 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 08:49:09 -0400 Subject: [PATCH 13/17] fix(check): name an interrupted .docket fast-forward and its plain-Git finish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: after an in-place fast-forward is killed between update-ref and read-tree, check and prepare gave the generic metadata-worktree-dirty remedy beginning "Commit", which would record a revert of remote metadata (spec §3 property 5). worktreeCleanState now detects that state (the branch's newest reflog entry is the fast-forward's own and the index equals the previous tip's tree) via gitcli InterruptedFastForward, carries it on WorktreeFact, and both commands share one message and remedy naming git -C .docket reset --merge HEAD, never commit. --- .../app/repoinplaceff_integration_test.go | 34 +++++++++-- internal/app/repository_check.go | 26 +++++--- internal/app/repository_check_test.go | 22 +++++++ internal/app/repository_prepare.go | 10 ++- internal/gitcli/fastforward_inplace.go | 47 ++++++++++++++ .../fastforward_inplace_integration_test.go | 61 +++++++++++++++++++ internal/reposetup/health.go | 19 +++++- internal/reposetup/health_test.go | 8 ++- internal/reposetup/probe.go | 15 ++--- 9 files changed, 215 insertions(+), 27 deletions(-) diff --git a/internal/app/repoinplaceff_integration_test.go b/internal/app/repoinplaceff_integration_test.go index aa4076ad9..f9dc87986 100644 --- a/internal/app/repoinplaceff_integration_test.go +++ b/internal/app/repoinplaceff_integration_test.go @@ -206,22 +206,48 @@ func TestIntegrationRepoInPlaceFFIdempotent(t *testing.T) { } // TestIntegrationRepoInPlaceFFInterruptedReadsDirty simulates an interruption after -// the branch moved but before the tree did: it must read dirty, never clean, and -// prepare must refuse without removing anything. +// the branch moved (with the fast-forward's own reflog message) but before the tree +// did: it must read dirty, never clean, and prepare must refuse without removing +// anything. Both name the interrupted update and a plain-Git finish, never the +// generic "Commit" remedy that would record a revert of the remote metadata. func TestIntegrationRepoInPlaceFFInterruptedReadsDirty(t *testing.T) { r, oldTip, newTip := newBehindHealthyRepo(t) dot := filepath.Join(r.invocation, ".docket") runGit(t, r.invocation, "fetch", "-q", "origin", "docket") - runGit(t, dot, "update-ref", "refs/heads/docket", newTip, oldTip) + runGit(t, dot, "update-ref", "-m", "docket: fast-forward", "refs/heads/docket", newTip, oldTip) + wantInterrupted := func(where string, f reposetup.Finding) { + t.Helper() + if f.Message != reposetup.InterruptedFastForwardMessage || f.Remedy != reposetup.InterruptedFastForwardRemedy { + t.Errorf("%s: dirty finding %+v, want the interrupted fast-forward message and remedy", where, f) + } + if strings.HasPrefix(f.Remedy, "Commit") || !strings.Contains(f.Remedy, "git -C .docket reset --merge HEAD") { + t.Errorf("%s: remedy %q must name the plain-Git finish and never lead with Commit", where, f.Remedy) + } + } res := r.runCheck(t) - if !checkCodes(res)["metadata-worktree-dirty"] { + var dirty *reposetup.Finding + for i := range res.Findings { + if res.Findings[i].Code == "metadata-worktree-dirty" { + dirty = &res.Findings[i] + } + } + if dirty == nil { t.Fatalf("interrupted fast-forward: findings %+v, want metadata-worktree-dirty", res.Findings) } + wantInterrupted("check", *dirty) pr := runPrepareAt(t, r.invocation) if pr.Disposition != PrepareDispositionRefused { t.Fatalf("prepare = %q, want refused", pr.Disposition) } + if len(pr.Findings) != 1 || pr.Findings[0].Code != "metadata-worktree-dirty" { + t.Fatalf("prepare findings %+v, want one metadata-worktree-dirty", pr.Findings) + } + wantInterrupted("prepare", pr.Findings[0]) + + // The named remedy really finishes the update: afterwards check is healthy. + runGit(t, dot, "reset", "--merge", "HEAD") + requireCheckHealthy(t, r) if _, err := os.Stat(dot); err != nil { t.Fatalf(".docket removed: %v", err) } diff --git a/internal/app/repository_check.go b/internal/app/repository_check.go index a1addd8a7..7d358511a 100644 --- a/internal/app/repository_check.go +++ b/internal/app/repository_check.go @@ -212,7 +212,7 @@ func augmentCheckFacts(ctx context.Context, git *gitcli.Client, f *reposetup.Fac // present. if f.DocketWorktree.Presence == reposetup.PresencePresent { worktreeDir := filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName) - f.DocketWorktree.Clean, f.DocketWorktree.UnfinishedOperation = worktreeCleanState(ctx, git, worktreeDir) + f.DocketWorktree.Clean, f.DocketWorktree.UnfinishedOperation, f.DocketWorktree.InterruptedFastForward = worktreeCleanState(ctx, git, worktreeDir) f.DocketWorktree.HooksOff = hooksOffPresence(ctx, git, worktreeDir) } applyLocalMetadataSync(ctx, git, sc.repo, f) @@ -268,24 +268,32 @@ func augmentCheckFacts(ctx context.Context, git *gitcli.Client, f *reposetup.Fac // prepare both call it). Clean means no uncommitted or untracked change AND no // unfinished Git operation (merge, cherry-pick, revert, rebase, am, bisect): a merge // whose index equals HEAD lists nothing in `git status`, yet a fast-forward would -// silently drop its MERGE_HEAD. The bool reports that an unfinished operation is -// what made it not clean. Any probe error is the safe Unknown. -func worktreeCleanState(ctx context.Context, git *gitcli.Client, worktreeDir string) (reposetup.Presence, bool) { +// silently drop its MERGE_HEAD. The bools report what made it not clean: an +// unfinished operation, or an interrupted in-place fast-forward (branch moved, tree +// not — its staged changes would undo the remote update, so it must never get the +// generic "commit" remedy). Any clean-probe error is the safe Unknown; a failed +// interruption probe on a proven-dirty worktree only drops the specific cause. +func worktreeCleanState(ctx context.Context, git *gitcli.Client, worktreeDir string) (clean reposetup.Presence, unfinishedOp, interruptedFF bool) { st, err := git.WorktreeCheckoutState(ctx, worktreeDir) if err != nil { - return reposetup.PresenceUnknown, false + return reposetup.PresenceUnknown, false, false } changes, err := git.ChangedPaths(ctx, worktreeDir) if err != nil { - return reposetup.PresenceUnknown, false + return reposetup.PresenceUnknown, false, false } if st.OperationInProgress { - return reposetup.PresenceAbsent, true + return reposetup.PresenceAbsent, true, false } if len(changes) == 0 { - return reposetup.PresencePresent, false + return reposetup.PresencePresent, false, false } - return reposetup.PresenceAbsent, false + if !st.Detached { + if interrupted, ierr := git.InterruptedFastForward(ctx, worktreeDir, st.Branch); ierr == nil && interrupted { + return reposetup.PresenceAbsent, false, true + } + } + return reposetup.PresenceAbsent, false, false } // hooksOffPresence reports whether the .docket worktree's hooks are disabled the diff --git a/internal/app/repository_check_test.go b/internal/app/repository_check_test.go index 0bb3a12fc..18a5164fc 100644 --- a/internal/app/repository_check_test.go +++ b/internal/app/repository_check_test.go @@ -256,4 +256,26 @@ func TestLocalMetadataRefusalsMatchPrepare(t *testing.T) { t.Errorf("dirty (operation=%v): check %+v != prepare %+v", op, got[0], *v.finding) } } + + // An interrupted in-place fast-forward: check and prepare carry the same code, + // message, AND remedy, and the remedy never invites committing the revert. + f := preparableFacts() + f.DocketWorktree.Clean = reposetup.PresenceAbsent + f.DocketWorktree.InterruptedFastForward = true + v := prepareRoute(f, reposetup.PresenceAbsent) + if v.finding == nil { + t.Fatal("interrupted fast-forward: prepare produced no finding") + } + got := reposetup.EvaluateHealth( + reposetup.Classification{State: reposetup.StateConflict, Reasons: []string{"metadata-worktree-dirty"}}, + reposetup.Facts{DocketWorktree: reposetup.WorktreeFact{InterruptedFastForward: true}}, nil) + if len(got) != 1 { + t.Fatalf("interrupted fast-forward: check findings = %+v", got) + } + if got[0].Code != v.finding.Code || got[0].Message != v.finding.Message || got[0].Remedy != v.finding.Remedy { + t.Errorf("interrupted fast-forward: check %+v != prepare %+v", got[0], *v.finding) + } + if got[0].Message != reposetup.InterruptedFastForwardMessage || got[0].Remedy != reposetup.InterruptedFastForwardRemedy { + t.Errorf("interrupted fast-forward: finding %+v, want the interrupted message and remedy", got[0]) + } } diff --git a/internal/app/repository_prepare.go b/internal/app/repository_prepare.go index 320848409..db57aa170 100644 --- a/internal/app/repository_prepare.go +++ b/internal/app/repository_prepare.go @@ -344,15 +344,19 @@ func prepareRoute(f reposetup.Facts, heldElsewhere reposetup.Presence) prepareVe switch f.DocketWorktree.Clean { case reposetup.PresenceAbsent: msg := "The .docket metadata worktree has uncommitted or untracked changes." - if f.DocketWorktree.UnfinishedOperation { + remedy := "Commit or set aside the changes in the .docket metadata worktree, then re-run." + switch { + case f.DocketWorktree.UnfinishedOperation: msg = "The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect)." + case f.DocketWorktree.InterruptedFastForward: + msg, remedy = reposetup.InterruptedFastForwardMessage, reposetup.InterruptedFastForwardRemedy } return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ Code: string(FCMetadataWorktreeDirty), Severity: reposetup.SeverityError, Ref: docketWorktreeName, Message: msg, - Remedy: "Commit or set aside the changes in the .docket metadata worktree, then re-run.", + Remedy: remedy, }) case reposetup.PresenceUnknown: return prepareLocalUnknownVerdict() @@ -622,7 +626,7 @@ func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, // The .docket worktree clean state, probed only when the worktree is present. if f.DocketWorktree.Presence == reposetup.PresencePresent { - f.DocketWorktree.Clean, f.DocketWorktree.UnfinishedOperation = worktreeCleanState(ctx, git, filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName)) + f.DocketWorktree.Clean, f.DocketWorktree.UnfinishedOperation, f.DocketWorktree.InterruptedFastForward = worktreeCleanState(ctx, git, filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName)) } applyLocalMetadataSync(ctx, git, sc.repo, f) diff --git a/internal/gitcli/fastforward_inplace.go b/internal/gitcli/fastforward_inplace.go index 3cd082467..60518021e 100644 --- a/internal/gitcli/fastforward_inplace.go +++ b/internal/gitcli/fastforward_inplace.go @@ -108,3 +108,50 @@ func (c *Client) FastForwardCheckedOutBranch(ctx context.Context, worktreeDir st } return f } + +const interruptedFastForwardOp Operation = "interrupted-fast-forward" + +// InterruptedFastForward reports whether worktreeDir, with branch checked out, holds +// the state FastForwardCheckedOutBranch leaves when killed between its ref swap and +// its tree update: the branch's newest reflog entry is the fast-forward's own (not +// its roll-back) and the index still equals the tree of the branch's previous tip. +// A completed fast-forward has an index at the new tip, so it reads false. Every +// probe failure is a typed error, never a false. +func (c *Client) InterruptedFastForward(ctx context.Context, worktreeDir string, branch RefName) (bool, error) { + op := interruptedFastForwardOp + if !filepath.IsAbs(worktreeDir) { + return false, newFailure(op, KindInvalidRequest, "worktree path must be absolute", nil) + } + if err := validateRefName(branch); err != nil { + return false, newFailure(op, KindInvalidRequest, "invalid branch ref", err) + } + if _, ok := branchShortName(branch); !ok { + return false, newFailure(op, KindInvalidRequest, "branch must be fully qualified refs/heads/", nil) + } + + last, f := c.run(ctx, runRequest{op: op, dir: worktreeDir, + args: []string{"log", "--walk-reflogs", "-1", "--format=%gs", string(branch), "--"}}) + if f != nil { + return false, f + } + if last.exitCode != 0 { + return false, newFailure(op, KindCommandFailed, "reading the branch reflog failed: "+stderrExcerpt(last.stderr), nil).withExitCode(last.exitCode) + } + if strings.TrimSpace(string(last.stdout)) != fastForwardReflogMessage { + return false, nil + } + + diff, f := c.run(ctx, runRequest{op: op, dir: worktreeDir, + args: []string{"diff", "--cached", "--quiet", string(branch) + "@{1}", "--"}}) + if f != nil { + return false, f + } + switch diff.exitCode { + case 0: + return true, nil + case 1: + return false, nil + default: + return false, newFailure(op, KindCommandFailed, "comparing the index with the previous tip failed: "+stderrExcerpt(diff.stderr), nil).withExitCode(diff.exitCode) + } +} diff --git a/internal/gitcli/fastforward_inplace_integration_test.go b/internal/gitcli/fastforward_inplace_integration_test.go index b0546d5e5..b1c182bd7 100644 --- a/internal/gitcli/fastforward_inplace_integration_test.go +++ b/internal/gitcli/fastforward_inplace_integration_test.go @@ -239,3 +239,64 @@ func appendExclude(t *testing.T, repoDir, pattern string) { t.Fatal(err) } } + +// TestIntegrationBranchTipInterruptedFastForward pins the probe that tells an +// interrupted in-place fast-forward (ref swapped, tree not) from every other state. +func TestIntegrationBranchTipInterruptedFastForward(t *testing.T) { + ctx := context.Background() + probe := func(t *testing.T, c *Client, wt string) bool { + t.Helper() + got, err := c.InterruptedFastForward(ctx, wt, "refs/heads/meta") + if err != nil { + t.Fatalf("InterruptedFastForward: %v", err) + } + return got + } + + t.Run("ref swapped but tree not is interrupted", func(t *testing.T) { + _, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + gitOut(t, wt, "update-ref", "-m", fastForwardReflogMessage, "refs/heads/meta", string(target), string(base)) + if !probe(t, c, wt) { + t.Fatal("interrupted fast-forward read as not interrupted") + } + }) + + t.Run("completed fast-forward is not interrupted", func(t *testing.T) { + _, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err != nil { + t.Fatalf("FastForwardCheckedOutBranch: %v", err) + } + if probe(t, c, wt) { + t.Fatal("completed fast-forward read as interrupted") + } + // A later local edit staged on top is ordinary dirt, not an interruption. + writeWorktreeFile(t, wt, "README.md", "local edit\n") + gitOut(t, wt, "add", "--", "README.md") + if probe(t, c, wt) { + t.Fatal("staged edit after a completed fast-forward read as interrupted") + } + }) + + t.Run("rolled-back fast-forward is not interrupted", func(t *testing.T) { + _, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + writeWorktreeFile(t, wt, "target.txt", "mine\n") + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err == nil { + t.Fatal("fast-forward over an untracked file succeeded; want refusal") + } + if probe(t, c, wt) { + t.Fatal("rolled-back fast-forward read as interrupted") + } + }) + + t.Run("a ref move by any other writer is not interrupted", func(t *testing.T) { + _, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + gitOut(t, wt, "update-ref", "-m", "someone else", "refs/heads/meta", string(target), string(base)) + if probe(t, c, wt) { + t.Fatal("a foreign ref move read as an interrupted docket fast-forward") + } + }) +} diff --git a/internal/reposetup/health.go b/internal/reposetup/health.go index bc7ece673..a5c438c25 100644 --- a/internal/reposetup/health.go +++ b/internal/reposetup/health.go @@ -110,6 +110,17 @@ func EvaluateHealth(c Classification, f Facts, fm []RepairFinding) []Finding { return out } +// InterruptedFastForwardMessage and InterruptedFastForwardRemedy describe a .docket +// worktree left by an interrupted in-place fast-forward: the branch moved to the +// remote revision but the index and files did not, so `git status` shows staged +// changes that would undo the remote update. Check and prepare share these texts +// (TestLocalMetadataRefusalsMatchPrepare). Committing those changes would record a +// revert of remote metadata, so the remedy names the plain-Git finish instead. +const ( + InterruptedFastForwardMessage = "The .docket metadata worktree holds an interrupted fast-forward: its branch moved to the new revision but its files did not, so the staged changes would undo the remote update." + InterruptedFastForwardRemedy = "Finish the update inside .docket with plain Git — `git -C .docket reset --merge HEAD` (it refuses rather than overwrite a local edit) — and never commit the staged changes; then re-run." +) + // findingFor builds the single finding for one classifier reason token. The // remedy is branched on the reason (and, for needs-review, on the pending // paths), so every printed remedy is valid in exactly the state that produced @@ -170,15 +181,19 @@ func findingFor(reason string, f Facts) Finding { } case "metadata-worktree-dirty": msg := "The .docket metadata worktree has uncommitted or untracked changes." - if f.DocketWorktree.UnfinishedOperation { + remedy := "Commit or inspect the changes in the .docket metadata worktree before any repository operation; leave them in place." + switch { + case f.DocketWorktree.UnfinishedOperation: msg = "The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect)." + case f.DocketWorktree.InterruptedFastForward: + msg, remedy = InterruptedFastForwardMessage, InterruptedFastForwardRemedy } return Finding{ Code: "metadata-worktree-dirty", Severity: SeverityError, Ref: ".docket", Message: msg, - Remedy: "Commit or inspect the changes in the .docket metadata worktree before any repository operation; leave them in place.", + Remedy: remedy, } case "local-metadata-ahead": return Finding{ diff --git a/internal/reposetup/health_test.go b/internal/reposetup/health_test.go index 4495edd5a..60d5c40b5 100644 --- a/internal/reposetup/health_test.go +++ b/internal/reposetup/health_test.go @@ -805,12 +805,16 @@ func TestHealthDirtyMessageNamesTheCause(t *testing.T) { op := healthyFacts() op.DocketWorktree.Clean = PresenceAbsent op.DocketWorktree.UnfinishedOperation = true + ff := healthyFacts() + ff.DocketWorktree.Clean = PresenceAbsent + ff.DocketWorktree.InterruptedFastForward = true for name, tc := range map[string]struct { f Facts want string }{ - "files": {files, "The .docket metadata worktree has uncommitted or untracked changes."}, - "operation": {op, "The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect)."}, + "files": {files, "The .docket metadata worktree has uncommitted or untracked changes."}, + "operation": {op, "The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect)."}, + "fastforward": {ff, InterruptedFastForwardMessage}, } { var msg string for _, fn := range EvaluateHealth(Classify(tc.f), tc.f, nil) { diff --git a/internal/reposetup/probe.go b/internal/reposetup/probe.go index 1c1602985..fdf298e98 100644 --- a/internal/reposetup/probe.go +++ b/internal/reposetup/probe.go @@ -64,13 +64,14 @@ func (r SyncRelation) Synchronized() Presence { // WorktreeFact carries the probed state of the persistent .docket/ metadata // worktree. type WorktreeFact struct { - Presence Presence // .docket/ path state: absent, or present-and-probed - Registered Presence // registered as a linked worktree of THIS repo on the metadata branch - Foreign bool // present but a foreign dir / escaping link / conflicting registration - Clean Presence - UnfinishedOperation bool // Clean is Absent because a merge, cherry-pick, revert, rebase, am, or bisect is unfinished - Synchronized Presence // proven current or behind the remote metadata tip: the local copy holds nothing the remote lacks - HooksOff Presence + Presence Presence // .docket/ path state: absent, or present-and-probed + Registered Presence // registered as a linked worktree of THIS repo on the metadata branch + Foreign bool // present but a foreign dir / escaping link / conflicting registration + Clean Presence + UnfinishedOperation bool // Clean is Absent because a merge, cherry-pick, revert, rebase, am, or bisect is unfinished + InterruptedFastForward bool // Clean is Absent because docket's in-place fast-forward moved the branch but not the tree + Synchronized Presence // proven current or behind the remote metadata tip: the local copy holds nothing the remote lacks + HooksOff Presence } // Facts is the complete classifier input. Every field defaults to the safe From b325aa58c3132ca07394ec1d99ec41b5bff10a57 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 08:54:08 -0400 Subject: [PATCH 14/17] fix(prepare): attach a behind docket branch before moving it, so Git's guard decides Review finding: AdvanceBranchChecked's holder check read only `git worktree list`, which reports a worktree rebasing or bisecting `docket` as detached and races a concurrent `worktree add`, so prepare's behind-attach path moved the branch before Git's own checked-out guard ran -- breaking spec section 5's "refuse and touch nothing". prepareAttachFresh now attaches the branch where it stands first (Git's checked-out/rebase/bisect guard refuses a held branch before anything moves), applies hooks-off, then advances it in place with FastForwardCheckedOutBranch (hooks-off compare-and-swap from the observed tip). AdvanceBranchChecked had no other caller and is removed with its tests; the branchtip shard header no longer names it. New regression test: a behind docket branch held by a worktree mid-rebase leaves the branch untouched and .docket unattached. --- .../app/repoinplaceff_integration_test.go | 28 +++++ internal/app/repository_prepare.go | 32 ++++-- internal/gitcli/refadvance.go | 57 ---------- .../gitcli/refadvance_integration_test.go | 103 ------------------ tests/test_go_integration_gitcli_branchtip.sh | 6 +- 5 files changed, 53 insertions(+), 173 deletions(-) delete mode 100644 internal/gitcli/refadvance.go delete mode 100644 internal/gitcli/refadvance_integration_test.go diff --git a/internal/app/repoinplaceff_integration_test.go b/internal/app/repoinplaceff_integration_test.go index f9dc87986..373b18462 100644 --- a/internal/app/repoinplaceff_integration_test.go +++ b/internal/app/repoinplaceff_integration_test.go @@ -307,3 +307,31 @@ func TestIntegrationRepoInPlaceFFAttachDivergedRefuses(t *testing.T) { t.Fatalf(".docket attached on a refusal (err=%v)", err) } } + +// TestIntegrationRepoInPlaceFFAttachBehindHeldByRebaseRefuses holds the behind docket +// branch in another worktree that is mid-rebase, which `git worktree list` reports as +// detached, so the router's holder probe cannot see it. Git's own checked-out guard +// must decide before the branch moves: prepare fails, the branch keeps its tip, and +// .docket is never attached. +func TestIntegrationRepoInPlaceFFAttachBehindHeldByRebaseRefuses(t *testing.T) { + r, oldTip, _ := newBehindHealthyRepo(t) + removeDocketWorktree(t, r) + other := filepath.Join(testsupport.TempDir(t), "rebasing") + runGit(t, r.invocation, "worktree", "add", other, "docket") + runGit(t, other, "-c", `sequence.editor=sh -c 'echo break > "$1"' -`, "rebase", "-i", "HEAD") + list := runGit(t, r.invocation, "worktree", "list", "--porcelain") + if strings.Contains(list, "branch refs/heads/docket") { + t.Fatalf("premise: the rebasing worktree is listed on the branch, want detached:\n%s", list) + } + + res := runPrepareAt(t, r.invocation) + if res.Disposition == PrepareDispositionApplied { + t.Fatalf("prepare = applied (%s), want a refusal while another worktree rebases docket", res.HumanText()) + } + if local := runGit(t, r.invocation, "rev-parse", "refs/heads/docket"); local != oldTip { + t.Fatalf("local docket = %s, want untouched %s", local, oldTip) + } + if _, err := os.Stat(filepath.Join(r.invocation, ".docket")); !os.IsNotExist(err) { + t.Fatalf(".docket attached on a refusal (err=%v)", err) + } +} diff --git a/internal/app/repository_prepare.go b/internal/app/repository_prepare.go index db57aa170..a932c822f 100644 --- a/internal/app/repository_prepare.go +++ b/internal/app/repository_prepare.go @@ -661,10 +661,7 @@ func prepareExecute(ctx context.Context, git *gitcli.Client, sc setupContext, ve metaRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) switch verdict.action { case prepareActionAttach: - if err := prepareAttachFresh(ctx, git, sc.repo, worktreePath, metaRef, verdict); err != nil { - return err - } - return git.DisableWorktreeHooks(ctx, worktreePath) + return prepareAttachFresh(ctx, git, sc.repo, worktreePath, metaRef, verdict) case prepareActionFastForward: return git.FastForwardCheckedOutBranch(ctx, worktreePath, metaRef, gitcli.ObjectID(verdict.observedTip), gitcli.ObjectID(verdict.targetRev)) default: @@ -674,18 +671,33 @@ func prepareExecute(ctx context.Context, git *gitcli.Client, sc setupContext, ve // prepareAttachFresh is prepare's own attach; ensureMetadataWorktree stays init's and // migrate's, with its contract unchanged. With no local docket branch it creates one -// at the target (git refuses if a branch appeared since routing). With one, it moves -// that branch from the observed tip to the target by compare-and-swap (a no-op that -// still verifies the tip when it is already current), then attaches it. +// at the target (git refuses if a branch appeared since routing). With one, it +// attaches the branch where it stands FIRST and only then moves it: `git worktree +// add`'s own checked-out guard decides whether another worktree holds the branch — +// including one mid-rebase or mid-bisect, which `git worktree list` reports as +// detached and the router's holder probe cannot see — so a held branch is refused +// before anything moves. The attached branch then advances in place +// (gitcli's FastForwardCheckedOutBranch) by compare-and-swap from the observed tip, +// hooks forced off; when the branch is already current that is a no-op that still +// verifies the tip. A branch that moved between routing and the attach fails that +// compare-and-swap with its commits kept, leaving .docket attached (hooks-off already +// applied) at the moved tip for the next run to classify. Hooks-off is applied +// right after the attach in every case. func prepareAttachFresh(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, worktreePath string, metaRef gitcli.RefName, v prepareVerdict) error { target := gitcli.ObjectID(v.targetRev) if v.observedTip == "" { - return git.AddBranchWorktree(ctx, repo, worktreePath, metaRef, target) + if err := git.AddBranchWorktree(ctx, repo, worktreePath, metaRef, target); err != nil { + return err + } + return git.DisableWorktreeHooks(ctx, worktreePath) + } + if err := git.AttachBranchWorktree(ctx, repo, worktreePath, metaRef); err != nil { + return err } - if err := git.AdvanceBranchChecked(ctx, repo, metaRef, gitcli.ObjectID(v.observedTip), target); err != nil { + if err := git.DisableWorktreeHooks(ctx, worktreePath); err != nil { return err } - return git.AttachBranchWorktree(ctx, repo, worktreePath, metaRef) + return git.FastForwardCheckedOutBranch(ctx, worktreePath, metaRef, gitcli.ObjectID(v.observedTip), target) } // prepareContextResult builds an applied/no-op result carrying the closed typed diff --git a/internal/gitcli/refadvance.go b/internal/gitcli/refadvance.go deleted file mode 100644 index ad78ebaa6..000000000 --- a/internal/gitcli/refadvance.go +++ /dev/null @@ -1,57 +0,0 @@ -package gitcli - -import "context" - -const advanceBranchOp Operation = "advance-branch" - -// AdvanceBranchChecked moves a branch that no worktree has checked out from -// expectedTip to target by compare-and-swap (`update-ref -// `), with repository hooks forced off. target must descend from -// expectedTip, judged with replace refs and grafts ignored. A branch some worktree -// holds is refused (moving it would strand that worktree's index), and a branch no -// longer at expectedTip is refused with nothing changed. expectedTip == target is a -// CAS-verified no-op. branch must be a fully qualified refs/heads/. -func (c *Client) AdvanceBranchChecked(ctx context.Context, repo Repository, branch RefName, expectedTip, target ObjectID) error { - op := advanceBranchOp - if err := validateRefName(branch); err != nil { - return newFailure(op, KindInvalidRequest, "invalid branch ref", err) - } - if _, ok := branchShortName(branch); !ok { - return newFailure(op, KindInvalidRequest, "branch must be fully qualified refs/heads/", nil) - } - if err := validateObjectID(expectedTip); err != nil { - return newFailure(op, KindInvalidRequest, "invalid expected tip id", err) - } - if err := validateObjectID(target); err != nil { - return newFailure(op, KindInvalidRequest, "invalid target id", err) - } - descends, err := c.isAncestorIn(ctx, repo.PrimaryWorktree, expectedTip, target, true) - if err != nil { - return err - } - if !descends { - return newFailure(op, KindInvalidRepository, "the target does not descend from the expected tip", nil) - } - infos, err := c.ListWorktrees(ctx, repo) - if err != nil { - return err - } - for _, wi := range infos { - if wi.Branch == branch { - return newFailure(op, KindInvalidRepository, "the branch is checked out in a worktree", nil) - } - } - hooks, f := c.emptyHooksDir(ctx, op, repo.PrimaryWorktree) - if f != nil { - return f - } - res, f := c.run(ctx, runRequest{op: op, dir: repo.PrimaryWorktree, - args: []string{"-c", "core.hooksPath=" + hooks, "update-ref", "-m", fastForwardReflogMessage, string(branch), string(target), string(expectedTip)}}) - if f != nil { - return f - } - if res.exitCode != 0 { - return newFailure(op, KindInvalidRepository, "the branch is no longer at the expected tip; nothing changed: "+stderrExcerpt(res.stderr), nil).withExitCode(res.exitCode) - } - return nil -} diff --git a/internal/gitcli/refadvance_integration_test.go b/internal/gitcli/refadvance_integration_test.go deleted file mode 100644 index 69777181b..000000000 --- a/internal/gitcli/refadvance_integration_test.go +++ /dev/null @@ -1,103 +0,0 @@ -//go:build integration - -package gitcli - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/danielhanold/docket/internal/testsupport" -) - -// newUnattachedBranchFixture: branch "meta" at base (checked out nowhere) and a -// later commit on main (target) descending from base. -func newUnattachedBranchFixture(t *testing.T) (r *testRepos, base, target ObjectID) { - t.Helper() - r = newMainModeRepos(t) - base = ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) - gitOut(t, r.Invocation, "branch", "meta", string(base)) - writeWorktreeFile(t, r.Invocation, "target.txt", "target\n") - gitOut(t, r.Invocation, "add", "--", "target.txt") - gitOut(t, r.Invocation, "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "target") - target = ObjectID(gitOut(t, r.Invocation, "rev-parse", "HEAD")) - return r, base, target -} - -func TestIntegrationBranchTipAdvanceBranchChecked(t *testing.T) { - ctx := context.Background() - - t.Run("advances an unattached branch", func(t *testing.T) { - r, base, target := newUnattachedBranchFixture(t) - c := newRealClient(t) - if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, target); err != nil { - t.Fatalf("AdvanceBranchChecked: %v", err) - } - if got := metaTip(t, r); got != target { - t.Fatalf("meta = %s, want %s", got, target) - } - }) - - t.Run("equal tips are a verified no-op", func(t *testing.T) { - r, base, _ := newUnattachedBranchFixture(t) - c := newRealClient(t) - if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, base); err != nil { - t.Fatalf("AdvanceBranchChecked equal tips: %v", err) - } - if got := metaTip(t, r); got != base { - t.Fatalf("meta = %s, want %s", got, base) - } - }) - - t.Run("stale expected tip refuses", func(t *testing.T) { - r, base, target := newUnattachedBranchFixture(t) - c := newRealClient(t) - if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", target, target); err == nil { - t.Fatal("advance from a stale expected tip succeeded; want refusal") - } - if got := metaTip(t, r); got != base { - t.Fatalf("meta = %s, want untouched %s", got, base) - } - }) - - t.Run("branch checked out in a worktree refuses", func(t *testing.T) { - r, base, target := newUnattachedBranchFixture(t) - c := newRealClient(t) - wt := filepath.Join(testsupport.TempDir(t), "holder") - gitOut(t, r.Invocation, "worktree", "add", "-q", wt, "meta") - if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, target); err == nil { - t.Fatal("advance of a checked-out branch succeeded; want refusal") - } - if got := metaTip(t, r); got != base { - t.Fatalf("meta = %s, want untouched %s", got, base) - } - }) - - t.Run("target not descending refuses", func(t *testing.T) { - r, base, _ := newUnattachedBranchFixture(t) - c := newRealClient(t) - tree := gitOut(t, r.Invocation, "rev-parse", "HEAD^{tree}") - orphan := ObjectID(gitOut(t, r.Invocation, "-c", "user.name=t", "-c", "user.email=t@example.com", "commit-tree", tree, "-m", "orphan")) - if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, orphan); err == nil { - t.Fatal("advance to a non-descendant succeeded; want refusal") - } - if got := metaTip(t, r); got != base { - t.Fatalf("meta = %s, want untouched %s", got, base) - } - }) - - t.Run("repository hooks never run", func(t *testing.T) { - r, base, target := newUnattachedBranchFixture(t) - c := newRealClient(t) - sentinel := filepath.Join(testsupport.TempDir(t), "hook-ran") - hooks := installHooks(t, sentinel, 1, "reference-transaction") - gitOut(t, r.Invocation, "config", "core.hooksPath", hooks) - if err := c.AdvanceBranchChecked(ctx, Repository{PrimaryWorktree: r.Invocation}, "refs/heads/meta", base, target); err != nil { - t.Fatalf("AdvanceBranchChecked with a failing reference-transaction hook: %v", err) - } - if b, err := os.ReadFile(sentinel); err == nil { - t.Fatalf("a repository hook ran: %q", b) - } - }) -} diff --git a/tests/test_go_integration_gitcli_branchtip.sh b/tests/test_go_integration_gitcli_branchtip.sh index 220d680ca..d794949d0 100644 --- a/tests/test_go_integration_gitcli_branchtip.sh +++ b/tests/test_go_integration_gitcli_branchtip.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # docket-suite: go # tests/test_go_integration_gitcli_branchtip.sh — Go integration shard (change -# 0333): the gitcli branch-tip movement tests (in-place and unattached -# compare-and-swap fast-forwards, the checked delete, and the replace-ref-blind -# ancestry probe that judges them), behind the `integration` build tag, prefix +# 0333): the gitcli branch-tip movement tests (the in-place compare-and-swap +# fast-forward, the worktree fast-forward, the checked delete, and the +# replace-ref-blind ancestry probe that judges them), behind the `integration` build tag, prefix # ^TestIntegrationBranchTip. Split out of the gitcli repo shard so neither grows # past its runtime budget. Declarations only — execution and inspection live in # tests/lib/go-integration-shard.sh; the completeness contract is From 6b2abd34a7ca8fac61ee3c064f9578de3b6f67db Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 09:01:21 -0400 Subject: [PATCH 15/17] fix(prepare): review fixes F2, F4, F5, F7 for the .docket attach and in-place fast-forward - F2: the absent-.docket attach row checks the holder probe for a current branch too, not only a behind one, so a held branch refuses with docket-worktree-ambiguous-registration instead of applying and failing the attach. A holder that is .docket's own leftover registration (directory deleted) or one Git lists as prunable names `git worktree prune` in the remedy; WorktreeInfo now carries Git's prunable annotation. - F4: real-git test for prepareAugment's ListWorktrees holder probe (TestIntegrationRepoInPlaceFFAttachBehindHeldElsewhereRefuses), plus the deleted-.docket prune case. - F5: FastForwardCheckedOutBranch refuses an unfinished Git operation before the compare-and-swap; subtests pin that refusal and the roll-back over a modified tracked file on a changed path. - F7: prepare's attach runs `git worktree add` with docket's empty hooks dir (AddBranchWorktreeNoHooks / AttachBranchWorktreeNoHooks), so a failing post-checkout hook neither runs nor breaks the attach. --- .../app/repoinplaceff_integration_test.go | 76 +++++++++++++++++ internal/app/repository_check_test.go | 6 +- internal/app/repository_prepare.go | 85 ++++++++++++------- internal/app/repository_prepare_test.go | 58 +++++++++---- internal/gitcli/fastforward_inplace.go | 14 ++- .../fastforward_inplace_integration_test.go | 44 ++++++++++ internal/gitcli/worktree.go | 59 +++++++++++-- 7 files changed, 287 insertions(+), 55 deletions(-) diff --git a/internal/app/repoinplaceff_integration_test.go b/internal/app/repoinplaceff_integration_test.go index 373b18462..e9e09ba21 100644 --- a/internal/app/repoinplaceff_integration_test.go +++ b/internal/app/repoinplaceff_integration_test.go @@ -335,3 +335,79 @@ func TestIntegrationRepoInPlaceFFAttachBehindHeldByRebaseRefuses(t *testing.T) { t.Fatalf(".docket attached on a refusal (err=%v)", err) } } + +// TestIntegrationRepoInPlaceFFAttachBehindHeldElsewhereRefuses holds the behind docket +// branch in a live worktree elsewhere, which `git worktree list` reports on the +// branch: the router's holder probe (prepareAugment) must refuse before any effect, +// so the branch keeps its tip and .docket is never attached. +func TestIntegrationRepoInPlaceFFAttachBehindHeldElsewhereRefuses(t *testing.T) { + r, oldTip, _ := newBehindHealthyRepo(t) + removeDocketWorktree(t, r) + other := filepath.Join(testsupport.TempDir(t), "holder") + runGit(t, r.invocation, "worktree", "add", "--force", other, "docket") + + res := runPrepareAt(t, r.invocation) + if res.Disposition != PrepareDispositionRefused || !prepareFinding(res, "docket-worktree-ambiguous-registration") { + t.Fatalf("prepare = %q %+v, want refused docket-worktree-ambiguous-registration", res.Disposition, res.Findings) + } + if local := runGit(t, r.invocation, "rev-parse", "refs/heads/docket"); local != oldTip { + t.Fatalf("local docket = %s, want untouched %s", local, oldTip) + } + if _, err := os.Stat(filepath.Join(r.invocation, ".docket")); !os.IsNotExist(err) { + t.Fatalf(".docket attached on a refusal (err=%v)", err) + } +} + +// TestIntegrationRepoInPlaceFFAttachHeldByDeletedDocketNamesPrune deletes the .docket +// directory but keeps its registration, the common way a current docket branch ends +// up held: prepare must refuse naming `git worktree prune` (never apply and then fail +// the attach), and after that prune it attaches. +func TestIntegrationRepoInPlaceFFAttachHeldByDeletedDocketNamesPrune(t *testing.T) { + r := newHealthyRepo(t) + if err := os.RemoveAll(filepath.Join(r.invocation, ".docket")); err != nil { + t.Fatal(err) + } + res := runPrepareAt(t, r.invocation) + if res.Disposition != PrepareDispositionRefused || len(res.Findings) != 1 || + res.Findings[0].Code != "docket-worktree-ambiguous-registration" || + !strings.Contains(res.Findings[0].Remedy, "git worktree prune") { + t.Fatalf("prepare = %q %+v, want refused docket-worktree-ambiguous-registration naming git worktree prune", res.Disposition, res.Findings) + } + runGit(t, r.invocation, "worktree", "prune") + if again := runPrepareAt(t, r.invocation); again.Disposition != PrepareDispositionApplied { + t.Fatalf("prepare after prune = %q (%s), want applied", again.Disposition, again.HumanText()) + } + requireCheckHealthy(t, r) +} + +// TestIntegrationRepoInPlaceFFAttachRunsNoRepositoryHook installs a failing, +// file-writing post-checkout hook through the shared hooks path. prepare's attach — +// of an existing behind branch and of a newly created one — must not run it, so the +// attach succeeds and .docket ends with its hooks-off setting. +func TestIntegrationRepoInPlaceFFAttachRunsNoRepositoryHook(t *testing.T) { + for _, existing := range []bool{true, false} { + r, _, newTip := newBehindHealthyRepo(t) + removeDocketWorktree(t, r) + if !existing { + runGit(t, r.invocation, "branch", "-D", "docket") + } + hooks := filepath.Join(testsupport.TempDir(t), "shared-hooks") + sentinel := filepath.Join(testsupport.TempDir(t), "hook-ran") + writeHook(t, hooks, "post-checkout", "echo post-checkout >> '"+sentinel+"'\nexit 1\n") + runGit(t, r.invocation, "config", "core.hooksPath", hooks) + + res := runPrepareAt(t, r.invocation) + if res.Disposition != PrepareDispositionApplied { + t.Fatalf("existing=%v: prepare = %q (%s), want applied", existing, res.Disposition, res.HumanText()) + } + if b, err := os.ReadFile(sentinel); err == nil { + t.Fatalf("existing=%v: a repository hook ran during the attach: %q", existing, b) + } + if head := r.dotDocketHead(t); head != newTip { + t.Fatalf("existing=%v: .docket HEAD = %s, want %s", existing, head, newTip) + } + if hp := runGit(t, filepath.Join(r.invocation, ".docket"), "config", "--worktree", "core.hooksPath"); hp == "" || hp == hooks { + t.Fatalf("existing=%v: .docket hooksPath = %q, want docket's hooks-off setting", existing, hp) + } + } +} diff --git a/internal/app/repository_check_test.go b/internal/app/repository_check_test.go index 18a5164fc..e629f27cc 100644 --- a/internal/app/repository_check_test.go +++ b/internal/app/repository_check_test.go @@ -223,7 +223,7 @@ func TestLocalMetadataRefusalsMatchPrepare(t *testing.T) { f := preparableFacts() f.LocalMetadataSync = tc.rel f.DocketWorktree.Synchronized = reposetup.PresenceAbsent - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.finding == nil { t.Fatalf("%s: prepare produced no finding", tc.reason) } @@ -242,7 +242,7 @@ func TestLocalMetadataRefusalsMatchPrepare(t *testing.T) { f := preparableFacts() f.DocketWorktree.Clean = reposetup.PresenceAbsent f.DocketWorktree.UnfinishedOperation = op - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.finding == nil { t.Fatalf("dirty (operation=%v): prepare produced no finding", op) } @@ -262,7 +262,7 @@ func TestLocalMetadataRefusalsMatchPrepare(t *testing.T) { f := preparableFacts() f.DocketWorktree.Clean = reposetup.PresenceAbsent f.DocketWorktree.InterruptedFastForward = true - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.finding == nil { t.Fatal("interrupted fast-forward: prepare produced no finding") } diff --git a/internal/app/repository_prepare.go b/internal/app/repository_prepare.go index a932c822f..8a820fc2a 100644 --- a/internal/app/repository_prepare.go +++ b/internal/app/repository_prepare.go @@ -235,9 +235,9 @@ type prepareVerdict struct { // that is missing, current, or strictly behind (prepareAttachRoute). // // heldElsewhere says whether another worktree holds the local docket branch. It is -// consulted only on the absent-.docket attach row, where a behind branch must move -// before it attaches; prepareAugment probes it only for that row. -func prepareRoute(f reposetup.Facts, heldElsewhere reposetup.Presence) prepareVerdict { +// consulted only on the absent-.docket attach row, where an existing branch must be +// free to attach; prepareAugment probes it only for that row. +func prepareRoute(f reposetup.Facts, heldElsewhere prepareHolder) prepareVerdict { // 1. Fail-closed: a required topology probe that could not be resolved is an // error, never a fabricated absence that could authorize an attach. if u := prepareUnresolvedTopology(f); len(u) > 0 { @@ -400,12 +400,22 @@ func prepareDivergedFinding() reposetup.Finding { } } +// prepareHolder is what prepareAugment learned about another worktree holding the +// local docket branch. stale marks a leftover registration — the .docket path's own +// (its directory deleted, the registration kept) or one Git lists as prunable — which +// `git worktree prune` clears. +type prepareHolder struct { + presence reposetup.Presence + stale bool +} + // prepareAttachRoute decides the absent-.docket row by the local docket branch's // relationship to the pinned remote tip, so prepare never attaches a stale, ahead, -// or diverged branch as-is and then reports healthy. A behind branch moves to the -// target first (a compare-and-swap from the observed tip); a behind branch another -// worktree holds is a forced, unsupported setup and refuses with nothing touched. -func prepareAttachRoute(f reposetup.Facts, heldElsewhere reposetup.Presence) prepareVerdict { +// or diverged branch as-is and then reports healthy. A current or behind branch +// attaches (a behind one then moves to the target by compare-and-swap from the +// observed tip); one another worktree holds is a forced, unsupported setup — or a +// leftover registration — and refuses with nothing touched. +func prepareAttachRoute(f reposetup.Facts, heldElsewhere prepareHolder) prepareVerdict { target := f.RemoteMetadata.Tip switch f.LocalMetadata.Presence { case reposetup.PresenceAbsent: @@ -414,20 +424,12 @@ func prepareAttachRoute(f reposetup.Facts, heldElsewhere reposetup.Presence) pre return prepareLocalUnknownVerdict() } switch f.LocalMetadataSync { - case reposetup.SyncCurrent: - return prepareApplyVerdict(prepareActionAttach, target, f.LocalMetadata.Tip) - case reposetup.SyncBehind: - switch heldElsewhere { + case reposetup.SyncCurrent, reposetup.SyncBehind: + switch heldElsewhere.presence { case reposetup.PresenceAbsent: return prepareApplyVerdict(prepareActionAttach, target, f.LocalMetadata.Tip) case reposetup.PresencePresent: - return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ - Code: string(FCDocketWorktreeAmbiguousRegistration), - Severity: reposetup.SeverityError, - Ref: docketWorktreeName, - Message: "The local docket branch is checked out in another worktree, so it cannot be moved to the remote tip and attached at .docket.", - Remedy: "Inspect the worktree holding the docket branch and resolve it manually with a human, then run `docket repository check`.", - }) + return prepareRefuseVerdict(reposetup.StateConflict, prepareHeldElsewhereFinding(heldElsewhere.stale)) default: return prepareLocalUnknownVerdict() } @@ -440,6 +442,24 @@ func prepareAttachRoute(f reposetup.Facts, heldElsewhere reposetup.Presence) pre } } +// prepareHeldElsewhereFinding refuses an attach whose local docket branch another +// worktree registration holds. A stale registration names `git worktree prune`, the +// plain-Git cleanup; a live one is a human's to resolve. +func prepareHeldElsewhereFinding(stale bool) reposetup.Finding { + f := reposetup.Finding{ + Code: string(FCDocketWorktreeAmbiguousRegistration), + Severity: reposetup.SeverityError, + Ref: docketWorktreeName, + Message: "The local docket branch is checked out in another worktree, so it cannot be attached at .docket.", + Remedy: "Inspect the worktree holding the docket branch and resolve it manually with a human, then run `docket repository check`.", + } + if stale { + f.Message = "The local docket branch is still registered to a worktree whose directory is gone, so it cannot be attached at .docket." + f.Remedy = "Run `git worktree prune` to drop the stale worktree registration, then run `docket repository prepare`." + } + return f +} + // prepareUnresolvedTopology lists the required topology probes prepare could not // prove, mirroring the classifier's unknown ladder. A non-empty result is // fail-closed: the repository state is undeterminable and no local effect is safe. @@ -555,7 +575,7 @@ func RunRepositoryPrepare(ctx context.Context, d SetupDeps, o PrepareOptions) Re // metadata branch is proven present; a fresh/legacy repository is decided from // the base facts alone. Every probe maps its own error to the safe Unknown // value, never a false absence. - held := reposetup.PresenceUnknown + held := prepareHolder{presence: reposetup.PresenceUnknown} if facts.RemoteMetadata.Presence == reposetup.PresencePresent { held = prepareAugment(ctx, d.Git, &facts, sc) } @@ -582,11 +602,12 @@ func RunRepositoryPrepare(ctx context.Context, d SetupDeps, o PrepareOptions) Re // deliberately leaves unproven, including LocalMetadataSync (the ancestry // relationship between the local metadata tip and the pinned remote metadata // revision, computed by applyLocalMetadataSync). It returns whether another worktree -// holds the local docket branch, probed only when `.docket` is absent and the local +// holds the local docket branch, and whether that holder is a stale registration +// (prepareHolder), probed only when `.docket` is absent and the local // branch is present — the one attach row that consults it; everywhere else it is // PresenceUnknown and never read. Every probe maps its own error to the safe Unknown // value so a probe that could not run can never let the router read healthy. -func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, sc setupContext) reposetup.Presence { +func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, sc setupContext) prepareHolder { metaRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) // Metadata root shape at the FETCHED remote docket tip (fetch it first so the @@ -632,18 +653,22 @@ func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, applyLocalMetadataSync(ctx, git, sc.repo, f) if f.DocketWorktree.Presence != reposetup.PresenceAbsent || f.LocalMetadata.Presence != reposetup.PresencePresent { - return reposetup.PresenceUnknown // not consulted outside the absent-.docket attach row + return prepareHolder{presence: reposetup.PresenceUnknown} // not consulted outside the absent-.docket attach row } wts, err := git.ListWorktrees(ctx, sc.repo) if err != nil { - return reposetup.PresenceUnknown + return prepareHolder{presence: reposetup.PresenceUnknown} } + worktreePath := filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName) for _, wt := range wts { if wt.Branch == metaRef { - return reposetup.PresencePresent + return prepareHolder{ + presence: reposetup.PresencePresent, + stale: wt.Prunable || filepath.Clean(wt.Path) == filepath.Clean(worktreePath), + } } } - return reposetup.PresenceAbsent + return prepareHolder{presence: reposetup.PresenceAbsent} } // prepareExecute performs the single planned idempotent Git effect. Attachment @@ -681,17 +706,19 @@ func prepareExecute(ctx context.Context, git *gitcli.Client, sc setupContext, ve // hooks forced off; when the branch is already current that is a no-op that still // verifies the tip. A branch that moved between routing and the attach fails that // compare-and-swap with its commits kept, leaving .docket attached (hooks-off already -// applied) at the moved tip for the next run to classify. Hooks-off is applied -// right after the attach in every case. +// applied) at the moved tip for the next run to classify. The attach itself runs no +// repository hook (gitcli's AttachBranchWorktreeNoHooks / AddBranchWorktreeNoHooks), +// so a failing post-checkout hook cannot leave .docket attached with hooks on; +// hooks-off is applied right after the attach in every case. func prepareAttachFresh(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, worktreePath string, metaRef gitcli.RefName, v prepareVerdict) error { target := gitcli.ObjectID(v.targetRev) if v.observedTip == "" { - if err := git.AddBranchWorktree(ctx, repo, worktreePath, metaRef, target); err != nil { + if err := git.AddBranchWorktreeNoHooks(ctx, repo, worktreePath, metaRef, target); err != nil { return err } return git.DisableWorktreeHooks(ctx, worktreePath) } - if err := git.AttachBranchWorktree(ctx, repo, worktreePath, metaRef); err != nil { + if err := git.AttachBranchWorktreeNoHooks(ctx, repo, worktreePath, metaRef); err != nil { return err } if err := git.DisableWorktreeHooks(ctx, worktreePath); err != nil { diff --git a/internal/app/repository_prepare_test.go b/internal/app/repository_prepare_test.go index 4e6131123..a3cb5a8e0 100644 --- a/internal/app/repository_prepare_test.go +++ b/internal/app/repository_prepare_test.go @@ -55,7 +55,7 @@ func TestRepositoryPrepareFreshRefusesWithInitRemedy(t *testing.T) { f.LiveSurface = reposetup.PresenceAbsent f.LocalMetadataSync = reposetup.SyncUnknown - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -81,7 +81,7 @@ func TestRepositoryPrepareLegacyRefusesWithMigrateRemedy(t *testing.T) { f.LiveSurface = reposetup.PresencePresent f.LocalMetadataSync = reposetup.SyncUnknown - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionRefused || v.state != reposetup.StateLegacy { t.Fatalf("verdict = %q/%q, want refused/legacy", v.disposition, v.state) } @@ -102,7 +102,7 @@ func TestRepositoryPrepareHealthyMissingWorktreeAttaches(t *testing.T) { f.LocalMetadata = reposetup.BranchFact{Presence: reposetup.PresenceAbsent} f.LocalMetadataSync = reposetup.SyncUnknown - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionApplied { t.Fatalf("disposition = %q, want applied", v.disposition) } @@ -141,7 +141,9 @@ func TestRepositoryPrepareAbsentWorktreeRoutesOnFreshness(t *testing.T) { }{ {"no local branch creates", absent(reposetup.BranchFact{Presence: reposetup.PresenceAbsent}, reposetup.SyncUnknown), reposetup.PresenceUnknown, PrepareDispositionApplied, prepareActionAttach, "", ""}, {"local unknown refuses", absent(reposetup.BranchFact{}, reposetup.SyncUnknown), reposetup.PresenceUnknown, PrepareDispositionRefused, prepareActionNone, "", "prepare-local-state-unknown"}, - {"current attaches", absent(present("m9"), reposetup.SyncCurrent), reposetup.PresenceUnknown, PrepareDispositionApplied, prepareActionAttach, "m9", ""}, + {"current free attaches", absent(present("m9"), reposetup.SyncCurrent), reposetup.PresenceAbsent, PrepareDispositionApplied, prepareActionAttach, "m9", ""}, + {"current held elsewhere refuses", absent(present("m9"), reposetup.SyncCurrent), reposetup.PresencePresent, PrepareDispositionRefused, prepareActionNone, "", "docket-worktree-ambiguous-registration"}, + {"current holder unknown refuses", absent(present("m9"), reposetup.SyncCurrent), reposetup.PresenceUnknown, PrepareDispositionRefused, prepareActionNone, "", "prepare-local-state-unknown"}, {"behind free advances then attaches", absent(present("m1"), reposetup.SyncBehind), reposetup.PresenceAbsent, PrepareDispositionApplied, prepareActionAttach, "m1", ""}, {"behind held elsewhere refuses", absent(present("m1"), reposetup.SyncBehind), reposetup.PresencePresent, PrepareDispositionRefused, prepareActionNone, "", "docket-worktree-ambiguous-registration"}, {"behind holder unknown refuses", absent(present("m1"), reposetup.SyncBehind), reposetup.PresenceUnknown, PrepareDispositionRefused, prepareActionNone, "", "prepare-local-state-unknown"}, @@ -150,7 +152,7 @@ func TestRepositoryPrepareAbsentWorktreeRoutesOnFreshness(t *testing.T) { {"relation unknown refuses", absent(present("mX"), reposetup.SyncUnknown), reposetup.PresenceAbsent, PrepareDispositionRefused, prepareActionNone, "", "prepare-local-state-unknown"}, } for _, row := range rows { - v := prepareRoute(row.f, row.held) + v := prepareRoute(row.f, prepareHolder{presence: row.held}) if v.disposition != row.wantDisp || v.action != row.wantAction || v.observedTip != row.wantObserved { t.Errorf("%s: verdict %q/%v observed %q, want %q/%v observed %q", row.name, v.disposition, v.action, v.observedTip, row.wantDisp, row.wantAction, row.wantObserved) } @@ -163,6 +165,28 @@ func TestRepositoryPrepareAbsentWorktreeRoutesOnFreshness(t *testing.T) { } } +// TestRepositoryPrepareHeldByStaleRegistrationNamesPrune — a docket branch held by a +// leftover registration (the deleted .docket's own, or one Git lists as prunable) +// refuses naming `git worktree prune`; a live holder never does. +func TestRepositoryPrepareHeldByStaleRegistrationNamesPrune(t *testing.T) { + for _, rel := range []reposetup.SyncRelation{reposetup.SyncCurrent, reposetup.SyncBehind} { + f := preparableFacts() + f.DocketWorktree = reposetup.WorktreeFact{Presence: reposetup.PresenceAbsent} + f.LocalMetadata = reposetup.BranchFact{Presence: reposetup.PresencePresent, Tip: "m1"} + f.LocalMetadataSync = rel + stale := prepareRoute(f, prepareHolder{presence: reposetup.PresencePresent, stale: true}) + if stale.disposition != PrepareDispositionRefused || stale.finding == nil || + stale.finding.Code != string(FCDocketWorktreeAmbiguousRegistration) || + !strings.Contains(stale.finding.Remedy, "git worktree prune") { + t.Errorf("%v stale holder: verdict %q finding %+v, want refused naming git worktree prune", rel, stale.disposition, stale.finding) + } + live := prepareRoute(f, prepareHolder{presence: reposetup.PresencePresent}) + if live.finding == nil || strings.Contains(live.finding.Remedy, "prune") { + t.Errorf("%v live holder: finding %+v, want a remedy that never names prune", rel, live.finding) + } + } +} + // TestRepositoryPrepareCleanBehindFastForwards — a clean, strictly-behind worktree // applies, fast-forwarding to the pinned remote metadata revision. func TestRepositoryPrepareCleanBehindFastForwards(t *testing.T) { @@ -171,7 +195,7 @@ func TestRepositoryPrepareCleanBehindFastForwards(t *testing.T) { f.DocketWorktree.Synchronized = reposetup.PresenceAbsent f.LocalMetadataSync = reposetup.SyncBehind - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionApplied { t.Fatalf("disposition = %q, want applied", v.disposition) } @@ -191,7 +215,7 @@ func TestRepositoryPrepareCleanBehindFastForwards(t *testing.T) { func TestRepositoryPrepareCleanCurrentIsNoOp(t *testing.T) { f := preparableFacts() f.LocalMetadataSync = reposetup.SyncCurrent - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionNoOp { t.Fatalf("disposition = %q, want no-op", v.disposition) } @@ -210,7 +234,7 @@ func TestRepositoryPrepareDirtyRefuses(t *testing.T) { f.DocketWorktree.Clean = reposetup.PresenceAbsent f.LocalMetadataSync = reposetup.SyncCurrent - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionRefused || v.action != prepareActionNone { t.Fatalf("verdict = %q/%v, want refused/none", v.disposition, v.action) } @@ -227,7 +251,7 @@ func TestRepositoryPrepareAheadRefuses(t *testing.T) { f.DocketWorktree.Synchronized = reposetup.PresenceAbsent f.LocalMetadataSync = reposetup.SyncAhead - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionRefused || v.action != prepareActionNone { t.Fatalf("verdict = %q/%v, want refused/none", v.disposition, v.action) } @@ -243,7 +267,7 @@ func TestRepositoryPrepareDivergedRefuses(t *testing.T) { f.DocketWorktree.Synchronized = reposetup.PresenceAbsent f.LocalMetadataSync = reposetup.SyncDiverged - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -259,7 +283,7 @@ func TestRepositoryPrepareForeignRefuses(t *testing.T) { f.DocketWorktree.Foreign = true f.LocalMetadataSync = reposetup.SyncCurrent - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -276,7 +300,7 @@ func TestRepositoryPrepareForeignRootRefusesAsForeign(t *testing.T) { f.MetadataRoot = reposetup.RootForeign f.LocalMetadataSync = reposetup.SyncCurrent - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -300,7 +324,7 @@ func TestRepositoryPrepareUnknownRootRefusesAsReachability(t *testing.T) { f.MetadataRoot = reposetup.RootUnknown // RemoteMetadata stays PresencePresent f.LocalMetadataSync = reposetup.SyncCurrent - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused (fail-closed, no mutation)", v.disposition) } @@ -334,7 +358,7 @@ func TestRepositoryPrepareAmbiguousRegistrationRefuses(t *testing.T) { f.DocketWorktree.Registered = reposetup.PresenceUnknown f.LocalMetadataSync = reposetup.SyncCurrent - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -348,7 +372,7 @@ func TestRepositoryPrepareAmbiguousRegistrationRefuses(t *testing.T) { func TestRepositoryPrepareProbeUnknownRefuses(t *testing.T) { f := preparableFacts() f.LocalMetadataSync = reposetup.SyncUnknown - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionRefused { t.Fatalf("disposition = %q, want refused", v.disposition) } @@ -379,7 +403,7 @@ func TestRepositoryPrepareProbeErrorIsNotAbsence(t *testing.T) { } f.LocalMetadataSync = reposetup.SyncUnknown - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) if v.disposition != PrepareDispositionError { t.Errorf("disposition = %q, want error (an errored probe is never a clean absence)", v.disposition) } @@ -700,7 +724,7 @@ func TestRepositoryPrepareResultOmitsContextOnRefusal(t *testing.T) { f := preparableFacts() f.RemoteMetadata = reposetup.BranchFact{Presence: reposetup.PresenceAbsent} f.LocalMetadataSync = reposetup.SyncUnknown - v := prepareRoute(f, reposetup.PresenceAbsent) + v := prepareRoute(f, prepareHolder{presence: reposetup.PresenceAbsent}) res := prepareDiagnosisResult(ResultInvalidState, PrepareDispositionRefused, v, nil) if res.Context != nil { t.Errorf("a refusal must carry no context, got %+v", res.Context) diff --git a/internal/gitcli/fastforward_inplace.go b/internal/gitcli/fastforward_inplace.go index 60518021e..4a6739848 100644 --- a/internal/gitcli/fastforward_inplace.go +++ b/internal/gitcli/fastforward_inplace.go @@ -16,7 +16,8 @@ const fastForwardReflogMessage = "docket: fast-forward" // worktree directory, its admin dir (registration, per-worktree config, lock), the // branch, and its reflog all survive. The branch moves by compare-and-swap from // expectedTip, so a commit made since the caller observed expectedTip makes it -// refuse with nothing changed. target must descend from expectedTip, judged with +// refuse with nothing changed, as does an unfinished Git operation in the worktree +// (WorktreeCheckoutState). target must descend from expectedTip, judged with // replace refs and grafts ignored. The tree moves with `read-tree -u -m`, which // refuses rather than overwrite a local change or an untracked file in the way; on // that refusal the branch is swapped back. An ignored file at a path target tracks @@ -50,6 +51,17 @@ func (c *Client) FastForwardCheckedOutBranch(ctx context.Context, worktreeDir st return newFailure(op, KindInvalidRepository, "the worktree does not have the branch checked out", nil) } + // An unfinished Git operation (merge, cherry-pick, revert, rebase, bisect) refuses + // before anything moves: its staged state may not collide with the target, so + // read-tree would succeed and strand the operation on a moved branch. + state, err := c.WorktreeCheckoutState(ctx, worktreeDir) + if err != nil { + return err + } + if state.OperationInProgress { + return newFailure(op, KindInvalidRepository, "an unfinished Git operation is in progress in the worktree; nothing changed", nil) + } + descends, err := c.isAncestorIn(ctx, worktreeDir, expectedTip, target, true) if err != nil { return err diff --git a/internal/gitcli/fastforward_inplace_integration_test.go b/internal/gitcli/fastforward_inplace_integration_test.go index b1c182bd7..7a2f3c373 100644 --- a/internal/gitcli/fastforward_inplace_integration_test.go +++ b/internal/gitcli/fastforward_inplace_integration_test.go @@ -138,6 +138,50 @@ func TestIntegrationBranchTipFastForwardCheckedOutBranch(t *testing.T) { } }) + t.Run("modified tracked file on a changed path refuses and rolls back", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + writeWorktreeFile(t, wt, "README.md", "local edit\n") + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err == nil { + t.Fatal("fast-forward over a modified tracked file succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s after refusal, want rolled back to %s", got, base) + } + if b, _ := os.ReadFile(filepath.Join(wt, "README.md")); string(b) != "local edit\n" { + t.Fatalf("README.md = %q, want the local modification intact", b) + } + }) + + // An unfinished merge whose staged content does not collide with the target would + // let read-tree succeed and strand MERGE_HEAD; the operation check refuses first. + t.Run("unfinished merge refuses and changes nothing", func(t *testing.T) { + r, wt, base, target := newCheckedOutBranchFixture(t) + c := newRealClient(t) + gitOut(t, wt, "checkout", "-q", "-b", "side") + writeWorktreeFile(t, wt, "side.txt", "side\n") + gitOut(t, wt, "add", "--", "side.txt") + gitOut(t, wt, "-c", "user.name=t", "-c", ffIdent, "commit", "-q", "-m", "side") + gitOut(t, wt, "checkout", "-q", "meta") + gitOut(t, wt, "-c", "user.name=t", "-c", ffIdent, "merge", "-q", "--no-ff", "--no-commit", "side") + mergeHead := filepath.Join(gitOut(t, wt, "rev-parse", "--absolute-git-dir"), "MERGE_HEAD") + if _, err := os.Stat(mergeHead); err != nil { + t.Fatalf("premise: no merge in progress: %v", err) + } + if err := c.FastForwardCheckedOutBranch(ctx, wt, "refs/heads/meta", base, target); err == nil { + t.Fatal("fast-forward during an unfinished merge succeeded; want refusal") + } + if got := metaTip(t, r); got != base { + t.Fatalf("meta = %s, want untouched %s", got, base) + } + if _, err := os.Stat(mergeHead); err != nil { + t.Fatalf("MERGE_HEAD gone after refusal: %v", err) + } + if b, _ := os.ReadFile(filepath.Join(wt, "side.txt")); string(b) != "side\n" { + t.Fatalf("merged side.txt = %q, want the in-progress merge content intact", b) + } + }) + t.Run("stale stat info on a changed path still fast-forwards", func(t *testing.T) { r, wt, base, target := newCheckedOutBranchFixture(t) c := newRealClient(t) diff --git a/internal/gitcli/worktree.go b/internal/gitcli/worktree.go index 9ec2c02c6..8e1327e65 100644 --- a/internal/gitcli/worktree.go +++ b/internal/gitcli/worktree.go @@ -20,12 +20,15 @@ const ( // WorktreeInfo is one registered worktree as reported by // `git worktree list --porcelain -z`. Path is the path git reports; callers // canonicalize (Abs + every symlink hop) before comparing it to their own -// spelling. Branch is empty on a detached worktree. +// spelling. Branch is empty on a detached worktree. Prunable is Git's own +// "prunable" annotation: the registration's directory is gone, so +// `git worktree prune` would drop it. type WorktreeInfo struct { Path string Head ObjectID Detached bool Branch RefName + Prunable bool } // AddDetachedWorktree registers a detached worktree at path checked out at @@ -89,6 +92,18 @@ func (c *Client) RemoveWorktree(ctx context.Context, repo Repository, path strin // command-failed. path must be absolute; a malformed start commit id or branch // ref is invalid-request. func (c *Client) AddBranchWorktree(ctx context.Context, repo Repository, path string, branch RefName, startCommit ObjectID) error { + return c.addBranchWorktree(ctx, repo, path, branch, startCommit, false) +} + +// AddBranchWorktreeNoHooks is AddBranchWorktree with no repository hook run: the +// `worktree add` forces docket's empty hooks dir, so a failing post-checkout hook +// can neither run nor fail the attach before the caller turns the new worktree's +// own hooks off. +func (c *Client) AddBranchWorktreeNoHooks(ctx context.Context, repo Repository, path string, branch RefName, startCommit ObjectID) error { + return c.addBranchWorktree(ctx, repo, path, branch, startCommit, true) +} + +func (c *Client) addBranchWorktree(ctx context.Context, repo Repository, path string, branch RefName, startCommit ObjectID, noHooks bool) error { if !filepath.IsAbs(path) { return newFailure(worktreeAddBranchOp, KindInvalidRequest, "worktree path must be absolute", nil) } @@ -102,10 +117,14 @@ func (c *Client) AddBranchWorktree(ctx context.Context, repo Repository, path st if err := validateObjectID(startCommit); err != nil { return newFailure(worktreeAddBranchOp, KindInvalidRequest, "invalid start commit id", err) } + args, f := c.worktreeAddArgs(ctx, worktreeAddBranchOp, repo, noHooks, "-b", short, "--", path, string(startCommit)) + if f != nil { + return f + } res, f := c.run(ctx, runRequest{ op: worktreeAddBranchOp, dir: repo.PrimaryWorktree, - args: []string{"worktree", "add", "-b", short, "--", path, string(startCommit)}, + args: args, }) if f != nil { return f @@ -122,6 +141,16 @@ func (c *Client) AddBranchWorktree(ctx context.Context, repo Repository, path st // non-zero exit surfaced as command-failed, never a create. branch must be a // fully qualified refs/heads/... name; path must be absolute. func (c *Client) AttachBranchWorktree(ctx context.Context, repo Repository, path string, branch RefName) error { + return c.attachBranchWorktree(ctx, repo, path, branch, false) +} + +// AttachBranchWorktreeNoHooks is AttachBranchWorktree with no repository hook run +// (see AddBranchWorktreeNoHooks). +func (c *Client) AttachBranchWorktreeNoHooks(ctx context.Context, repo Repository, path string, branch RefName) error { + return c.attachBranchWorktree(ctx, repo, path, branch, true) +} + +func (c *Client) attachBranchWorktree(ctx context.Context, repo Repository, path string, branch RefName, noHooks bool) error { if !filepath.IsAbs(path) { return newFailure(worktreeAttachOp, KindInvalidRequest, "worktree path must be absolute", nil) } @@ -132,10 +161,14 @@ func (c *Client) AttachBranchWorktree(ctx context.Context, repo Repository, path if short == string(branch) { return newFailure(worktreeAttachOp, KindInvalidRequest, "branch must be fully qualified refs/heads/", nil) } + args, f := c.worktreeAddArgs(ctx, worktreeAttachOp, repo, noHooks, "--", path, short) + if f != nil { + return f + } res, f := c.run(ctx, runRequest{ op: worktreeAttachOp, dir: repo.PrimaryWorktree, - args: []string{"worktree", "add", "--", path, short}, + args: args, }) if f != nil { return f @@ -146,6 +179,20 @@ func (c *Client) AttachBranchWorktree(ctx context.Context, repo Repository, path return nil } +// worktreeAddArgs builds a `worktree add` argument list, prefixed with docket's +// forced empty hooks dir when noHooks is set. +func (c *Client) worktreeAddArgs(ctx context.Context, op Operation, repo Repository, noHooks bool, rest ...string) ([]string, *Failure) { + args := []string{"worktree", "add"} + if noHooks { + hooks, f := c.emptyHooksDir(ctx, op, repo.PrimaryWorktree) + if f != nil { + return nil, f + } + args = append([]string{"-c", "core.hooksPath=" + hooks}, args...) + } + return append(args, rest...), nil +} + // RemoveWorktreeClean deregisters exactly the worktree registered at path via // `git worktree remove -- ` (WITHOUT --force), run from the primary // worktree. Git itself rechecks cleanliness at the destructive boundary, @@ -200,7 +247,7 @@ func (c *Client) ListWorktrees(ctx context.Context, repo Repository) ([]Worktree // `worktree list --porcelain -z`. Each worktree stanza is a run of // "label value" (or bare "label") fields terminated by an empty field; the // "worktree " field opens a stanza. Recognized attributes are worktree, -// HEAD, branch, and detached; bare and any other attribute (locked, prunable, +// HEAD, branch, detached, and prunable; bare and any other attribute (locked, // …) are tolerated and ignored. A HEAD value must be a valid object id; a // stanza without a worktree path is malformed. func parseWorktreeListZ(out []byte) ([]WorktreeInfo, error) { @@ -256,8 +303,10 @@ func parseWorktreeListZ(out []byte) ([]WorktreeInfo, error) { cur.Detached = true case "bare": isBare = true + case "prunable": + cur.Prunable = true default: - // locked, prunable, and any future attribute: ignored. + // locked and any future attribute: ignored. } } if err := flush(); err != nil { From d2c34217f0d8387efb568805541fe34c3d53bb46 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 09:02:25 -0400 Subject: [PATCH 16/17] docs(upgrading): F6 prepare-optional prose names docket repository prepare, not any command --- docs/release/upgrading-from-bash.md | 2 +- internal/bashupgrade/registry_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release/upgrading-from-bash.md b/docs/release/upgrading-from-bash.md index 67af28ff7..58e66e157 100644 --- a/docs/release/upgrading-from-bash.md +++ b/docs/release/upgrading-from-bash.md @@ -197,7 +197,7 @@ docket repository repair --yes It then tells you to run `docket repository prepare` to bring your local `.docket` folder up to date. -That step is optional: the next docket command brings the folder up to date by itself. +That step is optional: every docket workflow runs `docket repository prepare` first, which brings the folder up to date. Check the repository: diff --git a/internal/bashupgrade/registry_test.go b/internal/bashupgrade/registry_test.go index bd5db19c4..70481dcf2 100644 --- a/internal/bashupgrade/registry_test.go +++ b/internal/bashupgrade/registry_test.go @@ -381,7 +381,7 @@ func gitRev(t *testing.T, c *upgradeCase, ref string) string { // repairPrepareOptionalProse is the guide's statement that the prepare step repair // mentions is optional. -const repairPrepareOptionalProse = "That step is optional: the next docket command brings the folder up to date by itself." +const repairPrepareOptionalProse = "That step is optional: every docket workflow runs `docket repository prepare` first, which brings the folder up to date." // releaseVerifiedProse is the guide's statement that the download and checksum // lines rest on the release verification, not on this test. From 1649e4fbca065a7553551e4bf1330de4b94efdc9 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 09:03:02 -0400 Subject: [PATCH 17/17] docs(results): change 0523 results --- ...hind-only-docket-copy-as-diverg-results.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/results/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg-results.md diff --git a/docs/results/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg-results.md b/docs/results/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg-results.md new file mode 100644 index 000000000..d36a3539a --- /dev/null +++ b/docs/results/2026-10-05-repository-check-reports-a-behind-only-docket-copy-as-diverg-results.md @@ -0,0 +1,51 @@ + +> ↩ **[Change 0523 — Treat a behind-only .docket copy as healthy and make prepare fast-forward it in place](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0523-repository-check-reports-a-behind-only-docket-copy-as-diverg.md)** + +# Treat a behind-only .docket copy as healthy and make prepare fast-forward it in place — Results + +**Human action:** No action is required before merge. One optional walkthrough is below if you want to watch the user-visible fix on a real checkout. + +## Outcome + +Before this change, `docket repository check` reported a clean `.docket` folder that was only behind `origin/docket` as both dirty and diverged, and told the user to reconcile it with a human. That is the normal state after any typed metadata write. Now: + +- **Check classifies the local copy by ancestry, not tip equality.** Behind-only is healthy. Ahead gets its own `local-metadata-ahead` finding. Diverged keeps `local-metadata-diverged`. An unprovable relationship gets `local-metadata-sync-unverified`. The ancestry probe ignores replace refs and grafts. Check and prepare compute the relationship through one shared function, so their verdicts and refusal texts match. A parity test pins this. +- **Prepare fast-forwards `.docket` in place.** It no longer removes and re-adds the worktree. The move is a compare-and-swap with repository hooks forced off: it refuses rather than overwrite a local edit, an untracked file in the way, or an unfinished Git operation, and it rolls the branch back if the checkout step fails. As decided during the build, an ignored file at a path the target newly tracks is overwritten, which is Git's normal checkout rule (spec §3 property 4). +- **The interrupted case gets a safe remedy.** If prepare is killed mid-update, the next check or prepare names the state and tells the user to run `git -C .docket reset --merge HEAD`. It no longer suggests committing, which would have recorded a revert of the remote metadata. +- **Attach checks freshness.** When `.docket` is missing, prepare attaches at the current tip and then fast-forwards it in place. Git's own checked-out, rebase, and bisect guard decides whether the branch is held elsewhere. A held branch gets the routed `docket-worktree-ambiguous-registration` refusal. If the holder is `.docket`'s own stale registration, the remedy names `git worktree prune`. The attach runs with repository hooks off. +- **Check treats an unfinished Git operation as dirty, and splits the primary-checkout finding by relationship.** A primary checkout that is ahead is no longer reported as behind. +- The upgrade guide no longer tells users to re-run `prepare` as a workaround. + +One departure from the plan: the plan's `AdvanceBranchChecked`, which moved the branch before attaching, was replaced during the fix pass by attach-then-fast-forward, and the primitive was deleted. + +## Human actions and testing + +### Optional — watch check accept a behind-only `.docket` + +This exercises behavior the integration tests already cover. It shows the fix on a real checkout. + +Prerequisites: a docket-managed repository with this branch's binary installed (`go run ./cmd/docket development install --source `), and a clean `.docket`. + +1. From another clone or a second terminal, make any typed metadata write that pushes to `origin/docket`, then run `git -C fetch origin`. + Expected: `git -C /.docket status` shows the branch is behind `origin/docket` and nothing to commit. +2. Run `docket repository check --repo-dir `. + Expected: no `metadata-worktree-dirty` and no `local-metadata-diverged` finding. +3. Run `docket repository prepare --repo-dir --json`. + Expected: `applied`. `.docket` is now at the remote tip, and the directory was not removed and re-created (its inode is unchanged: compare `ls -id /.docket` before and after). + +## Verification performed + +- Every plan task was test-driven by its worker (RED first, then GREEN) with focused tests. Each guard added on this branch was mutation-tested: an 8-row mutation matrix plus per-fix probes, and every mutation turned its test red. +- The build gate ran the full suite (`go run ./cmd/docket development test`) green before review. The suite runs again on this final head before the PR opens; that evidence goes in the PR body. +- Budget: the gitcli repo shard went over budget solo (36s against 30s). Following the split-not-bump rule, its scenarios were split into a new `gitcli_branchtip` shard (9s, budget 15s). The `repoinplaceff` shard runs 24–28s solo against its 30s ceiling. The gate's budget report showed only PARALLEL-SENSITIVE lines for unrelated shards. +- Whole-branch review (deep tier): 7 findings (1 important, 6 minor), all fixed in-branch. The full disposition table is in the PR body. + +## Known issues and follow-ups + +### The `repoinplaceff` test shard has little budget headroom + +It runs in 24–28s solo against a 30s ceiling. A heavily loaded machine may print a `BUDGET WATCH` line for it. That line is informational and does not fail the run. This is confirmed by measurement and is in scope. Next action: put the next test for this area in a sibling shard rather than this one. + +### A branch moved during attach leaves `.docket` attached at the moved tip + +If another writer moves the local `docket` branch between prepare's routing and its attach, the compare-and-swap refuses. `.docket` stays attached, hooks off, at the moved tip, with nothing lost, and the next run classifies it as ahead, behind, or diverged. This is a designed limitation, not a defect. No action is needed.