dry-ts
Version:
Find candidate duplicate TypeScript code by comparing normalized AST structure.
72 lines (71 loc) • 3.34 kB
JavaScript
import { spawnSync } from "node:child_process";
// Thin provider around the git subprocess for --changed-from. All policy
// (hunk parsing, intersection) lives in ChangedRegions; this module only
// spawns git and fails closed: ANY failure — binary missing, non-zero exit,
// unexpected output — throws with git's stderr attached. Exit codes are the
// gate's API; a silently wrong changed set would wave new duplication
// through green.
//
// Hardening:
// - argument vectors only, never a shell string (CI refs are
// attacker-influenced on fork PRs);
// - refs starting with "-" rejected outright (argument injection);
// - refs validated with `rev-parse --verify <ref>^{commit}` before use;
// - every call passes --no-color --no-ext-diff --no-textconv and
// -c core.quotePath=false, with pathspecs after --, so user/global git
// config can never reshape output under the strict parser.
export class GitProvider {
cwd;
constructor(cwd = process.cwd()) {
this.cwd = cwd;
}
repoRoot() {
return this.run(["rev-parse", "--show-toplevel"]).trim();
}
// Rejects unresolvable names and non-commit objects; returns the sha.
verifyRef(ref) {
if (ref.startsWith("-")) {
throw new Error(`Invalid git ref (must not start with "-"): ${ref}`);
}
return this.run(["rev-parse", "--verify", `${ref}^{commit}`]).trim();
}
// --changed-from never diffs literally against the ref: a branch behind its
// base would see unrelated base-side changes pollute the changed set.
mergeBase(ref) {
return this.run(["merge-base", ref, "HEAD"]).trim();
}
// base → working tree, matching the files the scanner reads. -U0: default
// context lines would silently widen the gate. -M: follow renames.
diffSince(base) {
return this.run(["diff", "--no-color", "--no-ext-diff", "--no-textconv", "-U0", "-M", base, "--"]);
}
// Canonical (root-relative, slash-separated) paths of files in git's index,
// bounded to the given pathspecs. A scanned file absent from this set is
// untracked and counts as fully changed.
indexedFiles(pathspecs) {
// -z: NUL-delimited, never C-quoted. Plain `ls-files` quotes any path with
// a tab/newline/quote/backslash (even under core.quotePath=false), which
// would not match the scanner's literal path and falsely mark a clean
// tracked file untracked. NUL output sidesteps quoting entirely.
const output = this.run(["ls-files", "-z", "--", ...pathspecs]);
return new Set(output.split("\0").filter((entry) => entry !== ""));
}
run(args) {
const result = spawnSync("git", ["-c", "core.quotePath=false", ...args], {
cwd: this.cwd,
encoding: "utf8",
maxBuffer: 1024 * 1024 * 256,
});
if (result.error) {
const code = result.error.code;
if (code === "ENOENT") {
throw new Error("git executable not found; --changed-from requires git");
}
throw new Error(`git ${args[0]} failed: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`git ${args.join(" ")} failed: ${(result.stderr ?? "").trim()}`);
}
return result.stdout;
}
}