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
6 changes: 4 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { asksForTrail, brand, cmd, graftNotice, PACKAGE_FOR, setBrand, strandedR
import { branchDiff } from "./cloud/diff.js";
import { openBrowser } from "./util/browser.js";
import { committedAtBase, learningsAtBase, teammateEdits, withBaseLearnings } from "./check/learning-edits.js";
import { CACHE_DIR, contextDirFor } from "./context/node-file.js";
import { CACHE_DIR, contextDirFor, gitIgnoresDirectory } from "./context/node-file.js";
import { loadGraphCached } from "./graph/load.js";
import { ensureFreshChildren, ensureFreshGraph, refreshNote } from "./graph/refresh.js";
import { isWorkspaceBuildRoot, readWorkspace } from "./graph/workspace.js";
Expand Down Expand Up @@ -821,8 +821,10 @@ grouped(program.command("build"), GROUP.setup)
const rel = relative(process.cwd(), g.contextDir) || "graft";
if (process.env.GRAFT_NO_GITIGNORE) {
console.log(` ${rel}/ is a local cache: add it to your gitignore if you want it untracked.`);
} else if (gitIgnoresDirectory(buildRoot, g.contextDir)) {
console.log(` ${rel}/ is git-ignored. Teammates run \`${cmd("graft build")}\` to get their own cache.`);
} else {
console.log(` ${rel}/ is git-ignored (added automatically), a local cache; teammates run \`${cmd("graft build")}\` to get their own.`);
console.error(` ${rel}/ is a local cache. Git did not confirm that it is ignored. Check before committing.`);
}

// #127: a --deep run whose LLM calls failed used to print the same success
Expand Down
13 changes: 7 additions & 6 deletions src/context/node-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export function ensureGitignored(root: string, contextDir: string, note = GRAPH_
// 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;
if (gitIgnoresDirectory(root, contextDir)) 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 */ }
Expand All @@ -196,15 +196,16 @@ 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
/** True when Git confirms its ignore rules cover the directory inside `root`.
* A missing Git, timeout or other failed check answers false. 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 {
export function gitIgnoresDirectory(root: string, directory: string): boolean {
const rel = relPosix(root, directory);
if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) return false;
try {
const env = { ...process.env };
for (const k of GIT_REPO_ENV) delete env[k];
Expand Down
37 changes: 37 additions & 0 deletions test/cli-cache-ignore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { test } from "node:test";
import { homeEnv, runCli, tmpRepo } from "./helpers.js";

test("build reports whether Git ignores the cache after a failed ignore write", () => {
const root = tmpRepo("cli-cache-ignore");
const env = {
...homeEnv(root),
DO_NOT_TRACK: "1",
GRAFT_NO_GITIGNORE: undefined,
GIT_CONFIG_GLOBAL: join(root, "empty-git-config"),
GIT_CONFIG_NOSYSTEM: "1",
};
const ignore = join(root, ".gitignore");

try {
execFileSync("git", ["init", "-q", "--template="], { cwd: root, env });
writeFileSync(join(root, "app.js"), "export function hello() { return 1; }\n");
mkdirSync(ignore); // Make the write fail even if tests run with elevated permissions.

const unignored = runCli(["build", root, "--no-ignore"], { env, timeoutMs: 20_000 });
assert.equal(unignored.status, 0, unignored.describe());
assert.doesNotMatch(unignored.stdout, /is git-ignored/, unignored.describe());
assert.match(unignored.stderr, /Git did not confirm that it is ignored/, unignored.describe());

rmdirSync(ignore);
const ignored = runCli(["build", root, "--no-ignore"], { env, timeoutMs: 20_000 });
assert.equal(ignored.status, 0, ignored.describe());
assert.match(ignored.stdout, /is git-ignored/, ignored.describe());
execFileSync("git", ["check-ignore", "-q", "--", "graft/"], { cwd: root, env });
} finally {
rmSync(root, { recursive: true, force: true });
}
});
Loading