Skip to content

feat: private visibility keeps the metadata branch on a local remote with neutral naming - #399

Merged
danielhanold merged 20 commits into
mainfrom
feat/private-visibility-keep-the-metadata-branch-on-a-local-remot
Oct 6, 2026
Merged

danielhanold merged 20 commits into
mainfrom
feat/private-visibility-keep-the-metadata-branch-on-a-local-remot

Conversation

@danielhanold

@danielhanold danielhanold commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

↩ Change 0531 — Keep the metadata branch on a local remote with neutral naming

Adds private visibility. A repository set up with docket repository init --private keeps docket's single metadata layout, with these differences:

  • The metadata branch is named dckt and is pushed to a bare repository on the user's machine. --metadata-remote can point it elsewhere. It never goes to origin.
  • Per-repo state and config live under .git/dckt/.
  • The metadata checkout sits outside the clone.
  • Ignore entries go into .git/info/exclude.

Shared repositories behave as before.

Main pieces:

  • internal/layout resolves four values that used to be hard-coded (metadata remote, metadata branch, state folder, metadata worktree) from repository state. Every metadata call site goes through them, and new repoguard guards stop hard-coded spellings from coming back.
  • A visibility config key, and .git/dckt/config.yml as the repository config layer in private repositories.
  • init --private.
  • Private-aware check, prepare and repair, with new visibility-mismatch and orphaned-checkout findings.
  • Skill prose now uses the prepare-context values (metadata_worktree_path, metadata_remote, metadata_tracking_ref).
  • Claim fix (ADR-0143): a gated claim's request id now carries its run-context identity, so a losing concurrent claim reports contended instead of invalid-input.

ADRs: 0142 (private visibility layout), 0143 (claim request identity).

Runtime budgets were raised for the workflowlifecycle (35→55), concurrency (35→45) and reposetup (15→35) shards. Please confirm the raise, or ask for a split.

Review (deep tier) — dispositions

# Severity Finding Disposition
1 blocker Re-cloning a private clone at the same path left a stale checkout that blocked init and prepare fixed 827f9a2
2 important Re-running init after --metadata-remote refused with a false conflict and created a stray store fixed 7d6cffc
3 important Private init did not refuse up front when .docket.local.yml exists fixed 471a29c
4 important Private-mode attachment findings named migrate, which refuses in private repositories fixed 50750d7
5 important Clones that share a store read identity keys from their own local config, so they can disagree deferred: needs a human design decision (see results)
6 minor Store-name collision between different origins fixed 8d9ed9b
7 minor A relative local --metadata-remote was stored as typed fixed c4d4e12
8 minor Skills still said "the docket branch" fixed c4d4e12

…havior unchanged)

Every path beneath <git-common-dir>/docket/ (run tracker, resume lock,
guardian marker, transactions, workspaces, gate-drive store roots, the
suite budget-state file, the empty hooks dir) is now built from
layout.StateDirOf, and reposeed.RecordPath takes the state name, which
ResolveRepoPhase derives from layout.CommonDirOf + layout.StateName.
With no <common>/dckt directory every path is byte-identical to before.

The new repoguard TestNoInlineStateFolderSpelling rejects any
filepath.Join/path.Join with a literal "docket" argument outside the
layout package and the two machine-path files (mutation-tested by
re-inlining "docket" in workspacesRoot). The workspace import boundary
admits the stdlib-only internal/layout leaf.

Default-tag tests prove gatedrive.OpenStore, workspacesRoot,
transactionsRoot and RecordPath follow a <common>/dckt folder. The
git-resolved sites (run tracker, suiterunner, hooks) are proven by
Task 10's private-lifecycle walk.
… StatusPin

The operational loader resolves the repository's layout right after
discovery (resolveLayout: shared reads no origin URL; private locates the
store from origin's URL and the data home) and carries it on
StatusPin.Layout. The metadata pin fetch, the sweep session's fetch, all 22
metadata transaction.Request sites (Remote/TargetRef via metadataRemote/
metadataRef of the pinned layout), the link context, the implementation
context's metadata ref, and the human status line now read it.

reposetup.MetadataBranchName is deleted. Repository-family sites alias
layout.SharedName with a Task 4 marker until Task 4 threads sc.layout.
render.LinkContext gains PrivateMetadata: a private metadata branch has no
web page, so its blob links render repo-relative.

The status JSON keeps omitting metadata_branch (the 0363 protocol guard
TestStatusContextProtocolOmitsModeFields); StatusContext.MetadataBranch is
json:"-" and feeds only the human line.

Guards: TestTransactionRequestsUseResolvedMetadataRemote (new) and
TestNoIntegrationPushOutsidePRMerge shape 1 (now keyed on metadataRef calls)
were mutation-tested together with the wiring test
TestIntegrationRecordOpsClaimTransactionTargetsPinnedMetadataLayout.
…hrough the layout

The repository command family (check, init, prepare, repair, migrate) and the
shared fact gatherer now read the metadata remote, branch ref, and worktree
path from setupContext.layout, resolved from state by gatherSetupFacts right
after Discover (a resolution failure is an external failure).
loadOperationalContext hands its pinned layout to gatherRepoFacts.

- delete docketWorktreeName; docketWorktreeFact takes the worktree path
- reposetup.Facts gains MetadataWorktreeRef and Private; every worktree
  finding names the resolved ref (.docket when shared)
- InterruptedFastForwardMessage/Remedy become functions of the worktree
- PrepareContext gains metadata_remote and metadata_tracking_ref
- publishOrAdoptMetadataRoot and publishSeed take the metadata remote
- prepareAttachFresh creates the worktree's parent first
- integration-branch work (migrate prune, primary sync) stays on origin
…ocal bare remote with neutral naming

`docket repository init` decides the mode with decideInitMode: a fresh
repository takes the effective `visibility` value, overridden by --private
or --shared; an already-set-up repository keeps its mode and a switching
flag refuses. A private init writes the `# dckt:` block into
.git/info/exclude and .git/dckt/config.yml, creates the bare store (or uses
--metadata-remote), adds the dckt remote (refusing a different existing
URL), publishes or adopts the dckt branch, and attaches this clone's
checkout under the store. Nothing is written into the working tree and
nothing reaches origin. Every failure after the config write names the
re-run recovery, and a re-run whose dckt remote was never added reads the
metadata branch as absent so it can finish.

The generated test policy writes through repoConfigTarget (private:
.git/dckt/config.yml, not a pending path). Migrate refuses a private
repository. resolveLayout spells the data home symlink-canonical, so a
re-run recognizes its own checkout registration.
…ms, resolver mutations

TestIntegrationWorkflowLifecyclePrivateInitToImplemented drives a private
repository from init --private through create, groom, claim, plan, build,
PR and mark-implemented against the dckt store, then proves origin gained
only the feature branch, the only ignored entry is .worktrees/, git branch
and git remote show dckt, and a walk of the whole clone (.git included)
finds no docket-named path. TestRaceIntegrationAppConcurrencyPrivateClaims
OneAppliesOneContends races two clones' keyed claims of one change: one
applies, the other contends, and nothing reaches origin.

The race exposed a product bug, shared mode too: claimRequestID was
claim-<id>-<revision> for every claim while the digest also carried the
run-context hash. Two dispatches with different run contexts shared the
request id, so the loser's retry after its lost lease found the winner's
receipt under a different digest and failed request-id-reused
(invalid-input) instead of reporting `contended`. A keyed claim's request
id now carries a 16-hex prefix of the hashed run context. The loser misses
the winner's receipt and the exact-revision expectation reports it
contended. An identical keyed retry still replays as already-claimed, and
an ungated claim keeps the bare claim-<id>-<revision> id, so ungated
replays and existing receipts are unchanged. The digest still binds the
full hash, so a prefix collision falls back to the request-id-reused
refusal and can never replay another context's receipt.
TestIntegrationWorkflowRepoClaimForeignRunContextSameRevisionContends is
the deterministic regression; TestClaimRequestIDIdentity pins the id's
shape.

Resolver mutations (each reddens the lifecycle test): MetadataRemote ->
SharedRemote fails init on the remote-URL conflict; MetadataBranch ->
SharedName loses the dckt branch on the store; StateName -> SharedName
leaves .git/docket in the walk; MetadataWorktree -> <primary>/.docket
shows ?? .docket/ and .docket in the walk.

Budgets, worst serial reading on a quiet machine: workflowlifecycle 46.16s
(row 35 -> 55; 34.82s without the new 7.8s private test),
concurrency 35.56s (35 -> 45), reposetup 27.73s (15 -> 35; Tasks 7-9's
private tests), workflow 26.36s (row 35 kept, 8.64s margin). Raised per the
plan's rule; the prefixes the plan fixes leave no non-overlapping sibling
shard to split into.
… instead of .docket/ and origin/docket

Adds TestSkillsSpellNoMetadataLiterals, ratchets the touched skill budgets,
repoints the auto-groom draft pin, and notes the private config layer in the
.docket.example.yml layer header.
… instead of republishing

A re-run inside the same second rebuilt a byte-identical init root, and the
create-only push reported the up-to-date ref as applied, so a finished private
init flaked to applied instead of no-op. Private init now probes the store
first and adopts a branch it already holds through the shared ownership
verifier (extracted as adoptPublishedMetadataRoot), publishing only when the
branch is absent. The re-run test pins commit dates to cover both the
identical-root and the different-root case deterministically, and a new test
proves a foreign store branch still refuses untouched.

Also moves operational_config_sources_test.go onto testsupport.TempDir, which
the real-process TempDir repoguard requires.
…kout instead of wedging

Review finding (blocker): deleting a private clone and re-cloning it at the
same path left the repository stuck. The clone id depends only on the path,
so the re-clone resolved the old checkout under the surviving store, whose
.git file names a gitdir the new clone no longer has. The fact gatherer read
it as foreign, init's `git worktree add` failed on the non-empty directory
(and its "re-run init" recovery failed the same way), and the orphan scan
skips this clone's own path, so repair never pruned it.

This clone's own checkout is now stale when its .git file names a gitdir that
no longer exists (staleOwnCheckout). The gatherer reads a stale checkout as
absent, and private init and prepare's attach remove it (re-verified on the
copy they act on; a live, absent, or unreadable checkout is never touched)
before attaching. TestIntegrationRepoSetupPrivateRecloneSamePathReattaches
covers init --private, delete, same-path re-clone, init, prepare, plus
prepare over a checkout that lost its gitdir. Each of the three sites was
mutation-tested: removing any one reddens the test.
…remote dckt remote

Review finding: runPrivateInit fell back to the default bare store whenever
no --metadata-remote flag was given, so re-running init on a repository set
up with --metadata-remote created a stray empty default store and then
refused the healthy repository with a false dckt-remote conflict. A
repository that already detected private now keeps its configured dckt
remote URL, and only a missing remote creates the default store.
…re any write

Review finding: runPrivateInit never checked for <primary>/.docket.local.yml,
so it wrote .git/dckt/config.yml, the store, remote, branch, and checkout, and
only then failed with ConflictingLocalConfigError — after which every
operation, the recommended init re-run included, refused. Init now refuses up
front with the same both-paths unsupported-config refusal, writing nothing.
…ng migrate

Review finding: conditionFinding's missing-attachment remedies told a private
repository (e.g. a moved clone) to run `docket repository migrate`, which
refuses every private repository, and repairApplied's local-sync note named
`.docket` even when the metadata worktree is the private checkout.

Private repositories now name `docket repository prepare` (it attaches an
absent private checkout and its local branch, idempotently), and the repair
note names the resolved metadata worktree. Shared text is byte-identical.
…different origin

Review finding: layout.OwnerRepo collapses distinct origins (acme/web-app vs
acme-web/app, or one owner/repo on two hosts) onto one <owner>-<repo> store
name, and adoptPublishedMetadataRoot accepted that store's init root from any
repository, so an unrelated repository silently adopted another's backlog.

The default store now records the creating repository's origin URL in
origin-url beside remote.git. Private init refuses (invalid-state/conflict,
before creating the store, adding the dckt remote, or attaching a checkout)
when the recorded origin names a different repository, and points at
--metadata-remote. A store with no record is adopted and gains one. A
--metadata-remote URL is not checked.
…tadata branch

Review fixes for change 0531:
- F7: a local-path --metadata-remote is made absolute and clean before
  AddRemote and the existing-remote comparison (URLs and scp-style
  host:path stay as typed); unit test TestAbsMetadataRemote.
- F8: skills say "the metadata branch" instead of "the `docket` branch";
  the convention's branch model names `docket` once for shared mode.
  Assets regenerated; convention word budget 7155 -> 7160.
@danielhanold
danielhanold merged commit 8ee9266 into main Oct 6, 2026
7 checks passed
@danielhanold
danielhanold deleted the feat/private-visibility-keep-the-metadata-branch-on-a-local-remot branch October 6, 2026 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant