Skip to content
Open
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
42 changes: 38 additions & 4 deletions src/context/node-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { matter } from "../util/front-matter.js";
import { contentHash, normalizeName } from "../util/id.js";
import { relPosix, stripTrailingSlashes } from "../util/paths.js";
Expand Down Expand Up @@ -115,10 +116,12 @@ export function contextDirFor(root: string, override?: string): string {
* Make sure the repo's root `.gitignore` ignores the graft output dir. The
* graph is a local, regenerable cache (like `node_modules`), not a committed
* artifact, so every `graft build` adds the entry itself the first time — the
* user never has to think about it. No-ops when the entry is already present
* or the dir lives outside `root` (a custom `--dir` elsewhere, which can't be
* expressed as a repo-relative ignore). Best-effort: an unwritable `.gitignore`
* must never abort a build, so write failures are swallowed.
* user never has to think about it. No-ops when the entry is already present,
* when git already ignores the dir by other means (`.git/info/exclude`, a parent
* `.gitignore`, `core.excludesFile`), or when the dir lives outside `root` (a
* custom `--dir` elsewhere, which can't be expressed as a repo-relative ignore).
* Best-effort: an unwritable `.gitignore` must never abort a build, so write
* failures are swallowed.
*/
const GRAPH_CACHE_NOTE = "graft's local graph cache — regenerable, not committed (run `graft build`).";

Expand Down Expand Up @@ -146,11 +149,42 @@ export function ensureGitignored(root: string, contextDir: string, note = GRAPH_
return t === entry || t === `${bare}/` || t === bare;
});
if (present) return;
// Not in `.gitignore` itself, but git may already ignore the dir through `.git/info/exclude`,
// a parent `.gitignore` or `core.excludesFile`. Then there is nothing to add, and appending
// would dirty the checkout again on every build for a user who keeps tooling noise out of
// the tracked `.gitignore` on purpose.
if (gitAlreadyIgnores(root, bare)) return;
const gap = current === "" ? "" : current.endsWith("\n") ? "\n" : "\n\n";
const block = `${gap}# ${note}\n${entry}\n`;
try { writeFileSync(path, current + block); } catch { /* best-effort — build already succeeded */ }
}

/** Variables that point git at a different repo than the one in `cwd` (a git hook exports
* `GIT_DIR` and `GIT_INDEX_FILE`, a wrapper may export the rest). Left in the child's
* environment they make `check-ignore` answer for that other repo. */
const GIT_REPO_ENV = [
"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", "GIT_NAMESPACE",
];

/** True when `root` is a git work tree whose ignore rules (every source git reads: `.gitignore`
* files, `.git/info/exclude`, `core.excludesFile`) already cover the directory `rel/`. Any
* failure (git missing, not a repo, timeout) answers false, so the caller falls back to
* writing the entry. The trailing slash makes git treat the path as a directory, so dir-only
* patterns like `graft/` match. A negation rule (an allowlist `.gitignore` that re-includes
* directories) can judge a not-yet-created path differently from an existing one, so callers
* create the dir first. `core.fsmonitor` is off because it would run a command named by the
* repo's own config. */
function gitAlreadyIgnores(root: string, rel: string): boolean {
try {
const env = { ...process.env };
for (const k of GIT_REPO_ENV) delete env[k];
const r = spawnSync("git", ["-c", "core.fsmonitor=false", "check-ignore", "-q", "--", `${rel}/`], {
cwd: root, env, stdio: "ignore", timeout: 5000, windowsHide: true,
});
return r.status === 0;
} catch { return false; }
}

/**
* Keep the card tree greppable even though it's gitignored, by writing a root
* `.ignore` that re-admits it.
Expand Down
114 changes: 111 additions & 3 deletions test/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
* End-to-end tests for the markdown-graph pipeline (`init` → `check`), driven by
* offline test doubles so no LLM/network is needed.
*/
import { test } from "node:test";
import { test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
import { join } from "node:path";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, realpathSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { execFileSync } from "node:child_process";
import { buildContext } from "../src/context/build.js";
Expand Down Expand Up @@ -423,6 +423,31 @@ test("graft check: keyless build then code changes (wiring stale) exits 1", () =
}
});

// The ensureGitignored tests ask real git whether a path is ignored, so they must not see the
// developer's environment: a global excludes file, a TMPDIR inside a repo, GIT_DIR exported by
// a hook, or GRAFT_NO_GITIGNORE would each change the answer. Pin all of it for every test
// in this file and restore it afterwards.
const ISOLATED_ENV = [
"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM",
"GIT_CEILING_DIRECTORIES", "XDG_CONFIG_HOME", "GRAFT_NO_GITIGNORE", "GRAFT_NO_IGNORE",
];
let savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
savedEnv = Object.fromEntries(ISOLATED_ENV.map((k) => [k, process.env[k]]));
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GRAFT_NO_GITIGNORE", "GRAFT_NO_IGNORE"]) delete process.env[k];
// Missing files read as empty config / no default excludes file.
process.env.GIT_CONFIG_GLOBAL = join(tmpdir(), "ctxgi-no-global-config");
process.env.XDG_CONFIG_HOME = join(tmpdir(), "ctxgi-no-xdg-config");
process.env.GIT_CONFIG_NOSYSTEM = "1";
// Stop git's repo discovery above the temp root, so a scratch dir never finds an outer repo.
process.env.GIT_CEILING_DIRECTORIES = dirname(realpathSync(tmpdir()));
});
afterEach(() => {
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k]; else process.env[k] = v;
}
});

// ensureGitignored — every `graft build` self-ignores its regenerable graph dir.
test("ensureGitignored: creates .gitignore with the graft/ entry when none exists", () => {
const dir = mkdtempSync(join(tmpdir(), "ctxgi-"));
Expand Down Expand Up @@ -527,6 +552,89 @@ test("ensureGitignored: GRAFT_NO_GITIGNORE=1 skips writing .gitignore", () => {
}
});

// A git-ignored context dir needs no `.gitignore` entry, whichever file git reads the rule from.
function gitInit(dir: string): void {
execFileSync("git", ["init", "-q"], { cwd: dir, stdio: "ignore" });
}

test("ensureGitignored: leaves .gitignore alone when .git/info/exclude already ignores the graph dir", () => {
const dir = mkdtempSync(join(tmpdir(), "ctxgi-"));
try {
gitInit(dir);
writeFileSync(join(dir, ".git", "info", "exclude"), "/graft/\n");
writeFileSync(join(dir, ".gitignore"), "node_modules/\n");
ensureGitignored(dir, contextDirFor(dir));
assert.equal(readFileSync(join(dir, ".gitignore"), "utf8"), "node_modules/\n");
const fresh = mkdtempSync(join(tmpdir(), "ctxgi-"));
try {
gitInit(fresh);
writeFileSync(join(fresh, ".git", "info", "exclude"), "/graft/\n");
ensureGitignored(fresh, contextDirFor(fresh));
assert.equal(existsSync(join(fresh, ".gitignore")), false, "must not create .gitignore either");
} finally {
rmSync(fresh, { recursive: true, force: true });
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("ensureGitignored: leaves .gitignore alone when core.excludesFile already ignores the graph dir", () => {
const dir = mkdtempSync(join(tmpdir(), "ctxgi-"));
try {
gitInit(dir);
const globalIgnore = join(dir, ".git", "global-ignore");
writeFileSync(globalIgnore, "graft/\n");
execFileSync("git", ["config", "core.excludesFile", globalIgnore], { cwd: dir, stdio: "ignore" });
ensureGitignored(dir, contextDirFor(dir));
assert.equal(existsSync(join(dir, ".gitignore")), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("ensureGitignored: leaves .gitignore alone when a parent .gitignore already ignores the graph dir", () => {
const dir = mkdtempSync(join(tmpdir(), "ctxgi-"));
try {
gitInit(dir);
writeFileSync(join(dir, ".gitignore"), "graft/\n");
const sub = join(dir, "pkg");
mkdirSync(sub);
ensureGitignored(sub, contextDirFor(sub));
assert.equal(existsSync(join(sub, ".gitignore")), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("ensureGitignored: still writes the entry in a git repo that does not ignore the graph dir", () => {
const dir = mkdtempSync(join(tmpdir(), "ctxgi-"));
try {
gitInit(dir);
writeFileSync(join(dir, ".git", "info", "exclude"), "/something-else/\n");
ensureGitignored(dir, contextDirFor(dir));
assert.match(readFileSync(join(dir, ".gitignore"), "utf8"), /^\/graft\/$/m);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("ensureGitignored: an inherited GIT_DIR does not make git answer for another repo", () => {
const dir = mkdtempSync(join(tmpdir(), "ctxgi-"));
const other = mkdtempSync(join(tmpdir(), "ctxgi-"));
try {
gitInit(dir);
gitInit(other);
writeFileSync(join(other, ".git", "info", "exclude"), "/graft/\n");
process.env.GIT_DIR = join(other, ".git"); // what a git hook exports; restored by afterEach
ensureGitignored(dir, contextDirFor(dir));
assert.match(readFileSync(join(dir, ".gitignore"), "utf8"), /^\/graft\/$/m);
} finally {
rmSync(dir, { recursive: true, force: true });
rmSync(other, { recursive: true, force: true });
}
});

test("ensureSearchable: GRAFT_NO_IGNORE=1 skips writing .ignore", () => {
const dir = mkdtempSync(join(tmpdir(), "ctxsearch-"));
process.env.GRAFT_NO_IGNORE = "1";
Expand Down
Loading