claude-nomad 0.25.0 → 0.25.2

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/src/commands.doctor.check-shared.scan.ts +158 -0
  5. package/src/commands.doctor.check-shared.ts +58 -189
  6. package/src/commands.doctor.checks.pathmap.ts +101 -0
  7. package/src/commands.doctor.checks.repo.ts +133 -0
  8. package/src/commands.doctor.checks.repository.ts +105 -0
  9. package/src/commands.doctor.checks.settings.ts +88 -0
  10. package/src/commands.doctor.format.ts +18 -0
  11. package/src/commands.doctor.ts +10 -7
  12. package/src/commands.drop-session.git.ts +81 -0
  13. package/src/commands.drop-session.ts +79 -138
  14. package/src/commands.pull.ts +3 -2
  15. package/src/commands.push.allowlist.ts +119 -0
  16. package/src/commands.push.ts +6 -121
  17. package/src/commands.update.git.ts +90 -0
  18. package/src/commands.update.resolve.ts +138 -0
  19. package/src/commands.update.test-helpers.git.ts +107 -0
  20. package/src/commands.update.ts +4 -221
  21. package/src/diff.ts +2 -1
  22. package/src/extras-sync.diff.ts +40 -0
  23. package/src/extras-sync.guards.ts +52 -0
  24. package/src/extras-sync.ts +146 -236
  25. package/src/init.classify.ts +1 -1
  26. package/src/init.snapshot.ts +3 -1
  27. package/src/init.ts +2 -1
  28. package/src/links.ts +3 -10
  29. package/src/nomad.dispatch.ts +25 -0
  30. package/src/nomad.help.ts +43 -0
  31. package/src/nomad.ts +6 -68
  32. package/src/preview.ts +2 -1
  33. package/src/push-gitleaks.scan.ts +115 -0
  34. package/src/push-gitleaks.ts +50 -106
  35. package/src/remap.ts +3 -1
  36. package/src/resume.ts +2 -1
  37. package/src/update.fork-extras.ts +2 -1
  38. package/src/utils.fs.ts +152 -0
  39. package/src/utils.json.ts +55 -0
  40. package/src/utils.lockfile.ts +168 -0
  41. package/src/utils.ts +0 -327
  42. package/src/commands.doctor.checks.ts +0 -350
@@ -3,23 +3,17 @@ import { existsSync, readdirSync, statSync } from 'node:fs';
3
3
  import { join, relative } from 'node:path';
4
4
 
5
5
  import { REPO_HOME } from './config.ts';
6
- import { acquireLock, die, fail, log, NomadFatal, releaseLock } from './utils.ts';
6
+ import { expandStagedDir, isInIndex, isTrackedInHead } from './commands.drop-session.git.ts';
7
+ import { die, fail, log, NomadFatal } from './utils.ts';
8
+ import { acquireLock, releaseLock } from './utils.lockfile.ts';
7
9
 
8
10
  /**
9
11
  * Surgical removal of a contaminated session from the staged tree of
10
- * `~/claude-nomad/`. For each `shared/projects/<logical>/` child this
11
- * matches both the flat `<id>.jsonl` AND the sibling subagent directory
12
- * `<id>/` (whose nested transcripts are keyed by the same session id),
13
- * classifies each staged entry via `git ls-files --error-unmatch`, and
14
- * unstages with the appropriate primitive:
15
- *
16
- * - tracked-in-HEAD -> `git restore --staged --worktree -- <rel>`
17
- * - newly-staged -> `git rm --cached -f -- <rel>`
18
- *
19
- * The directory tree is expanded into its staged entries via
20
- * `git ls-files -z -- <dir-rel>` so every nested file flows through the
21
- * same per-entry classification loop as the flat jsonl; this closes the
22
- * leak where a "dropped" session still shipped its subagent transcripts.
12
+ * `~/claude-nomad/`. Thin orchestrator: validates the id, acquires the
13
+ * lock, collects every staged path matching the flat `<id>.jsonl` and the
14
+ * sibling subagent directory `<id>/` (via `collectMatches`), then unstages
15
+ * each (via `unstageOne`). This closes the leak where a "dropped" session
16
+ * still shipped its subagent transcripts.
23
17
  *
24
18
  * Idempotent: entries not in the index are skipped silently. Exits 0 on
25
19
  * any drop (including an idempotent re-run); exits 1 with `✗ no staged
@@ -27,8 +21,8 @@ import { acquireLock, die, fail, log, NomadFatal, releaseLock } from './utils.ts
27
21
  * `<id>/` directory with staged entries exists anywhere in the tree.
28
22
  *
29
23
  * Defense-in-depth: the id is validated against the same allowlist regex
30
- * used in `src/resume.ts` before any path composition. argv-array form
31
- * for every git invocation.
24
+ * used in `src/resume.ts` before any path composition or lock acquisition.
25
+ * argv-array form for every git invocation.
32
26
  *
33
27
  * NEVER touches `~/.claude/projects/<encoded>/<id>.jsonl` or the local
34
28
  * `<id>/` tree; the local copies are preserved so they race-safely
@@ -54,68 +48,11 @@ export function cmdDropSession(id: string): void {
54
48
  if (!existsSync(repoProjects)) {
55
49
  throw new NomadFatal(`no staged session matches ${id}`);
56
50
  }
57
- // For each `shared/projects/<logical>/` child, match the flat
58
- // `<id>.jsonl` plus the sibling subagent directory `<id>/`. The
59
- // directory is expanded into its staged entries so every nested file
60
- // flows through the same per-entry unstage loop as the flat jsonl.
61
- const matches: string[] = [];
62
- for (const logical of readdirSync(repoProjects)) {
63
- const candidate = join(repoProjects, logical, `${id}.jsonl`);
64
- if (existsSync(candidate)) {
65
- matches.push(relative(REPO_HOME, candidate));
66
- }
67
- const dir = join(repoProjects, logical, id);
68
- if (existsSync(dir) && statSync(dir).isDirectory()) {
69
- const dirRel = relative(REPO_HOME, dir);
70
- const staged = expandStagedDir(dirRel);
71
- // A dir present on disk but absent from the index is an already-dropped
72
- // rerun: push the dir path itself so the per-entry isInIndex() guard
73
- // logs it as "already absent" rather than letting an empty match set
74
- // escalate to the no-match fatal (idempotency for dir-only sessions).
75
- if (staged.length > 0) matches.push(...staged);
76
- else matches.push(dirRel);
77
- }
78
- }
51
+ const matches = collectMatches(repoProjects, id);
79
52
  if (matches.length === 0) {
80
53
  throw new NomadFatal(`no staged session matches ${id}`);
81
54
  }
82
- for (const rel of matches) {
83
- // Pitfall 7: skip files that are not in the index at all (the
84
- // load-bearing guard for the idempotent second-run case, where the
85
- // first drop already removed the entry from the index but left the
86
- // working tree file in place).
87
- if (!isInIndex(rel)) {
88
- log(`dropped ${rel} (already absent from index)`);
89
- continue;
90
- }
91
- try {
92
- if (isTrackedInHead(rel)) {
93
- execFileSync('git', ['restore', '--staged', '--worktree', '--', rel], {
94
- cwd: REPO_HOME,
95
- stdio: ['ignore', 'pipe', 'pipe'],
96
- });
97
- } else {
98
- execFileSync('git', ['rm', '--cached', '-f', '--', rel], {
99
- cwd: REPO_HOME,
100
- stdio: ['ignore', 'pipe', 'pipe'],
101
- });
102
- }
103
- } catch (err) {
104
- // Convert raw execFileSync failures (non-zero git exit, EACCES on
105
- // .git/index, EPERM, etc.) into NomadFatal so the outer catch can
106
- // emit a clean `✗ ...` line instead of letting the ExecException
107
- // bubble past nomad.ts's NomadFatal-only dispatcher.
108
- // The `?? err.message` fallback only fires when git fails without
109
- // producing stderr (spawn-level error before the process emits
110
- // anything), which `cmdPush`'s gitleaks probe already rules out
111
- // for the typical install path. Excluded from coverage.
112
- const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
113
- /* c8 ignore next */
114
- const detail = e.stderr?.toString().trim() ?? e.message;
115
- throw new NomadFatal(`git failed to unstage ${rel}: ${detail}`);
116
- }
117
- log(`dropped ${rel}`);
118
- }
55
+ for (const rel of matches) unstageOne(rel);
119
56
  } catch (err) {
120
57
  // Defensive escape hatch: only fires if a non-NomadFatal error escapes
121
58
  // the try block. All execFileSync mutation failures are wrapped in
@@ -137,78 +74,82 @@ export function cmdDropSession(id: string): void {
137
74
  }
138
75
 
139
76
  /**
140
- * Expand a repo-relative directory into its staged entries via
141
- * `git ls-files -z -- <dirRel>` (argv-array form, NUL-split for path
142
- * safety). Returns repo-relative POSIX paths for every staged file under
143
- * the directory, or an empty array when none are staged or `git` fails
144
- * (missing/corrupt index); the caller then falls through to the existing
145
- * per-entry idempotency guard rather than escalating to a FATAL.
77
+ * Collect repo-relative staged paths matching the session `id`. For each
78
+ * `shared/projects/<logical>/` child, match the flat `<id>.jsonl` plus the
79
+ * sibling subagent directory `<id>/`. The directory is expanded into its
80
+ * staged entries via `expandStagedDir` so every nested file flows through
81
+ * the same per-entry unstage loop as the flat jsonl.
146
82
  *
147
- * @param dirRel Repo-relative directory path (`shared/projects/<logical>/<id>`).
83
+ * @param repoProjects Absolute path to `<REPO_HOME>/shared/projects`.
84
+ * @param id Already-validated session id (see `cmdDropSession`'s entry guard).
85
+ * @returns Repo-relative paths to unstage (possibly empty).
148
86
  */
149
- function expandStagedDir(dirRel: string): string[] {
150
- try {
151
- const out = execFileSync('git', ['ls-files', '-z', '--', dirRel], {
152
- cwd: REPO_HOME,
153
- stdio: ['ignore', 'pipe', 'pipe'],
154
- });
155
- return out
156
- .toString()
157
- .split('\0')
158
- .filter((p) => p !== '');
159
- } catch {
160
- return [];
87
+ function collectMatches(repoProjects: string, id: string): string[] {
88
+ const matches: string[] = [];
89
+ for (const logical of readdirSync(repoProjects)) {
90
+ const candidate = join(repoProjects, logical, `${id}.jsonl`);
91
+ if (existsSync(candidate)) {
92
+ matches.push(relative(REPO_HOME, candidate));
93
+ }
94
+ const dir = join(repoProjects, logical, id);
95
+ if (existsSync(dir) && statSync(dir).isDirectory()) {
96
+ const dirRel = relative(REPO_HOME, dir);
97
+ const staged = expandStagedDir(dirRel);
98
+ // A dir present on disk but absent from the index is an already-dropped
99
+ // rerun: push the dir path itself so the per-entry isInIndex() guard
100
+ // logs it as "already absent" rather than letting an empty match set
101
+ // escalate to the no-match fatal (idempotency for dir-only sessions).
102
+ if (staged.length > 0) matches.push(...staged);
103
+ else matches.push(dirRel);
104
+ }
161
105
  }
106
+ return matches;
162
107
  }
163
108
 
164
109
  /**
165
- * Is `rel` (repo-relative path) present in the HEAD tree? Wraps
166
- * `git cat-file -e HEAD:<rel>`: exit 0 means tracked in HEAD,
167
- * non-zero means either no HEAD exists yet (empty repo) or the path is
168
- * only in the index (newly-staged-not-in-HEAD). `git ls-files
169
- * --error-unmatch` is NOT a HEAD-presence check; it matches anything in
170
- * the index too, which would misclassify newly-staged paths.
110
+ * Unstage one repo-relative path via the tracked-vs-newly-staged primitive.
111
+ * Skips paths absent from the index (Pitfall 7 idempotency guard), then
112
+ * classifies via `isTrackedInHead` and unstages with `git restore
113
+ * --staged --worktree` (tracked-in-HEAD) or `git rm --cached -f`
114
+ * (newly-staged). Git calls keep `execFileSync` argv-array form (PUSH-04).
171
115
  *
172
- * The catch deliberately collapses three cases to `false`: (a) HEAD has
173
- * no commit yet (fresh `git init`), (b) HEAD is unresolvable / corrupt
174
- * (e.g., `.git/refs/heads/main` was deleted manually), and (c) the
175
- * specific path simply does not exist in a valid HEAD. Git produces the
176
- * same exit 128 and the same stderr (`fatal: invalid object name 'HEAD'`)
177
- * for (a) and (b), so a probe-based distinction would require additional
178
- * git-plumbing reads (`rev-parse --verify HEAD`, `.git/refs/heads/`
179
- * inspection) that are brittle and break the empty-repo path every
180
- * existing test runs through. The downstream `git rm --cached -f` is
181
- * idempotent and produces the user-intended unstage outcome regardless
182
- * of which case fired, so the collapsed return is intentional. Repo
183
- * health belongs to `nomad doctor`, not drop-session.
116
+ * @param rel Repo-relative path to unstage.
117
+ * @throws NomadFatal when the underlying git invocation fails.
184
118
  */
185
- function isTrackedInHead(rel: string): boolean {
186
- try {
187
- execFileSync('git', ['cat-file', '-e', `HEAD:${rel}`], {
188
- cwd: REPO_HOME,
189
- stdio: ['ignore', 'pipe', 'pipe'],
190
- });
191
- return true;
192
- } catch {
193
- return false;
119
+ function unstageOne(rel: string): void {
120
+ // Pitfall 7: skip files that are not in the index at all (the
121
+ // load-bearing guard for the idempotent second-run case, where the
122
+ // first drop already removed the entry from the index but left the
123
+ // working tree file in place).
124
+ if (!isInIndex(rel)) {
125
+ log(`dropped ${rel} (already absent from index)`);
126
+ return;
194
127
  }
195
- }
196
-
197
- /**
198
- * Is `rel` present in the index at all? Wraps `git ls-files -- <rel>` and
199
- * checks for non-empty stdout. Required for the Pitfall 7 idempotency
200
- * guard: a second invocation on the same id finds the file on disk (per
201
- * `existsSync`) but absent from the index, and must NOT call `git rm
202
- * --cached` on it (which would fail with exit 128).
203
- */
204
- function isInIndex(rel: string): boolean {
205
128
  try {
206
- const out = execFileSync('git', ['ls-files', '--', rel], {
207
- cwd: REPO_HOME,
208
- stdio: ['ignore', 'pipe', 'pipe'],
209
- });
210
- return out.toString().trim() !== '';
211
- } catch {
212
- return false;
129
+ if (isTrackedInHead(rel)) {
130
+ execFileSync('git', ['restore', '--staged', '--worktree', '--', rel], {
131
+ cwd: REPO_HOME,
132
+ stdio: ['ignore', 'pipe', 'pipe'],
133
+ });
134
+ } else {
135
+ execFileSync('git', ['rm', '--cached', '-f', '--', rel], {
136
+ cwd: REPO_HOME,
137
+ stdio: ['ignore', 'pipe', 'pipe'],
138
+ });
139
+ }
140
+ } catch (err) {
141
+ // Convert raw execFileSync failures (non-zero git exit, EACCES on
142
+ // .git/index, EPERM, etc.) into NomadFatal so the outer catch can
143
+ // emit a clean `✗ ...` line instead of letting the ExecException
144
+ // bubble past nomad.ts's NomadFatal-only dispatcher.
145
+ // The `?? err.message` fallback only fires when git fails without
146
+ // producing stderr (spawn-level error before the process emits
147
+ // anything), which `cmdPush`'s gitleaks probe already rules out
148
+ // for the typical install path. Excluded from coverage.
149
+ const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
150
+ /* c8 ignore next */
151
+ const detail = e.stderr?.toString().trim() ?? e.message;
152
+ throw new NomadFatal(`git failed to unstage ${rel}: ${detail}`);
213
153
  }
154
+ log(`dropped ${rel}`);
214
155
  }
@@ -7,8 +7,9 @@ import { applySharedLinks, regenerateSettings } from './links.ts';
7
7
  import { computePreview } from './preview.ts';
8
8
  import { remapPull } from './remap.ts';
9
9
  import { emitSummary } from './summary.ts';
10
- // prettier-ignore
11
- import { acquireLock, die, fail, freshBackupTs, gitOrFatal, log, NomadFatal, releaseLock } from './utils.ts';
10
+ import { die, fail, gitOrFatal, log, NomadFatal } from './utils.ts';
11
+ import { freshBackupTs } from './utils.fs.ts';
12
+ import { acquireLock, releaseLock } from './utils.lockfile.ts';
12
13
 
13
14
  /**
14
15
  * `nomad pull` command. Acquires the push/pull lock, takes a backup
@@ -0,0 +1,119 @@
1
+ import { NEVER_SYNC, PUSH_ALLOWED_STATIC, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
2
+ import { fail, NomadFatal } from './utils.ts';
3
+
4
+ /**
5
+ * Match `path` against an entry in the push allow-list. Exact match for
6
+ * non-`/`-terminated entries; prefix match for `/`-terminated entries; and
7
+ * a special case for `hosts/`: only `hosts/<name>.json` (single-level,
8
+ * `.json` extension) is allowed, so arbitrary credentials like
9
+ * `hosts/dell-wsl.key` are rejected even though they share the prefix.
10
+ */
11
+ function isAllowed(path: string, allowed: readonly string[]): boolean {
12
+ for (const entry of allowed) {
13
+ if (path === entry) return true;
14
+ if (entry === 'hosts/') {
15
+ if (/^hosts\/[^/]+\.json$/.test(path)) return true;
16
+ continue;
17
+ }
18
+ if (entry.endsWith('/') && path.startsWith(entry)) return true;
19
+ }
20
+ return false;
21
+ }
22
+
23
+ /**
24
+ * True when any path segment matches a `NEVER_SYNC` entry (hard-block list).
25
+ * Scope exception (Pitfall 6): paths beginning with `shared/extras/` are
26
+ * exempt. The segment list was authored against `~/.claude/` semantics for
27
+ * ephemeral Claude Code state (`todos/`, `shell-snapshots/`, etc.); inside
28
+ * the extras tree, `.planning/todos/` is a meaningful GSD-managed path. The
29
+ * narrowed scope preserves the original hard-block for all other surface.
30
+ */
31
+ function isNeverSync(path: string): boolean {
32
+ if (path.startsWith('shared/extras/')) return false;
33
+ for (const segment of path.split('/')) {
34
+ if (NEVER_SYNC.has(segment)) return true;
35
+ }
36
+ return false;
37
+ }
38
+
39
+ /**
40
+ * Parse `git status --porcelain=v1 -z` (NUL-delimited) output into a flat
41
+ * list of paths. Handles rename (`R`) and copy (`C`) records, which span
42
+ * two NUL fields (`XY new\0old\0`): both halves are returned so the
43
+ * allow-list can reject either side. `-z` avoids the quoting that LF
44
+ * porcelain applies to paths containing spaces or specials, which would
45
+ * otherwise cause parser misclassification.
46
+ */
47
+ export function parsePorcelainZ(statusPorcelain: string): string[] {
48
+ const records = statusPorcelain.split('\0');
49
+ const paths: string[] = [];
50
+ for (let i = 0; i < records.length; i++) {
51
+ const rec = records[i];
52
+ if (rec === undefined || rec === '') continue;
53
+ // Each record starts with "XY " (2 status chars + 1 space). The path is
54
+ // everything after byte 3. For R/C the NEXT record holds the old path.
55
+ if (rec.length < 4) continue;
56
+ const xy = rec.slice(0, 2);
57
+ const newPath = rec.slice(3);
58
+ paths.push(newPath);
59
+ // Check BOTH XY positions: X is the index status, Y is the working-tree
60
+ // status. Either can carry R (rename) or C (copy), and the old-path record
61
+ // follows the new-path record in -z porcelain regardless of which column
62
+ // detected the rename. Missing the Y-column case (e.g. ` R`) would skip
63
+ // the consume and let the next iteration misread the old path as a new
64
+ // record, smuggling unallowed sources past the allow-list.
65
+ if (/[RC]/.test(xy)) {
66
+ const oldPath = records[i + 1];
67
+ if (oldPath !== undefined && oldPath !== '') paths.push(oldPath);
68
+ i++; // consume the paired old-path record
69
+ }
70
+ }
71
+ return paths;
72
+ }
73
+
74
+ /**
75
+ * Reject any staged path that is not on the push allow-list or that matches a
76
+ * `NEVER_SYNC` entry. Builds the runtime allow-list by combining
77
+ * `PUSH_ALLOWED_STATIC` with one `shared/projects/<logical>/` prefix per entry
78
+ * in `path-map.json` AND, per (logical, whitelisted name) pair in
79
+ * `map.extras ?? {}`, an exact `shared/extras/<logical>/<name>` entry plus a
80
+ * `shared/extras/<logical>/<name>/` prefix entry (Pitfall 4 closed:
81
+ * data-driven, no hand-rolled bypass). The exact entry permits the declared
82
+ * name when it is a single root file (e.g. `CLAUDE.md`); the prefix entry
83
+ * permits the declared name's subtree when it is a directory. Neither widens
84
+ * to a logical-only prefix, so an arbitrary sibling file under the same
85
+ * logical stays rejected. The name filter (`SUPPORTED_EXTRAS`) is the same one
86
+ * `remapExtrasPush` honors, so manually staged content under a non-whitelisted
87
+ * name surfaces as a FATAL instead of riding through. Logs every violation as
88
+ * a FATAL line so the user sees the full set (not just the first), then throws
89
+ * `NomadFatal` to unwind the caller's try/finally and release the push lock.
90
+ */
91
+ export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
92
+ const extrasWhitelist: readonly string[] = SUPPORTED_EXTRAS;
93
+ const allowed = [
94
+ ...PUSH_ALLOWED_STATIC,
95
+ ...Object.keys(map.projects).map((l) => `shared/projects/${l}/`),
96
+ ...Object.entries(map.extras ?? {}).flatMap(([l, names]) =>
97
+ names
98
+ .filter((n) => extrasWhitelist.includes(n))
99
+ .flatMap((n) => [`shared/extras/${l}/${n}`, `shared/extras/${l}/${n}/`]),
100
+ ),
101
+ ];
102
+ const neverSyncHits: string[] = [];
103
+ const violations: string[] = [];
104
+ for (const path of parsePorcelainZ(statusPorcelain)) {
105
+ if (isNeverSync(path)) {
106
+ neverSyncHits.push(path);
107
+ } else if (!isAllowed(path, allowed)) {
108
+ violations.push(path);
109
+ }
110
+ }
111
+ if (neverSyncHits.length === 0 && violations.length === 0) return;
112
+ for (const p of neverSyncHits) {
113
+ fail(`${p} is in NEVER_SYNC and must never be pushed`);
114
+ }
115
+ for (const p of violations) {
116
+ fail(`to sync ${p}, add to PUSH_ALLOWED in src/config.ts`);
117
+ }
118
+ throw new NomadFatal('push allow-list violations');
119
+ }
@@ -1,132 +1,17 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join, relative } from 'node:path';
3
3
 
4
- // prettier-ignore
5
- import { HOME, HOST, NEVER_SYNC, PUSH_ALLOWED_STATIC, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
4
+ import { HOME, HOST, REPO_HOME } from './config.ts';
5
+ import { enforceAllowList } from './commands.push.allowlist.ts';
6
6
  import { remapExtrasPush } from './extras-sync.ts';
7
7
  import { findGitlinks, probeGitleaks, rebaseBeforePush } from './push-checks.ts';
8
8
  import { runGitleaksScan } from './push-gitleaks.ts';
9
9
  import { remapPush } from './remap.ts';
10
10
  import { emitSummary } from './summary.ts';
11
- // prettier-ignore
12
- import { acquireLock, die, fail, freshBackupTs, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal, readPathMap, releaseLock } from './utils.ts';
13
-
14
- /**
15
- * Match `path` against an entry in the push allow-list. Exact match for
16
- * non-`/`-terminated entries; prefix match for `/`-terminated entries; and
17
- * a special case for `hosts/`: only `hosts/<name>.json` (single-level,
18
- * `.json` extension) is allowed, so arbitrary credentials like
19
- * `hosts/dell-wsl.key` are rejected even though they share the prefix.
20
- */
21
- function isAllowed(path: string, allowed: readonly string[]): boolean {
22
- for (const entry of allowed) {
23
- if (path === entry) return true;
24
- if (entry === 'hosts/') {
25
- if (/^hosts\/[^/]+\.json$/.test(path)) return true;
26
- continue;
27
- }
28
- if (entry.endsWith('/') && path.startsWith(entry)) return true;
29
- }
30
- return false;
31
- }
32
-
33
- /**
34
- * True when any path segment matches a `NEVER_SYNC` entry (hard-block list).
35
- * Scope exception (Pitfall 6): paths beginning with `shared/extras/` are
36
- * exempt. The segment list was authored against `~/.claude/` semantics for
37
- * ephemeral Claude Code state (`todos/`, `shell-snapshots/`, etc.); inside
38
- * the extras tree, `.planning/todos/` is a meaningful GSD-managed path. The
39
- * narrowed scope preserves the original hard-block for all other surface.
40
- */
41
- function isNeverSync(path: string): boolean {
42
- if (path.startsWith('shared/extras/')) return false;
43
- for (const segment of path.split('/')) {
44
- if (NEVER_SYNC.has(segment)) return true;
45
- }
46
- return false;
47
- }
48
-
49
- /**
50
- * Parse `git status --porcelain=v1 -z` (NUL-delimited) output into a flat
51
- * list of paths. Handles rename (`R`) and copy (`C`) records, which span
52
- * two NUL fields (`XY new\0old\0`): both halves are returned so the
53
- * allow-list can reject either side. `-z` avoids the quoting that LF
54
- * porcelain applies to paths containing spaces or specials, which would
55
- * otherwise cause parser misclassification.
56
- */
57
- export function parsePorcelainZ(statusPorcelain: string): string[] {
58
- const records = statusPorcelain.split('\0');
59
- const paths: string[] = [];
60
- for (let i = 0; i < records.length; i++) {
61
- const rec = records[i];
62
- if (rec === undefined || rec === '') continue;
63
- // Each record starts with "XY " (2 status chars + 1 space). The path is
64
- // everything after byte 3. For R/C the NEXT record holds the old path.
65
- if (rec.length < 4) continue;
66
- const xy = rec.slice(0, 2);
67
- const newPath = rec.slice(3);
68
- paths.push(newPath);
69
- // Check BOTH XY positions: X is the index status, Y is the working-tree
70
- // status. Either can carry R (rename) or C (copy), and the old-path record
71
- // follows the new-path record in -z porcelain regardless of which column
72
- // detected the rename. Missing the Y-column case (e.g. ` R`) would skip
73
- // the consume and let the next iteration misread the old path as a new
74
- // record, smuggling unallowed sources past the allow-list.
75
- if (/[RC]/.test(xy)) {
76
- const oldPath = records[i + 1];
77
- if (oldPath !== undefined && oldPath !== '') paths.push(oldPath);
78
- i++; // consume the paired old-path record
79
- }
80
- }
81
- return paths;
82
- }
83
-
84
- /**
85
- * Reject any staged path that is not on the push allow-list or that matches a
86
- * `NEVER_SYNC` entry. Builds the runtime allow-list by combining
87
- * `PUSH_ALLOWED_STATIC` with one `shared/projects/<logical>/` prefix per entry
88
- * in `path-map.json` AND, per (logical, whitelisted name) pair in
89
- * `map.extras ?? {}`, an exact `shared/extras/<logical>/<name>` entry plus a
90
- * `shared/extras/<logical>/<name>/` prefix entry (Pitfall 4 closed:
91
- * data-driven, no hand-rolled bypass). The exact entry permits the declared
92
- * name when it is a single root file (e.g. `CLAUDE.md`); the prefix entry
93
- * permits the declared name's subtree when it is a directory. Neither widens
94
- * to a logical-only prefix, so an arbitrary sibling file under the same
95
- * logical stays rejected. The name filter (`SUPPORTED_EXTRAS`) is the same one
96
- * `remapExtrasPush` honors, so manually staged content under a non-whitelisted
97
- * name surfaces as a FATAL instead of riding through. Logs every violation as
98
- * a FATAL line so the user sees the full set (not just the first), then throws
99
- * `NomadFatal` to unwind the caller's try/finally and release the push lock.
100
- */
101
- export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
102
- const extrasWhitelist: readonly string[] = SUPPORTED_EXTRAS;
103
- const allowed = [
104
- ...PUSH_ALLOWED_STATIC,
105
- ...Object.keys(map.projects).map((l) => `shared/projects/${l}/`),
106
- ...Object.entries(map.extras ?? {}).flatMap(([l, names]) =>
107
- names
108
- .filter((n) => extrasWhitelist.includes(n))
109
- .flatMap((n) => [`shared/extras/${l}/${n}`, `shared/extras/${l}/${n}/`]),
110
- ),
111
- ];
112
- const neverSyncHits: string[] = [];
113
- const violations: string[] = [];
114
- for (const path of parsePorcelainZ(statusPorcelain)) {
115
- if (isNeverSync(path)) {
116
- neverSyncHits.push(path);
117
- } else if (!isAllowed(path, allowed)) {
118
- violations.push(path);
119
- }
120
- }
121
- if (neverSyncHits.length === 0 && violations.length === 0) return;
122
- for (const p of neverSyncHits) {
123
- fail(`${p} is in NEVER_SYNC and must never be pushed`);
124
- }
125
- for (const p of violations) {
126
- fail(`to sync ${p}, add to PUSH_ALLOWED in src/config.ts`);
127
- }
128
- throw new NomadFatal('push allow-list violations');
129
- }
11
+ import { die, fail, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal } from './utils.ts';
12
+ import { freshBackupTs } from './utils.fs.ts';
13
+ import { readPathMap } from './utils.json.ts';
14
+ import { acquireLock, releaseLock } from './utils.lockfile.ts';
130
15
 
131
16
  /**
132
17
  * `nomad push` command. Acquires the lock, runs the four pre-push safety
@@ -0,0 +1,90 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ import { REPO_HOME } from './config.ts';
4
+ import { log, NomadFatal } from './utils.ts';
5
+
6
+ /**
7
+ * Get the current Git branch name for the repository at REPO_HOME.
8
+ *
9
+ * Wraps the failure path so a corrupt or missing `.git` directory surfaces as
10
+ * ``✗ ...`` via the top-level dispatcher's `NomadFatal` catch
11
+ * rather than a raw `ExecException` stack trace.
12
+ *
13
+ * @returns The current branch name (trimmed).
14
+ * @throws NomadFatal when the git command fails; if the command produced stderr, that stderr is written to process.stderr before the exception is thrown.
15
+ */
16
+ export function currentBranch(): string {
17
+ try {
18
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
19
+ cwd: REPO_HOME,
20
+ stdio: ['ignore', 'pipe', 'pipe'],
21
+ })
22
+ .toString()
23
+ .trim();
24
+ } catch (err) {
25
+ const e = err as Error & { stderr?: Buffer };
26
+ if (e.stderr) process.stderr.write(e.stderr);
27
+ throw new NomadFatal('git rev-parse --abbrev-ref HEAD failed');
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Read and return the current `HEAD` commit SHA from the repository.
33
+ *
34
+ * Used to pin the pre-update commit so the post-update lockfile diff is
35
+ * exact regardless of whether the pull was a fast-forward, a no-op, or a
36
+ * merge. `HEAD@{1}` is unreliable here: a no-op `git pull --ff-only` does
37
+ * not always write a reflog entry, and a freshly cloned repo has no
38
+ * `HEAD@{1}` at all.
39
+ *
40
+ * @returns The `HEAD` commit SHA as a trimmed string.
41
+ * @throws NomadFatal if `git rev-parse HEAD` fails (stderr is written to stderr when present).
42
+ */
43
+ export function headSha(): string {
44
+ try {
45
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
46
+ cwd: REPO_HOME,
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ })
49
+ .toString()
50
+ .trim();
51
+ } catch (err) {
52
+ const e = err as Error & { stderr?: Buffer };
53
+ if (e.stderr) process.stderr.write(e.stderr);
54
+ throw new NomadFatal('git rev-parse HEAD failed');
55
+ }
56
+ }
57
+
58
+ /**
59
+ * List files changed between the given commit and the current HEAD.
60
+ *
61
+ * @param beforeSha - Commit SHA to compare against HEAD
62
+ * @returns An array of file paths changed between `beforeSha` and `HEAD`; an empty array if there are no changes
63
+ */
64
+ export function changedFilesSince(beforeSha: string): string[] {
65
+ const out = execFileSync('git', ['diff', '--name-only', `${beforeSha}..HEAD`], {
66
+ cwd: REPO_HOME,
67
+ stdio: ['ignore', 'pipe', 'pipe'],
68
+ }).toString();
69
+ return out.split('\n').filter((line) => line !== '');
70
+ }
71
+
72
+ /**
73
+ * Run `npm install` in the repository only if `package-lock.json` changed since a given commit.
74
+ *
75
+ * If `package-lock.json` did not change between `beforeSha` and `HEAD`, logs
76
+ * a skip message; otherwise runs `npm install` with working directory set to
77
+ * `REPO_HOME`. Routing through `execFileSync` (no shell) keeps the call
78
+ * mockable in tests and prevents any chance of argv injection.
79
+ *
80
+ * @param beforeSha - Commit SHA to compare against `HEAD` when determining whether the lockfile changed
81
+ */
82
+ export function reinstallIfNeeded(beforeSha: string): void {
83
+ const changed = changedFilesSince(beforeSha);
84
+ if (!changed.includes('package-lock.json')) {
85
+ log('skipping npm install (lockfile unchanged)');
86
+ return;
87
+ }
88
+ log('package-lock.json changed, running npm install');
89
+ execFileSync('npm', ['install'], { cwd: REPO_HOME, stdio: 'inherit' });
90
+ }