Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
# 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.<anything>.tpl` is skipped whatever
environment it names. `.env.test.local` is still copied — it is a local
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

- `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.
Expand Down
12 changes: 11 additions & 1 deletion src/commands/syncEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,25 @@ 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")
: " (nothing to copy)";
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;
Expand Down
44 changes: 32 additions & 12 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -16,16 +17,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(
Expand Down Expand Up @@ -70,24 +77,37 @@ function findFiles(
return files;
}

export type EnvSyncStatus = "new" | "overwrite" | "skipped";
export type EnvSyncStatus = "new" | "overwrite" | "skipped" | "tracked";

export interface EnvSyncEntry {
relPath: string;
status: EnvSyncStatus;
}

// 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" };
Expand All @@ -104,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),
Expand Down
18 changes: 18 additions & 0 deletions src/lib/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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",
Expand Down
5 changes: 3 additions & 2 deletions src/lib/ideTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@ 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.
//
// This narrows the odds; it does not close them. Doing that needs the colours to
// 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.
Expand Down
46 changes: 43 additions & 3 deletions tests/env-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string>): string {
Expand Down Expand Up @@ -44,18 +44,58 @@ 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"]);
});

// 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", () => {
Expand Down
4 changes: 3 additions & 1 deletion tests/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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%`);
});

Expand Down