Skip to content
Merged
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
121 changes: 78 additions & 43 deletions src/git/fetch-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,37 +283,65 @@ 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;
}
const patternHasGlob = (pattern: string) =>
pattern.includes("*") || pattern.includes("?") || pattern.includes("[");

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

const isDirectoryLiteral = (pattern: string) => pattern.endsWith("/");

const toNoConePattern = (pattern: string) => {
if (!patternHasGlob(pattern) && isDirectoryLiteral(pattern)) {
return pattern.endsWith("/") ? pattern : `${pattern}/`;
}
return true;
return pattern;
};

const extractSparsePaths = (include?: string[]) => {
if (!include) {
return [];
type SparseSpec =
| { enabled: false; mode: "cone"; patterns: string[] }
| { enabled: true; mode: "cone" | "no-cone"; patterns: string[] };

const resolveSparseSpec = (include?: string[]): SparseSpec => {
const normalized = normalizeSparsePatterns(include);
if (normalized.length === 0) {
return { enabled: false, mode: "cone", patterns: [] };
}
const paths = include.map((pattern) => {
const normalized = pattern.replace(/\\/g, "/");
const starIndex = normalized.indexOf("*");
const base = starIndex === -1 ? normalized : normalized.slice(0, starIndex);
return base.replace(/\/+$|\/$/, "");
});
return Array.from(new Set(paths.filter((value) => value.length > 0)));
const conePaths: string[] = [];
let coneEligible = true;
for (const pattern of normalized) {
if (pattern.includes("**")) {
coneEligible = false;
break;
}
if (patternHasGlob(pattern)) {
coneEligible = false;
break;
}
if (isDirectoryLiteral(pattern)) {
conePaths.push(pattern.replace(/\/+$/, ""));
continue;
}
coneEligible = false;
break;
}
const uniquePaths = Array.from(new Set(conePaths.filter(Boolean)));
if (coneEligible && uniquePaths.length > 0) {
return { enabled: true, mode: "cone", patterns: uniquePaths };
}
return {
enabled: true,
mode: "no-cone",
patterns: normalized.map(toNoConePattern),
};
};

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 +354,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 +375,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 +424,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 +551,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 +587,7 @@ const cloneOrUpdateRepo = async (
localCloneArgs.splice(2, 0, "--filter=blob:none");
}

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

Expand All @@ -575,15 +608,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
182 changes: 182 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,188 @@ 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 treats extensionless include patterns as files", async () => {
const tmpRoot = path.join(
tmpdir(),
`docs-cache-git-extensionless-${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: ["LICENSE"],
},
],
};
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("LICENSE"),
"expected extensionless file include pattern",
);
assert.ok(
!sparse.includes("LICENSE/"),
"expected LICENSE not to be treated as directory",
);
} 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