diff --git a/.docket.example.yml b/.docket.example.yml index b5159bf8e..0a92a7ed7 100644 --- a/.docket.example.yml +++ b/.docket.example.yml @@ -52,7 +52,8 @@ integration_branch: auto # bare repository under ~/.local/share/dckt, with nothing docket-named inside the repository — for # repositories that must not carry any docket files. `docket repository init` sets a new # repository up in this mode (`--private` / `--shared` override it); changing it later does not -# move a repository that is already set up. +# move a repository that is already set up; `docket repository set-visibility ` +# moves one, keeping every record. # scope: any layer visibility: shared diff --git a/docs/release/upgrading-from-bash.md b/docs/release/upgrading-from-bash.md index f418629c7..4664a1561 100644 --- a/docs/release/upgrading-from-bash.md +++ b/docs/release/upgrading-from-bash.md @@ -127,6 +127,7 @@ On a Bash repository it exits with an error and lists some of these findings: | `harnesses-unset` | No coding agents are chosen for this repository, so docket writes no instructions for them. | `docket repository configure-harnesses`, below. | | `board-stale` | `BOARD.md` on the `docket` branch is in the Bash format. | `docket repository repair`, below. | | `artifact-links-stale` | A change record's links block is in the Bash format. | `docket repository repair`, below. | +| `artifact-backlink-stale` | A spec, plan, or results file's link back to its change is not the relative link docket writes. | `docket repository repair`, below. | Start with `.gitignore`. Replace everything from its `# docket:start` line to its `# docket:end` line with exactly these lines, which `docket repository check` also prints: diff --git a/docs/superpowers/specs/2026-10-05-switch-a-repository-between-shared-and-private-visibility-design.md b/docs/superpowers/specs/2026-10-05-switch-a-repository-between-shared-and-private-visibility-design.md new file mode 100644 index 000000000..0b1ce83dc --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-switch-a-repository-between-shared-and-private-visibility-design.md @@ -0,0 +1,170 @@ +# Switch a repository between shared and private visibility: design + +Change 0533 — Switch a repository between shared and private visibility + +Change #533, groomed interactively on 2026-10-05; revised 2026-10-07 to have the switch commit its integration-branch file edits under two fixed messages. Last in the private-visibility series (#529–#535). It depends on #530 (relative links, artifacts on the metadata branch), #531 (private mode), and #535 (the private instructions file it moves). It is best built after #532. + +## Summary + +A repository's visibility is chosen at `init` and is then a fact about the repository's state. Editing config never moves a repository (#531). This change adds the one supported way to move an existing repository between modes, keeping every record: + +``` +docket repository set-visibility + [--yes] [--metadata-remote ] [--delete-shared-branch] [--remove-shared-files] [--repo-dir ] +``` + +## Evidence gathered at grooming + +- **The pattern to follow** is `docket repository migrate` (`internal/app/repository_migrate.go`): + - two-pass authorization: a preview, then `--yes` pinned to the previewed source revision (`ExpectedSource`; contended if it moved); + - phase routing that resumes an interrupted run from durable state (`migrateRoute`); + - receipts in commit trailers; + - no force-push, no foreign-ref deletion, and no rollback of a published branch. +- **Records survive a branch rename unchanged.** Metadata commit receipts don't encode the branch name (a `Docket-Result` trailer decoded at grooming holds only id, op, path and slug), so the identical commits can be published under another branch name with every claim receipt and idempotency replay intact. After #530, links between metadata files are relative, so no record needs rewriting. +- **Live runs** are visible in the state folder: active run-tracker keys, cancellation-pending runs, gate drives, and worktree locks. +- **No workflow gate covers the config gap.** Ordinary operations refuse only a legacy layout (`operationalRefusal`). A `needs-review` repository (an uncommitted `.docket.yml`) is admitted. If a repository switched to shared before its repository-identity keys reached the committed `.docket.yml`, workflows would silently run on defaults (for example `integration_branch` falling back to `auto`). The switch itself must prevent that window. + +## Decisions (settled with the human) + +1. **A dedicated command**, not an option on `migrate`: commands are named for their job. +2. **Preview, then `--yes`,** pinned to the previewed state. The switch is resumable, refuses while any run is live, and is idempotent. +3. **The identical metadata history is published** under the target branch name. It is never re-seeded or squashed. +4. **Shared → private:** + - `origin/docket` stays unless `--delete-shared-branch` is given, and is deleted only after verifying that the bare remote holds that exact tip. + - Files committed on the integration branch stay unless `--remove-shared-files` is given, which removes them in one local commit for the user's PR. + - Plan and results files merged into the integration branch before #530 always stay. +5. **Private → shared** refuses if `origin` already has a `docket` branch. Two backlogs are never merged. The bare remote is kept as a backup. +6. **Config alignment.** The switch rewrites `visibility` in the local file it manages, so file and state agree. +7. **A second clone** re-running the switch performs only its local phases. +8. **Sparse documentation:** command help, plus one line under `visibility` in `.docket.example.yml`. +9. **Fixed commit messages** (added 2026-10-07). The switch commits its own edits to integration-branch files itself, locally, so that the message that stays visible in the repository's history is never typed by hand. It never pushes. Going private, the message is `Remove docket configurations from repository`. Going shared, it is `Add docket configurations to repository`. These messages never contain the words "shared", "public", "private", or "visibility". They only say that configurations were added or removed. + +## Design + +### Preconditions (both directions) + +- The repository is set up: not fresh, not legacy, no migration incomplete. +- No live run. Active run-tracker keys, cancellation-pending runs, gate drives, and worktree locks are each named in the refusal, with the `run.cancel` remedy where applicable. +- The metadata worktree is clean, and its local branch equals the resolved remote's tip. +- When the switch will make an integration-branch commit (see *Commits on the integration branch*), the primary checkout is on a branch (not a detached HEAD), and none of the files that commit will touch has uncommitted changes. Otherwise the switch would sweep the user's own edits into its commit. +- If the target equals the current mode, the switch only aligns the local file's `visibility` value. Otherwise it is a no-op. + +### Preview + +The preview lists every phase below that will run and every flag's effect, including any integration-branch commit with its exact message, its branch, and its file list. It pins the metadata tip plus the relevant `origin` refs (`docket`, default branch). It also warns about the following, where they apply: + +- **Going private:** teammates' later writes to `origin/docket` will not be seen. If `--delete-shared-branch` is given, existing PR backlinks into `origin/docket` will stop resolving. +- **Any direction:** open PRs whose descriptions carry the other mode's text are listed, but not edited. + +### Shared → private phases + +1. **Bare remote.** + - Create it at the default store path (#531), or use `--metadata-remote `. + - If it already holds `dckt`, adopt it only when its tip is equal to or an ancestor of `origin/docket`'s tip. Otherwise refuse. +2. **Publish.** Push `origin/docket`'s tip to the bare remote as `refs/heads/dckt`, with a create-only or fast-forward lease. +3. **Remote and branch.** Add the `dckt` remote, and create local `dckt` at the same commit. +4. **Config.** + - Fold the committed `.docket.yml` (as read from `origin`'s default branch) and `.docket.local.yml` into `.git/dckt/config.yml`, with `visibility: private`. + - Agent model and effort pins are never copied, because they are global-only. + - Remove `.docket.local.yml`. +5. **State folder.** Rename `/docket/` to `/dckt/`. Refuse if the target exists and is non-empty. +6. **Ignore entries.** Write the `.git/info/exclude` block. +7. **Metadata worktree.** + - `git worktree move` it from `/.docket` to the private checkout path. + - Switch it to `dckt`, re-scope its hooks setting, and delete the local `docket` branch. +8. **Agent instructions.** When `agent_harnesses` is set, write the dispatch block into the private instructions file `/dckt/AGENTS.md` (#535). The repository's committed AGENTS.md keeps loading as before, promoted lessons included. Only docket's managed block is removed from it, and only with `--remove-shared-files`. +9. **`--delete-shared-branch`** (optional). + - Verify that the bare `dckt` tip equals `origin/docket`'s tip. + - Delete `origin`'s `docket` with an exact-tip lease. + - On any mismatch, keep the branch and report. +10. **`--remove-shared-files`** (optional). Make one local commit, with the message `Remove docket configurations from repository`, that: + - deletes `.docket.yml`; + - strips the managed `.gitignore` block; + - strips the AGENTS.md and CLAUDE.md dispatch blocks; + - deletes `.cursor/rules/docket-dispatch.mdc`. + + The user pushes it and merges it through a normal PR. The switch never pushes to the integration branch. + +### Private → shared phases + +1. **Refuse if `origin` already has `docket`.** +2. **Repository-identity keys.** If `.git/dckt/config.yml` sets any repository-identity key (`integration_branch`, `changes_dir`, `adrs_dir`, `results_dir`), write `.docket.yml` carrying them, commit it locally with the message `Add docket configurations to repository`, and stop with the remedy: get that commit onto the default branch on `origin` (push it, or merge it through a PR), then re-run. The re-run verifies that the committed file carries those values. This keeps workflows from running on defaults after the switch without adding a workflow gate. +3. **Publish.** Push the bare remote's `dckt` tip to `origin` as `refs/heads/docket`, with a create-only lease. +4. **Branch and worktree.** + - Create local `docket` at the same commit. + - `git worktree move` the metadata worktree back to `/.docket` and switch it to `docket`. + - Remove the `dckt` remote and the local `dckt` branch. +5. **Agent instructions.** + - When `agent_harnesses` is set, write the repository-level dispatch block (AGENTS.md, CLAUDE.md, and the Cursor rule, exactly as `install`'s repository phase would). It goes into the phase-7 commit. + - Promoted lessons held in the private instructions file are **listed** in the output for the human to move into the committed AGENTS.md. Promotion is always a human act. + - Then remove the private instructions file and the private Cursor rule file. +6. **State folder.** Rename `/dckt/` to `/docket/`. The private instructions file was already removed in phase 5, so it never lands under the shared state folder. +7. **Config split and commit.** + - Personal keys go to `.docket.local.yml`, which stays uncommitted (it is ignored). + - `visibility: shared` plus any remaining repository keys go to `.docket.yml`. + - Write the managed `.gitignore` block. + - Remove the `.git/info/exclude` block. + - Make one local commit, with the message `Add docket configurations to repository`, holding `.docket.yml`, the `.gitignore` block, and phase 5's dispatch blocks. The user pushes it and merges it through a normal PR. The switch never pushes to the integration branch. +8. **Backup.** The bare remote is kept, and its path is printed. + +### Commits on the integration branch + +The switch makes at most these commits, and only in the primary checkout: + +| Direction | When | Message | +|---|---|---| +| Shared → private | phase 10, only with `--remove-shared-files` | `Remove docket configurations from repository` | +| Private → shared | phase 2, only when repository-identity keys must reach `origin` first | `Add docket configurations to repository` | +| Private → shared | phase 7 | `Add docket configurations to repository` | + +Rules for every one of them: + +- **The message is exactly the subject in the table.** There is no body, no `Docket-` trailer, no change id, and no other text. The messages are fixed, not configurable. +- **No mode vocabulary.** A message never contains "shared", "public", "private", or "visibility", in any letter case. A test asserts this over every message the switch can produce, so a later edit cannot slip one in. +- **Only the switch's own files.** The commit is limited to the paths the switch wrote, so anything else the user has staged or changed stays where it was. The preview lists those paths, and the preconditions refuse when any of them already has uncommitted changes. +- **Local only.** The commit lands on the primary checkout's current branch, and the switch never pushes it. The user's repository hooks run as they do for any commit. If the commit fails, for example because a hook rejects it, the edits stay in the working tree, the output says so, and re-running the switch retries the commit. +- **Resumable.** A commit phase is complete when the current branch's tip already holds the target content for its paths. A re-run never makes a second commit for the same edits. +- **Outside the private leak check.** The removal commit names docket in its message by design. The user pushes it, not one of docket's publish operations, so the private leak check never sees it. + +### Resume and receipts + +Every phase is idempotent, and its completion is readable from state (refs on each remote, folder names, worktree location, config files). An interrupted switch is resumed by re-running the same command, which routes to the first incomplete phase, the same way `migrateRoute` does. A second clone of an already-switched repository finds the remote phases complete and runs only its local ones. + +### Catalog + +`repository.set-visibility`, with effects `local-write`, `metadata-write`, and `external-write` (pushes and the optional deletion on `origin`). + +## Acceptance criteria + +1. **Round trip.** shared → private → shared preserves the metadata tip commit id and every file byte for byte. A claim receipt and an idempotent create replay recorded before the switch replay correctly after it. +2. **Interruption.** Killing the switch after each phase, then re-running, completes it with no duplicate pushes and no orphaned state. +3. **Refusals:** + - a live run; + - a dirty or diverged metadata worktree; + - `origin` already having `docket` on the way to shared; + - a non-ancestor bare remote on the way to private; + - repository-identity keys not yet on `origin`'s default branch on the way to shared. +4. **`--delete-shared-branch`** deletes only on an exact tip match. On a mismatch it keeps the branch and reports. +5. **`--remove-shared-files`** makes exactly one local commit, with the message `Remove docket configurations from repository`, holding only the listed file edits. Nothing is pushed to the integration branch. +6. **Second clone.** It switches with local phases only. +7. **Private result is clean.** After switching to private, #531's private-layout checks pass: nothing docket-named in the root, `dckt` naming, and the worktree outside the clone. +8. **Instructions follow the switch.** + - After switching to private, `docket instructions` prints the dispatch block. + - After switching back to shared, it prints nothing, the repository-level block is in the local `Add docket configurations to repository` commit, and any promoted lessons from the private file are listed. +9. **Commit messages.** + - Every integration-branch commit the switch makes has exactly one of the two subjects, with no body or trailers. + - No message contains "shared", "public", "private", or "visibility", in any letter case. + - Each commit holds only the switch's own paths. A file the user had staged elsewhere stays staged and uncommitted. + - Nothing is pushed to the integration branch, and a re-run after an interrupted or failed commit makes exactly one commit. + - A preview refuses when a file the commit would touch already has uncommitted changes, or when HEAD is detached. + +## ADRs expected + +A visibility switch preserves records by publishing the identical metadata history under the target branch name. It never re-seeds and never rewrites records. Relates to ADR-0099 and ADR-0001. + +## Out of scope + +- Merging two backlogs. +- Removing plan and results files merged before #530. +- Rewriting already-pushed history or PR descriptions. +- Guide or concept pages about the switch. diff --git a/internal/app/finalize_closeout.go b/internal/app/finalize_closeout.go index 8ff9cb381..c0f8d3289 100644 --- a/internal/app/finalize_closeout.go +++ b/internal/app/finalize_closeout.go @@ -1243,19 +1243,16 @@ func retargetArtifactBacklinks(ctx context.Context, tree transaction.Tree, gc do if !present { continue } - doc, err := document.Parse(bytesAt) + updated, hasBlock, err := canonicalArtifactBacklink(bytesAt, gc, p) if err != nil { + if hasBlock { + return nil, fmt.Errorf("backlink retarget: rewriting the backlink in %q: %w", p, err) + } return nil, fmt.Errorf("backlink retarget: parsing linked artifact %q: %w", p, err) } - if _, ok := doc.Block(backlinkBlockName); !ok { + if !hasBlock { continue } - var ps document.PatchSet - ps.ReplaceBlock(backlinkBlockName, backlinkInterior(render.ArtifactBacklinkContent(gc, p))) - updated, err := doc.Apply(ps) - if err != nil { - return nil, fmt.Errorf("backlink retarget: rewriting the backlink in %q: %w", p, err) - } if string(updated) == string(bytesAt) { continue } diff --git a/internal/app/layout_resolve.go b/internal/app/layout_resolve.go index bd18c8fbb..c209f804a 100644 --- a/internal/app/layout_resolve.go +++ b/internal/app/layout_resolve.go @@ -35,6 +35,14 @@ func resolveLayout(ctx context.Context, r remoteURLReader, repo gitcli.Repositor if mode == layout.Shared { return layout.SharedLayout(repo.CommonDir, repo.PrimaryWorktree), nil } + return privateLayoutOf(ctx, r, repo) +} + +// privateLayoutOf is resolveLayout's private branch: repo's private layout, +// its store located from origin's URL and the data home, whatever mode the +// repository is in now. A repository switching to private needs it before its +// state reads as private. +func privateLayoutOf(ctx context.Context, r remoteURLReader, repo gitcli.Repository) (layout.Layout, error) { url, err := r.RemoteURL(ctx, repo, originRemote) if err != nil { return layout.Layout{}, fmt.Errorf("%w: private repository: reading origin's URL to locate the metadata store: %v", ErrStatusExternal, err) diff --git a/internal/app/private_instructions_integration_test.go b/internal/app/private_instructions_integration_test.go index d13aebb5b..8dea6dae5 100644 --- a/internal/app/private_instructions_integration_test.go +++ b/internal/app/private_instructions_integration_test.go @@ -150,7 +150,7 @@ func TestIntegrationPrivateInstructionsInstallPhaseFromFeatureWorktree(t *testin setPrivateHarnesses(t, common, "[claude, cursor]") feature := filepath.Join(r.root, "feature") runGit(t, r.invocation, "worktree", "add", "-q", "-b", "feat/x", feature) - metadata := privateLayoutOf(t, r.invocation, os.Getenv("XDG_DATA_HOME")).MetadataWorktree + metadata := expectedPrivateLayout(t, r.invocation, os.Getenv("XDG_DATA_HOME")).MetadataWorktree if _, err := os.Stat(metadata); err != nil { t.Fatalf("precondition: metadata checkout %s: %v", metadata, err) } @@ -463,7 +463,7 @@ func TestIntegrationPrivateInstructionsReadFromEveryWorktree(t *testing.T) { } feature := filepath.Join(r.root, "feature") runGit(t, r.invocation, "worktree", "add", "-q", "-b", "feat/read", feature) - metadata := privateLayoutOf(t, r.invocation, os.Getenv("XDG_DATA_HOME")).MetadataWorktree + metadata := expectedPrivateLayout(t, r.invocation, os.Getenv("XDG_DATA_HOME")).MetadataWorktree sub := filepath.Join(primary, "sub", "dir") if err := os.MkdirAll(sub, 0o755); err != nil { t.Fatal(err) diff --git a/internal/app/reposetup_harnesses_integration_test.go b/internal/app/reposetup_harnesses_integration_test.go index 0a1ea45f1..511a6f4c4 100644 --- a/internal/app/reposetup_harnesses_integration_test.go +++ b/internal/app/reposetup_harnesses_integration_test.go @@ -111,7 +111,7 @@ func TestIntegrationRepoSetupPrivateInitHarnessesFlag(t *testing.T) { if status := runGit(t, r.invocation, "status", "--porcelain"); status != "" { t.Errorf("git status --porcelain = %q, want a clean working tree", status) } - lay := privateLayoutOf(t, r.invocation, data) + lay := expectedPrivateLayout(t, r.invocation, data) if got := string(mustReadFile(t, lay.ConfigPath)); !strings.Contains(got, "agent_harnesses: [claude]\n") { t.Errorf("%s = %q, want agent_harnesses: [claude]", lay.ConfigPath, got) } @@ -509,7 +509,7 @@ func TestIntegrationRepoSetupPrivateConfigureHarnesses(t *testing.T) { if !strings.Contains(res.HumanText(), ".git/dckt/config.yml") { t.Errorf("human text %q does not name .git/dckt/config.yml", res.HumanText()) } - lay := privateLayoutOf(t, r.invocation, data) + lay := expectedPrivateLayout(t, r.invocation, data) if got := string(mustReadFile(t, lay.ConfigPath)); !strings.Contains(got, "agent_harnesses: [claude]\n") { t.Errorf("%s = %q, want agent_harnesses: [claude]", lay.ConfigPath, got) } diff --git a/internal/app/repository_check.go b/internal/app/repository_check.go index 6e0e72f0a..3f58563b9 100644 --- a/internal/app/repository_check.go +++ b/internal/app/repository_check.go @@ -485,14 +485,17 @@ func collectPendingReviewPaths(ctx context.Context, git *gitcli.Client, repo git // checkCorpus is everything the report-only check reads from the pinned metadata // corpus: the classified records, the two whole-file derived views (board and -// ADR index), and the link context the artifact-links renderer needs. A view's -// present flag distinguishes an absent file (no drift possible) from an empty -// one. +// ADR index), the link context the artifact-links renderer needs, and the +// metadata artifacts (spec, plan, results) the changes link. A view's present +// flag distinguishes an absent file (no drift possible) from an empty one; an +// artifact absent from artifacts is not on the pinned tip (a legacy file on the +// integration branch). type checkCorpus struct { - records []corpusRecord - link render.LinkContext - board corpusFile - adrIndex corpusFile + records []corpusRecord + link render.LinkContext + board corpusFile + adrIndex corpusFile + artifacts map[string][]byte } // corpusFile is one whole-file derived view read from the corpus: its bytes and @@ -566,6 +569,17 @@ func readCheckCorpus(ctx context.Context, git *gitcli.Client, sc setupContext) ( corpus.adrIndex = corpusFile{present: viewBlobs[1].Found, bytes: viewBlobs[1].Blob.Bytes} } + // The linked metadata artifacts, read in one batch on the same pinned source. + // A corpus whose snapshot does not build reads none (the frontmatter path + // surfaces the blocking errors, and no backlink can be rendered). + if snap, ok := buildCorpusSnapshot(sc.cfg, corpus.records); ok { + artifacts, aerr := readLinkedArtifacts(ctx, src, snap) + if aerr != nil { + return corpus, aerr + } + corpus.artifacts = artifacts + } + // The link context, derived from origin exactly as the authoritative writers // derive it (link_context.go), so the artifact-links comparison renders the // same bytes a mutation would have written. A RemoteURL failure is a read @@ -585,6 +599,37 @@ func readCheckCorpus(ctx context.Context, git *gitcli.Client, sc setupContext) ( return corpus, nil } +// readLinkedArtifacts reads every artifact path any change in snap links +// (deduplicated, sorted) with one ReadBlobs, keeping the ones found. A read +// error is returned, never a clean absence. +func readLinkedArtifacts(ctx context.Context, src gitcli.ObjectSource, snap domain.Snapshot) (map[string][]byte, error) { + seen := map[string]bool{} + var paths []gitcli.RepoPath + for _, c := range snap.Changes() { + for _, p := range artifactPathsOf(c) { + if !seen[p] { + seen[p] = true + paths = append(paths, gitcli.RepoPath(p)) + } + } + } + if len(paths) == 0 { + return nil, nil + } + sort.Slice(paths, func(i, j int) bool { return paths[i] < paths[j] }) + blobs, err := src.ReadBlobs(ctx, paths) + if err != nil { + return nil, err + } + out := map[string][]byte{} + for _, br := range blobs { + if br.Found { + out[string(br.Path)] = br.Blob.Bytes + } + } + return out, nil +} + // boardCorpusPath and adrIndexCorpusPath name the two whole-file derived views // in the corpus: the inline board under the changes directory and the ADR index // README under the ADR directory. @@ -675,9 +720,97 @@ func derivedViewFindings(cfg config.Effective, corpus checkCorpus) []reposetup.D } out = append(out, artifactLinkFindings(snap, corpus)...) + out = append(out, artifactBacklinkFindings(snap, corpus)...) + return out +} + +// artifactBacklinkFindings compares the generated docket:backlink block of each +// linked metadata artifact present in corpus.artifacts with the canonical +// relative link canonicalArtifactBacklink renders, visiting paths in sorted +// order. A path linked by several changes has no single canonical target and is +// manual review; malformed markers are manual review (the automatic rewrite +// never touches an unbalanced block); an artifact with no block is left alone (a +// block is never inserted); a differing block is a repairable stale finding. +func artifactBacklinkFindings(snap domain.Snapshot, corpus checkCorpus) []reposetup.DerivedFinding { + owners := map[string][]domain.Change{} + for _, c := range snap.Changes() { + for _, p := range artifactPathsOf(c) { + owners[p] = append(owners[p], c) + } + } + paths := make([]string, 0, len(owners)) + for p := range owners { + paths = append(paths, p) + } + sort.Strings(paths) + + var out []reposetup.DerivedFinding + for _, p := range paths { + stored, present := corpus.artifacts[p] + if !present { + continue + } + if len(owners[p]) > 1 { + out = append(out, reposetup.DerivedFinding{ + View: reposetup.DerivedViewArtifactBacklinks, + Code: reposetup.CodeArtifactBacklinkShared, + Path: p, + Repairable: false, + Message: "the artifact is linked by more than one change, so its generated backlink has no single canonical target.", + }) + continue + } + canonical, hasBlock, err := canonicalArtifactBacklink(stored, owners[p][0], p) + if err != nil { + if markerMalformed(err) { + out = append(out, reposetup.DerivedFinding{ + View: reposetup.DerivedViewArtifactBacklinks, + Code: reposetup.CodeArtifactBacklinkMalformed, + Path: p, + Repairable: false, + Message: "the generated backlink markers are unbalanced or malformed: " + err.Error(), + }) + } + continue + } + if !hasBlock { + continue + } + if derivedBytesDiffer(canonical, stored) { + out = append(out, reposetup.DerivedFinding{ + View: reposetup.DerivedViewArtifactBacklinks, + Code: reposetup.CodeArtifactBacklinkStale, + Path: p, + Repairable: true, + Message: "the generated backlink differs from the canonical relative link to its change.", + }) + } + } return out } +// canonicalArtifactBacklink renders src with its docket:backlink block replaced +// by the canonical relative link from path to c's current record. A parse error +// is returned; a src with no block returns (src, false, nil) — a block is never +// inserted. It is the one renderer behind check, repair, and the close-out/kill +// retarget (retargetArtifactBacklinks). +func canonicalArtifactBacklink(src []byte, c domain.Change, path string) (out []byte, hasBlock bool, err error) { + doc, err := document.Parse(src) + if err != nil { + return nil, false, err + } + if _, ok := doc.Block(backlinkBlockName); !ok { + return src, false, nil + } + var ps document.PatchSet + ps.ReplaceBlock(backlinkBlockName, backlinkInterior(render.ArtifactBacklinkContent(c, path))) + out, err = doc.Apply(ps) + if err != nil { + return nil, true, err + } + return out, true, nil +} + // artifactLinkFindings compares each change record's managed artifact-links block // against the canonical render from snap. Records are visited in path order. A // record whose managed markers are unbalanced is a NON-repairable malformed diff --git a/internal/app/repository_check_backlinks_test.go b/internal/app/repository_check_backlinks_test.go new file mode 100644 index 000000000..7421e74c4 --- /dev/null +++ b/internal/app/repository_check_backlinks_test.go @@ -0,0 +1,157 @@ +package app + +import ( + "testing" + + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/render" + "github.com/danielhanold/docket/internal/reposetup" + "github.com/danielhanold/docket/internal/repository" +) + +const ( + backlinkSpecPath = "docs/superpowers/specs/x.md" + backlinkPlanPath = "docs/superpowers/plans/x-plan.md" +) + +// backlinkChangeFM is one change whose spec (and, when withPlan, plan) points +// at the shared fixture artifact paths. +func backlinkChangeFM(id, slug string, withPlan bool) string { + fm := "id: " + id + "\nslug: " + slug + "\ntitle: T\nstatus: proposed\npriority: medium\ntype: feature\ncreated: 2026-08-30\nupdated: 2026-08-30\nspec: " + backlinkSpecPath + "\n" + if withPlan { + fm += "plan: " + backlinkPlanPath + "\n" + } + return fm +} + +func backlinkRecord(id, slug string, withPlan bool) corpusRecord { + return corpusRecord{ + path: "docs/changes/active/00" + id + "-" + slug + ".md", + bytes: changeRecordBytes(backlinkChangeFM(id, slug, withPlan), ""), + kind: repository.KindChange, + location: repository.LocationActive, + } +} + +// absoluteBacklinkArtifact is a metadata artifact whose generated backlink +// block still carries an absolute same-branch web URL. +const absoluteBacklinkArtifact = "\n> ↩ **[Change 0007 — T](https://github.com/o/r/blob/docket/docs/changes/active/0007-t.md)**\n\n\n# body\n" + +// backlinkFindingsFor builds the corpus snapshot over recs and returns only +// the artifact-backlinks findings of the full derived-view pass, so the +// production wiring (derivedViewFindings) is what the tests exercise. +func backlinkFindingsFor(t *testing.T, recs []corpusRecord, artifacts map[string][]byte) []reposetup.DerivedFinding { + t.Helper() + cfg := derivedTestConfig() + corpus := checkCorpus{ + records: recs, + link: render.LinkContext{MetadataBranch: layout.SharedName}, + artifacts: artifacts, + } + var out []reposetup.DerivedFinding + for _, df := range derivedViewFindings(cfg, corpus) { + if df.View == reposetup.DerivedViewArtifactBacklinks { + out = append(out, df) + } + } + return out +} + +func backlinkSnapshotChange(t *testing.T, recs []corpusRecord, id int) domain.Change { + t.Helper() + snap, ok := buildCorpusSnapshot(derivedTestConfig(), recs) + if !ok { + t.Fatal("buildCorpusSnapshot failed") + } + c, out := snap.Change(domain.ChangeID(id)) + if out != domain.LookupFound { + t.Fatalf("change %d absent from snapshot (outcome %d)", id, out) + } + return c +} + +func TestArtifactBacklinkAbsoluteSpecIsStale(t *testing.T) { + recs := []corpusRecord{backlinkRecord("07", "t", false)} + got := backlinkFindingsFor(t, recs, map[string][]byte{backlinkSpecPath: []byte(absoluteBacklinkArtifact)}) + if len(got) != 1 { + t.Fatalf("findings = %+v, want exactly one", got) + } + f := got[0] + if f.Code != reposetup.CodeArtifactBacklinkStale || f.Path != backlinkSpecPath || !f.Repairable { + t.Errorf("finding = %+v, want a repairable %s on %s", f, reposetup.CodeArtifactBacklinkStale, backlinkSpecPath) + } +} + +func TestArtifactBacklinkCanonicalSpecIsClean(t *testing.T) { + recs := []corpusRecord{backlinkRecord("07", "t", false)} + c := backlinkSnapshotChange(t, recs, 7) + canonical := assembleSpecFile(render.ArtifactBacklinkContent(c, backlinkSpecPath), "# body") + if got := backlinkFindingsFor(t, recs, map[string][]byte{backlinkSpecPath: canonical}); len(got) != 0 { + t.Errorf("canonical backlink produced findings: %+v", got) + } +} + +func TestArtifactBacklinkNoBlockIsClean(t *testing.T) { + recs := []corpusRecord{backlinkRecord("07", "t", false)} + if got := backlinkFindingsFor(t, recs, map[string][]byte{backlinkSpecPath: []byte("# body\n")}); len(got) != 0 { + t.Errorf("block-less artifact produced findings: %+v", got) + } +} + +func TestArtifactBacklinkAbsentArtifactIsClean(t *testing.T) { + recs := []corpusRecord{backlinkRecord("07", "t", false)} + if got := backlinkFindingsFor(t, recs, nil); len(got) != 0 { + t.Errorf("absent artifact produced findings: %+v", got) + } +} + +func TestArtifactBacklinkStartMarkerOnlyIsMalformed(t *testing.T) { + recs := []corpusRecord{backlinkRecord("07", "t", false)} + src := "\n> ↩ dangling\n\n# body\n" + got := backlinkFindingsFor(t, recs, map[string][]byte{backlinkSpecPath: []byte(src)}) + if len(got) != 1 || got[0].Code != reposetup.CodeArtifactBacklinkMalformed || got[0].Repairable { + t.Errorf("findings = %+v, want one non-repairable %s", got, reposetup.CodeArtifactBacklinkMalformed) + } +} + +func TestArtifactBacklinkSharedPathIsManualReview(t *testing.T) { + recs := []corpusRecord{backlinkRecord("07", "t", false), backlinkRecord("08", "u", false)} + got := backlinkFindingsFor(t, recs, map[string][]byte{backlinkSpecPath: []byte(absoluteBacklinkArtifact)}) + if len(got) != 1 || got[0].Code != reposetup.CodeArtifactBacklinkShared || got[0].Repairable || got[0].Path != backlinkSpecPath { + t.Errorf("findings = %+v, want one non-repairable %s on %s", got, reposetup.CodeArtifactBacklinkShared, backlinkSpecPath) + } +} + +func TestArtifactBacklinkAbsolutePlanIsStale(t *testing.T) { + recs := []corpusRecord{backlinkRecord("07", "t", true)} + c := backlinkSnapshotChange(t, recs, 7) + canonicalSpec := assembleSpecFile(render.ArtifactBacklinkContent(c, backlinkSpecPath), "# body") + got := backlinkFindingsFor(t, recs, map[string][]byte{ + backlinkSpecPath: canonicalSpec, + backlinkPlanPath: []byte(absoluteBacklinkArtifact), + }) + if len(got) != 1 || got[0].Code != reposetup.CodeArtifactBacklinkStale || got[0].Path != backlinkPlanPath { + t.Errorf("findings = %+v, want one %s on %s", got, reposetup.CodeArtifactBacklinkStale, backlinkPlanPath) + } +} + +// TestCanonicalArtifactBacklinkRewritesOnlyTheBlock proves the one renderer +// replaces the block interior and leaves every other byte alone. +func TestCanonicalArtifactBacklinkRewritesOnlyTheBlock(t *testing.T) { + recs := []corpusRecord{backlinkRecord("07", "t", false)} + c := backlinkSnapshotChange(t, recs, 7) + out, has, err := canonicalArtifactBacklink([]byte(absoluteBacklinkArtifact), c, backlinkSpecPath) + if err != nil || !has { + t.Fatalf("canonicalArtifactBacklink: has=%v err=%v", has, err) + } + want := assembleSpecFile(render.ArtifactBacklinkContent(c, backlinkSpecPath), "# body") + if string(out) != string(want) { + t.Errorf("out = %q\nwant %q", out, want) + } + src := []byte("# body\n") + out, has, err = canonicalArtifactBacklink(src, c, backlinkSpecPath) + if err != nil || has || string(out) != string(src) { + t.Errorf("block-less: out=%q has=%v err=%v, want unchanged, false, nil", out, has, err) + } +} diff --git a/internal/app/repository_facts.go b/internal/app/repository_facts.go index 36fbf9eca..4b1177b7f 100644 --- a/internal/app/repository_facts.go +++ b/internal/app/repository_facts.go @@ -11,6 +11,7 @@ import ( "github.com/danielhanold/docket/internal/config" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/reposeed" "github.com/danielhanold/docket/internal/reposetup" ) @@ -44,9 +45,11 @@ func setupRemote() gitcli.RemoteName { return originRemote } type SetupDeps struct { Git *gitcli.Client RepoDir string // invocation dir; Discover resolves the canonical primary - // GitHub is set ONLY by `repository repair --pr-backlinks` (the one - // repository command that reads and edits merged PR bodies). Every other - // repository command leaves it nil and never consults it. + // GitHub is set by `repository repair --pr-backlinks` (which reads and + // edits merged PR bodies) and by `repository set-visibility` (which only + // reads open PR bodies, to list the ones carrying docket text; nil there + // means the preview says they were not checked). Every other repository + // command leaves it nil and never consults it. GitHub RepairGitHub hooks setupHooks } @@ -66,6 +69,9 @@ type SetupDeps struct { // published and re-read and before the local finish — the seam the // LocalMovedAfterPublish scenario advances the local primary through (returning // nil so the finish still runs and reports the pending local sync). +// afterVisibilityPhase fires after each phase a `repository set-visibility` run +// applies, named by the phase; an error stops the run there, as a death after +// that phase would. type setupHooks struct { beforeSeedPush func() error afterSeedPush func() error @@ -73,6 +79,7 @@ type setupHooks struct { beforePrunePush func() error afterPrunePush func() error beforeLocalFinish func() error + afterVisibilityPhase func(phase string) error } // fire invokes hook when it is non-nil, returning its error (nil hook → nil). @@ -383,13 +390,13 @@ func liveSurfacePresence(ctx context.Context, p setupProber, repo gitcli.Reposit // the parent-facing dispatch surfaces. The clean preflight ignores exactly these // (and ignored paths), so a repeat init whose only difference from clean is its // own pending review edits is not read as a dirty primary. -var docketManagedWorktreePaths = map[string]bool{ - ".gitignore": true, - ".docket.yml": true, - "CLAUDE.md": true, - "AGENTS.md": true, - ".cursor/rules/docket-dispatch.mdc": true, -} +var docketManagedWorktreePaths = func() map[string]bool { + set := map[string]bool{".gitignore": true, ".docket.yml": true} + for _, p := range reposeed.SurfacePaths() { + set[p] = true + } + return set +}() // primaryCleanPresence reports whether the primary worktree is clean once the // docket-managed pending-review surfaces and ignored paths are set aside. A diff --git a/internal/app/repository_init_private.go b/internal/app/repository_init_private.go index 11f9a0d00..e19039843 100644 --- a/internal/app/repository_init_private.go +++ b/internal/app/repository_init_private.go @@ -164,74 +164,22 @@ func runPrivateInit(ctx context.Context, d SetupDeps, sc setupContext, cls repos return fail(repositoryInternalFailure(OperationRepositoryInit, cls.State, "resolving the private layout", errors.New("the repository does not detect as private after the config write"))) } - // 4. The bare remote: the flag URL as given; else, on a repository that - // already detected private before this run, the dckt remote it configured - // (a flagless re-run after --metadata-remote); else the default store, - // created when absent. Only that last case ever creates the default store. - remote := metadataRemote(lay) - got, err := d.Git.RemoteURL(ctx, sc.repo, remote) - unconfigured := isRemoteUnconfigured(err) - if err != nil && !unconfigured { - return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "reading the dckt git remote", err)) - } - want, err := absMetadataRemote(o.MetadataRemote) + // 4-5. The bare remote and the dckt git remote. A repository that already + // detected private before this run reuses the dckt remote it configured (a + // flagless re-run after --metadata-remote). + want, remoteChanged, err := ensurePrivateMetadataRemote(ctx, d.Git, sc.repo, lay, o.MetadataRemote, sc.layout.Mode == layout.Private) if err != nil { - return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "resolving the --metadata-remote path", err)) - } - if want == "" && !unconfigured && sc.layout.Mode == layout.Private { - want = got - } - createStore := want == "" - if createStore { - want = lay.DefaultBareRemote - } - // The default store's name is derived from origin's URL and can collide - // across distinct origins, so the store records the origin that created it - // and a repository with a different origin refuses before attaching to it. - var recordOrigin func() (bool, error) - if want == lay.DefaultBareRemote { - origin, oerr := d.Git.RemoteURL(ctx, sc.repo, originRemote) - if oerr != nil { - return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "reading origin's URL", oerr)) - } - record, rerr := checkStoreOrigin(lay.StoreDir, origin) - if rerr != nil { - return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "reading the metadata store's origin record", rerr)) - } - if record.foreign != "" { - return fail(initRefusal(reposetup.StateConflict, fmt.Sprintf( - "the metadata store %s belongs to origin %s, not this repository's origin %s (both derive the store name %s); re-run `docket repository init --metadata-remote ` with a bare repository of this repository's own", - lay.StoreDir, record.foreign, origin, filepath.Base(lay.StoreDir)))) - } - recordOrigin = record.write - } - if createStore { - _, statErr := os.Stat(want) - if err := d.Git.InitBare(ctx, want); err != nil { - return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "creating the bare metadata store", err)) + var mre *metadataRemoteError + if !errors.As(err, &mre) { + return fail(repositoryInternalFailure(OperationRepositoryInit, cls.State, "setting up the dckt git remote", err)) } - changed = changed || os.IsNotExist(statErr) - } - if recordOrigin != nil { - wrote, werr := recordOrigin() - if werr != nil { - return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "recording the metadata store's origin", werr)) + if mre.Conflict { + return fail(initRefusal(reposetup.StateConflict, mre.Err.Error())) } - changed = changed || wrote - } - - // 5. The dckt git remote: added when absent, kept when it already points at - // want, refused otherwise. Docket never rewrites a remote. - switch { - case unconfigured: - if aerr := d.Git.AddRemote(ctx, sc.repo, remote, want); aerr != nil { - return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "adding the dckt git remote", aerr)) - } - changed = true - case got != want: - return fail(initRefusal(reposetup.StateConflict, fmt.Sprintf( - "the dckt git remote already points at %s, not %s; resolve it by hand (docket never rewrites a remote)", got, want))) + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, mre.Stage, mre.Err)) } + changed = changed || remoteChanged + remote := metadataRemote(lay) // 6. The metadata branch: a branch the store already holds is adopted at its // tip when it is a verified init lineage (a re-run, or the second-clone path) @@ -325,6 +273,105 @@ func runPrivateInit(ctx context.Context, d SetupDeps, sc setupContext, cls repos return out } +// metadataRemoteError is ensurePrivateMetadataRemote's failure. Conflict marks +// a refusal a human resolves (a foreign default store, or a dckt remote that +// points elsewhere); otherwise it is an external failure at Stage. +type metadataRemoteError struct { + Conflict bool + Stage string + Err error +} + +func (e *metadataRemoteError) Error() string { + if e.Conflict { + return e.Err.Error() + } + return e.Stage + ": " + e.Err.Error() +} + +func (e *metadataRemoteError) Unwrap() error { return e.Err } + +// ensurePrivateMetadataRemote sets up the bare remote a private repository's +// metadata branch is pushed to, and the dckt git remote naming it. The URL is +// flagURL as given; else, when reuseConfigured, the dckt remote already +// configured; else the default store, created when absent. Only that last case +// ever creates the default store. The default store's name is derived from +// origin's URL and can collide across distinct origins, so the store records +// the origin that created it and a repository with a different origin refuses +// before attaching to it. The dckt git remote is added when absent, kept when +// it already points at the URL, and refused otherwise: docket never rewrites a +// remote. It returns the URL and whether it changed anything; every failure is +// a *metadataRemoteError. +func ensurePrivateMetadataRemote(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, lay layout.Layout, flagURL string, reuseConfigured bool) (string, bool, error) { + external := func(stage string, err error) (string, bool, error) { + return "", false, &metadataRemoteError{Stage: stage, Err: err} + } + conflict := func(msg string) (string, bool, error) { + return "", false, &metadataRemoteError{Conflict: true, Err: errors.New(msg)} + } + changed := false + remote := metadataRemote(lay) + got, err := git.RemoteURL(ctx, repo, remote) + unconfigured := isRemoteUnconfigured(err) + if err != nil && !unconfigured { + return external("reading the dckt git remote", err) + } + want, err := absMetadataRemote(flagURL) + if err != nil { + return external("resolving the --metadata-remote path", err) + } + if want == "" && !unconfigured && reuseConfigured { + want = got + } + createStore := want == "" + if createStore { + want = lay.DefaultBareRemote + } + var recordOrigin func() (bool, error) + if want == lay.DefaultBareRemote { + origin, oerr := git.RemoteURL(ctx, repo, originRemote) + if oerr != nil { + return external("reading origin's URL", oerr) + } + record, rerr := checkStoreOrigin(lay.StoreDir, origin) + if rerr != nil { + return external("reading the metadata store's origin record", rerr) + } + if record.foreign != "" { + return conflict(fmt.Sprintf( + "the metadata store %s belongs to origin %s, not this repository's origin %s (both derive the store name %s); re-run `docket repository init --metadata-remote ` with a bare repository of this repository's own", + lay.StoreDir, record.foreign, origin, filepath.Base(lay.StoreDir))) + } + recordOrigin = record.write + } + if createStore { + _, statErr := os.Stat(want) + if err := git.InitBare(ctx, want); err != nil { + return external("creating the bare metadata store", err) + } + changed = changed || os.IsNotExist(statErr) + } + if recordOrigin != nil { + wrote, werr := recordOrigin() + if werr != nil { + return external("recording the metadata store's origin", werr) + } + changed = changed || wrote + } + + switch { + case unconfigured: + if aerr := git.AddRemote(ctx, repo, remote, want); aerr != nil { + return external("adding the dckt git remote", aerr) + } + changed = true + case got != want: + return conflict(fmt.Sprintf( + "the dckt git remote already points at %s, not %s; resolve it by hand (docket never rewrites a remote)", got, want)) + } + return want, changed, nil +} + // ensureExcludeFile writes the neutral `# dckt:` block into the exclude file at // path (absent reads as empty) when it is missing or stale, and reports whether // it wrote. Malformed markers return *reposetup.MalformedExcludeError with the diff --git a/internal/app/repository_private_integration_test.go b/internal/app/repository_private_integration_test.go index 98246df7c..28700dca7 100644 --- a/internal/app/repository_private_integration_test.go +++ b/internal/app/repository_private_integration_test.go @@ -98,9 +98,9 @@ func (r *initRepo) runInitWithGlobal(t *testing.T, globalYML string, o InitOptio return RunRepositoryInit(context.Background(), SetupDeps{Git: client, RepoDir: r.invocation}, o) } -// privateLayoutOf resolves the clone's private layout from its origin URL and +// expectedPrivateLayout resolves the clone's private layout from its origin URL and // the test's data home, independently of the code under test's own resolution. -func privateLayoutOf(t *testing.T, dir, data string) layout.Layout { +func expectedPrivateLayout(t *testing.T, dir, data string) layout.Layout { t.Helper() common := runGit(t, dir, "rev-parse", "--path-format=absolute", "--git-common-dir") top := runGit(t, dir, "rev-parse", "--show-toplevel") @@ -121,7 +121,7 @@ func TestIntegrationRepoSetupPrivateFreshInitCreatesNeutralLayout(t *testing.T) if res.Result != ResultApplied { t.Fatalf("Result = %q (%s), want applied", res.Result, res.HumanText()) } - lay := privateLayoutOf(t, r.invocation, data) + lay := expectedPrivateLayout(t, r.invocation, data) gitDir := r.gitDir(t) cfg, err := os.ReadFile(filepath.Join(gitDir, "dckt", "config.yml")) @@ -177,7 +177,7 @@ func TestIntegrationRepoSetupPrivateRerunIsNoOp(t *testing.T) { if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { t.Fatalf("first init = %q (%s), want applied", res.Result, res.HumanText()) } - lay := privateLayoutOf(t, r.invocation, data) + lay := expectedPrivateLayout(t, r.invocation, data) tip := runGit(t, lay.DefaultBareRemote, "rev-parse", "refs/heads/dckt") pinCommitDate(t, tc.rerun) res := r.runInitWith(t, InitOptions{}) @@ -227,7 +227,7 @@ func TestIntegrationRepoSetupPrivateInterruptedAfterConfigResumes(t *testing.T) if res.Result != ResultApplied { t.Fatalf("resumed init = %q (%s), want applied", res.Result, res.HumanText()) } - lay := privateLayoutOf(t, r.invocation, data) + lay := expectedPrivateLayout(t, r.invocation, data) if url := runGit(t, r.invocation, "config", "--get", "remote.dckt.url"); url != lay.DefaultBareRemote { t.Errorf("dckt remote URL = %q, want %q", url, lay.DefaultBareRemote) } @@ -243,7 +243,7 @@ func TestIntegrationRepoSetupPrivateSecondCloneAdoptsSharedStore(t *testing.T) { if created.Result != ResultApplied { t.Fatalf("ChangeCreate in clone A = %q (%s)", created.Result, created.HumanText()) } - layA := privateLayoutOf(t, r.invocation, data) + layA := expectedPrivateLayout(t, r.invocation, data) headA := runGit(t, layA.MetadataWorktree, "rev-parse", "HEAD") b := r.freshClone(t) @@ -251,7 +251,7 @@ func TestIntegrationRepoSetupPrivateSecondCloneAdoptsSharedStore(t *testing.T) { if res := bRepo.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { t.Fatalf("clone B init = %q (%s), want applied", res.Result, res.HumanText()) } - layB := privateLayoutOf(t, b, data) + layB := expectedPrivateLayout(t, b, data) urlA := runGit(t, r.invocation, "config", "--get", "remote.dckt.url") urlB := runGit(t, b, "config", "--get", "remote.dckt.url") @@ -279,7 +279,7 @@ func TestIntegrationRepoSetupPrivateStoreOriginCollisionRefuses(t *testing.T) { if res := a.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { t.Fatalf("repo A init = %q (%s), want applied", res.Result, res.HumanText()) } - layA := privateLayoutOf(t, a.invocation, data) + layA := expectedPrivateLayout(t, a.invocation, data) recordPath := filepath.Join(layA.StoreDir, storeOriginRecordName) record, err := os.ReadFile(recordPath) if err != nil || strings.TrimSpace(string(record)) != a.origin { @@ -294,7 +294,7 @@ func TestIntegrationRepoSetupPrivateStoreOriginCollisionRefuses(t *testing.T) { collider := filepath.Join(testsupport.TempDir(t), filepath.Base(a.root), "origin.git") runGit(t, b.root, "clone", "-q", "--bare", b.origin, collider) runGit(t, b.invocation, "remote", "set-url", "origin", collider) - if layB := privateLayoutOf(t, b.invocation, data); layB.StoreDir != layA.StoreDir { + if layB := expectedPrivateLayout(t, b.invocation, data); layB.StoreDir != layA.StoreDir { t.Fatalf("fixture: B's store %q does not collide with A's %q", layB.StoreDir, layA.StoreDir) } @@ -328,7 +328,7 @@ func TestIntegrationRepoSetupPrivateUnrecordedStoreAdoptsAndRecords(t *testing.T if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { t.Fatalf("clone A init = %q (%s), want applied", res.Result, res.HumanText()) } - lay := privateLayoutOf(t, r.invocation, data) + lay := expectedPrivateLayout(t, r.invocation, data) recordPath := filepath.Join(lay.StoreDir, storeOriginRecordName) if err := os.Remove(recordPath); err != nil { t.Fatal(err) @@ -400,7 +400,7 @@ func TestIntegrationRepoSetupPrivateMetadataRemoteFlag(t *testing.T) { if _, err := tryGit(backup, "rev-parse", "--verify", "--quiet", "refs/heads/dckt"); err != nil { t.Errorf("the flag remote has no refs/heads/dckt: %v", err) } - lay := privateLayoutOf(t, r.invocation, data) + lay := expectedPrivateLayout(t, r.invocation, data) if _, err := os.Stat(lay.DefaultBareRemote); !os.IsNotExist(err) { t.Errorf("default bare remote %s was created (err=%v); a flag URL never creates it", lay.DefaultBareRemote, err) } @@ -432,7 +432,7 @@ func TestIntegrationRepoSetupPrivateMetadataRemoteRerunIsNoOp(t *testing.T) { if url := runGit(t, r.invocation, "config", "--get", "remote.dckt.url"); url != backup { t.Errorf("dckt remote URL = %q, want the flag %q", url, backup) } - lay := privateLayoutOf(t, r.invocation, data) + lay := expectedPrivateLayout(t, r.invocation, data) if _, err := os.Stat(lay.DefaultBareRemote); !os.IsNotExist(err) { t.Errorf("default bare remote %s was created on re-run (err=%v)", lay.DefaultBareRemote, err) } @@ -703,7 +703,7 @@ func TestIntegrationRepoSetupPrivateInitRefusesBesideLocalConfig(t *testing.T) { if _, err := tryGit(r.invocation, "config", "--get", "remote.dckt.url"); err == nil { t.Error("a dckt remote was added; init must write nothing") } - lay := privateLayoutOf(t, r.invocation, data) + lay := expectedPrivateLayout(t, r.invocation, data) if _, err := os.Lstat(lay.StoreDir); !os.IsNotExist(err) { t.Errorf("store %s exists after the refusal (err=%v); init must write nothing", lay.StoreDir, err) } @@ -726,13 +726,13 @@ func TestIntegrationRepoSetupPrivateRecloneSamePathReattaches(t *testing.T) { if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { t.Fatalf("first init = %q (%s), want applied", res.Result, res.HumanText()) } - checkout := privateLayoutOf(t, r.invocation, data).MetadataWorktree + checkout := expectedPrivateLayout(t, r.invocation, data).MetadataWorktree if err := os.RemoveAll(r.invocation); err != nil { t.Fatal(err) } runGit(t, r.root, "clone", "-q", r.origin, r.invocation) gitIdentity(t, r.invocation) - if got := privateLayoutOf(t, r.invocation, data).MetadataWorktree; got != checkout { + if got := expectedPrivateLayout(t, r.invocation, data).MetadataWorktree; got != checkout { t.Fatalf("re-clone resolved checkout %q, want the same path %q", got, checkout) } if _, err := os.Stat(checkout); err != nil { @@ -781,7 +781,7 @@ func TestIntegrationRepoSetupPrivateMovedCloneOrphanPruned(t *testing.T) { if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { t.Fatalf("init = %q (%s), want applied", res.Result, res.HumanText()) } - old := privateLayoutOf(t, r.invocation, data).MetadataWorktree + old := expectedPrivateLayout(t, r.invocation, data).MetadataWorktree moved := filepath.Join(r.root, "moved") if err := os.Rename(r.invocation, moved); err != nil { t.Fatal(err) @@ -822,7 +822,7 @@ func TestIntegrationRepoSetupPrivateMovedCloneOrphanPruned(t *testing.T) { t.Fatalf("prepare = %q (%s), want the held-elsewhere refusal naming git worktree prune", prep.Disposition, prep.HumanText()) } runGit(t, moved, "worktree", "prune") - newCheckout := privateLayoutOf(t, moved, data).MetadataWorktree + newCheckout := expectedPrivateLayout(t, moved, data).MetadataWorktree prep = RunRepositoryPrepare(context.Background(), d, PrepareOptions{}) if prep.Disposition != PrepareDispositionApplied || prep.Context == nil || prep.Context.MetadataWorktreePath != newCheckout { t.Fatalf("prepare after prune = %q (%s), want the new checkout %s attached", prep.Disposition, prep.HumanText(), newCheckout) diff --git a/internal/app/repository_repair.go b/internal/app/repository_repair.go index 775c55ea3..e20c36fc3 100644 --- a/internal/app/repository_repair.go +++ b/internal/app/repository_repair.go @@ -289,8 +289,12 @@ func planRepositoryRepair(sc setupContext, corpus checkCorpus) (repositoryRepair for _, r := range repaired.records { recByPath[r.path] = r } + viewByPath := map[string]reposetup.DerivedView{} + for _, f := range repairable { + viewByPath[f.Path] = f.View + } for _, f := range derivedRepairFiles(repairable) { - b, err := composeDerivedRepairBytes(sc, snap, repaired, recByPath, f) + b, err := composeDerivedRepairBytes(sc, snap, repaired, recByPath, viewByPath[f], f) if err != nil { return repositoryRepairPlan{}, fmt.Errorf("composing the repaired %s: %w", f, err) } @@ -381,16 +385,19 @@ func derivedRepairFiles(repairable []reposetup.DerivedFinding) []string { return files } -// composeDerivedRepairBytes recomputes the canonical bytes for one repaired file. -// The board and ADR index are whole-file renders; an artifact-links file is the -// record with its managed block rewritten (or, when absent, inserted after the -// frontmatter) — never any other authored byte. -func composeDerivedRepairBytes(sc setupContext, snap domain.Snapshot, corpus checkCorpus, recByPath map[string]corpusRecord, file string) ([]byte, error) { - switch file { - case boardCorpusPath(sc.cfg): +// composeDerivedRepairBytes recomputes the canonical bytes for one repaired file +// of the given derived view. The board and ADR index are whole-file renders; an +// artifact-links file is the record with its managed block rewritten (or, when +// absent, inserted after the frontmatter); a metadata artifact has only its +// existing backlink block re-stamped — never any other authored byte. +func composeDerivedRepairBytes(sc setupContext, snap domain.Snapshot, corpus checkCorpus, recByPath map[string]corpusRecord, view reposetup.DerivedView, file string) ([]byte, error) { + switch view { + case reposetup.DerivedViewBoard: return renderCanonicalBoard(snap, corpusBoardUnrenderable(sc.cfg, corpus.records), boardPresentation(sc.cfg)) - case adrIndexCorpusPath(sc.cfg): + case reposetup.DerivedViewADRIndex: return renderCanonicalADRIndex(snap, corpusADRIndexUnrenderable(sc.cfg, corpus.records)) + case reposetup.DerivedViewArtifactBacklinks: + return composeArtifactBacklinkRepair(snap, corpus, file) } // An artifact-links record. rec, ok := recByPath[file] @@ -421,6 +428,37 @@ func composeDerivedRepairBytes(sc setupContext, snap domain.Snapshot, corpus che return doc.Apply(ps) } +// composeArtifactBacklinkRepair re-stamps the backlink block of the metadata +// artifact at file against the one change linking it. Check reports an artifact +// linked by several changes, or carrying no block, as nothing repairable, so +// either reaching here is an internal inconsistency, never a guess. +func composeArtifactBacklinkRepair(snap domain.Snapshot, corpus checkCorpus, file string) ([]byte, error) { + stored, ok := corpus.artifacts[file] + if !ok { + return nil, fmt.Errorf("artifact %s absent from the corpus", file) + } + var owners []domain.Change + for _, c := range snap.Changes() { + for _, p := range artifactPathsOf(c) { + if p == file { + owners = append(owners, c) + break + } + } + } + if len(owners) != 1 { + return nil, fmt.Errorf("artifact %s is linked by %d changes, want exactly one", file, len(owners)) + } + out, hasBlock, err := canonicalArtifactBacklink(stored, owners[0], file) + if err != nil { + return nil, err + } + if !hasBlock { + return nil, fmt.Errorf("artifact %s carries no backlink block", file) + } + return out, nil +} + // changeByPath finds the change whose canonical path is p. func snapshotChangeByPath(snap domain.Snapshot, p string) (domain.Change, bool) { for _, c := range snap.Changes() { diff --git a/internal/app/repository_repair_integration_test.go b/internal/app/repository_repair_integration_test.go index 4b4cfce10..29e161a10 100644 --- a/internal/app/repository_repair_integration_test.go +++ b/internal/app/repository_repair_integration_test.go @@ -10,9 +10,12 @@ import ( "strings" "testing" + "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/githubcli" "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/reposetup" + "github.com/danielhanold/docket/internal/repository" ) // This is the real-Git `repository repair` shard (prefix TestIntegrationRepoRepair, @@ -212,6 +215,73 @@ func TestIntegrationRepoRepairAppliesOneDescendantCommit(t *testing.T) { } } +// --- metadata artifact backlinks --------------------------------------------- + +const ( + restampRecordPath = "docs/changes/active/0001-example.md" + restampSpecPath = "docs/superpowers/specs/2026-08-30-example-design.md" + restampProseLine = "see https://github.com/o/r/blob/docket/docs/adrs/README.md" +) + +// absoluteSpecWithProse is a spec whose generated backlink is an absolute +// same-branch URL, followed by an authored prose line that carries an absolute +// same-branch URL of its own; the repair may touch only the block. +func absoluteSpecWithProse() string { + return "\n" + + "> ↩ **[Change 0001 — Example change](https://github.com/o/r/blob/docket/" + restampRecordPath + ")**\n" + + "\n\n# Example design\n\n" + restampProseLine + "\n" +} + +// TestIntegrationRepoRepairRestampsAbsoluteSpecBacklink proves the preview +// lists the stale spec backlink, the authorized repair rewrites only the block +// to the canonical relative link (the prose URL and every other byte stay), +// and a following check reports no stale backlink (Review Focus 3). +func TestIntegrationRepoRepairRestampsAbsoluteSpecBacklink(t *testing.T) { + r := newHealthyRepo(t) + dotDocket := filepath.Join(r.invocation, ".docket") + writeRepoFile(t, dotDocket, restampRecordPath, staleRepairRecord()) + writeRepoFile(t, dotDocket, restampSpecPath, absoluteSpecWithProse()) + runGit(t, dotDocket, "add", "--", restampRecordPath, restampSpecPath) + runGit(t, dotDocket, "commit", "-q", "-m", "publish a spec with an absolute backlink") + runGit(t, dotDocket, "push", "-q", "origin", string(layout.SharedName)) + before := currentDocketTip(t, r) + + preview := r.runRepair(t, RepairOptions{}) + if !preview.ConfirmationRequired() { + t.Fatalf("preview = %q (%s), want confirmation-required", preview.Result, preview.HumanText()) + } + if !containsPath(preview.RepairedViews, restampSpecPath) { + t.Errorf("RepairedViews = %v, want %s", preview.RepairedViews, restampSpecPath) + } + if want := "[" + reposetup.CodeArtifactBacklinkStale + "] " + restampSpecPath; !strings.Contains(preview.HumanText(), want) { + t.Errorf("preview human lacks %q:\n%s", want, preview.HumanText()) + } + + res := r.runRepair(t, RepairOptions{Authorized: true, ExpectedSource: before}) + if res.Result != ResultApplied { + t.Fatalf("repair = %q (%s), want applied", res.Result, res.HumanText()) + } + snap, ok := buildCorpusSnapshot(derivedTestConfig(), []corpusRecord{{ + path: restampRecordPath, bytes: []byte(staleRepairRecord()), kind: repository.KindChange, location: repository.LocationActive, + }}) + if !ok { + t.Fatal("buildCorpusSnapshot failed") + } + c, out := snap.Change(domain.ChangeID(1)) + if out != domain.LookupFound { + t.Fatalf("change 1 absent from the snapshot (outcome %d)", out) + } + want := render.ArtifactBacklinkContent(c, restampSpecPath) + "\n# Example design\n\n" + restampProseLine + "\n" + if got := showDocketFile(t, r, restampSpecPath); got != strings.TrimRight(want, "\n") { + t.Errorf("repaired spec = %q\nwant %q", got, want) + } + for _, f := range r.runCheck(t).Findings { + if f.Code == reposetup.CodeArtifactBacklinkStale { + t.Errorf("check after repair still reports %+v", f) + } + } +} + // --- one-time relative-link conversion -------------------------------------- const ( diff --git a/internal/app/repository_repair_test.go b/internal/app/repository_repair_test.go index 4ba37116c..fde7ef834 100644 --- a/internal/app/repository_repair_test.go +++ b/internal/app/repository_repair_test.go @@ -388,7 +388,7 @@ func TestComposeDerivedRepairBoardBytes(t *testing.T) { corpus := checkCorpus{records: []corpusRecord{rec}, link: render.LinkContext{MetadataBranch: layout.SharedName}} recByPath := map[string]corpusRecord{rec.path: rec} - got, err := composeDerivedRepairBytes(setupContext{cfg: cfg}, snap, corpus, recByPath, boardCorpusPath(cfg)) + got, err := composeDerivedRepairBytes(setupContext{cfg: cfg}, snap, corpus, recByPath, reposetup.DerivedViewBoard, boardCorpusPath(cfg)) if err != nil { t.Fatalf("compose board: %v", err) } @@ -410,7 +410,7 @@ func TestComposeDerivedRepairArtifactLinksBytes(t *testing.T) { corpus := checkCorpus{records: []corpusRecord{stale}, link: render.LinkContext{MetadataBranch: layout.SharedName}} recByPath := map[string]corpusRecord{path: stale} - got, err := composeDerivedRepairBytes(setupContext{cfg: cfg}, snap, corpus, recByPath, path) + got, err := composeDerivedRepairBytes(setupContext{cfg: cfg}, snap, corpus, recByPath, reposetup.DerivedViewArtifactLinks, path) if err != nil { t.Fatalf("compose artifact-links: %v", err) } @@ -424,3 +424,28 @@ func TestComposeDerivedRepairArtifactLinksBytes(t *testing.T) { t.Errorf("re-check of the repaired record still reports stale: %+v", f) } } + +// TestPlanRepositoryRepairRestampsAbsoluteArtifactBacklink proves an artifact +// whose generated backlink is an absolute same-branch URL is planned as the +// canonical relative backlink, with every other byte unchanged. +func TestPlanRepositoryRepairRestampsAbsoluteArtifactBacklink(t *testing.T) { + cfg := derivedTestConfig() + recs := []corpusRecord{backlinkRecord("07", "t", false)} + corpus := checkCorpus{ + records: recs, + link: repairLink(), + artifacts: map[string][]byte{backlinkSpecPath: []byte(absoluteBacklinkArtifact)}, + } + plan, err := planRepositoryRepair(setupContext{cfg: cfg}, corpus) + if err != nil { + t.Fatalf("planRepositoryRepair: %v", err) + } + if !planHasFile(plan.files, backlinkSpecPath) { + t.Fatalf("files = %v, want %s", plan.files, backlinkSpecPath) + } + c := backlinkSnapshotChange(t, recs, 7) + want := assembleSpecFile(render.ArtifactBacklinkContent(c, backlinkSpecPath), "# body") + if got := plan.contents[backlinkSpecPath]; !bytes.Equal(got, want) { + t.Errorf("contents = %q\nwant %q", got, want) + } +} diff --git a/internal/app/repository_set_visibility.go b/internal/app/repository_set_visibility.go new file mode 100644 index 000000000..928cd491b --- /dev/null +++ b/internal/app/repository_set_visibility.go @@ -0,0 +1,868 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/reposetup" +) + +// repository_set_visibility.go — `docket repository set-visibility +// `: moves an existing repository between the shared and the +// private layout, keeping every record. It follows repository_migrate.go's +// two-pass authorization: an unauthorized run previews the phases with a +// composite pin over the remote tips it read; an authorized run re-proves that +// pin and runs every pending phase. Each phase's completion is read from STATE +// (refs on each remote, folder names, worktree registrations, config files), +// never from the detected mode alone, so a re-run resumes where an interrupted +// one stopped. It never force-pushes, never deletes a foreign ref, never rolls +// back, and makes no metadata commit: the identical history is published under +// the target branch name. + +// OperationRepositorySetVisibility is the operation key `repository +// set-visibility` records. +const OperationRepositorySetVisibility = "repository.set-visibility" + +// visibilityConfirmationRequiredState is the repository_state an unauthorized +// preview carries. +const visibilityConfirmationRequiredState = "confirmation-required" + +// The closed phase statuses a result reports. +const ( + visibilityPhaseDone = "done" + visibilityPhasePending = "pending" + visibilityPhaseApplied = "applied" + visibilityPhaseKept = "kept" + visibilityPhaseSkipped = "skipped" +) + +// The closed commit statuses a result reports. +const ( + visibilityCommitPlanned = "planned" + visibilityCommitCommitted = "committed" + visibilityCommitFailed = "failed" +) + +// visibilityPrivateFlagsMessage is the invalid-input text for a going-private +// flag passed with the shared target. +const visibilityPrivateFlagsMessage = "--metadata-remote, --delete-shared-branch and --remove-shared-files apply only when switching to private" + +// SetVisibilityOptions carries the target and the two-pass authorization the +// CLI resolves. Authorized is true only via --yes or an interactive confirmed +// preview; ExpectedSource is the composite pin the preview showed ("" on the +// preview pass). The three going-private options are refused with Target +// shared. +type SetVisibilityOptions struct { + Target string // "shared" | "private" + Authorized bool + ExpectedSource string // the preview's pin ("" on the preview pass) + MetadataRemote string // going private only + DeleteSharedBranch bool // going private only + RemoveSharedFiles bool // going private only +} + +// VisibilityPhase is one phase row: its name, its status (done, pending, +// applied, kept, skipped), and the preview detail. +type VisibilityPhase struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` +} + +// VisibilityCommit is one integration-branch commit the switch plans or made: +// its fixed subject, the branch it lands on, the paths it holds, the commit id +// once made, and its status (planned, committed, failed). +type VisibilityCommit struct { + Subject string `json:"subject"` + Branch string `json:"branch"` + Paths []string `json:"paths"` + Commit string `json:"commit,omitempty"` + Status string `json:"status"` +} + +// RepositorySetVisibilityResult is the protocol-v1 document `repository +// set-visibility` returns. RepositoryState is confirmation-required on a +// preview, else the mode the repository is in after the run (or the classified +// state of a refusal). SourceRevision is the composite pin. +type RepositorySetVisibilityResult struct { + Envelope + RepositoryState string `json:"repository_state"` + Current string `json:"current"` + Target string `json:"target"` + SourceRevision string `json:"source_revision"` + Phases []VisibilityPhase `json:"phases"` + Commits []VisibilityCommit `json:"commits,omitempty"` + Warnings []string `json:"warnings,omitempty"` + PendingLocal []string `json:"pending_local,omitempty"` + Lessons string `json:"lessons,omitempty"` + BackupRemote string `json:"backup_remote,omitempty"` + Findings []reposetup.Finding `json:"findings,omitempty"` + human string +} + +// HumanText renders the human summary. +func (r RepositorySetVisibilityResult) HumanText() string { + if r.human != "" { + return r.human + } + return fmt.Sprintf("%s: %s (%s)", r.Operation, r.Result, r.RepositoryState) +} + +// SourceRev exposes the composite pin a preview showed, so the CLI's +// interactive confirm flow re-invokes pinned to exactly that state. +func (r RepositorySetVisibilityResult) SourceRev() string { return r.SourceRevision } + +// ConfirmationRequired reports whether this result is a preview awaiting --yes. +func (r RepositorySetVisibilityResult) ConfirmationRequired() bool { + return r.RepositoryState == visibilityConfirmationRequiredState +} + +// visibilityStep is one phase of a switch: done is read from state, detail is +// the preview line, commits marks a phase that makes an integration-branch +// commit, and run executes it. +type visibilityStep struct { + name string + done bool // read from state + detail string // preview line + commits bool // makes an integration-branch commit + run func(ctx context.Context, x *visibilityRun) error +} + +// visibilityRun is what an executing phase reads and reports into. An +// executor sets stop to end the run after its phase (recorded applied, its +// afterVisibilityPhase seam fired) with that error, leaving the later phases +// pending for a re-run: the run never reports applied while the switch is +// incomplete. +type visibilityRun struct { + d SetupDeps + o SetVisibilityOptions + st visibilityState + res *RepositorySetVisibilityResult + stop error +} + +// markPhase sets the status (and, when non-empty, the detail) of the named +// phase row, so an executor can report kept or skipped instead of applied. +func (x *visibilityRun) markPhase(name, status, detail string) { + for i := range x.res.Phases { + if x.res.Phases[i].Name != name { + continue + } + x.res.Phases[i].Status = status + if detail != "" { + x.res.Phases[i].Detail = detail + } + return + } +} + +// visibilityRefusal is an executor's refusal a human resolves: it maps to +// invalid-state with its text, never to an external failure. +type visibilityRefusal struct { + state reposetup.State + msg string +} + +func (e *visibilityRefusal) Error() string { return e.msg } + +// visibilityInternal is a defect-shaped stop (a phase with no executor). +type visibilityInternal struct{ err error } + +func (e *visibilityInternal) Error() string { return e.err.Error() } +func (e *visibilityInternal) Unwrap() error { return e.err } + +// RunRepositorySetVisibility moves an existing repository between shared and +// private visibility under the two-pass authorization model. +func RunRepositorySetVisibility(ctx context.Context, d SetupDeps, o SetVisibilityOptions) RepositorySetVisibilityResult { + // 1. Input, before any read. + if msg := validateSetVisibilityInput(o); msg != "" { + return visibilityInvalidInput(o.Target, msg) + } + + // 2. Gather. + facts, sc, err := GatherSetupFacts(ctx, d, true) + if err != nil { + return visibilityFromMigrateResult(migrateGatherFailure(err), o.Target) + } + + // 3. Set up? + if r := visibilitySetupRefusal(ctx, d.Git, facts, sc, o.Target); r != nil { + return *r + } + + // 4. Debris, then the state probe. + debris := sweepSetupDebris(ctx, d.Git, sc.repo) + st, err := probeVisibility(ctx, d, sc, facts) + if err != nil { + return visibilityExternalFailure(o.Target, "", reposetup.StateUnknown, "reading the repository's visibility state", err) + } + current := string(st.current) + + // 5. Live runs and live gates, over both existing state folders. + lines, err := visibilityLiveRunLines(st) + if err != nil { + return visibilityExternalFailure(o.Target, current, reposetup.StateUnknown, "checking for live runs", err) + } + if len(lines) > 0 { + return visibilityRefusalResult(o.Target, current, reposetup.StateNeedsReview, + "a run or gate is still live in this repository; settle each one, then re-run:\n "+strings.Join(lines, "\n ")) + } + + // 6. Metadata sync. + if msg, err := visibilityMetadataSyncRefusal(ctx, d.Git, st); err != nil { + return visibilityExternalFailure(o.Target, current, reposetup.StateUnknown, "checking the metadata worktree", err) + } else if msg != "" { + return visibilityRefusalResult(o.Target, current, reposetup.StateNeedsReview, msg) + } + + // Going shared, the commit phase is complete only when every dispatch + // surface agent_harnesses authorizes is already settled on disk, which is + // decidable only once the repository reads shared. + if o.Target == string(layout.Shared) && st.current == layout.Shared { + if st.sharedSurfacesSettled, err = repoSurfacesSettled(ctx, d.Git, st.primary); err != nil { + return visibilityExternalFailure(o.Target, current, reposetup.StateUnknown, "reading the dispatch instructions", err) + } + } + + // 7. Plan; refuse the user's own edits to a path a pending commit holds. + steps := planVisibility(st, o) + commitPlan, err := planVisibilityCommit(ctx, d.Git, st, o, steps) + if err != nil { + var ref *visibilityRefusal + if errors.As(err, &ref) { + return visibilityRefusalResult(o.Target, current, ref.state, ref.msg) + } + return visibilityExternalFailure(o.Target, current, reposetup.StateUnknown, "planning the integration-branch commit", err) + } + if visibilityCommitsPending(steps) { + conflicts, err := commitPathConflicts(ctx, d.Git, st.sc.repo, st.common, commitPlan.guard) + if err != nil { + return visibilityExternalFailure(o.Target, current, reposetup.StateUnknown, "checking the integration-branch paths", err) + } + if len(conflicts) > 0 { + return visibilityRefusalResult(o.Target, current, reposetup.StateNeedsReview, + "commit or set aside these edits, then re-run:\n "+strings.Join(conflicts, "\n ")) + } + } + + // 8. Pin and warnings. + pin := visibilityPin(st) + res := RepositorySetVisibilityResult{ + Current: current, + Target: o.Target, + SourceRevision: pin, + Phases: visibilityPhaseRows(steps), + Commits: visibilityPlannedCommits(st, o, steps, commitPlan), + Warnings: visibilityWarnings(ctx, d, st, o), + PendingLocal: debris.pending(), + } + + // 9. Nothing pending, or a preview. + if !visibilityAnyPending(steps) { + res.Envelope = NewEnvelope(OperationRepositorySetVisibility, ResultNoOp) + res.RepositoryState = current + res.Commits = nil + res.human = fmt.Sprintf("repository set-visibility: already %s; nothing to do", current) + return res + } + if !o.Authorized { + res.Envelope = NewEnvelope(OperationRepositorySetVisibility, ResultInvalidState) + res.RepositoryState = visibilityConfirmationRequiredState + res.human = visibilityPreviewText(st, o, steps, res) + + "\nconfirmation required: re-run with --yes to authorize this switch" + return res + } + + // 10. Authorized: re-prove the pin, then run every pending phase. + if migrateSourceMoved(o.ExpectedSource, pin) { + res.Envelope = NewEnvelope(OperationRepositorySetVisibility, ResultContended) + res.RepositoryState = current + res.SourceRevision = o.ExpectedSource + res.human = fmt.Sprintf("set-visibility contended: the repository moved since the preview (pinned %s, now %s); re-run to preview the new state", + o.ExpectedSource, pin) + return res + } + x := &visibilityRun{d: d, o: o, st: st, res: &res} + if err := runVisibilitySteps(ctx, x, steps); err != nil { + return visibilityStopped(res, err) + } + res.Envelope = NewEnvelope(OperationRepositorySetVisibility, ResultApplied) + mode, derr := layout.Detect(st.common) + if derr != nil { + res.RepositoryState = string(reposetup.StateUnknown) + } else { + res.RepositoryState = string(mode) + } + if mode == layout.Shared && o.Target == string(layout.Shared) && res.BackupRemote == "" { + // A resumed run whose dckt remote was already removed still names the + // backup it kept, when that bare repository is on disk. + if url := visibilityBackupURL(st); url != "" { + if ok, _ := isDirAt(url); ok { + res.BackupRemote = url + } + } + } + res.human = visibilityAppliedText(res) + return res +} + +// validateSetVisibilityInput is the pre-read input check; "" is valid. +func validateSetVisibilityInput(o SetVisibilityOptions) string { + switch o.Target { + case string(layout.Private): + if _, err := absMetadataRemote(o.MetadataRemote); err != nil { + return fmt.Sprintf("--metadata-remote %q cannot be made absolute: %v", o.MetadataRemote, err) + } + return "" + case string(layout.Shared): + if o.MetadataRemote != "" || o.DeleteSharedBranch || o.RemoveSharedFiles { + return visibilityPrivateFlagsMessage + } + return "" + default: + return fmt.Sprintf("the target must be shared or private, not %q", o.Target) + } +} + +// visibilitySetupRefusal refuses a repository that is not set up: an unknown +// required probe, a live planning surface (migrate first), or neither a +// metadata branch on origin nor a configured dckt remote (init first). +func visibilitySetupRefusal(ctx context.Context, git *gitcli.Client, facts reposetup.Facts, sc setupContext, target string) *RepositorySetVisibilityResult { + var unknown []string + if facts.RemoteConfigured == reposetup.PresenceUnknown { + unknown = append(unknown, "remote-configured") + } + if facts.RemoteDefaultBranch.Presence == reposetup.PresenceUnknown { + unknown = append(unknown, "remote-default-branch") + } + if facts.RemoteIntegration.Presence == reposetup.PresenceUnknown { + unknown = append(unknown, "remote-integration-branch") + } + if facts.LiveSurface == reposetup.PresenceUnknown { + unknown = append(unknown, "live-surface") + } + origin, oerr := git.ProbeRemoteBranch(ctx, sc.repo, originRemote, metadataRef(layout.SharedLayout(sc.repo.CommonDir, sc.repo.PrimaryWorktree))) + if oerr != nil { + unknown = append(unknown, "origin-metadata-branch") + } + _, uerr := git.RemoteURL(ctx, sc.repo, gitcli.RemoteName(layout.PrivateName)) + dcktConfigured := uerr == nil + if uerr != nil && !isRemoteUnconfigured(uerr) { + unknown = append(unknown, "dckt-remote") + } + // A second clone whose origin branch is gone and whose dckt remote is not + // configured yet is still set up when another clone on this machine + // published the history to the default store. An origin URL that names no + // store means no store can exist; any other failure to locate the store + // (origin's URL or the data home unreadable) leaves it unknown. + storeHeld := false + if oerr == nil && origin.State != gitcli.RemoteRefFound && uerr != nil && isRemoteUnconfigured(uerr) { + priv, perr := privateLayoutOf(ctx, git, sc.repo) + switch { + case perr == nil: + ref, serr := defaultStoreTip(ctx, git, priv) + if serr != nil { + unknown = append(unknown, "private-store") + } + storeHeld = ref.State == gitcli.RemoteRefFound + case !originNamesNoStore(ctx, git, sc.repo): + unknown = append(unknown, "private-store") + } + } + current := string(sc.layout.Mode) + if len(unknown) > 0 { + r := visibilityRefusalResult(target, current, reposetup.StateUnknown, + "a required repository probe could not be resolved ("+strings.Join(unknown, ", ")+"); run `docket repository check` after ensuring the remote is configured and reachable") + return &r + } + if facts.LiveSurface == reposetup.PresencePresent { + r := visibilityRefusalResult(target, current, reposetup.StateLegacy, + "the integration branch still carries a live planning surface; run `docket repository migrate` first") + return &r + } + if origin.State != gitcli.RemoteRefFound && !dcktConfigured && !storeHeld { + r := visibilityRefusalResult(target, current, reposetup.StateFresh, + "the repository has no docket metadata branch on origin and no dckt remote; run `docket repository init` first") + return &r + } + return nil +} + +// originNamesNoStore reports whether origin's URL reads but names no store +// (layout.OwnerRepo cannot derive one from it). +func originNamesNoStore(ctx context.Context, git *gitcli.Client, repo gitcli.Repository) bool { + url, err := git.RemoteURL(ctx, repo, originRemote) + if err != nil { + return false + } + _, err = layout.OwnerRepo(url) + return err != nil +} + +// planVisibility returns the target direction's phases. +func planVisibility(st visibilityState, o SetVisibilityOptions) []visibilityStep { + if o.Target == string(layout.Private) { + return planToPrivate(st, o) + } + return planToShared(st, o) +} + +// planToPrivate is the going-private phase list, each with its done predicate +// read from state; a step without an executor stops an authorized run. +func planToPrivate(st visibilityState, o SetVisibilityOptions) []visibilityStep { + wantURL := st.private.DefaultBareRemote + if st.dcktURL != "" { + wantURL = st.dcktURL + } + if o.MetadataRemote != "" { + if abs, err := absMetadataRemote(o.MetadataRemote); err == nil { + wantURL = abs + } + } + sourceName := "the dckt branch" + if st.originDocket.State == gitcli.RemoteRefFound { + sourceName = "origin's docket branch" + } + // Once the state folder has moved, the publish (which precedes it) is + // behind the repository: origin's docket branch is a teammate's from then + // on, so a later run neither refuses on its divergence nor pulls its + // writes into the dckt branch. + passedPublish := st.privateStateDir && !st.sharedStateDir && + st.dcktURL != "" && st.dcktURL == wantURL && st.bareDckt.State == gitcli.RemoteRefFound + steps := []visibilityStep{ + { + name: "metadata-remote", + done: st.dcktURL != "" && st.dcktURL == wantURL, + detail: "point the dckt remote at " + wantURL, + run: privateMetadataRemotePhase, + }, + { + name: "publish", + // A clone that has not configured its dckt remote yet reads the + // default store directly: another clone on this machine may already + // have published the history there. + done: passedPublish || st.bareHoldsOrigin || (st.dcktURL == "" && wantURL == st.private.DefaultBareRemote && st.storeHoldsOrigin), + detail: "push the identical metadata history of " + sourceName + " to the dckt branch at " + wantURL, + run: privatePublishPhase, + }, + { + name: "config", + done: !st.localConfig && st.pendingConfig != "", + detail: "fold the configuration into " + layout.PrivateConfigDisplay + " (from " + st.foldSource + ")", + run: privateConfigPhase, + }, + { + name: "state-folder", + done: st.privateStateDir && !st.sharedStateDir, + detail: "rename " + st.shared.StateDir + " to " + st.private.StateDir, + run: privateStateFolderPhase, + }, + { + name: "ignore", + done: st.excludeBlock, + detail: "add the managed block to .git/info/exclude", + run: privateIgnorePhase, + }, + { + name: "metadata-worktree", + done: st.privateCheckoutReady && !st.sharedCheckoutRegistered && st.localDocket == "", + detail: "move the metadata checkout from " + st.shared.MetadataWorktree + " to " + st.private.MetadataWorktree, + run: privateMetadataWorktreePhase, + }, + { + name: "instructions", + done: st.privateStateDir && !st.recordWorkingTree && (!st.facts.SurfacesAuthorized || st.privateInstructions), + detail: "install the dispatch instructions into " + layout.PrivateInstructionsDisplay, + run: privateInstructionsPhase, + }, + } + if o.DeleteSharedBranch { + steps = append(steps, visibilityStep{ + name: "delete-shared-branch", + done: st.originDocket.State != gitcli.RemoteRefFound, + detail: "delete origin's docket branch once the dckt branch holds the same tip", + run: privateDeleteSharedBranchPhase, + }) + } + // A removal commit already journaled resumes even when the re-run omits + // the flag: its edits are in the working tree, waiting for the commit. + if o.RemoveSharedFiles || (st.journalPresent && st.journal.Subject == visibilityRemoveSubject) { + steps = append(steps, visibilityStep{ + name: "remove-shared-files", + done: !st.journalPresent && !st.headSharedFiles, + detail: "remove .docket.yml, the .gitignore block, and the dispatch instructions in one local commit", + commits: true, + run: privateRemoveSharedFilesPhase, + }) + } + steps = append(steps, visibilityStep{ + name: "align-visibility", + done: st.privateVisibilityAligned, + detail: "set visibility: private in " + layout.PrivateConfigDisplay, + run: privateAlignVisibilityPhase, + }) + return steps +} + +// planToShared is the going-shared phase list, each with its done predicate +// read from state; a step without an executor stops an authorized run. +func planToShared(st visibilityState, _ SetVisibilityOptions) []visibilityStep { + backup := visibilityBackupURL(st) + steps := []visibilityStep{ + { + name: "identity-keys", + done: st.identityKeysAligned, + detail: "commit the repository identity keys to .docket.yml and get that commit onto origin's " + st.sc.defaultBranch + " first", + commits: !st.identityKeysAligned, + run: sharedIdentityKeysPhase, + }, + { + name: "publish", + done: st.originHoldsBare, + detail: "push the identical metadata history of the dckt branch to origin's docket branch", + run: sharedPublishPhase, + }, + { + name: "instructions", + done: !st.privateInstructions && !st.recordGitDir, + detail: "retire " + layout.PrivateInstructionsDisplay + " (its promoted lessons are reported)", + run: sharedInstructionsPhase, + }, + { + name: "config-committed", + done: st.sharedConfigWritten, + detail: "write .docket.yml from the private configuration", + run: sharedConfigCommittedPhase, + }, + { + name: "state-folder", + done: st.sharedStateDir && !st.privateStateDir, + detail: "rename " + st.private.StateDir + " to " + st.shared.StateDir, + run: sharedStateFolderPhase, + }, + { + name: "config-local", + done: st.sharedStateDir && !st.privateStateDir && st.pendingConfig == "", + detail: "restore the clone-local keys to .docket.local.yml and retire the private configuration", + run: sharedConfigLocalPhase, + }, + { + name: "ignore", + done: !st.excludeBlockPresent && st.worktreeGitignoreValid, + detail: "move the managed ignore block from .git/info/exclude to .gitignore", + run: sharedIgnorePhase, + }, + { + name: "metadata-worktree", + done: st.sharedCheckoutReady && !st.privateCheckoutRegistered && st.localDckt == "" && st.dcktURL == "", + detail: "move the metadata checkout to " + st.shared.MetadataWorktree + " and remove the dckt remote", + run: sharedMetadataWorktreePhase, + }, + { + name: "commit", + done: !st.journalPresent && st.headGitignoreValid && !st.commitPathsDirty && st.current == layout.Shared && st.sharedSurfacesSettled, + detail: "commit .docket.yml, .gitignore, and the dispatch instructions in one local commit", + commits: true, + run: sharedCommitPhase, + }, + { + name: "backup", + done: true, + detail: "the bare repository " + backup + " is kept as a backup", + }, + { + name: "align-visibility", + done: st.sharedVisibilityAligned, + detail: "set visibility: shared in .docket.local.yml", + run: sharedAlignVisibilityPhase, + }, + } + // A repository that is already shared, with no switch under way, only + // aligns its clone-local visibility value: every other phase is settled, + // so the user's own edits to the committed paths are neither refused nor + // swept into a commit. + if !sharedSwitchUnderway(st) { + for i := range steps { + if steps[i].name != "align-visibility" { + steps[i].done = true + } + } + } + return steps +} + +// sharedSwitchUnderway reports whether a going-shared switch has anything left +// to move: the repository is not yet shared, or a trace of the private layout +// or of an interrupted run remains (the private state folder, a waiting private +// config, the dckt remote, branch, or checkout, the exclude block, the private +// instructions file or ownership-record entries under .git/, or the switch +// journal, which an authorized going-shared run holds from the first phase +// that retires the private layout until its commit lands). +func sharedSwitchUnderway(st visibilityState) bool { + return st.current != layout.Shared || st.privateStateDir || st.pendingConfig != "" || + st.dcktURL != "" || st.localDckt != "" || st.privateCheckoutRegistered || + st.excludeBlockPresent || st.privateInstructions || st.recordGitDir || st.journalPresent +} + +// sharedRetiringPhases are the going-shared phases that retire the private +// layout, up to and including the commit that closes the add journal. +var sharedRetiringPhases = map[string]bool{ + "instructions": true, "config-committed": true, "state-folder": true, "config-local": true, + "ignore": true, "metadata-worktree": true, "commit": true, +} + +// holdSharedSwitchJournal opens the add journal before a going-shared phase +// retires any of the private layout, so a run interrupted after its last +// private trace is gone still reads as under way and resumes its commit. It +// opens nothing for the identity-keys and publish phases (a refusal there +// leaves the repository as it was) or after the commit, and keeps an existing +// journal as it is. +func holdSharedSwitchJournal(x *visibilityRun, step string) error { + if x.o.Target != string(layout.Shared) || !sharedRetiringPhases[step] || !sharedSwitchUnderway(x.st) { + return nil + } + if _, ok, err := loadSwitchJournal(x.st.common); err != nil || ok { + return err + } + return saveSwitchJournal(x.st.common, switchJournal{Subject: visibilityAddSubject, Paths: map[string]string{}}) +} + +// runVisibilitySteps runs each pending step in order, records it applied +// (unless its executor reported otherwise), then fires the afterVisibilityPhase +// seam. An error stops the run, keeping the phases so far; a pending step with +// no executor is an internal error. +func runVisibilitySteps(ctx context.Context, x *visibilityRun, steps []visibilityStep) error { + for _, s := range steps { + if s.done { + continue + } + if s.run == nil { + return &visibilityInternal{err: fmt.Errorf("phase %s has no executor", s.name)} + } + if err := holdSharedSwitchJournal(x, s.name); err != nil { + return err + } + if err := s.run(ctx, x); err != nil { + return err + } + for i := range x.res.Phases { + if x.res.Phases[i].Name == s.name && x.res.Phases[i].Status == visibilityPhasePending { + x.res.Phases[i].Status = visibilityPhaseApplied + } + } + hook := x.d.hooks.afterVisibilityPhase + name := s.name + if err := fire(func() error { + if hook == nil { + return nil + } + return hook(name) + }); err != nil { + return err + } + if x.stop != nil { + return x.stop + } + } + return nil +} + +// visibilityAnyPending reports whether any step is still to run. +func visibilityAnyPending(steps []visibilityStep) bool { + for _, s := range steps { + if !s.done { + return true + } + } + return false +} + +// visibilityCommitsPending reports whether a pending step makes a commit. +func visibilityCommitsPending(steps []visibilityStep) bool { + for _, s := range steps { + if !s.done && s.commits { + return true + } + } + return false +} + +// visibilityPhaseRows projects the steps into their result rows. +func visibilityPhaseRows(steps []visibilityStep) []VisibilityPhase { + rows := make([]VisibilityPhase, 0, len(steps)) + for _, s := range steps { + status := visibilityPhasePending + if s.done { + status = visibilityPhaseDone + } + rows = append(rows, VisibilityPhase{Name: s.name, Status: status, Detail: s.detail}) + } + return rows +} + +// visibilityPlannedCommits is the planned commit row when a pending step +// commits: the fixed subject, the primary checkout's branch, and the paths the +// commit changes with what happens to each (planVisibilityCommit). +func visibilityPlannedCommits(st visibilityState, o SetVisibilityOptions, steps []visibilityStep, plan visibilityCommitPlan) []VisibilityCommit { + if !visibilityCommitsPending(steps) { + return nil + } + subject := visibilityAddSubject + if o.Target == string(layout.Private) { + subject = visibilityRemoveSubject + } + return []VisibilityCommit{{Subject: subject, Branch: st.primaryBranch, Paths: plan.rows, Status: visibilityCommitPlanned}} +} + +// visibilityPreviewText renders the confirmation preview. +func visibilityPreviewText(st visibilityState, o SetVisibilityOptions, steps []visibilityStep, res RepositorySetVisibilityResult) string { + var b strings.Builder + fmt.Fprintf(&b, "docket repository set-visibility %s — plan\n", o.Target) + fmt.Fprintf(&b, " repository: %s\n", st.primary) + fmt.Fprintf(&b, " current: %s\n", st.current) + fmt.Fprintf(&b, " target: %s\n", o.Target) + fmt.Fprintf(&b, " pinned: %s\n", res.SourceRevision) + fmt.Fprintf(&b, " phases:\n") + for _, s := range steps { + status := visibilityPhasePending + if s.done { + status = visibilityPhaseDone + } + fmt.Fprintf(&b, " [%s] %s: %s\n", status, s.name, s.detail) + } + for _, c := range res.Commits { + fmt.Fprintf(&b, " commit:\n") + fmt.Fprintf(&b, " branch: %s\n", c.Branch) + fmt.Fprintf(&b, " message: %s\n", c.Subject) + fmt.Fprintf(&b, " paths:\n") + for _, p := range c.Paths { + fmt.Fprintf(&b, " %s\n", p) + } + } + writeVisibilityWarnings(&b, res.Warnings) + writeVisibilityPending(&b, res.PendingLocal) + return strings.TrimRight(b.String(), "\n") +} + +func writeVisibilityWarnings(b *strings.Builder, warnings []string) { + if len(warnings) == 0 { + return + } + fmt.Fprintf(b, " warnings:\n") + for _, w := range warnings { + fmt.Fprintf(b, " - %s\n", w) + } +} + +func writeVisibilityPending(b *strings.Builder, pending []string) { + if len(pending) == 0 { + return + } + fmt.Fprintf(b, " pending:\n") + for _, p := range pending { + fmt.Fprintf(b, " - %s\n", p) + } +} + +// visibilityAppliedText renders a completed run. +func visibilityAppliedText(res RepositorySetVisibilityResult) string { + var b strings.Builder + fmt.Fprintf(&b, "repository set-visibility: %s -> %s (%s)\n", res.Current, res.Target, res.RepositoryState) + for _, p := range res.Phases { + fmt.Fprintf(&b, " [%s] %s\n", p.Status, p.Name) + } + for _, c := range res.Commits { + fmt.Fprintf(&b, " commit %s on %s: %s (%s)\n", c.Commit, c.Branch, c.Subject, c.Status) + } + if res.BackupRemote != "" { + fmt.Fprintf(&b, " backup remote kept: %s\n", res.BackupRemote) + } + if res.Lessons != "" { + fmt.Fprintf(&b, " %s\n", lessonsText(res.Lessons)) + } + writeVisibilityWarnings(&b, res.Warnings) + writeVisibilityPending(&b, res.PendingLocal) + return strings.TrimRight(b.String(), "\n") +} + +// visibilityStopped maps a stopped run to its result, keeping the phases so far. +func visibilityStopped(res RepositorySetVisibilityResult, err error) RepositorySetVisibilityResult { + var ref *visibilityRefusal + var internal *visibilityInternal + switch { + case errors.As(err, &ref): + res.Envelope = NewEnvelope(OperationRepositorySetVisibility, ResultInvalidState) + res.RepositoryState = string(ref.state) + res.human = fmt.Sprintf("%s: %s (%s): %s", OperationRepositorySetVisibility, ResultInvalidState, ref.state, ref.msg) + case errors.As(err, &internal): + res.Envelope = NewEnvelope(OperationRepositorySetVisibility, ResultInternalError) + res.RepositoryState = string(reposetup.StateUnknown) + res.human = fmt.Sprintf("%s: %s: %s", OperationRepositorySetVisibility, ResultInternalError, internal.Error()) + default: + res.Envelope = NewEnvelope(OperationRepositorySetVisibility, ResultExternalFailed) + res.RepositoryState = string(reposetup.StateUnknown) + res.human = fmt.Sprintf("%s: %s: %s", OperationRepositorySetVisibility, ResultExternalFailed, err.Error()) + } + var b strings.Builder + b.WriteString(res.human + "\n") + for _, p := range res.Phases { + fmt.Fprintf(&b, " [%s] %s\n", p.Status, p.Name) + } + for _, c := range res.Commits { + fmt.Fprintf(&b, " commit %s on %s: %s (%s)\n", c.Commit, c.Branch, c.Subject, c.Status) + } + if res.Lessons != "" { + // The private instructions file is already gone: report its lessons + // even though a later phase stopped the run. + fmt.Fprintf(&b, " %s\n", lessonsText(res.Lessons)) + } + writeVisibilityPending(&b, res.PendingLocal) + res.human = strings.TrimRight(b.String(), "\n") + return res +} + +// --- result constructors ----------------------------------------------------- + +func newVisibilityResult(result Result, target, current string, out RepositorySetVisibilityResult) RepositorySetVisibilityResult { + out.Envelope = NewEnvelope(OperationRepositorySetVisibility, result) + out.Target = target + out.Current = current + return out +} + +// visibilityInvalidInput refuses a bad target or a misplaced flag. +func visibilityInvalidInput(target, msg string) RepositorySetVisibilityResult { + out := newVisibilityResult(ResultInvalidInput, target, "", RepositorySetVisibilityResult{}) + out.human = fmt.Sprintf("%s: %s: %s", OperationRepositorySetVisibility, ResultInvalidInput, msg) + return out +} + +// visibilityRefusalResult builds an invalid-state refusal naming its remedy. +func visibilityRefusalResult(target, current string, state reposetup.State, remedy string) RepositorySetVisibilityResult { + out := newVisibilityResult(ResultInvalidState, target, current, RepositorySetVisibilityResult{RepositoryState: string(state)}) + out.human = fmt.Sprintf("%s: %s (%s): %s", OperationRepositorySetVisibility, ResultInvalidState, state, remedy) + return out +} + +// visibilityExternalFailure builds an external-failed result naming the stage. +func visibilityExternalFailure(target, current string, state reposetup.State, stage string, err error) RepositorySetVisibilityResult { + out := newVisibilityResult(ResultExternalFailed, target, current, RepositorySetVisibilityResult{RepositoryState: string(state)}) + out.human = fmt.Sprintf("%s: %s while %s: %s", OperationRepositorySetVisibility, ResultExternalFailed, stage, err.Error()) + return out +} + +// visibilityFromMigrateResult re-stamps a shared gather refusal as a +// repository.set-visibility result, keeping its result, state, findings, and +// remedy text. +func visibilityFromMigrateResult(m RepositoryMigrateResult, target string) RepositorySetVisibilityResult { + out := newVisibilityResult(m.Result, target, "", RepositorySetVisibilityResult{ + RepositoryState: m.RepositoryState, + Findings: m.Findings, + }) + out.human = strings.Replace(m.HumanText(), OperationRepositoryMigrate, OperationRepositorySetVisibility, 1) + return out +} diff --git a/internal/app/repository_set_visibility_commit.go b/internal/app/repository_set_visibility_commit.go new file mode 100644 index 000000000..2e8d118db --- /dev/null +++ b/internal/app/repository_set_visibility_commit.go @@ -0,0 +1,615 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/danielhanold/docket/internal/config" + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/install" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/reposeed" + "github.com/danielhanold/docket/internal/reposetup" +) + +// The two fixed subjects of every integration-branch commit a visibility switch +// makes. They carry no body, no trailer, and no mode vocabulary +// (TestVisibilitySubjectsCarryNoModeVocabulary). +const ( + visibilityRemoveSubject = "Remove docket configurations from repository" + visibilityAddSubject = "Add docket configurations to repository" +) + +// visibilityCommitPaths are the integration-branch paths a switch may commit: +// the committed config, the managed .gitignore block, and every dispatch +// surface reposeed plans for a shared repository. +var visibilityCommitPaths = append([]string{docketYMLRel, gitignoreRel}, reposeed.SurfacePaths()...) + +// switchJournalFile is the write-ahead journal, kept in layout.StateDirOf(common) +// so it travels with a state-folder rename. +const switchJournalFile = "visibility-switch.json" + +// digestDeleted is the journal digest of a path the switch removed. +const digestDeleted = "deleted" + +// switchJournal: path -> digest of what the switch wrote ("deleted" for a removal). +type switchJournal struct { + Subject string `json:"subject"` + Paths map[string]string `json:"paths"` +} + +func switchJournalPath(commonDir string) string { + return filepath.Join(layout.StateDirOf(commonDir), switchJournalFile) +} + +func validVisibilitySubject(s string) bool { + return s == visibilityRemoveSubject || s == visibilityAddSubject +} + +func contentDigest(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// pathDigest is "sha256:" of a regular file, "link:" of a symlink, +// and "deleted" when abs is absent; anything else (a directory, a device) errors. +func pathDigest(abs string) (string, error) { + fi, err := os.Lstat(abs) + switch { + case errors.Is(err, fs.ErrNotExist): + return digestDeleted, nil + case err != nil: + return "", err + } + switch { + case fi.Mode()&fs.ModeSymlink != 0: + target, err := os.Readlink(abs) + if err != nil { + return "", err + } + return "link:" + target, nil + case fi.Mode().IsRegular(): + b, err := os.ReadFile(abs) + if err != nil { + return "", err + } + return contentDigest(b), nil + default: + return "", fmt.Errorf("%s is neither a regular file nor a symlink", abs) + } +} + +// headPathDigest is pathDigest's counterpart for rel in commit: a symlink entry +// (mode 120000) is "link:", an absent entry "deleted". +func headPathDigest(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, commit gitcli.ObjectID, rel string) (string, error) { + src, err := git.OpenObjectSource(ctx, repo, gitcli.Revision{Commit: commit}) + if err != nil { + return "", err + } + res, err := src.ReadBlobs(ctx, []gitcli.RepoPath{gitcli.RepoPath(rel)}) + if err != nil { + return "", err + } + if len(res) != 1 { + return "", fmt.Errorf("reading %s at %s: expected one result, got %d", rel, commit, len(res)) + } + if !res[0].Found { + return digestDeleted, nil + } + if res[0].Blob.Mode == "120000" { + return "link:" + string(res[0].Blob.Bytes), nil + } + return contentDigest(res[0].Blob.Bytes), nil +} + +// loadSwitchJournal reads the journal; ok is false when none exists. A journal +// that does not decode, or names an unknown subject, is an error, never absence. +func loadSwitchJournal(commonDir string) (switchJournal, bool, error) { + p := switchJournalPath(commonDir) + b, err := os.ReadFile(p) + if errors.Is(err, fs.ErrNotExist) { + return switchJournal{}, false, nil + } + if err != nil { + return switchJournal{}, false, err + } + var j switchJournal + if err := json.Unmarshal(b, &j); err != nil { + return switchJournal{}, false, fmt.Errorf("visibility switch journal %s is unreadable: %w", p, err) + } + if !validVisibilitySubject(j.Subject) { + return switchJournal{}, false, fmt.Errorf("visibility switch journal %s names an unknown commit subject %q", p, j.Subject) + } + for rel := range j.Paths { + if err := validateSwitchRel(rel); err != nil { + return switchJournal{}, false, fmt.Errorf("visibility switch journal %s: %w", p, err) + } + } + if j.Paths == nil { + j.Paths = map[string]string{} + } + return j, true, nil +} + +// saveSwitchJournal writes the journal through a temp file beside it + rename. +func saveSwitchJournal(commonDir string, j switchJournal) error { + if !validVisibilitySubject(j.Subject) { + return fmt.Errorf("visibility switch journal: unknown commit subject %q", j.Subject) + } + b, err := json.MarshalIndent(j, "", " ") + if err != nil { + return err + } + return writeFileReplacing(switchJournalPath(commonDir), append(b, '\n'), 0o600) +} + +// clearSwitchJournal removes the journal; an absent one is already clear. +func clearSwitchJournal(commonDir string) error { + if err := os.Remove(switchJournalPath(commonDir)); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil +} + +// validateSwitchRel accepts only a clean, slash-separated path inside the checkout. +func validateSwitchRel(rel string) error { + if rel == "" || strings.HasPrefix(rel, "/") || strings.Contains(rel, "\\") || + path.Clean(rel) != rel || rel == "." || rel == ".." || strings.HasPrefix(rel, "../") { + return fmt.Errorf("invalid checkout-relative path %q", rel) + } + return nil +} + +// recordSwitchDigest journals rel -> digest under subject, refusing a journal +// that already belongs to the other subject and holds a path. A journal of the +// other subject holding no path (the marker an abandoned going-shared run +// opened) has no commit pending, so it is replaced. +func recordSwitchDigest(commonDir, subject, rel, digest string) error { + if !validVisibilitySubject(subject) { + return fmt.Errorf("unknown commit subject %q", subject) + } + if err := validateSwitchRel(rel); err != nil { + return err + } + j, ok, err := loadSwitchJournal(commonDir) + if err != nil { + return err + } + if ok && j.Subject != subject && len(j.Paths) > 0 { + return fmt.Errorf("a pending switch commit %q is journaled; it must complete before %q", j.Subject, subject) + } + if !ok || j.Subject != subject { + j = switchJournal{Subject: subject, Paths: map[string]string{}} + } + j.Paths[rel] = digest + return saveSwitchJournal(commonDir, j) +} + +// writeSwitchPath journals the digest of content (nil = delete) BEFORE writing +// / atomically (or removing it); subject must match an existing journal's. +func writeSwitchPath(commonDir, primary, subject, rel string, content []byte) error { + digest := digestDeleted + if content != nil { + digest = contentDigest(content) + } + if err := recordSwitchDigest(commonDir, subject, rel, digest); err != nil { + return err + } + abs := filepath.Join(primary, filepath.FromSlash(rel)) + if content == nil { + if err := os.Remove(abs); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil + } + mode := fs.FileMode(0o644) + if fi, err := os.Lstat(abs); err == nil && fi.Mode().IsRegular() { + mode = fi.Mode().Perm() + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return err + } + return writeFileReplacing(abs, content, mode) +} + +// journalSwitchPath records rel's current on-disk digest (after install wrote a surface). +func journalSwitchPath(commonDir, primary, subject, rel string) error { + if err := validateSwitchRel(rel); err != nil { + return err + } + digest, err := pathDigest(filepath.Join(primary, filepath.FromSlash(rel))) + if err != nil { + return err + } + return recordSwitchDigest(commonDir, subject, rel, digest) +} + +// unjournalSwitchPath drops rel from the journal; an absent journal or entry +// is already clear. +func unjournalSwitchPath(commonDir, rel string) error { + j, ok, err := loadSwitchJournal(commonDir) + if err != nil || !ok { + return err + } + if _, has := j.Paths[rel]; !has { + return nil + } + delete(j.Paths, rel) + return saveSwitchJournal(commonDir, j) +} + +// writeFileReplacing writes content to p through a same-directory temp file and +// a rename, creating p's state folder when it is missing. +func writeFileReplacing(p string, content []byte, mode fs.FileMode) error { + dir := filepath.Dir(p) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "."+filepath.Base(p)+".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op after a successful rename + if _, err := tmp.Write(content); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, p) +} + +// commitPathConflicts: one refusal line per path in ChangedPaths whose working-tree +// digest is not its journal digest (or is unjournaled), plus a detached HEAD. +// A probe error is returned. +func commitPathConflicts(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, commonDir string, paths []string) ([]string, error) { + primary := repo.PrimaryWorktree + st, err := git.WorktreeCheckoutState(ctx, primary) + if err != nil { + return nil, err + } + var out []string + if st.Detached { + out = append(out, fmt.Sprintf("the primary checkout %s has a detached HEAD; check out a branch to commit on", primary)) + } + changes, err := git.ChangedPaths(ctx, primary) + if err != nil { + return nil, err + } + changed := make(map[string]bool, len(changes)) + for _, c := range changes { + changed[string(c.Path)] = true + } + j, _, err := loadSwitchJournal(commonDir) + if err != nil { + return nil, err + } + for _, rel := range paths { + if !changed[rel] { + continue + } + want, journaled := j.Paths[rel] + if journaled { + got, err := pathDigest(filepath.Join(primary, filepath.FromSlash(rel))) + if err != nil { + return nil, err + } + if got == want { + continue + } + } + out = append(out, fmt.Sprintf("%s has uncommitted changes; commit or stash them first", rel)) + } + return out, nil +} + +type visibilityCommitOutcome struct { + Commit string // "" when nothing was committed + Paths []string + Complete bool // HEAD holds every journaled path +} + +// commitSwitchJournal commits the journaled paths whose HEAD digest differs from the +// journal, with its subject, via CommitOwnPaths, then clears the journal. When HEAD +// already holds them all (death between commit and cleanup) it commits nothing and +// clears. A commit failure leaves journal and edits in place. A journaled path +// whose working tree no longer matches the journal refuses: committing it would +// sweep someone else's edit into the switch's commit. +func commitSwitchJournal(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, commonDir string) (visibilityCommitOutcome, error) { + j, ok, err := loadSwitchJournal(commonDir) + if err != nil { + return visibilityCommitOutcome{}, err + } + if !ok { + return visibilityCommitOutcome{Complete: true}, nil + } + primary := repo.PrimaryWorktree + pending, err := pendingSwitchPaths(ctx, git, repo, j) + if err != nil { + return visibilityCommitOutcome{}, err + } + if len(pending) == 0 { + if err := clearSwitchJournal(commonDir); err != nil { + return visibilityCommitOutcome{}, err + } + return visibilityCommitOutcome{Complete: true}, nil + } + repoPaths := make([]gitcli.RepoPath, 0, len(pending)) + for _, rel := range pending { + got, err := pathDigest(filepath.Join(primary, filepath.FromSlash(rel))) + if err != nil { + return visibilityCommitOutcome{}, err + } + if got != j.Paths[rel] { + return visibilityCommitOutcome{}, fmt.Errorf("%s changed after the switch wrote it; commit or restore it, then re-run", rel) + } + repoPaths = append(repoPaths, gitcli.RepoPath(rel)) + } + head, err := git.CommitOwnPaths(ctx, primary, repoPaths, j.Subject) + if err != nil { + return visibilityCommitOutcome{}, fmt.Errorf("committing %s: %w; the edits stay in the working tree and a re-run retries the commit", strings.Join(pending, ", "), err) + } + out := visibilityCommitOutcome{Commit: string(head), Paths: pending} + left, err := pendingSwitchPaths(ctx, git, repo, j) + if err != nil { + return out, err + } + if len(left) > 0 { + return out, nil // a hook rewrote content: keep the journal for a re-run + } + if err := clearSwitchJournal(commonDir); err != nil { + return out, err + } + out.Complete = true + return out, nil +} + +// pendingSwitchPaths lists, sorted, the journaled paths whose HEAD digest +// differs from the journal's. +func pendingSwitchPaths(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, j switchJournal) ([]string, error) { + st, err := git.WorktreeCheckoutState(ctx, repo.PrimaryWorktree) + if err != nil { + return nil, err + } + rels := make([]string, 0, len(j.Paths)) + for rel := range j.Paths { + rels = append(rels, rel) + } + sort.Strings(rels) + var pending []string + for _, rel := range rels { + got, err := headPathDigest(ctx, git, repo, st.Head, rel) + if err != nil { + return nil, err + } + if got != j.Paths[rel] { + pending = append(pending, rel) + } + } + return pending, nil +} + +// visibilityCommitPlan is what the pending integration-branch commit touches: +// guard is every path the run writes or commits (the user's own edits to any of +// them refuse), rows are the paths the commit will actually change, each with +// what happens to it. +type visibilityCommitPlan struct { + guard []string + rows []string +} + +// planVisibilityCommit computes the pending commit's paths from the same +// sources its executors use: the removal targets of HEAD going private, and +// going shared the committed config, the managed .gitignore block, and the +// dispatch surfaces reposeed plans for the shared layout (the ones the managed +// block ignores excepted). With the identity-keys phase pending, the run +// commits .docket.yml alone and stops. +func planVisibilityCommit(ctx context.Context, git *gitcli.Client, st visibilityState, o SetVisibilityOptions, steps []visibilityStep) (visibilityCommitPlan, error) { + var plan visibilityCommitPlan + if !visibilityCommitsPending(steps) { + return plan, nil + } + if o.Target == string(layout.Private) { + if st.primaryHead == "" { + return plan, nil + } + targets, err := removalTargets(ctx, git, st.sc.repo, gitcli.ObjectID(st.primaryHead)) + if err != nil { + return plan, err + } + for _, t := range targets { + action := "write" + if t.content == nil { + action = "delete" + } + plan.guard = append(plan.guard, t.rel) + plan.rows = append(plan.rows, t.rel+" ("+action+")") + } + return plan, nil + } + + pending := map[string]bool{} + for _, s := range steps { + pending[s.name] = !s.done + } + // add records a path the run writes or commits; final is its content once + // the run is through (nil: it is absent), so it is a row iff that differs + // from HEAD. + add := func(rel string, final []byte, present bool) error { + plan.guard = append(plan.guard, rel) + want := digestDeleted + if present { + want = contentDigest(final) + } + have := digestDeleted + if st.primaryHead != "" { + var err error + if have, err = headPathDigest(ctx, git, st.sc.repo, gitcli.ObjectID(st.primaryHead), rel); err != nil { + return err + } + } + if want != have { + plan.rows = append(plan.rows, rel+" (write)") + } + return nil + } + + pc, err := readPendingPrivateConfig(st) + if err != nil { + return plan, err + } + _, ymlJournaled := st.journal.Paths[docketYMLRel] + ymlWritten := pc.path != "" && (pending["identity-keys"] || pending["config-committed"]) + if ymlWritten || ymlJournaled { + final, present, err := readOptionalFile(filepath.Join(st.primary, docketYMLRel)) + if err != nil { + return plan, err + } + if pc.path != "" { + if final, _, err = splitSharedConfig(st, pc); err != nil { + return plan, err + } + present = true + } + if err := add(docketYMLRel, final, present); err != nil { + return plan, err + } + } + if pending["identity-keys"] { + return plan, nil + } + + ignore, _, err := readOptionalFile(filepath.Join(st.primary, gitignoreRel)) + if err != nil { + return plan, err + } + _, ignoreJournaled := st.journal.Paths[gitignoreRel] + if pending["ignore"] { + ensured, changed, err := reposetup.EnsureGitignoreBlock(ignore) + if err != nil { + return plan, privateRefusal(reposetup.StateNeedsReview, gitignoreRel+": "+err.Error()+"; fix the markers by hand, then re-run") + } + if changed { + ignore, ignoreJournaled = ensured, true + } + } + if ignoreJournaled { + if err := add(gitignoreRel, ignore, true); err != nil { + return plan, err + } + } + + surfaces, err := plannedSharedSurfaces(ctx, git, st, pc) + if err != nil { + return plan, err + } + for _, t := range surfaces { + rel, err := filepath.Rel(st.primary, t.Path) + if err != nil { + return plan, err + } + rel = filepath.ToSlash(rel) + plan.guard = append(plan.guard, rel) + insp, err := install.InspectTarget(t, nil, nil) + if err != nil { + return plan, err + } + changed := insp.Disposition != install.DispositionNoop + if !changed { + disk, err := pathDigest(t.Path) + if err != nil { + return plan, err + } + head := digestDeleted + if st.primaryHead != "" { + if head, err = headPathDigest(ctx, git, st.sc.repo, gitcli.ObjectID(st.primaryHead), rel); err != nil { + return plan, err + } + } + changed = disk != head + } + if changed { + plan.rows = append(plan.rows, rel+" (write)") + } + } + return plan, nil +} + +// plannedSharedSurfaces is the dispatch surfaces the going-shared commit phase +// installs, minus the ones the managed .gitignore block ignores: planned from +// the split of the private config while one is pending, else as install's +// repository phase resolves them once the repository reads shared. No +// authorization plans nothing. +func plannedSharedSurfaces(ctx context.Context, git *gitcli.Client, st visibilityState, pc pendingPrivateConfig) ([]install.Target, error) { + runTracker, err := buildRunTracker() + if err != nil { + return nil, err + } + var targets []install.Target + if pc.path != "" { + committed, local, err := splitSharedConfig(st, pc) + if err != nil { + return nil, err + } + sources := []config.Source{{Layer: config.LayerRepository, Name: docketYMLRel, Data: committed}} + if local != nil { + sources = append(sources, config.Source{Layer: config.LayerRepositoryLocal, Name: localConfigRel, Data: local}) + } + snap, _, err := config.Resolve(sources, config.ResolveContext{DefaultBranch: st.sc.defaultBranch}) + if err != nil { + return nil, privateRefusal(reposetup.StateNeedsReview, "the configuration split from "+pc.path+" does not resolve: "+err.Error()+"; fix it by hand, then re-run") + } + ah := snap.Effective.AgentHarnesses + if !ah.Explicit || !isRepositoryLayer(ah.Provenance.Layer) { + return nil, nil + } + if targets, _, err = reposeed.Plan(reposeed.PlanInput{ + WorktreeRoot: st.primary, + Harnesses: ah.Value, + RunTracker: runTracker, + ClaudeMDState: classifyClaudeMD(st.primary), + }); err != nil { + return nil, err + } + } else if st.current == layout.Shared { + phase, _, _, err := ResolveRepoPhase(ctx, git, st.primary, nil, runTracker, nil, config.ResolveContext{DefaultBranch: "main"}) + if err != nil { + return nil, err + } + if phase == nil || !phase.Authorized { + return nil, nil + } + targets = phase.Targets + } + ignored := map[string]bool{} + for _, e := range reposetup.GitignoreEntries() { + ignored[e] = true + } + out := make([]install.Target, 0, len(targets)) + for _, t := range targets { + rel, err := filepath.Rel(st.primary, t.Path) + if err != nil { + return nil, err + } + if !ignored[filepath.ToSlash(rel)] { + out = append(out, t) + } + } + return out, nil +} diff --git a/internal/app/repository_set_visibility_commit_integration_test.go b/internal/app/repository_set_visibility_commit_integration_test.go new file mode 100644 index 000000000..183839330 --- /dev/null +++ b/internal/app/repository_set_visibility_commit_integration_test.go @@ -0,0 +1,212 @@ +//go:build integration + +package app + +import ( + "context" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/testsupport" +) + +// newSwitchCommitRepo builds a one-commit repository on main holding a tracked +// .docket.yml, .gitignore, and AGENTS.md. +func newSwitchCommitRepo(t *testing.T) (*gitcli.Client, gitcli.Repository) { + t.Helper() + requireRealGit(t) + git := newGitClient(t) + dir, err := filepath.EvalSymlinks(testsupport.TempDir(t)) + if err != nil { + t.Fatal(err) + } + runGit(t, dir, "init", "-q", "-b", "main") + gitIdentity(t, dir) + writeRepoFile(t, dir, ".docket.yml", "visibility: shared\n") + writeRepoFile(t, dir, ".gitignore", "/.docket/\n") + writeRepoFile(t, dir, "AGENTS.md", "# rules\n") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-q", "-m", "init") + return git, gitcli.Repository{PrimaryWorktree: dir, CommonDir: filepath.Join(dir, ".git")} +} + +func TestIntegrationRepoVisibilitySwitchCommitPathConflicts(t *testing.T) { + ctx := context.Background() + git, repo := newSwitchCommitRepo(t) + dir, common := repo.PrimaryWorktree, repo.CommonDir + + conflicts := func() []string { + t.Helper() + got, err := commitPathConflicts(ctx, git, repo, common, visibilityCommitPaths) + if err != nil { + t.Fatalf("commitPathConflicts: %v", err) + } + return got + } + + if got := conflicts(); len(got) != 0 { + t.Fatalf("clean checkout: want no conflicts, got %q", got) + } + + // A user's own edit to a commit path is a conflict. + writeRepoFile(t, dir, "AGENTS.md", "# rules\nmine\n") + got := conflicts() + if len(got) != 1 || !strings.Contains(got[0], "AGENTS.md") { + t.Fatalf("user edit: want one conflict naming AGENTS.md, got %q", got) + } + runGit(t, dir, "checkout", "--", "AGENTS.md") + + // The switch's own journaled write is not. + if err := writeSwitchPath(common, dir, visibilityAddSubject, ".docket.yml", []byte("visibility: private\n")); err != nil { + t.Fatal(err) + } + if err := writeSwitchPath(common, dir, visibilityAddSubject, ".gitignore", nil); err != nil { + t.Fatal(err) + } + if got := conflicts(); len(got) != 0 { + t.Fatalf("journaled writes: want no conflicts, got %q", got) + } + + // A hand edit on top of the journaled write is. + writeRepoFile(t, dir, ".docket.yml", "visibility: private\nextra: 1\n") + got = conflicts() + if len(got) != 1 || !strings.Contains(got[0], ".docket.yml") { + t.Fatalf("hand edit after journaled write: want one conflict naming .docket.yml, got %q", got) + } + + // A detached HEAD is a refusal of its own. + if err := clearSwitchJournal(common); err != nil { + t.Fatal(err) + } + runGit(t, dir, "checkout", "-q", "--", ".") + runGit(t, dir, "checkout", "-q", "--detach") + got = conflicts() + if len(got) != 1 || !strings.Contains(got[0], "detached HEAD") { + t.Fatalf("detached HEAD: want one detached-HEAD refusal, got %q", got) + } +} + +func TestIntegrationRepoVisibilitySwitchJournalCommitsOnlyJournaledPaths(t *testing.T) { + ctx := context.Background() + git, repo := newSwitchCommitRepo(t) + dir, common := repo.PrimaryWorktree, repo.CommonDir + + writeRepoFile(t, dir, "other.txt", "user work\n") + runGit(t, dir, "add", "other.txt") + if err := writeSwitchPath(common, dir, visibilityAddSubject, ".docket.yml", []byte("visibility: private\n")); err != nil { + t.Fatal(err) + } + if err := writeSwitchPath(common, dir, visibilityAddSubject, ".gitignore", nil); err != nil { + t.Fatal(err) + } + + out, err := commitSwitchJournal(ctx, git, repo, common) + if err != nil { + t.Fatalf("commitSwitchJournal: %v", err) + } + if out.Commit == "" || !out.Complete { + t.Fatalf("want a complete commit, got %+v", out) + } + if want := []string{".docket.yml", ".gitignore"}; strings.Join(out.Paths, ",") != strings.Join(want, ",") { + t.Fatalf("outcome paths = %q, want %q", out.Paths, want) + } + if head := runGit(t, dir, "rev-parse", "HEAD"); head != out.Commit { + t.Fatalf("HEAD %s, outcome commit %s", head, out.Commit) + } + if msg, _ := tryGit(dir, "log", "-1", "--format=%B"); msg != visibilityAddSubject+"\n\n" { + t.Fatalf("commit message = %q, want exactly the subject", msg) + } + names := strings.Split(runGit(t, dir, "show", "--name-status", "--format=", "HEAD"), "\n") + sort.Strings(names) + if want := []string{"D\t.gitignore", "M\t.docket.yml"}; strings.Join(names, "|") != strings.Join(want, "|") { + t.Fatalf("committed paths = %q, want %q", names, want) + } + if staged := runGit(t, dir, "diff", "--cached", "--name-only"); staged != "other.txt" { + t.Fatalf("staged after commit = %q, want other.txt still staged", staged) + } + if _, ok, err := loadSwitchJournal(common); err != nil || ok { + t.Fatalf("journal after commit: ok=%v err=%v, want cleared", ok, err) + } +} + +func TestIntegrationRepoVisibilitySwitchJournalIsIdempotentAfterCrash(t *testing.T) { + ctx := context.Background() + git, repo := newSwitchCommitRepo(t) + dir, common := repo.PrimaryWorktree, repo.CommonDir + + if err := writeSwitchPath(common, dir, visibilityRemoveSubject, ".docket.yml", nil); err != nil { + t.Fatal(err) + } + j, ok, err := loadSwitchJournal(common) + if err != nil || !ok { + t.Fatalf("load journal: ok=%v err=%v", ok, err) + } + first, err := commitSwitchJournal(ctx, git, repo, common) + if err != nil || first.Commit == "" { + t.Fatalf("first commit: %+v %v", first, err) + } + // Death between commit and cleanup: the journal is back. + if err := saveSwitchJournal(common, j); err != nil { + t.Fatal(err) + } + second, err := commitSwitchJournal(ctx, git, repo, common) + if err != nil { + t.Fatalf("second call: %v", err) + } + if second.Commit != "" || !second.Complete || len(second.Paths) != 0 { + t.Fatalf("second call: want nothing committed and complete, got %+v", second) + } + if head := runGit(t, dir, "rev-parse", "HEAD"); head != first.Commit { + t.Fatalf("HEAD moved to %s; want %s", head, first.Commit) + } + if _, ok, err := loadSwitchJournal(common); err != nil || ok { + t.Fatalf("journal after idempotent call: ok=%v err=%v, want cleared", ok, err) + } +} + +func TestIntegrationRepoVisibilitySwitchJournalRetriesAfterHookFailure(t *testing.T) { + ctx := context.Background() + git, repo := newSwitchCommitRepo(t) + dir, common := repo.PrimaryWorktree, repo.CommonDir + + marker := filepath.Join(testsupport.TempDir(t), "rejected-once") + hook := "#!/bin/sh\nif [ -e '" + marker + "' ]; then exit 0; fi\n: > '" + marker + "'\nexit 1\n" + if err := os.MkdirAll(filepath.Join(common, "hooks"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(common, "hooks", "commit-msg"), []byte(hook), 0o755); err != nil { + t.Fatal(err) + } + before := runGit(t, dir, "rev-parse", "HEAD") + if err := writeSwitchPath(common, dir, visibilityAddSubject, ".docket.yml", []byte("visibility: shared\nx: 1\n")); err != nil { + t.Fatal(err) + } + + if _, err := commitSwitchJournal(ctx, git, repo, common); err == nil { + t.Fatal("want the hook's rejection as an error") + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("hook did not run: %v", err) + } + if head := runGit(t, dir, "rev-parse", "HEAD"); head != before { + t.Fatalf("HEAD moved on a rejected commit") + } + if _, ok, err := loadSwitchJournal(common); err != nil || !ok { + t.Fatalf("journal after failure: ok=%v err=%v, want kept", ok, err) + } + if b, _ := os.ReadFile(filepath.Join(dir, ".docket.yml")); string(b) != "visibility: shared\nx: 1\n" { + t.Fatalf("edit lost after failure: %q", b) + } + + out, err := commitSwitchJournal(ctx, git, repo, common) + if err != nil || out.Commit == "" || !out.Complete { + t.Fatalf("retry: %+v %v", out, err) + } + if n := runGit(t, dir, "rev-list", "--count", before+"..HEAD"); n != "1" { + t.Fatalf("retry made %s commits, want 1", n) + } +} diff --git a/internal/app/repository_set_visibility_commit_test.go b/internal/app/repository_set_visibility_commit_test.go new file mode 100644 index 000000000..52686b485 --- /dev/null +++ b/internal/app/repository_set_visibility_commit_test.go @@ -0,0 +1,95 @@ +package app + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestVisibilitySubjectsCarryNoModeVocabulary keys on the subject VALUES the +// switch can commit with, so a later edit cannot slip mode vocabulary in. +func TestVisibilitySubjectsCarryNoModeVocabulary(t *testing.T) { + subjects := []string{visibilityRemoveSubject, visibilityAddSubject} + if len(subjects) != 2 || subjects[0] == subjects[1] { + t.Fatalf("want exactly two distinct subjects, got %q", subjects) + } + banned := regexp.MustCompile(`(?i)shared|public|private|visibility`) + for _, s := range subjects { + if strings.TrimSpace(s) == "" || strings.ContainsAny(s, "\r\n") { + t.Errorf("subject %q is not one non-empty line", s) + } + if m := banned.FindString(s); m != "" { + t.Errorf("subject %q carries mode vocabulary %q", s, m) + } + if !validVisibilitySubject(s) { + t.Errorf("subject %q is not accepted by validVisibilitySubject", s) + } + } +} + +// TestVisibilityJournalReplacesEmptyOtherSubject proves an open journal of the +// other subject that holds no path (an abandoned going-shared run's marker) +// gives way to a new switch's first path, while one holding a path still +// refuses. +func TestVisibilityJournalReplacesEmptyOtherSubject(t *testing.T) { + common := testsupport.TempDir(t) + if err := saveSwitchJournal(common, switchJournal{Subject: visibilityAddSubject, Paths: map[string]string{}}); err != nil { + t.Fatal(err) + } + if err := recordSwitchDigest(common, visibilityRemoveSubject, ".docket.yml", digestDeleted); err != nil { + t.Fatalf("recording over an empty add journal: %v", err) + } + j, ok, err := loadSwitchJournal(common) + if err != nil || !ok { + t.Fatalf("loadSwitchJournal = %v, %v", ok, err) + } + if j.Subject != visibilityRemoveSubject || len(j.Paths) != 1 || j.Paths[".docket.yml"] != digestDeleted { + t.Errorf("journal = %+v, want the removal subject holding .docket.yml", j) + } + if err := recordSwitchDigest(common, visibilityAddSubject, ".gitignore", digestDeleted); err == nil { + t.Error("recording over a removal journal holding a path succeeded, want a refusal") + } +} + +// TestKeepOnDiskCommittedKeepsHandFormatting proves going shared keeps a +// hand-formatted .docket.yml byte for byte when it already declares the +// split's leaves, and takes the split when a leaf differs, the file is absent, +// or the file does not parse. +func TestKeepOnDiskCommittedKeepsHandFormatting(t *testing.T) { + split := []byte("visibility: shared\nintegration_branch: main\nbuild:\n test_command: make\n") + cases := []struct { + name string + onDisk string // "" = absent + keep bool + }{ + {"same leaves", "# mine\nbuild: {test_command: make} # fast\nintegration_branch: 'main'\nvisibility: shared\n", true}, + {"a leaf differs", "build: {test_command: make all}\nintegration_branch: main\nvisibility: shared\n", false}, + {"absent", "", false}, + {"unparsable", "build: [unclosed\n", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + primary := testsupport.TempDir(t) + if c.onDisk != "" { + if err := os.WriteFile(filepath.Join(primary, docketYMLRel), []byte(c.onDisk), 0o644); err != nil { + t.Fatal(err) + } + } + got, err := keepOnDiskCommitted(primary, split) + if err != nil { + t.Fatal(err) + } + want := string(split) + if c.keep { + want = c.onDisk + } + if string(got) != want { + t.Errorf("keepOnDiskCommitted = %q, want %q", got, want) + } + }) + } +} diff --git a/internal/app/repository_set_visibility_integration_test.go b/internal/app/repository_set_visibility_integration_test.go new file mode 100644 index 000000000..b9e054446 --- /dev/null +++ b/internal/app/repository_set_visibility_integration_test.go @@ -0,0 +1,1866 @@ +//go:build integration + +package app + +import ( + "context" + "errors" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/config" + "github.com/danielhanold/docket/internal/document" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/process" + "github.com/danielhanold/docket/internal/reposetup" + "github.com/danielhanold/docket/internal/testsupport" +) + +// This file holds the real-Git `repository set-visibility` tests: the preview, +// every refusal that stops a switch before it writes, each direction's switch, +// and the acceptance proofs (round trip, resume after every phase, the private +// result, commit messages). Their names split three ways so each sibling shard +// stays under its runtime budget: TestIntegrationRepoVisibilityPrivate… (going +// private, tests/test_go_integration_app_repovisibility_private.sh), +// TestIntegrationRepoVisibilityShared… (going shared, +// tests/test_go_integration_app_repovisibility_shared.sh), and +// TestIntegrationRepoVisibilitySwitch… (preview, refusals, commit machinery, +// and the direction-neutral acceptance proofs, +// tests/test_go_integration_app_repovisibility.sh). No prefix is a prefix of +// another, so every test matches exactly one shard. + +// runSetVisibility runs RunRepositorySetVisibility against the invocation clone +// with the user's machine roots pinned to temp dirs. +func (r *initRepo) runSetVisibility(t *testing.T, o SetVisibilityOptions) RepositorySetVisibilityResult { + t.Helper() + return r.runSetVisibilityWithHooks(t, o, setupHooks{}) +} + +// runSetVisibilityWithHooks is runSetVisibility with the interruption seams set. +func (r *initRepo) runSetVisibilityWithHooks(t *testing.T, o SetVisibilityOptions, hooks setupHooks) RepositorySetVisibilityResult { + t.Helper() + pinInitUserRoots(t) + client := newGitClient(t) + return RunRepositorySetVisibility(context.Background(), SetupDeps{Git: client, RepoDir: r.invocation, hooks: hooks}, o) +} + +// newSharedVisibilityRepo is a shared repository right after init. +func newSharedVisibilityRepo(t *testing.T) *initRepo { + t.Helper() + r := newInitRepo(t, defaultSetupYML, nil) + if res := r.runInit(t); res.Result != ResultApplied { + t.Fatalf("init = %q (%s), want applied", res.Result, res.HumanText()) + } + return r +} + +// visibilitySnapshot captures what a preview must never change: the working +// tree status, every ref on origin, the top-level .git listing, and whether the +// state folders exist. +func visibilitySnapshot(t *testing.T, r *initRepo) string { + t.Helper() + var b strings.Builder + b.WriteString(runGit(t, r.invocation, "status", "--porcelain", "--untracked-files=all") + "\n--\n") + b.WriteString(runGit(t, r.origin, "for-each-ref", "--format=%(refname) %(objectname)") + "\n--\n") + entries, err := os.ReadDir(r.gitDir(t)) + if err != nil { + t.Fatal(err) + } + var names []string + for _, e := range entries { + if e.Name() == "FETCH_HEAD" { + continue // a read-only probe fetches; FETCH_HEAD is git's scratch record of that + } + names = append(names, e.Name()) + } + sort.Strings(names) + b.WriteString(strings.Join(names, " ")) + return b.String() +} + +func requireVisibilityRefusal(t *testing.T, res RepositorySetVisibilityResult, want ...string) { + t.Helper() + if res.Result != ResultInvalidState || res.ConfirmationRequired() { + t.Fatalf("Result = %q state %q (%s), want an invalid-state refusal", res.Result, res.RepositoryState, res.HumanText()) + } + for _, w := range want { + if !strings.Contains(res.HumanText(), w) { + t.Errorf("refusal text lacks %q:\n%s", w, res.HumanText()) + } + } +} + +// TestIntegrationRepoVisibilitySwitchPreviewWritesNothing proves a private preview +// lists its phases and pin while the working tree, origin's refs, the .git +// listing, and the private store stay untouched. +func TestIntegrationRepoVisibilitySwitchPreviewWritesNothing(t *testing.T) { + r := newSharedVisibilityRepo(t) + before := visibilitySnapshot(t, r) + res := r.runSetVisibility(t, SetVisibilityOptions{Target: "private"}) + after := visibilitySnapshot(t, r) + + if res.Result != ResultInvalidState || !res.ConfirmationRequired() { + t.Fatalf("Result = %q state %q (%s), want a confirmation-required preview", res.Result, res.RepositoryState, res.HumanText()) + } + if before != after { + t.Errorf("the preview changed the repository:\nbefore:\n%s\nafter:\n%s", before, after) + } + if res.Current != "shared" || res.Target != "private" { + t.Errorf("current/target = %q/%q, want shared/private", res.Current, res.Target) + } + if !strings.HasPrefix(res.SourceRevision, "origin-docket=") || !strings.Contains(res.SourceRevision, " dckt=absent ") { + t.Errorf("SourceRevision = %q, want the composite pin with no dckt branch", res.SourceRevision) + } + pending := 0 + for _, p := range res.Phases { + if p.Status == visibilityPhasePending { + pending++ + } + if !strings.Contains(res.HumanText(), "] "+p.Name+": ") { + t.Errorf("preview text does not list phase %s:\n%s", p.Name, res.HumanText()) + } + } + if pending == 0 { + t.Errorf("phases = %+v, want pending phases", res.Phases) + } + priv := expectedPrivateLayout(t, r.invocation, os.Getenv("XDG_DATA_HOME")) + if _, err := os.Stat(priv.StoreDir); !os.IsNotExist(err) { + t.Errorf("the preview touched the private store %s (stat err %v)", priv.StoreDir, err) + } +} + +// TestIntegrationRepoVisibilitySwitchRefusesLiveRun proves an active run refuses the +// switch, naming the cancel command for its key. +func TestIntegrationRepoVisibilitySwitchRefusesLiveRun(t *testing.T) { + r := newSharedVisibilityRepo(t) + key, err := MintRunTrackerRecord(r.invocation, sampleRunTrackerRecord()) + if err != nil { + t.Fatalf("MintRunTrackerRecord: %v", err) + } + if _, err := MintRunRecord(r.invocation, key, "375"); err != nil { + t.Fatalf("MintRunRecord: %v", err) + } + res := r.runSetVisibility(t, SetVisibilityOptions{Target: "private"}) + requireVisibilityRefusal(t, res, "docket run cancel --key "+key) +} + +// TestIntegrationRepoVisibilitySwitchRefusesBusyGateLock proves a held gate lock in +// the state folder refuses the switch. +func TestIntegrationRepoVisibilitySwitchRefusesBusyGateLock(t *testing.T) { + r := newSharedVisibilityRepo(t) + dir := filepath.Join(r.gitDir(t), layout.SharedName, "worktree-locks", "x") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + f, busy, err := process.TryExclusiveLock(filepath.Join(dir, "busy.lock")) + if err != nil || busy { + t.Fatalf("TryExclusiveLock: busy=%v err=%v", busy, err) + } + defer f.Close() + res := r.runSetVisibility(t, SetVisibilityOptions{Target: "private"}) + requireVisibilityRefusal(t, res, "busy.lock") +} + +// TestIntegrationRepoVisibilitySwitchRefusesDirtyMetadataWorktree proves an +// uncommitted change in .docket refuses the switch. +func TestIntegrationRepoVisibilitySwitchRefusesDirtyMetadataWorktree(t *testing.T) { + r := newSharedVisibilityRepo(t) + writeRepoFile(t, filepath.Join(r.invocation, ".docket"), "scratch.md", "unsaved\n") + res := r.runSetVisibility(t, SetVisibilityOptions{Target: "private"}) + requireVisibilityRefusal(t, res, "commit or discard the changes in") +} + +// TestIntegrationRepoVisibilitySwitchRefusesUnpublishedMetadataCommit proves a +// metadata commit origin lacks refuses the switch. +func TestIntegrationRepoVisibilitySwitchRefusesUnpublishedMetadataCommit(t *testing.T) { + r := newSharedVisibilityRepo(t) + dotDocket := filepath.Join(r.invocation, ".docket") + gitIdentity(t, dotDocket) + writeRepoFile(t, dotDocket, "local.md", "not pushed\n") + runGit(t, dotDocket, "add", "--", "local.md") + runGit(t, dotDocket, "commit", "-q", "-m", "local only") + res := r.runSetVisibility(t, SetVisibilityOptions{Target: "private"}) + requireVisibilityRefusal(t, res, "docket repository prepare") +} + +// TestIntegrationRepoVisibilitySwitchRefusesFreshAndLegacy proves a repository that +// was never set up names init, and a legacy one names migrate. +func TestIntegrationRepoVisibilitySwitchRefusesFreshAndLegacy(t *testing.T) { + fresh := newInitRepo(t, defaultSetupYML, nil) + requireVisibilityRefusal(t, fresh.runSetVisibility(t, SetVisibilityOptions{Target: "private"}), "docket repository init") + + legacy := newInitRepo(t, defaultSetupYML, cleanLegacyFiles()) + res := legacy.runSetVisibility(t, SetVisibilityOptions{Target: "private"}) + requireVisibilityRefusal(t, res, "docket repository migrate") + if res.RepositoryState != string(reposetup.StateLegacy) { + t.Errorf("RepositoryState = %q, want legacy", res.RepositoryState) + } +} + +// TestIntegrationRepoVisibilitySwitchWarnsAbsoluteLinksWithoutRefusing proves a +// metadata file whose prose links absolutely into the docket branch is named in +// a warning that names `docket repository repair`, and the preview still stands. +func TestIntegrationRepoVisibilitySwitchWarnsAbsoluteLinksWithoutRefusing(t *testing.T) { + r := newSharedVisibilityRepo(t) + runGit(t, r.invocation, "remote", "set-url", "origin", "https://github.com/acme/app.git") + runGit(t, r.invocation, "config", "url."+r.origin+".insteadOf", "https://github.com/acme/app.git") + + const spec = "docs/superpowers/specs/2026-10-01-example-design.md" + dotDocket := filepath.Join(r.invocation, ".docket") + gitIdentity(t, dotDocket) + writeRepoFile(t, dotDocket, spec, "# Example\n\nSee https://github.com/acme/app/blob/docket/x.md for context.\n") + runGit(t, dotDocket, "add", "--", spec) + runGit(t, dotDocket, "commit", "-q", "-m", "a spec with an absolute link") + runGit(t, dotDocket, "push", "-q", "origin", layout.SharedName) + + res := r.runSetVisibility(t, SetVisibilityOptions{Target: "private"}) + if res.Result != ResultInvalidState || !res.ConfirmationRequired() { + t.Fatalf("Result = %q state %q (%s), want a confirmation-required preview", res.Result, res.RepositoryState, res.HumanText()) + } + found := false + for _, w := range res.Warnings { + if strings.Contains(w, spec) && strings.Contains(w, "docket repository repair") { + found = true + } + } + if !found { + t.Errorf("warnings %q do not name %s and `docket repository repair`", res.Warnings, spec) + } +} + +// --- going private ----------------------------------------------------------- + +// privateSwitchYML is the committed configuration the going-private fixtures +// carry: the shared defaults, a harness opt-in (so init writes the committed +// dispatch surfaces), and a build command the private config must keep. +const privateSwitchYML = defaultSetupYML + "agent_harnesses: [codex]\nbuild:\n test_command: make\n" + +// privateSwitchLocalYML is the clone-local configuration whose leaves must +// survive the fold. +const privateSwitchLocalYML = "reclaim:\n auto: true\n" + +// newPrivateSwitchRepo is a shared repository right after init whose init +// output is committed and pushed (so the working tree is clean), with +// XDG_DATA_HOME pinned for the whole test so every run resolves one store. It +// returns the repository and that data home. +func newPrivateSwitchRepo(t *testing.T) (*initRepo, string) { + t.Helper() + data := testsupport.TempDir(t) + t.Setenv("XDG_DATA_HOME", data) + r := newInitRepo(t, privateSwitchYML, nil) + if res := r.runInitWith(t, InitOptions{}); res.Result != ResultApplied { + t.Fatalf("init = %q (%s), want applied", res.Result, res.HumanText()) + } + commitAndPushAll(t, r.invocation, "commit the docket setup") + if out := runGit(t, r.invocation, "status", "--porcelain", "--untracked-files=all"); out != "" { + t.Fatalf("the fixture is not clean after committing init's output:\n%s", out) + } + return r, data +} + +// commitAndPushAll commits every pending path in dir and pushes main. +func commitAndPushAll(t *testing.T, dir, msg string) { + t.Helper() + runGit(t, dir, "add", "-A") + if out := runGit(t, dir, "status", "--porcelain"); out != "" { + runGit(t, dir, "commit", "-q", "-m", msg) + } + runGit(t, dir, "push", "-q", "origin", "main") +} + +// switchVisibility previews o, then applies it pinned to the preview. +func (r *initRepo) switchVisibility(t *testing.T, o SetVisibilityOptions) RepositorySetVisibilityResult { + t.Helper() + return r.switchVisibilityWithHooks(t, o, setupHooks{}) +} + +// switchVisibilityWithHooks is switchVisibility with the seams set on the +// authorized run. +func (r *initRepo) switchVisibilityWithHooks(t *testing.T, o SetVisibilityOptions, hooks setupHooks) RepositorySetVisibilityResult { + t.Helper() + preview := r.runSetVisibility(t, o) + if !preview.ConfirmationRequired() { + t.Fatalf("preview = %q state %q (%s), want confirmation-required", preview.Result, preview.RepositoryState, preview.HumanText()) + } + o.Authorized, o.ExpectedSource = true, preview.SourceRev() + return r.runSetVisibilityWithHooks(t, o, hooks) +} + +// requireSwitchApplied fails unless res is an applied switch landing in mode. +func requireSwitchApplied(t *testing.T, res RepositorySetVisibilityResult, mode string) { + t.Helper() + if res.Result != ResultApplied || res.RepositoryState != mode { + t.Fatalf("switch = %q state %q, want applied %s:\n%s", res.Result, res.RepositoryState, mode, res.HumanText()) + } +} + +// phaseStatus returns the status of the named phase row ("" when absent). +func phaseStatus(res RepositorySetVisibilityResult, name string) string { + for _, p := range res.Phases { + if p.Name == name { + return p.Status + } + } + return "" +} + +// createSwitchChange records one change through the production planning path +// and returns its result. +func createSwitchChange(t *testing.T, dir string) ChangeCreateResult { + t.Helper() + node := planningDepsFor(t, dir) + res := ChangeCreate(context.Background(), node.deps, node.dir, validChangeCreateRequest()) + if res.Result != ResultApplied { + t.Fatalf("ChangeCreate = %q (%v)", res.Result, res.Findings) + } + return res +} + +// localBranchExists reports whether dir's repository has the local branch. +func localBranchExists(dir, branch string) bool { + _, err := tryGit(dir, "rev-parse", "--verify", "--quiet", "refs/heads/"+branch) + return err == nil +} + +// requireNoSharedStateLeft asserts the clone holds only the private state +// folder and no .docket checkout or local docket branch. +func requireNoSharedStateLeft(t *testing.T, r *initRepo) { + t.Helper() + gitDir := r.gitDir(t) + if _, err := os.Stat(filepath.Join(gitDir, layout.SharedName)); !os.IsNotExist(err) { + t.Errorf("the shared state folder is still present (stat err %v)", err) + } + if fi, err := os.Stat(filepath.Join(gitDir, layout.PrivateName)); err != nil || !fi.IsDir() { + t.Errorf("the private state folder is missing (err %v)", err) + } + if _, err := os.Stat(filepath.Join(r.invocation, layout.SharedWorktreeDir)); !os.IsNotExist(err) { + t.Errorf("the .docket checkout is still present (stat err %v)", err) + } + if localBranchExists(r.invocation, layout.SharedName) { + t.Error("the local docket branch is still present") + } +} + +// TestIntegrationRepoVisibilityPrivateHappyPath proves the whole going-private +// switch: the identical history on the bare dckt remote, origin's branch kept, +// the folded private config with the local keys saved, the exclude block, the +// private checkout, the private dispatch instructions, the committed files +// untouched, a clean working tree, and a no-op re-preview and prepare. +func TestIntegrationRepoVisibilityPrivateHappyPath(t *testing.T) { + r, data := newPrivateSwitchRepo(t) + createSwitchChange(t, r.invocation) + writeRepoFile(t, r.invocation, ".docket.local.yml", privateSwitchLocalYML) + originTip := r.originTip(t, layout.SharedName) + agentsBefore := runGit(t, r.invocation, "show", "HEAD:AGENTS.md") + headBefore := runGit(t, r.invocation, "rev-parse", "HEAD") + + res := r.switchVisibility(t, SetVisibilityOptions{Target: "private"}) + requireSwitchApplied(t, res, "private") + + lay := expectedPrivateLayout(t, r.invocation, data) + if got := runGit(t, lay.DefaultBareRemote, "rev-parse", "refs/heads/dckt"); got != originTip { + t.Errorf("bare dckt tip = %s, want origin's docket tip %s", got, originTip) + } + if got := r.originTip(t, layout.SharedName); got != originTip { + t.Errorf("origin's docket moved to %s, want it kept at %s", got, originTip) + } + requireNoSharedStateLeft(t, r) + gitDir := r.gitDir(t) + + folded, err := os.ReadFile(filepath.Join(gitDir, "dckt", "config.yml")) + if err != nil { + t.Fatal(err) + } + snap, _, err := config.Resolve([]config.Source{{Layer: config.LayerRepository, Name: layout.PrivateConfigDisplay, Data: folded}}, config.ResolveContext{DefaultBranch: "main"}) + if err != nil { + t.Fatalf("the private config does not resolve: %v\n%s", err, folded) + } + if v := snap.Effective.Visibility.Value; v != "private" { + t.Errorf("visibility = %q, want private", v) + } + if v := snap.Effective.Build.TestCommand.Value; v != "make" { + t.Errorf("build.test_command = %q, want make", v) + } + if !snap.Effective.Reclaim.Auto.Value { + t.Errorf("reclaim.auto = false, want the local key carried:\n%s", folded) + } + if saved, err := os.ReadFile(filepath.Join(gitDir, "dckt", layout.PrivateLocalKeysFile)); err != nil || string(saved) != privateSwitchLocalYML { + t.Errorf("local-keys.yml = %q (%v), want the old local file verbatim", saved, err) + } + if _, err := os.Stat(filepath.Join(r.invocation, ".docket.local.yml")); !os.IsNotExist(err) { + t.Errorf(".docket.local.yml is still present (stat err %v)", err) + } + exclude, err := os.ReadFile(filepath.Join(gitDir, "info", "exclude")) + if err != nil || !reposetup.ValidExcludeBlock(exclude) { + t.Errorf(".git/info/exclude = %q (%v), want the valid managed block", exclude, err) + } + + if got := runGit(t, lay.MetadataWorktree, "rev-parse", "--abbrev-ref", "HEAD"); got != "dckt" { + t.Errorf("the private checkout is on %q, want dckt", got) + } + if off := runGit(t, lay.MetadataWorktree, "config", "--worktree", "core.hooksPath"); off == "" { + t.Error("the private checkout's hooks are not disabled") + } + ins := Instructions(r.invocation, InstructionsSectionDispatch) + if !strings.Contains(ins.Content, document.MarkerSpelling(instructionsBlockName)+":start") { + t.Errorf("Instructions(dispatch) = %+v, want the dispatch block", ins) + } + if got := runGit(t, r.invocation, "show", "HEAD:AGENTS.md"); got != agentsBefore { + t.Errorf("the committed AGENTS.md changed:\n%s", got) + } + if got := runGit(t, r.invocation, "rev-parse", "HEAD"); got != headBefore { + t.Errorf("HEAD moved to %s without --remove-shared-files", got) + } + if out := runGit(t, r.invocation, "status", "--porcelain", "--untracked-files=all"); out != "" { + t.Errorf("the working tree is not clean after the switch:\n%s", out) + } + + again := r.runSetVisibility(t, SetVisibilityOptions{Target: "private"}) + if again.Result != ResultNoOp { + t.Errorf("re-preview = %q (%s), want no-op", again.Result, again.HumanText()) + } + prep := RunRepositoryPrepare(context.Background(), SetupDeps{Git: newGitClient(t), RepoDir: r.invocation}, PrepareOptions{}) + if prep.Disposition != PrepareDispositionNoOp || prep.Context == nil || prep.Context.MetadataRemote != "dckt" { + t.Errorf("prepare = %q context %+v (%s), want no-op on the dckt remote", prep.Disposition, prep.Context, prep.HumanText()) + } +} + +// TestIntegrationRepoVisibilityPrivateDeleteSharedBranch proves +// --delete-shared-branch removes origin's docket branch once the bare remote +// holds its tip, and a re-run is a no-op. +func TestIntegrationRepoVisibilityPrivateDeleteSharedBranch(t *testing.T) { + r, _ := newPrivateSwitchRepo(t) + o := SetVisibilityOptions{Target: "private", DeleteSharedBranch: true} + res := r.switchVisibility(t, o) + requireSwitchApplied(t, res, "private") + if r.remoteBranchExists(t, layout.SharedName) { + t.Error("origin still has the docket branch") + } + if got := phaseStatus(res, "delete-shared-branch"); got != visibilityPhaseApplied { + t.Errorf("delete-shared-branch = %q, want applied", got) + } + if again := r.runSetVisibility(t, o); again.Result != ResultNoOp { + t.Errorf("re-run = %q (%s), want no-op", again.Result, again.HumanText()) + } +} + +// TestIntegrationRepoVisibilityPrivateDeleteSharedBranchKeepsOnMismatch proves +// a teammate's write to origin's docket branch after the publish keeps the +// branch: the phase is kept and the pending line names both tips. +func TestIntegrationRepoVisibilityPrivateDeleteSharedBranchKeepsOnMismatch(t *testing.T) { + r, data := newPrivateSwitchRepo(t) + var advanced string + res := r.switchVisibilityWithHooks(t, SetVisibilityOptions{Target: "private", DeleteSharedBranch: true}, setupHooks{ + afterVisibilityPhase: func(phase string) error { + if phase == "publish" { + advanced = r.advanceRemoteDocketChild(t, "a teammate's later write") + } + return nil + }, + }) + requireSwitchApplied(t, res, "private") + if got := r.originTip(t, layout.SharedName); got != advanced { + t.Errorf("origin's docket = %s, want it kept at the teammate's tip %s", got, advanced) + } + if got := phaseStatus(res, "delete-shared-branch"); got != visibilityPhaseKept { + t.Errorf("delete-shared-branch = %q, want kept", got) + } + bare := runGit(t, expectedPrivateLayout(t, r.invocation, data).DefaultBareRemote, "rev-parse", "refs/heads/dckt") + pending := strings.Join(res.PendingLocal, "\n") + if !strings.Contains(pending, advanced) || !strings.Contains(pending, bare) { + t.Errorf("PendingLocal = %q, want both tips (origin %s, dckt %s)", res.PendingLocal, advanced, bare) + } +} + +// requireRemovalCommit asserts HEAD is exactly one new commit over before, +// with the fixed subject and no body, deleting .docket.yml and removing the +// managed .gitignore block and the AGENTS.md dispatch block, and nothing else. +func requireRemovalCommit(t *testing.T, r *initRepo, before string) { + t.Helper() + if n := runGit(t, r.invocation, "rev-list", "--count", before+"..HEAD"); n != "1" { + t.Fatalf("%s new commits on main, want exactly one", n) + } + if msg, err := tryGit(r.invocation, "log", "-1", "--format=%B"); err != nil || msg != visibilityRemoveSubject+"\n\n" && msg != visibilityRemoveSubject+"\n" { + t.Errorf("commit message = %q (%v), want exactly the subject", msg, err) + } + got := map[string]string{} + for _, line := range strings.Split(runGit(t, r.invocation, "show", "--name-status", "--format=", "HEAD"), "\n") { + if f := strings.Fields(line); len(f) == 2 { + got[f[1]] = f[0] + } + } + if got[".docket.yml"] != "D" { + t.Errorf(".docket.yml status = %q, want D (%v)", got[".docket.yml"], got) + } + for _, p := range []string{".gitignore", "AGENTS.md"} { + if s := got[p]; s != "M" && s != "D" { + t.Errorf("%s status = %q, want M or D (%v)", p, s, got) + } + } + for p := range got { + if !contains(visibilityCommitPaths, p) { + t.Errorf("the commit holds %s, which is not one of the switch's paths", p) + } + } + if b, err := tryGit(r.invocation, "show", "HEAD:AGENTS.md"); err == nil && strings.Contains(b, document.MarkerSpelling(instructionsBlockName)) { + t.Errorf("the committed AGENTS.md still carries the dispatch block:\n%s", b) + } +} + +// TestIntegrationRepoVisibilityPrivateRemoveSharedFiles proves +// --remove-shared-files makes one local commit with the fixed subject holding +// only the switch's paths: a staged unrelated file stays staged and origin's +// main is not pushed. +func TestIntegrationRepoVisibilityPrivateRemoveSharedFiles(t *testing.T) { + r, _ := newPrivateSwitchRepo(t) + writeRepoFile(t, r.invocation, "notes.txt", "mine\n") + runGit(t, r.invocation, "add", "--", "notes.txt") + before := runGit(t, r.invocation, "rev-parse", "HEAD") + originMain := r.originTip(t, "main") + + res := r.switchVisibility(t, SetVisibilityOptions{Target: "private", RemoveSharedFiles: true}) + requireSwitchApplied(t, res, "private") + requireRemovalCommit(t, r, before) + if staged := runGit(t, r.invocation, "diff", "--cached", "--name-only"); staged != "notes.txt" { + t.Errorf("staged = %q, want notes.txt still staged", staged) + } + if got := r.originTip(t, "main"); got != originMain { + t.Errorf("origin's main moved to %s; the switch must never push it", got) + } + if len(res.Commits) != 1 || res.Commits[0].Status != visibilityCommitCommitted || res.Commits[0].Branch != "main" || + res.Commits[0].Commit != runGit(t, r.invocation, "rev-parse", "HEAD") { + t.Errorf("Commits = %+v, want the one committed row on main", res.Commits) + } + if again := r.runSetVisibility(t, SetVisibilityOptions{Target: "private", RemoveSharedFiles: true}); again.Result != ResultNoOp { + t.Errorf("re-run = %q (%s), want no-op", again.Result, again.HumanText()) + } +} + +// TestIntegrationRepoVisibilityPrivateRemovalPreviewIsTheCommit proves the +// removal preview lists exactly what the removal commit does — a .gitignore and +// AGENTS.md left empty by the removal are deletions, not writes — and that a +// user's uncommitted edit to a path the commit never touches (a committed +// CLAUDE.md with no dispatch block) neither refuses the switch nor is swept in. +func TestIntegrationRepoVisibilityPrivateRemovalPreviewIsTheCommit(t *testing.T) { + r, _ := newPrivateSwitchRepo(t) + writeRepoFile(t, r.invocation, "CLAUDE.md", "my notes\n") + commitAndPushAll(t, r.invocation, "add my notes") + writeRepoFile(t, r.invocation, "CLAUDE.md", "my notes\nmore notes\n") + before := runGit(t, r.invocation, "rev-parse", "HEAD") + + o := SetVisibilityOptions{Target: "private", RemoveSharedFiles: true} + preview := r.runSetVisibility(t, o) + if !preview.ConfirmationRequired() { + t.Fatalf("preview = %q state %q (%s), want confirmation-required", preview.Result, preview.RepositoryState, preview.HumanText()) + } + if len(preview.Commits) != 1 { + t.Fatalf("preview commits = %+v, want one planned row", preview.Commits) + } + rows := preview.Commits[0].Paths + if want := []string{".docket.yml (delete)", ".gitignore (delete)", "AGENTS.md (delete)"}; strings.Join(rows, ",") != strings.Join(want, ",") { + t.Errorf("preview rows = %q, want %q", rows, want) + } + o.Authorized, o.ExpectedSource = true, preview.SourceRev() + requireSwitchApplied(t, r.runSetVisibility(t, o), "private") + + got := commitNameStatus(t, r.invocation, "HEAD") + if len(got) != len(rows) { + t.Errorf("the commit holds %v, the preview listed %q", got, rows) + } + for _, row := range rows { + rel, action, _ := strings.Cut(strings.TrimSuffix(row, ")"), " (") + wantStatus := "M" + if action == "delete" { + wantStatus = "D" + } + if got[rel] != wantStatus { + t.Errorf("the commit's %s = %q, the preview said %s", rel, got[rel], action) + } + } + if n := runGit(t, r.invocation, "rev-list", "--count", before+"..HEAD"); n != "1" { + t.Errorf("%s new commits, want the one removal commit", n) + } + if b := string(mustReadFile(t, filepath.Join(r.invocation, "CLAUDE.md"))); b != "my notes\nmore notes\n" { + t.Errorf("CLAUDE.md = %q, want the uncommitted edit kept", b) + } + if diff := runGit(t, r.invocation, "diff", "--name-only", "--", "CLAUDE.md"); diff != "CLAUDE.md" { + t.Errorf("unstaged diff = %q, want CLAUDE.md still modified", diff) + } + if staged := runGit(t, r.invocation, "diff", "--cached", "--name-only"); staged != "" { + t.Errorf("staged = %q, want nothing staged", staged) + } +} + +// TestIntegrationRepoVisibilityPrivateUnlocatableStoreIsUnknown proves a +// repository with no metadata branch on origin and no dckt remote, whose +// default store cannot be located (no data home and no home directory), is an +// unknown probe, never a confident "run init first". +func TestIntegrationRepoVisibilityPrivateUnlocatableStoreIsUnknown(t *testing.T) { + r := newInitRepo(t, defaultSetupYML, nil) + client := newGitClient(t) + t.Setenv("HOME", "") + t.Setenv("XDG_DATA_HOME", "relative-data-home") + res := RunRepositorySetVisibility(context.Background(), SetupDeps{Git: client, RepoDir: r.invocation}, SetVisibilityOptions{Target: "private"}) + if res.Result != ResultInvalidState || res.RepositoryState != string(reposetup.StateUnknown) { + t.Fatalf("Result = %q state %q (%s), want invalid-state unknown", res.Result, res.RepositoryState, res.HumanText()) + } + if !strings.Contains(res.HumanText(), "private-store") { + t.Errorf("refusal text lacks the private-store probe:\n%s", res.HumanText()) + } +} + +// TestIntegrationRepoVisibilityPrivateRemoveSharedFilesLater proves the flag +// on a repository that already went private makes the same one commit. +func TestIntegrationRepoVisibilityPrivateRemoveSharedFilesLater(t *testing.T) { + r, _ := newPrivateSwitchRepo(t) + before := runGit(t, r.invocation, "rev-parse", "HEAD") + requireSwitchApplied(t, r.switchVisibility(t, SetVisibilityOptions{Target: "private"}), "private") + if got := runGit(t, r.invocation, "rev-parse", "HEAD"); got != before { + t.Fatalf("HEAD moved to %s without the flag", got) + } + res := r.switchVisibility(t, SetVisibilityOptions{Target: "private", RemoveSharedFiles: true}) + requireSwitchApplied(t, res, "private") + requireRemovalCommit(t, r, before) + for _, p := range res.Phases { + if p.Name != "remove-shared-files" && p.Status != visibilityPhaseDone { + t.Errorf("phase %s = %q, want done on the later run", p.Name, p.Status) + } + } +} + +// TestIntegrationRepoVisibilityPrivateRerunIgnoresOriginAfterDivergence proves +// a later run on a repository that already went private judges the publish by +// its own progress, not origin's docket branch: after a teammate advanced +// origin's branch and this clone made a private commit, adding +// --remove-shared-files succeeds, pulls none of origin's commits into the dckt +// branch, and --delete-shared-branch keeps origin's branch, naming both tips. +func TestIntegrationRepoVisibilityPrivateRerunIgnoresOriginAfterDivergence(t *testing.T) { + r, data := newPrivateSwitchRepo(t) + before := runGit(t, r.invocation, "rev-parse", "HEAD") + requireSwitchApplied(t, r.switchVisibility(t, SetVisibilityOptions{Target: "private"}), "private") + bareRemote := expectedPrivateLayout(t, r.invocation, data).DefaultBareRemote + advanced := r.advanceRemoteDocketChild(t, "a teammate's later write") + createSwitchChange(t, r.invocation) + privateTip := runGit(t, bareRemote, "rev-parse", "refs/heads/dckt") + if _, err := tryGit(bareRemote, "merge-base", "--is-ancestor", advanced, privateTip); err == nil { + t.Fatal("the fixture's private commit already holds the teammate's write") + } + + res := r.switchVisibility(t, SetVisibilityOptions{Target: "private", RemoveSharedFiles: true}) + requireSwitchApplied(t, res, "private") + requireRemovalCommit(t, r, before) + if got := phaseStatus(res, "publish"); got != visibilityPhaseDone { + t.Errorf("publish = %q, want done on the later run", got) + } + if got := runGit(t, bareRemote, "rev-parse", "refs/heads/dckt"); got != privateTip { + t.Errorf("the dckt branch moved to %s, want it kept at the private tip %s", got, privateTip) + } + + del := r.switchVisibility(t, SetVisibilityOptions{Target: "private", DeleteSharedBranch: true}) + requireSwitchApplied(t, del, "private") + if got := r.originTip(t, layout.SharedName); got != advanced { + t.Errorf("origin's docket = %s, want it kept at the teammate's tip %s", got, advanced) + } + if got := phaseStatus(del, "delete-shared-branch"); got != visibilityPhaseKept { + t.Errorf("delete-shared-branch = %q, want kept", got) + } + pending := strings.Join(del.PendingLocal, "\n") + if !strings.Contains(pending, advanced) || !strings.Contains(pending, privateTip) { + t.Errorf("PendingLocal = %q, want both tips (origin %s, dckt %s)", del.PendingLocal, advanced, privateTip) + } + if got := runGit(t, bareRemote, "rev-parse", "refs/heads/dckt"); got != privateTip { + t.Errorf("the dckt branch moved to %s, want it kept at the private tip %s", got, privateTip) + } +} + +// TestIntegrationRepoVisibilityPrivateRerunKeepsTeammateWritesOut proves a +// later run on a repository that already went private, with no private commit +// since, never fast-forwards a teammate's write to origin's docket branch into +// the dckt branch. +func TestIntegrationRepoVisibilityPrivateRerunKeepsTeammateWritesOut(t *testing.T) { + r, data := newPrivateSwitchRepo(t) + requireSwitchApplied(t, r.switchVisibility(t, SetVisibilityOptions{Target: "private"}), "private") + bareRemote := expectedPrivateLayout(t, r.invocation, data).DefaultBareRemote + privateTip := runGit(t, bareRemote, "rev-parse", "refs/heads/dckt") + r.advanceRemoteDocketChild(t, "a teammate's later write") + + res := r.switchVisibility(t, SetVisibilityOptions{Target: "private", RemoveSharedFiles: true}) + requireSwitchApplied(t, res, "private") + if got := runGit(t, bareRemote, "rev-parse", "refs/heads/dckt"); got != privateTip { + t.Errorf("the dckt branch moved to %s, want it kept at %s without the teammate's write", got, privateTip) + } +} + +// TestIntegrationRepoVisibilityPrivateRetiresIgnoredCursorRule proves going +// private with cursor opted in retires the generated Cursor dispatch rule, +// which shared mode ignores rather than commits, so stripping the managed +// .gitignore block leaves no untracked docket-named file behind. +func TestIntegrationRepoVisibilityPrivateRetiresIgnoredCursorRule(t *testing.T) { + data := testsupport.TempDir(t) + t.Setenv("XDG_DATA_HOME", data) + r := newInitRepo(t, defaultSetupYML+"agent_harnesses: [codex, cursor]\n", nil) + if res := r.runInitWith(t, InitOptions{}); res.Result != ResultApplied { + t.Fatalf("init = %q (%s), want applied", res.Result, res.HumanText()) + } + commitAndPushAll(t, r.invocation, "commit the docket setup") + const cursorRule = ".cursor/rules/docket-dispatch.mdc" + if _, err := os.Stat(filepath.Join(r.invocation, cursorRule)); err != nil { + t.Fatalf("the Cursor dispatch rule is not installed: %v", err) + } + if _, err := tryGit(r.invocation, "cat-file", "-e", "HEAD:"+cursorRule); err == nil { + t.Fatal("the fixture committed the Cursor dispatch rule; it must be ignored") + } + + res := r.switchVisibility(t, SetVisibilityOptions{Target: "private", RemoveSharedFiles: true}) + requireSwitchApplied(t, res, "private") + if _, err := os.Lstat(filepath.Join(r.invocation, cursorRule)); !os.IsNotExist(err) { + t.Errorf("the Cursor dispatch rule is still present (lstat err %v)", err) + } + if out := runGit(t, r.invocation, "status", "--porcelain", "--untracked-files=all"); out != "" { + t.Errorf("the working tree is not clean after the switch:\n%s", out) + } +} + +// TestIntegrationRepoVisibilityPrivateRefusesForeignBareRemote proves a dckt +// branch in the default store that is unrelated to origin's docket branch +// refuses the publish, naming both tips, before the state folder moves. +func TestIntegrationRepoVisibilityPrivateRefusesForeignBareRemote(t *testing.T) { + r, data := newPrivateSwitchRepo(t) + lay := expectedPrivateLayout(t, r.invocation, data) + if err := os.MkdirAll(filepath.Dir(lay.DefaultBareRemote), 0o755); err != nil { + t.Fatal(err) + } + runGit(t, r.root, "init", "-q", "--bare", lay.DefaultBareRemote) + foreign := filepath.Join(r.root, "foreign") + runGit(t, r.root, "init", "-q", "-b", "dckt", foreign) + gitIdentity(t, foreign) + writeRepoFile(t, foreign, "other.md", "another backlog\n") + runGit(t, foreign, "add", "-A") + runGit(t, foreign, "commit", "-q", "-m", "another backlog") + runGit(t, foreign, "push", "-q", lay.DefaultBareRemote, "dckt:refs/heads/dckt") + foreignTip := runGit(t, foreign, "rev-parse", "HEAD") + originTip := r.originTip(t, layout.SharedName) + stateBefore := treeListing(t, filepath.Join(r.gitDir(t), layout.SharedName)) + + res := r.switchVisibility(t, SetVisibilityOptions{Target: "private"}) + requireVisibilityRefusal(t, res, foreignTip, originTip) + if got := runGit(t, lay.DefaultBareRemote, "rev-parse", "refs/heads/dckt"); got != foreignTip { + t.Errorf("the foreign dckt branch moved to %s", got) + } + if after := treeListing(t, filepath.Join(r.gitDir(t), layout.SharedName)); after != stateBefore { + t.Errorf("the shared state folder changed:\nbefore:\n%s\nafter:\n%s", stateBefore, after) + } + if _, err := os.Stat(filepath.Join(r.gitDir(t), layout.PrivateName)); !os.IsNotExist(err) { + t.Errorf("the private state folder exists after the refusal (stat err %v)", err) + } +} + +// treeListing lists every path under dir with its size, sorted. +func treeListing(t *testing.T, dir string) string { + t.Helper() + var lines []string + err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + info, err := d.Info() + if err != nil { + return err + } + rel, _ := filepath.Rel(dir, p) + lines = append(lines, rel+" "+info.Mode().String()+" "+strconv.FormatInt(info.Size(), 10)) + return nil + }) + if err != nil { + t.Fatal(err) + } + sort.Strings(lines) + return strings.Join(lines, "\n") +} + +// TestIntegrationRepoVisibilityPrivateMetadataRemoteFlag proves +// --metadata-remote publishes to the named bare repository and never creates +// the default store's remote.git. +func TestIntegrationRepoVisibilityPrivateMetadataRemoteFlag(t *testing.T) { + r, data := newPrivateSwitchRepo(t) + backup := filepath.Join(r.root, "backup.git") + runGit(t, r.root, "init", "-q", "--bare", backup) + originTip := r.originTip(t, layout.SharedName) + + res := r.switchVisibility(t, SetVisibilityOptions{Target: "private", MetadataRemote: backup}) + requireSwitchApplied(t, res, "private") + canonical, err := filepath.EvalSymlinks(backup) + if err != nil { + t.Fatal(err) + } + if got := runGit(t, r.invocation, "config", "--get", "remote.dckt.url"); got != backup && got != canonical { + t.Errorf("remote.dckt.url = %q, want %s", got, backup) + } + if got := runGit(t, backup, "rev-parse", "refs/heads/dckt"); got != originTip { + t.Errorf("backup dckt = %s, want origin's docket tip %s", got, originTip) + } + if _, err := os.Stat(expectedPrivateLayout(t, r.invocation, data).DefaultBareRemote); !os.IsNotExist(err) { + t.Errorf("the default store's remote.git exists (stat err %v)", err) + } + if again := r.runSetVisibility(t, SetVisibilityOptions{Target: "private"}); again.Result != ResultNoOp { + t.Errorf("a flagless re-run = %q (%s), want no-op", again.Result, again.HumanText()) + } +} + +// TestIntegrationRepoVisibilityPrivateSecondCloneAfterCleanup proves a second +// clone of a repository that already went private with both flags (origin's +// docket branch gone, .docket.yml off origin's default branch) adopts the +// store on the same machine: its publish is already done, its config folds +// from its own HEAD, and its checkout holds the first clone's change. +func TestIntegrationRepoVisibilityPrivateSecondCloneAfterCleanup(t *testing.T) { + r, data := newPrivateSwitchRepo(t) + b := &initRepo{root: r.root, origin: r.origin, writer: r.writer, invocation: filepath.Join(r.root, "second")} + runGit(t, r.root, "clone", "-q", r.origin, b.invocation) + gitIdentity(t, b.invocation) + if res := b.runInitWith(t, InitOptions{}); res.Result != ResultApplied && res.Result != ResultNoOp { + t.Fatalf("second clone init = %q (%s)", res.Result, res.HumanText()) + } + + created := createSwitchChange(t, r.invocation) + requireSwitchApplied(t, r.switchVisibility(t, SetVisibilityOptions{Target: "private", DeleteSharedBranch: true, RemoveSharedFiles: true}), "private") + runGit(t, r.invocation, "push", "-q", "origin", "main") + if r.remoteBranchExists(t, layout.SharedName) { + t.Fatal("origin still has the docket branch after the first clone's switch") + } + + preview := b.runSetVisibility(t, SetVisibilityOptions{Target: "private"}) + if !preview.ConfirmationRequired() { + t.Fatalf("second clone preview = %q (%s), want confirmation-required", preview.Result, preview.HumanText()) + } + if got := phaseStatus(preview, "publish"); got != visibilityPhaseDone { + t.Errorf("publish = %q, want done (the store already holds the history)", got) + } + for _, p := range preview.Phases { + if p.Name == "config" && !strings.Contains(p.Detail, "HEAD") { + t.Errorf("config detail = %q, want it to name the primary checkout's HEAD", p.Detail) + } + } + res := b.runSetVisibility(t, SetVisibilityOptions{Target: "private", Authorized: true, ExpectedSource: preview.SourceRev()}) + requireSwitchApplied(t, res, "private") + if got := phaseStatus(res, "publish"); got != visibilityPhaseDone { + t.Errorf("publish = %q after the run, want done", got) + } + requireNoSharedStateLeft(t, b) + checkout := expectedPrivateLayout(t, b.invocation, data).MetadataWorktree + if _, err := os.Stat(filepath.Join(checkout, filepath.FromSlash(created.Path))); err != nil { + t.Errorf("the second clone's checkout lacks the first clone's change %s: %v", created.Path, err) + } +} + +// --- going shared ------------------------------------------------------------ + +// sharedSwitchLessons is the promoted-lessons paragraph a born-private +// repository's instructions file carries outside its dispatch block. +const sharedSwitchLessons = "## Lessons\n\n- Prefer the narrow fix over the clever one.\n" + +// newBornPrivateRepo is a repository set up private from the start, with the +// codex harness opted in so its dispatch block lives in the private +// instructions file. It returns the repository and the pinned data home. +func newBornPrivateRepo(t *testing.T) (*initRepo, string) { + t.Helper() + r, data := newPrivateInitRepo(t, nil) + if res := r.runInitWith(t, InitOptions{Private: true, Harnesses: harnessFlag("codex")}); res.Result != ResultApplied { + t.Fatalf("private init = %q (%s), want applied", res.Result, res.HumanText()) + } + return r, data +} + +// commitNameStatus maps each path of commit to its --name-status letter. +func commitNameStatus(t *testing.T, dir, commit string) map[string]string { + t.Helper() + got := map[string]string{} + for _, line := range strings.Split(runGit(t, dir, "show", "--name-status", "--format=", commit), "\n") { + if f := strings.Fields(line); len(f) == 2 { + got[f[1]] = f[0] + } + } + return got +} + +// requireAddCommit asserts commit carries exactly the fixed add subject with no +// body, and touches exactly the paths in want. +func requireAddCommit(t *testing.T, dir, commit string, want ...string) { + t.Helper() + if msg := runGit(t, dir, "log", "-1", "--format=%B", commit); msg != visibilityAddSubject { + t.Errorf("commit %s message = %q, want exactly %q", commit, msg, visibilityAddSubject) + } + got := commitNameStatus(t, dir, commit) + if len(got) != len(want) { + t.Errorf("commit %s holds %v, want exactly %v", commit, got, want) + } + for _, p := range want { + if _, ok := got[p]; !ok { + t.Errorf("commit %s lacks %s (%v)", commit, p, got) + } + } +} + +// requireSharedLayoutRestored asserts the clone holds only the shared state +// folder, the .docket checkout on the docket branch with hooks off, no dckt +// remote or branch, and no exclude block. +func requireSharedLayoutRestored(t *testing.T, r *initRepo) { + t.Helper() + gitDir := r.gitDir(t) + if _, err := os.Stat(filepath.Join(gitDir, layout.PrivateName)); !os.IsNotExist(err) { + t.Errorf("the private state folder is still present (stat err %v)", err) + } + if fi, err := os.Stat(filepath.Join(gitDir, layout.SharedName)); err != nil || !fi.IsDir() { + t.Errorf("the shared state folder is missing (err %v)", err) + } + dotDocket := filepath.Join(r.invocation, layout.SharedWorktreeDir) + if got := runGit(t, dotDocket, "rev-parse", "--abbrev-ref", "HEAD"); got != layout.SharedName { + t.Errorf(".docket is on %q, want docket", got) + } + if off := runGit(t, dotDocket, "config", "--worktree", "core.hooksPath"); off == "" { + t.Error("the .docket checkout's hooks are not disabled") + } + if _, err := tryGit(r.invocation, "config", "--get", "remote.dckt.url"); err == nil { + t.Error("the dckt remote is still configured") + } + if localBranchExists(r.invocation, layout.PrivateName) { + t.Error("the local dckt branch is still present") + } + if exclude, err := os.ReadFile(filepath.Join(gitDir, "info", "exclude")); err == nil && strings.Contains(string(exclude), reposetup.ExcludeStart) { + t.Errorf(".git/info/exclude still carries the managed block:\n%s", exclude) + } + for _, name := range []string{layout.PrivateConfigFile, layout.PrivateLocalKeysFile, switchJournalFile} { + if _, err := os.Stat(filepath.Join(gitDir, layout.SharedName, name)); !os.IsNotExist(err) { + t.Errorf("%s is left in the shared state folder (stat err %v)", name, err) + } + } +} + +// TestIntegrationRepoVisibilitySharedFromBornPrivate proves the whole +// going-shared switch of a repository that was never shared: the identical +// history on origin, the shared layout, the bare store kept as the backup, one +// local commit holding .docket.yml, the .gitignore block, and the dispatch +// block, the promoted lessons reported, and origin's main never pushed. +func TestIntegrationRepoVisibilitySharedFromBornPrivate(t *testing.T) { + r, data := newBornPrivateRepo(t) + createSwitchChange(t, r.invocation) + gitDir := r.gitDir(t) + insPath := filepath.Join(gitDir, layout.PrivateName, layout.PrivateInstructionsFile) + if err := os.WriteFile(insPath, append(mustReadFile(t, insPath), []byte("\n"+sharedSwitchLessons)...), 0o644); err != nil { + t.Fatal(err) + } + lay := expectedPrivateLayout(t, r.invocation, data) + bareTip := runGit(t, lay.DefaultBareRemote, "rev-parse", "refs/heads/dckt") + originMain := r.originTip(t, "main") + before := runGit(t, r.invocation, "rev-parse", "HEAD") + + res := r.switchVisibility(t, SetVisibilityOptions{Target: "shared"}) + requireSwitchApplied(t, res, "shared") + + if got := r.originTip(t, layout.SharedName); got != bareTip { + t.Errorf("origin's docket = %s, want the bare dckt tip %s", got, bareTip) + } + requireSharedLayoutRestored(t, r) + if got := runGit(t, lay.DefaultBareRemote, "rev-parse", "refs/heads/dckt"); got != bareTip { + t.Errorf("the backup store's dckt moved to %s, want it kept at %s", got, bareTip) + } + canonicalStore, _ := filepath.EvalSymlinks(lay.DefaultBareRemote) + if res.BackupRemote != lay.DefaultBareRemote && res.BackupRemote != canonicalStore { + t.Errorf("BackupRemote = %q, want the store %s", res.BackupRemote, lay.DefaultBareRemote) + } + if !strings.Contains(res.HumanText(), res.BackupRemote) || res.BackupRemote == "" { + t.Errorf("the output does not name the backup remote:\n%s", res.HumanText()) + } + + if n := runGit(t, r.invocation, "rev-list", "--count", before+"..HEAD"); n != "1" { + t.Fatalf("%s new commits on main, want exactly one", n) + } + requireAddCommit(t, r.invocation, "HEAD", ".docket.yml", ".gitignore", "AGENTS.md") + if yml := runGit(t, r.invocation, "show", "HEAD:.docket.yml"); !strings.Contains(yml, "visibility: shared") { + t.Errorf("the committed .docket.yml lacks visibility: shared:\n%s", yml) + } + if ign := runGit(t, r.invocation, "show", "HEAD:.gitignore"); !reposetup.ValidGitignoreBlock([]byte(ign + "\n")) { + t.Errorf("the committed .gitignore lacks the valid block:\n%s", ign) + } + if agents := runGit(t, r.invocation, "show", "HEAD:AGENTS.md"); !strings.Contains(agents, document.MarkerSpelling(instructionsBlockName)+":start") { + t.Errorf("the committed AGENTS.md lacks the dispatch block:\n%s", agents) + } + if len(res.Commits) != 1 || res.Commits[0].Status != visibilityCommitCommitted || res.Commits[0].Subject != visibilityAddSubject || + res.Commits[0].Commit != runGit(t, r.invocation, "rev-parse", "HEAD") { + t.Errorf("Commits = %+v, want the one committed add row", res.Commits) + } + if !strings.Contains(res.Lessons, "Prefer the narrow fix over the clever one.") { + t.Errorf("Lessons = %q, want the promoted lessons paragraph", res.Lessons) + } + if strings.Contains(res.Lessons, document.MarkerSpelling(instructionsBlockName)) { + t.Errorf("Lessons = %q, want the dispatch block left out", res.Lessons) + } + if ins := Instructions(r.invocation, InstructionsSectionAll); ins.Private || ins.Content != "" { + t.Errorf("Instructions = %+v, want a shared repository with nothing printed", ins) + } + if got := r.originTip(t, "main"); got != originMain { + t.Errorf("origin's main moved to %s; the switch must never push it", got) + } + if out := runGit(t, r.invocation, "status", "--porcelain", "--untracked-files=all"); out != "" { + t.Errorf("the working tree is not clean after the switch:\n%s", out) + } + if again := r.runSetVisibility(t, SetVisibilityOptions{Target: "shared"}); again.Result != ResultNoOp { + t.Errorf("re-preview = %q (%s), want no-op", again.Result, again.HumanText()) + } +} + +// TestIntegrationRepoVisibilitySharedCommitsOnlyTrackedSurfaces proves going +// shared with claude and cursor opted in completes: the add commit carries the +// tracked surfaces (CLAUDE.md, AGENTS.md) but never the Cursor dispatch rule, +// which the managed .gitignore block ignores, and a re-run is a no-op. +func TestIntegrationRepoVisibilitySharedCommitsOnlyTrackedSurfaces(t *testing.T) { + r, _ := newPrivateInitRepo(t, nil) + if res := r.runInitWith(t, InitOptions{Private: true, Harnesses: harnessFlag("codex", "claude", "cursor")}); res.Result != ResultApplied { + t.Fatalf("private init = %q (%s), want applied", res.Result, res.HumanText()) + } + before := runGit(t, r.invocation, "rev-parse", "HEAD") + + // The preview lists the commit's real paths: the tracked surfaces, never + // the ignored Cursor rule. + preview := r.runSetVisibility(t, SetVisibilityOptions{Target: "shared"}) + if !preview.ConfirmationRequired() || len(preview.Commits) != 1 { + t.Fatalf("preview = %q (%s), want confirmation-required with one planned commit", preview.Result, preview.HumanText()) + } + if want := []string{".docket.yml (write)", ".gitignore (write)", "AGENTS.md (write)", "CLAUDE.md (write)"}; strings.Join(preview.Commits[0].Paths, ",") != strings.Join(want, ",") { + t.Errorf("preview rows = %q, want %q", preview.Commits[0].Paths, want) + } + + res := r.switchVisibility(t, SetVisibilityOptions{Target: "shared"}) + requireSwitchApplied(t, res, "shared") + + if n := runGit(t, r.invocation, "rev-list", "--count", before+"..HEAD"); n != "1" { + t.Fatalf("%s new commits on main, want exactly one", n) + } + requireAddCommit(t, r.invocation, "HEAD", ".docket.yml", ".gitignore", "AGENTS.md", "CLAUDE.md") + const cursorRule = ".cursor/rules/docket-dispatch.mdc" + if _, err := os.Stat(filepath.Join(r.invocation, cursorRule)); err != nil { + t.Errorf("the Cursor dispatch rule is not installed: %v", err) + } + if _, err := tryGit(r.invocation, "check-ignore", "-q", cursorRule); err != nil { + t.Errorf("the Cursor dispatch rule is not ignored: %v", err) + } + requireSharedLayoutRestored(t, r) + if out := runGit(t, r.invocation, "status", "--porcelain", "--untracked-files=all"); out != "" { + t.Errorf("the working tree is not clean after the switch:\n%s", out) + } + if again := r.runSetVisibility(t, SetVisibilityOptions{Target: "shared"}); again.Result != ResultNoOp { + t.Errorf("re-preview = %q (%s), want no-op", again.Result, again.HumanText()) + } +} + +// TestIntegrationRepoVisibilitySharedIdentityKeysStopThenResume proves a +// private config that sets a repository-identity key first commits .docket.yml +// and stops (nothing published), refuses until that commit reaches origin's +// default branch, then completes without recommitting .docket.yml. +func TestIntegrationRepoVisibilitySharedIdentityKeysStopThenResume(t *testing.T) { + r, _ := newBornPrivateRepo(t) + cfgPath := filepath.Join(r.gitDir(t), layout.PrivateName, layout.PrivateConfigFile) + if err := os.WriteFile(cfgPath, append(mustReadFile(t, cfgPath), []byte("integration_branch: main\n")...), 0o644); err != nil { + t.Fatal(err) + } + before := runGit(t, r.invocation, "rev-parse", "HEAD") + + run1 := r.switchVisibility(t, SetVisibilityOptions{Target: "shared"}) + // The stop is a needs-review refusal naming the remedy, never applied: the + // repository is still private. + if run1.Result != ResultInvalidState || run1.RepositoryState != string(reposetup.StateNeedsReview) { + t.Fatalf("run 1 = %q state %q (%s), want invalid-state needs-review", run1.Result, run1.RepositoryState, run1.HumanText()) + } + if len(run1.Commits) != 1 || run1.Commits[0].Status != visibilityCommitCommitted || run1.Commits[0].Commit == "" { + t.Errorf("run 1 commits = %+v, want the one committed identity commit", run1.Commits) + } + if got := phaseStatus(run1, "identity-keys"); got != visibilityPhaseApplied { + t.Errorf("identity-keys = %q, want applied", got) + } + if got := phaseStatus(run1, "publish"); got != visibilityPhasePending { + t.Errorf("publish = %q, want still pending after the stop", got) + } + if n := runGit(t, r.invocation, "rev-list", "--count", before+"..HEAD"); n != "1" { + t.Fatalf("%s new commits after run 1, want exactly one", n) + } + identityCommit := runGit(t, r.invocation, "rev-parse", "HEAD") + if len(run1.Commits) == 1 && run1.Commits[0].Commit != identityCommit { + t.Errorf("run 1 reports commit %s, want HEAD %s", run1.Commits[0].Commit, identityCommit) + } + requireAddCommit(t, r.invocation, identityCommit, ".docket.yml") + if yml := runGit(t, r.invocation, "show", "HEAD:.docket.yml"); !strings.Contains(yml, "integration_branch: main") { + t.Errorf("the identity commit's .docket.yml lacks integration_branch:\n%s", yml) + } + remedy := "get this commit onto main on origin" + if !strings.Contains(run1.HumanText(), remedy) { + t.Errorf("run 1 text = %q, want the push remedy", run1.HumanText()) + } + if r.remoteBranchExists(t, layout.SharedName) { + t.Error("origin gained a docket branch before the identity keys reached it") + } + + run2 := r.switchVisibility(t, SetVisibilityOptions{Target: "shared"}) + requireVisibilityRefusal(t, run2, remedy) + if got := runGit(t, r.invocation, "rev-parse", "HEAD"); got != identityCommit { + t.Errorf("run 2 moved HEAD to %s, want it kept at the identity commit", got) + } + if r.remoteBranchExists(t, layout.SharedName) { + t.Error("run 2 published origin's docket branch") + } + + runGit(t, r.invocation, "push", "-q", "origin", "main") + run3 := r.switchVisibility(t, SetVisibilityOptions{Target: "shared"}) + requireSwitchApplied(t, run3, "shared") + if got := phaseStatus(run3, "identity-keys"); got != visibilityPhaseDone { + t.Errorf("identity-keys = %q on run 3, want done", got) + } + if n := runGit(t, r.invocation, "rev-list", "--count", identityCommit+"..HEAD"); n != "1" { + t.Fatalf("%s new commits on run 3, want exactly one", n) + } + requireAddCommit(t, r.invocation, "HEAD", ".gitignore", "AGENTS.md") + requireSharedLayoutRestored(t, r) +} + +// TestIntegrationRepoVisibilitySharedFastForwardsLeftBehindBranch proves a +// round trip without --delete-shared-branch, with a private write in between, +// fast-forwards the docket branch origin kept instead of refusing it. +func TestIntegrationRepoVisibilitySharedFastForwardsLeftBehindBranch(t *testing.T) { + r, data := newPrivateSwitchRepo(t) + leftBehind := r.originTip(t, layout.SharedName) + requireSwitchApplied(t, r.switchVisibility(t, SetVisibilityOptions{Target: "private"}), "private") + createSwitchChange(t, r.invocation) + bareTip := runGit(t, expectedPrivateLayout(t, r.invocation, data).DefaultBareRemote, "rev-parse", "refs/heads/dckt") + if bareTip == leftBehind { + t.Fatal("the private write did not advance the dckt branch") + } + + res := r.switchVisibility(t, SetVisibilityOptions{Target: "shared"}) + requireSwitchApplied(t, res, "shared") + if got := r.originTip(t, layout.SharedName); got != bareTip { + t.Errorf("origin's docket = %s, want it fast-forwarded to %s", got, bareTip) + } + if _, err := tryGit(r.origin, "merge-base", "--is-ancestor", leftBehind, bareTip); err != nil { + t.Errorf("the left-behind tip %s is not an ancestor of the published tip %s", leftBehind, bareTip) + } + requireSharedLayoutRestored(t, r) +} + +// TestIntegrationRepoVisibilitySharedRefusesUnrelatedOriginBranch proves an +// unrelated docket branch already on origin refuses the publish, and nothing +// is renamed or pushed. +func TestIntegrationRepoVisibilitySharedRefusesUnrelatedOriginBranch(t *testing.T) { + r, _ := newBornPrivateRepo(t) + foreign := filepath.Join(r.root, "foreign") + runGit(t, r.root, "init", "-q", "-b", layout.SharedName, foreign) + gitIdentity(t, foreign) + writeRepoFile(t, foreign, "other.md", "another backlog\n") + runGit(t, foreign, "add", "-A") + runGit(t, foreign, "commit", "-q", "-m", "another backlog") + runGit(t, foreign, "push", "-q", r.origin, layout.SharedName+":refs/heads/"+layout.SharedName) + foreignTip := runGit(t, foreign, "rev-parse", "HEAD") + privateDir := filepath.Join(r.gitDir(t), layout.PrivateName) + stateBefore := treeListing(t, privateDir) + before := runGit(t, r.invocation, "rev-parse", "HEAD") + + res := r.switchVisibility(t, SetVisibilityOptions{Target: "shared"}) + requireVisibilityRefusal(t, res, "two backlogs are never merged", foreignTip) + if got := r.originTip(t, layout.SharedName); got != foreignTip { + t.Errorf("origin's docket moved to %s, want the unrelated tip %s kept", got, foreignTip) + } + if after := treeListing(t, privateDir); after != stateBefore { + t.Errorf("the private state folder changed:\nbefore:\n%s\nafter:\n%s", stateBefore, after) + } + if _, err := os.Stat(filepath.Join(r.gitDir(t), layout.SharedName)); !os.IsNotExist(err) { + t.Errorf("the shared state folder exists after the refusal (stat err %v)", err) + } + if got := runGit(t, r.invocation, "rev-parse", "HEAD"); got != before { + t.Errorf("HEAD moved to %s after the refusal", got) + } +} + +// TestIntegrationRepoVisibilitySharedRestoresLocalKeys proves a clone-local key +// survives shared -> private -> shared in .docket.local.yml (with no +// visibility), and never lands in .docket.yml. The going-shared run is +// interrupted while the repository still reads private, after .docket.yml is +// written: no .docket.local.yml may exist yet (a private repository refuses +// one beside its config), so the re-run resumes and completes. +func TestIntegrationRepoVisibilitySharedRestoresLocalKeys(t *testing.T) { + r, _ := newPrivateSwitchRepo(t) + writeRepoFile(t, r.invocation, localConfigRel, privateSwitchLocalYML) + requireSwitchApplied(t, r.switchVisibility(t, SetVisibilityOptions{Target: "private"}), "private") + interrupted := r.switchVisibilityWithHooks(t, SetVisibilityOptions{Target: "shared"}, setupHooks{ + afterVisibilityPhase: func(phase string) error { + if phase == "config-committed" { + return errors.New("killed after config-committed") + } + return nil + }, + }) + if interrupted.Result != ResultExternalFailed { + t.Fatalf("interrupted run = %q (%s), want external-failed", interrupted.Result, interrupted.HumanText()) + } + requireSwitchApplied(t, r.switchVisibility(t, SetVisibilityOptions{Target: "shared"}), "shared") + + local := mustReadFile(t, filepath.Join(r.invocation, localConfigRel)) + vals, err := reposetup.ConfigLeafValues(local, []string{"reclaim.auto", "visibility"}) + if err != nil { + t.Fatal(err) + } + if vals["reclaim.auto"] != "true" { + t.Errorf(".docket.local.yml = %q, want reclaim.auto: true restored", local) + } + if _, ok := vals["visibility"]; ok { + t.Errorf(".docket.local.yml = %q, want no visibility", local) + } + for _, yml := range []string{ + string(mustReadFile(t, filepath.Join(r.invocation, ".docket.yml"))), + runGit(t, r.invocation, "show", "HEAD:.docket.yml"), + } { + if strings.Contains(yml, "reclaim") { + t.Errorf(".docket.yml carries the clone-local key:\n%s", yml) + } + } + if tracked := runGit(t, r.invocation, "ls-files", "--", localConfigRel); tracked != "" { + t.Errorf(".docket.local.yml is tracked: %q", tracked) + } + if out := runGit(t, r.invocation, "status", "--porcelain", "--untracked-files=all"); out != "" { + t.Errorf("the working tree is not clean after the round trip:\n%s", out) + } + requireSharedLayoutRestored(t, r) +} + +// --- acceptance -------------------------------------------------------------- + +// visibilityIdentityRemedy is the remedy a going-shared run names when its +// identity-keys commit must reach origin's default branch first. +const visibilityIdentityRemedy = "get this commit onto main on origin" + +// driveVisibility switches r to o.Target, pushing main whenever the switch stops +// for its identity-keys commit, until the repository lands in the target mode. +// A preview that is already a no-op counts as landed. +func (r *initRepo) driveVisibility(t *testing.T, o SetVisibilityOptions) RepositorySetVisibilityResult { + t.Helper() + for i := 0; i < 4; i++ { + preview := r.runSetVisibility(t, o) + if preview.Result == ResultNoOp { + return preview + } + if !preview.ConfirmationRequired() { + if strings.Contains(preview.HumanText(), visibilityIdentityRemedy) { + runGit(t, r.invocation, "push", "-q", "origin", "main") + continue + } + t.Fatalf("preview = %q state %q (%s), want confirmation-required", preview.Result, preview.RepositoryState, preview.HumanText()) + } + run := o + run.Authorized, run.ExpectedSource = true, preview.SourceRev() + res := r.runSetVisibility(t, run) + if res.Result == ResultApplied && res.RepositoryState == o.Target { + return res + } + if res.Result == ResultInvalidState && strings.Contains(res.HumanText(), visibilityIdentityRemedy) { + runGit(t, r.invocation, "push", "-q", "origin", "main") + continue + } + t.Fatalf("switch = %q state %q, want applied %s:\n%s", res.Result, res.RepositoryState, o.Target, res.HumanText()) + } + t.Fatalf("the switch to %s did not land in four runs", o.Target) + return RepositorySetVisibilityResult{} +} + +// metadataTreeAt lists every path and blob of commit in dir's repository. +func metadataTreeAt(t *testing.T, dir, commit string) string { + t.Helper() + return runGit(t, dir, "ls-tree", "-r", commit) +} + +// TestIntegrationRepoVisibilitySwitchRoundTripPreservesRecords proves shared -> +// private (both flags) -> shared publishes back the identical metadata history: +// origin's docket tip and tree are what they were, and replaying an earlier +// change create and claim returns their original outcomes without a commit. +func TestIntegrationRepoVisibilitySwitchRoundTripPreservesRecords(t *testing.T) { + r, _ := newPrivateSwitchRepo(t) + ctx := context.Background() + created := createSwitchChange(t, r.invocation) + claimID := created.ID + 1 + recPath := groomPath(claimID, "widget") + dotDocket := filepath.Join(r.invocation, layout.SharedWorktreeDir) + runGit(t, dotDocket, "fetch", "-q", "origin", layout.SharedName) + runGit(t, dotDocket, "merge", "-q", "--ff-only", "FETCH_HEAD") + r.writeDocketFileAndPush(t, recPath, buildReadyChange(claimID, "widget"), "a build-ready change to claim") + node := planningDepsFor(t, r.invocation) + bundle := ContextImplementation(ctx, node.deps, node.dir, ImplementationContextRequest{ID: claimID}) + if bundle.Result != ResultApplied || bundle.Context == nil { + t.Fatalf("context read = %q (reason %q); want a bundle", bundle.Result, bundle.Reason) + } + claimReq := ChangeClaimRequest{ID: claimID, Revision: bundle.Context.Change.Revision} + if claim := ChangeClaim(ctx, node.deps, node.dir, claimReq); claim.Result != ResultApplied || claim.Disposition != ClaimDispositionApplied { + t.Fatalf("claim = (%q, %q), want applied/applied (findings %v)", claim.Result, claim.Disposition, claim.Findings) + } + tip := r.originTip(t, layout.SharedName) + tree := metadataTreeAt(t, r.origin, tip) + + r.driveVisibility(t, SetVisibilityOptions{Target: "private", DeleteSharedBranch: true, RemoveSharedFiles: true}) + runGit(t, r.invocation, "push", "-q", "origin", "main") + if r.remoteBranchExists(t, layout.SharedName) { + t.Fatal("origin still has the docket branch after going private with --delete-shared-branch") + } + r.driveVisibility(t, SetVisibilityOptions{Target: "shared"}) + + if got := r.originTip(t, layout.SharedName); got != tip { + t.Fatalf("origin's docket = %s after the round trip, want the original tip %s", got, tip) + } + if got := metadataTreeAt(t, r.origin, tip); got != tree { + t.Errorf("the metadata tree changed:\nbefore:\n%s\nafter:\n%s", tree, got) + } + + node = planningDepsFor(t, r.invocation) + replay := ChangeCreate(ctx, node.deps, node.dir, validChangeCreateRequest()) + if replay.Result != ResultApplied || !replay.Replayed || replay.ID != created.ID { + t.Errorf("create replay = %q replayed=%v id=%d (findings %v), want a replay of %d", replay.Result, replay.Replayed, replay.ID, replay.Findings, created.ID) + } + claimReplay := ChangeClaim(ctx, node.deps, node.dir, claimReq) + if claimReplay.Result != ResultApplied || claimReplay.Disposition != ClaimDispositionAlreadyClaimed { + t.Errorf("claim replay = (%q, %q), want applied/already-claimed (findings %v)", claimReplay.Result, claimReplay.Disposition, claimReplay.Findings) + } + if got := r.originTip(t, layout.SharedName); got != tip { + t.Errorf("a replay moved origin's docket %s -> %s", tip, got) + } +} + +// visibilityFinalState summarizes what an uninterrupted switch and a resumed +// one must agree on, independent of the fixture's temp paths and object ids: +// which state folders exist, the worktree registrations, every local branch, +// remote, and origin/store branch, how often each fixed subject is on main, the +// working tree status, and whether the metadata tip is still metaTip. +func visibilityFinalState(t *testing.T, r *initRepo, data, metaTip string) string { + t.Helper() + gitDir := r.gitDir(t) + var b strings.Builder + for _, name := range []string{layout.SharedName, layout.PrivateName} { + _, err := os.Stat(filepath.Join(gitDir, name)) + b.WriteString("state-folder " + name + " " + strconv.FormatBool(err == nil) + "\n") + } + worktrees := 0 + for _, line := range strings.Split(runGit(t, r.invocation, "worktree", "list", "--porcelain"), "\n") { + if strings.HasPrefix(line, "worktree ") { + worktrees++ + } + } + b.WriteString("worktrees " + strconv.Itoa(worktrees) + "\n") + b.WriteString("local " + runGit(t, r.invocation, "for-each-ref", "--format=%(refname:short)", "refs/heads") + "\n") + b.WriteString("remotes " + runGit(t, r.invocation, "remote") + "\n") + b.WriteString("origin " + runGit(t, r.origin, "for-each-ref", "--format=%(refname:short)", "refs/heads") + "\n") + store := expectedPrivateLayout(t, r.invocation, data).DefaultBareRemote + if _, err := os.Stat(store); err == nil { + b.WriteString("store " + runGit(t, store, "for-each-ref", "--format=%(refname:short)", "refs/heads") + "\n") + if got, err := tryGit(store, "rev-parse", "--verify", "--quiet", "refs/heads/"+layout.PrivateName); err == nil { + b.WriteString("store-tip-unchanged " + strconv.FormatBool(got == metaTip) + "\n") + } + } + if r.remoteBranchExists(t, layout.SharedName) { + b.WriteString("origin-tip-unchanged " + strconv.FormatBool(r.originTip(t, layout.SharedName) == metaTip) + "\n") + } + subjects := strings.Split(runGit(t, r.invocation, "log", "--format=%s", "main"), "\n") + for _, s := range []string{visibilityRemoveSubject, visibilityAddSubject} { + n := 0 + for _, got := range subjects { + if got == s { + n++ + } + } + b.WriteString("subject " + s + " " + strconv.Itoa(n) + "\n") + } + b.WriteString("status " + runGit(t, r.invocation, "status", "--porcelain", "--untracked-files=all") + "\n") + return b.String() +} + +// requireNoSwitchDebris asserts the state folders are never both present, no +// journal is left, and the shared state folder holds no private config files. +func requireNoSwitchDebris(t *testing.T, r *initRepo) { + t.Helper() + gitDir := r.gitDir(t) + _, serr := os.Stat(filepath.Join(gitDir, layout.SharedName)) + _, perr := os.Stat(filepath.Join(gitDir, layout.PrivateName)) + if serr == nil && perr == nil { + t.Error("both state folders exist") + } + for _, folder := range []string{layout.SharedName, layout.PrivateName} { + if _, err := os.Stat(filepath.Join(gitDir, folder, switchJournalFile)); !os.IsNotExist(err) { + t.Errorf("the journal is left in %s (stat err %v)", folder, err) + } + } + for _, name := range []string{layout.PrivateConfigFile, layout.PrivateLocalKeysFile} { + if _, err := os.Stat(filepath.Join(gitDir, layout.SharedName, name)); !os.IsNotExist(err) { + t.Errorf("%s is left in the shared state folder (stat err %v)", name, err) + } + } +} + +// interruptAt is a hook that fails the run right after phase. +func interruptAt(phase string) setupHooks { + return setupHooks{afterVisibilityPhase: func(p string) error { + if p == phase { + return errors.New("killed after " + phase) + } + return nil + }} +} + +// visibilityDirection is one direction of the interruption table: a fixture +// builder returning the repository, its data home, and its metadata tip, and +// the options of the switch. +type visibilityDirection struct { + name string + fixture func(t *testing.T) (*initRepo, string, string) + o SetVisibilityOptions +} + +// TestIntegrationRepoVisibilityPrivateInterruptedPhasesResume proves a switch +// to private killed right after any phase completes on the next run and lands +// in exactly the state an uninterrupted switch does: the same folders, +// worktree registrations, branches, and remotes, each fixed subject once, the +// metadata tip unchanged, and no switch debris. +func TestIntegrationRepoVisibilityPrivateInterruptedPhasesResume(t *testing.T) { + requireInterruptedPhasesResume(t, visibilityDirection{ + name: "private", + fixture: func(t *testing.T) (*initRepo, string, string) { + r, data := newPrivateSwitchRepo(t) + return r, data, r.originTip(t, layout.SharedName) + }, + o: SetVisibilityOptions{Target: "private", DeleteSharedBranch: true, RemoveSharedFiles: true}, + }) +} + +// TestIntegrationRepoVisibilitySharedInterruptedPhasesResume is the shared +// direction of the same proof, plus the identity-keys phase that is pending +// only when the private config sets a repository identity key. +func TestIntegrationRepoVisibilitySharedInterruptedPhasesResume(t *testing.T) { + requireInterruptedPhasesResume(t, visibilityDirection{ + name: "shared", + fixture: func(t *testing.T) (*initRepo, string, string) { + r, data := newBornPrivateRepo(t) + return r, data, runGit(t, expectedPrivateLayout(t, r.invocation, data).DefaultBareRemote, "rev-parse", "refs/heads/"+layout.PrivateName) + }, + o: SetVisibilityOptions{Target: "shared"}, + }) + + // identity-keys is pending only when the private config sets a repository + // identity key; a kill right after its commit still resumes once that + // commit reaches origin. + t.Run("identity-keys", func(t *testing.T) { + r, _ := newBornPrivateRepo(t) + cfgPath := filepath.Join(r.gitDir(t), layout.PrivateName, layout.PrivateConfigFile) + if err := os.WriteFile(cfgPath, append(mustReadFile(t, cfgPath), []byte("integration_branch: main\n")...), 0o644); err != nil { + t.Fatal(err) + } + before := runGit(t, r.invocation, "rev-parse", "HEAD") + first := r.switchVisibilityWithHooks(t, SetVisibilityOptions{Target: "shared"}, interruptAt("identity-keys")) + if first.Result != ResultExternalFailed { + t.Fatalf("run 1 = %q (%s), want external-failed", first.Result, first.HumanText()) + } + r.driveVisibility(t, SetVisibilityOptions{Target: "shared"}) + requireNoSwitchDebris(t, r) + requireSharedLayoutRestored(t, r) + if n := runGit(t, r.invocation, "rev-list", "--count", before+"..HEAD"); n != "2" { + t.Errorf("%s new commits, want the identity commit and the final commit", n) + } + }) +} + +// requireInterruptedPhasesResume runs dir's switch once uninterrupted for the +// reference state and its pending phases, then once per pending phase killed +// right after that phase and resumed, requiring the resumed state to equal the +// reference. +func requireInterruptedPhasesResume(t *testing.T, dir visibilityDirection) { + t.Helper() + var want string + var pending []string + t.Run("uninterrupted", func(t *testing.T) { + r, data, tip := dir.fixture(t) + preview := r.runSetVisibility(t, dir.o) + for _, p := range preview.Phases { + if p.Status == visibilityPhasePending { + pending = append(pending, p.Name) + } + } + requireSwitchApplied(t, r.switchVisibility(t, dir.o), dir.o.Target) + requireNoSwitchDebris(t, r) + want = visibilityFinalState(t, r, data, tip) + }) + if want == "" || len(pending) < 5 { + t.Fatalf("the uninterrupted %s switch left no reference state (pending phases %q)", dir.name, pending) + } + for _, phase := range pending { + t.Run(phase, func(t *testing.T) { + r, data, tip := dir.fixture(t) + first := r.switchVisibilityWithHooks(t, dir.o, interruptAt(phase)) + if first.Result != ResultExternalFailed { + t.Fatalf("run 1 = %q (%s), want external-failed at %s", first.Result, first.HumanText(), phase) + } + requireNoSwitchDebrisFolders(t, r) + r.driveVisibility(t, dir.o) + requireNoSwitchDebris(t, r) + if got := visibilityFinalState(t, r, data, tip); got != want { + t.Errorf("resumed after %s:\n%s\nwant (uninterrupted):\n%s", phase, got, want) + } + }) + } +} + +// requireNoSwitchDebrisFolders asserts an interrupted run never leaves both +// state folders. +func requireNoSwitchDebrisFolders(t *testing.T, r *initRepo) { + t.Helper() + gitDir := r.gitDir(t) + _, serr := os.Stat(filepath.Join(gitDir, layout.SharedName)) + _, perr := os.Stat(filepath.Join(gitDir, layout.PrivateName)) + if serr == nil && perr == nil { + t.Error("both state folders exist after the interrupted run") + } +} + +// TestIntegrationRepoVisibilityPrivateResultIsClean proves a repository that +// went private with both flags checks clean: no error, no metadata on the +// shared remote, no visibility mismatch, nothing docket-named at the root, and +// the metadata checkout outside the primary checkout. +func TestIntegrationRepoVisibilityPrivateResultIsClean(t *testing.T) { + r, data := newPrivateSwitchRepo(t) + createSwitchChange(t, r.invocation) + r.driveVisibility(t, SetVisibilityOptions{Target: "private", DeleteSharedBranch: true, RemoveSharedFiles: true}) + // The removal commit is the user's to publish; check reads an unpushed + // local commit as its own finding. + runGit(t, r.invocation, "push", "-q", "origin", "main") + + chk := r.runCheck(t) + for _, f := range chk.Findings { + if f.Severity == reposetup.SeverityError || f.Code == FindingMetadataOnSharedRemote || f.Code == FindingVisibilityMismatch { + t.Errorf("check reported %+v", f) + } + } + entries, err := os.ReadDir(r.invocation) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.Name() != ".git" && strings.Contains(strings.ToLower(e.Name()), "docket") { + t.Errorf("the root still holds %s", e.Name()) + } + } + checkout := expectedPrivateLayout(t, r.invocation, data).MetadataWorktree + if rel, err := filepath.Rel(r.invocation, checkout); err != nil || !strings.HasPrefix(rel, "..") { + t.Errorf("the metadata checkout %s is inside the primary checkout %s", checkout, r.invocation) + } + if rel, err := filepath.Rel(canonicalDir(t, r.invocation), canonicalDir(t, checkout)); err != nil || !strings.HasPrefix(rel, "..") { + t.Errorf("the metadata checkout %s resolves inside the primary checkout %s", checkout, r.invocation) + } +} + +// canonicalDir resolves dir's symlinks. +func canonicalDir(t *testing.T, dir string) string { + t.Helper() + out, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + return out +} + +// TestIntegrationRepoVisibilitySwitchCommitMessages proves every commit a switch makes +// carries one of the two fixed subjects and no body, a hand-edited .gitignore +// refuses the going-shared preview, a detached HEAD refuses a committing plan, +// and a commit hook that fails once leaves the edits for a re-run that makes +// exactly one commit. +func TestIntegrationRepoVisibilitySwitchCommitMessages(t *testing.T) { + t.Run("round trip subjects", func(t *testing.T) { + r, _ := newPrivateSwitchRepo(t) + before := runGit(t, r.invocation, "rev-parse", "HEAD") + r.driveVisibility(t, SetVisibilityOptions{Target: "private", RemoveSharedFiles: true}) + r.driveVisibility(t, SetVisibilityOptions{Target: "shared"}) + commits := strings.Fields(runGit(t, r.invocation, "rev-list", before+"..HEAD")) + if len(commits) < 2 { + t.Fatalf("%d switch commits, want at least the removal and the addition", len(commits)) + } + for _, c := range commits { + subject := runGit(t, r.invocation, "log", "-1", "--format=%s", c) + if subject != visibilityRemoveSubject && subject != visibilityAddSubject { + t.Errorf("commit %s subject = %q, want one of the two fixed subjects", c, subject) + } + if body := runGit(t, r.invocation, "log", "-1", "--format=%b", c); body != "" { + t.Errorf("commit %s body = %q, want none", c, body) + } + } + }) + + t.Run("hand-edited gitignore refuses", func(t *testing.T) { + r, _ := newPrivateSwitchRepo(t) + r.driveVisibility(t, SetVisibilityOptions{Target: "private"}) + ign := filepath.Join(r.invocation, ".gitignore") + if err := os.WriteFile(ign, append(mustReadFile(t, ign), []byte("scratch/\n")...), 0o644); err != nil { + t.Fatal(err) + } + res := r.runSetVisibility(t, SetVisibilityOptions{Target: "shared"}) + requireVisibilityRefusal(t, res, "commit or set aside these edits", ".gitignore") + }) + + t.Run("detached HEAD refuses", func(t *testing.T) { + r, _ := newPrivateSwitchRepo(t) + runGit(t, r.invocation, "checkout", "-q", "--detach") + res := r.runSetVisibility(t, SetVisibilityOptions{Target: "private", RemoveSharedFiles: true}) + requireVisibilityRefusal(t, res, "detached HEAD") + }) + + t.Run("hook fails once", func(t *testing.T) { + r, _ := newPrivateSwitchRepo(t) + gitDir := r.gitDir(t) + marker := filepath.Join(testsupport.TempDir(t), "rejected-once") + hook := "#!/bin/sh\nif [ -e '" + marker + "' ]; then exit 0; fi\n: > '" + marker + "'\nexit 1\n" + if err := os.MkdirAll(filepath.Join(gitDir, "hooks"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitDir, "hooks", "commit-msg"), []byte(hook), 0o755); err != nil { + t.Fatal(err) + } + before := runGit(t, r.invocation, "rev-parse", "HEAD") + o := SetVisibilityOptions{Target: "private", RemoveSharedFiles: true} + first := r.switchVisibility(t, o) + if first.Result != ResultExternalFailed { + t.Fatalf("run 1 = %q (%s), want external-failed on the hook's rejection", first.Result, first.HumanText()) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("the commit hook did not run: %v", err) + } + if got := runGit(t, r.invocation, "rev-parse", "HEAD"); got != before { + t.Fatalf("HEAD moved to %s on a rejected commit", got) + } + if _, err := os.Stat(filepath.Join(r.invocation, ".docket.yml")); !os.IsNotExist(err) { + t.Errorf("the journaled deletion of .docket.yml was not kept (stat err %v)", err) + } + if len(first.Commits) != 1 || first.Commits[0].Status != visibilityCommitFailed { + t.Errorf("Commits = %+v, want one failed row", first.Commits) + } + requireSwitchApplied(t, r.switchVisibility(t, o), "private") + requireRemovalCommit(t, r, before) + }) +} + +// TestIntegrationRepoVisibilitySwitchUserEditsSurvive proves a staged unrelated file +// and an unstaged edit to a tracked file outside the switch's paths survive +// both directions, never entering a switch commit. +func TestIntegrationRepoVisibilitySwitchUserEditsSurvive(t *testing.T) { + r, _ := newPrivateSwitchRepo(t) + writeRepoFile(t, r.invocation, "notes.txt", "mine\n") + runGit(t, r.invocation, "add", "--", "notes.txt") + writeRepoFile(t, r.invocation, "README.md", "readme\nmy unstaged edit\n") + before := runGit(t, r.invocation, "rev-parse", "HEAD") + + requireEdits := func(when string) { + t.Helper() + if staged := runGit(t, r.invocation, "diff", "--cached", "--name-only"); staged != "notes.txt" { + t.Errorf("%s: staged = %q, want notes.txt", when, staged) + } + if unstaged := runGit(t, r.invocation, "diff", "--name-only"); unstaged != "README.md" { + t.Errorf("%s: unstaged = %q, want README.md", when, unstaged) + } + if got := string(mustReadFile(t, filepath.Join(r.invocation, "README.md"))); got != "readme\nmy unstaged edit\n" { + t.Errorf("%s: README.md = %q, want the user's edit", when, got) + } + for _, c := range strings.Fields(runGit(t, r.invocation, "rev-list", before+"..HEAD")) { + for p := range commitNameStatus(t, r.invocation, c) { + if p == "notes.txt" || p == "README.md" { + t.Errorf("%s: switch commit %s holds the user's %s", when, c, p) + } + } + } + } + r.driveVisibility(t, SetVisibilityOptions{Target: "private", RemoveSharedFiles: true}) + requireEdits("after going private") + r.driveVisibility(t, SetVisibilityOptions{Target: "shared"}) + requireEdits("after going shared") +} + +// TestIntegrationRepoVisibilitySwitchSecondCloneShared proves two clones sharing one +// private store: once the first goes shared, the second's publish is already +// done and it completes with local phases only, leaving origin and the store +// untouched. +func TestIntegrationRepoVisibilitySwitchSecondCloneShared(t *testing.T) { + a, data := newPrivateSwitchRepo(t) + b := &initRepo{root: a.root, origin: a.origin, writer: a.writer, invocation: filepath.Join(a.root, "second")} + runGit(t, a.root, "clone", "-q", a.origin, b.invocation) + gitIdentity(t, b.invocation) + if res := b.runInitWith(t, InitOptions{}); res.Result != ResultApplied && res.Result != ResultNoOp { + t.Fatalf("second clone init = %q (%s)", res.Result, res.HumanText()) + } + a.driveVisibility(t, SetVisibilityOptions{Target: "private"}) + b.driveVisibility(t, SetVisibilityOptions{Target: "private"}) + if expectedPrivateLayout(t, a.invocation, data).DefaultBareRemote != expectedPrivateLayout(t, b.invocation, data).DefaultBareRemote { + t.Fatal("the two clones do not share one private store") + } + a.driveVisibility(t, SetVisibilityOptions{Target: "shared"}) + + store := expectedPrivateLayout(t, b.invocation, data).DefaultBareRemote + originRefs := runGit(t, b.origin, "for-each-ref", "--format=%(refname) %(objectname)") + storeRefs := runGit(t, store, "for-each-ref", "--format=%(refname) %(objectname)") + preview := b.runSetVisibility(t, SetVisibilityOptions{Target: "shared"}) + if !preview.ConfirmationRequired() { + t.Fatalf("second clone preview = %q (%s), want confirmation-required", preview.Result, preview.HumanText()) + } + for _, name := range []string{"identity-keys", "publish"} { + if got := phaseStatus(preview, name); got != visibilityPhaseDone { + t.Errorf("%s = %q, want done (the first clone already published)", name, got) + } + } + res := b.runSetVisibility(t, SetVisibilityOptions{Target: "shared", Authorized: true, ExpectedSource: preview.SourceRev()}) + requireSwitchApplied(t, res, "shared") + if got := runGit(t, b.origin, "for-each-ref", "--format=%(refname) %(objectname)"); got != originRefs { + t.Errorf("the second clone's switch changed origin:\nbefore:\n%s\nafter:\n%s", originRefs, got) + } + if got := runGit(t, store, "for-each-ref", "--format=%(refname) %(objectname)"); got != storeRefs { + t.Errorf("the second clone's switch changed the store:\nbefore:\n%s\nafter:\n%s", storeRefs, got) + } + requireSharedLayoutRestored(t, b) + requireNoSwitchDebris(t, b) +} + +// TestIntegrationRepoVisibilitySwitchSameModeIsAlignOnly proves a switch to the mode a +// never-switched repository is already in only aligns the clone-local +// visibility value: a freshly initialized shared repository with uncommitted +// edits to the switch's own paths (init's unstaged .gitignore block, a dirty +// AGENTS.md) neither refuses nor commits, and leaves every edit as it was; a +// born-private repository switched to private is likewise a no-op. +func TestIntegrationRepoVisibilitySwitchSameModeIsAlignOnly(t *testing.T) { + newDirtyShared := func(t *testing.T) *initRepo { + t.Helper() + t.Setenv("XDG_DATA_HOME", testsupport.TempDir(t)) + r := newInitRepo(t, privateSwitchYML, nil) + if res := r.runInitWith(t, InitOptions{}); res.Result != ResultApplied { + t.Fatalf("init = %q (%s), want applied", res.Result, res.HumanText()) + } + agents := filepath.Join(r.invocation, "AGENTS.md") + writeRepoFile(t, r.invocation, "AGENTS.md", string(mustReadFile(t, agents))+"\nmy own note\n") + return r + } + requireUntouched := func(t *testing.T, r *initRepo, head, status, agents string) { + t.Helper() + if got := runGit(t, r.invocation, "rev-parse", "HEAD"); got != head { + t.Errorf("HEAD moved %s -> %s; a same-mode switch must not commit", head, got) + } + if got := runGit(t, r.invocation, "status", "--porcelain", "--untracked-files=all"); got != status { + t.Errorf("the working tree changed:\nbefore:\n%s\nafter:\n%s", status, got) + } + if got := string(mustReadFile(t, filepath.Join(r.invocation, "AGENTS.md"))); got != agents { + t.Errorf("AGENTS.md changed:\nbefore:\n%s\nafter:\n%s", agents, got) + } + if _, err := os.Stat(filepath.Join(r.gitDir(t), layout.SharedName, switchJournalFile)); !os.IsNotExist(err) { + t.Errorf("a switch journal was left (stat err %v)", err) + } + } + + t.Run("shared to shared is a no-op", func(t *testing.T) { + r := newDirtyShared(t) + head := runGit(t, r.invocation, "rev-parse", "HEAD") + status := runGit(t, r.invocation, "status", "--porcelain", "--untracked-files=all") + agents := string(mustReadFile(t, filepath.Join(r.invocation, "AGENTS.md"))) + if preview := r.runSetVisibility(t, SetVisibilityOptions{Target: "shared"}); preview.Result != ResultNoOp { + t.Errorf("preview = %q state %q, want no-op:\n%s", preview.Result, preview.RepositoryState, preview.HumanText()) + } + if res := r.runSetVisibility(t, SetVisibilityOptions{Target: "shared", Authorized: true}); res.Result != ResultNoOp { + t.Errorf("--yes = %q state %q, want no-op:\n%s", res.Result, res.RepositoryState, res.HumanText()) + } + requireUntouched(t, r, head, status, agents) + }) + + t.Run("shared to shared aligns only the local value", func(t *testing.T) { + r := newDirtyShared(t) + writeRepoFile(t, r.invocation, ".docket.local.yml", "visibility: private\n") + head := runGit(t, r.invocation, "rev-parse", "HEAD") + agents := string(mustReadFile(t, filepath.Join(r.invocation, "AGENTS.md"))) + res := r.runSetVisibility(t, SetVisibilityOptions{Target: "shared", Authorized: true}) + if res.Result != ResultApplied || res.RepositoryState != "shared" { + t.Fatalf("--yes = %q state %q, want applied shared:\n%s", res.Result, res.RepositoryState, res.HumanText()) + } + for _, p := range res.Phases { + want := visibilityPhaseDone + if p.Name == "align-visibility" { + want = visibilityPhaseApplied + } + if p.Status != want { + t.Errorf("phase %s = %q, want %q", p.Name, p.Status, want) + } + } + if len(res.Commits) != 0 { + t.Errorf("Commits = %+v, want none", res.Commits) + } + if got := string(mustReadFile(t, filepath.Join(r.invocation, ".docket.local.yml"))); !strings.Contains(got, "visibility: shared") { + t.Errorf(".docket.local.yml = %q, want visibility: shared", got) + } + status := runGit(t, r.invocation, "status", "--porcelain", "--untracked-files=all") + requireUntouched(t, r, head, status, agents) + }) + + t.Run("private to private is a no-op", func(t *testing.T) { + r, _ := newBornPrivateRepo(t) + head := runGit(t, r.invocation, "rev-parse", "HEAD") + status := runGit(t, r.invocation, "status", "--porcelain", "--untracked-files=all") + if preview := r.runSetVisibility(t, SetVisibilityOptions{Target: "private"}); preview.Result != ResultNoOp { + t.Errorf("preview = %q state %q, want no-op:\n%s", preview.Result, preview.RepositoryState, preview.HumanText()) + } + if res := r.runSetVisibility(t, SetVisibilityOptions{Target: "private", Authorized: true}); res.Result != ResultNoOp { + t.Errorf("--yes = %q state %q, want no-op:\n%s", res.Result, res.RepositoryState, res.HumanText()) + } + if got := runGit(t, r.invocation, "rev-parse", "HEAD"); got != head { + t.Errorf("HEAD moved %s -> %s", head, got) + } + if got := runGit(t, r.invocation, "status", "--porcelain", "--untracked-files=all"); got != status { + t.Errorf("the working tree changed:\nbefore:\n%s\nafter:\n%s", status, got) + } + }) +} + +// TestIntegrationRepoVisibilitySharedResumesCommitWithoutJournaledEdits proves +// a going-shared run killed after its metadata checkout moved still resumes its +// commit when no earlier phase journaled an edit: the repository went private +// keeping its committed files, .docket.yml already holds what the private +// configuration splits into, and the user dropped the dispatch block from +// AGENTS.md. The resumed run reinstalls the block and commits it with the add +// subject instead of reading the repository as never switched. +func TestIntegrationRepoVisibilitySharedResumesCommitWithoutJournaledEdits(t *testing.T) { + r, _ := newPrivateSwitchRepo(t) + r.driveVisibility(t, SetVisibilityOptions{Target: "private"}) + gitDir := r.gitDir(t) + cfg := mustReadFile(t, filepath.Join(gitDir, layout.PrivateName, layout.PrivateConfigFile)) + keys, _, err := readOptionalFile(filepath.Join(gitDir, layout.PrivateName, layout.PrivateLocalKeysFile)) + if err != nil { + t.Fatal(err) + } + committed, _, err := reposetup.SplitPrivateConfig(cfg, keys, config.RepoOnlyPaths()) + if err != nil { + t.Fatal(err) + } + writeRepoFile(t, r.invocation, ".docket.yml", string(committed)) + writeRepoFile(t, r.invocation, "AGENTS.md", "# Agents\n") + runGit(t, r.invocation, "add", "--", ".docket.yml", "AGENTS.md") + runGit(t, r.invocation, "commit", "-q", "-m", "hand edits while private") + before := runGit(t, r.invocation, "rev-parse", "HEAD") + + first := r.switchVisibilityWithHooks(t, SetVisibilityOptions{Target: "shared"}, interruptAt("metadata-worktree")) + if first.Result != ResultExternalFailed { + t.Fatalf("run 1 = %q (%s), want external-failed after metadata-worktree", first.Result, first.HumanText()) + } + if got := phaseStatus(first, "config-committed"); got != visibilityPhaseDone { + t.Fatalf("config-committed = %q, want done (the fixture must journal no edit)", got) + } + r.driveVisibility(t, SetVisibilityOptions{Target: "shared"}) + requireSharedLayoutRestored(t, r) + requireNoSwitchDebris(t, r) + if n := runGit(t, r.invocation, "rev-list", "--count", before+"..HEAD"); n != "1" { + t.Fatalf("%s new commits, want the one add commit", n) + } + requireAddCommit(t, r.invocation, "HEAD", "AGENTS.md") + if agents := runGit(t, r.invocation, "show", "HEAD:AGENTS.md"); !strings.Contains(agents, document.MarkerSpelling(instructionsBlockName)+":start") { + t.Errorf("the committed AGENTS.md lacks the dispatch block:\n%s", agents) + } +} diff --git a/internal/app/repository_set_visibility_private.go b/internal/app/repository_set_visibility_private.go new file mode 100644 index 000000000..6aefef702 --- /dev/null +++ b/internal/app/repository_set_visibility_private.go @@ -0,0 +1,669 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/danielhanold/docket/internal/config" + "github.com/danielhanold/docket/internal/document" + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/reposeed" + "github.com/danielhanold/docket/internal/reposetup" +) + +// repository_set_visibility_private.go — the going-private executors of +// `docket repository set-visibility private`, and the helpers they share with +// going shared: publishing the identical metadata history under the other +// branch name, stripping the dispatch block from a committed instructions +// file, and disowning working-tree surfaces in the ownership record. Every +// executor re-reads the remote tip it acts on; none reuses a gather-time value. + +// localConfigRel is the clone-local configuration file of a shared repository. +const localConfigRel = ".docket.local.yml" + +// cursorRuleRel is the committed Cursor dispatch rule of a shared repository. +const cursorRuleRel = ".cursor/rules/docket-dispatch.mdc" + +// visibilityHistoryConflict is publishVisibilityHistory's refusal: the remote +// ref holds history that is unrelated to, or diverged from, the tip being +// published. Two backlogs are never merged, so a human resolves it. +type visibilityHistoryConflict struct { + Remote gitcli.RemoteName + Ref gitcli.RefName + RemoteTip gitcli.ObjectID + Tip gitcli.ObjectID +} + +func (e *visibilityHistoryConflict) Error() string { + return fmt.Sprintf("%s on %s is at %s, which is unrelated to or diverged from %s; two backlogs are never merged", + e.Ref, e.Remote, e.RemoteTip, e.Tip) +} + +// publishVisibilityHistory makes remote's ref hold tip without rewriting history: +// create-only when absent; a fast-forward lease (expected = current remote tip) when +// that tip is an ancestor of tip; nothing when it equals or descends from tip. +// Unrelated or diverged history refuses naming both tips. Re-read afterwards: the +// remote must hold tip or a descendant. +func publishVisibilityHistory(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, remote gitcli.RemoteName, ref gitcli.RefName, tip gitcli.ObjectID) (changed bool, err error) { + cur, err := git.ProbeRemoteBranch(ctx, repo, remote, ref) + if err != nil { + return false, fmt.Errorf("reading %s on %s: %w", ref, remote, err) + } + switch { + case cur.State != gitcli.RemoteRefFound: + out, err := git.PushCreateLease(ctx, repo, remote, ref, tip) + if err != nil { + return false, fmt.Errorf("publishing %s to %s: %w", ref, remote, err) + } + switch out.Disposition { + case gitcli.PushApplied: + changed = true + case gitcli.PushLeaseLost: + return false, fmt.Errorf("%s on %s was created at %s while the switch published it; re-run to preview the new state", ref, remote, out.Remote) + default: + return false, fmt.Errorf("publishing %s to %s failed; re-run to retry", ref, remote) + } + case cur.Commit == tip: + // Already published. + default: + // Bring the remote tip's objects in so ancestry can be read. + rev, err := git.FetchBranch(ctx, repo, remote, ref) + if err != nil { + return false, fmt.Errorf("fetching %s from %s: %w", ref, remote, err) + } + remoteTip := rev.Commit + if remoteTip == tip { + break + } + descends, err := git.IsAncestorIgnoringReplacements(ctx, repo, tip, remoteTip) + if err != nil { + return false, err + } + if descends { + break // the remote already holds tip and more + } + behind, err := git.IsAncestorIgnoringReplacements(ctx, repo, remoteTip, tip) + if err != nil { + return false, err + } + if !behind { + return false, &visibilityHistoryConflict{Remote: remote, Ref: ref, RemoteTip: remoteTip, Tip: tip} + } + out, err := git.PushLease(ctx, repo, remote, ref, tip, remoteTip) + if err != nil { + return false, fmt.Errorf("fast-forwarding %s on %s: %w", ref, remote, err) + } + switch out.Disposition { + case gitcli.PushApplied: + changed = true + case gitcli.PushLeaseLost: + return false, fmt.Errorf("%s on %s moved to %s while the switch fast-forwarded it; re-run to preview the new state", ref, remote, out.Remote) + default: + return false, fmt.Errorf("fast-forwarding %s on %s failed; re-run to retry", ref, remote) + } + } + + // Re-read: the remote must hold tip or a descendant of it. + after, err := git.ProbeRemoteBranch(ctx, repo, remote, ref) + if err != nil { + return changed, fmt.Errorf("re-reading %s on %s: %w", ref, remote, err) + } + if after.State != gitcli.RemoteRefFound { + return changed, fmt.Errorf("%s is absent from %s after the publish", ref, remote) + } + if after.Commit == tip { + return changed, nil + } + rev, err := git.FetchBranch(ctx, repo, remote, ref) + if err != nil { + return changed, fmt.Errorf("fetching %s from %s: %w", ref, remote, err) + } + held, err := git.IsAncestorIgnoringReplacements(ctx, repo, tip, rev.Commit) + if err != nil { + return changed, err + } + if !held { + return changed, fmt.Errorf("%s on %s is at %s after the publish, which does not hold %s", ref, remote, rev.Commit, tip) + } + return changed, nil +} + +// stripDispatchBlock removes the `dispatch` managed block; remove = only whitespace +// is left. Malformed markers error (manual review); no block -> (src, false, nil). +func stripDispatchBlock(src []byte) (out []byte, remove bool, err error) { + doc, err := document.Parse(src) + if err != nil { + return nil, false, err + } + if _, ok := doc.Block(instructionsBlockName); !ok { + return src, false, nil + } + var patch document.PatchSet + patch.RemoveBlock(instructionsBlockName) + out, err = doc.Apply(patch) + if err != nil { + return nil, false, err + } + return out, len(bytes.TrimSpace(out)) == 0, nil +} + +// disownWorkingTreeSurfaces rewrites the ownership record without the surfaces +// outside .git/ that tracked names, so a private-mode install never retires a +// committed file; every other working-tree surface (an ignored, generated one) +// stays owned for that install to retire. Absent record -> (false, nil). A +// record left with no surface is removed. +func disownWorkingTreeSurfaces(recordPath string, tracked map[string]bool) (bool, error) { + return disownSurfaces(recordPath, func(p string) bool { return !isWorkingTreeSurface(p) || !tracked[p] }) +} + +// trackedSharedPaths returns which of the switch's shared paths head holds. An +// empty head (an unborn branch) tracks nothing. +func trackedSharedPaths(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, head string) (map[string]bool, error) { + tracked := map[string]bool{} + if head == "" { + return tracked, nil + } + src, err := git.OpenObjectSource(ctx, repo, gitcli.Revision{Commit: gitcli.ObjectID(head)}) + if err != nil { + return nil, err + } + paths := make([]gitcli.RepoPath, 0, len(visibilityCommitPaths)) + for _, p := range visibilityCommitPaths { + paths = append(paths, gitcli.RepoPath(p)) + } + blobs, err := src.ReadBlobs(ctx, paths) + if err != nil { + return nil, err + } + for _, b := range blobs { + if b.Found { + tracked[string(b.Path)] = true + } + } + return tracked, nil +} + +// isWorkingTreeSurface reports whether a record path lies outside .git/. +func isWorkingTreeSurface(p string) bool { return !strings.HasPrefix(p, ".git/") } + +// disownSurfaces rewrites the ownership record keeping only the surfaces keep +// accepts. Absent record, or nothing dropped -> (false, nil). A record left +// with no surface is removed. +func disownSurfaces(recordPath string, keep func(path string) bool) (bool, error) { + rec, err := reposeed.LoadRecord(recordPath) + if err != nil || rec == nil { + return false, err + } + kept := make([]reposeed.SurfaceRecord, 0, len(rec.Surfaces)) + for _, s := range rec.Surfaces { + if keep(s.Path) { + kept = append(kept, s) + } + } + if len(kept) == len(rec.Surfaces) { + return false, nil + } + if len(kept) == 0 { + if err := os.Remove(recordPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return false, err + } + return true, nil + } + rec.Surfaces = kept + b, err := reposeed.EncodeRecord(rec) + if err != nil { + return false, err + } + return true, writeFileReplacing(recordPath, b, 0o644) +} + +// --- the default store, read before the dckt remote exists ------------------ + +// storeRepository addresses the default store's bare repository directly, so +// a clone that has not configured its dckt remote yet can read what another +// clone on this machine already published there. +func storeRepository(priv layout.Layout) gitcli.Repository { + return gitcli.Repository{PrimaryWorktree: priv.DefaultBareRemote, CommonDir: priv.DefaultBareRemote} +} + +// defaultStoreTip reads the dckt branch of the default store from disk: absent +// when the store or its branch does not exist. Any other probe error is +// returned. +func defaultStoreTip(ctx context.Context, git *gitcli.Client, priv layout.Layout) (gitcli.RemoteRef, error) { + absent := gitcli.RemoteRef{State: gitcli.RemoteRefAbsent} + if priv.DefaultBareRemote == "" { + return absent, nil + } + ok, err := isDirAt(priv.DefaultBareRemote) + if err != nil || !ok { + return absent, err + } + tip, err := localBranchTip(ctx, git, storeRepository(priv), metadataRef(priv)) + if err != nil || tip == "" { + return absent, err + } + return gitcli.RemoteRef{State: gitcli.RemoteRefFound, Commit: gitcli.ObjectID(tip)}, nil +} + +// storeHoldsCommit reports whether the store's tip equals or descends from +// commit, read inside the store: a commit the store lacks is not held. +func storeHoldsCommit(ctx context.Context, git *gitcli.Client, priv layout.Layout, tip, commit gitcli.ObjectID) (bool, error) { + if commit == tip { + return true, nil + } + repo := storeRepository(priv) + if _, err := git.OpenObjectSource(ctx, repo, gitcli.Revision{Commit: commit}); err != nil { + if f, ok := gitcli.AsFailure(err); ok && f.Kind == gitcli.KindRefUnavailable { + return false, nil + } + return false, err + } + return git.IsAncestor(ctx, repo, commit, tip) +} + +// --- the going-private executors ----------------------------------------------- + +// privateRefusal is an executor refusal a human resolves. +func privateRefusal(state reposetup.State, msg string) error { + return &visibilityRefusal{state: state, msg: msg} +} + +// privateMetadataRemotePhase sets up the bare remote and the dckt git remote. +func privateMetadataRemotePhase(ctx context.Context, x *visibilityRun) error { + _, _, err := ensurePrivateMetadataRemote(ctx, x.d.Git, x.st.sc.repo, x.st.private, x.o.MetadataRemote, x.st.dcktURL != "") + if err == nil { + return nil + } + var mre *metadataRemoteError + if errors.As(err, &mre) && mre.Conflict { + msg := strings.ReplaceAll(mre.Err.Error(), "`docket repository init --metadata-remote `", + "`docket repository set-visibility private --metadata-remote `") + return privateRefusal(reposetup.StateConflict, msg) + } + return err +} + +// privatePublishPhase pushes origin's docket history, re-read fresh, to the +// dckt branch of the bare remote. +func privatePublishPhase(ctx context.Context, x *visibilityRun) error { + git, repo := x.d.Git, x.st.sc.repo + remote, ref := metadataRemote(x.st.private), metadataRef(x.st.private) + url, err := git.RemoteURL(ctx, repo, remote) + if err != nil { + return fmt.Errorf("reading the dckt remote's URL: %w", err) + } + origin, err := probeAndFetch(ctx, git, repo, originRemote, metadataRef(x.st.shared)) + if err != nil { + return fmt.Errorf("reading origin's docket branch: %w", err) + } + if origin.State != gitcli.RemoteRefFound { + bare, err := git.ProbeRemoteBranch(ctx, repo, remote, ref) + if err != nil { + return fmt.Errorf("reading the dckt branch at %s: %w", url, err) + } + if bare.State == gitcli.RemoteRefFound { + return nil // the bare remote already holds the only history + } + return privateRefusal(reposetup.StateNeedsReview, fmt.Sprintf( + "no metadata history is reachable: origin has no docket branch and %s holds no dckt branch; re-run with --metadata-remote ", url)) + } + if _, err := publishVisibilityHistory(ctx, git, repo, remote, ref, origin.Commit); err != nil { + var conflict *visibilityHistoryConflict + if errors.As(err, &conflict) { + return privateRefusal(reposetup.StateConflict, fmt.Sprintf( + "the dckt branch at %s is at %s, which is unrelated to or diverged from origin's docket branch at %s; two backlogs are never merged; point --metadata-remote at a bare repository of this repository's own, or reconcile that dckt branch by hand", + url, conflict.RemoteTip, conflict.Tip)) + } + return err + } + return nil +} + +// privateConfigPhase folds the committed and clone-local configuration into the +// private config, written into the state folder before it is renamed (so the +// rename is the one atomic flip), then removes .docket.local.yml. A config +// already waiting there is kept; only the local file goes. +func privateConfigPhase(ctx context.Context, x *visibilityRun) error { + st := x.st + localPath := filepath.Join(st.primary, localConfigRel) + if st.pendingConfig == "" { + committed, err := privateFoldCommitted(ctx, x) + if err != nil { + return err + } + local, _, err := readOptionalFile(localPath) + if err != nil { + return err + } + folded, dropped, err := reposetup.FoldPrivateConfig(committed, local, config.RepoOnlyPaths()) + if err != nil { + return privateRefusal(reposetup.StateNeedsReview, "the configuration cannot be folded into "+layout.PrivateConfigDisplay+": "+err.Error()+"; fix .docket.yml or .docket.local.yml, then re-run") + } + if _, _, err := config.Resolve([]config.Source{{Layer: config.LayerRepository, Name: layout.PrivateConfigDisplay, Data: folded}}, + config.ResolveContext{DefaultBranch: st.sc.defaultBranch}); err != nil { + return privateRefusal(reposetup.StateNeedsReview, "the folded "+layout.PrivateConfigDisplay+" does not resolve: "+err.Error()+"; fix .docket.yml or .docket.local.yml, then re-run") + } + dir := st.shared.StateDir + if !st.sharedStateDir && st.privateStateDir { + dir = st.private.StateDir + } + if local != nil { + if err := writeFileReplacing(filepath.Join(dir, layout.PrivateLocalKeysFile), local, 0o644); err != nil { + return err + } + } + if err := writeFileReplacing(filepath.Join(dir, layout.PrivateConfigFile), folded, 0o644); err != nil { + return err + } + if len(dropped) > 0 { + x.res.Warnings = append(x.res.Warnings, "not carried into the private config: "+strings.Join(dropped, ", ")) + } + } + if err := os.Remove(localPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil +} + +// privateFoldCommitted reads the committed half of the fold: .docket.yml at +// origin's default-branch tip, else at the primary checkout's HEAD, else none. +func privateFoldCommitted(ctx context.Context, x *visibilityRun) ([]byte, error) { + for _, rev := range []string{x.st.defaultTip, x.st.primaryHead} { + if rev == "" { + continue + } + b, found, err := readCommitBlob(ctx, x.d.Git, x.st.sc.repo, rev, docketYMLRel) + if err != nil { + return nil, err + } + if found { + return b, nil + } + } + return nil, nil +} + +// privateStateFolderPhase renames /docket to /dckt. From here +// the repository detects private. It never writes into an existing dckt. +func privateStateFolderPhase(_ context.Context, x *visibilityRun) error { + from, to := x.st.shared.StateDir, x.st.private.StateDir + if _, err := os.Lstat(to); err == nil { + return privateRefusal(reposetup.StateConflict, to+" already exists beside "+from+"; inspect both by hand") + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + return os.Rename(from, to) +} + +// privateIgnorePhase writes the neutral managed block into .git/info/exclude. +func privateIgnorePhase(_ context.Context, x *visibilityRun) error { + if _, err := ensureExcludeFile(filepath.Join(x.st.common, "info", "exclude")); err != nil { + var mal *reposetup.MalformedExcludeError + if errors.As(err, &mal) { + return privateRefusal(reposetup.StateNeedsReview, err.Error()+"; fix the markers by hand, then re-run") + } + return err + } + return nil +} + +// privateMetadataWorktreePhase removes the .docket checkout cleanly, attaches +// the private checkout on the dckt branch with hooks off, and deletes the local +// docket branch when the dckt branch holds it. +func privateMetadataWorktreePhase(ctx context.Context, x *visibilityRun) error { + git, repo, st := x.d.Git, x.st.sc.repo, x.st + wts, err := git.ListWorktrees(ctx, repo) + if err != nil { + return err + } + if wt, ok := worktreeAt(wts, st.shared.MetadataWorktree); ok { + remove := git.RemoveWorktreeClean + if wt.Prunable { + remove = git.RemoveWorktree // its folder is already gone + } + if err := remove(ctx, repo, st.shared.MetadataWorktree); err != nil { + return fmt.Errorf("removing the metadata checkout %s: %w", st.shared.MetadataWorktree, err) + } + } + if err := os.MkdirAll(st.private.CheckoutsDir, 0o755); err != nil { + return err + } + if err := removeStaleOwnCheckout(st.private); err != nil { + return err + } + bare, err := probeAndFetch(ctx, git, repo, metadataRemote(st.private), metadataRef(st.private)) + if err != nil { + return fmt.Errorf("reading the dckt branch: %w", err) + } + if bare.State != gitcli.RemoteRefFound { + return errors.New("the dckt remote holds no dckt branch; re-run to publish it") + } + if _, err := ensureMetadataWorktree(ctx, git, repo, st.private.MetadataWorktree, metadataRef(st.private), bare.Commit); err != nil { + return fmt.Errorf("attaching the metadata checkout %s: %w", st.private.MetadataWorktree, err) + } + if err := git.DisableWorktreeHooks(ctx, st.private.MetadataWorktree); err != nil { + return fmt.Errorf("disabling metadata-checkout hooks: %w", err) + } + + shared := metadataRef(st.shared) + tip, err := localBranchTip(ctx, git, repo, shared) + if err != nil || tip == "" { + return err + } + held := gitcli.ObjectID(tip) == bare.Commit + if !held { + if held, err = git.IsAncestor(ctx, repo, gitcli.ObjectID(tip), bare.Commit); err != nil { + return err + } + } + if !held { + x.markPhase("metadata-worktree", visibilityPhaseKept, "") + x.res.PendingLocal = append(x.res.PendingLocal, + "the local docket branch has commits the dckt branch lacks; inspect it, then delete it by hand") + return nil + } + return git.DeleteLocalBranchChecked(ctx, repo, shared, gitcli.ObjectID(tip)) +} + +// privateInstructionsPhase disowns the working-tree surfaces committed at the +// primary checkout's HEAD (a private install would otherwise retire them; the +// removal commit owns them), then installs the dispatch block into the private +// instructions file when agent_harnesses authorizes it. That install retires +// every surface left owned, such as the ignored Cursor dispatch rule. +func privateInstructionsPhase(ctx context.Context, x *visibilityRun) error { + tracked, err := trackedSharedPaths(ctx, x.d.Git, x.st.sc.repo, x.st.primaryHead) + if err != nil { + return fmt.Errorf("reading the committed surfaces: %w", err) + } + if _, err := disownWorkingTreeSurfaces(reposeed.RecordPath(x.st.common, layout.PrivateName), tracked); err != nil { + return fmt.Errorf("rewriting the ownership record: %w", err) + } + if _, _, err := installAuthorizedSurfaces(ctx, x.d.Git, x.st.primary); err != nil { + return fmt.Errorf("installing the private dispatch instructions: %w", err) + } + return nil +} + +// privateDeleteSharedBranchPhase deletes origin's docket branch under an exact +// lease, only when it is at the very tip the dckt branch holds; any mismatch +// keeps it and reports both tips. +func privateDeleteSharedBranchPhase(ctx context.Context, x *visibilityRun) error { + git, repo := x.d.Git, x.st.sc.repo + origin, err := probeAndFetch(ctx, git, repo, originRemote, metadataRef(x.st.shared)) + if err != nil { + return fmt.Errorf("reading origin's docket branch: %w", err) + } + if origin.State != gitcli.RemoteRefFound { + return nil + } + bare, err := git.ProbeRemoteBranch(ctx, repo, metadataRemote(x.st.private), metadataRef(x.st.private)) + if err != nil { + return fmt.Errorf("reading the dckt branch: %w", err) + } + keep := func(originTip gitcli.ObjectID) error { + bareTip := "absent" + if bare.State == gitcli.RemoteRefFound { + bareTip = string(bare.Commit) + } + x.markPhase("delete-shared-branch", visibilityPhaseKept, "") + x.res.PendingLocal = append(x.res.PendingLocal, fmt.Sprintf( + "origin's docket branch is at %s, the dckt branch at %s; nothing was deleted", originTip, bareTip)) + return nil + } + if bare.State != gitcli.RemoteRefFound || bare.Commit != origin.Commit { + return keep(origin.Commit) + } + out, err := git.DeleteRemoteRefLease(ctx, repo, originRemote, metadataRef(x.st.shared), origin.Commit) + if err != nil { + return fmt.Errorf("deleting origin's docket branch: %w", err) + } + switch out.Disposition { + case gitcli.PushApplied: + return nil + case gitcli.PushLeaseLost: + return keep(out.Remote) + default: + return errors.New("deleting origin's docket branch failed; re-run to retry") + } +} + +// privateRemoveSharedFilesPhase removes .docket.yml, the managed .gitignore +// block, the dispatch blocks, and the Cursor rule — each target computed from +// the path's HEAD blob — through the journal, then commits exactly those paths +// with the fixed subject. The commit stays local. +func privateRemoveSharedFilesPhase(ctx context.Context, x *visibilityRun) error { + git, repo, st := x.d.Git, x.st.sc.repo, x.st + cs, err := git.WorktreeCheckoutState(ctx, st.primary) + if err != nil { + return err + } + if cs.Detached { + return privateRefusal(reposetup.StateNeedsReview, "the primary checkout "+st.primary+" has a detached HEAD; check out a branch to commit on, then re-run") + } + branch, _ := shortBranch(cs.Branch) + targets, err := removalTargets(ctx, git, repo, cs.Head) + if err != nil { + return err + } + for _, t := range targets { + if err := writeSwitchPath(st.common, st.primary, visibilityRemoveSubject, t.rel, t.content); err != nil { + return err + } + } + row := VisibilityCommit{Subject: visibilityRemoveSubject, Branch: branch, Status: visibilityCommitCommitted} + out, err := commitSwitchJournal(ctx, git, repo, st.common) + row.Commit, row.Paths = out.Commit, out.Paths + if err != nil { + row.Status = visibilityCommitFailed + for _, t := range targets { + row.Paths = append(row.Paths, t.rel) + } + x.res.Commits = []VisibilityCommit{row} + return fmt.Errorf("%w (the edits are journaled; re-run `docket repository set-visibility private --remove-shared-files` to retry the commit)", err) + } + x.res.Commits = []VisibilityCommit{row} + if !out.Complete { + return errors.New("a commit hook changed what the switch committed; the journal is kept: review the commit, then re-run `docket repository set-visibility private --remove-shared-files`") + } + return nil +} + +// removalTarget is one path the removal commit writes (content) or deletes (nil). +type removalTarget struct { + rel string + content []byte +} + +// removalTargets computes, from the HEAD blobs, what the removal commit does to +// each committed shared path. A CLAUDE.md symlink goes only with a deleted +// AGENTS.md; malformed markers refuse for manual review. +func removalTargets(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, head gitcli.ObjectID) ([]removalTarget, error) { + src, err := git.OpenObjectSource(ctx, repo, gitcli.Revision{Commit: head}) + if err != nil { + return nil, err + } + paths := make([]gitcli.RepoPath, 0, len(visibilityCommitPaths)) + for _, p := range visibilityCommitPaths { + paths = append(paths, gitcli.RepoPath(p)) + } + blobs, err := src.ReadBlobs(ctx, paths) + if err != nil { + return nil, err + } + at := map[string]gitcli.Blob{} + for _, b := range blobs { + if b.Found { + at[string(b.Path)] = b.Blob + } + } + isLink := func(b gitcli.Blob) bool { return b.Mode == "120000" } + var out []removalTarget + if _, ok := at[docketYMLRel]; ok { + out = append(out, removalTarget{rel: docketYMLRel}) + } + if b, ok := at[gitignoreRel]; ok && !isLink(b) { + stripped, changed, err := reposetup.RemoveGitignoreBlock(b.Bytes) + if err != nil { + return nil, privateRefusal(reposetup.StateNeedsReview, gitignoreRel+": "+err.Error()+"; fix the markers by hand, then re-run") + } + if changed { + if len(bytes.TrimSpace(stripped)) == 0 { + stripped = nil + } + out = append(out, removalTarget{rel: gitignoreRel, content: stripped}) + } + } + agentsDeleted := false + for _, rel := range []string{"AGENTS.md", "CLAUDE.md"} { + b, ok := at[rel] + if !ok { + continue + } + if isLink(b) { + if rel == "CLAUDE.md" && agentsDeleted { + out = append(out, removalTarget{rel: rel}) + } + continue + } + stripped, remove, err := stripDispatchBlock(b.Bytes) + if err != nil { + return nil, privateRefusal(reposetup.StateNeedsReview, rel+": "+err.Error()+"; fix the dispatch block markers by hand, then re-run") + } + switch { + case remove: + out = append(out, removalTarget{rel: rel}) + agentsDeleted = agentsDeleted || rel == "AGENTS.md" + case !bytes.Equal(stripped, b.Bytes): + out = append(out, removalTarget{rel: rel, content: stripped}) + } + } + if _, ok := at[cursorRuleRel]; ok { + out = append(out, removalTarget{rel: cursorRuleRel}) + } + return out, nil +} + +// privateAlignVisibilityPhase sets an explicit, different visibility in the +// private config to private. +func privateAlignVisibilityPhase(_ context.Context, x *visibilityRun) error { + p := layout.PrivateConfigPath(x.st.common) + existing, ok, err := readOptionalFile(p) + if err != nil || !ok { + return err + } + edited, changed, err := reposetup.RenderVisibilityEdit(existing, string(layout.Private)) + if err != nil { + return privateRefusal(reposetup.StateNeedsReview, layout.PrivateConfigDisplay+": "+err.Error()) + } + if !changed { + return nil + } + return writeFileReplacing(p, edited, 0o644) +} diff --git a/internal/app/repository_set_visibility_private_test.go b/internal/app/repository_set_visibility_private_test.go new file mode 100644 index 000000000..f45054f28 --- /dev/null +++ b/internal/app/repository_set_visibility_private_test.go @@ -0,0 +1,129 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/install" + "github.com/danielhanold/docket/internal/reposeed" + "github.com/danielhanold/docket/internal/testsupport" +) + +const ( + testDispatchStart = "" + testDispatchEnd = "" +) + +func TestStripDispatchBlock(t *testing.T) { + block := testDispatchStart + "\nrun the dispatcher\n" + testDispatchEnd + "\n" + cases := []struct { + name string + src string + want string + remove bool + wantErr bool + }{ + {name: "middle block", src: "# Rules\n\n" + block + "\nKeep this lesson.\n", want: "# Rules\n\n\nKeep this lesson.\n"}, + {name: "block only", src: block, want: "", remove: true}, + {name: "block with blank lines only", src: "\n\n" + block + "\n", want: "\n\n\n", remove: true}, + {name: "no block", src: "# Rules\n\nKeep this lesson.\n", want: "# Rules\n\nKeep this lesson.\n"}, + {name: "malformed markers", src: "# Rules\n" + testDispatchStart + "\nunterminated\n", wantErr: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + out, remove, err := stripDispatchBlock([]byte(c.src)) + if c.wantErr { + if err == nil { + t.Fatalf("stripDispatchBlock = (%q, %v, nil), want an error", out, remove) + } + return + } + if err != nil { + t.Fatalf("stripDispatchBlock: %v", err) + } + if string(out) != c.want || remove != c.remove { + t.Errorf("stripDispatchBlock = (%q, %v), want (%q, %v)", out, remove, c.want, c.remove) + } + }) + } +} + +func TestDisownWorkingTreeSurfaces(t *testing.T) { + dir := testsupport.TempDir(t) + + t.Run("absent record", func(t *testing.T) { + changed, err := disownWorkingTreeSurfaces(filepath.Join(dir, "absent", "install.json"), nil) + if err != nil || changed { + t.Fatalf("disownWorkingTreeSurfaces = (%v, %v), want (false, nil)", changed, err) + } + }) + + t.Run("keeps the git-dir surface", func(t *testing.T) { + path := filepath.Join(dir, "mixed", "install.json") + writeTestRecord(t, path, "AGENTS.md", ".git/dckt/AGENTS.md") + changed, err := disownWorkingTreeSurfaces(path, map[string]bool{"AGENTS.md": true}) + if err != nil || !changed { + t.Fatalf("disownWorkingTreeSurfaces = (%v, %v), want (true, nil)", changed, err) + } + rec, err := reposeed.LoadRecord(path) + if err != nil || rec == nil { + t.Fatalf("LoadRecord = (%v, %v)", rec, err) + } + if len(rec.Surfaces) != 1 || rec.Surfaces[0].Path != ".git/dckt/AGENTS.md" { + t.Errorf("surfaces = %+v, want only .git/dckt/AGENTS.md", rec.Surfaces) + } + if changed, err := disownWorkingTreeSurfaces(path, map[string]bool{"AGENTS.md": true}); err != nil || changed { + t.Errorf("second call = (%v, %v), want (false, nil)", changed, err) + } + }) + + t.Run("removes an emptied record", func(t *testing.T) { + path := filepath.Join(dir, "shared", "install.json") + writeTestRecord(t, path, "AGENTS.md", "CLAUDE.md") + changed, err := disownWorkingTreeSurfaces(path, map[string]bool{"AGENTS.md": true, "CLAUDE.md": true}) + if err != nil || !changed { + t.Fatalf("disownWorkingTreeSurfaces = (%v, %v), want (true, nil)", changed, err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("the emptied record is still present (stat err %v)", err) + } + }) + + t.Run("keeps an untracked working-tree surface owned", func(t *testing.T) { + path := filepath.Join(dir, "ignored", "install.json") + writeTestRecord(t, path, "AGENTS.md", cursorRuleRel) + changed, err := disownWorkingTreeSurfaces(path, map[string]bool{"AGENTS.md": true}) + if err != nil || !changed { + t.Fatalf("disownWorkingTreeSurfaces = (%v, %v), want (true, nil)", changed, err) + } + rec, err := reposeed.LoadRecord(path) + if err != nil || rec == nil { + t.Fatalf("LoadRecord = (%v, %v)", rec, err) + } + if len(rec.Surfaces) != 1 || rec.Surfaces[0].Path != cursorRuleRel { + t.Errorf("surfaces = %+v, want only %s, which no commit tracks", rec.Surfaces, cursorRuleRel) + } + }) +} + +// writeTestRecord writes an ownership record listing one managed block per path. +func writeTestRecord(t *testing.T, path string, paths ...string) { + t.Helper() + rec := &reposeed.Record{FormatVersion: reposeed.RecordFormatVersion} + for _, p := range paths { + rec.Surfaces = append(rec.Surfaces, reposeed.SurfaceRecord{ + Path: p, Kind: install.KindManagedBlock, BlockName: instructionsBlockName, SHA256: "abc", Harnesses: []string{"codex"}, + }) + } + b, err := reposeed.EncodeRecord(rec) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, b, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/repository_set_visibility_probe.go b/internal/app/repository_set_visibility_probe.go new file mode 100644 index 000000000..418a63031 --- /dev/null +++ b/internal/app/repository_set_visibility_probe.go @@ -0,0 +1,799 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/danielhanold/docket/internal/config" + "github.com/danielhanold/docket/internal/document" + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/gatedrive" + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/githubcli" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/reposeed" + "github.com/danielhanold/docket/internal/reposetup" +) + +// repository_set_visibility_probe.go — the state a visibility switch decides +// over, and its read-only preconditions and warnings. Nothing here writes, +// except the fetches that bring each remote's metadata tip into the object +// store so ancestry and blobs can be read. + +// repositoryIdentityKeys are the repository-only keys whose values must agree +// with origin's default-branch .docket.yml before going shared publishes +// anything: they decide where every record lives. +var repositoryIdentityKeys = []string{"integration_branch", "changes_dir", "adrs_dir", "results_dir"} + +// visibilityLinkListCap bounds how many paths the absolute-link warning names. +const visibilityLinkListCap = 20 + +// visibilityState is read without trusting the detected mode: both layouts +// (private via privateLayoutOf), layout.Detect, whether /docket and +// /dckt are dirs, origin's docket ref, the dckt remote URL ("" = +// unconfigured) and its dckt ref, origin's default tip, the worktree list, local +// docket/dckt tips ("" = absent), whether .docket.local.yml exists, a config.yml +// waiting in either state folder, and the journal — plus the derived facts +// each phase's done predicate reads. +type visibilityState struct { + sc setupContext + facts reposetup.Facts + common, primary string + shared, private layout.Layout + current layout.Mode + sharedStateDir, privateStateDir bool + originDocket, bareDckt gitcli.RemoteRef + storeDckt gitcli.RemoteRef // the default store's dckt branch, read from disk while the dckt remote is unconfigured + dcktURL, defaultTip string + worktrees []gitcli.WorktreeInfo + localDocket, localDckt string + localConfig bool + pendingConfig string + journal switchJournal + journalPresent bool + + // Derived facts. + primaryBranch string // the primary checkout's branch, short ("" when detached) + primaryHead string + bareHoldsOrigin bool // the bare dckt equals or descends from origin's docket (or origin has none and the bare one exists) + originHoldsBare bool // origin's docket equals or descends from the bare dckt (or the bare one is absent and origin's exists) + storeHoldsOrigin bool // the default store's dckt equals or descends from origin's docket (or origin has none and the store's exists) + excludeBlock bool // .git/info/exclude carries the valid managed block + excludeBlockPresent bool // .git/info/exclude carries any `# dckt:` block + sharedCheckoutRegistered bool + sharedCheckoutReady bool // on the docket branch with hooks off + privateCheckoutRegistered bool + privateCheckoutReady bool // on the dckt branch with hooks off + recordWorkingTree bool // the ownership record lists a surface outside .git/ + recordGitDir bool // the ownership record lists a surface under .git/ + privateInstructions bool // .git/dckt/AGENTS.md exists + headSharedFiles bool // HEAD still holds a file the shared layout commits + headGitignoreValid bool + worktreeGitignoreValid bool + commitPathsDirty bool + foldSource string + identityKeysAligned bool + sharedConfigWritten bool + privateVisibilityAligned bool + sharedVisibilityAligned bool + sharedSurfacesSettled bool // every authorized dispatch surface is settled on disk (read only going shared, once shared) +} + +// probeVisibility reads the switch's state. Every probe error is returned: +// liveness, presence, and ancestry are never guessed. +func probeVisibility(ctx context.Context, d SetupDeps, sc setupContext, facts reposetup.Facts) (visibilityState, error) { + git := d.Git + repo := sc.repo + st := visibilityState{ + sc: sc, + facts: facts, + common: repo.CommonDir, + primary: repo.PrimaryWorktree, + shared: layout.SharedLayout(repo.CommonDir, repo.PrimaryWorktree), + defaultTip: facts.RemoteDefaultBranch.Tip, + } + priv, err := privateLayoutOf(ctx, git, repo) + if err != nil { + return st, err + } + st.private = priv + if st.current, err = layout.Detect(st.common); err != nil { + return st, err + } + if st.sharedStateDir, err = isDirAt(st.shared.StateDir); err != nil { + return st, err + } + if st.privateStateDir, err = isDirAt(st.private.StateDir); err != nil { + return st, err + } + + // Remote tips, fetched so their objects are local. + if st.originDocket, err = probeAndFetch(ctx, git, repo, originRemote, metadataRef(st.shared)); err != nil { + return st, fmt.Errorf("reading origin's docket branch: %w", err) + } + url, uerr := git.RemoteURL(ctx, repo, metadataRemote(st.private)) + switch { + case uerr == nil: + st.dcktURL = url + if st.bareDckt, err = probeAndFetch(ctx, git, repo, metadataRemote(st.private), metadataRef(st.private)); err != nil { + return st, fmt.Errorf("reading the dckt branch at %s: %w", url, err) + } + case isRemoteUnconfigured(uerr): + st.bareDckt = gitcli.RemoteRef{State: gitcli.RemoteRefAbsent} + if st.storeDckt, err = defaultStoreTip(ctx, git, st.private); err != nil { + return st, fmt.Errorf("reading the dckt branch in %s: %w", st.private.DefaultBareRemote, err) + } + if st.storeDckt.State == gitcli.RemoteRefFound { + st.storeHoldsOrigin = st.originDocket.State != gitcli.RemoteRefFound + if !st.storeHoldsOrigin { + if st.storeHoldsOrigin, err = storeHoldsCommit(ctx, git, st.private, st.storeDckt.Commit, st.originDocket.Commit); err != nil { + return st, err + } + } + } + default: + return st, uerr + } + if st.bareHoldsOrigin, err = remoteHolds(ctx, git, repo, st.bareDckt, st.originDocket); err != nil { + return st, err + } + if st.originHoldsBare, err = remoteHolds(ctx, git, repo, st.originDocket, st.bareDckt); err != nil { + return st, err + } + + // Worktrees and local branches. + if st.worktrees, err = git.ListWorktrees(ctx, repo); err != nil { + return st, err + } + if st.sharedCheckoutRegistered, st.sharedCheckoutReady, err = checkoutReady(ctx, git, st.worktrees, st.shared); err != nil { + return st, err + } + if st.privateCheckoutRegistered, st.privateCheckoutReady, err = checkoutReady(ctx, git, st.worktrees, st.private); err != nil { + return st, err + } + if st.localDocket, err = localBranchTip(ctx, git, repo, metadataRef(st.shared)); err != nil { + return st, err + } + if st.localDckt, err = localBranchTip(ctx, git, repo, metadataRef(st.private)); err != nil { + return st, err + } + cs, err := git.WorktreeCheckoutState(ctx, st.primary) + if err != nil { + return st, err + } + st.primaryHead = string(cs.Head) + if b, ok := shortBranch(cs.Branch); ok && !cs.Detached { + st.primaryBranch = b + } + + // Config files and the journal. + localYML, localPresent, err := readOptionalFile(filepath.Join(st.primary, ".docket.local.yml")) + if err != nil { + return st, err + } + st.localConfig = localPresent + var pendingBytes []byte + for _, dir := range []string{st.private.StateDir, st.shared.StateDir} { + p := filepath.Join(dir, layout.PrivateConfigFile) + b, ok, err := readOptionalFile(p) + if err != nil { + return st, err + } + if ok { + st.pendingConfig, pendingBytes = p, b + break + } + } + if st.journal, st.journalPresent, err = loadSwitchJournal(st.common); err != nil { + return st, err + } + + // Ignore blocks. + exclude, _, err := readOptionalFile(filepath.Join(st.common, "info", "exclude")) + if err != nil { + return st, err + } + st.excludeBlock = reposetup.ValidExcludeBlock(exclude) + st.excludeBlockPresent = bytes.Contains(exclude, []byte(reposetup.ExcludeStart)) + worktreeIgnore, _, err := readOptionalFile(filepath.Join(st.primary, gitignoreRel)) + if err != nil { + return st, err + } + st.worktreeGitignoreValid = reposetup.ValidGitignoreBlock(worktreeIgnore) + + // The ownership record and the private instructions file. + rec, err := reposeed.LoadRecord(reposeed.RecordPath(st.common, layout.StateName(st.common))) + if err != nil { + return st, err + } + if rec != nil { + for _, s := range rec.Surfaces { + if strings.HasPrefix(s.Path, ".git/") { + st.recordGitDir = true + } else { + st.recordWorkingTree = true + } + } + } + if _, st.privateInstructions, err = readOptionalFile(layout.PrivateInstructionsPath(st.common)); err != nil { + return st, err + } + + // What HEAD and the working tree hold of the committed shared files. + if err := st.readHeadFiles(ctx, git); err != nil { + return st, err + } + changes, err := git.ChangedPaths(ctx, st.primary) + if err != nil { + return st, err + } + for _, c := range changes { + for _, p := range visibilityCommitPaths { + if string(c.Path) == p { + st.commitPathsDirty = true + } + } + } + + // Config facts. + if err := st.readConfigFacts(ctx, git, pendingBytes, localYML); err != nil { + return st, err + } + return st, nil +} + +// readHeadFiles records which of the shared layout's committed files the +// primary checkout's HEAD still holds. +func (st *visibilityState) readHeadFiles(ctx context.Context, git *gitcli.Client) error { + dispatchStart := []byte(document.MarkerSpelling(instructionsBlockName) + ":start") + for _, rel := range visibilityCommitPaths { + b, found, err := readCommitBlob(ctx, git, st.sc.repo, st.primaryHead, rel) + if err != nil { + return err + } + if !found { + continue + } + held := false + switch rel { + case gitignoreRel: + st.headGitignoreValid = reposetup.ValidGitignoreBlock(b) + held = bytes.Contains(b, []byte(reposetup.GitignoreStart)) + case "AGENTS.md", "CLAUDE.md": + held = bytes.Contains(b, dispatchStart) + default: + held = true + } + if held { + st.headSharedFiles = true + } + } + return nil +} + +// readConfigFacts decides the config phases' done predicates: where a fold +// reads from, whether the identity keys agree with origin, whether the +// committed half is written, and whether each layer's explicit visibility +// already matches. +func (st *visibilityState) readConfigFacts(ctx context.Context, git *gitcli.Client, pending, localYML []byte) error { + originYML, originFound, err := readDefaultDocketYML(ctx, git, *st) + if err != nil { + return err + } + switch { + case st.pendingConfig != "": + st.foldSource = "the configuration already waiting in " + st.pendingConfig + case originFound: + st.foldSource = "origin's " + st.sc.defaultBranch + " .docket.yml" + default: + _, headFound, err := readCommitBlob(ctx, git, st.sc.repo, st.primaryHead, ".docket.yml") + if err != nil { + return err + } + if headFound { + st.foldSource = "the primary checkout's HEAD .docket.yml" + } else { + st.foldSource = "an empty configuration" + } + } + + st.privateVisibilityAligned = true + st.identityKeysAligned = true + st.sharedConfigWritten = true + if st.pendingConfig != "" { + vis, err := reposetup.ConfigLeafValues(pending, []string{"visibility"}) + if err != nil { + return fmt.Errorf("reading %s: %w", st.pendingConfig, err) + } + if v, ok := vis["visibility"]; ok && v != string(layout.Private) { + st.privateVisibilityAligned = false + } + want, err := reposetup.ConfigLeafValues(pending, repositoryIdentityKeys) + if err != nil { + return fmt.Errorf("reading %s: %w", st.pendingConfig, err) + } + if len(want) > 0 { + have := map[string]string{} + if originFound { + if have, err = reposetup.ConfigLeafValues(originYML, repositoryIdentityKeys); err != nil { + return fmt.Errorf("reading origin's .docket.yml: %w", err) + } + } + for k, v := range want { + if have[k] != v { + st.identityKeysAligned = false + } + } + } + localKeys, _, err := readOptionalFile(filepath.Join(filepath.Dir(st.pendingConfig), layout.PrivateLocalKeysFile)) + if err != nil { + return err + } + committed, _, err := reposetup.SplitPrivateConfig(pending, localKeys, config.RepoOnlyPaths()) + if err != nil { + return fmt.Errorf("splitting %s: %w", st.pendingConfig, err) + } + if committed, err = keepOnDiskCommitted(st.primary, committed); err != nil { + return err + } + onDisk, _, err := readOptionalFile(filepath.Join(st.primary, ".docket.yml")) + if err != nil { + return err + } + st.sharedConfigWritten = bytes.Equal(onDisk, committed) + } + + st.sharedVisibilityAligned = true + if st.localConfig { + vis, err := reposetup.ConfigLeafValues(localYML, []string{"visibility"}) + if err != nil { + return fmt.Errorf("reading .docket.local.yml: %w", err) + } + if v, ok := vis["visibility"]; ok && v != string(layout.Shared) { + st.sharedVisibilityAligned = false + } + } + return nil +} + +// readDefaultDocketYML reads .docket.yml at origin's default-branch tip. +func readDefaultDocketYML(ctx context.Context, git *gitcli.Client, st visibilityState) ([]byte, bool, error) { + if st.defaultTip == "" { + return nil, false, nil + } + return readCommitBlob(ctx, git, st.sc.repo, st.defaultTip, ".docket.yml") +} + +// probeAndFetch probes remote's ref and, when found, fetches it so its objects +// are local. A fetched tip that differs from the probe is the fetched one. +func probeAndFetch(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, remote gitcli.RemoteName, ref gitcli.RefName) (gitcli.RemoteRef, error) { + rr, err := git.ProbeRemoteBranch(ctx, repo, remote, ref) + if err != nil { + return rr, err + } + if rr.State != gitcli.RemoteRefFound { + return rr, nil + } + rev, err := git.FetchBranch(ctx, repo, remote, ref) + if err != nil { + return rr, err + } + rr.Commit = rev.Commit + return rr, nil +} + +// remoteHolds reports whether holder equals or descends from source; an absent +// source is held by any present holder. +func remoteHolds(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, holder, source gitcli.RemoteRef) (bool, error) { + if holder.State != gitcli.RemoteRefFound { + return false, nil + } + if source.State != gitcli.RemoteRefFound || holder.Commit == source.Commit { + return true, nil + } + return git.IsAncestor(ctx, repo, source.Commit, holder.Commit) +} + +// checkoutReady reports whether l's metadata worktree is registered and, when +// it is, whether it is on l's metadata branch with hooks off. +func checkoutReady(ctx context.Context, git *gitcli.Client, wts []gitcli.WorktreeInfo, l layout.Layout) (registered, ready bool, err error) { + wt, ok := worktreeAt(wts, l.MetadataWorktree) + if !ok { + return false, false, nil + } + if wt.Prunable || wt.Branch != metadataRef(l) { + return true, false, nil + } + off, err := git.WorktreeHooksDisabled(ctx, l.MetadataWorktree) + if err != nil { + return true, false, err + } + return true, off, nil +} + +// worktreeAt finds the registration at path. +func worktreeAt(wts []gitcli.WorktreeInfo, path string) (gitcli.WorktreeInfo, bool) { + for _, wt := range wts { + if filepath.Clean(wt.Path) == filepath.Clean(path) { + return wt, true + } + } + return gitcli.WorktreeInfo{}, false +} + +// localBranchTip resolves a local branch; an unresolvable ref is "". +func localBranchTip(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, ref gitcli.RefName) (string, error) { + id, err := git.ResolveRef(ctx, repo, ref) + if err != nil { + if f, ok := gitcli.AsFailure(err); ok && f.Kind == gitcli.KindRefUnavailable { + return "", nil + } + return "", err + } + return string(id), nil +} + +// isDirAt reports whether p is a directory; absence is false, any other probe +// error is returned. +func isDirAt(p string) (bool, error) { + fi, err := os.Lstat(p) + switch { + case errors.Is(err, fs.ErrNotExist): + return false, nil + case err != nil: + return false, err + } + return fi.IsDir(), nil +} + +// readOptionalFile reads p; absence is (nil, false, nil). +func readOptionalFile(p string) ([]byte, bool, error) { + b, err := os.ReadFile(p) + if errors.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return b, true, nil +} + +// visibilityPin is the composite pin over the remote tips the switch reads. +func visibilityPin(st visibilityState) string { + tip := func(r gitcli.RemoteRef) string { + if r.State == gitcli.RemoteRefFound { + return string(r.Commit) + } + return "absent" + } + def := st.defaultTip + if def == "" { + def = "absent" + } + dckt := st.bareDckt + if dckt.State != gitcli.RemoteRefFound { + dckt = st.storeDckt // read from the default store while the dckt remote is unconfigured + } + return fmt.Sprintf("origin-docket=%s dckt=%s origin-default=%s", tip(st.originDocket), tip(dckt), def) +} + +// --- preconditions ----------------------------------------------------------- + +// visibilityLiveRunLines lists every live run and every busy gate lock under +// both existing state folders, each with its remedy. +func visibilityLiveRunLines(st visibilityState) ([]string, error) { + var lines []string + for _, dir := range []string{st.shared.StateDir, st.private.StateDir} { + ok, err := isDirAt(dir) + if err != nil { + return nil, err + } + if !ok { + continue + } + runs, err := liveRunsUnder(dir) + if err != nil { + return nil, err + } + for _, r := range runs { + line := fmt.Sprintf("run %s (%s", r.Key, r.State) + if r.ChangeID != "" { + line += ", change " + r.ChangeID + } + lines = append(lines, line+"): "+r.Remedy) + } + locks, err := gatedrive.BusyWorktreeLocks(dir) + if err != nil { + return nil, err + } + for _, l := range locks { + lines = append(lines, "a gate holds "+filepath.Join(l, "busy.lock")+holderNote(l)+": wait for it to finish, or stop its run") + } + } + return lines, nil +} + +// holderNote names a busy lock's holder.json when it is readable (a +// diagnostic only: it names the last holder). +func holderNote(dir string) string { + b, err := os.ReadFile(filepath.Join(dir, "holder.json")) + if err != nil { + return "" + } + var n gatedrive.HolderNote + if json.Unmarshal(b, &n) != nil { + return "" + } + var parts []string + if n.ChangeID != "" { + parts = append(parts, "change "+n.ChangeID) + } + if n.RunDir != "" { + parts = append(parts, "run "+n.RunDir) + } + if len(parts) == 0 { + return "" + } + return " (last holder: " + strings.Join(parts, ", ") + ")" +} + +// visibilityMetadataSyncRefusal refuses a metadata worktree (at either layout's +// path) that is dirty, mid-operation, or holds commits its authoritative remote +// branch lacks. No registered worktree passes. +func visibilityMetadataSyncRefusal(ctx context.Context, git *gitcli.Client, st visibilityState) (string, error) { + for _, l := range []layout.Layout{st.shared, st.private} { + wt, ok := worktreeAt(st.worktrees, l.MetadataWorktree) + if !ok || wt.Prunable { + continue + } + clean, _, _ := worktreeCleanState(ctx, git, l.MetadataWorktree) + switch clean { + case reposetup.PresenceUnknown: + return "", fmt.Errorf("the clean state of %s could not be read", l.MetadataWorktree) + case reposetup.PresenceAbsent: + return "commit or discard the changes in " + l.MetadataWorktree + ", then re-run", nil + } + auth := "" + switch wt.Branch { + case metadataRef(st.shared): + if st.originDocket.State == gitcli.RemoteRefFound { + auth = string(st.originDocket.Commit) + } else if st.bareDckt.State == gitcli.RemoteRefFound { + auth = string(st.bareDckt.Commit) + } + case metadataRef(st.private): + if st.bareDckt.State == gitcli.RemoteRefFound { + auth = string(st.bareDckt.Commit) + } + default: + continue + } + head := string(wt.Head) + published := head != "" && head == auth + switch { + case published || head == "": + case auth != "": + anc, err := git.IsAncestor(ctx, st.sc.repo, gitcli.ObjectID(head), gitcli.ObjectID(auth)) + if err != nil { + return "", err + } + published = anc + case wt.Branch == metadataRef(st.shared) && st.storeDckt.State == gitcli.RemoteRefFound: + // Neither remote holds a branch this clone can read, but another + // clone on this machine published the history to the default store. + held, err := storeHoldsCommit(ctx, git, st.private, st.storeDckt.Commit, gitcli.ObjectID(head)) + if err != nil { + return "", err + } + published = held + } + if !published { + return "the metadata worktree " + l.MetadataWorktree + " holds commits its remote branch lacks; publish them with `docket repository prepare`, or resolve them by hand", nil + } + } + return "", nil +} + +// --- warnings ---------------------------------------------------------------- + +// visibilityWarnings collects the preview's warnings; none of them refuses. +func visibilityWarnings(ctx context.Context, d SetupDeps, st visibilityState, o SetVisibilityOptions) []string { + var out []string + if o.Target == string(layout.Private) { + out = append(out, fmt.Sprintf("teammates' later writes to %s/%s will not be seen by this clone", + st.shared.MetadataRemote, st.shared.MetadataBranch)) + if o.DeleteSharedBranch { + out = append(out, "existing pull-request backlinks into that branch will stop resolving") + } + } + out = append(out, absoluteLinkWarning(ctx, d.Git, st)...) + if o.Target == string(layout.Private) { + out = append(out, docketTextPRWarning(ctx, d, st)...) + } + return out +} + +// visibilityMetadataTip is the metadata tip the warnings read: origin's docket +// branch, else the bare dckt branch ("" when neither exists). +func visibilityMetadataTip(st visibilityState) string { + if st.originDocket.State == gitcli.RemoteRefFound { + return string(st.originDocket.Commit) + } + if st.bareDckt.State == gitcli.RemoteRefFound { + return string(st.bareDckt.Commit) + } + return "" +} + +// absoluteLinkWarning warns about metadata files that still carry absolute +// links into the docket branch. A read error is its own warning. +func absoluteLinkWarning(ctx context.Context, git *gitcli.Client, st visibilityState) []string { + originURL, err := git.RemoteURL(ctx, st.sc.repo, originRemote) + if err != nil { + return []string{"metadata files were not checked for absolute links: " + err.Error()} + } + webURL := githubWebURL(originURL) + tip := visibilityMetadataTip(st) + if webURL == "" || tip == "" { + return nil + } + files, err := readMetadataMarkdown(ctx, git, st.sc.repo, tip) + if err != nil { + return []string{"metadata files were not checked for absolute links: " + err.Error()} + } + hits := absoluteMetadataLinks(webURL, layout.SharedName, files) + if len(hits) == 0 { + return nil + } + shown := hits + more := "" + if len(hits) > visibilityLinkListCap { + shown = hits[:visibilityLinkListCap] + more = fmt.Sprintf(", and %d more", len(hits)-visibilityLinkListCap) + } + return []string{fmt.Sprintf("%d metadata file(s) still carry absolute links into the docket branch; run `docket repository repair` to make the generated ones relative: %s%s", + len(hits), strings.Join(shown, ", "), more)} +} + +// readMetadataMarkdown reads every .md blob at the metadata tip. +func readMetadataMarkdown(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, tip string) (map[string][]byte, error) { + src, err := git.OpenObjectSource(ctx, repo, gitcli.Revision{Commit: gitcli.ObjectID(tip)}) + if err != nil { + return nil, err + } + entries, err := src.ListTree(ctx, nil) + if err != nil { + return nil, err + } + var paths []gitcli.RepoPath + for _, e := range entries { + if e.Type == "blob" && strings.HasSuffix(string(e.Path), ".md") { + paths = append(paths, e.Path) + } + } + files := map[string][]byte{} + if len(paths) == 0 { + return files, nil + } + blobs, err := src.ReadBlobs(ctx, paths) + if err != nil { + return nil, err + } + for _, b := range blobs { + if b.Found { + files[string(b.Path)] = b.Blob.Bytes + } + } + return files, nil +} + +// absoluteMetadataLinks lists, sorted, the files whose bytes link absolutely +// into branch on the repository at webURL (a blob/ or tree/ URL). An empty +// webURL matches nothing. +func absoluteMetadataLinks(webURL, branch string, files map[string][]byte) []string { + if webURL == "" { + return nil + } + needles := [][]byte{ + []byte(webURL + "/blob/" + branch + "/"), + []byte(webURL + "/tree/" + branch + "/"), + } + var out []string + for p, b := range files { + for _, n := range needles { + if bytes.Contains(b, n) { + out = append(out, p) + break + } + } + } + sort.Strings(out) + return out +} + +// docketTextPRWarning lists the open pull requests whose descriptions carry +// docket text; it never edits them, and an unchecked list is its own warning. +func docketTextPRWarning(ctx context.Context, d SetupDeps, st visibilityState) []string { + if d.GitHub == nil { + return []string{"open pull requests were not checked: no GitHub client is available"} + } + tip := visibilityMetadataTip(st) + if tip == "" { + return []string{"open pull requests were not checked: no metadata branch was readable"} + } + sc := st.sc + sc.metadataTip = tip + corpus, err := readCheckCorpus(ctx, d.Git, sc) + if err != nil { + return []string{"open pull requests were not checked: " + err.Error()} + } + snap, ok := buildCorpusSnapshot(sc.cfg, corpus.records) + if !ok { + return []string{"open pull requests were not checked: the metadata corpus could not be read"} + } + urls, err := listDocketTextPRs(ctx, d.GitHub, st.primary, snap.Changes()) + if err != nil { + return []string{"open pull requests were not checked: " + err.Error()} + } + if len(urls) == 0 { + return nil + } + return []string{"open pull requests whose descriptions carry docket text (listed, not edited): " + strings.Join(urls, ", ")} +} + +// docketTextMarker is what a docket-written PR description carries. +const docketTextMarker = "\nx\n\n" + gh := &fakeOpenPRGitHub{prs: map[int]githubcli.PullRequest{ + 11: {Number: 11, URL: "https://github.com/acme/widget/pull/11", State: githubcli.StateOpen, Body: marked}, + 12: {Number: 12, URL: "https://github.com/acme/widget/pull/12", State: githubcli.StateMerged, Body: marked}, + 13: {Number: 13, URL: "https://github.com/acme/widget/pull/13", State: githubcli.StateOpen, Body: "plain"}, + 14: {Number: 14, URL: "https://github.com/acme/widget/pull/14", State: githubcli.StateOpen, Body: marked}, + }} + got, err := listDocketTextPRs(context.Background(), gh, "/repo", snap.Changes()) + if err != nil { + t.Fatal(err) + } + if want := []string{"https://github.com/acme/widget/pull/11"}; !reflect.DeepEqual(got, want) { + t.Errorf("listDocketTextPRs = %v, want %v", got, want) + } + if want := [][]int{{11, 12, 13}}; !reflect.DeepEqual(gh.asked, want) { + t.Errorf("batches asked = %v, want %v", gh.asked, want) + } + + gh.batchErr = errors.New("rate limited") + if _, err := listDocketTextPRs(context.Background(), gh, "/repo", snap.Changes()); err == nil { + t.Error("a failed batch must be an error, never an empty list") + } +} + +// previewTestState is a visibility state with every phase pending except the +// ones the test sets done. +func previewTestState() visibilityState { + common, primary := "/repo/.git", "/repo" + return visibilityState{ + common: common, + primary: primary, + current: layout.Shared, + shared: layout.SharedLayout(common, primary), + private: layout.PrivateLayout(common, primary, "/data", "acme-app"), + primaryBranch: "main", + originDocket: gitcli.RemoteRef{State: gitcli.RemoteRefFound, Commit: "abc"}, + foldSource: "origin's main .docket.yml", + headSharedFiles: true, + sharedStateDir: true, + privateVisibilityAligned: true, + } +} + +// TestVisibilityPreviewTextNamesEveryPendingPhase proves the preview names every +// phase with its status, the planned commit's exact subject and branch, and the +// warnings. +func TestVisibilityPreviewTextNamesEveryPendingPhase(t *testing.T) { + st := previewTestState() + o := SetVisibilityOptions{Target: "private", DeleteSharedBranch: true, RemoveSharedFiles: true} + steps := planToPrivate(st, o) + res := RepositorySetVisibilityResult{ + SourceRevision: "origin-docket=abc dckt=absent origin-default=def", + Commits: visibilityPlannedCommits(st, o, steps, visibilityCommitPlan{rows: []string{".docket.yml (delete)"}}), + Warnings: []string{"a warning"}, + } + text := visibilityPreviewText(st, o, steps, res) + for _, want := range []string{ + "docket repository set-visibility private — plan", + "pinned: origin-docket=abc dckt=absent origin-default=def", + "message: " + visibilityRemoveSubject, + "branch: main", + ".docket.yml (delete)", + "- a warning", + } { + if !strings.Contains(text, want) { + t.Errorf("preview lacks %q:\n%s", want, text) + } + } + for _, name := range []string{"metadata-remote", "publish", "config", "state-folder", "ignore", "metadata-worktree", "instructions", "delete-shared-branch", "remove-shared-files"} { + if !strings.Contains(text, "[pending] "+name+":") { + t.Errorf("preview does not list pending phase %s:\n%s", name, text) + } + } + if !strings.Contains(text, "[done] align-visibility:") { + t.Errorf("preview does not list the done phase align-visibility:\n%s", text) + } + + shared := planToShared(st, SetVisibilityOptions{Target: "shared"}) + sharedText := visibilityPreviewText(st, SetVisibilityOptions{Target: "shared"}, shared, RepositorySetVisibilityResult{}) + for _, s := range shared { + if !strings.Contains(sharedText, s.name+":") { + t.Errorf("shared preview does not list phase %s:\n%s", s.name, sharedText) + } + } +} + +// TestSetVisibilityNilExecutorIsInternalError proves a pending phase with no +// executor stops the run as an internal error, while done phases are skipped. +func TestSetVisibilityNilExecutorIsInternalError(t *testing.T) { + ran := false + steps := []visibilityStep{ + {name: "first", done: true}, + {name: "second", run: func(context.Context, *visibilityRun) error { ran = true; return nil }}, + {name: "third"}, + } + res := RepositorySetVisibilityResult{Phases: visibilityPhaseRows(steps)} + x := &visibilityRun{res: &res} + err := runVisibilitySteps(context.Background(), x, steps) + var internal *visibilityInternal + if !errors.As(err, &internal) || !strings.Contains(err.Error(), "phase third has no executor") { + t.Fatalf("err = %v, want the internal no-executor error", err) + } + if !ran || res.Phases[1].Status != visibilityPhaseApplied || res.Phases[2].Status != visibilityPhasePending { + t.Errorf("phases = %+v, want second applied and third still pending", res.Phases) + } + if out := visibilityStopped(res, err); out.Result != ResultInternalError { + t.Errorf("stopped Result = %q, want internal-error", out.Result) + } +} + +// TestSetVisibilityPhaseHookStopsRun proves the afterVisibilityPhase seam fires +// after each applied phase, named, and its error stops the run there. +func TestSetVisibilityPhaseHookStopsRun(t *testing.T) { + var fired []string + d := SetupDeps{hooks: setupHooks{afterVisibilityPhase: func(p string) error { + fired = append(fired, p) + if p == "b" { + return errors.New("died after b") + } + return nil + }}} + noop := func(context.Context, *visibilityRun) error { return nil } + steps := []visibilityStep{{name: "a", run: noop}, {name: "b", run: noop}, {name: "c", run: noop}} + res := RepositorySetVisibilityResult{Phases: visibilityPhaseRows(steps)} + err := runVisibilitySteps(context.Background(), &visibilityRun{d: d, res: &res}, steps) + if err == nil || !reflect.DeepEqual(fired, []string{"a", "b"}) { + t.Fatalf("err = %v, fired = %v; want the run stopped after b", err, fired) + } +} diff --git a/internal/app/runtracker_live.go b/internal/app/runtracker_live.go new file mode 100644 index 000000000..e63aa381c --- /dev/null +++ b/internal/app/runtracker_live.go @@ -0,0 +1,82 @@ +package app + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" +) + +// liveRunStateUnreadable is the State a liveRunLocator carries for a run.json the +// store cannot read: its liveness is unknown, and unknown is never "not live". +const liveRunStateUnreadable = "unreadable" + +// liveRunLocator names one run that may still be live, for a repository-wide +// precondition that refuses while any run is live. Remedy is the exact next step +// a human takes to settle it. +type liveRunLocator struct{ Key, State, ChangeID, Remedy string } + +// liveRunsUnder scans every //*/run.json through +// readStoredRun and returns the runs that may still be live, sorted by key. +// +// Live = RunActive, RunCompleting, RunCancelling, or any state this binary does +// not know; RunCompleted, RunCancelled, and RunSuperseded are not. A run.json the +// store cannot read (corrupt, unknown schema, an I/O error) is a locator with +// State liveRunStateUnreadable. A key directory without run.json holds no run and +// is skipped, as is any non-directory entry. A missing root is (nil, nil); any +// other failure to list the root is an error. +func liveRunsUnder(stateDir string) ([]liveRunLocator, error) { + root := filepath.Join(stateDir, runTrackerDirName) + entries, err := os.ReadDir(root) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("reading %s: %w", root, err) + } + var live []liveRunLocator + for _, e := range entries { + if !e.IsDir() { + continue + } + key := e.Name() + dir := filepath.Join(root, key) + rec, _, rerr := readStoredRun(dir, "live-runs") + if rerr != nil { + if re, ok := AsRunError(rerr); ok && re.Kind == ErrRunNotFound { + continue + } + live = append(live, liveRunLocator{ + Key: key, State: liveRunStateUnreadable, Remedy: byHandRemedy(dir), + }) + continue + } + loc := liveRunLocator{Key: key, State: string(rec.State), ChangeID: rec.ChangeID} + switch rec.State { + case RunCompleted, RunCancelled, RunSuperseded: + continue + case RunActive, RunCompleting: + loc.Remedy = runCancelCommand(key) + case RunCancelling: + loc.Remedy = "re-run the same " + runCancelCommand(key) + " until it reports cancelled" + default: + // run cancel refuses a state it does not know, so only a person can settle it. + loc.Remedy = byHandRemedy(dir) + } + live = append(live, loc) + } + sort.Slice(live, func(i, j int) bool { return live[i].Key < live[j].Key }) + return live, nil +} + +// runCancelCommand is the ready-to-run cancel for key; stays a literal +// placeholder for the human's reason, as in runStartStopNote. +func runCancelCommand(key string) string { + return "docket run cancel --key " + key + " --reason " +} + +func byHandRemedy(dir string) string { + return "inspect or remove " + dir + " by hand" +} diff --git a/internal/app/runtracker_live_test.go b/internal/app/runtracker_live_test.go new file mode 100644 index 000000000..0fc4efe61 --- /dev/null +++ b/internal/app/runtracker_live_test.go @@ -0,0 +1,90 @@ +package app + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// writeRunFixture writes a storedRun envelope for key in state under stateDir's +// run-tracker root and returns the key's directory. +func writeRunFixture(t *testing.T, stateDir, key string, state runState) string { + t.Helper() + buf, err := json.Marshal(storedRun{Generation: "g", Record: RunRecord{ + SchemaVersion: runSchemaVersion, RunKey: key, ChangeID: "7", State: state, + }}) + if err != nil { + t.Fatal(err) + } + return writeRunFile(t, stateDir, key, buf) +} + +func writeRunFile(t *testing.T, stateDir, key string, buf []byte) string { + t.Helper() + dir := filepath.Join(stateDir, runTrackerDirName, key) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, runRecordFileName), buf, 0o600); err != nil { + t.Fatal(err) + } + return dir +} + +func TestLiveRunsUnderReportsLiveAndUnreadable(t *testing.T) { + stateDir := testsupport.TempDir(t) + writeRunFixture(t, stateDir, "k-active", RunActive) + writeRunFixture(t, stateDir, "k-completing", RunCompleting) + writeRunFixture(t, stateDir, "k-cancelling", RunCancelling) + writeRunFixture(t, stateDir, "k-completed", RunCompleted) + writeRunFixture(t, stateDir, "k-cancelled", RunCancelled) + writeRunFixture(t, stateDir, "k-superseded", RunSuperseded) + badDir := writeRunFile(t, stateDir, "k-broken", []byte("{not json")) + // A key directory without run.json (a run-tracker record only) is no run. + if err := os.MkdirAll(filepath.Join(stateDir, runTrackerDirName, "k-norun"), 0o700); err != nil { + t.Fatal(err) + } + + got, err := liveRunsUnder(stateDir) + if err != nil { + t.Fatal(err) + } + cancel := func(k string) string { return "docket run cancel --key " + k + " --reason " } + want := []liveRunLocator{ + {Key: "k-active", State: string(RunActive), ChangeID: "7", Remedy: cancel("k-active")}, + {Key: "k-broken", State: "unreadable", Remedy: "inspect or remove " + badDir + " by hand"}, + {Key: "k-cancelling", State: string(RunCancelling), ChangeID: "7", + Remedy: "re-run the same " + cancel("k-cancelling") + " until it reports cancelled"}, + {Key: "k-completing", State: string(RunCompleting), ChangeID: "7", Remedy: cancel("k-completing")}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("liveRunsUnder =\n%#v\nwant\n%#v", got, want) + } +} + +// A state this binary does not know is live (liveness unknown), and run cancel +// refuses it, so its remedy is the by-hand one. +func TestLiveRunsUnderUnknownStateIsLive(t *testing.T) { + stateDir := testsupport.TempDir(t) + dir := writeRunFixture(t, stateDir, "k-future", runState("paused")) + got, err := liveRunsUnder(stateDir) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Key != "k-future" || got[0].State != "paused" || + !strings.Contains(got[0].Remedy, dir) { + t.Fatalf("liveRunsUnder = %#v", got) + } +} + +func TestLiveRunsUnderMissingRoot(t *testing.T) { + got, err := liveRunsUnder(testsupport.TempDir(t)) + if err != nil || got != nil { + t.Fatalf("missing root = %#v, %v; want nil, nil", got, err) + } +} diff --git a/internal/app/schema_registry.go b/internal/app/schema_registry.go index ed7a6413b..f591ca0a7 100644 --- a/internal/app/schema_registry.go +++ b/internal/app/schema_registry.go @@ -109,6 +109,7 @@ var operationBindings = []OperationBinding{ {ID: "repository.migrate", Request: nil, Result: RepositoryMigrateResult{}}, // RunRepositoryMigrate {ID: "repository.prepare", Request: nil, Result: RepositoryPrepareResult{}}, // RunRepositoryPrepare {ID: "repository.repair", Request: nil, Result: RepositoryRepairResult{}}, // RunRepositoryRepair + {ID: "repository.set-visibility", Request: nil, Result: RepositorySetVisibilityResult{}}, // RunRepositorySetVisibility {ID: "repository.sync-integration", Request: nil, Result: RepositorySyncResult{}}, // RunRepositorySyncIntegration {ID: "run.cancel", Request: nil, Result: RunCancelResult{}}, // RunCancel {ID: "run.continue", Request: nil, Result: RunContinueResult{}}, // RunContinue diff --git a/internal/app/workflow_private_integration_test.go b/internal/app/workflow_private_integration_test.go index 583aa41e0..ee8c6cf89 100644 --- a/internal/app/workflow_private_integration_test.go +++ b/internal/app/workflow_private_integration_test.go @@ -254,7 +254,7 @@ func TestIntegrationWorkflowLifecyclePrivateInitToImplemented(t *testing.T) { originBefore := localHeads(t, r.origin) lay := resolvedPrivateLayout(t, r.invocation) - if want := privateLayoutOf(t, r.invocation, data).DefaultBareRemote; lay.DefaultBareRemote != want { + if want := expectedPrivateLayout(t, r.invocation, data).DefaultBareRemote; lay.DefaultBareRemote != want { t.Fatalf("resolved store %q, want %q", lay.DefaultBareRemote, want) } if url := runGit(t, r.invocation, "config", "--get", "remote.dckt.url"); url != lay.DefaultBareRemote { diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 5ea2d31d0..71afca4d9 100644 --- a/internal/assets/embedded/manifest.json +++ b/internal/assets/embedded/manifest.json @@ -1,14 +1,14 @@ { "format_version": 1, "asset_protocol": 1, - "asset_set_id": "sha256:ebf6576613d6042482da69471a7ebd3ff22bce38c13bccfee634bd5ee8ae043d", + "asset_set_id": "sha256:05fb9b2fdf4bbd2c88734f81efa7fc7bdd1a7050c2bbb6e55e35733a53137f3c", "entries": [ { "path": ".docket.example.yml", "role": "config-schema", "mode": 420, - "size": 20509, - "sha256": "bbe397f9b29e6971e63578b5e0affb4e75ae63976ec601f43cc016dea0b7085e" + "size": 20596, + "sha256": "3937c05a796905b7dc5f2028811f661744b3ab671036ebfcb2acd2da0620ff91" }, { "path": "agents/docket-adr.md", @@ -322,8 +322,8 @@ "path": "skills/docket-convention/SKILL.md", "role": "skill", "mode": 420, - "size": 52933, - "sha256": "2947b14fb015f8e586a89012826c18c479755535de53d89b489305187c50ab8d" + "size": 53050, + "sha256": "0074153aa2e0843811a933b1235b1deb75f8a85171245b012320080cfe65520c" }, { "path": "skills/docket-convention/references/agent-layer.md", diff --git a/internal/assets/embedded/tree/.docket.example.yml b/internal/assets/embedded/tree/.docket.example.yml index b5159bf8e..0a92a7ed7 100644 --- a/internal/assets/embedded/tree/.docket.example.yml +++ b/internal/assets/embedded/tree/.docket.example.yml @@ -52,7 +52,8 @@ integration_branch: auto # bare repository under ~/.local/share/dckt, with nothing docket-named inside the repository — for # repositories that must not carry any docket files. `docket repository init` sets a new # repository up in this mode (`--private` / `--shared` override it); changing it later does not -# move a repository that is already set up. +# move a repository that is already set up; `docket repository set-visibility ` +# moves one, keeping every record. # scope: any layer visibility: shared diff --git a/internal/assets/embedded/tree/skills/docket-convention/SKILL.md b/internal/assets/embedded/tree/skills/docket-convention/SKILL.md index 0d6976f55..ab7ac55d4 100644 --- a/internal/assets/embedded/tree/skills/docket-convention/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-convention/SKILL.md @@ -350,7 +350,7 @@ At startup, after resolving config, fetch origin and evaluate a 2×2 over two pr Metadata (change files, `BOARD.md`, ADRs, specs) commits to the metadata branch (`docket` in a shared repository) via the persistent **metadata worktree** (ADR-0099: docket is the sole metadata topology) — and is **always pushed to the metadata remote immediately**: `origin` in a shared repository (the planning surface stays browsable there), the local `dckt` bare remote in a private one. The `repository.prepare` context values — `metadata_worktree_path`, `metadata_remote`, `metadata_tracking_ref` — are authoritative; never assume a worktree path or remote. -A private repository (`.git/dckt/` present) spells the metadata branch and remote `dckt`, keeps its config in `.git/dckt/config.yml`, and never reads `.docket.yml`. +A private repository (`.git/dckt/` present) spells the metadata branch and remote `dckt`, keeps its config in `.git/dckt/config.yml`, and never reads `.docket.yml`. Editing `visibility` never moves a repository; the `repository.set-visibility` operation does, keeping every record. **Private-repository writing rule.** In a private repository (`visibility: private` in the `repository.prepare` context), nothing that ships through the feature branch or the PR may contain the words "docket" or "dckt", docket marker comments, `Docket-` trailers, `.docket`/`dckt` paths, change ids or change references (`change 0612`, `#0612`, `(0612)`), docket ADR numbers, or references to the backlog or board. The metadata branch's own files (plan, results, records) are exempt and must not be copied from. `workspace.publish`, `pr.publish`, and `finalize.publish` enforce it with a blocking leak check (`leak-detected`); `leak_check.match_word: false` lets the bare word "docket" through. A shared repository is unaffected. diff --git a/internal/cli/install.go b/internal/cli/install.go index b5b136967..c4d8cf880 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -112,6 +112,7 @@ var assetIndependent = map[string]bool{ "repository check": true, "repository migrate": true, "repository repair": true, + "repository set-visibility": true, "repository prepare": true, "repository configure-tests": true, "repository configure-harnesses": true, diff --git a/internal/cli/repository.go b/internal/cli/repository.go index 600c18359..b22492fc6 100644 --- a/internal/cli/repository.go +++ b/internal/cli/repository.go @@ -55,6 +55,14 @@ var ( } return gh, nil } + repositorySetVisibilityRunner = func(ctx context.Context, d app.SetupDeps, o app.SetVisibilityOptions) app.OperationResult { + return app.RunRepositorySetVisibility(ctx, d, o) + } + // repositorySetVisibilityGitHub constructs the GitHub client `repository + // set-visibility` reads open PR bodies through; an error leaves it unwired + // and the preview says the PRs were not checked. Tests replace it. + repositorySetVisibilityGitHub = func() (app.RepairGitHub, error) { return repositoryRepairGitHub() } + repositoryPrepareRunner = func(ctx context.Context, d app.SetupDeps, o app.PrepareOptions) app.OperationResult { return app.RunRepositoryPrepare(ctx, d, o) } @@ -166,6 +174,7 @@ func newRepositoryCommand(setResult func(app.OperationResult)) *cobra.Command { EffectRead) migrateCmd := newRepositoryMigrateCommand(setResult) repairCmd := newRepositoryRepairCommand(setResult) + setVisibilityCmd := newRepositorySetVisibilityCommand(setResult) prepareCmd := repositorySubcommand("prepare", "Prepare the repository for a workflow: pin topology and attach or fast-forward the .docket worktree (the startup check)", func(c *cobra.Command, deps app.SetupDeps) { @@ -215,7 +224,7 @@ func newRepositoryCommand(setResult func(app.OperationResult)) *cobra.Command { // the primary checkout; it never pushes and changes no planning metadata. EffectLocalWrite) - repositoryCmd.AddCommand(initCmd, checkCmd, migrateCmd, repairCmd, prepareCmd, configureTestsCmd, configureHarnessesCmd, syncIntegrationCmd) + repositoryCmd.AddCommand(initCmd, checkCmd, migrateCmd, repairCmd, setVisibilityCmd, prepareCmd, configureTestsCmd, configureHarnessesCmd, syncIntegrationCmd) return repositoryCmd } @@ -355,6 +364,92 @@ func newRepositoryRepairCommand(setResult func(app.OperationResult)) *cobra.Comm return cmd } +// newRepositorySetVisibilityCommand builds `docket repository set-visibility +// ` with the two-pass confirm flow `repair` uses: --yes applies +// directly; without it the service previews, and a terminal confirmation +// re-invokes authorized, pinned to exactly the state the preview showed. +func newRepositorySetVisibilityCommand(setResult func(app.OperationResult)) *cobra.Command { + cmd := &cobra.Command{ + Use: "set-visibility ", + Short: "Move an existing repository between shared and private visibility, keeping every record", + Long: `Move an existing repository between shared and private visibility, keeping every record. + +Without --yes it previews every phase and the composite revision it pinned; on a +terminal it asks for confirmation, and --yes applies the switch directly. Each +phase reads its completion from the repository's state, so re-running after an +interruption resumes where the last run stopped. It refuses while any run or gate +is live in this repository. + +Going private publishes the identical metadata history to the dckt branch of a +bare repository on this machine and moves the configuration, the metadata +checkout, and the dispatch instructions under .git/. Origin's docket branch and +the committed files stay unless --delete-shared-branch or --remove-shared-files +asks to remove them. + +Going shared publishes the history to origin's docket branch, restores +.docket.yml, .gitignore, and the dispatch instructions, and keeps the bare +repository as a backup. + +Commits on the integration branch are local and hold only the switch's own +paths; they are never pushed.`, + Args: cobra.ExactArgs(1), + // local-write (state folder, config, ignore files, metadata checkout, + // dispatch surfaces, local commits) + metadata-write (the identical + // history published under the target branch name) + external-write + // (origin's docket branch deleted under --delete-shared-branch). + Annotations: capability("repository.set-visibility", EffectLocalWrite, EffectMetadataWrite, EffectExternalWrite), + RunE: func(c *cobra.Command, args []string) error { + repoDir, err := resolveRepoDir(c) + if err != nil { + return err + } + client, err := gitcli.NewClient() + if err != nil { + return err + } + deps := app.SetupDeps{Git: client, RepoDir: repoDir} + if gh, err := repositorySetVisibilityGitHub(); err == nil && gh != nil { + deps.GitHub = gh + } + o := app.SetVisibilityOptions{Target: args[0]} + o.MetadataRemote, _ = c.Flags().GetString("metadata-remote") + o.DeleteSharedBranch, _ = c.Flags().GetBool("delete-shared-branch") + o.RemoveSharedFiles, _ = c.Flags().GetBool("remove-shared-files") + + if yes, _ := c.Flags().GetBool("yes"); yes { + o.Authorized = true + setResult(repositorySetVisibilityRunner(c.Context(), deps, o)) + return nil + } + preview := repositorySetVisibilityRunner(c.Context(), deps, o) + jsonMode, _ := c.Flags().GetBool("json") + confirmable, ok := preview.(interface{ ConfirmationRequired() bool }) + if jsonMode || !repositoryConfirmInteractive() || !ok || !confirmable.ConfirmationRequired() { + setResult(preview) + return nil + } + fmt.Fprintln(c.OutOrStdout(), preview.HumanText()) + fmt.Fprint(c.OutOrStdout(), "switch? [y/N] ") + if !repositoryReadYes(c.InOrStdin()) { + setResult(preview) + return nil + } + if p, ok := preview.(interface{ SourceRev() string }); ok { + o.ExpectedSource = p.SourceRev() + } + o.Authorized = true + setResult(repositorySetVisibilityRunner(c.Context(), deps, o)) + return nil + }, + } + cmd.Flags().String("repo-dir", "", "repository `dir` to operate on (default: current directory)") + cmd.Flags().Bool("yes", false, "authorize the previewed switch without an interactive confirmation") + cmd.Flags().String("metadata-remote", "", "going private: push the dckt branch to this git `url` instead of the default bare repository") + cmd.Flags().Bool("delete-shared-branch", false, "going private: delete origin's docket branch once the bare remote holds the same tip") + cmd.Flags().Bool("remove-shared-files", false, "going private: remove .docket.yml, the .gitignore block, and the dispatch instructions in one local commit") + return cmd +} + // repositoryReadYes reads one line from r and reports whether it affirms the // prompt (an empty or absent line is No — the prompt is [y/N]). func repositoryReadYes(r io.Reader) bool { diff --git a/internal/cli/repository_test.go b/internal/cli/repository_test.go index c1c0665b6..56a6b219a 100644 --- a/internal/cli/repository_test.go +++ b/internal/cli/repository_test.go @@ -2,6 +2,7 @@ package cli import ( "context" + "errors" "strings" "testing" @@ -14,7 +15,7 @@ import ( // group and an unknown subcommand both fail rather than silently succeeding. func TestRepositoryCommandsRegistered(t *testing.T) { root := captureTree(t) - for _, sub := range []string{"init", "check", "migrate", "prepare", "configure-tests", "configure-harnesses", "repair"} { + for _, sub := range []string{"init", "check", "migrate", "prepare", "configure-tests", "configure-harnesses", "repair", "set-visibility"} { cmd, _, err := root.Find([]string{"repository", sub}) if err != nil || cmd == nil || cmd.Name() != sub { t.Fatalf("repository %s not registered: cmd=%v err=%v", sub, cmd, err) @@ -727,3 +728,106 @@ func TestRepositoryHarnessesHelp(t *testing.T) { t.Error(`assetIndependent["repository configure-harnesses"] must be true`) } } + +// TestRepositorySetVisibilityRegisteredWithCapability proves `repository +// set-visibility` is a registered leaf taking exactly one argument, carrying its +// flags, the catalog id repository.set-visibility, exactly the local-write, +// metadata-write, and external-write effects, and an asset-independent entry. +func TestRepositorySetVisibilityRegisteredWithCapability(t *testing.T) { + root := captureTree(t) + cmd, _, err := root.Find([]string{"repository", "set-visibility"}) + if err != nil || cmd == nil || cmd.Name() != "set-visibility" { + t.Fatalf("repository set-visibility not registered: cmd=%v err=%v", cmd, err) + } + for _, flag := range []string{"repo-dir", "yes", "metadata-remote", "delete-shared-branch", "remove-shared-files"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("repository set-visibility: missing --%s flag", flag) + } + } + if got := cmd.Annotations[capAnnotationID]; got != "repository.set-visibility" { + t.Errorf("capability id = %q, want repository.set-visibility", got) + } + if got, want := cmd.Annotations[capAnnotationEffects], "external-write local-write metadata-write"; got != want { + t.Errorf("effects = %q, want exactly %q", got, want) + } + if !assetIndependent["repository set-visibility"] { + t.Errorf("repository set-visibility must be asset-independent") + } + if cmd.Args == nil || cmd.Args(cmd, nil) == nil || cmd.Args(cmd, []string{"a", "b"}) == nil || cmd.Args(cmd, []string{"private"}) != nil { + t.Errorf("repository set-visibility must take exactly one argument") + } +} + +// fakeVisibilityResult is a stub set-visibility result. +type fakeVisibilityResult struct { + app.Envelope + source string + confirm bool +} + +func (r fakeVisibilityResult) HumanText() string { return "visibility plan @ " + r.source } +func (r fakeVisibilityResult) SourceRev() string { return r.source } +func (r fakeVisibilityResult) ConfirmationRequired() bool { return r.confirm } + +// stubSetVisibility replaces the runner and the GitHub constructor for one test. +func stubSetVisibility(t *testing.T, confirm func(o app.SetVisibilityOptions) bool) *[]app.SetVisibilityOptions { + t.Helper() + var calls []app.SetVisibilityOptions + old := repositorySetVisibilityRunner + repositorySetVisibilityRunner = func(ctx context.Context, d app.SetupDeps, o app.SetVisibilityOptions) app.OperationResult { + calls = append(calls, o) + return fakeVisibilityResult{Envelope: app.NewEnvelope("repository.set-visibility", app.ResultInvalidState), source: "pin", confirm: confirm(o)} + } + oldGH := repositorySetVisibilityGitHub + repositorySetVisibilityGitHub = func() (app.RepairGitHub, error) { return nil, errors.New("no gh") } + t.Cleanup(func() { repositorySetVisibilityRunner = old; repositorySetVisibilityGitHub = oldGH }) + return &calls +} + +// TestRepositorySetVisibilityFlagsReachOptions proves the argument and every +// flag reach the service, and --yes authorizes directly with no preview pass. +func TestRepositorySetVisibilityFlagsReachOptions(t *testing.T) { + calls := stubSetVisibility(t, func(app.SetVisibilityOptions) bool { return false }) + _, _, _ = runCLI(t, "repository", "set-visibility", "private", "--yes", + "--metadata-remote", "/tmp/backup.git", "--delete-shared-branch", "--remove-shared-files") + if len(*calls) != 1 { + t.Fatalf("runner called %d times, want once", len(*calls)) + } + got := (*calls)[0] + want := app.SetVisibilityOptions{Target: "private", Authorized: true, MetadataRemote: "/tmp/backup.git", DeleteSharedBranch: true, RemoveSharedFiles: true} + if got != want { + t.Errorf("options = %+v, want %+v", got, want) + } +} + +// TestRepositorySetVisibilityInteractiveConfirmReinvokes proves `y` re-invokes +// authorized, pinned to the previewed state, keeping the target and flags. +func TestRepositorySetVisibilityInteractiveConfirmReinvokes(t *testing.T) { + calls := stubSetVisibility(t, func(o app.SetVisibilityOptions) bool { return !o.Authorized }) + oldI := repositoryConfirmInteractive + repositoryConfirmInteractive = func() bool { return true } + defer func() { repositoryConfirmInteractive = oldI }() + + _, _, _ = runCLIStdin(t, "y\n", "repository", "set-visibility", "private", "--remove-shared-files") + if len(*calls) != 2 { + t.Fatalf("runner called %d times, want preview then authorized", len(*calls)) + } + p, a := (*calls)[0], (*calls)[1] + if p.Authorized || !a.Authorized || a.ExpectedSource != "pin" || a.Target != "private" || !a.RemoveSharedFiles { + t.Errorf("calls = %+v, want an unauthorized preview then an authorized run pinned to pin with the same flags", *calls) + } +} + +// TestRepositorySetVisibilityNonInteractivePreview proves without --yes and +// without a terminal the service is called once, unauthorized. +func TestRepositorySetVisibilityNonInteractivePreview(t *testing.T) { + calls := stubSetVisibility(t, func(app.SetVisibilityOptions) bool { return true }) + oldI := repositoryConfirmInteractive + repositoryConfirmInteractive = func() bool { return false } + defer func() { repositoryConfirmInteractive = oldI }() + + _, _, _ = runCLI(t, "repository", "set-visibility", "shared") + if len(*calls) != 1 || (*calls)[0].Authorized || (*calls)[0].Target != "shared" { + t.Fatalf("want exactly one unauthorized shared preview: %+v", *calls) + } +} diff --git a/internal/config/schema.go b/internal/config/schema.go index 96179e9d0..565f522e9 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -203,6 +203,19 @@ func SettingPaths() []SettingPath { return out } +// RepoOnlyPaths returns every registry path a machine layer may not declare +// (scopeRepoOnly), in registry order: the repository-identity leaves a +// visibility switch never carries out of the local file. +func RepoOnlyPaths() []string { + var out []string + for _, s := range registryTable { + if s.scope == scopeRepoOnly { + out = append(out, s.path) + } + } + return out +} + // dispositionSupported reports whether a disposition family is supported // configuration. finalize.gate (refused only for some values), board_surfaces // (one token dropped), and the agents model/effort leaves (honoured only from diff --git a/internal/config/setting_paths_test.go b/internal/config/setting_paths_test.go index 47e7d788b..8eb7a81f9 100644 --- a/internal/config/setting_paths_test.go +++ b/internal/config/setting_paths_test.go @@ -9,6 +9,22 @@ import ( // supported key list the documentation promises. The expected supported set is // written out because it is the contract docs and .docket.example.yml are held // to; the unsupported side is checked by sampling every disposition family. +// TestRepoOnlyPaths pins the repository-only setting list, in registry order: +// the leaves a machine layer may not declare and a visibility switch never +// carries out of the local file. +func TestRepoOnlyPaths(t *testing.T) { + want := []string{"integration_branch", "changes_dir", "adrs_dir", "results_dir", "github_project", "terminal_publish"} + got := RepoOnlyPaths() + if len(got) != len(want) { + t.Fatalf("RepoOnlyPaths() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("RepoOnlyPaths() = %v, want %v", got, want) + } + } +} + func TestSettingPathsSupportSplit(t *testing.T) { paths := SettingPaths() if len(paths) != len(registry()) { diff --git a/internal/gatedrive/busy_worktree_locks_test.go b/internal/gatedrive/busy_worktree_locks_test.go new file mode 100644 index 000000000..178ac3345 --- /dev/null +++ b/internal/gatedrive/busy_worktree_locks_test.go @@ -0,0 +1,99 @@ +package gatedrive + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/danielhanold/docket/internal/process" + "github.com/danielhanold/docket/internal/testsupport" +) + +// mkLockDir creates /worktree-locks/, with a busy.lock in it +// when withLock is set, and returns the directory. +func mkLockDir(t *testing.T, stateDir, name string, withLock bool) string { + t.Helper() + dir := filepath.Join(stateDir, "worktree-locks", name) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if withLock { + if err := os.WriteFile(filepath.Join(dir, worktreeLockFile), nil, 0o600); err != nil { + t.Fatal(err) + } + } + return dir +} + +func TestBusyWorktreeLocksReturnsOnlyHeldDirs(t *testing.T) { + stateDir := testsupport.TempDir(t) + held := mkLockDir(t, stateDir, "bbb", true) + mkLockDir(t, stateDir, "aaa", true) // free: nobody holds it + mkLockDir(t, stateDir, "ccc", false) // no busy.lock: skipped + if err := os.WriteFile(filepath.Join(stateDir, "worktree-locks", "stray"), nil, 0o600); err != nil { + t.Fatal(err) + } + f, busy, err := process.TryExclusiveLock(filepath.Join(held, worktreeLockFile)) + if err != nil || busy { + t.Fatalf("hold lock: busy=%v err=%v", busy, err) + } + defer f.Close() + + got, err := BusyWorktreeLocks(stateDir) + if err != nil { + t.Fatal(err) + } + if want := []string{held}; !reflect.DeepEqual(got, want) { + t.Fatalf("BusyWorktreeLocks = %v, want %v", got, want) + } + // The probe never creates a lock file where none was. + if _, err := os.Stat(filepath.Join(stateDir, "worktree-locks", "ccc", worktreeLockFile)); !os.IsNotExist(err) { + t.Fatalf("probe created busy.lock in a lockless dir: %v", err) + } + // The probe leaves a free lock free: a fresh acquire still succeeds. + g, busy, err := process.TryExclusiveLock(filepath.Join(stateDir, "worktree-locks", "aaa", worktreeLockFile)) + if err != nil || busy { + t.Fatalf("free lock after probe: busy=%v err=%v", busy, err) + } + g.Close() + + f.Close() + got, err = BusyWorktreeLocks(stateDir) + if err != nil || got != nil { + t.Fatalf("after release = %v, %v; want nil, nil", got, err) + } +} + +func TestBusyWorktreeLocksEmptyAndMissingRoot(t *testing.T) { + stateDir := testsupport.TempDir(t) + got, err := BusyWorktreeLocks(stateDir) + if err != nil || got != nil { + t.Fatalf("missing root = %v, %v; want nil, nil", got, err) + } + if err := os.MkdirAll(filepath.Join(stateDir, "worktree-locks"), 0o700); err != nil { + t.Fatal(err) + } + got, err = BusyWorktreeLocks(stateDir) + if err != nil || got != nil { + t.Fatalf("empty root = %v, %v; want nil, nil", got, err) + } +} + +// An unprobeable lock is liveness unknown: it is an error, never "not busy". +func TestBusyWorktreeLocksUnprobeableIsError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores file modes") + } + stateDir := testsupport.TempDir(t) + dir := mkLockDir(t, stateDir, "aaa", true) + lock := filepath.Join(dir, worktreeLockFile) + if err := os.Chmod(lock, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(lock, 0o600) }) + got, err := BusyWorktreeLocks(stateDir) + if err == nil { + t.Fatalf("unprobeable lock = %v, nil; want an error", got) + } +} diff --git a/internal/gatedrive/store.go b/internal/gatedrive/store.go index 29a72da60..e1c98e7c8 100644 --- a/internal/gatedrive/store.go +++ b/internal/gatedrive/store.go @@ -164,7 +164,7 @@ func OpenStore(gitCommonDir string) *Store { root: filepath.Join(state, "gate-drives", "v2"), scopeRoot: filepath.Join(state, "gate-scopes", "v2"), suiteBudgetRoot: filepath.Join(state, "gate-suite-budgets", "v1"), - lockRoot: filepath.Join(state, "worktree-locks"), + lockRoot: filepath.Join(state, worktreeLockRootName), } } diff --git a/internal/gatedrive/worktree_lock.go b/internal/gatedrive/worktree_lock.go index 16b1407cf..7a64ede35 100644 --- a/internal/gatedrive/worktree_lock.go +++ b/internal/gatedrive/worktree_lock.go @@ -21,17 +21,22 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" + "fmt" + "io/fs" "os" "path/filepath" + "sort" "time" "github.com/danielhanold/docket/internal/process" ) const ( - worktreeLockFile = "busy.lock" - worktreeHolderFile = "holder.json" - opWorktreeAdmission = "worktree-admission" + worktreeLockRootName = "worktree-locks" + worktreeLockFile = "busy.lock" + worktreeHolderFile = "holder.json" + opWorktreeAdmission = "worktree-admission" ) // HolderNote is the diagnostic record of the gate that last took a worktree lock. @@ -176,3 +181,39 @@ func liveHolder(dir string, obs HolderObserver) *IncumbentSnapshot { Owner: n.Owner, } } + +// BusyWorktreeLocks reports every worktree lock a live process holds right now: +// the /worktree-locks/ directories whose busy.lock is held, +// sorted. It is a repository-wide precondition probe, never an admission: each +// lock is probed with process.ProbeLock (no create, never waiting, released by +// close), so a free lock stays free and no lock file is minted where none was. +// A missing root is (nil, nil) and a directory without busy.lock is skipped. A +// lock the probe cannot decide is an error, never "not busy": liveness unknown +// is not absence. +func BusyWorktreeLocks(stateDir string) ([]string, error) { + root := filepath.Join(stateDir, worktreeLockRootName) + entries, err := os.ReadDir(root) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("reading %s: %w", root, err) + } + var busy []string + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(root, e.Name()) + switch process.ProbeLock(filepath.Join(dir, worktreeLockFile)) { + case process.LockProbeHeld: + busy = append(busy, dir) + case process.LockProbeFree, process.LockProbeMissing: + // Nobody holds it, or there is no lock file: not busy. + default: + return nil, fmt.Errorf("cannot probe the worktree lock in %s", dir) + } + } + sort.Strings(busy) + return busy, nil +} diff --git a/internal/gitcli/commit.go b/internal/gitcli/commit.go index ff6a06239..bb1d84d73 100644 --- a/internal/gitcli/commit.go +++ b/internal/gitcli/commit.go @@ -5,6 +5,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "strings" "time" ) @@ -12,6 +14,9 @@ import ( // commitPathsOp labels every Failure raised by the explicit-path commit surface. const commitPathsOp Operation = "commit-paths" +// commitOwnPathsOp labels every Failure raised by CommitOwnPaths. +const commitOwnPathsOp Operation = "commit-own-paths" + // Trailer is one engine-owned commit trailer. Key is a token (letters, digits, // and hyphens, beginning with a letter or digit); Value is a single line with no // control characters. Both are validated before a message is composed so a @@ -141,6 +146,105 @@ func (c *Client) CommitPaths(ctx context.Context, repo Repository, req CommitReq return id, nil } +// CommitOwnPaths commits exactly paths on dir's current branch with the one-line +// message subject and returns the new HEAD. dir is the absolute root of a +// non-bare worktree; paths are relative to it. Every other staged or unstaged +// change stays put (`git commit --only`). The repository's own hooks and signing +// apply, as for any commit a person makes: unlike CommitPaths there is no +// core.hooksPath override, no --no-verify, and no signing override. A path +// absent from the index but present in the working tree is first registered +// with `git add --intent-to-add` so the commit can carry it; a named path +// deleted from the working tree is committed as a removal. Detached HEAD is +// invalid-request; a hook or other failure is command-failed with a stderr +// excerpt. +func (c *Client) CommitOwnPaths(ctx context.Context, dir string, paths []RepoPath, subject string) (ObjectID, error) { + if !filepath.IsAbs(dir) { + return "", newFailure(commitOwnPathsOp, KindInvalidRequest, "worktree path must be absolute", nil) + } + if len(paths) == 0 { + return "", newFailure(commitOwnPathsOp, KindInvalidRequest, "no paths to commit", nil) + } + if err := validateCommitSubject(subject); err != nil { + return "", newFailure(commitOwnPathsOp, KindInvalidRequest, "invalid commit subject", err) + } + for _, p := range paths { + if err := validateRepoPath(p, false); err != nil { + return "", newFailure(commitOwnPathsOp, KindInvalidRequest, "invalid commit path", err) + } + } + + sym, f := c.run(ctx, runRequest{op: commitOwnPathsOp, dir: dir, args: []string{"symbolic-ref", "--quiet", "HEAD"}}) + if f != nil { + return "", f + } + switch sym.exitCode { + case 0: + case 1: // --quiet: exit 1 IS the clean detached answer + return "", newFailure(commitOwnPathsOp, KindInvalidRequest, "HEAD is detached; no branch to commit on", nil) + default: + return "", newFailure(commitOwnPathsOp, KindCommandFailed, + "symbolic-ref HEAD failed: "+stderrExcerpt(sym.stderr), nil).withExitCode(sym.exitCode) + } + + // Register untracked-but-present paths so `commit --only` can name them. + var intent bytes.Buffer + for _, p := range paths { + res, f := c.run(ctx, runRequest{op: commitOwnPathsOp, dir: dir, args: []string{"ls-files", "--error-unmatch", "--", string(p)}}) + if f != nil { + return "", f + } + if res.exitCode == 0 { + continue + } + if _, err := os.Lstat(filepath.Join(dir, filepath.FromSlash(string(p)))); err != nil { + if os.IsNotExist(err) { + continue // absent everywhere but HEAD: committed as a removal + } + return "", newFailure(commitOwnPathsOp, KindCommandFailed, "stat commit path", err) + } + intent.WriteString(string(p)) + intent.WriteByte(0) + } + if intent.Len() > 0 { + res, f := c.run(ctx, runRequest{ + op: commitOwnPathsOp, + dir: dir, + args: []string{"add", "--intent-to-add", "--pathspec-from-file=-", "--pathspec-file-nul"}, + stdin: intent.Bytes(), + }) + if f != nil { + return "", f + } + if res.exitCode != 0 { + return "", newFailure(commitOwnPathsOp, KindCommandFailed, "git add --intent-to-add failed: "+stderrExcerpt(res.stderr), nil).withExitCode(res.exitCode) + } + } + + var spec bytes.Buffer + for _, p := range paths { + spec.WriteString(string(p)) + spec.WriteByte(0) + } + cres, f := c.run(ctx, runRequest{ + op: commitOwnPathsOp, + dir: dir, + args: []string{"commit", "--only", "-m", subject, "--pathspec-from-file=-", "--pathspec-file-nul"}, + stdin: spec.Bytes(), + }) + if f != nil { + return "", f + } + if cres.exitCode != 0 { + return "", newFailure(commitOwnPathsOp, KindCommandFailed, "git commit failed: "+stderrExcerpt(cres.stderr), nil).withExitCode(cres.exitCode) + } + + head, hf := c.worktreeHead(ctx, commitOwnPathsOp, dir) + if hf != nil { + return "", hf + } + return head, nil +} + // composeCommitMessage renders the subject followed, when trailers are present, // by a blank line and the trailer block (one "Key: Value" per line). The blank // line makes the trailer lines the message's final paragraph, which is what diff --git a/internal/gitcli/commitownpaths_integration_test.go b/internal/gitcli/commitownpaths_integration_test.go new file mode 100644 index 000000000..f3445f23b --- /dev/null +++ b/internal/gitcli/commitownpaths_integration_test.go @@ -0,0 +1,152 @@ +//go:build integration + +package gitcli + +import ( + "context" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// newOwnPathsRepo builds a non-bare repository on branch main holding tracked +// a.txt, gone.txt, and other.txt in one commit, with a pinned identity. +func newOwnPathsRepo(t *testing.T) string { + t.Helper() + requireGit(t) + dir := filepath.Join(testsupport.TempDir(t), "repo") + gitOut(t, filepath.Dir(dir), "init", "-q", "-b", "main", dir) + configRepoIdentity(t, dir) + writeWorktreeFile(t, dir, "a.txt", "a\n") + writeWorktreeFile(t, dir, "gone.txt", "gone\n") + writeWorktreeFile(t, dir, "other.txt", "other\n") + gitOut(t, dir, "add", "-A") + gitOut(t, dir, "commit", "-q", "-m", "base") + return dir +} + +// TestIntegrationRepoCommitOwnPathsLeavesOtherStagedChanges proves CommitOwnPaths +// commits exactly the named paths (a modify, an untracked create, and a delete) +// with the bare subject as the whole message, while a change the user staged +// beforehand stays staged and out of the commit. +func TestIntegrationRepoCommitOwnPathsLeavesOtherStagedChanges(t *testing.T) { + c := newRealClient(t) + ctx := context.Background() + dir := newOwnPathsRepo(t) + + writeWorktreeFile(t, dir, "other.txt", "user staged edit\n") + gitOut(t, dir, "add", "other.txt") + + writeWorktreeFile(t, dir, "a.txt", "a changed\n") + writeWorktreeFile(t, dir, "new.yml", "k: v\n") + if err := os.Remove(filepath.Join(dir, "gone.txt")); err != nil { + t.Fatal(err) + } + + subject := "Add docket configurations to repository" + got, err := c.CommitOwnPaths(ctx, dir, []RepoPath{"a.txt", "new.yml", "gone.txt"}, subject) + if err != nil { + t.Fatalf("CommitOwnPaths: %v", err) + } + if head := ObjectID(gitOut(t, dir, "rev-parse", "HEAD")); got != head { + t.Fatalf("CommitOwnPaths returned %q, HEAD is %q", got, head) + } + + msg, err := gitTry(dir, "show", "-s", "--format=%B", "HEAD") + if err != nil { + t.Fatal(err) + } + if want := subject + "\n\n"; msg != want { + t.Fatalf("commit message = %q, want %q", msg, want) + } + nameStatus := gitOut(t, dir, "diff-tree", "--no-commit-id", "--name-status", "-r", "HEAD") + lines := strings.Split(nameStatus, "\n") + sort.Strings(lines) + wantLines := []string{"D\tgone.txt", "M\ta.txt", "A\tnew.yml"} + sort.Strings(wantLines) + if strings.Join(lines, "|") != strings.Join(wantLines, "|") { + t.Fatalf("committed name-status = %q, want %q", lines, wantLines) + } + + if staged := gitOut(t, dir, "diff", "--cached", "--name-only"); staged != "other.txt" { + t.Fatalf("staged after commit = %q, want only other.txt", staged) + } +} + +// TestIntegrationRepoCommitOwnPathsRunsRepositoryHooks proves the repository's +// own hooks run: a failing commit-msg hook fires, fails the commit as +// command-failed, and leaves HEAD and the working-tree edits untouched. +func TestIntegrationRepoCommitOwnPathsRunsRepositoryHooks(t *testing.T) { + c := newRealClient(t) + ctx := context.Background() + dir := newOwnPathsRepo(t) + before := gitOut(t, dir, "rev-parse", "HEAD") + + marker := filepath.Join(testsupport.TempDir(t), "hook-ran") + hook := "#!/bin/sh\ntouch '" + marker + "'\nexit 1\n" + if err := os.WriteFile(filepath.Join(dir, ".git", "hooks", "commit-msg"), []byte(hook), 0o755); err != nil { + t.Fatal(err) + } + writeWorktreeFile(t, dir, "a.txt", "a changed\n") + + _, err := c.CommitOwnPaths(ctx, dir, []RepoPath{"a.txt"}, "Remove docket configurations from repository") + assertKind(t, err, KindCommandFailed) + if _, statErr := os.Stat(marker); statErr != nil { + t.Fatalf("commit-msg hook did not run: %v", statErr) + } + if after := gitOut(t, dir, "rev-parse", "HEAD"); after != before { + t.Fatalf("HEAD moved from %s to %s after a failing hook", before, after) + } + data, rerr := os.ReadFile(filepath.Join(dir, "a.txt")) + if rerr != nil || string(data) != "a changed\n" { + t.Fatalf("a.txt = %q (%v), want the edit kept", data, rerr) + } +} + +// TestIntegrationRepoCommitOwnPathsRefusesDetachedHead proves a detached HEAD is +// an invalid request and commits nothing. +func TestIntegrationRepoCommitOwnPathsRefusesDetachedHead(t *testing.T) { + c := newRealClient(t) + ctx := context.Background() + dir := newOwnPathsRepo(t) + gitOut(t, dir, "checkout", "-q", "--detach") + before := gitOut(t, dir, "rev-parse", "HEAD") + writeWorktreeFile(t, dir, "a.txt", "a changed\n") + + _, err := c.CommitOwnPaths(ctx, dir, []RepoPath{"a.txt"}, "Add docket configurations to repository") + assertKind(t, err, KindInvalidRequest) + if after := gitOut(t, dir, "rev-parse", "HEAD"); after != before { + t.Fatalf("HEAD moved on a detached refusal") + } +} + +// TestIntegrationRepoCommitOwnPathsValidatesRequest proves a relative dir, an +// empty or multi-line subject, an empty path list, and an escaping path are +// invalid requests. +func TestIntegrationRepoCommitOwnPathsValidatesRequest(t *testing.T) { + c := newRealClient(t) + ctx := context.Background() + dir := newOwnPathsRepo(t) + cases := []struct { + name string + dir string + paths []RepoPath + subject string + }{ + {"relative dir", "repo", []RepoPath{"a.txt"}, "s"}, + {"empty subject", dir, []RepoPath{"a.txt"}, ""}, + {"multi-line subject", dir, []RepoPath{"a.txt"}, "s\nbody"}, + {"no paths", dir, nil, "s"}, + {"escaping path", dir, []RepoPath{"../x"}, "s"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := c.CommitOwnPaths(ctx, tc.dir, tc.paths, tc.subject) + assertKind(t, err, KindInvalidRequest) + }) + } +} diff --git a/internal/gitcli/remoteadmin.go b/internal/gitcli/remoteadmin.go index bfb59f735..4ce4c0201 100644 --- a/internal/gitcli/remoteadmin.go +++ b/internal/gitcli/remoteadmin.go @@ -8,8 +8,9 @@ import ( ) const ( - initBareOp Operation = "init-bare" - addRemoteOp Operation = "add-remote" + initBareOp Operation = "init-bare" + addRemoteOp Operation = "add-remote" + removeRemoteOp Operation = "remove-remote" ) // InitBare creates a bare repository at path, creating missing parent @@ -78,3 +79,28 @@ func (c *Client) AddRemote(ctx context.Context, repo Repository, name RemoteName } return nil } + +// RemoveRemote deletes remote name and its remote-tracking refs from repo +// (`git remote remove`). An unconfigured remote is remote-unavailable: git +// documents exit status 2 for a remote that does not exist. +func (c *Client) RemoveRemote(ctx context.Context, repo Repository, name RemoteName) error { + if err := validateRemoteName(name); err != nil { + return newFailure(removeRemoteOp, KindInvalidRequest, "invalid remote name", err) + } + res, f := c.run(ctx, runRequest{ + op: removeRemoteOp, + dir: repo.PrimaryWorktree, + args: []string{"remote", "remove", string(name)}, + }) + if f != nil { + return f + } + switch res.exitCode { + case 0: + return nil + case 2: + return newFailure(removeRemoteOp, KindRemoteUnavailable, "remote is not configured", nil).withExitCode(res.exitCode) + default: + return newFailure(removeRemoteOp, KindCommandFailed, "git remote remove failed: "+stderrExcerpt(res.stderr), nil).withExitCode(res.exitCode) + } +} diff --git a/internal/gitcli/remoteadmin_integration_test.go b/internal/gitcli/remoteadmin_integration_test.go index 1666664a3..ca940878d 100644 --- a/internal/gitcli/remoteadmin_integration_test.go +++ b/internal/gitcli/remoteadmin_integration_test.go @@ -51,3 +51,39 @@ func TestIntegrationRepoInitBareAndAddRemote(t *testing.T) { t.Fatalf("InitBare on a non-bare repository succeeded, want failure") } } + +// TestIntegrationRepoRemoveRemote proves RemoveRemote deletes a configured +// remote and its tracking refs, after which RemoteURL reports it unconfigured, +// and that removing an unconfigured remote is remote-unavailable. +func TestIntegrationRepoRemoveRemote(t *testing.T) { + requireGit(t) + c := newRealClient(t) + ctx := context.Background() + r := newMainModeRepos(t) + repo := mustDiscover(t, c, r.Invocation) + + bare := filepath.Join(testsupport.TempDir(t), "remote.git") + if err := c.InitBare(ctx, bare); err != nil { + t.Fatalf("InitBare: %v", err) + } + if err := c.AddRemote(ctx, repo, "dckt", bare); err != nil { + t.Fatalf("AddRemote: %v", err) + } + gitOut(t, r.Invocation, "push", "-q", "dckt", "main") + gitOut(t, r.Invocation, "fetch", "-q", "dckt") + if _, err := gitTry(r.Invocation, "rev-parse", "--verify", "refs/remotes/dckt/main"); err != nil { + t.Fatalf("tracking ref missing before removal: %v", err) + } + + if err := c.RemoveRemote(ctx, repo, "dckt"); err != nil { + t.Fatalf("RemoveRemote: %v", err) + } + _, err := c.RemoteURL(ctx, repo, "dckt") + assertKind(t, err, KindRemoteUnavailable) + if _, err := gitTry(r.Invocation, "rev-parse", "--verify", "refs/remotes/dckt/main"); err == nil { + t.Fatalf("tracking ref refs/remotes/dckt/main survived RemoveRemote") + } + + assertKind(t, c.RemoveRemote(ctx, repo, "dckt"), KindRemoteUnavailable) + assertKind(t, c.RemoveRemote(ctx, repo, "-x"), KindInvalidRequest) +} diff --git a/internal/layout/layout.go b/internal/layout/layout.go index 741059440..e7999a2b1 100644 --- a/internal/layout/layout.go +++ b/internal/layout/layout.go @@ -36,6 +36,11 @@ const ( // user-facing spelling. PrivateInstructionsFile = "AGENTS.md" PrivateInstructionsDisplay = ".git/dckt/AGENTS.md" + + // PrivateLocalKeysFile is the file beneath /dckt that keeps a + // repository's .docket.local.yml while it is private, so going shared + // again can return those keys to the clone-local layer. + PrivateLocalKeysFile = "local-keys.yml" ) // Detect decides the mode from state; a probe error is returned, never guessed. @@ -79,6 +84,12 @@ func PrivateInstructionsPath(commonDir string) string { return filepath.Join(commonDir, PrivateName, PrivateInstructionsFile) } +// PrivateLocalKeysPath is the saved .docket.local.yml of a private +// repository, /dckt/local-keys.yml. +func PrivateLocalKeysPath(commonDir string) string { + return filepath.Join(commonDir, PrivateName, PrivateLocalKeysFile) +} + // CommonDirOf resolves a working-tree root's common dir from the filesystem // alone (no git process). ok=false when /.git does not exist. // diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go index e45b2bb2e..21c21969a 100644 --- a/internal/layout/layout_test.go +++ b/internal/layout/layout_test.go @@ -199,3 +199,9 @@ func TestPrivateInstructionsPath(t *testing.T) { t.Fatalf("PrivateInstructionsDisplay = %q, want %q", got, want) } } + +func TestPrivateLocalKeysPath(t *testing.T) { + if got, want := PrivateLocalKeysPath("/r/.git"), "/r/.git/dckt/local-keys.yml"; got != want { + t.Fatalf("PrivateLocalKeysPath = %q, want %q", got, want) + } +} diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index 00962a337..d4ba90a1c 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -196,7 +196,7 @@ var skillBudgets = []skillBudget{ {"docket-build/references/gate-driver.md", 138, 1552}, // 0502 review: the raw-verb section names finalize's repaired-head re-gate (137/1546 -> 138/1552); 0502: "loop" means /loop only (137/1550 -> 137/1546); 0502: finalize's repaired-head re-gate is a direct caller (136/1523 -> 137/1550); 0502: the gate-execution intro reference is deleted (138/1555 -> 136/1523); 0502: renamed from gate-caller-loop.md; 0497: +halted-gate tree-survives finding relay (134/1510 -> 138/1555); 0491: the run id is retired (134/1513 -> 134/1510); 0490: the admission section describes the supervisor-held worktree lock; launch-unconfirmed is no longer a refusal (136/1511 -> 134/1513); 0488 review fix: finalize reaches the driver only through finalize.rebase; task-era handoff clauses dropped (word ceiling 1504 -> 1511); 0488 review: start row carries --change-id (word ceiling 1491 -> 1504); 0488: worker-scope/takeover/acknowledge rows and the parent-takeover section removed; callers are the full-suite gates (175/1872 -> 136/1491); 0467: +prepare-scope --run-id and scope-inherited start run id (word ceiling 1826 -> 1872); 0375: +worktree-admission section (word ceiling 1750 -> 1826) {"docket-build/references/task-routing.md", 50, 500}, {"docket-build-task/SKILL.md", 173, 1695}, // 0532: the private-repository writing rule and visibility hand-off (168/1640 -> 173/1695); 0531: the metadata worktree and remote come from the repository.prepare context (168/1637 -> 168/1640); 0488: scoped drive protocol replaced by direct foreground tests under timeout; three outcomes (211/2235 -> 168/1637); 0467 review fix: +the repair re-run run-id exception (206/2183 -> 211/2235); 0467: +the run id rides on the scope, never the bundle (204/2151 -> 206/2183); 0459: +post-handoff continuation never acknowledges or reuses the transferred scope (188/1964 -> 204/2151); 0405: sequential-drive receipt and acknowledgement; 0420: shell-safe capture; 0375: worktree-busy-not-a-retry rule (179/1842 -> 188/1964) - {"docket-convention/SKILL.md", 361, 7263}, // 0532: the private-repository writing rule and visibility hand-off (359/7160 -> 361/7263); 0531 review: skills say "the metadata branch", naming `docket` once for shared mode (359/7155 -> 359/7160); 0531: the metadata worktree and remote come from the repository.prepare context (357/7062 -> 359/7155); 0530: plan, results, and build evidence live on the docket branch — the plan-writer's one metadata operation, the attach operations as file-backlink writers, relative same-branch links, and the new ## Build evidence record section (357/7026 -> 357/7062); 0529 review: the backlink block names its PR-body writers beside artifact.backlink, one shared renderer (357/7004 -> 357/7026); 0515: the repair sign-off is retired; a green repair merges (357/7007 -> 357/7004); 0502 rebase onto 0510: kept the backlog-match follow-up clause in the results-artifact paragraph (357/7004 -> 357/7007); 0502 review: the review-tier dispatch is the controller's own (357/6995 -> 357/7004); 0502: configuration read paths corrected — operations read .docket.yml from origin's default-branch tip, the repository setup operations from the primary worktree (357/6959 -> 357/6995); 0502: "loop" means /loop only (357/7036 -> 357/7037); 0502: one metadata layout — the docket branch and the .docket/ worktree (357/7035 -> 357/7036); 0502: agent layer names user-level wrappers and global-only pins (357/7024 -> 357/7035); 0502: typed operations replace the Bash-era script names; ## Run halted facts corrected (359/7092 -> 357/7024); 0502: terminal publish, the learnings cap, and the repository auto_groom default are deleted (362/7256 -> 359/7092); 0502: the auto-capture export paragraph is deleted (370/7334 -> 362/7256); 0502: the configuration contract rewritten from the schema (371/7488 -> 370/7334); 0502: fixed role skills; halt posture (372/7660 -> 371/7488); 0502: record blocks mirror the renderers (367/7619 -> 372/7660); 0502: the dummy-mode shared definition is deleted (390/7848 -> 367/7619); 0410: +required-results lifecycle prose; 0399: +schema request/result contract prose; 0388: +sync-integration prose (see note above); 0502: drop change citations and Go v1 narration (357/7037 -> 357/6958); 0502: the harness adapter is named without a docket source path (357/6958 -> 357/6959) + {"docket-convention/SKILL.md", 361, 7276}, // 0533: editing visibility never moves a repository; repository.set-visibility does (361/7263 -> 361/7276); 0532: the private-repository writing rule and visibility hand-off (359/7160 -> 361/7263); 0531 review: skills say "the metadata branch", naming `docket` once for shared mode (359/7155 -> 359/7160); 0531: the metadata worktree and remote come from the repository.prepare context (357/7062 -> 359/7155); 0530: plan, results, and build evidence live on the docket branch — the plan-writer's one metadata operation, the attach operations as file-backlink writers, relative same-branch links, and the new ## Build evidence record section (357/7026 -> 357/7062); 0529 review: the backlink block names its PR-body writers beside artifact.backlink, one shared renderer (357/7004 -> 357/7026); 0515: the repair sign-off is retired; a green repair merges (357/7007 -> 357/7004); 0502 rebase onto 0510: kept the backlog-match follow-up clause in the results-artifact paragraph (357/7004 -> 357/7007); 0502 review: the review-tier dispatch is the controller's own (357/6995 -> 357/7004); 0502: configuration read paths corrected — operations read .docket.yml from origin's default-branch tip, the repository setup operations from the primary worktree (357/6959 -> 357/6995); 0502: "loop" means /loop only (357/7036 -> 357/7037); 0502: one metadata layout — the docket branch and the .docket/ worktree (357/7035 -> 357/7036); 0502: agent layer names user-level wrappers and global-only pins (357/7024 -> 357/7035); 0502: typed operations replace the Bash-era script names; ## Run halted facts corrected (359/7092 -> 357/7024); 0502: terminal publish, the learnings cap, and the repository auto_groom default are deleted (362/7256 -> 359/7092); 0502: the auto-capture export paragraph is deleted (370/7334 -> 362/7256); 0502: the configuration contract rewritten from the schema (371/7488 -> 370/7334); 0502: fixed role skills; halt posture (372/7660 -> 371/7488); 0502: record blocks mirror the renderers (367/7619 -> 372/7660); 0502: the dummy-mode shared definition is deleted (390/7848 -> 367/7619); 0410: +required-results lifecycle prose; 0399: +schema request/result contract prose; 0388: +sync-integration prose (see note above); 0502: drop change citations and Go v1 narration (357/7037 -> 357/6958); 0502: the harness adapter is named without a docket source path (357/6958 -> 357/6959) // 0154: docket-convention/github-board-mirror.md removed — the GitHub mirror is // retired (unsupported, mutation-blocking); its budget row is deleted with it. {"docket-convention/references/agent-layer.md", 168, 1593}, // 0538: init and repository.configure-harnesses set agent_harnesses; repository.check reports harnesses-unset (164/1553 -> 168/1593); 0535: a private repository's instructions file and its user-level triggers (148/1419 -> 164/1553); 0502: rewritten around user-level wrappers and global-only pins (202/2346 -> 148/1419); 0502: the legacy agents-file migration clause is deleted (202/2349 -> 202/2346) diff --git a/internal/repoguard/integration_push_test.go b/internal/repoguard/integration_push_test.go index 7c5d1b047..9108bbd32 100644 --- a/internal/repoguard/integration_push_test.go +++ b/internal/repoguard/integration_push_test.go @@ -43,6 +43,7 @@ var pushAllowlist = map[string]string{ "reconcileResumeSeed": "repository migrate resumes the metadata seed", "publishSeed": "repository migrate publishes the metadata seed", "migrateExecute": "repository migrate's one-time, human-run legacy prune of the integration branch", + "publishVisibilityHistory": "repository set-visibility publishes the identical metadata history under the target branch name", } // integrationPushReport is what the walk over internal/app found. diff --git a/internal/reposeed/plan.go b/internal/reposeed/plan.go index ddb52a5db..cf431c903 100644 --- a/internal/reposeed/plan.go +++ b/internal/reposeed/plan.go @@ -197,6 +197,14 @@ func Plan(in PlanInput) ([]install.Target, map[string][]string, error) { return targets, owners, nil } +// SurfacePaths lists, sorted, every repository-relative path Plan can emit for +// any harness selection and CLAUDE.md state — the working-tree surfaces a +// shared repository may commit. TestSurfacePathsCoverPlan derives the set from +// Plan itself, so a new surface cannot be missed here. +func SurfacePaths() []string { + return []string{cursorRuleRel, agentsMDName, claudeMDName} +} + // selectHarnesses validates the opt-in tokens and returns them as a set. An // unknown token is an error (defense in depth; see the harness constants). func selectHarnesses(tokens []string) (map[string]bool, error) { diff --git a/internal/reposeed/plan_test.go b/internal/reposeed/plan_test.go index dde498dc3..5572e7fb5 100644 --- a/internal/reposeed/plan_test.go +++ b/internal/reposeed/plan_test.go @@ -541,3 +541,41 @@ func TestPlanPrivate(t *testing.T) { } }) } + +// TestSurfacePathsCoverPlan derives the surface set from Plan itself: every +// harness subset under every CLAUDE.md state, relativized to the worktree, must +// together emit exactly SurfacePaths — no path SurfacePaths misses, and none it +// lists that Plan never emits. +func TestSurfacePathsCoverPlan(t *testing.T) { + all := []string{harnessClaude, harnessCodex, harnessOpencode, harnessCursor} + states := []ClaudeMDState{ClaudeMDAbsent, ClaudeMDRegularFile, ClaudeMDLinkToAgents, ClaudeMDOther, ClaudeMDForeignLink} + emitted := map[string]bool{} + for mask := 1; mask < 1<= lineEnd { + return 0, false + } + line := src[from:lineEnd] + switch { + case style&yaml.DoubleQuotedStyle != 0, style&yaml.SingleQuotedStyle != 0: + q := line[0] + for i := 1; i < len(line); i++ { + if q == '"' && line[i] == '\\' { + i++ + continue + } + if line[i] == q { + if q == '\'' && i+1 < len(line) && line[i+1] == '\'' { + i++ + continue + } + return from + i + 1, true + } + } + return 0, false + default: + end := len(line) + if i := bytes.Index(line, []byte(" #")); i >= 0 { + end = i + } + return from + len(bytes.TrimRight(line[:end], " \t\r\n")), true + } +} + +// verifyVisibilityEdit re-parses edited and refuses unless the top-level keys +// are unchanged, every other value decodes identically, and visibility is want. +func verifyVisibilityEdit(origRoot *yaml.Node, edited []byte, want string) error { + after, err := topLevelMapping(edited) + if err != nil || after == nil { + return fmt.Errorf("the edited file does not parse as a mapping: %v", err) + } + if got, orig := mappingKeys(after), mappingKeys(origRoot); !slices.Equal(got, orig) { + return fmt.Errorf("the top-level settings would be %v, want %v", got, orig) + } + for i := 0; i+1 < len(after.Content); i += 2 { + k, v := after.Content[i].Value, after.Content[i+1] + if k == visibilityKey { + if v.Kind != yaml.ScalarNode || v.Value != want { + return fmt.Errorf("visibility would not be %s", want) + } + continue + } + _, orig, _ := findChild(origRoot, k) + var a, b any + if orig == nil || orig.Decode(&a) != nil || v.Decode(&b) != nil || !reflect.DeepEqual(a, b) { + return fmt.Errorf("setting %q would change", k) + } + } + return nil +} + +// RemoveGitignoreBlock removes the managed docket block from a .gitignore, +// keeping every byte outside it and trimming trailing blank lines to one final +// newline (empty when nothing else remains). Malformed markers refuse with +// *MalformedGitignoreError and a nil out; no block returns (current, false, nil). +func RemoveGitignoreBlock(current []byte) (out []byte, changed bool, err error) { + if gitignoreMarkersMalformed(current, GitignoreStart, GitignoreEnd) { + return nil, false, &MalformedGitignoreError{Generation: "docket"} + } + return removeManagedBlock(current, GitignoreStart, GitignoreEnd) +} + +// RemoveExcludeBlock removes the managed `# dckt:` block from +// .git/info/exclude with RemoveGitignoreBlock's rules; malformed markers +// refuse with *MalformedExcludeError. +func RemoveExcludeBlock(current []byte) (out []byte, changed bool, err error) { + if gitignoreMarkersMalformed(current, ExcludeStart, ExcludeEnd) { + return nil, false, &MalformedExcludeError{} + } + return removeManagedBlock(current, ExcludeStart, ExcludeEnd) +} + +// removeManagedBlock strips a well-formed [start,end] block; callers guard the +// markers first. +func removeManagedBlock(current []byte, start, end string) ([]byte, bool, error) { + if !hasLine(current, start) { + return current, false, nil + } + rest := bytes.TrimRight(stripGitignoreBlock(current, start, end), "\n") + out := make([]byte, 0, len(rest)+1) + if len(rest) > 0 { + out = append(append(out, rest...), '\n') + } + return out, true, nil +} + +// parseConfigMapping parses src into a document node whose single child is +// the root mapping. Nil, whitespace, comments-only, or null input yields an +// empty mapping; more than one document or a non-mapping root errors. +func parseConfigMapping(src []byte) (doc, root *yaml.Node, err error) { + doc = &yaml.Node{Kind: yaml.DocumentNode} + dec := yaml.NewDecoder(bytes.NewReader(src)) + if derr := dec.Decode(doc); derr != nil && !errors.Is(derr, io.EOF) { + return nil, nil, fmt.Errorf("undecodable YAML: %w", derr) + } else if derr == nil { + var extra yaml.Node + if xerr := dec.Decode(&extra); !errors.Is(xerr, io.EOF) { + return nil, nil, errors.New("more than one YAML document") + } + } + doc.Kind = yaml.DocumentNode + if len(doc.Content) == 0 || doc.Content[0].Tag == "!!null" { + doc.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}} + } + root = doc.Content[0] + if root.Kind != yaml.MappingNode { + return nil, nil, errors.New("the document root is not a mapping") + } + return doc, root, nil +} + +// configLeaves lists m's leaves depth-first in file order, recursing only into +// non-empty mappings. +func configLeaves(m *yaml.Node, prefix []string) []configLeaf { + var out []configLeaf + for i := 0; i+1 < len(m.Content); i += 2 { + keys := append(slices.Clone(prefix), m.Content[i].Value) + v := m.Content[i+1] + if v.Kind == yaml.MappingNode && len(v.Content) > 0 { + out = append(out, configLeaves(v, keys)...) + continue + } + out = append(out, configLeaf{path: strings.Join(keys, "."), keys: keys, val: v}) + } + return out +} + +// isAgentPin reports an agents...model|effort|runner leaf. +func isAgentPin(keys []string) bool { + if len(keys) != 4 || keys[0] != "agents" { + return false + } + switch keys[3] { + case "model", "effort", "runner": + return true + } + return false +} + +// isRepoOnly reports whether leaf path p is, or sits under, a repo-only entry. +func isRepoOnly(p string, repoOnly []string) bool { + for _, r := range repoOnly { + if p == r || strings.HasPrefix(p, r+".") { + return true + } + } + return false +} + +// lookupPath returns the value node at keys under m, or nil. +func lookupPath(m *yaml.Node, keys []string) *yaml.Node { + n := m + for _, k := range keys { + if n.Kind != yaml.MappingNode { + return nil + } + _, v, _ := findChild(n, k) + if v == nil { + return nil + } + n = v + } + return n +} + +// setLeaf sets keys under m to a copy of val, creating intermediate mappings +// (replacing a non-mapping in the way) and replacing an existing value. An +// empty-mapping leaf never replaces an existing mapping: it declares nothing. +func setLeaf(m *yaml.Node, keys []string, val *yaml.Node) { + n := m + for i, k := range keys { + _, v, _ := findChild(n, k) + last := i == len(keys)-1 + if last { + if v != nil && v.Kind == yaml.MappingNode && val.Kind == yaml.MappingNode && len(val.Content) == 0 { + return + } + c := cloneNode(val) + if v != nil { + *v = *c + } else { + n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, c) + } + return + } + if v == nil { + v = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, v) + } else if v.Kind != yaml.MappingNode { + *v = yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + } + n = v + } +} + +// deleteLeaf removes keys under m and prunes every mapping the removal empties. +func deleteLeaf(m *yaml.Node, keys []string) { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value != keys[0] { + continue + } + if len(keys) > 1 { + v := m.Content[i+1] + if v.Kind != yaml.MappingNode { + return + } + deleteLeaf(v, keys[1:]) + if len(v.Content) > 0 { + return + } + } + m.Content = slices.Delete(m.Content, i, i+2) + return + } +} + +// setTopScalar sets the top-level key to a plain string scalar, keeping an +// existing value's comments, or appends the key when absent. +func setTopScalar(m *yaml.Node, key, value string) { + if _, v, _ := findChild(m, key); v != nil { + *v = yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value, + HeadComment: v.HeadComment, LineComment: v.LineComment, FootComment: v.FootComment} + return + } + m.Content = append(m.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}) +} + +// encodeConfig renders a document node with two-space indentation, so node +// comments survive. +func encodeConfig(doc *yaml.Node) ([]byte, error) { + var b bytes.Buffer + enc := yaml.NewEncoder(&b) + enc.SetIndent(2) + if err := enc.Encode(doc); err != nil { + return nil, fmt.Errorf("reposetup: encode config: %w", err) + } + if err := enc.Close(); err != nil { + return nil, fmt.Errorf("reposetup: encode config: %w", err) + } + return b.Bytes(), nil +} + +// cloneNode deep-copies n. +func cloneNode(n *yaml.Node) *yaml.Node { + c := *n + if n.Content != nil { + c.Content = make([]*yaml.Node, len(n.Content)) + for i, ch := range n.Content { + c.Content[i] = cloneNode(ch) + } + } + return &c +} + +// stripComments clears every comment and optional quoting in n and returns it, +// so equal values render equally (the encoder still quotes where it must). +func stripComments(n *yaml.Node) *yaml.Node { + n.HeadComment, n.LineComment, n.FootComment = "", "", "" + n.Style &^= yaml.SingleQuotedStyle | yaml.DoubleQuotedStyle + for _, c := range n.Content { + stripComments(c) + } + return n +} diff --git a/internal/reposetup/visibilityconfig_test.go b/internal/reposetup/visibilityconfig_test.go new file mode 100644 index 000000000..88f654144 --- /dev/null +++ b/internal/reposetup/visibilityconfig_test.go @@ -0,0 +1,291 @@ +package reposetup + +import ( + "errors" + "reflect" + "slices" + "testing" + + "github.com/danielhanold/docket/internal/config" +) + +// repoOnlyForTest is the live registry list, so the tests follow the schema. +func repoOnlyForTest() []string { return config.RepoOnlyPaths() } + +// mustResolve proves bytes resolve as the repository (and optional +// repository-local) layer the switch will hand to config. +func mustResolve(t *testing.T, committed, local []byte) { + t.Helper() + sources := []config.Source{{Layer: config.LayerRepository, Name: ".docket.yml", Data: committed}} + if local != nil { + sources = append(sources, config.Source{Layer: config.LayerRepositoryLocal, Name: ".docket.local.yml", Data: local}) + } + if _, diags, err := config.Resolve(sources, config.ResolveContext{DefaultBranch: "main"}); err != nil { + t.Fatalf("config.Resolve refused the output: %v (diagnostics %v)\ncommitted:\n%s\nlocal:\n%s", err, diags, committed, local) + } +} + +func leafValues(t *testing.T, src []byte, paths ...string) map[string]string { + t.Helper() + got, err := ConfigLeafValues(src, paths) + if err != nil { + t.Fatalf("ConfigLeafValues: %v", err) + } + return got +} + +func TestFoldPrivateConfigMergesLeaves(t *testing.T) { + committed := []byte("# team settings\nfinalize:\n gate: local\n test_command: make\n") + local := []byte("finalize:\n test_command: make quick\n") + folded, dropped, err := FoldPrivateConfig(committed, local, repoOnlyForTest()) + if err != nil { + t.Fatalf("FoldPrivateConfig: %v", err) + } + if len(dropped) != 0 { + t.Errorf("dropped = %v, want none", dropped) + } + want := map[string]string{"finalize.gate": "local", "finalize.test_command": "make quick", "visibility": "private"} + if got := leafValues(t, folded, "finalize.gate", "finalize.test_command", "visibility"); !reflect.DeepEqual(got, want) { + t.Errorf("folded leaves = %v, want %v\n%s", got, want, folded) + } + if !containsLine(folded, "# team settings") { + t.Errorf("committed comment lost:\n%s", folded) + } + mustResolve(t, folded, nil) +} + +func containsLine(b []byte, line string) bool { return hasLine(b, line) } + +func TestFoldPrivateConfigDropsRepoOnlyLocalKeys(t *testing.T) { + committed := []byte("integration_branch: main\n") + local := []byte("integration_branch: develop\nreclaim:\n auto: true\n") + folded, dropped, err := FoldPrivateConfig(committed, local, repoOnlyForTest()) + if err != nil { + t.Fatalf("FoldPrivateConfig: %v", err) + } + want := map[string]string{"integration_branch": "main", "reclaim.auto": "true", "visibility": "private"} + if got := leafValues(t, folded, "integration_branch", "reclaim.auto", "visibility"); !reflect.DeepEqual(got, want) { + t.Errorf("folded leaves = %v, want %v\n%s", got, want, folded) + } + if !slices.Equal(dropped, []string{"integration_branch"}) { + t.Errorf("dropped = %v, want [integration_branch]", dropped) + } + mustResolve(t, folded, nil) +} + +func TestFoldPrivateConfigDropsAgentPins(t *testing.T) { + committed := []byte("agents:\n claude:\n build-standard:\n model: opus\nreclaim:\n auto: true\n") + local := []byte("agents:\n codex:\n review-lean:\n effort: high\n") + folded, dropped, err := FoldPrivateConfig(committed, local, repoOnlyForTest()) + if err != nil { + t.Fatalf("FoldPrivateConfig: %v", err) + } + wantDropped := []string{"agents.claude.build-standard.model", "agents.codex.review-lean.effort"} + if !slices.Equal(dropped, wantDropped) { + t.Errorf("dropped = %v, want %v", dropped, wantDropped) + } + if got := leafValues(t, folded, "agents"); len(got) != 0 { + t.Errorf("empty agents mapping kept: %v\n%s", got, folded) + } + mustResolve(t, folded, nil) + + empty, dropped, err := FoldPrivateConfig(nil, nil, repoOnlyForTest()) + if err != nil || len(dropped) != 0 { + t.Fatalf("FoldPrivateConfig(nil, nil) = %q, %v, %v", empty, dropped, err) + } + if string(empty) != "visibility: private\n" { + t.Errorf("FoldPrivateConfig(nil, nil) = %q, want %q", empty, "visibility: private\n") + } + if _, _, err := FoldPrivateConfig([]byte("- a\n"), nil, repoOnlyForTest()); err == nil { + t.Errorf("a sequence root folded without error") + } + if _, _, err := FoldPrivateConfig(nil, []byte("- a\n"), repoOnlyForTest()); err == nil { + t.Errorf("a sequence local root folded without error") + } +} + +func TestSplitPrivateConfigRoutesSavedLocalLeaves(t *testing.T) { + private := []byte("visibility: private\nbuild:\n test_command: make\nreclaim:\n auto: true\nintegration_branch: main\n") + localKeys := []byte("reclaim:\n auto: false\nintegration_branch: x\n") + committed, local, err := SplitPrivateConfig(private, localKeys, repoOnlyForTest()) + if err != nil { + t.Fatalf("SplitPrivateConfig: %v", err) + } + wantC := map[string]string{"build.test_command": "make", "integration_branch": "main", "visibility": "shared"} + if got := leafValues(t, committed, "build.test_command", "integration_branch", "visibility", "reclaim.auto", "reclaim"); !reflect.DeepEqual(got, wantC) { + t.Errorf("committed leaves = %v, want %v\n%s", got, wantC, committed) + } + if string(local) != "reclaim:\n auto: true\n" { + t.Errorf("local = %q, want %q", local, "reclaim:\n auto: true\n") + } + mustResolve(t, committed, local) +} + +func TestSplitPrivateConfigBornPrivate(t *testing.T) { + committed, local, err := SplitPrivateConfig([]byte("visibility: private\nreclaim:\n auto: true\n"), nil, repoOnlyForTest()) + if err != nil { + t.Fatalf("SplitPrivateConfig: %v", err) + } + if local != nil { + t.Errorf("born-private local = %q, want nil", local) + } + want := map[string]string{"reclaim.auto": "true", "visibility": "shared"} + if got := leafValues(t, committed, "reclaim.auto", "visibility"); !reflect.DeepEqual(got, want) { + t.Errorf("committed leaves = %v, want %v", got, want) + } + mustResolve(t, committed, nil) +} + +func TestFoldSplitRoundTrip(t *testing.T) { + committed := []byte("integration_branch: main\nfinalize:\n gate: local\n test_command: make\n") + local := []byte("finalize:\n test_command: make quick\nreclaim:\n auto: true\n") + folded, _, err := FoldPrivateConfig(committed, local, repoOnlyForTest()) + if err != nil { + t.Fatalf("FoldPrivateConfig: %v", err) + } + c2, l2, err := SplitPrivateConfig(folded, local, repoOnlyForTest()) + if err != nil { + t.Fatalf("SplitPrivateConfig: %v", err) + } + paths := []string{"integration_branch", "finalize.gate", "finalize.test_command", "reclaim.auto", "visibility"} + wantC := map[string]string{"integration_branch": "main", "finalize.gate": "local", "visibility": "shared"} + if got := leafValues(t, c2, paths...); !reflect.DeepEqual(got, wantC) { + t.Errorf("committed after round trip = %v, want %v\n%s", got, wantC, c2) + } + wantL := map[string]string{"finalize.test_command": "make quick", "reclaim.auto": "true"} + if got := leafValues(t, l2, paths...); !reflect.DeepEqual(got, wantL) { + t.Errorf("local after round trip = %v, want %v\n%s", got, wantL, l2) + } + mustResolve(t, c2, l2) +} + +func TestConfigLeafValues(t *testing.T) { + src := []byte("finalize:\n gate: [local, ci] # note\n test_command: \"make x\"\n") + got := leafValues(t, src, "finalize.gate", "finalize.test_command", "absent", "finalize.absent") + want := map[string]string{"finalize.gate": "[local, ci]", "finalize.test_command": "make x"} + if !reflect.DeepEqual(got, want) { + t.Errorf("ConfigLeafValues = %v, want %v", got, want) + } +} + +func TestRenderVisibilityEdit(t *testing.T) { + edited, changed, err := RenderVisibilityEdit([]byte("visibility: shared # note\nx: 1\n"), "private") + if err != nil || !changed || string(edited) != "visibility: private # note\nx: 1\n" { + t.Errorf("edit = %q, %v, %v; want the value rewritten with its comment", edited, changed, err) + } + edited, changed, err = RenderVisibilityEdit([]byte("x: 1\nvisibility: 'shared'\n"), "private") + if err != nil || !changed || string(edited) != "x: 1\nvisibility: private\n" { + t.Errorf("quoted edit = %q, %v, %v", edited, changed, err) + } + for name, in := range map[string]string{ + "absent": "x: 1\n", + "equal": "visibility: private # kept\n", + "nested": "x:\n visibility: shared\n", + "empty": "", + } { + edited, changed, err := RenderVisibilityEdit([]byte(in), "private") + if err != nil || changed || string(edited) != in { + t.Errorf("%s: edit = %q, %v, %v; want unchanged", name, edited, changed, err) + } + } + if _, _, err := RenderVisibilityEdit([]byte("visibility: shared\n"), "public"); err == nil { + t.Errorf("an invalid target value was accepted") + } + if _, _, err := RenderVisibilityEdit([]byte("visibility: shared\nvisibility: private\n"), "private"); err == nil { + t.Errorf("a duplicated key was edited") + } +} + +func TestRemoveGitignoreBlock(t *testing.T) { + block := string(GitignoreBlock()) + in := "node_modules/\n# keep\n\n" + block + "\n\n" + out, changed, err := RemoveGitignoreBlock([]byte(in)) + if err != nil || !changed || string(out) != "node_modules/\n# keep\n" { + t.Errorf("remove = %q, %v, %v", out, changed, err) + } + out, changed, err = RemoveGitignoreBlock([]byte("a\n" + block + "b\n")) + if err != nil || !changed || string(out) != "a\nb\n" { + t.Errorf("mid-file remove = %q, %v, %v", out, changed, err) + } + out, changed, err = RemoveGitignoreBlock([]byte(block)) + if err != nil || !changed || len(out) != 0 { + t.Errorf("block-only remove = %q, %v, %v; want empty", out, changed, err) + } + out, changed, err = RemoveGitignoreBlock([]byte("a\n")) + if err != nil || changed || string(out) != "a\n" { + t.Errorf("no-block remove = %q, %v, %v; want unchanged", out, changed, err) + } + out, changed, err = RemoveGitignoreBlock([]byte("a\n" + GitignoreStart + "\n.docket/\n")) + var mal *MalformedGitignoreError + if !errors.As(err, &mal) || mal.Generation != "docket" || out != nil || changed { + t.Errorf("dangling start = %q, %v, %v; want MalformedGitignoreError and nil out", out, changed, err) + } +} + +func TestRemoveExcludeBlock(t *testing.T) { + block := string(canonicalExcludeBytes) + out, changed, err := RemoveExcludeBlock([]byte("# git ls-files --others\n*.swp\n" + block)) + if err != nil || !changed || string(out) != "# git ls-files --others\n*.swp\n" { + t.Errorf("remove = %q, %v, %v", out, changed, err) + } + out, changed, err = RemoveExcludeBlock([]byte(block)) + if err != nil || !changed || len(out) != 0 { + t.Errorf("block-only remove = %q, %v, %v; want empty", out, changed, err) + } + out, changed, err = RemoveExcludeBlock([]byte("*.swp\n")) + if err != nil || changed || string(out) != "*.swp\n" { + t.Errorf("no-block remove = %q, %v, %v; want unchanged", out, changed, err) + } + out, changed, err = RemoveExcludeBlock([]byte(ExcludeStart + "\n.worktrees/\n")) + var mal *MalformedExcludeError + if !errors.As(err, &mal) || out != nil || changed { + t.Errorf("dangling start = %q, %v, %v; want MalformedExcludeError and nil out", out, changed, err) + } +} + +// TestSameConfigLeaves proves the comparison ignores comments, quoting, key +// order, and layout, but not a changed, added, or removed leaf. +func TestSameConfigLeaves(t *testing.T) { + hand := []byte("# my config\nintegration_branch: 'main' # trunk\nbuild: {test_command: make}\nvisibility: shared\n") + cases := []struct { + other string + want bool + }{ + {"visibility: shared\nbuild:\n test_command: make\nintegration_branch: main\n", true}, + {"visibility: shared\nbuild:\n test_command: make test\nintegration_branch: main\n", false}, + {"build:\n test_command: make\nintegration_branch: main\n", false}, + {"visibility: shared\nbuild:\n test_command: make\nintegration_branch: main\nchanges_dir: x\n", false}, + } + for _, c := range cases { + got, err := SameConfigLeaves(hand, []byte(c.other)) + if err != nil || got != c.want { + t.Errorf("SameConfigLeaves(hand, %q) = %v, %v; want %v", c.other, got, err, c.want) + } + } + if _, err := SameConfigLeaves([]byte("a: [unclosed\n"), hand); err == nil { + t.Error("an unparsable file compared without an error") + } +} + +// TestSplitOfFoldDeclaresTheHandFormattedLeaves proves a fold then a split +// declares exactly the leaves of a hand-formatted committed file (that already +// carries visibility: shared), while re-encoding its bytes: the switch keeps the +// on-disk file because of this. +func TestSplitOfFoldDeclaresTheHandFormattedLeaves(t *testing.T) { + hand := []byte("# my config\nvisibility: shared\nintegration_branch: 'main' # trunk\nbuild: {test_command: make}\n") + local := []byte("reclaim:\n auto: true\n") + folded, _, err := FoldPrivateConfig(hand, local, repoOnlyForTest()) + if err != nil { + t.Fatal(err) + } + committed, _, err := SplitPrivateConfig(folded, local, repoOnlyForTest()) + if err != nil { + t.Fatal(err) + } + if string(committed) == string(hand) { + t.Fatalf("the split kept the hand formatting by itself; this test no longer proves anything:\n%s", committed) + } + if same, err := SameConfigLeaves(hand, committed); err != nil || !same { + t.Errorf("SameConfigLeaves(hand, split) = %v, %v; want true\nsplit:\n%s", same, err, committed) + } +} diff --git a/skills/docket-convention/SKILL.md b/skills/docket-convention/SKILL.md index 0d6976f55..ab7ac55d4 100644 --- a/skills/docket-convention/SKILL.md +++ b/skills/docket-convention/SKILL.md @@ -350,7 +350,7 @@ At startup, after resolving config, fetch origin and evaluate a 2×2 over two pr Metadata (change files, `BOARD.md`, ADRs, specs) commits to the metadata branch (`docket` in a shared repository) via the persistent **metadata worktree** (ADR-0099: docket is the sole metadata topology) — and is **always pushed to the metadata remote immediately**: `origin` in a shared repository (the planning surface stays browsable there), the local `dckt` bare remote in a private one. The `repository.prepare` context values — `metadata_worktree_path`, `metadata_remote`, `metadata_tracking_ref` — are authoritative; never assume a worktree path or remote. -A private repository (`.git/dckt/` present) spells the metadata branch and remote `dckt`, keeps its config in `.git/dckt/config.yml`, and never reads `.docket.yml`. +A private repository (`.git/dckt/` present) spells the metadata branch and remote `dckt`, keeps its config in `.git/dckt/config.yml`, and never reads `.docket.yml`. Editing `visibility` never moves a repository; the `repository.set-visibility` operation does, keeping every record. **Private-repository writing rule.** In a private repository (`visibility: private` in the `repository.prepare` context), nothing that ships through the feature branch or the PR may contain the words "docket" or "dckt", docket marker comments, `Docket-` trailers, `.docket`/`dckt` paths, change ids or change references (`change 0612`, `#0612`, `(0612)`), docket ADR numbers, or references to the backlog or board. The metadata branch's own files (plan, results, records) are exempt and must not be copied from. `workspace.publish`, `pr.publish`, and `finalize.publish` enforce it with a blocking leak check (`leak-detected`); `leak_check.match_word: false` lets the bare word "docket" through. A shared repository is unaffected. diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index 43fc65386..d8c7076b4 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -41,6 +41,9 @@ tests/test_go_integration_app_repoownership.sh 30 parallel tests/test_go_integration_app_repoprepare.sh 20 parallel tests/test_go_integration_app_reporecovery.sh 20 parallel tests/test_go_integration_app_reporepair.sh 30 parallel +tests/test_go_integration_app_repovisibility.sh 50 parallel +tests/test_go_integration_app_repovisibility_private.sh 75 parallel +tests/test_go_integration_app_repovisibility_shared.sh 70 parallel tests/test_go_integration_app_reposetup.sh 35 parallel tests/test_go_integration_app_reposetup_race.sh 25 parallel tests/test_go_integration_app_privateinstructions.sh 30 parallel diff --git a/tests/test_go_integration_app_repovisibility.sh b/tests/test_go_integration_app_repovisibility.sh new file mode 100755 index 000000000..431ae95f9 --- /dev/null +++ b/tests/test_go_integration_app_repovisibility.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_repovisibility.sh — Go integration shard: the +# `repository set-visibility` switch over real git — the preview, the refusals +# that stop a switch before it writes, the journaled own-path integration-branch +# commit machinery (fixed subjects, the user's other staged work left alone, hook +# failure and crash recovery), and the direction-neutral acceptance proofs — +# behind the `integration` build tag, prefix ^TestIntegrationRepoVisibilitySwitch. The +# visibility tests split across three sibling shards by name prefix (none a +# prefix of another) so each stays under 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/app" +SHARD_PREFIX="TestIntegrationRepoVisibilitySwitch" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_app_repovisibility_private.sh b/tests/test_go_integration_app_repovisibility_private.sh new file mode 100755 index 000000000..fba892213 --- /dev/null +++ b/tests/test_go_integration_app_repovisibility_private.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_repovisibility_private.sh — Go integration shard: the +# `repository set-visibility` switch to private over real git — each switch +# option, re-runs, second clones, the clean result, and resume after every phase — +# behind the `integration` build tag, prefix ^TestIntegrationRepoVisibilityPrivate. The +# visibility tests split across three sibling shards by name prefix (none a +# prefix of another) so each stays under 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/app" +SHARD_PREFIX="TestIntegrationRepoVisibilityPrivate" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail" diff --git a/tests/test_go_integration_app_repovisibility_shared.sh b/tests/test_go_integration_app_repovisibility_shared.sh new file mode 100755 index 000000000..905f63f72 --- /dev/null +++ b/tests/test_go_integration_app_repovisibility_shared.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# docket-suite: go +# tests/test_go_integration_app_repovisibility_shared.sh — Go integration shard: the +# `repository set-visibility` switch to shared over real git — restoring the +# shared layout and keys, origin-branch handling, and resume after every phase — +# behind the `integration` build tag, prefix ^TestIntegrationRepoVisibilityShared. The +# visibility tests split across three sibling shards by name prefix (none a +# prefix of another) so each stays under 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/app" +SHARD_PREFIX="TestIntegrationRepoVisibilityShared" +SHARD_MODE="normal" + +. "$REPO/tests/lib/go-integration-shard.sh" +shard_inspect_maybe +run_integration_shard +exit "$fail"