From 537f60c2eabe0766c3dd42e75283bff6186b6d23 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:59:48 +0100 Subject: [PATCH 1/4] fix(dest-handling): prevent concurrent clones --- src/git/fetch-source.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/git/fetch-source.ts b/src/git/fetch-source.ts index 483290d..ce6e86b 100644 --- a/src/git/fetch-source.ts +++ b/src/git/fetch-source.ts @@ -561,12 +561,37 @@ const handleMissingCache = async ( return { usedCache: false, worktreeUsed: false }; }; +const cloneOrUpdateInFlight = new Map>(); + // Clone or update a repository using persistent cache -const cloneOrUpdateRepo = async ( +const cloneOrUpdateRepo = ( params: FetchParams, outDir: string, ): Promise => { const cachePath = getPersistentCachePath(params.repo); + const inflight = cloneOrUpdateInFlight.get(cachePath); + if (inflight !== undefined) { + return inflight.then( + () => cloneOrUpdateRepo(params, outDir), + () => cloneOrUpdateRepo(params, outDir), + ); + } + const promise = (async () => { + try { + return await cloneOrUpdateRepoImpl(params, outDir, cachePath); + } finally { + cloneOrUpdateInFlight.delete(cachePath); + } + })(); + cloneOrUpdateInFlight.set(cachePath, promise); + return promise; +}; + +const cloneOrUpdateRepoImpl = async ( + params: FetchParams, + outDir: string, + cachePath: string, +): Promise => { const cacheExists = await exists(cachePath); const cacheValid = cacheExists && (await isValidGitRepo(cachePath)); const isCommitRef = /^[0-9a-f]{7,40}$/i.test(params.ref); From cf1127aa7e8908329126a92b7fd8d0767336a48d Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:01:32 +0100 Subject: [PATCH 2/4] fix(dest-handling): override minimatch version --- pnpm-lock.yaml | 33 +++++++++++++++++++-------------- pnpm-workspace.yaml | 2 ++ 2 files changed, 21 insertions(+), 14 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0160ac1..9a49f7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + minimatch@<10.2.1: '>=10.2.1' + importers: .: @@ -582,8 +585,9 @@ packages: peerDependencies: postcss: ^8.1.0 - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} @@ -592,8 +596,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.3: + resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1059,9 +1064,9 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} + minimatch@10.2.2: + resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + engines: {node: 18 || 20 || >=22} minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} @@ -1993,15 +1998,15 @@ snapshots: postcss: 8.5.6 postcss-value-parser: 4.2.0 - balanced-match@1.0.2: {} + balanced-match@4.0.4: {} baseline-browser-mapping@2.9.19: {} boolbase@1.0.0: {} - brace-expansion@2.0.2: + brace-expansion@5.0.3: dependencies: - balanced-match: 1.0.2 + balanced-match: 4.0.4 braces@3.0.3: dependencies: @@ -2372,7 +2377,7 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 9.0.5 + minimatch: 10.2.2 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -2523,9 +2528,9 @@ snapshots: mimic-function@5.0.1: {} - minimatch@9.0.5: + minimatch@10.2.2: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 5.0.3 minipass@7.1.2: {} @@ -2981,7 +2986,7 @@ snapshots: dependencies: '@istanbuljs/schema': 0.1.3 glob: 10.5.0 - minimatch: 9.0.5 + minimatch: 10.2.2 tinybench@6.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..8db3f83 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +overrides: + minimatch@<10.2.1: '>=10.2.1' From 01d895d5b3c7b108ac79d699f326a852d82839f5 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:39:34 +0100 Subject: [PATCH 3/4] fix(dest-handling): prevent multiple clones --- src/git/fetch-source.ts | 5 +- tests/fetch-source-file-protocol.test.js | 79 ++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/git/fetch-source.ts b/src/git/fetch-source.ts index ce6e86b..66376b0 100644 --- a/src/git/fetch-source.ts +++ b/src/git/fetch-source.ts @@ -571,10 +571,7 @@ const cloneOrUpdateRepo = ( const cachePath = getPersistentCachePath(params.repo); const inflight = cloneOrUpdateInFlight.get(cachePath); if (inflight !== undefined) { - return inflight.then( - () => cloneOrUpdateRepo(params, outDir), - () => cloneOrUpdateRepo(params, outDir), - ); + return inflight.then(() => cloneOrUpdateRepo(params, outDir)); } const promise = (async () => { try { diff --git a/tests/fetch-source-file-protocol.test.js b/tests/fetch-source-file-protocol.test.js index 43dc6c2..c80c150 100644 --- a/tests/fetch-source-file-protocol.test.js +++ b/tests/fetch-source-file-protocol.test.js @@ -422,3 +422,82 @@ test("sync fetches missing commit from local cache", async () => { await rm(tmpRoot, { recursive: true, force: true }); } }); + +test("concurrent syncs for the same repo clone only once", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-concurrent-${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/concurrent-repo.git"; + + await mkdir(binDir, { 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: "a", repo, include: ["docs"] }, + { id: "b", repo, include: ["docs"] }, + ], + }; + 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, + concurrency: 2, + }, + { + 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 clones = entries.filter( + (args) => args.includes("clone") && args.includes(repo), + ); + assert.equal(clones.length, 1, "expected repo to be cloned exactly once"); + } 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 }); + } +}); From 076cd838d64a11fb3cf20e7340772e3e99828cb1 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:53:34 +0100 Subject: [PATCH 4/4] fix(dest-handling): handle more concurrent syncs --- tests/fetch-source-file-protocol.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/fetch-source-file-protocol.test.js b/tests/fetch-source-file-protocol.test.js index c80c150..abcabef 100644 --- a/tests/fetch-source-file-protocol.test.js +++ b/tests/fetch-source-file-protocol.test.js @@ -445,6 +445,7 @@ test("concurrent syncs for the same repo clone only once", async () => { sources: [ { id: "a", repo, include: ["docs"] }, { id: "b", repo, include: ["docs"] }, + { id: "c", repo, include: ["docs"] }, ], }; await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); @@ -473,7 +474,7 @@ test("concurrent syncs for the same repo clone only once", async () => { lockOnly: false, offline: false, failOnMiss: false, - concurrency: 2, + concurrency: 3, }, { resolveRemoteCommit: async () => ({