mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(reference): scope ignore rules and honor local Git case policy
This commit is contained in:
+11
-2
@@ -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;
|
||||
|
||||
@@ -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<string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReferenceSourceGitDirectory | undefined> => {
|
||||
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;
|
||||
@@ -119,7 +119,7 @@ const createShouldExclude =
|
||||
: kind === "unknown"
|
||||
? [path, `${path}/`]
|
||||
: [path];
|
||||
const firstMatch = (matcher: typeof secrets) => {
|
||||
const firstMatch = (matcher: Pick<typeof secrets, "test">) => {
|
||||
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),
|
||||
]);
|
||||
|
||||
@@ -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<typeof ignore>;
|
||||
readonly ignored: Pick<ReturnType<typeof ignore>, "test">;
|
||||
readonly beforeDirectory: (
|
||||
path: string,
|
||||
) => Promise<Result<undefined, ReferenceSourceReaderError>>;
|
||||
readonly secrets: ReturnType<typeof ignore>;
|
||||
}
|
||||
|
||||
@@ -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<Result<string | undefined, ReferenceSourceImportError>> => {
|
||||
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<Result<boolean, ReferenceSourceImportError>> => {
|
||||
let readFailure: ReferenceSourceImportError | undefined;
|
||||
const readText = async (path: string): Promise<string> => {
|
||||
// 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<Result<ReturnType<typeof ignore>, 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<PreparedReferenceSourceImport, "ignored" | "beforeDirectory">,
|
||||
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<string, ReturnType<typeof ignore>>();
|
||||
const loadDirectory = async (
|
||||
path: string,
|
||||
): Promise<Result<undefined, ReferenceSourceImportError>> => {
|
||||
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) {
|
||||
|
||||
@@ -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<Result<ReferenceSourceVcsInfo, ReferenceSourceReaderError>> => {
|
||||
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<string> => {
|
||||
): Promise<ReferenceSourceVcsInfo> => {
|
||||
// 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 =>
|
||||
|
||||
@@ -60,6 +60,9 @@ const readSource = async (
|
||||
...(options.shouldExclude === undefined
|
||||
? {}
|
||||
: { shouldExclude: options.shouldExclude }),
|
||||
...(options.beforeDirectory === undefined
|
||||
? {}
|
||||
: { beforeDirectory: options.beforeDirectory }),
|
||||
entries: [],
|
||||
exclusions: [],
|
||||
pending: [{ path: canonicalRoot }],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ReferenceSourceResult<undefined>>;
|
||||
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<ReferenceSourceResult<undefined>>;
|
||||
readonly shouldExclude?: (
|
||||
path: string,
|
||||
kind: ReferenceSourceExclusionKind,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user