From c8e695397b44b983587b9881a452fe1816fb40cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lionel=20Gibern=C3=A9?= Date: Sun, 6 Sep 2026 14:31:07 +0200 Subject: [PATCH 1/2] fix: skip env templates by suffix, not by exact name --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ src/lib/env.ts | 22 ++++++++++++++-------- src/lib/ideTheme.ts | 5 +++-- tests/env-sync.test.ts | 13 +++++++++++-- tests/theme.test.ts | 4 +++- 7 files changed, 64 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fcb286..30e8435 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,9 @@ jobs: node: ["22", "24"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node }} cache: npm diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index eb596a3..cc97b24 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 # No `cache: npm` here, deliberately — npm's own guidance is to avoid # caching in release builds, so what ships is built from what the registry @@ -32,7 +32,7 @@ jobs: # placeholder, npm finds credentials, uses them, and never falls back to # OIDC — failing with E401 as though the token had expired. Publishing # goes to registry.npmjs.org by default anyway. - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "24" diff --git a/CHANGELOG.md b/CHANGELOG.md index 41978cf..c09f68d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## Unreleased + +### Fixed + +- **`gwt sync-env` overwrote committed `.env` templates.** The scan skipped + `.env.tpl`, `.env.example` and their three siblings by exact name, so every + prefixed form went through — `.env.development.tpl`, `.env.production.tpl`, + `.env.development.example`. Those files are usually tracked by git, so the main + worktree's copy landed on top of a branch that had edited one, and `git status` + showed a change nobody made. A monorepo naming its templates per environment + had 44 of them. + + The check is on the suffix now, so `.env..tpl` is skipped whatever + environment it names. `.env.test.local` is still copied — it is a local + override, not a committed fixture. Nothing else changes. + +### Internal + +- `actions/checkout` and `actions/setup-node` go from v4 to v7, which ends the + Node 20 deprecation warning on the runners. `setup-node` v7 also drops the + dummy `NODE_AUTH_TOKEN` export that made `registry-url` return E401 against + npm's trusted publisher — the workaround of leaving `registry-url` out is no + longer load-bearing, though it stays, since the publish path works. + +- The collision rate for four worktree colours is **8.8%**, measured over 20,000 + draws. The code said eight percent in one place and one in fourteen in another, + and 0.8.3's entry below says seven; the first was closest. The comments and the + test now carry the measured figure. + ## 0.8.3 Nothing to reinstall — the shell wrapper is unchanged. diff --git a/src/lib/env.ts b/src/lib/env.ts index fdfc49f..eaeb145 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -16,16 +16,22 @@ const EXCLUDED_DIRS = new Set([ const ENV_PATTERN = /^\.env/; -const EXCLUDED_FILES = new Set([ - ".env.test", - ".env.example", - ".env.tpl", - ".env.template", - ".env.sample", -]); +// Templates, examples and test fixtures are committed, so their content belongs +// to the branch: copying the main worktree's version over a branch that edited +// one produces a diff nobody asked for. Matching by exact name missed every +// prefixed form — `.env.development.tpl`, `.env.production.tpl` — which is the +// shape real repositories use, so the check is on the suffix instead. +const EXCLUDED_SUFFIXES = [ + ".example", + ".sample", + ".template", + ".test", + ".tpl", +]; function isEnvFile(name: string): boolean { - return ENV_PATTERN.test(name) && !EXCLUDED_FILES.has(name); + if (!ENV_PATTERN.test(name)) return false; + return !EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix)); } function scanDir( diff --git a/src/lib/ideTheme.ts b/src/lib/ideTheme.ts index 944dbcc..8a37759 100644 --- a/src/lib/ideTheme.ts +++ b/src/lib/ideTheme.ts @@ -15,7 +15,8 @@ const FORMATTING = { tabSize: 2, insertSpaces: true }; // four open, two are perceptually identical (ΔE < 5) about one time in five. // // Varying lightness and saturation as well turns one wheel into three, which -// drops that to roughly one in fourteen. Both variants of each keep white text +// drops that to roughly one in eleven — 8.8%, measured over 20,000 draws of four +// branch names. Both variants of each keep white text // above WCAG AA — measured at 4.53:1 in the worst case, against the 4.5 floor — // so the readability the fixed values protected is not traded away for it. // @@ -23,7 +24,7 @@ const FORMATTING = { tabSize: 2, insertSpaces: true }; // be coordinated across worktrees rather than derived from a name, which costs // determinism (the same branch would look different on another machine) and // breaks under two concurrent `gwt add` — a real scenario for anyone running -// parallel agents. Not worth it for the remaining eight percent. +// parallel agents. Not worth it for the remaining nine percent. // Three combinations, not the four that hue × lightness × saturation allows. // The darkest and dullest of them (0.42 / 0.22) sat ΔE 22 from VS Code's default // dark chrome — close enough that a themed window read as barely themed at all. diff --git a/tests/env-sync.test.ts b/tests/env-sync.test.ts index 7c0dd53..7df79f1 100644 --- a/tests/env-sync.test.ts +++ b/tests/env-sync.test.ts @@ -44,18 +44,27 @@ describe("planEnvSync", () => { ); }); - test("skips the template variants that are meant to be committed", () => { + // The property is "a committed file is never copied", not "these five names are + // never copied". Asserting the list is what let `.env.development.tpl` through: + // it is a template by every reader's judgement and by git's, and the exact-name + // check had no opinion about it. + test("skips templates and fixtures whatever environment they name", () => { const src = source({ ".env": "real", ".env.local": "real too", + ".env.development": "real as well", ".env.example": "no", ".env.test": "no", ".env.sample": "no", ".env.template": "no", ".env.tpl": "no", + ".env.development.tpl": "no", + ".env.production.tpl": "no", + ".env.development.local.tpl": "no", + ".env.development.example": "no", }); const names = planEnvSync(src, tmpDir("dest-")).map((e) => e.relPath).sort(); - assert.deepEqual(names, [".env", ".env.local"]); + assert.deepEqual(names, [".env", ".env.development", ".env.local"]); }); test("scanDirs restricts the scan, and a missing one is ignored", () => { diff --git a/tests/theme.test.ts b/tests/theme.test.ts index 400d4ad..423eab4 100644 --- a/tests/theme.test.ts +++ b/tests/theme.test.ts @@ -127,7 +127,9 @@ describe("pickColor", () => { } const rate = collisions / runs; - // Measured at ~7% with the four-wheel palette, ~20% with hue alone. + // Measured at 8.8% over 20,000 draws with the three-combination palette, + // against ~20% with hue alone. The 400 structured runs below are a guard, not + // a measurement — they sample too few to pin the rate, so the bound is loose. assert.ok(rate < 0.12, `${(rate * 100).toFixed(0)}% collided, expected under 12%`); }); From ec428197be60e555b17e5a59683c1440ac843ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lionel=20Gibern=C3=A9?= Date: Sun, 6 Sep 2026 14:46:22 +0200 Subject: [PATCH 2/2] fix: never overwrite an env file git tracks --- CHANGELOG.md | 13 ++++++++++++- src/commands/syncEnv.ts | 12 +++++++++++- src/lib/env.ts | 22 ++++++++++++++++++---- src/lib/git.ts | 18 ++++++++++++++++++ tests/env-sync.test.ts | 33 ++++++++++++++++++++++++++++++++- 5 files changed, 91 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c09f68d..125ddb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,18 @@ The check is on the suffix now, so `.env..tpl` is skipped whatever environment it names. `.env.test.local` is still copied — it is a local - override, not a committed fixture. Nothing else changes. + override, not a committed fixture. + +- **And a suffix list is still a guess, so git decides.** A repository that + versions `.env.defaults` — no template suffix anywhere — had the same bug in a + different shape. `sync-env` now leaves any file git tracks in the destination + alone, whatever it is called, and says how many it left. The suffix rules stay + on top, because they carry an intent the index cannot: an untracked + `.env.example` is still an example. + + The lookup is one `git ls-files` scoped by pathspec. That is deliberate — a + bare listing runs to 1.09 MB on a large monorepo, past `execFileSync`'s default + buffer, against 1.7 KB scoped. ### Internal diff --git a/src/commands/syncEnv.ts b/src/commands/syncEnv.ts index f1cebaa..07d26a5 100644 --- a/src/commands/syncEnv.ts +++ b/src/commands/syncEnv.ts @@ -65,8 +65,12 @@ export async function commandSyncEnv( })); let actionableTotal = 0; + let trackedTotal = 0; for (const { target, plan } of plans) { - const actionable = plan.filter((e) => e.status !== "skipped"); + const actionable = plan.filter( + (e) => e.status === "new" || e.status === "overwrite", + ); + trackedTotal += plan.filter((e) => e.status === "tracked").length; actionableTotal += actionable.length; const lines = actionable.length ? actionable.map((e) => ` ${label(e)} ${e.relPath}`).join("\n") @@ -74,6 +78,12 @@ export async function commandSyncEnv( log.step(`→ ${target.branch}\n${lines}`); } + if (trackedTotal > 0) { + log.info( + `${trackedTotal} versioned file(s) left alone — git owns them, so they arrive with the branch.`, + ); + } + if (actionableTotal === 0) { outro("Nothing to sync."); return; diff --git a/src/lib/env.ts b/src/lib/env.ts index eaeb145..097c222 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,6 +1,7 @@ import fs from "fs"; import path from "path"; import { log } from "@clack/prompts"; +import { trackedEnvFiles } from "./git.js"; const EXCLUDED_DIRS = new Set([ "node_modules", @@ -76,7 +77,7 @@ function findFiles( return files; } -export type EnvSyncStatus = "new" | "overwrite" | "skipped"; +export type EnvSyncStatus = "new" | "overwrite" | "skipped" | "tracked"; export interface EnvSyncEntry { relPath: string; @@ -84,16 +85,29 @@ export interface EnvSyncEntry { } // Builds the list of .env* files to copy from source to dest, classifying each: -// "overwrite" (dest already has the file), "new" (absent), or "skipped" (the -// file's parent directory doesn't exist in the destination worktree). +// "overwrite" (dest already has the file), "new" (absent), "skipped" (the file's +// parent directory doesn't exist in the destination worktree), or "tracked". +// +// "tracked" is the correctness rule the suffix list only approximates: a file +// git versions has content that belongs to the branch, so writing the main +// worktree's copy over it produces a change the user never made — and commits it +// on the next `git add -A`. The suffix list stays, because it carries intent an +// index cannot: an untracked `.env.example` is still an example. +// +// The check is on the destination, since that is the working tree a spurious +// diff would appear in. export function planEnvSync( sourceRoot: string, destRoot: string, scanDirs?: string[] | null, ): EnvSyncEntry[] { const files = findFiles(sourceRoot, isEnvFile, scanDirs); + const tracked = trackedEnvFiles(destRoot); return files.map((src) => { const relPath = path.relative(sourceRoot, src); + if (tracked.has(relPath.split(path.sep).join("/"))) { + return { relPath, status: "tracked" }; + } const dest = path.join(destRoot, relPath); if (!fs.existsSync(path.dirname(dest))) { return { relPath, status: "skipped" }; @@ -110,7 +124,7 @@ export function applyEnvSync( ): number { let copied = 0; for (const entry of plan) { - if (entry.status === "skipped") continue; + if (entry.status !== "new" && entry.status !== "overwrite") continue; fs.copyFileSync( path.join(sourceRoot, entry.relPath), path.join(destRoot, entry.relPath), diff --git a/src/lib/git.ts b/src/lib/git.ts index 702334b..a3cb364 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -394,6 +394,24 @@ export function worktreeStatus(worktreePath: string): WorktreeStatus { return status; } +/** + * Relative paths of the `.env*` files git tracks in `root`, forward-slashed. + * + * Scoped by pathspec deliberately: a bare `git ls-files` runs past a megabyte on + * a large monorepo, which is over execFileSync's default buffer — measured at + * 1.09 MB on one, against 1.7 KB for this pathspec. An unreadable index is not + * worth failing a sync over, so a failure here yields the empty set and the + * suffix rules stand alone. + */ +export function trackedEnvFiles(root: string): Set { + try { + const out = run("git", ["ls-files", "-z", "--", "*.env*"], root); + return new Set(out.split("\0").filter(Boolean)); + } catch { + return new Set(); + } +} + export function listWorktrees(): WorktreeEntry[] { const output = run( "git", diff --git a/tests/env-sync.test.ts b/tests/env-sync.test.ts index 7df79f1..eb0983d 100644 --- a/tests/env-sync.test.ts +++ b/tests/env-sync.test.ts @@ -2,7 +2,7 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { existsSync, readFileSync, mkdirSync } from "node:fs"; import path from "node:path"; -import { tmpDir, write } from "./helpers/fixtures.ts"; +import { tmpDir, tmpRepo, git, write } from "./helpers/fixtures.ts"; import { planEnvSync, applyEnvSync } from "../dist/lib/env.js"; function source(files: Record): string { @@ -67,6 +67,37 @@ describe("planEnvSync", () => { assert.deepEqual(names, [".env", ".env.development", ".env.local"]); }); + // The suffix list is a guess about intent; this is the rule. A repository that + // versions `.env.defaults` — no template suffix anywhere — must still not have + // it overwritten, or `git status` reports a change the user never made. + test("a file git tracks in the destination is never copied", () => { + const src = source({ ".env": "from main", ".env.defaults": "from main" }); + const dest = tmpRepo("dest"); + write(path.join(dest, ".env.defaults"), "from the branch"); + git(dest, "add", ".env.defaults"); + git(dest, "commit", "-q", "-m", "defaults"); + + const plan = planEnvSync(src, dest); + assert.equal(statusOf(plan, ".env.defaults"), "tracked"); + assert.equal(statusOf(plan, ".env"), "new"); + + assert.equal(applyEnvSync(src, dest, plan), 1); + assert.equal( + readFileSync(path.join(dest, ".env.defaults"), "utf8"), + "from the branch", + ); + }); + + test("an untracked file of the same name is copied", () => { + const src = source({ ".env.defaults": "from main" }); + const dest = tmpRepo("dest"); + write(path.join(dest, ".env.defaults"), "local"); + + const plan = planEnvSync(src, dest); + assert.equal(statusOf(plan, ".env.defaults"), "overwrite"); + assert.equal(applyEnvSync(src, dest, plan), 1); + }); + test("scanDirs restricts the scan, and a missing one is ignored", () => { const src = source({ ".env": "root",