@wipcomputer/wip-repos 1.9.69 → 1.9.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/SKILL.md +6 -2
- package/cli.mjs +79 -1
- package/core.mjs +142 -0
- package/mcp-server.mjs +27 -1
- package/package.json +1 -1
- package/test.mjs +38 -3
package/README.md
CHANGED
|
@@ -24,6 +24,10 @@ wip-repos check
|
|
|
24
24
|
wip-repos check --all
|
|
25
25
|
wip-repos check --class worktree
|
|
26
26
|
|
|
27
|
+
# Report release-pipeline enrollment status
|
|
28
|
+
wip-repos release-enrollment
|
|
29
|
+
wip-repos release-enrollment --strict
|
|
30
|
+
|
|
27
31
|
# See what sync would do
|
|
28
32
|
wip-repos sync
|
|
29
33
|
|
|
@@ -46,6 +50,7 @@ wip-repos tree
|
|
|
46
50
|
--manifest Path to repos-manifest.json (default: ./repos-manifest.json)
|
|
47
51
|
--root Path to repos root directory (default: directory containing manifest)
|
|
48
52
|
--dry-run Show what would happen without making changes
|
|
53
|
+
--strict Treat missing release enrollment decisions as failures
|
|
49
54
|
--json Output as JSON
|
|
50
55
|
```
|
|
51
56
|
|
|
@@ -57,10 +62,37 @@ wip-repos tree
|
|
|
57
62
|
|
|
58
63
|
3. **add/move** update the manifest file. The actual folder moves happen on the next `sync`.
|
|
59
64
|
|
|
65
|
+
4. **release-enrollment** reports which active manifest repos are enrolled in the release pipeline, explicitly excluded, still need a release-profile decision, missing on disk, or active on disk but unmanifested. By default, missing repos and unmanifested active repos are blockers. With `--strict`, missing release decisions are blockers too.
|
|
66
|
+
|
|
67
|
+
Release enrollment metadata lives in each manifest entry:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"ldm-os/devops/my-tool-private": {
|
|
72
|
+
"remote": "wipcomputer/my-tool-private",
|
|
73
|
+
"release": {
|
|
74
|
+
"enabled": true,
|
|
75
|
+
"profile": "node-package",
|
|
76
|
+
"smokeProfile": "ldm-tool",
|
|
77
|
+
"publicMirror": "wipcomputer/my-tool",
|
|
78
|
+
"requiredSecrets": ["NPM_TOKEN"]
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"ldm-os/docs/archive": {
|
|
82
|
+
"remote": "wipcomputer/docs-archive",
|
|
83
|
+
"release": {
|
|
84
|
+
"enabled": false,
|
|
85
|
+
"reason": "archived repo"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
60
91
|
## Integration
|
|
61
92
|
|
|
62
93
|
- `deploy-public` and `wip-release` can call `wip-repos check` before running. Stale manifest blocks deploys.
|
|
63
94
|
- CI: run `wip-repos check` as a PR check. Drift = blocked merge.
|
|
95
|
+
- CI/release planning: run `wip-repos release-enrollment --strict --json` to make release-owned repo enrollment machine-readable.
|
|
64
96
|
- README generation: `wip-repos tree` outputs a directory tree from the manifest.
|
|
65
97
|
|
|
66
98
|
## Source
|
package/SKILL.md
CHANGED
|
@@ -39,6 +39,7 @@ Repo manifest reconciler. Like prettier for folder structure. Move folders aroun
|
|
|
39
39
|
**Use wip-repos for:**
|
|
40
40
|
- Checking if the filesystem matches the manifest (`check`)
|
|
41
41
|
- Moving repos to match the manifest (`sync`)
|
|
42
|
+
- Reporting release-pipeline enrollment state (`release-enrollment`)
|
|
42
43
|
- Adding a new repo to the manifest (`add`)
|
|
43
44
|
- Moving a repo in the manifest (`move`)
|
|
44
45
|
- Generating a directory tree from the manifest (`tree`)
|
|
@@ -61,6 +62,8 @@ Repo manifest reconciler. Like prettier for folder structure. Move folders aroun
|
|
|
61
62
|
wip-repos check # diff filesystem vs manifest
|
|
62
63
|
wip-repos check --all # include worktrees/trash/archive paths
|
|
63
64
|
wip-repos check --class worktree # inspect one lifecycle class
|
|
65
|
+
wip-repos release-enrollment # report release enrollment decisions
|
|
66
|
+
wip-repos release-enrollment --strict --json # CI-ready enrollment gate
|
|
64
67
|
wip-repos sync # preview moves
|
|
65
68
|
wip-repos sync --apply # execute safe moves
|
|
66
69
|
wip-repos add ldm-os/utilities/new-tool --remote wipcomputer/new-tool
|
|
@@ -71,9 +74,10 @@ wip-repos tree # generate directory tree
|
|
|
71
74
|
### Module
|
|
72
75
|
|
|
73
76
|
```javascript
|
|
74
|
-
import { check, planSync, addRepo, moveRepo, generateReadmeTree } from '@wipcomputer/wip-repos';
|
|
77
|
+
import { check, releaseEnrollment, planSync, addRepo, moveRepo, generateReadmeTree } from '@wipcomputer/wip-repos';
|
|
75
78
|
|
|
76
79
|
const result = check('/path/to/manifest.json', '/path/to/repos/');
|
|
80
|
+
const enrollment = releaseEnrollment('/path/to/manifest.json', '/path/to/repos/', { strict: true });
|
|
77
81
|
const moves = planSync('/path/to/manifest.json', '/path/to/repos/');
|
|
78
82
|
```
|
|
79
83
|
|
|
@@ -81,4 +85,4 @@ const moves = planSync('/path/to/manifest.json', '/path/to/repos/');
|
|
|
81
85
|
|
|
82
86
|
### MCP
|
|
83
87
|
|
|
84
|
-
Tools: `repos_check`, `repos_sync_plan`, `repos_add`, `repos_move`, `repos_tree`
|
|
88
|
+
Tools: `repos_check`, `repos_release_enrollment`, `repos_sync_plan`, `repos_add`, `repos_move`, `repos_tree`
|
package/cli.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* tree - Generate directory tree from manifest
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { check, planSync, executeSync, addRepo, moveRepo, generateReadmeTree, loadManifest, checkCompliance, fixCompliance, findUnmanifested } from './core.mjs';
|
|
14
|
+
import { check, planSync, executeSync, addRepo, moveRepo, generateReadmeTree, loadManifest, checkCompliance, fixCompliance, findUnmanifested, releaseEnrollment } from './core.mjs';
|
|
15
15
|
import { runClaude } from './claude.mjs';
|
|
16
16
|
import { resolve, dirname, join } from 'node:path';
|
|
17
17
|
import { readFileSync } from 'node:fs';
|
|
@@ -35,6 +35,7 @@ Usage:
|
|
|
35
35
|
wip-repos sync [--manifest path] [--root path] [--apply]
|
|
36
36
|
wip-repos compliance [--manifest path] [--root path] [--fix]
|
|
37
37
|
wip-repos watchdog [--manifest path] [--root path]
|
|
38
|
+
wip-repos release-enrollment [--manifest path] [--root path] [--strict]
|
|
38
39
|
wip-repos add <path> --remote <org/repo> [--category cat] [--description desc]
|
|
39
40
|
wip-repos move <path> --to <new-path>
|
|
40
41
|
wip-repos tree [--manifest path]
|
|
@@ -44,6 +45,8 @@ Commands:
|
|
|
44
45
|
sync Move local folders to match the manifest
|
|
45
46
|
compliance Check all repos for .license-guard.json, LICENSE, CLA.md, .npmignore
|
|
46
47
|
watchdog Find repos on disk that are not in the manifest
|
|
48
|
+
release-enrollment
|
|
49
|
+
Report release-pipeline enrollment decisions for active repos
|
|
47
50
|
add Add a repo to the manifest
|
|
48
51
|
move Move a repo to a different category in the manifest
|
|
49
52
|
tree Generate directory tree from manifest
|
|
@@ -53,6 +56,7 @@ Options:
|
|
|
53
56
|
--root Path to repos root directory (default: directory containing manifest)
|
|
54
57
|
--all Include worktrees, trash, archived, and other ignored lifecycle paths
|
|
55
58
|
--class Show one lifecycle class only (active, worktree, trash, sort, sunsetted, archived, third-party)
|
|
59
|
+
--strict Treat missing release enrollment decisions as failures
|
|
56
60
|
--apply Apply sync moves. Without this, sync is a dry run
|
|
57
61
|
--fix Create missing compliance files (with compliance command)
|
|
58
62
|
--json Output as JSON`);
|
|
@@ -265,6 +269,80 @@ try {
|
|
|
265
269
|
break;
|
|
266
270
|
}
|
|
267
271
|
|
|
272
|
+
case 'release-enrollment': {
|
|
273
|
+
const result = releaseEnrollment(manifestPath, reposRoot, {
|
|
274
|
+
strict: hasFlag('--strict'),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (jsonOutput) {
|
|
278
|
+
console.log(JSON.stringify(result, null, 2));
|
|
279
|
+
if (!result.clean) process.exit(1);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
console.log('Release enrollment inventory');
|
|
284
|
+
console.log(`Active manifest repos: ${result.summary.manifestActive}`);
|
|
285
|
+
console.log(`Enrolled: ${result.summary.enrolled}`);
|
|
286
|
+
console.log(`Excluded: ${result.summary.excluded}`);
|
|
287
|
+
console.log(`Needs decision: ${result.summary.needsDecision}`);
|
|
288
|
+
console.log(`Missing on disk: ${result.summary.missingOnDisk}`);
|
|
289
|
+
console.log(`Unmanifested active: ${result.summary.unmanifestedActive}`);
|
|
290
|
+
console.log(`Blockers: ${result.summary.blockers}`);
|
|
291
|
+
console.log();
|
|
292
|
+
|
|
293
|
+
if (result.enrolled.length > 0) {
|
|
294
|
+
console.log(`ENROLLED (${result.enrolled.length}):`);
|
|
295
|
+
for (const entry of result.enrolled) {
|
|
296
|
+
const profile = entry.profile ? ` profile=${entry.profile}` : '';
|
|
297
|
+
const pkg = entry.packageName ? ` package=${entry.packageName}` : '';
|
|
298
|
+
const issues = entry.issues?.length ? ` issues=${entry.issues.join('; ')}` : '';
|
|
299
|
+
console.log(` ✓ ${entry.path}${profile}${pkg}${issues}`);
|
|
300
|
+
}
|
|
301
|
+
console.log();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (result.excluded.length > 0) {
|
|
305
|
+
console.log(`EXCLUDED (${result.excluded.length}):`);
|
|
306
|
+
for (const entry of result.excluded) {
|
|
307
|
+
console.log(` - ${entry.path}: ${entry.reason}`);
|
|
308
|
+
}
|
|
309
|
+
console.log();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (result.needsDecision.length > 0) {
|
|
313
|
+
const mode = result.strict ? 'BLOCKING because --strict is set' : 'NON-BLOCKING without --strict';
|
|
314
|
+
console.log(`NEEDS DECISION (${result.needsDecision.length}, ${mode}):`);
|
|
315
|
+
for (const entry of result.needsDecision) {
|
|
316
|
+
console.log(` ? ${entry.path}: ${entry.issue}`);
|
|
317
|
+
}
|
|
318
|
+
console.log();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (result.missingOnDisk.length > 0) {
|
|
322
|
+
console.log(`MISSING ON DISK (${result.missingOnDisk.length}):`);
|
|
323
|
+
for (const entry of result.missingOnDisk) {
|
|
324
|
+
console.log(` ! ${entry.path}: ${entry.issue}`);
|
|
325
|
+
}
|
|
326
|
+
console.log();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (result.unmanifestedActive.length > 0) {
|
|
330
|
+
console.log(`UNMANIFESTED ACTIVE (${result.unmanifestedActive.length}):`);
|
|
331
|
+
for (const entry of result.unmanifestedActive) {
|
|
332
|
+
console.log(` + ${entry.path}: ${entry.reason}`);
|
|
333
|
+
}
|
|
334
|
+
console.log();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (result.clean) {
|
|
338
|
+
console.log('Release enrollment inventory is actionable.');
|
|
339
|
+
} else {
|
|
340
|
+
console.log('Release enrollment inventory has blockers.');
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
|
|
268
346
|
case 'claude': {
|
|
269
347
|
runClaude(manifestPath, args.slice(1));
|
|
270
348
|
break;
|
package/core.mjs
CHANGED
|
@@ -491,3 +491,145 @@ export function findUnmanifested(manifestPath, reposRoot) {
|
|
|
491
491
|
const result = check(manifestPath, reposRoot);
|
|
492
492
|
return result.onDiskOnly;
|
|
493
493
|
}
|
|
494
|
+
|
|
495
|
+
function readPackageInfo(fullPath) {
|
|
496
|
+
const packagePath = join(fullPath, 'package.json');
|
|
497
|
+
if (!existsSync(packagePath)) return null;
|
|
498
|
+
try {
|
|
499
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
|
|
500
|
+
return {
|
|
501
|
+
name: pkg.name || null,
|
|
502
|
+
version: pkg.version || null,
|
|
503
|
+
private: Boolean(pkg.private),
|
|
504
|
+
scripts: pkg.scripts || {},
|
|
505
|
+
};
|
|
506
|
+
} catch (err) {
|
|
507
|
+
return { error: err.message };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function getReleaseConfig(info) {
|
|
512
|
+
if (!info || typeof info !== 'object') return {};
|
|
513
|
+
if (info.release && typeof info.release === 'object') return info.release;
|
|
514
|
+
if (info.ci && typeof info.ci === 'object' && info.ci.release && typeof info.ci.release === 'object') {
|
|
515
|
+
return info.ci.release;
|
|
516
|
+
}
|
|
517
|
+
return {};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function isReleaseExcluded(releaseConfig) {
|
|
521
|
+
return releaseConfig.enabled === false || releaseConfig.enrolled === false;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function isReleaseEnrolled(releaseConfig) {
|
|
525
|
+
return releaseConfig.enabled === true || releaseConfig.enrolled === true || Boolean(releaseConfig.profile);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Report release-pipeline enrollment status for active manifest repos.
|
|
530
|
+
*
|
|
531
|
+
* This intentionally reuses the manifest and filesystem classifier from check().
|
|
532
|
+
* It answers the Phase 0 release-pipeline question: which repos are enrolled,
|
|
533
|
+
* which are explicitly excluded, and which still need a release-profile decision.
|
|
534
|
+
*/
|
|
535
|
+
export function releaseEnrollment(manifestPath, reposRoot, opts = {}) {
|
|
536
|
+
const manifest = loadManifest(manifestPath);
|
|
537
|
+
const inventory = check(manifestPath, reposRoot);
|
|
538
|
+
const strict = Boolean(opts.strict);
|
|
539
|
+
|
|
540
|
+
const enrolled = [];
|
|
541
|
+
const excluded = [];
|
|
542
|
+
const needsDecision = [];
|
|
543
|
+
const missingOnDisk = [];
|
|
544
|
+
|
|
545
|
+
for (const [repoPath, info] of Object.entries(manifest.repos)) {
|
|
546
|
+
const classification = classifyRepoPath(repoPath, join(reposRoot, repoPath));
|
|
547
|
+
if (classification.class !== 'active') continue;
|
|
548
|
+
|
|
549
|
+
const fullPath = join(reposRoot, repoPath);
|
|
550
|
+
const exists = existsSync(fullPath);
|
|
551
|
+
const releaseConfig = getReleaseConfig(info);
|
|
552
|
+
const packageInfo = exists ? readPackageInfo(fullPath) : null;
|
|
553
|
+
const entry = {
|
|
554
|
+
path: repoPath,
|
|
555
|
+
remote: info.remote || null,
|
|
556
|
+
packageName: packageInfo?.name || releaseConfig.packageName || releaseConfig.npmPackage || null,
|
|
557
|
+
packageVersion: packageInfo?.version || null,
|
|
558
|
+
profile: releaseConfig.profile || null,
|
|
559
|
+
smokeProfile: releaseConfig.smokeProfile || null,
|
|
560
|
+
publicMirror: releaseConfig.publicMirror || info.public || null,
|
|
561
|
+
requiredSecrets: Array.isArray(releaseConfig.requiredSecrets) ? releaseConfig.requiredSecrets : [],
|
|
562
|
+
scripts: packageInfo?.scripts ? Object.keys(packageInfo.scripts).sort() : [],
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
if (!exists) {
|
|
566
|
+
missingOnDisk.push({
|
|
567
|
+
...entry,
|
|
568
|
+
issue: 'manifest entry is active but repo is not on disk',
|
|
569
|
+
});
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (packageInfo?.error) {
|
|
574
|
+
needsDecision.push({
|
|
575
|
+
...entry,
|
|
576
|
+
issue: `package.json is invalid: ${packageInfo.error}`,
|
|
577
|
+
});
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (isReleaseExcluded(releaseConfig)) {
|
|
582
|
+
excluded.push({
|
|
583
|
+
...entry,
|
|
584
|
+
reason: releaseConfig.reason || releaseConfig.excludeReason || 'release enrollment explicitly disabled',
|
|
585
|
+
});
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (isReleaseEnrolled(releaseConfig)) {
|
|
590
|
+
const issues = [];
|
|
591
|
+
if (!entry.profile) issues.push('missing release.profile');
|
|
592
|
+
if (!entry.packageName && packageInfo) issues.push('missing package name');
|
|
593
|
+
enrolled.push({ ...entry, issues });
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
needsDecision.push({
|
|
598
|
+
...entry,
|
|
599
|
+
issue: 'missing release enrollment decision',
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const unmanifestedActive = inventory.onDiskOnlyDetails
|
|
604
|
+
.filter(entry => entry.class === 'active')
|
|
605
|
+
.map(entry => ({
|
|
606
|
+
path: entry.path,
|
|
607
|
+
reason: entry.reason,
|
|
608
|
+
}));
|
|
609
|
+
|
|
610
|
+
const blockers = [
|
|
611
|
+
...missingOnDisk.map(entry => ({ type: 'missing-on-disk', path: entry.path, issue: entry.issue })),
|
|
612
|
+
...unmanifestedActive.map(entry => ({ type: 'unmanifested-active', path: entry.path, issue: entry.reason })),
|
|
613
|
+
...(strict ? needsDecision.map(entry => ({ type: 'needs-decision', path: entry.path, issue: entry.issue })) : []),
|
|
614
|
+
].sort((a, b) => a.path.localeCompare(b.path));
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
strict,
|
|
618
|
+
clean: blockers.length === 0,
|
|
619
|
+
summary: {
|
|
620
|
+
manifestActive: enrolled.length + excluded.length + needsDecision.length + missingOnDisk.length,
|
|
621
|
+
enrolled: enrolled.length,
|
|
622
|
+
excluded: excluded.length,
|
|
623
|
+
needsDecision: needsDecision.length,
|
|
624
|
+
missingOnDisk: missingOnDisk.length,
|
|
625
|
+
unmanifestedActive: unmanifestedActive.length,
|
|
626
|
+
blockers: blockers.length,
|
|
627
|
+
},
|
|
628
|
+
enrolled: enrolled.sort((a, b) => a.path.localeCompare(b.path)),
|
|
629
|
+
excluded: excluded.sort((a, b) => a.path.localeCompare(b.path)),
|
|
630
|
+
needsDecision: needsDecision.sort((a, b) => a.path.localeCompare(b.path)),
|
|
631
|
+
missingOnDisk: missingOnDisk.sort((a, b) => a.path.localeCompare(b.path)),
|
|
632
|
+
unmanifestedActive,
|
|
633
|
+
blockers,
|
|
634
|
+
};
|
|
635
|
+
}
|
package/mcp-server.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
7
7
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
import {
|
|
10
|
-
check, planSync, addRepo, moveRepo, generateReadmeTree,
|
|
10
|
+
check, planSync, addRepo, moveRepo, generateReadmeTree, releaseEnrollment,
|
|
11
11
|
} from './core.mjs';
|
|
12
12
|
|
|
13
13
|
const server = new Server(
|
|
@@ -43,6 +43,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
43
43
|
required: ['manifestPath', 'reposRoot'],
|
|
44
44
|
},
|
|
45
45
|
},
|
|
46
|
+
{
|
|
47
|
+
name: 'repos_release_enrollment',
|
|
48
|
+
description: 'Report release-pipeline enrollment status for active manifest repos: enrolled, excluded, needs decision, missing on disk, and unmanifested active repos.',
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
manifestPath: { type: 'string', description: 'Path to repos-manifest.json' },
|
|
53
|
+
reposRoot: { type: 'string', description: 'Root directory containing repos' },
|
|
54
|
+
strict: { type: 'boolean', description: 'Treat repos missing release enrollment decisions as blockers' },
|
|
55
|
+
},
|
|
56
|
+
required: ['manifestPath', 'reposRoot'],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
46
59
|
{
|
|
47
60
|
name: 'repos_add',
|
|
48
61
|
description: 'Add a repo to the manifest.',
|
|
@@ -112,6 +125,19 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
112
125
|
};
|
|
113
126
|
}
|
|
114
127
|
|
|
128
|
+
if (name === 'repos_release_enrollment') {
|
|
129
|
+
const result = releaseEnrollment(args.manifestPath, args.reposRoot, {
|
|
130
|
+
strict: Boolean(args.strict),
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
content: [{
|
|
134
|
+
type: 'text',
|
|
135
|
+
text: JSON.stringify(result, null, 2),
|
|
136
|
+
}],
|
|
137
|
+
isError: !result.clean,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
115
141
|
if (name === 'repos_add') {
|
|
116
142
|
const entry = addRepo(args.manifestPath, args.repoPath, args.remote, {
|
|
117
143
|
description: args.description,
|
package/package.json
CHANGED
package/test.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { execSync } from 'node:child_process';
|
|
|
3
3
|
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { check, executeSync, planSync } from './core.mjs';
|
|
6
|
+
import { check, executeSync, planSync, releaseEnrollment } from './core.mjs';
|
|
7
7
|
|
|
8
8
|
function run(cmd, cwd) {
|
|
9
9
|
execSync(cmd, { cwd, stdio: 'ignore' });
|
|
@@ -22,6 +22,8 @@ const root = mkdtempSync(join(tmpdir(), 'wip-repos-test-'));
|
|
|
22
22
|
const manifestPath = join(root, 'repos-manifest.json');
|
|
23
23
|
|
|
24
24
|
initRepo(join(root, 'active/repo-a'), 'wipcomputer/repo-a');
|
|
25
|
+
initRepo(join(root, 'active/repo-b'), 'wipcomputer/repo-b');
|
|
26
|
+
initRepo(join(root, 'active/no-decision'), 'wipcomputer/no-decision');
|
|
25
27
|
initRepo(join(root, '_trash/old-repo'), 'wipcomputer/old-repo');
|
|
26
28
|
initRepo(join(root, 'active/misplaced'), 'wipcomputer/right-place');
|
|
27
29
|
mkdirSync(join(root, '.worktrees'), { recursive: true });
|
|
@@ -29,8 +31,19 @@ run(`git worktree add -q -b feat ${join(root, '.worktrees/repo-a--feat')}`, join
|
|
|
29
31
|
|
|
30
32
|
writeFileSync(manifestPath, JSON.stringify({
|
|
31
33
|
_format: 'v2',
|
|
32
|
-
'active/repo-a': {
|
|
33
|
-
|
|
34
|
+
'active/repo-a': {
|
|
35
|
+
remote: 'wipcomputer/repo-a',
|
|
36
|
+
release: { enabled: true, profile: 'node-package', smokeProfile: 'none' },
|
|
37
|
+
},
|
|
38
|
+
'active/repo-b': {
|
|
39
|
+
remote: 'wipcomputer/repo-b',
|
|
40
|
+
release: { enabled: false, reason: 'docs-only fixture' },
|
|
41
|
+
},
|
|
42
|
+
'active/no-decision': { remote: 'wipcomputer/no-decision' },
|
|
43
|
+
'active/right-place': {
|
|
44
|
+
remote: 'wipcomputer/right-place',
|
|
45
|
+
release: { enabled: true, profile: 'node-package' },
|
|
46
|
+
},
|
|
34
47
|
}, null, 2) + '\n');
|
|
35
48
|
|
|
36
49
|
const defaultCheck = check(manifestPath, root);
|
|
@@ -54,6 +67,28 @@ if (worktreeCheck.disk.length !== 1 || worktreeCheck.disk[0].class !== 'worktree
|
|
|
54
67
|
throw new Error('--class worktree should return only worktrees');
|
|
55
68
|
}
|
|
56
69
|
|
|
70
|
+
const enrollment = releaseEnrollment(manifestPath, root);
|
|
71
|
+
if (enrollment.summary.enrolled !== 1 || enrollment.summary.excluded !== 1) {
|
|
72
|
+
throw new Error('releaseEnrollment should count enrolled and excluded repos');
|
|
73
|
+
}
|
|
74
|
+
if (enrollment.summary.needsDecision !== 1) {
|
|
75
|
+
throw new Error('releaseEnrollment should count repos without release decisions');
|
|
76
|
+
}
|
|
77
|
+
if (enrollment.summary.blockers !== 2) {
|
|
78
|
+
throw new Error('releaseEnrollment should block on missing and unmanifested active repos by default');
|
|
79
|
+
}
|
|
80
|
+
if (!enrollment.missingOnDisk.some(r => r.path === 'active/right-place')) {
|
|
81
|
+
throw new Error('releaseEnrollment should report active manifest repos missing on disk');
|
|
82
|
+
}
|
|
83
|
+
if (!enrollment.unmanifestedActive.some(r => r.path === 'active/misplaced')) {
|
|
84
|
+
throw new Error('releaseEnrollment should report active repos on disk but not in manifest');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const strictEnrollment = releaseEnrollment(manifestPath, root, { strict: true });
|
|
88
|
+
if (strictEnrollment.summary.blockers !== 3) {
|
|
89
|
+
throw new Error('releaseEnrollment --strict should make missing decisions blocking');
|
|
90
|
+
}
|
|
91
|
+
|
|
57
92
|
const moves = planSync(manifestPath, root);
|
|
58
93
|
if (moves.length !== 1 || moves[0].to !== 'active/right-place') {
|
|
59
94
|
throw new Error('planSync should plan the misplaced active repo only');
|