diff --git a/internal/dispatch/build.go b/internal/dispatch/build.go index 4377bcd0..b9d5786c 100644 --- a/internal/dispatch/build.go +++ b/internal/dispatch/build.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" "regexp" "strings" @@ -407,8 +408,13 @@ func runBuildFields(probe claudeteam.TeamStateProbe, opts buildOptions, fields m // underivable branch (should not happen for a split-root dir) falls back to a // branch-neutral reminder inside stateCommitGuidance. var stateBranch string + // stateRemotePresent gates the remote-sync tail of the state-commit guidance: + // a split-root checkout with no `origin` cannot push/pull, so the guidance + // degrades to local-only. Probed once here so both guidance callers below agree. + var stateRemotePresent bool if splitRoot { stateBranch, _ = status.StateBranch(workflowDir) + stateRemotePresent = stateHasOrigin(stateCheckout) } // Rule 5: Feedback context required for feedback reflow. @@ -495,7 +501,7 @@ func runBuildFields(probe claudeteam.TeamStateProbe, opts buildOptions, fields m "code to main.\n"+ "%s", worktreePath, worktreePath, branch, - stateCommitGuidance(stateCheckout, entityPath, stateBranch))) + stateCommitGuidance(stateCheckout, entityPath, stateBranch, stateRemotePresent))) } else { parts = append(parts, fmt.Sprintf( "Your working directory is %s\n"+ @@ -505,7 +511,7 @@ func runBuildFields(probe claudeteam.TeamStateProbe, opts buildOptions, fields m worktreePath, worktreePath, branch)) } } else if splitRoot { - parts = append(parts, stateCommitGuidance(stateCheckout, entityPath, stateBranch)) + parts = append(parts, stateCommitGuidance(stateCheckout, entityPath, stateBranch, stateRemotePresent)) } // 4. Entity-read instruction. Under split root the entity lives in the state @@ -707,31 +713,53 @@ func dispatchPointerPrompt(host, dispatchFilePath string) string { dispatchFilePath) } +// stateHasOrigin reports whether the state checkout has a named `origin` remote, +// the named-remote question the split-root sync contract pushes/pulls against — +// true iff `git remote get-url origin` exits 0. Network-free (unlike ls-remote) +// and discriminating (unlike a bare `git remote`, which exits 0 with no output). +// A non-repo dir or any other git failure reports false, degrading the checkout +// to local-only. This is the dispatch-package local exec the design pairs with +// the status package's runGitCmd-backed probe; both ask the identical question. +func stateHasOrigin(checkout string) bool { + cmd := exec.Command("git", "-C", checkout, "remote", "get-url", "origin") + return cmd.Run() == nil +} + // stateCommitGuidance is the split-root state-commit instruction, shared by the // worktree and non-worktree branches so the wording lives in one place. It // substitutes the resolved absolute state checkout and entity paths into the // path-scoped commit command — never literal {state_checkout}/{entity_path} // brace tokens — and carries the concurrency-safe "never a bare git add -A" -// rule that governs every split-root stage. After the commit it reminds the -// worker to push the orphan state branch peers share and `pull --rebase` on a -// rejection; stateBranch is named verbatim when resolved, else a branch-neutral -// reminder stands in. -func stateCommitGuidance(stateCheckout, entityPath, stateBranch string) string { - pushReminder := "Then push the state branch so peers see your entity/report: " + - "`git -C " + stateCheckout + " push origin " - if stateBranch != "" { - pushReminder += stateBranch + "`" - } else { - pushReminder += "`" - } - pushReminder += "; on a non-fast-forward rejection, " + - "`git -C " + stateCheckout + " pull --rebase origin " - if stateBranch != "" { - pushReminder += stateBranch + "`" +// rule that governs every split-root stage. After the commit the remote-sync +// tail diverges on hasOrigin: with an origin it reminds the worker to push the +// orphan state branch peers share and `pull --rebase` on a rejection +// (stateBranch named verbatim when resolved, else a branch-neutral reminder); +// without one it tells the worker the checkout is local-only and to skip +// push/pull — the path-scoped commit instruction is unchanged either way. +func stateCommitGuidance(stateCheckout, entityPath, stateBranch string, hasOrigin bool) string { + var pushReminder string + if hasOrigin { + pushReminder = "Then push the state branch so peers see your entity/report: " + + "`git -C " + stateCheckout + " push origin " + if stateBranch != "" { + pushReminder += stateBranch + "`" + } else { + pushReminder += "`" + } + pushReminder += "; on a non-fast-forward rejection, " + + "`git -C " + stateCheckout + " pull --rebase origin " + if stateBranch != "" { + pushReminder += stateBranch + "`" + } else { + pushReminder += "`" + } + pushReminder += " then re-push.\n" } else { - pushReminder += "`" + pushReminder = "This state checkout has no `origin` remote — commit " + + "path-scoped locally as above; do NOT run `git push`/`git pull` " + + "(there is no remote to sync). State is local-only and will not " + + "survive on a shared remote until an `origin` is configured.\n" } - pushReminder += " then re-push.\n" return fmt.Sprintf( "This workflow is split-root: the entity body and your stage report "+ diff --git a/internal/dispatch/build_parity_test.go b/internal/dispatch/build_parity_test.go index b0533226..4a2e1ae2 100644 --- a/internal/dispatch/build_parity_test.go +++ b/internal/dispatch/build_parity_test.go @@ -119,6 +119,20 @@ func TestBuildParityCrossProduct(t *testing.T) { writeFile(t, entityPath, entityFM("Thing", tc.stage, worktreeRel)) gitInit(t, root) + // The split-root goldens encode the origin-backed contract (push / + // pull-rebase reminder present), at parity with the oracle's unconditional + // remote-sync wording. The resolved state checkout (workflowDir/) + // must exist and carry an origin for stateHasOrigin to report true, so the + // contract holds; the no-origin local-only degrade is a native-only + // divergence covered by build_state_no_origin_test.go. + if tc.splitRoot { + stateCheckout := filepath.Join(workflowDir, "state-checkout") + if err := os.MkdirAll(stateCheckout, 0o755); err != nil { + t.Fatal(err) + } + gitInitBare(t, stateCheckout) + gitAddOrigin(t, stateCheckout) + } stdin := mergeStdin(map[string]any{ "schema_version": 2, diff --git a/internal/dispatch/build_state_no_origin_test.go b/internal/dispatch/build_state_no_origin_test.go new file mode 100644 index 00000000..67d413d4 --- /dev/null +++ b/internal/dispatch/build_state_no_origin_test.go @@ -0,0 +1,102 @@ +// ABOUTME: AC-2/AC-3/AC-4 coverage — split-root state-commit guidance degrades to +// ABOUTME: local-only when the state checkout has no origin, and keeps push/pull when it does. +package dispatch + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// buildSplitRootDispatchBody drives `dispatch build` over a split-root workflow +// whose state checkout lives at workflowDir/state-checkout, returning the emitted +// dispatch body. withOrigin adds a named origin remote to the git repo the state +// checkout resolves against, so stateHasOrigin reports true. +func buildSplitRootDispatchBody(t *testing.T, withOrigin bool) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + root := t.TempDir() + + workflowDir := root + stateCheckout := filepath.Join(workflowDir, "state-checkout") + writeFile(t, filepath.Join(workflowDir, "README.md"), readmeWorktree(true)) + + worktreeRel := ".worktrees/spacedock-ensign-thing" + if err := os.MkdirAll(filepath.Join(root, worktreeRel), 0o755); err != nil { + t.Fatal(err) + } + + entityPath := filepath.Join(stateCheckout, "thing", "index.md") + writeFile(t, entityPath, entityFM("Thing", "implementation", worktreeRel)) + + gitInit(t, root) + if withOrigin { + gitAddOrigin(t, root) + } + + stdin := mergeStdin(map[string]any{ + "schema_version": 2, + "entity_path": entityPath, + "workflow_dir": workflowDir, + "stage": "implementation", + "checklist": []string{"- a", "- b"}, + "team_name": "fixture-team", + "bare_mode": false, + }, nil) + + native := runNative(stdin, "build", "--workflow-dir", workflowDir) + return readDispatchBody(t, dispatchFilePathFromStdout(t, native.stdout)) +} + +// TestStateCommitGuidanceNoOriginDropsRemoteSync (AC-2, AC-4) pins the degrade: a +// split-root state checkout with NO origin keeps the path-scoped local commit but +// emits a local-only line and drops both `git push origin` and +// `git pull --rebase origin`. +func TestStateCommitGuidanceNoOriginDropsRemoteSync(t *testing.T) { + body := buildSplitRootDispatchBody(t, false) + + // Path-scoped local commit instruction is retained. + if !strings.Contains(body, "git -C ") || !strings.Contains(body, " add ") { + t.Fatalf("no-origin body dropped the path-scoped commit instruction\n--- body ---\n%s", body) + } + if !strings.Contains(body, "never a bare `git add -A`") { + t.Fatalf("no-origin body dropped the concurrency-safety phrase\n--- body ---\n%s", body) + } + + // The impossible remote-sync commands are gone. The shipped wording is + // `git -C push origin ` / `pull --rebase origin`, so the + // remote-sync verbs are the discriminators. + if strings.Contains(body, "push origin") { + t.Errorf("no-origin body still instructs `push origin`\n--- body ---\n%s", body) + } + if strings.Contains(body, "pull --rebase origin") { + t.Errorf("no-origin body still instructs `pull --rebase origin`\n--- body ---\n%s", body) + } + + // AC-4: the local-only mode is named, not silent. + if !strings.Contains(body, "no `origin` remote") { + t.Errorf("no-origin body does not name the missing-origin condition\n--- body ---\n%s", body) + } + if !strings.Contains(body, "local-only") { + t.Errorf("no-origin body does not name the local-only state mode\n--- body ---\n%s", body) + } +} + +// TestStateCommitGuidanceWithOriginKeepsRemoteSync (AC-3) pins the unchanged +// origin path: a split-root state checkout WITH an origin keeps the push and +// pull-rebase reminders verbatim and does NOT carry the local-only line. +func TestStateCommitGuidanceWithOriginKeepsRemoteSync(t *testing.T) { + body := buildSplitRootDispatchBody(t, true) + + if !strings.Contains(body, "push origin") { + t.Errorf("origin body dropped the `push origin` reminder\n--- body ---\n%s", body) + } + if !strings.Contains(body, "pull --rebase origin") { + t.Errorf("origin body dropped the `pull --rebase origin` reminder\n--- body ---\n%s", body) + } + if strings.Contains(body, "local-only") { + t.Errorf("origin body leaks the no-origin local-only line\n--- body ---\n%s", body) + } +} diff --git a/internal/dispatch/build_statecommit_test.go b/internal/dispatch/build_statecommit_test.go index f3bb8da8..92880bc3 100644 --- a/internal/dispatch/build_statecommit_test.go +++ b/internal/dispatch/build_statecommit_test.go @@ -342,6 +342,11 @@ func TestStateCommitGuidanceResolvesPaths(t *testing.T) { writeFile(t, entityPath, entityFM("Thing", tc.stage, worktreeRel)) gitInit(t, root) + // This case asserts the push / pull-rebase reminder IS emitted, which is + // the origin-backed contract — so the state checkout must have an origin. + // The no-origin degrade is covered separately in + // build_state_no_origin_test.go. + gitAddOrigin(t, root) stdin := mergeStdin(map[string]any{ "schema_version": 2, diff --git a/internal/dispatch/parity_harness_test.go b/internal/dispatch/parity_harness_test.go index c6fb1bab..babf1429 100644 --- a/internal/dispatch/parity_harness_test.go +++ b/internal/dispatch/parity_harness_test.go @@ -86,6 +86,32 @@ func gitInit(t *testing.T, dir string) { } } +// gitInitBare initializes a git repo at dir with no seed commit — enough for a +// remote query (`remote get-url origin`) to resolve there. Unlike gitInit it does +// not add/commit, so it works on an empty directory. +func gitInitBare(t *testing.T, dir string) { + t.Helper() + cmd := exec.Command("git", "-C", dir, "init", "-q") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init: %v\n%s", err, out) + } +} + +// gitAddOrigin adds a named `origin` remote to the repo at dir, pointing at a +// throwaway bare upstream, so stateHasOrigin reports true. The probe is +// `remote get-url origin` (network-free), so the upstream is never contacted and +// needs no content — it exists only to give the remote a valid URL. +func gitAddOrigin(t *testing.T, dir string) { + t.Helper() + upstream := filepath.Join(t.TempDir(), "upstream.git") + if out, err := exec.Command("git", "init", "-q", "--bare", upstream).CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v\n%s", err, out) + } + if out, err := exec.Command("git", "-C", dir, "remote", "add", "origin", upstream).CombinedOutput(); err != nil { + t.Fatalf("git remote add origin: %v\n%s", err, out) + } +} + // writeFile writes content to path, creating parent dirs. func writeFile(t *testing.T, path, content string) { t.Helper() diff --git a/internal/status/boot.go b/internal/status/boot.go index 0e3cee70..a23c9f32 100644 --- a/internal/status/boot.go +++ b/internal/status/boot.go @@ -151,6 +151,11 @@ type bootData struct { definitionDir string entityDir string entityDirPresent bool + // stateRemote is the state checkout's remote-sync availability, populated only + // under split-root: "origin" when the checkout has a named origin remote, + // "none" when it does not (local-only — state is not remotely synced). Empty + // for single-root, where remote sync does not apply. + stateRemote string // Sandbox posture: the three-way safehouse state (enabled / available, not // enabled / unavailable) computed from a .safehouse profile at the repo root and // whether the safehouse binary resolves on PATH, so the operator sees the @@ -200,6 +205,13 @@ func gatherBoot(probe claudeteam.TeamStateProbe, entities []*entity, stages []St d.entityDir = entityDir if entityDir != definitionDir { d.stateBackend = "split-root" + // Remote-sync availability is read only under split-root: the FO uses it to + // know whether the state checkout can push/pull origin or is local-only. + if stateHasOrigin(entityDir) { + d.stateRemote = "origin" + } else { + d.stateRemote = "none" + } } else { d.stateBackend = "single-root" } @@ -292,9 +304,18 @@ func printBoot(probe claudeteam.TeamStateProbe, w io.Writer, entities []*entity, } fmt.Fprintf(w, "hint: %s\n", d.teamHint) - // STATE_BACKEND - fmt.Fprintf(w, "STATE_BACKEND: %s (entity_dir: %s, present: %t)\n", - d.stateBackend, d.entityDir, d.entityDirPresent) + // STATE_BACKEND. The remote clause is appended only under split-root: origin + // when the state checkout can push/pull, else a local-only marker so the FO + // sees state is not remotely synced. Single-root omits the clause entirely. + remoteClause := "" + switch d.stateRemote { + case "origin": + remoteClause = ", remote: origin" + case "none": + remoteClause = ", remote: none — state not remotely synced" + } + fmt.Fprintf(w, "STATE_BACKEND: %s (entity_dir: %s, present: %t%s)\n", + d.stateBackend, d.entityDir, d.entityDirPresent, remoteClause) // SANDBOX: appended last so every prior section's order is preserved. fmt.Fprintf(w, "SANDBOX: %s\n", d.sandbox) diff --git a/internal/status/boot_state_remote_test.go b/internal/status/boot_state_remote_test.go new file mode 100644 index 00000000..1784bf95 --- /dev/null +++ b/internal/status/boot_state_remote_test.go @@ -0,0 +1,177 @@ +// ABOUTME: AC-1/AC-4 boot coverage — STATE_BACKEND surfaces state-remote +// ABOUTME: availability (origin vs none) for split-root, and omits it for single-root. +package status + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +// initStateRepoWithOrigin turns an existing state checkout dir into a real git +// repo with a named `origin` remote pointing at a throwaway bare upstream, so +// stateHasOrigin reports true. The remote is never contacted (the probe is +// `remote get-url origin`, network-free), so the bare upstream needs no content. +func initStateRepoWithOrigin(t *testing.T, stateDir string) { + t.Helper() + gitC(t, stateDir, "init", "-q") + gitC(t, stateDir, "config", "user.email", "t@t") + gitC(t, stateDir, "config", "user.name", "t") + upstream := filepath.Join(t.TempDir(), "upstream.git") + gitC(t, t.TempDir(), "init", "-q", "--bare", upstream) + gitC(t, stateDir, "remote", "add", "origin", upstream) +} + +// TestBootTextStateRemoteNone (AC-1, AC-4) asserts a split-root state checkout +// with NO origin remote names the local-only mode on the text STATE_BACKEND line. +func TestBootTextStateRemoteNone(t *testing.T) { + def, _ := buildSplitRoot(t, splitRootReadme, map[string]string{ + "add-login.md": "---\nstatus: ideation\n---\n", + }) + env := pinnedEnv(t) + + out, stderr, code := runNative(t, def, env, "--workflow-dir", def, "--boot") + if code != 0 { + t.Fatalf("--boot exit=%d stderr=%q", code, stderr) + } + line := stateBackendLineOf(t, out) + if !strings.Contains(line, "remote: none") { + t.Fatalf("STATE_BACKEND line missing `remote: none`: %q", line) + } + if !strings.Contains(line, "state not remotely synced") { + t.Fatalf("STATE_BACKEND line missing not-remotely-synced phrase: %q", line) + } +} + +// TestBootTextStateRemoteOrigin (AC-1) asserts a split-root state checkout WITH +// an origin remote reports `remote: origin` and does NOT carry the not-synced +// phrase. +func TestBootTextStateRemoteOrigin(t *testing.T) { + def, state := buildSplitRoot(t, splitRootReadme, map[string]string{ + "add-login.md": "---\nstatus: ideation\n---\n", + }) + initStateRepoWithOrigin(t, state) + env := pinnedEnv(t) + + out, stderr, code := runNative(t, def, env, "--workflow-dir", def, "--boot") + if code != 0 { + t.Fatalf("--boot exit=%d stderr=%q", code, stderr) + } + line := stateBackendLineOf(t, out) + if !strings.Contains(line, "remote: origin") { + t.Fatalf("STATE_BACKEND line missing `remote: origin`: %q", line) + } + if strings.Contains(line, "state not remotely synced") { + t.Fatalf("STATE_BACKEND line leaks not-remotely-synced phrase under origin: %q", line) + } +} + +// TestBootTextSingleRootNoRemoteClause (AC-1 negative) asserts a single-root +// workflow's STATE_BACKEND line carries NO remote clause at all — the degrade is +// split-root-only. +func TestBootTextSingleRootNoRemoteClause(t *testing.T) { + root, err := filepath.Abs(filepath.Join("testdata", "sdb32-workflow")) + if err != nil { + t.Fatal(err) + } + env := pinnedEnv(t) + + out, stderr, code := runNative(t, root, env, "--workflow-dir", root, "--boot") + if code != 0 { + t.Fatalf("--boot exit=%d stderr=%q", code, stderr) + } + line := stateBackendLineOf(t, out) + if strings.Contains(line, "remote:") { + t.Fatalf("single-root STATE_BACKEND line leaks a remote clause: %q", line) + } +} + +// TestBootJSONStateRemoteNone (AC-1, AC-4) asserts the JSON envelope carries +// state_remote "none" for a no-origin split-root checkout, positioned AFTER +// entity_dir_present so the FO's key-order parse is preserved. +func TestBootJSONStateRemoteNone(t *testing.T) { + def, _ := buildSplitRoot(t, splitRootReadme, map[string]string{ + "add-login.md": "---\nstatus: ideation\n---\n", + }) + env := pinnedEnv(t) + + out, stderr, code := runNative(t, def, env, "--workflow-dir", def, "--boot", "--json") + if code != 0 { + t.Fatalf("--boot --json exit=%d stderr=%q", code, stderr) + } + + var b struct { + StateRemote string `json:"state_remote"` + } + if err := json.Unmarshal([]byte(out), &b); err != nil { + t.Fatalf("parse --boot --json: %v\n%s", err, out) + } + if b.StateRemote != "none" { + t.Fatalf("state_remote = %q, want none", b.StateRemote) + } + + // Key order: state_remote must appear AFTER entity_dir_present. + presentIdx := strings.Index(out, `"entity_dir_present"`) + remoteIdx := strings.Index(out, `"state_remote"`) + if presentIdx < 0 || remoteIdx < 0 { + t.Fatalf("--boot --json missing entity_dir_present or state_remote\n%s", out) + } + if remoteIdx < presentIdx { + t.Fatalf("state_remote must follow entity_dir_present\n%s", out) + } +} + +// TestBootJSONStateRemoteOrigin (AC-1) asserts the JSON envelope carries +// state_remote "origin" for an origin-backed split-root checkout. +func TestBootJSONStateRemoteOrigin(t *testing.T) { + def, state := buildSplitRoot(t, splitRootReadme, map[string]string{ + "add-login.md": "---\nstatus: ideation\n---\n", + }) + initStateRepoWithOrigin(t, state) + env := pinnedEnv(t) + + out, stderr, code := runNative(t, def, env, "--workflow-dir", def, "--boot", "--json") + if code != 0 { + t.Fatalf("--boot --json exit=%d stderr=%q", code, stderr) + } + var b struct { + StateRemote string `json:"state_remote"` + } + if err := json.Unmarshal([]byte(out), &b); err != nil { + t.Fatalf("parse --boot --json: %v\n%s", err, out) + } + if b.StateRemote != "origin" { + t.Fatalf("state_remote = %q, want origin", b.StateRemote) + } +} + +// TestBootJSONSingleRootNoStateRemote (AC-1 negative) asserts a single-root +// workflow's JSON envelope OMITS state_remote entirely. +func TestBootJSONSingleRootNoStateRemote(t *testing.T) { + root, err := filepath.Abs(filepath.Join("testdata", "sdb32-workflow")) + if err != nil { + t.Fatal(err) + } + env := pinnedEnv(t) + + out, stderr, code := runNative(t, root, env, "--workflow-dir", root, "--boot", "--json") + if code != 0 { + t.Fatalf("--boot --json exit=%d stderr=%q", code, stderr) + } + if strings.Contains(out, `"state_remote"`) { + t.Fatalf("single-root --boot --json leaks state_remote key\n%s", out) + } +} + +// stateBackendLineOf extracts the STATE_BACKEND line from --boot text output. +func stateBackendLineOf(t *testing.T, out string) string { + t.Helper() + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, "STATE_BACKEND:") { + return line + } + } + t.Fatalf("--boot output missing STATE_BACKEND line\n%s", out) + return "" +} diff --git a/internal/status/json_commands.go b/internal/status/json_commands.go index ac013c03..069a9dad 100644 --- a/internal/status/json_commands.go +++ b/internal/status/json_commands.go @@ -196,6 +196,12 @@ func bootJSON(d *bootData) *jsonObj { out.set("definition_dir", d.definitionDir) out.set("entity_dir", d.entityDir) out.set("entity_dir_present", strconv.FormatBool(d.entityDirPresent)) + // state_remote ("origin"/"none") is appended AFTER entity_dir_present, present + // only under split-root where remote sync applies. Single-root omits it so the + // envelope carries no remote concept where there is none. + if d.stateRemote != "" { + out.set("state_remote", d.stateRemote) + } // sandbox: the three-way safehouse posture, appended AFTER the state-backend keys // so every existing key's relative order is preserved for the FO's key-order parse. diff --git a/internal/status/state.go b/internal/status/state.go index ea2a0ad3..a502d13f 100644 --- a/internal/status/state.go +++ b/internal/status/state.go @@ -56,6 +56,18 @@ func ClassifyState(stateValue string) (StateMode, string, error) { return StateSplitRoot, cleaned, nil } +// stateHasOrigin reports whether the state checkout has a named `origin` remote. +// The split-root sync contract pushes/pulls `origin` specifically, so the probe +// asks the exact named-remote question via `git remote get-url origin` — true iff +// exit 0. This is network-free (unlike `ls-remote`) and discriminating (unlike a +// bare `git remote`, which exits 0 with no output for a checkout with no remotes). +// A non-repo dir or any other git failure reports false, so a checkout that +// cannot push/pull `origin` for any reason degrades to local-only. +func stateHasOrigin(checkout string) bool { + _, err := runGitCmd(checkout, "remote", "get-url", "origin") + return err == nil +} + // StateBranch returns the orphan state branch for a split-root workflow. By // default it derives from the workflow dir's basename // (spacedock-state/), reproducing the shipped spacedock-state/dev for diff --git a/internal/status/state_origin_test.go b/internal/status/state_origin_test.go new file mode 100644 index 00000000..6c4e1703 --- /dev/null +++ b/internal/status/state_origin_test.go @@ -0,0 +1,62 @@ +// ABOUTME: Detection-unit coverage for stateHasOrigin — the network-free +// ABOUTME: named-origin probe distinguishing a remote-backed state checkout from a local one. +package status + +import ( + "os/exec" + "path/filepath" + "testing" +) + +// gitInit initializes a bare-minimum git repo at dir so a remote query resolves +// there. No remote is added, so `git remote get-url origin` exits non-zero. +func gitInitNoRemote(t *testing.T, dir string) { + t.Helper() + for _, args := range [][]string{ + {"init", "-q"}, + {"config", "user.email", "t@t"}, + {"config", "user.name", "t"}, + } { + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } +} + +// TestStateHasOriginNoRemote (detection unit, seeds AC-1/AC-2 false path): a +// freshly-init'd checkout with no remote returns false — the spike's exit-2 +// `No such remote 'origin'` case. +func TestStateHasOriginNoRemote(t *testing.T) { + dir := t.TempDir() + gitInitNoRemote(t, dir) + if stateHasOrigin(dir) { + t.Fatalf("stateHasOrigin(no-remote checkout) = true, want false") + } +} + +// TestStateHasOriginWithOrigin (detection unit, seeds AC-1/AC-2 true path): a +// checkout with a named `origin` remote returns true — the spike's exit-0 case. +func TestStateHasOriginWithOrigin(t *testing.T) { + dir := t.TempDir() + gitInitNoRemote(t, dir) + upstream := filepath.Join(t.TempDir(), "upstream.git") + if out, err := exec.Command("git", "init", "-q", "--bare", upstream).CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v\n%s", err, out) + } + if out, err := exec.Command("git", "-C", dir, "remote", "add", "origin", upstream).CombinedOutput(); err != nil { + t.Fatalf("git remote add origin: %v\n%s", err, out) + } + if !stateHasOrigin(dir) { + t.Fatalf("stateHasOrigin(origin-backed checkout) = false, want true") + } +} + +// TestStateHasOriginNonRepo (detection unit, defensive): a directory that is not +// a git repo at all returns false — the probe must not panic or report true when +// the git command fails for a non-`origin` reason. +func TestStateHasOriginNonRepo(t *testing.T) { + if stateHasOrigin(t.TempDir()) { + t.Fatalf("stateHasOrigin(non-repo dir) = true, want false") + } +} diff --git a/skills/ensign/references/ensign-shared-core.md b/skills/ensign/references/ensign-shared-core.md index b581b1a0..cc9b8ec8 100644 --- a/skills/ensign/references/ensign-shared-core.md +++ b/skills/ensign/references/ensign-shared-core.md @@ -39,6 +39,8 @@ When the workflow is split-root (README declares `state:` checkout, e.g. `state: **Multi-writer sync.** After your path-scoped commit, `git -C {state_checkout} push origin {state_branch}` (e.g. `spacedock-state/dev`). On non-fast-forward rejection, `git -C {state_checkout} pull --rebase origin {state_branch}` replays your single-file commit atop the peer's (disjoint paths → no conflict), then re-push. +**No-origin carve-out.** When the state checkout has no `origin` remote (boot reports `remote: none — state not remotely synced`), commit path-scoped locally as above and skip push/pull. State is local-only until an `origin` is configured. + **Rebase-conflict halt.** If `pull --rebase` CONFLICTS (two writers editing the SAME entity's frontmatter), HALT, `git -C {state_checkout} rebase --abort`, surface the conflicting entity path(s) and peer commit to the first officer, and stop. Do NOT `--force` / `--force-with-lease` push; do NOT auto-resolve (`-X ours/theirs` or discarding either side silently loses a peer's edit). This is manual intervention — the escalate-rather-than-guess discipline below. ## Rules diff --git a/skills/first-officer/references/first-officer-shared-core.md b/skills/first-officer/references/first-officer-shared-core.md index 6656b6a2..0ad4966d 100644 --- a/skills/first-officer/references/first-officer-shared-core.md +++ b/skills/first-officer/references/first-officer-shared-core.md @@ -235,6 +235,8 @@ When the workflow is split-root (README declares `state:` checkout, e.g. `state: - **On push rejection (non-fast-forward) → `pull --rebase` then re-push.** `git -C {state_checkout} pull --rebase origin {state_branch}` replays the local single-file commit atop the peer's; disjoint paths → no conflict. Then re-push. - **At FO boot (before first dispatch) → `pull --rebase`.** Integrate peers' state once at boot (the Startup pull-on-boot step), not per-read. +**No-origin carve-out.** When the state checkout has no `origin` remote, none of the three sync points apply: boot reports `STATE_BACKEND: … remote: none — state not remotely synced` (and `state_remote: none` in `--boot --json`), the dispatch omits the push/pull reminder, and writers commit path-scoped locally only. State is local-only until an `origin` is configured — surface that to the captain rather than treating the missing remote as a sync failure. + **Rebase-conflict halt (B6).** If `pull --rebase` CONFLICTS (two writers editing the SAME entity's frontmatter concurrently), the FO MUST: 1. **HALT** the dispatch. Do not proceed against an unmerged state tree.