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
2 changes: 1 addition & 1 deletion RELEASE_NOTES_DRAFT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
13 changes: 13 additions & 0 deletions tools/loop-worktree/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tools/loop-worktree/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 22 additions & 1 deletion tools/loop-worktree/package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
{
"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": {
"loop-worktree": "dist/cli.js"
},
"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"
Expand Down
14 changes: 10 additions & 4 deletions tools/loop-worktree/test/lock.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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-'));
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);
});

Expand All @@ -221,4 +228,3 @@ test('lockPaths deadlock detection aborts cycle immediately', async () => {
await unlockOwner(dir, 'B');
await pA;
});

137 changes: 137 additions & 0 deletions tools/loop-worktree/test/package-exports.test.mjs
Original file line number Diff line number Diff line change
@@ -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 },
);
}));