From 52476ed0b5292b3094686bf5be4eb37429e758a8 Mon Sep 17 00:00:00 2001 From: shixi-li Date: Tue, 28 Jul 2026 17:57:25 +0800 Subject: [PATCH] feat(loop-worktree): expose public lock API --- RELEASE_NOTES_DRAFT.md | 2 +- tools/loop-worktree/README.md | 13 ++ tools/loop-worktree/package-lock.json | 4 +- tools/loop-worktree/package.json | 23 ++- tools/loop-worktree/test/lock.test.mjs | 14 +- .../test/package-exports.test.mjs | 137 ++++++++++++++++++ 6 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 tools/loop-worktree/test/package-exports.test.mjs diff --git a/RELEASE_NOTES_DRAFT.md b/RELEASE_NOTES_DRAFT.md index b81406b3..17456a34 100644 --- a/RELEASE_NOTES_DRAFT.md +++ b/RELEASE_NOTES_DRAFT.md @@ -71,7 +71,7 @@ npx @cobusgreyling/loop-worktree list | `@cobusgreyling/loop-context` | **1.5.0** | `loop-context-v1.5.0` | **Published** | | `@cobusgreyling/loop-audit` | 1.7.0 | — | No change | | `@cobusgreyling/loop-init` | 1.5.0 | — | No change | -| `@cobusgreyling/loop-worktree` | 1.2.0 | — | Already published | +| `@cobusgreyling/loop-worktree` | 1.2.0 | `loop-worktree-v1.3.0` | Publish 1.3.0 after merge (public `./lock` subpath) | | `@cobusgreyling/loop-gate` | 1.0.0 | — | Already published | | `@cobusgreyling/goal-init` | 1.0.0 | — | Already published | | `@cobusgreyling/readiness-core` | 1.0.0 | — | Already published | diff --git a/tools/loop-worktree/README.md b/tools/loop-worktree/README.md index 2e0ba248..198c09a7 100644 --- a/tools/loop-worktree/README.md +++ b/tools/loop-worktree/README.md @@ -19,6 +19,19 @@ npm install npm test ``` +## Library API + +ESM consumers can use the worktree API from the package root and the lock +coordinator from its supported `lock` subpath: + +```js +import { createWorktree, cleanupWorktrees } from '@cobusgreyling/loop-worktree'; +import { lockPaths, unlockOwner } from '@cobusgreyling/loop-worktree/lock'; +``` + +The `lock` subpath is the stable programmatic entry point for tools that need +advisory path locking without importing private files under `dist/`. + ## Commands ```bash diff --git a/tools/loop-worktree/package-lock.json b/tools/loop-worktree/package-lock.json index b586b43f..b00b230c 100644 --- a/tools/loop-worktree/package-lock.json +++ b/tools/loop-worktree/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cobusgreyling/loop-worktree", - "version": "1.1.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cobusgreyling/loop-worktree", - "version": "1.1.0", + "version": "1.3.0", "license": "MIT", "bin": { "loop-worktree": "dist/cli.js" diff --git a/tools/loop-worktree/package.json b/tools/loop-worktree/package.json index c6441c4b..cfb7c1ae 100644 --- a/tools/loop-worktree/package.json +++ b/tools/loop-worktree/package.json @@ -1,6 +1,6 @@ { "name": "@cobusgreyling/loop-worktree", - "version": "1.2.0", + "version": "1.3.0", "description": "Manage isolated git worktrees for loop engineering attempts: create, mark, cleanup, and reconcile one worktree per fix attempt.", "type": "module", "bin": { @@ -8,6 +8,27 @@ }, "main": "dist/worktree.js", "types": "dist/worktree.d.ts", + "typesVersions": { + "*": { + "lock": [ + "dist/lock.d.ts" + ] + } + }, + "exports": { + ".": { + "types": "./dist/worktree.d.ts", + "import": "./dist/worktree.js", + "default": "./dist/worktree.js" + }, + "./lock": { + "types": "./dist/lock.d.ts", + "import": "./dist/lock.js", + "default": "./dist/lock.js" + }, + "./dist/*": "./dist/*", + "./package.json": "./package.json" + }, "files": [ "dist", "README.md" diff --git a/tools/loop-worktree/test/lock.test.mjs b/tools/loop-worktree/test/lock.test.mjs index 24c4717b..4ced1bb4 100644 --- a/tools/loop-worktree/test/lock.test.mjs +++ b/tools/loop-worktree/test/lock.test.mjs @@ -4,7 +4,14 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { lockPaths, unlockOwner, listLocks, sweepExpiredLocks, pathsOverlap, isExpired } from '../dist/lock.js'; +import { + lockPaths, + unlockOwner, + listLocks, + sweepExpiredLocks, + pathsOverlap, + isExpired, +} from '@cobusgreyling/loop-worktree/lock'; async function freshDir() { return mkdtemp(path.join(tmpdir(), 'loop-worktree-lock-')); @@ -166,7 +173,7 @@ test('lockPaths with --wait queues and acquires lock when released', async () => // Ensure the wait intent is written await new Promise(r => setTimeout(r, 50)); - const { listWaits } = await import('../dist/lock.js'); + const { listWaits } = await import('@cobusgreyling/loop-worktree/lock'); const waits = await listWaits(dir); assert.equal(waits.length, 1); assert.equal(waits[0].owner, 'dependency-sweeper'); @@ -194,7 +201,7 @@ test('lockPaths with --wait times out', async () => { ); assert.ok(Date.now() - start >= 1000); - const { listWaits } = await import('../dist/lock.js'); + const { listWaits } = await import('@cobusgreyling/loop-worktree/lock'); assert.equal((await listWaits(dir)).length, 0); }); @@ -221,4 +228,3 @@ test('lockPaths deadlock detection aborts cycle immediately', async () => { await unlockOwner(dir, 'B'); await pA; }); - diff --git a/tools/loop-worktree/test/package-exports.test.mjs b/tools/loop-worktree/test/package-exports.test.mjs new file mode 100644 index 00000000..502624e3 --- /dev/null +++ b/tools/loop-worktree/test/package-exports.test.mjs @@ -0,0 +1,137 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +const run = promisify(execFile); +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const npmCli = process.env.npm_execpath; + +async function runNpm(args, options) { + assert.ok(npmCli, 'package export tests must be run through npm'); + return run(process.execPath, [npmCli, ...args], options); +} + +async function withTempDir(callback) { + const temp = await mkdtemp(path.join(tmpdir(), 'loop-worktree-package-')); + try { + return await callback(temp); + } finally { + await rm(temp, { recursive: true, force: true }); + } +} + +test('packed package exposes the public lock subpath and keeps legacy deep imports', async () => withTempDir(async (temp) => { + const npmCache = path.join(temp, 'npm-cache'); + + const { stdout } = await runNpm( + [ + 'pack', + '--json', + '--ignore-scripts', + '--cache', + npmCache, + '--pack-destination', + temp, + ], + { cwd: packageRoot, maxBuffer: 10 * 1024 * 1024 }, + ); + const packed = JSON.parse(stdout); + assert.equal(packed.length, 1); + assert.ok( + packed[0].files.some((file) => file.path === 'dist/lock.d.ts'), + 'packed lock subpath should include its declarations', + ); + + const consumer = path.join(temp, 'consumer'); + await mkdir(consumer); + await writeFile( + path.join(consumer, 'package.json'), + `${JSON.stringify({ private: true, type: 'module' }, null, 2)}\n`, + ); + await runNpm( + [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--cache', + npmCache, + path.join(temp, packed[0].filename), + ], + { cwd: consumer, maxBuffer: 10 * 1024 * 1024 }, + ); + + const probe = ` + import { createWorktree } from '@cobusgreyling/loop-worktree'; + import { + LOCKS_DIR, + lockPaths as publicLockPaths, + } from '@cobusgreyling/loop-worktree/lock'; + import { + lockPaths as legacyLockPaths, + } from '@cobusgreyling/loop-worktree/dist/lock.js'; + + if (typeof createWorktree !== 'function') throw new Error('root export missing'); + if (typeof publicLockPaths !== 'function') throw new Error('public lock export missing'); + if (publicLockPaths !== legacyLockPaths) throw new Error('public and legacy exports diverged'); + if (LOCKS_DIR !== '.loop-worktrees/locks') throw new Error('lock constants missing'); + `; + const probeFile = path.join(consumer, 'probe.mjs'); + await writeFile(probeFile, probe); + await run(process.execPath, [probeFile], { + cwd: consumer, + maxBuffer: 10 * 1024 * 1024, + }); + + const typeProbeFile = path.join(consumer, 'probe.ts'); + await writeFile( + typeProbeFile, + ` + import type { LockPathsInput } from '@cobusgreyling/loop-worktree/lock'; + + const input: LockPathsInput = { + root: '.', + owner: 'consumer', + paths: ['src/**'], + }; + void input; + `, + ); + await run( + process.execPath, + [ + path.join(packageRoot, 'node_modules', 'typescript', 'bin', 'tsc'), + '--noEmit', + '--strict', + '--module', + 'NodeNext', + '--moduleResolution', + 'NodeNext', + '--target', + 'ES2022', + typeProbeFile, + ], + { cwd: consumer, maxBuffer: 10 * 1024 * 1024 }, + ); + await run( + process.execPath, + [ + path.join(packageRoot, 'node_modules', 'typescript', 'bin', 'tsc'), + '--noEmit', + '--strict', + '--module', + 'ES2020', + '--moduleResolution', + 'Node', + '--target', + 'ES2020', + typeProbeFile, + ], + { cwd: consumer, maxBuffer: 10 * 1024 * 1024 }, + ); +}));