Skip to content

Stop auto-indexed repos leaking into config.yaml and becoming tracked on restart #1086

Open

Description

@zzet

Why

Repos can show up as tracked even though nobody ran a track command for them. The daemon keeps one shared in-memory GlobalConfig, and every index path appends the indexed root to it. That includes roots nobody asked to track, such as GORTEX_AUTOINDEX=1 auto-index and promotion of an implicit checkout. Several daemon writes then dump the whole struct to ~/.gortex/config.yaml. On the next boot, Seed reads the leaked entry back as explicit configuration and creates a manual_config tracking intent for it. After that, nothing distinguishes it from a repo the user configured. The baseline release v0.64.10 has this defect.

The implicit path can even cause its own leak. RecordImplicit calls reconcileFamilyNow, and that saves the config whenever the family has a removed worktree. From then on, every leaked root is indexed on each boot, which costs warmup time, disk and memory. This issue stops new leaks. Cleaning up roots that already leaked is tracked separately.

Current state (origin/main)

  • internal/indexer/multi.go: TrackRepoCtx (around :3162) and ReconcileRepoCtx (around :3434) call mi.configMgr.Global().AddRepo(entry) for every origin. The origin is only used as a log label (WithIndexOrigin).
  • internal/indexer/checkout_lifecycle.go:
    • Register (:729) relies on that append, then calls saveConfig("track") (:782).
    • RecordImplicit (:802) says the list is "deliberately NOT persisted". It still calls reconcileFamilyNow (:1900), which runs saveConfig("reconcile") when a family member was removed (:1914).
    • sweep dumps the config with saveConfig("janitor") (:1788). The comment above it (:1786) describes an older design.
    • Seed dumps the config with saveConfig("reconcile") (:4168).
  • internal/indexer/checkout_overview.go:710 has one more saveConfig("reconcile") dump.
  • internal/indexer/checkout_promote.go: promotion calls TrackRepoCtx and saves (saveConfig("promote"), :231) even when source == TrackSourceImplicit. The source is already checked for the intent at :115.
  • Other writers that save the whole struct: set_active_project (internal/mcp/tools_multi.go:306), (*GlobalConfig).RemoveRepoAndSaveIfPresent (internal/config/global.go:607) and healDuplicateRepos (cmd/gortex/daemon_state.go:387).
  • Every config removal the daemon makes already saves itself through RemoveRepoAndSaveIfPresent. The janitor and reconcile dumps only re-dump memory, and they are the main carriers of the leak.
  • The existing tests TestCheckoutLifecycleImplicitRegistrationWritesNoIntent and TestCheckoutLifecycleImplicitRegistrationSurvivesRestartWithoutIntent (internal/indexer/checkout_lifecycle_test.go:1059, :1092) never run a dump between the implicit index and the restart, so they do not catch the leak.
  • ApplyReload (:1578) retires every live root that is not in gc.Repos. Once implicit roots stop going into the config, a reload would retire them unless reload learns to skip them.
  • Open PR fix(indexer): reject tracking a second repository under a taken prefix #954 (prefix collision) touches TrackRepoCtx and the CLI track.go. It is not merged.

What to do

  • Remove the AddRepo calls from TrackRepoCtx and ReconcileRepoCtx. Return the resolved entry (absolute path plus pinned Name) on IndexResult, for example as IndexResult.Entry config.RepoEntry.

  • In CheckoutLifecycle.Register, call AddRepo(entry) before saveConfig("track") for every explicit source:

    • use result.Entry;
    • when result == nil (the repo is already tracked), use the entry with its pinned name.

    This turns "explicitly track a repo that was auto-indexed earlier" into a deliberate adoption. Persist only after the prefix-collision rejection.

  • In promotion, add the entry to the config only when source != TrackSourceImplicit.

  • Delete these four dumps:

    • saveConfig("janitor") in sweep;
    • saveConfig("reconcile") in reconcileFamilyNow;
    • saveConfig("reconcile") in Seed;
    • saveConfig("reconcile") in checkout_overview.go.

    Keep notifyTrackedSetChanged and notifyFamilyChanged. Fix the stale janitor comment.

  • Make ApplyReload skip retiring a live root that is known to be implicit. That means a checkout with no active tracking intent, or a root that RecordImplicit registered in this process. Without this, the change above would make a reload drop auto-indexed repos. If fix(indexer): reject tracking a second repository under a taken prefix #954 lands first with the same behaviour, reuse it and add only the test.

  • Add a static guard test, TestNoIndexerConfigWrites, built on go/types. It fails when (*config.GlobalConfig).AddRepo is called from anywhere outside this allowlist:

    • CheckoutLifecycle.Register;
    • promotion;
    • cmd/gortex/track.go;
    • cmd/gortex/init.go;
    • cmd/gortex/mcp.go (embedded --track, until a later change).

    Watcher AddRepo methods are a different method and must not trip the guard.

  • Add a table-driven regression test in internal/indexer that uses newLifecycleFixture:

    1. Write a config with repo A and its linked worktree, a project containing A, a comment and an unknown key.
    2. Auto-index an untracked repo X: call TrackRepoCtx with origin mcp_auto_index, then RecordImplicit.
    3. Run one config writer per case (8 cases): implicit-self (X has a removed worktree), track another repo, janitor sweep, family reconcile, seed reconcile after restart, set active project, untrack another repo, untrack during warmup.
    4. Assert the file has no entry for X.
    5. Restart, run Seed, and assert X has no tracking intent.
  • Add TestHealDuplicateReposRemovesOnlyDuplicate in cmd/gortex.

  • If fix(indexer): reject tracking a second repository under a taken prefix #954 merges first, rebase onto it and keep its CLI rollback of a just-added entry.

Out of scope

  • Removing entries that already leaked. The doctor listing and the --apply cleanup are separate issues.
  • Config writes that preserve comments, and a config lock shared across processes.
  • Durable provenance ("who tracked this"), and showing it in repos families / list_checkouts.
  • A lifecycle-owned implicit set, a full redesign of reload, and no longer persisting embedded gortex mcp --track. These come in a later release.
  • Changing the agent-facing "run gortex track" advice, or whether gortex init persists.

Done when

  • The 8-case regression test fails in all 8 cases on ce04b3e (the merge commit of Improve incremental indexing and resumable contract analysis #806) and passes in all 8 cases on the branch.
  • TestTrackRepoCtxDoesNotMutateConfig, TestRegisterAdoptsImplicitRepo, TestRemovalsPersistWithoutSweepSave, TestNoIndexerConfigWrites and TestHealDuplicateReposRemovesOnlyDuplicate exist and pass under go test -race.
  • A test shows that ApplyReload keeps an implicitly indexed root live.
  • Validation workload: an isolated daemon with 3 repos and 20 worktrees, autostarted from an untracked repo with GORTEX_AUTOINDEX=1, followed by 15 minutes of worktree add/remove churn and a graceful restart. After the workload and again after the restart:
    • config.yaml contains 0 implicit roots;
    • the auto-indexed checkout has 0 tracking intents.
  • During that 15-minute churn, the daemon writes config.yaml 0 times with an empty diff: the mtime does not change unless an entry was intentionally added or removed.
  • No regression:
    • entries added by explicit gortex track, MCP track and reload still persist;
    • janitor and reconcile removals are still on disk after a restart;
    • the existing internal/indexer and cmd/gortex tests pass.

How to verify

  • Run go test -race ./internal/indexer/ -run 'NoIndexerConfigWrites|TrackRepoCtxDoesNotMutateConfig|RegisterAdoptsImplicitRepo|RemovalsPersistWithoutSweepSave|AutoIndexThenDumpWriter|ApplyReload' and go test -race ./cmd/gortex/ -run HealDuplicateRepos.
  • To prove the defect, cherry-pick only the regression test onto ce04b3e and confirm that all 8 cases fail.
  • Manual run:
    1. Start an isolated daemon with GORTEX_AUTOINDEX=1 from an untracked repo X that has a removed linked worktree.
    2. Make one tool call, run gortex track on another repo, then restart the daemon.
    3. Check that grep X ~/.gortex/config.yaml prints nothing.
    4. Check that sqlite3 <store> "select c.root_path, i.source_kind from tracking_intents i join checkouts c using(checkout_id)" returns no row for X.
  • During churn, record the mtime with stat -f %m ~/.gortex/config.yaml (macOS) or stat -c %Y (Linux), and the hash with shasum -a 256, before and after.

Dependencies

Blocked by (must land first):

Related issues

Planning

  • Milestone: v0.64.17
  • Priority: disk and memory (second priority)
  • Estimate: ~3 engineer-days

Activity

  1. added this to the v0.64.17 milestone on Oct 25, 2026
  2. added
    indexingIndexer, watcher, graph store and enrichment passes
    daemonDaemon lifecycle, sessions and repo registry
    worktreesGit worktrees, checkout families and views
    performanceLatency, memory and disk cost
    p2: disk & memoryDisk writes and memory footprint (second priority)
    on Oct 25, 2026
  3. moved this to Todo in Gortexon Oct 25, 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 registryindexingIndexer, watcher, graph store and enrichment passesp2: disk & memoryDisk writes and memory footprint (second priority)performanceLatency, memory and disk costworktreesGit worktrees, checkout families and views

    Projects

    • Status
      Todo

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions