Files
kandev/apps/web/scripts/lib/changed-files.mjs
T
2026-08-13 22:50:13 +01:00

88 lines
3.6 KiB
JavaScript

/**
* Which files a change added or modified, and the raw diff its added-line ranges
* are parsed from.
*
* Split out of `check-new-code-i18n.mjs` so the selection can be tested against
* real git topologies — a merge ref, a rebased branch, a push — without needing
* ESLint or this repository's own history.
*/
import { git } from "./git-base.mjs";
export const WEB_PREFIX = "apps/web/";
/** Only UI source. Tests build fixtures out of literals on purpose. */
export function isCandidate(repoPath) {
if (!repoPath.startsWith(WEB_PREFIX)) return false;
const rel = repoPath.slice(WEB_PREFIX.length);
if (!/\.tsx?$/.test(rel)) return false;
if (/\.(test|spec)\.tsx?$/.test(rel)) return false;
if (rel.startsWith("e2e/") || rel.startsWith("scripts/")) return false;
return /^(components|app|hooks|lib|src)\//.test(rel);
}
/**
* Only Playwright E2E sources, for the sleep ratchet.
*
* The exact complement of the `e2e/` exclusion in {@link isCandidate}: the i18n
* guard skips this tree because tests build fixtures out of literals on purpose,
* and the sleep guard exists only for this tree. `.spec.ts` files are the
* *primary* target here rather than an exclusion, which is why this cannot be
* expressed as options on the other predicate.
*
* `causal-waits.ts` is not excluded here. Exemption belongs to the eslint config
* (`SLEEP_EXEMPT_FILES`), so the editor, the preview and the gate all read one
* list; filtering it out at selection time as well would be a second place to
* keep in sync, and the one that fails silently.
*/
export function isE2eCandidate(repoPath) {
if (!repoPath.startsWith(WEB_PREFIX)) return false;
const rel = repoPath.slice(WEB_PREFIX.length);
return rel.startsWith("e2e/") && /\.tsx?$/.test(rel);
}
/**
* Every path matching `filter` (a `--diff-filter` value) between `base` and the
* working tree, unfiltered.
*
* `--find-renames` explicitly: rename detection defaults on since Git 2.9, but a
* repo with diff.renames=false would report a moved file as ADDED, and an added
* file is judged whole — demanding a full migration for a plain `git mv`.
*/
export function changedPaths(base, filter, cwd) {
return (
git(["diff", "--name-only", "--find-renames", `--diff-filter=${filter}`, base], { cwd })
.split("\n")
.map((line) => line.trim())
// `"".split("\n")` is `[""]`, so a no-match diff would otherwise yield one
// empty "path". Both predicates reject it, but this function is exported and
// its next caller should not have to know that.
.filter(Boolean)
);
}
/**
* Candidate UI-source paths matching `filter`.
*
* A pure delegation to {@link changedPaths} + {@link isCandidate}: the i18n
* ratchet's file selection is byte-for-byte what it was before the sleep ratchet
* needed the same git invocation with a different predicate. Kept as its own
* export rather than inlined at the call site so that stays true by
* construction.
*/
export function changedFiles(base, filter, cwd) {
return changedPaths(base, filter, cwd).filter(isCandidate);
}
/**
* The zero-context diff that added-line attribution is parsed from.
*
* Scoped to apps/web rather than to the individual files, because narrowing the
* pathspec to only the NEW path of a rename hides the matching deletion and git
* then reports the whole file as added — which would demand a full migration for
* a pure `git mv`. Keeping both sides in scope lets rename detection pair them,
* so a pure rename yields no hunks and reports nothing.
*/
export function webDiff(base, cwd) {
return git(["diff", "--unified=0", "--find-renames", base, "--", WEB_PREFIX], { cwd });
}