From f1f1ad9dff1ec41bb24aec057ad4760d96c99ecd Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 14 Jul 2026 17:01:28 +0200 Subject: [PATCH 1/2] Isolate golangci-lint cache per worktree to stop cross-worktree contamination golangci-lint keys its results cache on file *contents* but stores each cached issue's *absolute* path. Two worktrees with byte-identical packages (the near-static tools/ module is the usual culprit) get a cross-worktree cache hit, so the second inherits the first's absolute paths. The //nolint processor then re-opens those paths to apply suppressions; when the other worktree has been deleted the open fails, so suppressed issues (e.g. dupword) leak through as phantom failures pointing at a gone path, plus a wall of "no such file or directory" warnings. The content-addressable cache keys added in v2.12.0 only fixed the concurrent case (both worktrees live). The deleted-worktree case still reproduces on the pinned v2.12.2. Rooting GOLANGCI_LINT_CACHE under the gitignored per-worktree .task/ makes contamination structurally impossible. CI is unaffected: it runs a single checkout and never persists this directory (setup-go caches only the Go build cache). Co-authored-by: Isaac --- Taskfile.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 0e18296c53..7c76e58680 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -20,6 +20,20 @@ vars: EMBED_SOURCES: sh: 'uv run -p ">=3.11" --no-project python tools/list_embeds.py' +env: + # Isolate golangci-lint's results cache per worktree. Its cache key is a hash + # of file *contents*, but each cached issue stores the file's *absolute* path. + # Two worktrees with byte-identical packages (the near-static tools/ module is + # the usual culprit) get a cross-worktree cache hit, so the second inherits the + # first's absolute paths; the //nolint processor then re-opens those paths to + # apply suppressions and, when the other worktree was deleted, can't — so + # suppressed issues leak through as phantom failures pointing at a gone path. + # The default shared cache (~/Library/Caches/golangci-lint) is machine-global; + # rooting it under the gitignored per-worktree .task/ makes contamination + # impossible. CI is unaffected: it runs a single checkout and never persists + # this directory (setup-go caches only the Go build cache). + GOLANGCI_LINT_CACHE: '{{.ROOT_DIR}}/.task/golangci-lint' + # pydabs-* tasks live in python/Taskfile.yml so `task pydabs-foo` works when # run from python/. Flattened so they keep their `pydabs-` names at the root. includes: @@ -113,8 +127,10 @@ tasks: # -j=4 — cap analyzer concurrency; higher values use more CPU without # reducing wall time on this codebase. # - # Cross-worktree concurrency relies on content-addressable cache keys added - # in golangci-lint v2.12.0; keep the binary at v2.12.0+ in tools/go.mod. + # Concurrent cross-worktree runs rely on content-addressable cache keys added + # in golangci-lint v2.12.0; keep the binary at v2.12.0+ in tools/go.mod. Those + # keys don't cover the deleted-worktree case (stale absolute paths in cached + # issues) — the per-worktree GOLANGCI_LINT_CACHE in the top-level `env:` does. lint-go: desc: Lint Go files across all modules (root, tools, codegen) deps: ['lint-go-root', 'lint-go-tools', 'lint-go-codegen'] From 4085d5219ace54b98fdd4fd416239f2f5a813ffc Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Thu, 16 Jul 2026 11:45:10 +0200 Subject: [PATCH 2/2] lint cache: add opt-out and seed-lint-cache to soften cold-worktree cost Per-worktree GOLANGCI_LINT_CACHE isolation removes cross-worktree cache reuse, so a fresh worktree's first full `task lint` runs cold (~2min on the root module vs ~20s warm; the incremental `task lint-q` in the default dev loop is unaffected, <1s either way). Two mitigations for developers with many worktrees: - Opt-out: Task's `env:` yields to an inherited value, so exporting GOLANGCI_LINT_CACHE (e.g. to the shared ~/Library/Caches/golangci-lint) restores cross-worktree reuse without editing the Taskfile. - `task seed-lint-cache`: copies the main worktree's cache into the current one so the first full lint runs warm (~4s in testing). Safe because the main worktree is permanent, so its cached issue paths always resolve; the task no-ops when GOLANGCI_LINT_CACHE has been overridden. Also link golangci/golangci-lint#6656, where the maintainer confirmed the shared-cache path contamination is an accepted trade-off of #6445. Co-authored-by: Isaac --- Taskfile.yml | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 7c76e58680..d2bbfbbc2b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -28,10 +28,21 @@ env: # first's absolute paths; the //nolint processor then re-opens those paths to # apply suppressions and, when the other worktree was deleted, can't — so # suppressed issues leak through as phantom failures pointing at a gone path. - # The default shared cache (~/Library/Caches/golangci-lint) is machine-global; - # rooting it under the gitignored per-worktree .task/ makes contamination - # impossible. CI is unaffected: it runs a single checkout and never persists - # this directory (setup-go caches only the Go build cache). + # See golangci/golangci-lint#6656 (closed as an accepted trade-off of the + # shared-cache work in #6445). The default shared cache + # (~/Library/Caches/golangci-lint) is machine-global; rooting it under the + # gitignored per-worktree .task/ makes contamination impossible. + # + # CI is unaffected: it runs a single checkout and never persists this directory + # (setup-go caches only the Go build cache). + # + # Trade-off: a fresh worktree's FIRST full `task lint` can't reuse another + # worktree's issue cache, so it runs cold (~2min on the root module vs ~20s + # warm; the incremental `task lint-q` used by the default dev loop is + # unaffected, <1s either way). Task's `env:` yields to an inherited value, so + # this is opt-out: export GOLANGCI_LINT_CACHE (e.g. to the shared + # ~/Library/Caches/golangci-lint) to trade isolation back for cross-worktree + # reuse, or run `task seed-lint-cache` to warm a new worktree from main. GOLANGCI_LINT_CACHE: '{{.ROOT_DIR}}/.task/golangci-lint' # pydabs-* tasks live in python/Taskfile.yml so `task pydabs-foo` works when @@ -189,6 +200,27 @@ tasks: cmds: - "./tools/lintdiff.py {{.GO_TOOL}} golangci-lint run --allow-parallel-runners -j=4 --fix" + seed-lint-cache: + desc: >- + Warm this worktree's golangci-lint cache by copying the main worktree's, + so the first full `task lint` runs warm instead of cold. Safe because the + main worktree is permanent, so its cached issue paths always resolve. + # Skip when GOLANGCI_LINT_CACHE has been overridden away from the per-worktree + # default (the shared-cache opt-out already gets cross-worktree reuse for free). + status: + - test "$GOLANGCI_LINT_CACHE" != "{{.ROOT_DIR}}/.task/golangci-lint" + cmds: + - | + main_wt=$(git worktree list --porcelain | grep -m1 '^worktree ' | cut -d' ' -f2-) + src="$main_wt/.task/golangci-lint" + dst="{{.ROOT_DIR}}/.task/golangci-lint" + if [ "$src" = "$dst" ]; then echo "Already in the main worktree; nothing to seed."; exit 0; fi + if [ ! -d "$src" ]; then echo "No cache at $src; run 'task lint' in the main worktree first." >&2; exit 1; fi + mkdir -p "{{.ROOT_DIR}}/.task" + rm -rf "$dst" + cp -R "$src" "$dst" + echo "Seeded $dst from $src" + # --- Formatting --- fmt: