Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 48 additions & 20 deletions internal/dispatch/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"+
Expand All @@ -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
Expand Down Expand Up @@ -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 += "<state-branch>`"
}
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 += "<state-branch>`"
}
pushReminder += "; on a non-fast-forward rejection, " +
"`git -C " + stateCheckout + " pull --rebase origin "
if stateBranch != "" {
pushReminder += stateBranch + "`"
} else {
pushReminder += "<state-branch>`"
}
pushReminder += " then re-push.\n"
} else {
pushReminder += "<state-branch>`"
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 "+
Expand Down
14 changes: 14 additions & 0 deletions internal/dispatch/build_parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<state>)
// 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,
Expand Down
102 changes: 102 additions & 0 deletions internal/dispatch/build_state_no_origin_test.go
Original file line number Diff line number Diff line change
@@ -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 <checkout> push origin <branch>` / `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)
}
}
5 changes: 5 additions & 0 deletions internal/dispatch/build_statecommit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions internal/dispatch/parity_harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
27 changes: 24 additions & 3 deletions internal/status/boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading