Skip to content

Reject a second repository whose folder name is already tracked, on every path (land #954) #1025

Closed

Description

@zzet

Why

A repository's prefix is its folder name unless name is set. The prefix is the first segment of every node id. It is also the key of the live registry, the watcher map, the per-repo config cache and the resolver helper registry, and it is the input to the dedicated graph id. Nothing reserves a prefix. On main, every "already tracked?" check treats "this prefix is taken" as "this root is tracked" and never compares the roots. With /a/src tracked, running gortex track /b/src on main:

  • reports success, and --wait times out;
  • replaces the owner's cached .gortex.yaml, or deletes it if /b/src has none;
  • can move the owner's checkout binding (owner checkout, family, primary slot) to /b/src when the owner has no worktree readers;
  • logs only watcher already exists for repo: src.

After a restart, both entries race for src on parallel warmup workers. In one 30-restart review run, the wrong repo won 14 times. When it wins, the owner's corpus holds both trees' files. The loser also schedules a base publication for the owner's prefix. The obvious workaround, untracking the second path, can evict the owner's corpus. This is the likely cause of the "silently dropped" and "already tracked but not tracked" reports from people with two clones of one project. It also hits monorepos with many src, app or api folders. This is a safety fix and is never cut from the release.

Current state

Verified on origin/main:

  • internal/indexer/multi.go prepareTrackRepo: if _, exists := mi.repos[prefix]; exists { … return nil, nil // already tracked }. The same pattern appears in the in-lane recheck in trackPreparedRepoSourceCtx and at both checks in ReconcileRepoCtx.
  • internal/indexer/checkout_lifecycle.go Register: when TrackRepoCtx returns nil, it falls back to l.ResolvePrefix(absPath) and then to config.ResolvePrefix(entry) (the folder name). It then calls recordCheckout and attaches the watcher for a root it never indexed.
  • internal/indexer/checkout_promote.go: the error arms, including the UpsertDedicatedGraph failure, call rollbackPromotionOrLog(ctx, out.Prefix, …), which purges whatever is registered under that prefix. Success is checked as result == nil && l.mi.GetMetadata(prefix) == nil, so another root's metadata counts as success.
  • cmd/gortex/daemon_state.go: a loser's nil reconcile falls back to the folder-name prefix and reaches state.basePublisher.Schedule(indexed).
  • cmd/gortex/daemon_controller.go Track holds c.mu for the whole track. internal/mcp/tools_multi.go answers {status:"accepted"} near its deadline, so a collision found later only goes to the log.
  • internal/indexer/repo_load_state.go already reports a warmup job that ended unregistered as prefix_taken: … already registered to <owner>. That is only a status reason. It does not prevent the takeover.
  • internal/indexer/multi_watcher.go produces the watcher already exists for repo: %s error.

Open PR #954 does most of the core. The maintainer approved it as a partial solution, to be merged once its merge conflict is resolved. It contains:

  • PrefixCollisionError (with OwnerMissing) and trackedLocked, returned at both track checks;
  • resolution of live and configured owners before warmup. A skipped configured entry ends as failed, not untracked;
  • a promotion guard: a collision leaves the transition pending and never purges the other root, and the next sweep retries under a suffixed prefix;
  • bindDedicatedGraph refusing to move a binding owned by another checkout or family unless the binding is unpublished and abandoned. It also adds metadata-only repair of wrong legacy unpublished bindings;
  • workspace and gitignore reads moved outside mi.mu;
  • a CLI non-zero exit with or without --wait, which removes only the config entry this call added;
  • a config-only untrack for a root that is configured but not loaded;
  • --name / name refusing to rename a path that is already configured;
  • MCP messages that name the name parameter;
  • the prefix_collision code in internal/daemon/proto.go and server.go, and docs in docs/cli.md and docs/multi-repo.md.

What #954 does not cover:

  • owner-root stats bounded and done outside mi.mu on the track path;
  • removal of Register's folder-name fallback;
  • the typed warmup skip, so the loser no longer counts as changed and no longer schedules a base publication;
  • a collision check that runs before the controller lock is taken;
  • a stored outcome for deferred MCP tracks;
  • a structured MCP error code;
  • the probe arm.

What to do

  1. Land fix(indexer): reject tracking a second repository under a taken prefix #954. Rebase it on main, resolve the conflict and merge it. The PR measured +12% on reconcile at 1,000 repositories. Keep that within the no-regression target below.
  2. Follow-up PR with the remaining items:
    • Stats outside mi.mu. Stat the candidate root and the owner root before taking mi.mu (in trackedLocked, the same-directory identity check and PrefixCollisionError construction), with a 2 s bound. A timeout counts as "cannot stat". Under the lock, only re-check that the owner entry is unchanged, and retry once if it changed. PrefixCollisionError.Error() does no I/O.
    • Register. Never fall back to the folder name for a root that is not registered. If the track result is nil and no registered entry has the same root identity, return PrefixCollisionError before recordCheckout, the watcher attach and the config save.
    • Promotion. Count "registered under this prefix" as success only when the root is the same one. Apply the no-purge guard to every failure arm, including UpsertDedicatedGraph.
    • Warmup loser (typed skip). At the reconcile sites, return a typed skip, not a bare nil. Warmup treats it as unchanged: nothing is added to changedRepos or scopeUnknown, the delta frontier is not invalidated, and basePublisher.Schedule is never called for the owner's prefix. The job is recorded as failed{prefix_collision}. This covers both the warm reconcile arm and the cold track arm.
    • Control socket. Add a cheap collision pre-check before c.mu.Lock() in realController.Track, and before the goroutine starts in handleTrackRepository. Use a registry snapshot, with the stats done outside any lock.
    • MCP deferred path. Keep each track goroutine's final outcome for 10 minutes, keyed by canonical root. A follow-up track_repository for the same root returns that outcome instead of running the track again. Collisions return a structured error with error_code: prefix_collision, prefix, owner_path and owner_state.
    • Auto-index. internal/mcp/auto_index.go never records an implicit checkout for a root whose prefix belongs to another root.
    • Messages. Log one warn line per collision, naming both roots and the action taken. No message recommends untrack for the colliding path. Suggest untracking the owner only when the owner root is missing.
    • Probe. A configured root whose prefix is registered to a different root answers prefix_collision. It does not answer repo_loading, repo_unavailable or repo_not_tracked, and a re-track of that root does not answer already_configured.
    • Keep fix(indexer): reject tracking a second repository under a taken prefix #954's same-directory no-ops: a different case or Unicode spelling, a symlink alias with the same folder name, and a directory already tracked under another name.

Out of scope

  • Automatic parent-qualified names such as b-src. Collisions are rejected for now.
  • A full ownership plan with a persistent conflict probe state, and repair of corpora already mixed by earlier takeovers, beyond the metadata repair fix(indexer): reject tracking a second repository under a taken prefix #954 ships.
  • Renaming an already indexed corpus.
  • Symlink aliases with a different folder name. These make a duplicate corpus, not a collision.
  • projects:-only entries.

Done when

Fixture: two git repos /T/a/src and /T/b/src. Each has a .gortex.yaml declaring a distinct workspace and exclude. /T/a/src has 2 linked worktrees.

  • fix(indexer): reject tracking a second repository under a taken prefix #954 is merged to main (PR fix(indexer): reject tracking a second repository under a taken prefix #954, merge 1e6605c0cf7b9fad7bda3aa43b9c7eeb62dc7753, 2026-10-25).
  • With /T/a/src tracked, gortex track /T/b/src exits 1 within 1 s, with and without --wait. This holds on an idle controller and while another track holds the controller lock. Config is unchanged, and the error names /T/a/src.
  • After that track, and again with /T/b/src having no .gortex.yaml, a restart, a config reload and 3 janitor sweeps, these owner properties are unchanged:
    • node and edge counts;
    • GetRepoConfig("src") (non-nil and unchanged) and its gitignore root;
    • the watcher root and the LSP helper root;
    • the base-publication schedule;
    • the dedicated graph binding: owner checkout, family, primary flag, active generation.
  • Each collision produces exactly 1 warn line naming both roots, and 0 watcher already exists lines.
  • With a stat hook that blocks forever on the owner root, the track returns within 2.5 s, and concurrent GetMetadata p99 is at most 10 ms.
  • On a warm restart over the fixture, the loser adds nothing to changedRepos or scopeUnknown, never triggers basePublisher.Schedule("src"), and is reported as failed{prefix_collision}. Warmup takes the fast path when nothing else changed.
  • A promotion whose derived prefix is taken mid-build leaves the other corpus's node count unchanged.
  • A second MCP track_repository for the same colliding root within 10 minutes returns the stored prefix_collision outcome, with error_code, prefix, owner_path and owner_state, and starts no new track.
  • These tests exist and pass under go test -race, alongside fix(indexer): reject tracking a second repository under a taken prefix #954's tests:
    • TestWarmupCollisionTypedSkip
    • TestPromotionCollisionKeepsOtherCorpus
    • TestRegisterCollisionNoCatalogNoWatcherNoSave
    • TestBusyControllerCollisionWithin1s
    • TestMCPDeferredCollisionOutcome
    • TestHungOwnerStatBounded
    • TestAutoIndexCollisionNoRecordImplicit
  • Re-tracking the same directory under another spelling is still a silent no-op, and the existing tests stay green.
  • No regression against main, measured on a CI arm64 runner with 6 runs per side:
    • the reconcile benchmark at 1,000 repositories is at most 15% slower;
    • daemon warmup time and call-changing save time at 200 and 1,000 repositories show no statistically significant slowdown (benchstat p < 0.05).
  • docs/cli.md, docs/multi-repo.md and docs/mcp.md describe the unique-prefix rule, the prefix_collision error, --name / name, and the deferred MCP outcome.

How to verify

  • Run go test -race ./internal/indexer/... ./cmd/gortex/... ./internal/mcp/... ./internal/daemon/....
  • Reproduce by hand on an isolated daemon:
    1. Run git init /T/a/src /T/b/src, then gortex track /T/a/src --wait, then gortex track /T/b/src --wait.
    2. Expect exit 1 within 1 s. daemon.log has one collision warn line and no watcher already exists.
    3. Restart the daemon 5 times. Each time, search_text for a marker string unique to each tree finds only /T/a/src's marker under src/.
  • Compare the reconcile, warmup and call-changing save benchmarks against main with benchstat, 6 runs per side.

Dependencies

Blocks:

Related issues

Planning

  • Milestone: v0.64.12
  • Priority: release gate
  • Estimate: ~4 engineer-days

Activity

  1. added this to the v0.64.12 milestone on Oct 25, 2026
  2. added
    documentationImprovements or additions to documentation
    indexingIndexer, watcher, graph store and enrichment passes
    daemonDaemon lifecycle, sessions and repo registry
    worktreesGit worktrees, checkout families and views
    mcpMCP tool surface, schemas, facade and errors
    release gateSafety or measurement gate a release cannot ship without
    on Oct 25, 2026
  3. moved this to Todo in Gortexon Oct 25, 2026
  4. moved this from Todo to In Progress in Gortexon Oct 28, 2026
  5. zzet commented on Oct 28, 2026

    @zzet
    OwnerAuthor

    Implementation is available in draft PR #1164, stacked on #1162. The final pushed commit passed the prescribed pinned lint/format/CGO/workflow/tidy checks, and all 116 retained focused controls have passing evidence with per-commit attribution; ownership repeats and final changed-expression checks also passed. The work remains In Progress: hosted CI, the isolated runtime scenarios and the six-run ARM64 performance comparisons are still pending.

    The existing manual CI and skill-drift workflows were dispatched once because automatic PR workflows only target main. Manual CI falls back to full tests. Security/normal PR checks must also run after retargeting to main; no acceptance or merge is claimed.

  6. zzet commented on Oct 28, 2026

    @zzet
    OwnerAuthor

    CI at the first draft head found regressions and fixture assumptions beyond the original focused checks. They are preserved in run 40678839397. The superseded manual run was canceled after its failed-job logs were saved.

    PR #1164 now contains seven atomic corrections at bc2a927fdc8059a2c0bc7bbbb9d43d7d923cecce: preserve missing-path errors; avoid reading workspace configuration during canonical membership lookup; register promotion publication with output-generation authority; avoid unnecessary naming discovery when a prefix is explicit; compare decoded Windows paths; distinguish collision from genuinely unavailable roots; and finish admitted cleanup before the replacement-owner fixture proceeds.

    All 38 focused race regression/neighbor tests passed locally, with zero skips, followed by the full prescribed pinned lint, formatting, CGO/workflow, tidy and module checks. Fresh hosted CI is running. Reference runtime and performance acceptance remain outstanding; this issue stays In Progress.

  7. zzet commented on Oct 28, 2026

    @zzet
    OwnerAuthor

    The corrected head bc2a927fdc8059a2c0bc7bbbb9d43d7d923cecce passed the full hosted CI run, including every Linux, macOS and Windows test group and parser stress. Skill-drift checks also passed. The original failures remain recorded; this is a new validated head.

    Local validation at this head passed 38 focused race tests and all prescribed pinned lint, formatting, CGO/workflow, tidy and module checks. Runtime and performance acceptance, dependency integration and the eventual main-target PR checks remain open, so #1025 stays In Progress and PR #1164 stays draft.

  8. moved this from In Progress to Done in Gortexon Nov 1, 2026
  9. zzet commented on Nov 1, 2026

    @zzet
    OwnerAuthor

    The code in #1164 is merged with exact-head correctness/security checks and a merge tree equal to the locally tested tree. GitHub automatically closed this issue through the PR link; reopening it because the recorded R3 and native/ARM64 runtime comparisons remain open. This merge is source readiness, and does not claim those measurements passed.

  10. reopened this on Nov 1, 2026
  11. moved this from Done to In Progress in Gortexon Nov 1, 2026
  12. zzet commented on Nov 3, 2026

    @zzet
    OwnerAuthor

    Qualified performance acceptance is recorded following the maintainer’s explicit decision to accept the measured +3.32% (p=.002) and +1.61% (p=.026) call-changing save-time results with a profiling follow-up before v0.65.0.

    The follow-up is #1301. It links the exact source pair, all 96 observations, build/host/tool pins and retained artifact from run 41579513305, and preserves the allocation observations and the unproven-cause limitation. The four warmup timing cells and the 1,000-repository reconcile bound passed separately.

    This decision applies to those two measured save-time results only. It does not change unrelated criteria or establish an allocation threshold waiver. The original failed comparison and the significant results remain retained; no third cloud run was dispatched. Verification of the remaining functional, documentation and race criteria is being attached before issue closure. #831 still requires reporter confirmation separately.

  13. zzet commented on Nov 3, 2026

    @zzet
    OwnerAuthor

    Closing #1025 with acceptance evidence complete in the selected scopes.

    • The native collision fixture passed with two same-named repositories and two linked worktrees. Rejected CLI requests preserve configuration and identify the owner; repeated MCP requests preserve structured collision details.
    • Owner counts and checkout/catalog bindings survived reload and restart. A separate, explicitly instrumented correctness supplement verified cached configuration, gitignore, watcher and resolver-helper roots, publication state, and three completed sweeps of the same janitor. The supplement supplies no performance credit.
    • Actual warning counts were 1 / 1 / 0 across the three daemon starts, with both roots named and no watcher already exists messages.
    • The retained ownership, promotion, deferred-outcome, alias, and bounded-stat race controls remain qualified, and the required CLI/MCP documentation is delivered.

    The qualified ARM64 comparison contains all 96 samples, six per side for each benchmark. Reconcile at 1,000 repositories stayed within the 15% limit, and all four warmup cells met the original significance rule. The user accepted only the two save-time regressions: +3.32% (p=.002) and +1.61% (p=.026). Their profiling follow-up, #1301, remains required before the v0.65.0 cut. Every allocation delta remains separately reported; the timing decision does not waive allocations or another criterion.

    This closeout applies to #1025 only. It does not close #831 or declare the whole release accepted. Native raw evidence and provenance/cleanup receipts remain private.

    The final native public case passed in 3.67 seconds and the private correctness supplement in 2.78 seconds, with no skipped tests or forced cleanup. Earlier controller, catalog and log-sink failures remain preserved; none is relabeled as a pass.

  14. moved this from In Progress to Done in Gortexon Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    daemonDaemon lifecycle, sessions and repo registrydocumentationImprovements or additions to documentationindexingIndexer, watcher, graph store and enrichment passesmcpMCP tool surface, schemas, facade and errorsrelease gateSafety or measurement gate a release cannot ship withoutworktreesGit worktrees, checkout families and views

    Projects

    • Status
      Done

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions