Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
98 changes: 55 additions & 43 deletions src/git/fetch-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,37 +283,42 @@ type CloneResult = {
cleanup: () => Promise<void>;
};

const isSparseEligible = (include?: string[]) => {
if (!include || include.length === 0) {
return false;
}
for (const pattern of include) {
if (!pattern || pattern.includes("**")) {
return false;
}
}
return true;
};
const patternHasGlob = (pattern: string) =>
pattern.includes("*") || pattern.includes("?") || pattern.includes("[");

const normalizeSparsePatterns = (include?: string[]) =>
(include ?? []).map((pattern) => pattern.replace(/\\/g, "/")).filter(Boolean);

const extractSparsePaths = (include?: string[]) => {
if (!include) {
return [];
const resolveSparseSpec = (include?: string[]) => {
const normalized = normalizeSparsePatterns(include);
if (normalized.length === 0) {
return { enabled: false, mode: "cone" as const, patterns: [] as string[] };
}
const paths = include.map((pattern) => {
const normalized = pattern.replace(/\\/g, "/");
const starIndex = normalized.indexOf("*");
const base = starIndex === -1 ? normalized : normalized.slice(0, starIndex);
const hasDoubleStar = normalized.some((pattern) => pattern.includes("**"));
const hasLiteral = normalized.some((pattern) => !patternHasGlob(pattern));
if (hasDoubleStar || hasLiteral) {
return { enabled: true, mode: "no-cone" as const, patterns: normalized };
Comment thread
fbosch marked this conversation as resolved.
Outdated
}
const paths = normalized.map((pattern) => {
const starIndex = pattern.indexOf("*");
const base = starIndex === -1 ? pattern : pattern.slice(0, starIndex);
return base.replace(/\/+$|\/$/, "");
Comment thread
fbosch marked this conversation as resolved.
Outdated
});
return Array.from(new Set(paths.filter((value) => value.length > 0)));
const uniquePaths = Array.from(
new Set(paths.filter((value) => value.length > 0)),
);
Comment thread
fbosch marked this conversation as resolved.
Outdated
if (uniquePaths.length === 0) {
return { enabled: true, mode: "no-cone" as const, patterns: normalized };
}
return { enabled: true, mode: "cone" as const, patterns: uniquePaths };
};

const cloneRepo = async (params: FetchParams, outDir: string) => {
if (params.offline) {
throw new Error(`Cannot clone ${params.repo} while offline.`);
}
const isCommitRef = /^[0-9a-f]{7,40}$/i.test(params.ref);
const useSparse = isSparseEligible(params.include);
const sparseSpec = resolveSparseSpec(params.include);
const buildCloneArgs = () => {
const cloneArgs = [
"clone",
Expand All @@ -326,7 +331,7 @@ const cloneRepo = async (params: FetchParams, outDir: string) => {
return cloneArgs;
};
const cloneArgs = buildCloneArgs();
if (useSparse) {
if (sparseSpec.enabled) {
cloneArgs.push("--sparse");
}
if (!isCommitRef) {
Expand All @@ -347,14 +352,16 @@ const cloneRepo = async (params: FetchParams, outDir: string) => {
logger: params.logger,
offline: params.offline,
});
if (useSparse) {
const sparsePaths = extractSparsePaths(params.include);
if (sparsePaths.length > 0) {
await git(["-C", outDir, "sparse-checkout", "set", ...sparsePaths], {
timeoutMs: params.timeoutMs,
logger: params.logger,
});
if (sparseSpec.enabled) {
const sparseArgs = ["-C", outDir, "sparse-checkout", "set"];
if (sparseSpec.mode === "no-cone") {
sparseArgs.push("--no-cone");
}
sparseArgs.push(...sparseSpec.patterns);
await git(sparseArgs, {
timeoutMs: params.timeoutMs,
logger: params.logger,
});
}
await git(
["-C", outDir, "checkout", "--quiet", "--detach", params.resolvedCommit],
Expand Down Expand Up @@ -394,11 +401,14 @@ const addWorktreeFromCache = async (
allowFileProtocol: true,
},
);
const sparsePaths = isSparseEligible(params.include)
? extractSparsePaths(params.include)
: [];
if (sparsePaths.length > 0) {
await git(["-C", outDir, "sparse-checkout", "set", ...sparsePaths], {
const sparseSpec = resolveSparseSpec(params.include);
if (sparseSpec.enabled) {
const sparseArgs = ["-C", outDir, "sparse-checkout", "set"];
if (sparseSpec.mode === "no-cone") {
sparseArgs.push("--no-cone");
}
sparseArgs.push(...sparseSpec.patterns);
await git(sparseArgs, {
timeoutMs: params.timeoutMs,
logger: params.logger,
allowFileProtocol: true,
Expand Down Expand Up @@ -518,7 +528,7 @@ const cloneOrUpdateRepo = async (
const cacheExists = await exists(cachePath);
const cacheValid = cacheExists && (await isValidGitRepo(cachePath));
const isCommitRef = /^[0-9a-f]{7,40}$/i.test(params.ref);
const useSparse = isSparseEligible(params.include);
const sparseSpec = resolveSparseSpec(params.include);
let usedCache = cacheValid;
let worktreeUsed = false;

Expand Down Expand Up @@ -554,7 +564,7 @@ const cloneOrUpdateRepo = async (
localCloneArgs.splice(2, 0, "--filter=blob:none");
}

if (useSparse) {
if (sparseSpec.enabled) {
localCloneArgs.push("--sparse");
}

Expand All @@ -575,15 +585,17 @@ const cloneOrUpdateRepo = async (
forceProgress: Boolean(params.progressLogger),
});

if (useSparse) {
const sparsePaths = extractSparsePaths(params.include);
if (sparsePaths.length > 0) {
await git(["-C", outDir, "sparse-checkout", "set", ...sparsePaths], {
timeoutMs: params.timeoutMs,
allowFileProtocol: true,
logger: params.logger,
});
if (sparseSpec.enabled) {
const sparseArgs = ["-C", outDir, "sparse-checkout", "set"];
if (sparseSpec.mode === "no-cone") {
sparseArgs.push("--no-cone");
}
sparseArgs.push(...sparseSpec.patterns);
await git(sparseArgs, {
timeoutMs: params.timeoutMs,
allowFileProtocol: true,
logger: params.logger,
});
}

await ensureCommitAvailable(outDir, params.resolvedCommit, {
Expand Down
91 changes: 91 additions & 0 deletions tests/fetch-source-file-protocol.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,97 @@ test("sync uses file protocol allowlist for local cache checkout", async () => {
}
});

test("sync uses no-cone sparse for mixed include patterns", async () => {
const tmpRoot = path.join(
tmpdir(),
`docs-cache-git-protocol-${Date.now().toString(36)}`,
);
const binDir = path.join(tmpRoot, "bin");
const logPath = path.join(tmpRoot, "git.log");
const cacheDir = path.join(tmpRoot, ".docs");
const configPath = path.join(tmpRoot, "docs.config.json");
const gitCacheRoot = path.join(tmpRoot, "git-cache");
const repo = "https://example.com/repo.git";
const repoHash = hashRepoUrl(repo);
const cachePath = path.join(gitCacheRoot, repoHash);

await mkdir(binDir, { recursive: true });
await mkdir(cachePath, { recursive: true });
await writeGitShim(binDir, logPath);
await writeFile(logPath, "", "utf8");

const config = {
$schema:
"https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json",
sources: [
{
id: "local",
repo,
include: ["Configuration.md", "**/others/*.md"],
},
],
};
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");

const previousPath = process.env.PATH ?? process.env.Path;
const previousPathExt = process.env.PATHEXT;
const previousGitDir = process.env.DOCS_CACHE_GIT_DIR;
const nextPath =
process.platform === "win32"
? binDir
: `${binDir}${path.delimiter}${previousPath ?? ""}`;
process.env.PATH = nextPath;
process.env.Path = nextPath;
if (process.platform === "win32") {
process.env.PATHEXT = previousPathExt ?? ".COM;.EXE;.BAT;.CMD";
}
process.env.DOCS_CACHE_GIT_DIR = gitCacheRoot;
process.env.GIT_TERMINAL_PROMPT = "0";

try {
await runSync(
{
configPath,
cacheDirOverride: cacheDir,
json: false,
lockOnly: false,
offline: false,
failOnMiss: false,
},
{
resolveRemoteCommit: async () => ({
repo,
ref: "HEAD",
resolvedCommit: "abc123",
}),
},
);

const logRaw = await readFile(logPath, "utf8");
const entries = logRaw
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
const sparse = entries.find((args) => args.includes("sparse-checkout"));
assert.ok(sparse, "expected sparse-checkout to run via git shim");
assert.ok(sparse.includes("--no-cone"), "expected no-cone sparse mode");
assert.ok(
sparse.includes("Configuration.md"),
"expected literal include pattern",
);
assert.ok(
sparse.includes("**/others/*.md"),
"expected mixed glob include pattern",
);
} finally {
process.env.PATH = previousPath;
process.env.Path = previousPath;
process.env.PATHEXT = previousPathExt;
process.env.DOCS_CACHE_GIT_DIR = previousGitDir;
await rm(tmpRoot, { recursive: true, force: true });
}
});

test("sync fetches missing commit from local cache", async () => {
const tmpRoot = path.join(
tmpdir(),
Expand Down
Loading