@wipcomputer/wip-repos 1.9.68 → 1.9.69

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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 1.9.69 (2026-04-24)
4
+
5
+ # wip-repos v1.9.69
6
+
7
+ ## Classify lifecycle paths and make sync apply explicit
8
+
9
+ - Classifies discovered repos as active, worktree, trash, sort, sunsetted, archived, or third-party.
10
+ - Ignores worktrees and lifecycle folders by default in `wip-repos check`, with `--all` and `--class` for inspection.
11
+ - Makes `wip-repos sync` dry-run by default; repo moves require `--apply`.
12
+ - Refuses sync moves for dirty repos, linked worktrees, and target collisions.
13
+ - Adds a regression test for default check filtering, lifecycle class output, sync planning, and dirty-repo refusal.
package/README.md CHANGED
@@ -21,12 +21,14 @@ Move folders around all day. On sync, everything snaps back to where the manifes
21
21
  ```bash
22
22
  # Check for drift between filesystem and manifest
23
23
  wip-repos check
24
+ wip-repos check --all
25
+ wip-repos check --class worktree
24
26
 
25
27
  # See what sync would do
26
- wip-repos sync --dry-run
28
+ wip-repos sync
27
29
 
28
30
  # Actually move folders to match manifest
29
- wip-repos sync
31
+ wip-repos sync --apply
30
32
 
31
33
  # Add a new repo
32
34
  wip-repos add ldm-os/utilities/my-tool --remote wipcomputer/my-tool
@@ -49,9 +51,9 @@ wip-repos tree
49
51
 
50
52
  ## How It Works
51
53
 
52
- 1. **check** walks the filesystem, finds all git repos, compares against the manifest. Reports what's on disk but not in manifest, and what's in manifest but not on disk. Exit code 1 if drift detected.
54
+ 1. **check** walks the filesystem, classifies repos, and compares active repos against the manifest. Worktrees, trash, sort, sunsetted, and archived paths are ignored by default and summarized separately. Use `--all` or `--class <class>` to inspect them.
53
55
 
54
- 2. **sync** matches repos by their git remote URL. If a repo's remote matches a manifest entry but it's at the wrong path, sync moves it to the manifest path.
56
+ 2. **sync** matches active repos by their git remote URL. If a repo's remote matches a manifest entry but it's at the wrong path, sync prints the move plan by default. It only moves with `--apply`, and refuses dirty repos, linked worktrees, and target collisions.
55
57
 
56
58
  3. **add/move** update the manifest file. The actual folder moves happen on the next `sync`.
57
59
 
package/SKILL.md CHANGED
@@ -59,8 +59,10 @@ Repo manifest reconciler. Like prettier for folder structure. Move folders aroun
59
59
 
60
60
  ```bash
61
61
  wip-repos check # diff filesystem vs manifest
62
- wip-repos sync --dry-run # preview moves
63
- wip-repos sync # execute moves
62
+ wip-repos check --all # include worktrees/trash/archive paths
63
+ wip-repos check --class worktree # inspect one lifecycle class
64
+ wip-repos sync # preview moves
65
+ wip-repos sync --apply # execute safe moves
64
66
  wip-repos add ldm-os/utilities/new-tool --remote wipcomputer/new-tool
65
67
  wip-repos move ldm-os/utilities/tool --to ldm-os/devops/tool
66
68
  wip-repos tree # generate directory tree
@@ -75,6 +77,8 @@ const result = check('/path/to/manifest.json', '/path/to/repos/');
75
77
  const moves = planSync('/path/to/manifest.json', '/path/to/repos/');
76
78
  ```
77
79
 
80
+ `sync` is dry-run by default. It refuses dirty repos, linked worktrees, and target collisions when `--apply` is used.
81
+
78
82
  ### MCP
79
83
 
80
84
  Tools: `repos_check`, `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 } from './core.mjs';
14
+ import { check, planSync, executeSync, addRepo, moveRepo, generateReadmeTree, loadManifest, checkCompliance, fixCompliance, findUnmanifested } 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';
@@ -31,16 +31,30 @@ function usage() {
31
31
  console.log(`wip-repos ... repo manifest reconciler
32
32
 
33
33
  Usage:
34
- wip-repos check [--manifest path] [--root path]
35
- wip-repos sync [--manifest path] [--root path] [--dry-run]
34
+ wip-repos check [--manifest path] [--root path] [--all] [--class class]
35
+ wip-repos sync [--manifest path] [--root path] [--apply]
36
+ wip-repos compliance [--manifest path] [--root path] [--fix]
37
+ wip-repos watchdog [--manifest path] [--root path]
36
38
  wip-repos add <path> --remote <org/repo> [--category cat] [--description desc]
37
39
  wip-repos move <path> --to <new-path>
38
- wip-repos tree [--manifest path]
40
+ wip-repos tree [--manifest path]
41
+
42
+ Commands:
43
+ check Diff filesystem against manifest, flag drift
44
+ sync Move local folders to match the manifest
45
+ compliance Check all repos for .license-guard.json, LICENSE, CLA.md, .npmignore
46
+ watchdog Find repos on disk that are not in the manifest
47
+ add Add a repo to the manifest
48
+ move Move a repo to a different category in the manifest
49
+ tree Generate directory tree from manifest
39
50
 
40
51
  Options:
41
52
  --manifest Path to repos-manifest.json (default: ./repos-manifest.json)
42
53
  --root Path to repos root directory (default: directory containing manifest)
43
- --dry-run Show what would happen without making changes
54
+ --all Include worktrees, trash, archived, and other ignored lifecycle paths
55
+ --class Show one lifecycle class only (active, worktree, trash, sort, sunsetted, archived, third-party)
56
+ --apply Apply sync moves. Without this, sync is a dry run
57
+ --fix Create missing compliance files (with compliance command)
44
58
  --json Output as JSON`);
45
59
  }
46
60
 
@@ -56,13 +70,17 @@ function hasFlag(flag) {
56
70
 
57
71
  const manifestPath = resolve(getFlag('--manifest') || 'repos-manifest.json');
58
72
  const reposRoot = resolve(getFlag('--root') || dirname(manifestPath));
59
- const dryRun = hasFlag('--dry-run');
73
+ const apply = hasFlag('--apply');
74
+ const dryRun = hasFlag('--dry-run') || !apply;
60
75
  const jsonOutput = hasFlag('--json');
61
76
 
62
77
  try {
63
78
  switch (command) {
64
79
  case 'check': {
65
- const result = check(manifestPath, reposRoot);
80
+ const result = check(manifestPath, reposRoot, {
81
+ all: hasFlag('--all'),
82
+ classFilter: getFlag('--class') || null,
83
+ });
66
84
 
67
85
  if (jsonOutput) {
68
86
  console.log(JSON.stringify(result, null, 2));
@@ -72,12 +90,18 @@ try {
72
90
  console.log(`Manifest: ${result.total.manifest} repos`);
73
91
  console.log(`On disk: ${result.total.disk} repos`);
74
92
  console.log(`Matched: ${result.total.matched}`);
93
+ if (Object.keys(result.ignored).length > 0 && !hasFlag('--all') && !getFlag('--class')) {
94
+ const ignored = Object.entries(result.ignored).map(([k, v]) => `${k}=${v}`).join(', ');
95
+ console.log(`Ignored lifecycle paths: ${ignored}`);
96
+ }
75
97
  console.log();
76
98
 
77
99
  if (result.onDiskOnly.length > 0) {
78
100
  console.log(`On disk but NOT in manifest (${result.onDiskOnly.length}):`);
79
- for (const p of result.onDiskOnly) {
80
- console.log(` + ${p}`);
101
+ for (const p of result.onDiskOnly) {
102
+ const detail = result.onDiskOnlyDetails.find(d => d.path === p);
103
+ const suffix = detail ? ` [${detail.class}: ${detail.reason}]` : '';
104
+ console.log(` + ${p}${suffix}`);
81
105
  }
82
106
  console.log();
83
107
  }
@@ -122,7 +146,7 @@ try {
122
146
  }
123
147
 
124
148
  if (dryRun) {
125
- console.log('Dry run. No changes made.');
149
+ console.log('Dry run. No changes made. Re-run with --apply to move repos.');
126
150
  break;
127
151
  }
128
152
 
@@ -174,6 +198,73 @@ try {
174
198
  break;
175
199
  }
176
200
 
201
+ case 'compliance': {
202
+ const results = checkCompliance(manifestPath, reposRoot);
203
+ const failing = results.filter(r => !r.clean);
204
+ const passing = results.filter(r => r.clean);
205
+
206
+ if (jsonOutput) {
207
+ console.log(JSON.stringify(results, null, 2));
208
+ break;
209
+ }
210
+
211
+ console.log(`Compliance check: ${results.length} repos scanned\n`);
212
+
213
+ if (failing.length > 0) {
214
+ console.log(`FAILING (${failing.length}):`);
215
+ for (const r of failing) {
216
+ console.log(` ✗ ${r.repoPath}`);
217
+ for (const issue of r.issues) {
218
+ console.log(` - ${issue}`);
219
+ }
220
+ }
221
+ console.log();
222
+ }
223
+
224
+ if (passing.length > 0) {
225
+ console.log(`PASSING (${passing.length}):`);
226
+ for (const r of passing) {
227
+ console.log(` ✓ ${r.repoPath}`);
228
+ }
229
+ console.log();
230
+ }
231
+
232
+ if (failing.length > 0 && hasFlag('--fix')) {
233
+ console.log('Fixing...\n');
234
+ for (const r of failing) {
235
+ const created = fixCompliance(r.fullPath);
236
+ if (created.length > 0) {
237
+ console.log(` ${r.repoPath}: created ${created.join(', ')}`);
238
+ }
239
+ }
240
+ console.log('\nFiles created. You still need to commit and push each repo.');
241
+ } else if (failing.length > 0) {
242
+ console.log('Run with --fix to create missing files.');
243
+ }
244
+
245
+ if (failing.length > 0) process.exit(1);
246
+ break;
247
+ }
248
+
249
+ case 'watchdog': {
250
+ const unmanifested = findUnmanifested(manifestPath, reposRoot);
251
+ if (jsonOutput) {
252
+ console.log(JSON.stringify(unmanifested, null, 2));
253
+ break;
254
+ }
255
+ if (unmanifested.length === 0) {
256
+ console.log('All repos on disk are in the manifest.');
257
+ } else {
258
+ console.log(`Repos on disk but NOT in manifest (${unmanifested.length}):`);
259
+ for (const p of unmanifested) {
260
+ console.log(` ! ${p}`);
261
+ }
262
+ console.log('\nAdd them with: wip-repos add <path> --remote <org/repo>');
263
+ process.exit(1);
264
+ }
265
+ break;
266
+ }
267
+
177
268
  case 'claude': {
178
269
  runClaude(manifestPath, args.slice(1));
179
270
  break;
package/core.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  * Like prettier for folder structure.
6
6
  */
7
7
 
8
+ import { execSync } from 'node:child_process';
8
9
  import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync, renameSync } from 'node:fs';
9
10
  import { join, dirname, relative, resolve } from 'node:path';
10
11
 
@@ -58,45 +59,91 @@ export function saveManifest(manifestPath, meta, repos) {
58
59
  writeFileSync(manifestPath, JSON.stringify(out, null, 2) + '\n', 'utf8');
59
60
  }
60
61
 
62
+ export function classifyRepoPath(relPath, fullPath) {
63
+ const parts = relPath.split('/');
64
+ if (parts.includes('.worktrees')) return { class: 'worktree', reason: 'inside .worktrees' };
65
+ if (parts.includes('_trash')) return { class: 'trash', reason: 'inside _trash' };
66
+ if (parts.includes('_sort')) return { class: 'sort', reason: 'inside _sort' };
67
+ if (parts.includes('_sunsetted')) return { class: 'sunsetted', reason: 'inside _sunsetted' };
68
+ if (parts.includes('_archive') || parts.includes('archive') || parts.includes('archived')) {
69
+ return { class: 'archived', reason: 'inside archive path' };
70
+ }
71
+ if (parts.includes('third-party') || parts.includes('third-party-repos') || parts.includes('external')) {
72
+ return { class: 'third-party', reason: 'inside third-party path' };
73
+ }
74
+ try {
75
+ const gitStat = statSync(join(fullPath, '.git'));
76
+ if (gitStat.isFile()) return { class: 'worktree', reason: '.git is a worktree file' };
77
+ } catch {}
78
+ return { class: 'active', reason: 'regular repo path' };
79
+ }
80
+
81
+ function shouldSkipDirName(name, includeIgnored) {
82
+ if (name === '.git' || name === 'node_modules' || name === '.DS_Store') return true;
83
+ if (includeIgnored) return false;
84
+ return ['.worktrees', '_trash', '_sort', '_sunsetted', '_archive', 'archive', 'archived'].includes(name);
85
+ }
86
+
61
87
  /**
62
- * Walk a directory tree and find all git repos (directories containing .git/).
63
- * Returns array of relative paths from the root.
88
+ * Walk a directory tree and find all git repos (directories containing .git).
89
+ * Returns classified entries: { path, fullPath, class, reason }.
64
90
  */
65
- export function walkRepos(rootDir, prefix = '') {
91
+ export function walkRepoEntries(rootDir, prefix = '', opts = {}) {
66
92
  const results = [];
67
93
  if (!existsSync(rootDir)) return results;
94
+ const includeIgnored = opts.includeIgnored || false;
95
+ const classFilter = opts.classFilter || null;
68
96
 
69
97
  const entries = readdirSync(rootDir, { withFileTypes: true });
70
98
  for (const entry of entries) {
71
99
  if (!entry.isDirectory()) continue;
72
- if (entry.name === '.git' || entry.name === 'node_modules') continue;
73
- if (entry.name === '.DS_Store') continue;
100
+ if (shouldSkipDirName(entry.name, includeIgnored)) continue;
74
101
 
75
102
  const fullPath = join(rootDir, entry.name);
76
103
  const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
77
104
 
78
105
  // If this directory has a .git, it's a repo
79
106
  if (existsSync(join(fullPath, '.git'))) {
80
- results.push(relPath);
107
+ const cls = classifyRepoPath(relPath, fullPath);
108
+ if ((!classFilter || cls.class === classFilter) && (includeIgnored || cls.class === 'active')) {
109
+ results.push({ path: relPath, fullPath, ...cls });
110
+ }
81
111
  } else {
82
112
  // Recurse into non-repo directories (organizational folders)
83
- results.push(...walkRepos(fullPath, relPath));
113
+ results.push(...walkRepoEntries(fullPath, relPath, opts));
84
114
  }
85
115
  }
86
116
  return results;
87
117
  }
88
118
 
119
+ export function walkRepos(rootDir, prefix = '', opts = {}) {
120
+ return walkRepoEntries(rootDir, prefix, opts).map(r => r.path);
121
+ }
122
+
89
123
  /**
90
124
  * Check: diff the filesystem against the manifest.
91
125
  * Returns { inManifestOnly, onDiskOnly, matched }
92
126
  */
93
- export function check(manifestPath, reposRoot) {
127
+ export function check(manifestPath, reposRoot, opts = {}) {
94
128
  const manifest = loadManifest(manifestPath);
95
- const manifestPaths = new Set(Object.keys(manifest.repos));
96
- const diskPaths = new Set(walkRepos(reposRoot));
129
+ const includeIgnored = opts.all || opts.includeIgnored || Boolean(opts.classFilter);
130
+ const manifestEntries = Object.keys(manifest.repos)
131
+ .map(path => ({ path, ...classifyRepoPath(path, join(reposRoot, path)) }))
132
+ .filter(entry => {
133
+ if (opts.classFilter) return entry.class === opts.classFilter;
134
+ return includeIgnored || entry.class === 'active';
135
+ });
136
+ const manifestPaths = new Set(manifestEntries.map(e => e.path));
137
+ const allDiskEntries = walkRepoEntries(reposRoot, '', { includeIgnored: true });
138
+ const diskEntries = walkRepoEntries(reposRoot, '', {
139
+ includeIgnored,
140
+ classFilter: opts.classFilter || null,
141
+ });
142
+ const diskPaths = new Set(diskEntries.map(e => e.path));
97
143
 
98
144
  const inManifestOnly = [];
99
145
  const onDiskOnly = [];
146
+ const onDiskOnlyDetails = [];
100
147
  const matched = [];
101
148
 
102
149
  for (const p of manifestPaths) {
@@ -110,16 +157,28 @@ export function check(manifestPath, reposRoot) {
110
157
  for (const p of diskPaths) {
111
158
  if (!manifestPaths.has(p)) {
112
159
  onDiskOnly.push(p);
160
+ const detail = diskEntries.find(e => e.path === p);
161
+ if (detail) onDiskOnlyDetails.push(detail);
113
162
  }
114
163
  }
115
164
 
165
+ const ignored = {};
166
+ for (const entry of allDiskEntries) {
167
+ if (entry.class === 'active') continue;
168
+ ignored[entry.class] = (ignored[entry.class] || 0) + 1;
169
+ }
170
+
116
171
  return {
117
172
  inManifestOnly: inManifestOnly.sort(),
118
173
  onDiskOnly: onDiskOnly.sort(),
174
+ onDiskOnlyDetails: onDiskOnlyDetails.sort((a, b) => a.path.localeCompare(b.path)),
119
175
  matched: matched.sort(),
176
+ disk: diskEntries.sort((a, b) => a.path.localeCompare(b.path)),
177
+ ignored,
120
178
  total: {
121
179
  manifest: manifestPaths.size,
122
180
  disk: diskPaths.size,
181
+ diskAll: allDiskEntries.length,
123
182
  matched: matched.length,
124
183
  },
125
184
  };
@@ -187,6 +246,14 @@ export function executeSync(moves, reposRoot) {
187
246
  results.push({ ...move, status: 'skipped', reason: 'target exists' });
188
247
  continue;
189
248
  }
249
+ if (isWorktree(move.fromFull)) {
250
+ results.push({ ...move, status: 'skipped', reason: 'source is a linked worktree' });
251
+ continue;
252
+ }
253
+ if (isDirtyRepo(move.fromFull)) {
254
+ results.push({ ...move, status: 'skipped', reason: 'source repo has uncommitted changes' });
255
+ continue;
256
+ }
190
257
  try {
191
258
  renameSync(move.fromFull, move.toFull);
192
259
  results.push({ ...move, status: 'moved' });
@@ -197,6 +264,26 @@ export function executeSync(moves, reposRoot) {
197
264
  return results;
198
265
  }
199
266
 
267
+ export function isWorktree(repoPath) {
268
+ try {
269
+ return statSync(join(repoPath, '.git')).isFile();
270
+ } catch {
271
+ return false;
272
+ }
273
+ }
274
+
275
+ export function isDirtyRepo(repoPath) {
276
+ try {
277
+ return execSync('git status --porcelain 2>/dev/null', {
278
+ cwd: repoPath,
279
+ encoding: 'utf8',
280
+ timeout: 3000,
281
+ }).trim().length > 0;
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+
200
287
  /**
201
288
  * Add a repo to the manifest.
202
289
  */
@@ -288,3 +375,119 @@ export function generateReadmeTree(manifestPath) {
288
375
  render(tree, '', false);
289
376
  return lines.join('\n');
290
377
  }
378
+
379
+ // ── Compliance checking (Wave 3.3) ──────────────────────────────────
380
+
381
+ const DEFAULT_LICENSE_GUARD = {
382
+ copyright: 'WIP Computer, Inc.',
383
+ license: 'MIT+AGPL',
384
+ spdx: 'MIT AND AGPL-3.0-or-later',
385
+ };
386
+
387
+ /**
388
+ * Check compliance of all repos in the manifest.
389
+ * Returns array of { repoPath, fullPath, issues[], hasPackageJson }
390
+ */
391
+ export function checkCompliance(manifestPath, reposRoot) {
392
+ const manifest = loadManifest(manifestPath);
393
+ const results = [];
394
+
395
+ for (const [repoPath, info] of Object.entries(manifest.repos)) {
396
+ const fullPath = join(reposRoot, repoPath);
397
+ if (!existsSync(fullPath)) continue;
398
+ if (!existsSync(join(fullPath, 'package.json'))) continue;
399
+
400
+ // Skip _to-privatize, _sort, _sunsetted, _trash
401
+ if (repoPath.includes('_to-privatize') || repoPath.includes('_sort') ||
402
+ repoPath.includes('_sunsetted') || repoPath.includes('_trash')) continue;
403
+
404
+ const issues = [];
405
+
406
+ if (!existsSync(join(fullPath, '.license-guard.json'))) {
407
+ issues.push('missing .license-guard.json');
408
+ }
409
+ if (!existsSync(join(fullPath, 'LICENSE'))) {
410
+ issues.push('missing LICENSE');
411
+ } else {
412
+ const lic = readFileSync(join(fullPath, 'LICENSE'), 'utf8');
413
+ if (!lic.includes('WIP Computer')) issues.push('LICENSE missing copyright');
414
+ if (!lic.includes('AGPL') && !lic.includes('GNU Affero')) issues.push('LICENSE missing AGPL');
415
+ }
416
+ if (!existsSync(join(fullPath, 'CLA.md'))) {
417
+ issues.push('missing CLA.md');
418
+ }
419
+ if (existsSync(join(fullPath, 'ai')) && !existsSync(join(fullPath, '.npmignore'))) {
420
+ issues.push('missing .npmignore (has ai/ directory)');
421
+ } else if (existsSync(join(fullPath, 'ai')) && existsSync(join(fullPath, '.npmignore'))) {
422
+ const npmignore = readFileSync(join(fullPath, '.npmignore'), 'utf8');
423
+ if (!npmignore.includes('ai/')) issues.push('.npmignore does not exclude ai/');
424
+ }
425
+
426
+ results.push({ repoPath, fullPath, issues, clean: issues.length === 0 });
427
+ }
428
+
429
+ return results;
430
+ }
431
+
432
+ /**
433
+ * Fix compliance issues for a single repo.
434
+ * Creates missing files with WIP Computer defaults.
435
+ * Returns array of files created.
436
+ */
437
+ export function fixCompliance(fullPath) {
438
+ const created = [];
439
+
440
+ // .license-guard.json
441
+ if (!existsSync(join(fullPath, '.license-guard.json'))) {
442
+ writeFileSync(
443
+ join(fullPath, '.license-guard.json'),
444
+ JSON.stringify(DEFAULT_LICENSE_GUARD, null, 2) + '\n',
445
+ );
446
+ created.push('.license-guard.json');
447
+ }
448
+
449
+ // CLA.md
450
+ if (!existsSync(join(fullPath, 'CLA.md'))) {
451
+ writeFileSync(join(fullPath, 'CLA.md'), [
452
+ '# Contributor License Agreement',
453
+ '',
454
+ 'By submitting a pull request to this repository, you agree that:',
455
+ '',
456
+ '1. Your contribution is original work or you have the right to submit it.',
457
+ '2. You grant WIP Computer, Inc. a perpetual, worldwide, non-exclusive,',
458
+ ' royalty-free license to use, modify, and distribute your contribution',
459
+ ' under the terms of this project\'s license.',
460
+ '3. You understand your contribution will be publicly available.',
461
+ '',
462
+ 'This CLA is required for all contributions. By opening a PR, you accept these terms.',
463
+ '',
464
+ ].join('\n'));
465
+ created.push('CLA.md');
466
+ }
467
+
468
+ // .npmignore (if ai/ exists)
469
+ if (existsSync(join(fullPath, 'ai'))) {
470
+ const npmignorePath = join(fullPath, '.npmignore');
471
+ if (!existsSync(npmignorePath)) {
472
+ writeFileSync(npmignorePath, 'ai/\n.claude/\n.worktrees/\n.DS_Store\nCLAUDE.md\n');
473
+ created.push('.npmignore');
474
+ } else {
475
+ const content = readFileSync(npmignorePath, 'utf8');
476
+ if (!content.includes('ai/')) {
477
+ writeFileSync(npmignorePath, content.trimEnd() + '\nai/\n');
478
+ created.push('.npmignore (updated)');
479
+ }
480
+ }
481
+ }
482
+
483
+ return created;
484
+ }
485
+
486
+ /**
487
+ * Find repos on disk that are NOT in the manifest.
488
+ * This is the watchdog function for the daily audit.
489
+ */
490
+ export function findUnmanifested(manifestPath, reposRoot) {
491
+ const result = check(manifestPath, reposRoot);
492
+ return result.onDiskOnly;
493
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-repos",
3
- "version": "1.9.68",
3
+ "version": "1.9.69",
4
4
  "type": "module",
5
5
  "description": "Repo manifest reconciler. Single source of truth for repo organization. Like prettier for folder structure.",
6
6
  "main": "core.mjs",
@@ -11,7 +11,7 @@
11
11
  ".": "./core.mjs"
12
12
  },
13
13
  "scripts": {
14
- "test": "node cli.mjs --help"
14
+ "test": "node cli.mjs --help && node test.mjs"
15
15
  },
16
16
  "keywords": [
17
17
  "repo-manifest",
package/test.mjs ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { check, executeSync, planSync } from './core.mjs';
7
+
8
+ function run(cmd, cwd) {
9
+ execSync(cmd, { cwd, stdio: 'ignore' });
10
+ }
11
+
12
+ function initRepo(path, remote) {
13
+ mkdirSync(path, { recursive: true });
14
+ run('git init -q -b main', path);
15
+ run(`git remote add origin git@github.com:${remote}.git`, path);
16
+ writeFileSync(join(path, 'README.md'), '# repo\n');
17
+ run('git add README.md', path);
18
+ run('git -c user.email=t@t -c user.name=t commit -q -m init', path);
19
+ }
20
+
21
+ const root = mkdtempSync(join(tmpdir(), 'wip-repos-test-'));
22
+ const manifestPath = join(root, 'repos-manifest.json');
23
+
24
+ initRepo(join(root, 'active/repo-a'), 'wipcomputer/repo-a');
25
+ initRepo(join(root, '_trash/old-repo'), 'wipcomputer/old-repo');
26
+ initRepo(join(root, 'active/misplaced'), 'wipcomputer/right-place');
27
+ mkdirSync(join(root, '.worktrees'), { recursive: true });
28
+ run(`git worktree add -q -b feat ${join(root, '.worktrees/repo-a--feat')}`, join(root, 'active/repo-a'));
29
+
30
+ writeFileSync(manifestPath, JSON.stringify({
31
+ _format: 'v2',
32
+ 'active/repo-a': { remote: 'wipcomputer/repo-a' },
33
+ 'active/right-place': { remote: 'wipcomputer/right-place' },
34
+ }, null, 2) + '\n');
35
+
36
+ const defaultCheck = check(manifestPath, root);
37
+ if (defaultCheck.onDiskOnly.includes('_trash/old-repo')) {
38
+ throw new Error('default check should ignore _trash repos');
39
+ }
40
+ if (defaultCheck.disk.some(r => r.class !== 'active')) {
41
+ throw new Error('default check should only include active repos');
42
+ }
43
+
44
+ const allCheck = check(manifestPath, root, { all: true });
45
+ if (!allCheck.disk.some(r => r.class === 'trash')) {
46
+ throw new Error('--all check should include trash class');
47
+ }
48
+ if (!allCheck.disk.some(r => r.class === 'worktree')) {
49
+ throw new Error('--all check should include worktree class');
50
+ }
51
+
52
+ const worktreeCheck = check(manifestPath, root, { classFilter: 'worktree' });
53
+ if (worktreeCheck.disk.length !== 1 || worktreeCheck.disk[0].class !== 'worktree') {
54
+ throw new Error('--class worktree should return only worktrees');
55
+ }
56
+
57
+ const moves = planSync(manifestPath, root);
58
+ if (moves.length !== 1 || moves[0].to !== 'active/right-place') {
59
+ throw new Error('planSync should plan the misplaced active repo only');
60
+ }
61
+
62
+ writeFileSync(join(root, 'active/misplaced/dirty.txt'), 'dirty\n');
63
+ const results = executeSync(moves, root);
64
+ if (results[0].status !== 'skipped' || !results[0].reason.includes('uncommitted')) {
65
+ throw new Error('executeSync should refuse dirty repo moves');
66
+ }
67
+
68
+ console.log('wip-repos regression passed');