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' diff --git a/src/git/fetch-source.ts b/src/git/fetch-source.ts index 483290d..66376b0 100644 --- a/src/git/fetch-source.ts +++ b/src/git/fetch-source.ts @@ -561,12 +561,34 @@ 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)); + } + 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); diff --git a/tests/fetch-source-file-protocol.test.js b/tests/fetch-source-file-protocol.test.js index 43dc6c2..abcabef 100644 --- a/tests/fetch-source-file-protocol.test.js +++ b/tests/fetch-source-file-protocol.test.js @@ -422,3 +422,83 @@ 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"] }, + { id: "c", 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: 3, + }, + { + 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 }); + } +});