From 19986406692be3316434122dcebfd37f267ffa5a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:27:18 +0800 Subject: [PATCH] fix(reference): scope ignore rules and honor local Git case policy --- docs/cli.md | 13 +- .../ReferenceSourceGitDirectory.ts | 57 ++++ src/application/ReferenceSourceImport.ts | 3 +- .../ReferenceSourceImportPolicy.ts | 275 +++++++++++++----- src/application/ReferenceSourceVcsAdapter.ts | 77 ++--- src/reference/ReferenceSourceReader.ts | 3 + src/reference/ReferenceSourceReaderEntries.ts | 5 + src/reference/ReferenceSourceReaderTypes.ts | 8 + .../referenceSourceIgnorePolicy.test.ts | 126 ++++++++ .../referenceSourceMetadataCleanup.test.ts | 62 +++- 10 files changed, 493 insertions(+), 136 deletions(-) create mode 100644 src/application/ReferenceSourceGitDirectory.ts create mode 100644 tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts diff --git a/docs/cli.md b/docs/cli.md index 84d8c39777..a1470e84f8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -407,8 +407,17 @@ other facts. No fixed output-size or runtime improvement is promised. The import records hashes and metadata for the supplied files, separately from observations of the current app. For a directory inside a Git checkout, it records the HEAD of the nearest enclosing repository; whether the working -tree matches that commit remains unknown. It honors the source tree's `.gitignore` -and applies default exclusions for common generated and dependency paths such +tree matches that commit remains unknown. Root selection follows filesystem +symlink/parent semantics before canonical identity is derived. It honors +`.gitignore` files at the selected root and in traversed child directories, +with patterns relative to their containing directory and deeper negations +applied only when the parent was not excluded. Matching uses the nearest +repository's local `core.ignorecase` setting (false when unset), including the +shared configuration of linked worktrees. Policy and Git metadata reads retain +their descriptor cleanup under the import's resource owner; unreadable policy +is a typed failure, not an empty policy. Above-root `.gitignore` files, global +excludes, `.git/info/exclude`, config includes and worktree-specific config are +not loaded. The import also applies default exclusions for common generated and dependency paths such as `node_modules/`, `dist/`, and `*.log`. Pattern-based exclusions record the matched pattern and whether it came from project, default, caller, or sensitive path policy. Links into excluded paths retain their internal target metadata; diff --git a/src/application/ReferenceSourceGitDirectory.ts b/src/application/ReferenceSourceGitDirectory.ts new file mode 100644 index 0000000000..78927935ff --- /dev/null +++ b/src/application/ReferenceSourceGitDirectory.ts @@ -0,0 +1,57 @@ +import { lstat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; + +/** Parsed repository locations shared by HEAD and local ignore-policy readers. */ +export interface ReferenceSourceGitDirectory { + readonly repository: string; + readonly gitDirectory: string; + readonly commonDirectory: string; +} + +/** Locate the nearest repository without running Git or following config includes. */ +export const locateReferenceSourceGitDirectory = async ( + root: string, + readText: (path: string) => Promise, + signal?: AbortSignal, +): Promise => { + let repository = root; + for (;;) { + signal?.throwIfAborted(); + const dotgit = join(repository, ".git"); + let metadata; + try { + metadata = await lstat(dotgit); + } catch (cause: unknown) { + if (!hasCode(cause, "ENOENT")) throw cause; + const parent = dirname(repository); + if (parent === repository) return undefined; + repository = parent; + continue; + } + let gitDirectory = dotgit; + if (metadata.isFile()) { + const pointer = (await readText(dotgit)).replace(/\r?\n$/u, ""); + if (!pointer.startsWith("gitdir: ") || pointer.length === 8) + throw new Error("Invalid Git directory pointer"); + gitDirectory = absoluteCallerPath(pointer.slice(8), { cwd: repository }); + } + let commonDirectory = gitDirectory; + try { + const common = (await readText(join(gitDirectory, "commondir"))).replace( + /\r?\n$/u, + "", + ); + commonDirectory = absoluteCallerPath(common, { cwd: gitDirectory }); + } catch (cause: unknown) { + if (!hasCode(cause, "ENOENT")) throw cause; + } + return { repository, gitDirectory, commonDirectory }; + } +}; + +const hasCode = (cause: unknown, code: string): boolean => + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === code; diff --git a/src/application/ReferenceSourceImport.ts b/src/application/ReferenceSourceImport.ts index 4c27f6c457..8f34e190ba 100644 --- a/src/application/ReferenceSourceImport.ts +++ b/src/application/ReferenceSourceImport.ts @@ -119,7 +119,7 @@ const createShouldExclude = : kind === "unknown" ? [path, `${path}/`] : [path]; - const firstMatch = (matcher: typeof secrets) => { + const firstMatch = (matcher: Pick) => { const results = patternPaths.map((patternPath) => matcher.test(patternPath), ); @@ -181,6 +181,7 @@ export const importReferenceSource = async ( readReferenceSource(root, resources, { ...(options.signal === undefined ? {} : { signal: options.signal }), shouldExclude, + beforeDirectory: prepared.value.beforeDirectory, }), readReferenceSourceVcs(root, resources, options.signal), ]); diff --git a/src/application/ReferenceSourceImportPolicy.ts b/src/application/ReferenceSourceImportPolicy.ts index 4527ee70bd..704a01a4d2 100644 --- a/src/application/ReferenceSourceImportPolicy.ts +++ b/src/application/ReferenceSourceImportPolicy.ts @@ -1,5 +1,8 @@ import { lstat, realpath, stat } from "node:fs/promises"; +import fs from "node:fs"; import { join } from "node:path"; +import { locateReferenceSourceGitDirectory } from "./ReferenceSourceGitDirectory.js"; +import type { ReferenceSourceReaderError } from "../reference/ReferenceSourceReaderTypes.js"; import ignore from "ignore"; import { @@ -25,7 +28,10 @@ import { /** Validated inputs ready for filesystem traversal. */ export interface PreparedReferenceSourceImport { readonly root: string; - readonly ignored: ReturnType; + readonly ignored: Pick, "test">; + readonly beforeDirectory: ( + path: string, + ) => Promise>; readonly secrets: ReturnType; } @@ -102,82 +108,213 @@ const resolveRoot = async ( } }; +/** Read one policy/configuration file through the existing descriptor owner. */ +const readPolicyFile = async ( + policyPath: string, + resources: ArtifactResourceScope, + signal?: AbortSignal, +): Promise> => { + try { + let present = true; + try { + await lstat(policyPath); + } catch (cause: unknown) { + if ( + !( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ENOENT" + ) + ) + throw cause; + present = false; + } + if (signal?.aborted === true) + throw new IgnorePolicyReadCancelled(signal.reason); + if (present) { + let text: string; + try { + text = await readRegularFileText(policyPath, { + signal, + cancellationFailure: (reason) => + new IgnorePolicyReadCancelled(reason), + }); + } catch (cause: unknown) { + if (cause instanceof IgnorePolicyReadCancelled) throw cause; + if (!(cause instanceof RegularFileCleanupFailure)) throw cause; + const retry = await retryRegularFileCleanup(cause, resources, signal); + const operationCancellation = + cause.outcome.kind === "failed" + ? regularFileCancellation(cause) + : undefined; + if (retry.cleanup !== undefined) { + const primary = + retry.outcome.kind === "failed" + ? retry.outcome.cause + : cause.cleanupCause; + const primaryCause = ignorePolicyCause(primary); + return err({ + ...failure( + isIgnorePolicyCancellation(primary) || + operationCancellation !== undefined + ? "cancelled" + : "io", + `Reference source ignore policy could not be read at ${policyPath}: ${errorMessage(primaryCause)}`, + ), + cleanup: retry.cleanup, + cause: primaryCause, + }); + } + if (retry.outcome.kind === "failed") { + if (operationCancellation !== undefined) + throw new IgnorePolicyReadCancelled(operationCancellation.reason); + throw retry.outcome.cause; + } + text = retry.outcome.value; + } + return ok(text); + } + return ok(undefined); + } catch (cause: unknown) { + const causeValue = ignorePolicyCause(cause); + return err({ + ...failure( + isIgnorePolicyCancellation(cause) ? "cancelled" : "io", + `Reference source policy could not be read at ${policyPath}: ${errorMessage(causeValue)}`, + ), + cause: causeValue, + }); + } +}; + +const readIgnoreCase = async ( + root: string, + resources: ArtifactResourceScope, + signal?: AbortSignal, +): Promise> => { + let readFailure: ReferenceSourceImportError | undefined; + const readText = async (path: string): Promise => { + // isomorphic-git probes readFile() without a path to detect promise APIs. + if (path === undefined) + throw new TypeError("Git metadata path is required"); + const read = await readPolicyFile(path, resources, signal); + if (!read.ok) { + readFailure = read.error; + throw read.error; + } + if (read.value === undefined) { + const absent = new Error(`Git metadata file is absent: ${path}`); + Object.assign(absent, { code: "ENOENT" }); + throw absent; + } + return read.value; + }; + try { + const location = await locateReferenceSourceGitDirectory( + root, + readText, + signal, + ); + if (location === undefined) return ok(false); + const { getConfig } = await import("isomorphic-git"); + const value = await getConfig({ + fs: { ...fs, promises: { ...fs.promises, readFile: readText } }, + gitdir: location.commonDirectory, + path: "core.ignorecase", + }); + if (readFailure !== undefined) return err(readFailure); + if (value === undefined) return ok(false); + // The installed Git parser applies the core.ignorecase boolean schema. + if (typeof value === "boolean") return ok(value); + return err( + failure( + "io", + `Invalid core.ignorecase in ${location.commonDirectory}/config`, + ), + ); + } catch (cause: unknown) { + if (readFailure !== undefined) return err(readFailure); + if (isAborted(signal) || isIgnorePolicyCancellation(cause)) + return err(failure("cancelled", "Reference source import cancelled")); + return err({ + ...failure( + "io", + `Reference source Git policy could not be read: ${errorMessage(cause)}`, + ), + cause, + }); + } +}; + const buildIgnored = async ( root: string, excludePaths: readonly string[], resources: ArtifactResourceScope, signal?: AbortSignal, -): Promise, ReferenceSourceImportError>> => { - const ignored = ignore(); - const policyPath = join(root, ".gitignore"); - let present = true; - try { - await lstat(policyPath); - } catch (cause: unknown) { - if ( - !( - typeof cause === "object" && - cause !== null && - "code" in cause && - cause.code === "ENOENT" - ) - ) - throw cause; - present = false; - } - if (signal?.aborted === true) - throw new IgnorePolicyReadCancelled(signal.reason); - if (present) { - let text: string; - try { - text = await readRegularFileText(policyPath, { - signal, - cancellationFailure: (reason) => new IgnorePolicyReadCancelled(reason), - }); - } catch (cause: unknown) { - if (cause instanceof IgnorePolicyReadCancelled) throw cause; - if (!(cause instanceof RegularFileCleanupFailure)) throw cause; - const retry = await retryRegularFileCleanup(cause, resources, signal); - const operationCancellation = - cause.outcome.kind === "failed" - ? regularFileCancellation(cause) - : undefined; - if (retry.cleanup !== undefined) { - const primary = - retry.outcome.kind === "failed" - ? retry.outcome.cause - : cause.cleanupCause; - const primaryCause = ignorePolicyCause(primary); - return err({ - ...failure( - isIgnorePolicyCancellation(primary) || - operationCancellation !== undefined - ? "cancelled" - : "io", - `Reference source ignore policy could not be read at ${policyPath}: ${errorMessage(primaryCause)}`, - ), - cleanup: retry.cleanup, - cause: primaryCause, - }); - } - if (retry.outcome.kind === "failed") { - if (operationCancellation !== undefined) - throw new IgnorePolicyReadCancelled(operationCancellation.reason); - throw retry.outcome.cause; - } - text = retry.outcome.value; - } - addMarkedPatterns(ignored, [text], "project-ignored"); - } +): Promise< + Result< + Pick, + ReferenceSourceImportError + > +> => { + const ignoreCase = await readIgnoreCase(root, resources, signal); + if (!ignoreCase.ok) return ignoreCase; + const mandatory = ignore(); addMarkedPatterns( - ignored, + mandatory, DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS, "default-ignored", ); - for (const path of excludePaths) { - addMarkedPatterns(ignored, [path, `${path}/`], "caller-excluded"); - } - return ok(ignored); + for (const path of excludePaths) + addMarkedPatterns(mandatory, [path, `${path}/`], "caller-excluded"); + const scopes = new Map>(); + const loadDirectory = async ( + path: string, + ): Promise> => { + if (scopes.has(path)) return ok(undefined); + const policyPath = + path === "." ? join(root, ".gitignore") : join(root, path, ".gitignore"); + const read = await readPolicyFile(policyPath, resources, signal); + if (!read.ok) return read; + const matcher = ignore({ ignoreCase: ignoreCase.value }); + if (read.value !== undefined) + addMarkedPatterns(matcher, [read.value], "project-ignored"); + scopes.set(path, matcher); + return ok(undefined); + }; + const rootPolicy = await loadDirectory("."); + if (!rootPolicy.ok) return rootPolicy; + return ok({ + ignored: { + test: (path) => { + const mandatoryMatch = mandatory.test(path); + if (mandatoryMatch.ignored) return mandatoryMatch; + let result = mandatoryMatch; + const pieces = path.replace(/\/$/u, "").split("/"); + for (let depth = 0; depth < pieces.length; depth += 1) { + const scope = depth === 0 ? "." : pieces.slice(0, depth).join("/"); + const matcher = scopes.get(scope); + if (matcher === undefined) continue; + const scopedPath = + pieces.slice(depth).join("/") + (path.endsWith("/") ? "/" : ""); + const match = matcher.test(scopedPath); + if (match.ignored || match.unignored) result = match; + } + return result; + }, + }, + beforeDirectory: async (path) => { + const loaded = await loadDirectory(path); + return loaded.ok + ? loaded + : err({ + ...loaded.error, + tag: "reference-source-reader", + code: loaded.error.code === "cancelled" ? "cancelled" : "io", + }); + }, + }); }; /** Resolve the caller-selected directory and build path filters. */ @@ -201,7 +338,7 @@ export const prepareReferenceSourceImport = async ( if (!ignored.ok) return ignored; return ok({ root: root.value, - ignored: ignored.value, + ...ignored.value, secrets: ignore().add([...options.policy.secretPatterns]), }); } catch (cause: unknown) { diff --git a/src/application/ReferenceSourceVcsAdapter.ts b/src/application/ReferenceSourceVcsAdapter.ts index 54bc6a7475..904828e288 100644 --- a/src/application/ReferenceSourceVcsAdapter.ts +++ b/src/application/ReferenceSourceVcsAdapter.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; -import { lstat } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; +import { join } from "node:path"; +import { locateReferenceSourceGitDirectory } from "./ReferenceSourceGitDirectory.js"; import { err, ok, type Result } from "../domain/result.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; @@ -30,26 +30,8 @@ export const readReferenceSourceVcs = async ( ): Promise> => { try { return await resources.run(async () => { - // Like Git, a subdirectory belongs to its nearest enclosing repository; - // only a missing `.git` at every ancestor means no version control. - let repository = resolve(root); - for (;;) { - if (isAborted(signal)) return ok(unknownVcs()); - try { - await lstat(join(repository, ".git")); - break; - } catch (cause: unknown) { - if (errorCode(cause) !== "ENOENT") return ok(unknownVcs()); - } - const parent = dirname(repository); - if (parent === repository) - return ok({ kind: "none", head: null, dirty: null }); - repository = parent; - } try { - const head = await resolveSourceHead(repository, resources, signal); - if (isAborted(signal)) return ok(unknownVcs()); - return ok({ kind: "git", head, dirty: null }); + return ok(await resolveSourceVcs(root, resources, signal)); } catch (cause: unknown) { if (cause instanceof ArtifactReaderFailure) throw cause; return ok(unknownVcs()); @@ -67,11 +49,11 @@ const unknownVcs = (): ReferenceSourceVcsInfo => ({ dirty: null, }); -const resolveSourceHead = async ( +const resolveSourceVcs = async ( root: string, resources: ArtifactResourceScope, signal?: AbortSignal, -): Promise => { +): Promise => { // isomorphic-git is loaded on first use so CLI and MCP startup skip it. const { resolveRef } = await import("isomorphic-git"); let failedRead: { readonly cause: unknown } | undefined; @@ -198,35 +180,32 @@ const resolveSourceHead = async ( if (failedRead !== undefined) throw failedRead.cause; return head; }; - const dotgit = join(root, ".git"); - if (!(await lstat(dotgit)).isFile()) return readRef({ ref: "HEAD" }); - const pointer = (await readMetadataText(dotgit)).replace(/\r?\n$/u, ""); - if (!pointer.startsWith("gitdir: ") || pointer.length === 8) - throw new Error("Invalid Git directory pointer"); - const gitdir = resolve(root, pointer.slice(8)); - let commonDirectory: string; - try { - commonDirectory = resolve( - gitdir, - (await readMetadataText(join(gitdir, "commondir"))).replace( - /\r?\n$/u, - "", - ), - ); - } catch (cause: unknown) { - if (errorCode(cause) !== "ENOENT") throw cause; - return readRef({ gitdir, ref: "HEAD" }); - } - const head = (await readMetadataText(join(gitdir, "HEAD"))).trim(); - if (!head.startsWith("ref: ")) return readRef({ gitdir, ref: "HEAD" }); - const ref = head.slice(5); - const privateRef = ["refs/bisect/", "refs/rewritten/", "refs/worktree/"].some( - (prefix) => ref.startsWith(prefix), + const locations = await locateReferenceSourceGitDirectory( + root, + readMetadataText, + signal, ); - return readRef({ - gitdir: privateRef ? gitdir : commonDirectory, + if (locations === undefined) return { kind: "none", head: null, dirty: null }; + const { gitDirectory, commonDirectory } = locations; + if (gitDirectory === commonDirectory) { + const head = await readRef({ gitdir: gitDirectory, ref: "HEAD" }); + return isAborted(signal) + ? unknownVcs() + : { kind: "git", head, dirty: null }; + } + const headText = (await readMetadataText(join(gitDirectory, "HEAD"))).trim(); + const ref = headText.startsWith("ref: ") ? headText.slice(5) : "HEAD"; + const privateRef = + ref === "HEAD" || + ["refs/bisect/", "refs/rewritten/", "refs/worktree/"].some((prefix) => + ref.startsWith(prefix), + ); + const head = await readRef({ + gitdir: privateRef ? gitDirectory : commonDirectory, ref, }); + if (isAborted(signal)) return unknownVcs(); + return { kind: "git", head, dirty: null }; }; const errorCode = (cause: unknown): string | undefined => diff --git a/src/reference/ReferenceSourceReader.ts b/src/reference/ReferenceSourceReader.ts index 0b43b55f96..ab3afc3a76 100644 --- a/src/reference/ReferenceSourceReader.ts +++ b/src/reference/ReferenceSourceReader.ts @@ -60,6 +60,9 @@ const readSource = async ( ...(options.shouldExclude === undefined ? {} : { shouldExclude: options.shouldExclude }), + ...(options.beforeDirectory === undefined + ? {} + : { beforeDirectory: options.beforeDirectory }), entries: [], exclusions: [], pending: [{ path: canonicalRoot }], diff --git a/src/reference/ReferenceSourceReaderEntries.ts b/src/reference/ReferenceSourceReaderEntries.ts index 5998eab21a..adecd9b18a 100644 --- a/src/reference/ReferenceSourceReaderEntries.ts +++ b/src/reference/ReferenceSourceReaderEntries.ts @@ -65,6 +65,11 @@ export const traverseDirectory = async ( recordDirectoryFailure(state, current.path, before.code, before.message); return { ok: true, value: undefined }; } + const policy = await state.beforeDirectory?.( + pathFromRoot(state.root, current.path), + ); + if (policy !== undefined && !policy.ok) return policy; + if (isAborted(state.signal)) return err(cancelled()); const directories: PendingDirectory[] = []; let readFailure: string | undefined; let unrepresentableNameCount = 0; diff --git a/src/reference/ReferenceSourceReaderTypes.ts b/src/reference/ReferenceSourceReaderTypes.ts index a9e3f470bf..2f5532106d 100644 --- a/src/reference/ReferenceSourceReaderTypes.ts +++ b/src/reference/ReferenceSourceReaderTypes.ts @@ -18,6 +18,10 @@ export type ReferenceSourceExclusionKind = ReferenceSourceEntryKind | "unknown"; export interface ReferenceSourceReaderOptions { readonly signal?: AbortSignal; + /** Prepare directory-scoped policy after directory identity admission, before enumeration. */ + readonly beforeDirectory?: ( + path: string, + ) => Promise>; readonly shouldExclude?: ( path: string, kind: ReferenceSourceExclusionKind, @@ -95,6 +99,10 @@ export type TraversalState = { readonly root: string; readonly rootIdentity: BigIntStats; readonly signal?: AbortSignal; + /** Prepare directory-scoped policy after directory identity admission, before enumeration. */ + readonly beforeDirectory?: ( + path: string, + ) => Promise>; readonly shouldExclude?: ( path: string, kind: ReferenceSourceExclusionKind, diff --git a/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts b/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts new file mode 100644 index 0000000000..bc347e55d6 --- /dev/null +++ b/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts @@ -0,0 +1,126 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { execFileOutput } from "../../../src/process/ExecFileOutput.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; + +const importTree = async (root: string) => { + const result = await importReferenceSource({ + root, + caller: "ignore-policy-test", + policy: { secretPatterns: [] }, + }); + if (!result.ok) throw result.error; + return result.value; +}; + +it("scopes nested ignore rules and negations like Git while preserving mandatory exclusions", async () => { + const root = await createTestTempDirectory("rea-reference-scoped-policy-"); + await execFileOutput("git", ["init", "--initial-branch=main", root]); + await execFileOutput("git", [ + "-C", + root, + "config", + "core.ignorecase", + "false", + ]); + await mkdir(join(root, "packages", "widget", "blocked"), { recursive: true }); + await mkdir(join(root, "packages", "widget", "node_modules")); + await mkdir(join(root, "sibling")); + await writeFile(join(root, ".gitignore"), "*.tmp\nblocked/\n"); + await writeFile( + join(root, "packages", "widget", ".gitignore"), + "/local.js\n!keep.tmp\n!node_modules/\n", + ); + await writeFile( + join(root, "packages", "widget", "blocked", ".gitignore"), + "!inside.js\n", + ); + const excluded = [ + "drop.tmp", + "packages/widget/local.js", + "packages/widget/blocked/inside.js", + ]; + const included = [ + "packages/widget/keep.tmp", + "packages/widget/main.js", + "sibling/local.js", + ]; + for (const path of [ + ...excluded, + ...included, + "packages/widget/node_modules/dependency.js", + ]) + await writeFile(join(root, path), "export const value = 1;\n"); + const oracle = await execFileOutput("git", [ + "-C", + root, + "check-ignore", + "--no-index", + ...excluded, + ...included, + ]); + expect(oracle.stdout.trim().split(/\r?\n/u)).toEqual(excluded); + const graph = await importTree(root); + const files = graph.entries + .filter(({ kind }) => kind === "file") + .map(({ path }) => path); + for (const path of included) expect(files).toContain(path); + for (const path of [ + ...excluded, + "packages/widget/node_modules/dependency.js", + ]) + expect(files).not.toContain(path); + expect(graph.exclusions).toContainEqual({ + path: "packages/widget/local.js", + entry_kind: "file", + reason: "project-ignored", + pattern: "/local.js", + }); + expect(graph.exclusions).toContainEqual({ + path: "packages/widget/node_modules", + entry_kind: "directory", + reason: "default-ignored", + pattern: "node_modules/", + }); + expect( + await readFile(join(root, "packages", "widget", "keep.tmp"), "utf8"), + ).toBe("export const value = 1;\n"); +}); + +it.each([false, true])( + "honors local core.ignorecase=%s against Git's check-ignore oracle", + async (ignoreCase) => { + const root = await createTestTempDirectory("rea-reference-ignore-case-"); + await execFileOutput("git", ["init", "--initial-branch=main", root]); + await execFileOutput("git", [ + "-C", + root, + "config", + "core.ignorecase", + String(ignoreCase), + ]); + await writeFile(join(root, ".gitignore"), "*.TMP\n"); + await writeFile(join(root, "upper.TMP"), "upper\n"); + await writeFile(join(root, "lower.tmp"), "lower\n"); + const oracle = await execFileOutput("git", [ + "-C", + root, + "check-ignore", + "--no-index", + "upper.TMP", + "lower.tmp", + ]); + const expected = oracle.stdout.trim().split(/\r?\n/u); + const graph = await importTree(root); + expect( + graph.exclusions + .filter(({ reason }) => reason === "project-ignored") + .map(({ path }) => path), + ).toEqual([...expected].sort()); + expect(graph.entries.some(({ path }) => path === "lower.tmp")).toBe( + !ignoreCase, + ); + }, +); diff --git a/tests/boundary/filesystem/referenceSourceMetadataCleanup.test.ts b/tests/boundary/filesystem/referenceSourceMetadataCleanup.test.ts index fd322996ca..8f9131e52c 100644 --- a/tests/boundary/filesystem/referenceSourceMetadataCleanup.test.ts +++ b/tests/boundary/filesystem/referenceSourceMetadataCleanup.test.ts @@ -87,37 +87,69 @@ const createPackedRefRepository = async () => { return { root, looseRef, packedRefs }; }; -it("retains failed .gitignore cleanup under the caller's scope", async () => { - const root = await createTestTempDirectory("rea-reference-ignore-owner-"); - const path = join(root, ".gitignore"); - await writeFile(path, "generated/\n"); +it.each([".", "nested"])( + "retains failed %s/.gitignore cleanup under the caller's scope", + async (directory) => { + const root = await createTestTempDirectory("rea-reference-ignore-owner-"); + await mkdir(join(root, directory), { recursive: true }); + const path = join(root, directory, ".gitignore"); + await writeFile(path, "generated/\n"); + const resources = new ArtifactResourceScope(); + const close = failCloseUntilAllowed(path); + onTestFinished(async () => { + close.allow(); + await resources.close().catch(() => undefined); + }); + + const result = await importReferenceSource( + { + root, + caller: "reference-source-metadata-cleanup-test", + policy: { secretPatterns: [] }, + }, + resources, + ); + expect(result).toMatchObject({ + ok: false, + error: { + code: "io", + cleanup: { + resources: [path], + reason: expect.stringContaining("injected metadata close failure"), + }, + }, + }); + expect(close.calls()).toBe(2); + + close.allow(); + await resources.close(); + expect(close.calls()).toBe(3); + }, +); + +it("retains local Git configuration cleanup before trusting ignore policy", async () => { + const root = await createTestTempDirectory("rea-reference-config-owner-"); + await execFileOutput("git", ["init", "--initial-branch=main"], { cwd: root }); + const path = join(root, ".git", "config"); const resources = new ArtifactResourceScope(); const close = failCloseUntilAllowed(path); onTestFinished(async () => { close.allow(); await resources.close().catch(() => undefined); }); - const result = await importReferenceSource( { root, - caller: "reference-source-metadata-cleanup-test", + caller: "reference-source-config-cleanup-test", policy: { secretPatterns: [] }, }, resources, ); expect(result).toMatchObject({ ok: false, - error: { - code: "io", - cleanup: { - resources: [path], - reason: expect.stringContaining("injected metadata close failure"), - }, - }, + error: { code: "io", cleanup: { resources: [path] } }, }); expect(close.calls()).toBe(2); - close.allow(); await resources.close(); expect(close.calls()).toBe(3); @@ -135,7 +167,7 @@ it("preserves Git metadata cleanup failure and the completed source read", async const bytes = "export const value = 1;\n"; await writeFile(source, bytes); await writeFile(join(sourceRoot, ".git"), "gitdir: ../repository/.git\n"); - const head = join(sourceRoot, ".git"); + const head = join(repository, ".git", "HEAD"); const resources = new ArtifactResourceScope(); const close = failCloseUntilAllowed(head); onTestFinished(async () => {