fix(#3663): fold path casing only on win32 in the w027 active-worktree check (#3793)

* test(#3663): failing-first rows for w027 path-casing normalization

* fix(#3663): fold path casing only on win32 in the w027 active-worktree check

* fix(#3663): close review findings — seam-owned compare key, deterministic case pin

* chore(#3663): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-24 00:57:51 -04:00
committed by GitHub
parent 4af59f8dd3
commit 314ea20fa4
5 changed files with 144 additions and 7 deletions

View File

@@ -35,7 +35,8 @@
* gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs (gitignored).
*/
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import shellCmdProjection = require('../shell-command-projection.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- type-only; erased at compile time, no runtime require emitted
import type planningSnapshotMod = require('../planning-snapshot.cjs');
@@ -141,15 +142,35 @@ function checkW017(snapshot: PlanningSnapshot): Diagnostic[] {
// (with the real path) lives in `message`; `remedy.args.command` stays a
// static `<path>` template, mirroring the split the brief specifies.
// ─── #3663 — path-comparison provenance helper ─────────────────────────────
//
// snapshot.cwd is raw process-cwd-derived — path.resolve() normalizes
// separators and relative segments but NOT casing, and a process launched via
// a differently-cased path echoes that spelling back. finding.path is
// `git worktree list`-derived, which self-normalizes to the canonical
// on-disk casing (forward slashes). Comparing those two spellings strictly
// misclassifies the ACTIVE worktree as stale on win32 — so the comparison
// folds case ONLY on win32 (case-insensitive filesystem, via the Shell
// Command Projection seam's toComparablePathKey — the platform-conditional
// fold policy lives there, not per call site) and stays case-sensitive on
// POSIX, where differently-cased paths are genuinely different directories.
// No realpath resolution — that would change symlink matching behavior.
function isActiveWorktreePath(
activeCwd: string,
worktreePath: string,
platform: string = process.platform,
): boolean {
const active = shellCmdProjection.toComparablePathKey(activeCwd, platform);
const worktree = shellCmdProjection.toComparablePathKey(worktreePath, platform);
return active === worktree || active.startsWith(worktree + '/');
}
function checkW027(snapshot: PlanningSnapshot): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
const activeCwd = snapshot.cwd;
for (const finding of snapshot.worktreeHealth.value) {
if (finding.kind !== 'stale') continue;
const normalizedWorktree = path.resolve(finding.path);
const isActiveWorktree =
activeCwd === normalizedWorktree || activeCwd.startsWith(normalizedWorktree + path.sep);
if (isActiveWorktree) continue;
if (isActiveWorktreePath(activeCwd, finding.path)) continue;
diagnostics.push({
code: 'W027',
severity: SEVERITY.WARNING,
@@ -194,4 +215,4 @@ const RULES: Rule[] = [
},
];
export = { RULES };
export = { RULES, isActiveWorktreePath };

View File

@@ -56,6 +56,21 @@ export function posixNormalize(p: string): string {
return p.replace(/\\/g, '/');
}
/**
* #3663 — comparison key for a path that must equal another path regardless
* of spelling: separator form (forward slashes via posixNormalize), relative
* segments and trailing separators (path.resolve + strip), and — ONLY on
* win32's case-insensitive filesystem — letter casing. POSIX stays
* case-sensitive: differently-cased paths are genuinely different
* directories there. This module owns the platform-conditional fold so the
* policy lives at the seam instead of accreting per-call-site copies (the
* class init.cts's normalizeForCompare/toComparableRaw predate).
*/
export function toComparablePathKey(p: string, platform: string = process.platform): string {
const normalized = posixNormalize(path.resolve(p)).replace(/\/+$/g, '');
return platform === 'win32' ? normalized.toLowerCase() : normalized;
}
/**
* Return true when a managed hook command must be prefixed with PowerShell's
* call operator so a quoted executable token is invokable by the target