diff --git a/src/cli.ts b/src/cli.ts index 9f06d115c..09712e88e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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"; @@ -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 diff --git a/src/context/node-file.ts b/src/context/node-file.ts index 3461ffa8a..90f0e5416 100644 --- a/src/context/node-file.ts +++ b/src/context/node-file.ts @@ -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 */ } @@ -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]; diff --git a/test/cli-cache-ignore.test.ts b/test/cli-cache-ignore.test.ts new file mode 100644 index 000000000..72d757f1b --- /dev/null +++ b/test/cli-cache-ignore.test.ts @@ -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 }); + } +});