Skip to content

Commit d5d3bb2

Browse files
committed
feat(git): add no-cone support for sparse patterns
1 parent dda1fd3 commit d5d3bb2

2 files changed

Lines changed: 146 additions & 43 deletions

File tree

src/git/fetch-source.ts

Lines changed: 55 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -283,37 +283,42 @@ type CloneResult = {
283283
cleanup: () => Promise<void>;
284284
};
285285

286-
const isSparseEligible = (include?: string[]) => {
287-
if (!include || include.length === 0) {
288-
return false;
289-
}
290-
for (const pattern of include) {
291-
if (!pattern || pattern.includes("**")) {
292-
return false;
293-
}
294-
}
295-
return true;
296-
};
286+
const patternHasGlob = (pattern: string) =>
287+
pattern.includes("*") || pattern.includes("?") || pattern.includes("[");
288+
289+
const normalizeSparsePatterns = (include?: string[]) =>
290+
(include ?? []).map((pattern) => pattern.replace(/\\/g, "/")).filter(Boolean);
297291

298-
const extractSparsePaths = (include?: string[]) => {
299-
if (!include) {
300-
return [];
292+
const resolveSparseSpec = (include?: string[]) => {
293+
const normalized = normalizeSparsePatterns(include);
294+
if (normalized.length === 0) {
295+
return { enabled: false, mode: "cone" as const, patterns: [] as string[] };
301296
}
302-
const paths = include.map((pattern) => {
303-
const normalized = pattern.replace(/\\/g, "/");
304-
const starIndex = normalized.indexOf("*");
305-
const base = starIndex === -1 ? normalized : normalized.slice(0, starIndex);
297+
const hasDoubleStar = normalized.some((pattern) => pattern.includes("**"));
298+
const hasLiteral = normalized.some((pattern) => !patternHasGlob(pattern));
299+
if (hasDoubleStar || hasLiteral) {
300+
return { enabled: true, mode: "no-cone" as const, patterns: normalized };
301+
}
302+
const paths = normalized.map((pattern) => {
303+
const starIndex = pattern.indexOf("*");
304+
const base = starIndex === -1 ? pattern : pattern.slice(0, starIndex);
306305
return base.replace(/\/+$|\/$/, "");
307306
});
308-
return Array.from(new Set(paths.filter((value) => value.length > 0)));
307+
const uniquePaths = Array.from(
308+
new Set(paths.filter((value) => value.length > 0)),
309+
);
310+
if (uniquePaths.length === 0) {
311+
return { enabled: true, mode: "no-cone" as const, patterns: normalized };
312+
}
313+
return { enabled: true, mode: "cone" as const, patterns: uniquePaths };
309314
};
310315

311316
const cloneRepo = async (params: FetchParams, outDir: string) => {
312317
if (params.offline) {
313318
throw new Error(`Cannot clone ${params.repo} while offline.`);
314319
}
315320
const isCommitRef = /^[0-9a-f]{7,40}$/i.test(params.ref);
316-
const useSparse = isSparseEligible(params.include);
321+
const sparseSpec = resolveSparseSpec(params.include);
317322
const buildCloneArgs = () => {
318323
const cloneArgs = [
319324
"clone",
@@ -326,7 +331,7 @@ const cloneRepo = async (params: FetchParams, outDir: string) => {
326331
return cloneArgs;
327332
};
328333
const cloneArgs = buildCloneArgs();
329-
if (useSparse) {
334+
if (sparseSpec.enabled) {
330335
cloneArgs.push("--sparse");
331336
}
332337
if (!isCommitRef) {
@@ -347,14 +352,16 @@ const cloneRepo = async (params: FetchParams, outDir: string) => {
347352
logger: params.logger,
348353
offline: params.offline,
349354
});
350-
if (useSparse) {
351-
const sparsePaths = extractSparsePaths(params.include);
352-
if (sparsePaths.length > 0) {
353-
await git(["-C", outDir, "sparse-checkout", "set", ...sparsePaths], {
354-
timeoutMs: params.timeoutMs,
355-
logger: params.logger,
356-
});
355+
if (sparseSpec.enabled) {
356+
const sparseArgs = ["-C", outDir, "sparse-checkout", "set"];
357+
if (sparseSpec.mode === "no-cone") {
358+
sparseArgs.push("--no-cone");
357359
}
360+
sparseArgs.push(...sparseSpec.patterns);
361+
await git(sparseArgs, {
362+
timeoutMs: params.timeoutMs,
363+
logger: params.logger,
364+
});
358365
}
359366
await git(
360367
["-C", outDir, "checkout", "--quiet", "--detach", params.resolvedCommit],
@@ -394,11 +401,14 @@ const addWorktreeFromCache = async (
394401
allowFileProtocol: true,
395402
},
396403
);
397-
const sparsePaths = isSparseEligible(params.include)
398-
? extractSparsePaths(params.include)
399-
: [];
400-
if (sparsePaths.length > 0) {
401-
await git(["-C", outDir, "sparse-checkout", "set", ...sparsePaths], {
404+
const sparseSpec = resolveSparseSpec(params.include);
405+
if (sparseSpec.enabled) {
406+
const sparseArgs = ["-C", outDir, "sparse-checkout", "set"];
407+
if (sparseSpec.mode === "no-cone") {
408+
sparseArgs.push("--no-cone");
409+
}
410+
sparseArgs.push(...sparseSpec.patterns);
411+
await git(sparseArgs, {
402412
timeoutMs: params.timeoutMs,
403413
logger: params.logger,
404414
allowFileProtocol: true,
@@ -518,7 +528,7 @@ const cloneOrUpdateRepo = async (
518528
const cacheExists = await exists(cachePath);
519529
const cacheValid = cacheExists && (await isValidGitRepo(cachePath));
520530
const isCommitRef = /^[0-9a-f]{7,40}$/i.test(params.ref);
521-
const useSparse = isSparseEligible(params.include);
531+
const sparseSpec = resolveSparseSpec(params.include);
522532
let usedCache = cacheValid;
523533
let worktreeUsed = false;
524534

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

557-
if (useSparse) {
567+
if (sparseSpec.enabled) {
558568
localCloneArgs.push("--sparse");
559569
}
560570

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

578-
if (useSparse) {
579-
const sparsePaths = extractSparsePaths(params.include);
580-
if (sparsePaths.length > 0) {
581-
await git(["-C", outDir, "sparse-checkout", "set", ...sparsePaths], {
582-
timeoutMs: params.timeoutMs,
583-
allowFileProtocol: true,
584-
logger: params.logger,
585-
});
588+
if (sparseSpec.enabled) {
589+
const sparseArgs = ["-C", outDir, "sparse-checkout", "set"];
590+
if (sparseSpec.mode === "no-cone") {
591+
sparseArgs.push("--no-cone");
586592
}
593+
sparseArgs.push(...sparseSpec.patterns);
594+
await git(sparseArgs, {
595+
timeoutMs: params.timeoutMs,
596+
allowFileProtocol: true,
597+
logger: params.logger,
598+
});
587599
}
588600

589601
await ensureCommitAvailable(outDir, params.resolvedCommit, {

tests/fetch-source-file-protocol.test.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,97 @@ test("sync uses file protocol allowlist for local cache checkout", async () => {
153153
}
154154
});
155155

156+
test("sync uses no-cone sparse for mixed include patterns", async () => {
157+
const tmpRoot = path.join(
158+
tmpdir(),
159+
`docs-cache-git-protocol-${Date.now().toString(36)}`,
160+
);
161+
const binDir = path.join(tmpRoot, "bin");
162+
const logPath = path.join(tmpRoot, "git.log");
163+
const cacheDir = path.join(tmpRoot, ".docs");
164+
const configPath = path.join(tmpRoot, "docs.config.json");
165+
const gitCacheRoot = path.join(tmpRoot, "git-cache");
166+
const repo = "https://example.com/repo.git";
167+
const repoHash = hashRepoUrl(repo);
168+
const cachePath = path.join(gitCacheRoot, repoHash);
169+
170+
await mkdir(binDir, { recursive: true });
171+
await mkdir(cachePath, { recursive: true });
172+
await writeGitShim(binDir, logPath);
173+
await writeFile(logPath, "", "utf8");
174+
175+
const config = {
176+
$schema:
177+
"https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json",
178+
sources: [
179+
{
180+
id: "local",
181+
repo,
182+
include: ["Configuration.md", "**/others/*.md"],
183+
},
184+
],
185+
};
186+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
187+
188+
const previousPath = process.env.PATH ?? process.env.Path;
189+
const previousPathExt = process.env.PATHEXT;
190+
const previousGitDir = process.env.DOCS_CACHE_GIT_DIR;
191+
const nextPath =
192+
process.platform === "win32"
193+
? binDir
194+
: `${binDir}${path.delimiter}${previousPath ?? ""}`;
195+
process.env.PATH = nextPath;
196+
process.env.Path = nextPath;
197+
if (process.platform === "win32") {
198+
process.env.PATHEXT = previousPathExt ?? ".COM;.EXE;.BAT;.CMD";
199+
}
200+
process.env.DOCS_CACHE_GIT_DIR = gitCacheRoot;
201+
process.env.GIT_TERMINAL_PROMPT = "0";
202+
203+
try {
204+
await runSync(
205+
{
206+
configPath,
207+
cacheDirOverride: cacheDir,
208+
json: false,
209+
lockOnly: false,
210+
offline: false,
211+
failOnMiss: false,
212+
},
213+
{
214+
resolveRemoteCommit: async () => ({
215+
repo,
216+
ref: "HEAD",
217+
resolvedCommit: "abc123",
218+
}),
219+
},
220+
);
221+
222+
const logRaw = await readFile(logPath, "utf8");
223+
const entries = logRaw
224+
.split("\n")
225+
.filter(Boolean)
226+
.map((line) => JSON.parse(line));
227+
const sparse = entries.find((args) => args.includes("sparse-checkout"));
228+
assert.ok(sparse, "expected sparse-checkout to run via git shim");
229+
assert.ok(sparse.includes("--no-cone"), "expected no-cone sparse mode");
230+
assert.ok(
231+
sparse.includes("Configuration.md"),
232+
"expected literal include pattern",
233+
);
234+
assert.ok(
235+
sparse.includes("**/others/*.md"),
236+
"expected mixed glob include pattern",
237+
);
238+
} finally {
239+
process.env.PATH = previousPath;
240+
process.env.Path = previousPath;
241+
process.env.PATHEXT = previousPathExt;
242+
process.env.DOCS_CACHE_GIT_DIR = previousGitDir;
243+
await rm(tmpRoot, { recursive: true, force: true });
244+
}
245+
});
246+
156247
test("sync fetches missing commit from local cache", async () => {
157248
const tmpRoot = path.join(
158249
tmpdir(),

0 commit comments

Comments
 (0)