claude-nomad 0.26.0 → 0.26.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.26.2](https://github.com/funkadelic/claude-nomad/compare/v0.26.1...v0.26.2) (2026-05-28)
4
+
5
+
6
+ ### Fixed
7
+
8
+ * **gitleaks:** condense push output and group doctor check-shared layout ([#161](https://github.com/funkadelic/claude-nomad/issues/161)) ([d9e5758](https://github.com/funkadelic/claude-nomad/commit/d9e57589f616e70b8e2d6249c957af55a25a578b))
9
+
10
+ ## [0.26.1](https://github.com/funkadelic/claude-nomad/compare/v0.26.0...v0.26.1) (2026-05-27)
11
+
12
+
13
+ ### Fixed
14
+
15
+ * **preview:** replace diffJsonStrings parallel walk with jsdiff LCS line diff ([#159](https://github.com/funkadelic/claude-nomad/issues/159)) ([d9c0dca](https://github.com/funkadelic/claude-nomad/commit/d9c0dcae7b417d069e826ce3b825e9f23115ba2d))
16
+ * **remap:** fail closed on path-map collisions in remapPush before any write ([#156](https://github.com/funkadelic/claude-nomad/issues/156)) ([db33157](https://github.com/funkadelic/claude-nomad/commit/db33157faa02b5439278ae4e92a02bd671faee72))
17
+
18
+
19
+ ### Changed
20
+
21
+ * **codeql:** skip analysis on release-please PR branches ([#160](https://github.com/funkadelic/claude-nomad/issues/160)) ([2bfed46](https://github.com/funkadelic/claude-nomad/commit/2bfed463ccd76f6331e5ab775bbd5d19df2898e9))
22
+ * **tests:** skip PR-time test matrix on release-please PR branches ([#158](https://github.com/funkadelic/claude-nomad/issues/158)) ([222e27b](https://github.com/funkadelic/claude-nomad/commit/222e27b2643382136505f72e148c17c7ecb7a08f))
23
+
3
24
  ## [0.26.0](https://github.com/funkadelic/claude-nomad/compare/v0.25.5...v0.26.0) (2026-05-27)
4
25
 
5
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "type": "module",
5
5
  "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
6
  "keywords": [
@@ -80,6 +80,7 @@
80
80
  "vitest": "^4.1.6"
81
81
  },
82
82
  "dependencies": {
83
+ "diff": "^9.0.0",
83
84
  "picocolors": "^1.1.1",
84
85
  "tsx": "^4.22.2"
85
86
  }
@@ -7,14 +7,14 @@
7
7
  * Owns the post-stage block: run the shared `scanStagedTree` (the same git
8
8
  * init + add + `gitleaks protect --staged` mechanism push uses), classify the
9
9
  * findings via `partitionFindings`, and emit the doctor glyph rows (clean,
10
- * per-session leak with rotate-and-scrub guidance, and the nested "other"
11
- * bucket). All external work flows through `scanStagedTree`; this module spawns
10
+ * per-session leak rows, then a Remediation block, then a Finding types legend).
11
+ * All external work flows through `scanStagedTree`; this module spawns
12
12
  * nothing itself.
13
13
  */
14
14
 
15
15
  import { join } from 'node:path';
16
16
 
17
- import { green, red, dim, okGlyph, failGlyph } from './color.ts';
17
+ import { green, red, dim, bold, okGlyph, failGlyph } from './color.ts';
18
18
  import { addItem, type DoctorSection } from './commands.doctor.format.ts';
19
19
  import { CLAUDE_HOME } from './config.ts';
20
20
  import { type Finding, partitionFindings, scanStagedTree } from './push-gitleaks.ts';
@@ -32,31 +32,18 @@ function scrubPath(logical: string, sid: string, logicalToEncoded: Map<string, s
32
32
  }
33
33
 
34
34
  /**
35
- * Emit one fail row per affected session plus rotate-and-scrub + allowlist
36
- * guidance, and set `process.exitCode = 1`. `logicalBySession` carries the
37
- * `<logical>` captured from the same match that keyed `bySession`, so the
38
- * scrub-path hint reuses the authoritative parse. The hint guard omits a row
39
- * rather than print a wrong path if the invariant ever breaks; the leak row is
40
- * always emitted.
35
+ * Emit one fail row per affected session (the `✗ ... in session` rows only)
36
+ * and set `process.exitCode = 1`. Rotate-and-scrub guidance and the
37
+ * false-positive hint are NOT emitted here; they belong to `reportRemediation`
38
+ * which is called after all finding rows.
41
39
  */
42
40
  function reportSessionFindings(
43
41
  section: DoctorSection,
44
42
  bySession: Map<string, Map<string, number>>,
45
- logicalBySession: Map<string, string>,
46
- logicalToEncoded: Map<string, string>,
47
43
  ): void {
48
44
  for (const [sid, counts] of bySession) {
49
45
  const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
50
46
  addItem(section, `${red(failGlyph)} ${red(summary)} in session ${sid}`);
51
- const logical = logicalBySession.get(sid);
52
- /* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
53
- if (logical !== undefined) {
54
- addItem(
55
- section,
56
- ` ${dim(`rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`)}`,
57
- );
58
- }
59
- addItem(section, ` ${dim('false positive? add a pattern to .gitleaks.toml')}`);
60
47
  }
61
48
  process.exitCode = 1;
62
49
  }
@@ -75,6 +62,36 @@ function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
75
62
  process.exitCode = 1;
76
63
  }
77
64
 
65
+ /**
66
+ * Emit the remediation block after all finding rows. Findings-first ordering
67
+ * means guidance is grouped at the end, not interleaved between session rows.
68
+ * Emits a leading blank, a bold `Remediation` header, one rotate-and-scrub
69
+ * line per session (using `logicalBySession` to build the scrub path), and
70
+ * exactly ONE false-positive hint after the loop (deduped, not once per session).
71
+ * The hint guard omits a rotate row rather than print a wrong path if the
72
+ * invariant ever breaks; the false-positive line is always appended.
73
+ */
74
+ function reportRemediation(
75
+ section: DoctorSection,
76
+ bySession: Map<string, Map<string, number>>,
77
+ logicalBySession: Map<string, string>,
78
+ logicalToEncoded: Map<string, string>,
79
+ ): void {
80
+ addItem(section, '');
81
+ addItem(section, bold('Remediation'));
82
+ for (const [sid] of bySession) {
83
+ const logical = logicalBySession.get(sid);
84
+ /* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
85
+ if (logical !== undefined) {
86
+ addItem(
87
+ section,
88
+ ` ${dim(`- rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`)}`,
89
+ );
90
+ }
91
+ }
92
+ addItem(section, ` ${dim('- false positive? add a pattern to .gitleaks.toml')}`);
93
+ }
94
+
78
95
  /**
79
96
  * Captures both the `<logical>` segment and the `<sid>` from a repo-relative
80
97
  * `shared/projects/<logical>/<sid>.jsonl` path. The session-id group matches
@@ -112,14 +129,14 @@ function buildLogicalBySession(findings: Finding[]): Map<string, string> {
112
129
  }
113
130
 
114
131
  /**
115
- * Emit a deduplicated description legend in the footer: one `[rule-id]:
116
- * description` row per distinct RuleID across all findings, set off by a blank
117
- * line before and after. Sourced from the `Description` gitleaks bakes into
118
- * each finding, so it needs no network; rules whose description is absent
119
- * (older gitleaks, custom rules) are skipped, and the whole block (including
120
- * the surrounding blanks) is omitted when no descriptions are available. The
121
- * legend lives in the footer so a rule hit across many files or sessions
122
- * (e.g. `sonar-api-token`) is explained once, not per occurrence.
132
+ * Emit a deduplicated description legend in the footer: one `- [rule-id]:
133
+ * description` row per distinct RuleID across all findings, headed by a bold
134
+ * `Finding types` header with a leading blank but no trailing blank (renderSection
135
+ * attaches the `└` elbow to the last non-empty item). Rules whose description
136
+ * is absent (older gitleaks, custom rules) are skipped, and the whole block
137
+ * (including the surrounding blank and header) is omitted when no descriptions
138
+ * are available. The legend lives in the footer so a rule hit across many files
139
+ * or sessions (e.g. `sonar-api-token`) is explained once, not per occurrence.
123
140
  */
124
141
  function emitDescriptionLegend(section: DoctorSection, findings: Finding[]): void {
125
142
  const descByRule = new Map<string, string>();
@@ -128,16 +145,19 @@ function emitDescriptionLegend(section: DoctorSection, findings: Finding[]): voi
128
145
  }
129
146
  if (descByRule.size === 0) return;
130
147
  addItem(section, '');
148
+ addItem(section, bold('Finding types'));
131
149
  for (const [rule, desc] of descByRule) {
132
- addItem(section, ` ${red(`[${rule}]`)}: ${dim(desc)}`);
150
+ addItem(section, ` ${red(`- [${rule}]`)}: ${dim(desc)}`);
133
151
  }
134
- addItem(section, '');
135
152
  }
136
153
 
137
154
  /**
138
155
  * Scan the staged temp tree through the shared `scanStagedTree` and emit the
139
- * result rows. Isolates the deepest nesting from `reportCheckShared`: the scan
140
- * try/catch (failure -> fail row + exit 1, carrying `err.message` only, never
156
+ * result rows in findings-first order: other-bucket leak rows, then per-session
157
+ * leak rows, then a `Remediation` block (gated on bySession.size > 0), then a
158
+ * `Finding types` legend (gated on at least one finding carrying a Description).
159
+ * Isolates the deepest nesting from `reportCheckShared`: the scan try/catch
160
+ * (failure -> fail row + exit 1, carrying `err.message` only, never
141
161
  * stderr/stdout), the unparseable `findings === null` branch, `partitionFindings`,
142
162
  * and the clean / `other` / `bySession` rows. BOTH buckets gate the clean row: a
143
163
  * finding in `other` (nested transcripts matching neither the flat `SESSION_PATH`
@@ -175,8 +195,9 @@ export function scanAndReport(
175
195
  return;
176
196
  }
177
197
  if (other.length > 0) reportOtherFindings(section, other);
198
+ if (bySession.size > 0) reportSessionFindings(section, bySession);
178
199
  if (bySession.size > 0) {
179
- reportSessionFindings(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
200
+ reportRemediation(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
180
201
  }
181
202
  emitDescriptionLegend(section, findings);
182
203
  }
@@ -0,0 +1,43 @@
1
+ import { diffLines } from 'diff';
2
+
3
+ import { green, red } from './color.ts';
4
+
5
+ /**
6
+ * Map a jsdiff `diffLines` result for two pre-stringified JSON strings into
7
+ * an array of unified-diff body lines (the two-line header is the caller's
8
+ * responsibility).
9
+ *
10
+ * Each jsdiff part has a `value` that may span multiple lines and may carry a
11
+ * trailing `\n`. The value is split on `\n` and any trailing empty element
12
+ * (produced by the trailing newline) is dropped so that it does not become a
13
+ * spurious blank output line.
14
+ *
15
+ * Line prefix mapping per part type:
16
+ * - context (neither added nor removed): a single leading space then the line
17
+ * - removed (`part.removed === true`): `red('-' + line)`
18
+ * - added (`part.added === true`): `green('+' + line)`
19
+ *
20
+ * Coloring routes through `color.ts` `red`/`green` helpers, so `NO_COLOR` /
21
+ * non-TTY environments degrade to literal `-` / `+` prefixed lines with no
22
+ * ANSI escape sequences. Picocolors owns the detection logic.
23
+ */
24
+ export function diffLinesToUnified(oldStr: string, newStr: string): string[] {
25
+ const parts = diffLines(oldStr, newStr);
26
+ const lines: string[] = [];
27
+ for (const part of parts) {
28
+ const partLines = part.value.split('\n');
29
+ // A part value ending in '\n' yields a trailing '' after split; drop it.
30
+ if (partLines[partLines.length - 1] === '') {
31
+ partLines.pop();
32
+ }
33
+ const prefix = part.removed
34
+ ? (line: string) => red(`-${line}`)
35
+ : part.added
36
+ ? (line: string) => green(`+${line}`)
37
+ : (line: string) => ` ${line}`;
38
+ for (const line of partLines) {
39
+ lines.push(prefix(line));
40
+ }
41
+ }
42
+ return lines;
43
+ }
package/src/preview.ts CHANGED
@@ -1,53 +1,34 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { green, red } from './color.ts';
5
4
  import { CLAUDE_HOME, HOST, REPO_HOME } from './config.ts';
5
+ import { diffLinesToUnified } from './diff-lines.ts';
6
6
  import { applySharedLinks } from './links.ts';
7
7
  import { remapPull } from './remap.ts';
8
8
  import { log } from './utils.ts';
9
9
  import { deepMerge, readJson } from './utils.json.ts';
10
10
 
11
11
  /**
12
- * Minimal in-tree unified-diff helper for two pre-stringified JSON
13
- * documents. Walks the line arrays in parallel and emits a unified-diff
14
- * style output: unchanged lines prefixed with a space, removed lines with
15
- * `-` (red), added lines with `+` (green), plus at most three lines of
16
- * surrounding context per changed block. The implementation is intentionally
17
- * naive (no LCS); for two ~50-line settings JSON inputs the result is
18
- * acceptable even if not optimal.
12
+ * LCS line diff for two pre-stringified JSON documents via jsdiff. Returns a
13
+ * unified-diff style string: the two literal header lines
14
+ * `--- ~/.claude/settings.json` and `+++ would write`, followed by body lines
15
+ * where unchanged lines are prefixed with a space, removed lines with `-`
16
+ * (red), and added lines with `+` (green). Coloring routes through `color.ts`
17
+ * so `NO_COLOR` / non-TTY environments degrade to literal prefixes with no
18
+ * ANSI escape sequences.
19
19
  *
20
- * Returns the empty string when the inputs are byte-identical so the caller
21
- * can suppress the section. Picocolors handles `NO_COLOR` / `FORCE_COLOR`
22
- * detection, so the `red`/`green` wrappers degrade to identity in non-TTY
23
- * environments and the output stays literal `-` / `+` prefixed.
24
- *
25
- * The header line `--- ~/.claude/settings.json` / `+++ would write` is
26
- * literal; callers that want a different header can prepend their own.
20
+ * Returns the empty string when inputs are byte-identical so the caller can
21
+ * suppress the section. jsdiff `diffLines` aligns on the longest common
22
+ * subsequence, so a mid-document insertion does not cascade false `-`/`+`
23
+ * pairs for the unchanged tail.
27
24
  */
28
25
  export function diffJsonStrings(currentJsonText: string, newJsonText: string): string {
29
26
  if (currentJsonText === newJsonText) return '';
30
- const a = currentJsonText.split('\n');
31
- const b = newJsonText.split('\n');
32
- const lines: string[] = [];
33
- lines.push('--- ~/.claude/settings.json');
34
- lines.push('+++ would write');
35
-
36
- // Walk both arrays in parallel. Lines that match index-wise are context;
37
- // others are emitted as -a / +b. A real unified-diff would compute the
38
- // longest common subsequence; this naive walk is good enough for two JSON
39
- // documents pretty-printed at the same indentation level.
40
- const maxLen = Math.max(a.length, b.length);
41
- for (let i = 0; i < maxLen; i++) {
42
- const av = a[i];
43
- const bv = b[i];
44
- if (av === bv) {
45
- if (av !== undefined) lines.push(` ${av}`);
46
- continue;
47
- }
48
- if (av !== undefined) lines.push(red(`-${av}`));
49
- if (bv !== undefined) lines.push(green(`+${bv}`));
50
- }
27
+ const lines: string[] = [
28
+ '--- ~/.claude/settings.json',
29
+ '+++ would write',
30
+ ...diffLinesToUnified(currentJsonText, newJsonText),
31
+ ];
51
32
  return lines.join('\n');
52
33
  }
53
34
 
@@ -83,10 +83,12 @@ export function readGitleaksReport(reportPath: string): Finding[] | null {
83
83
  *
84
84
  * `forwardStreams` (default `false`): when `true`, the gitleaks redacted
85
85
  * stderr/stdout captured on a non-zero exit is written to the process streams
86
- * so the operator sees which file is dirty. `runGitleaksScan` passes `true`
87
- * (byte-identical push behavior); the read-only `--check-shared` preflight
88
- * leaves it `false` so it never writes to stderr (its scan-failed row carries
89
- * the error message only, never the streams).
86
+ * ONLY on the scan-crash path (when the report is unparseable or missing, i.e.
87
+ * `readGitleaksReport` returns `null`). On the leaks-found path the report
88
+ * parses to a findings array, the structured caller FATAL fully describes the
89
+ * findings, and the raw streams are suppressed to avoid printing them twice.
90
+ * `runGitleaksScan` passes `true`; the read-only `--check-shared` preflight
91
+ * leaves it `false` so it never writes to streams on any path.
90
92
  */
91
93
  export function scanStagedTree(repoDir: string, forwardStreams = false): Finding[] | null {
92
94
  const cacheDir = join(homedir(), '.cache', 'claude-nomad');
@@ -111,11 +113,12 @@ export function scanStagedTree(repoDir: string, forwardStreams = false): Finding
111
113
  } catch (err) {
112
114
  const e = err as NodeJS.ErrnoException & { stderr?: Buffer; stdout?: Buffer };
113
115
  if (e.code === 'ENOENT') throw err;
114
- if (forwardStreams) {
116
+ const report = readGitleaksReport(reportPath);
117
+ if (forwardStreams && report === null) {
115
118
  if (e.stderr) process.stderr.write(e.stderr);
116
119
  if (e.stdout) process.stdout.write(e.stdout);
117
120
  }
118
- return readGitleaksReport(reportPath);
121
+ return report;
119
122
  } finally {
120
123
  rmSync(reportPath, { force: true });
121
124
  }
@@ -92,6 +92,20 @@ export function partitionFindings(findings: Finding[]): {
92
92
  * session, since those nested paths route to the `other` bucket and are
93
93
  * not listed per-session. Pure.
94
94
  */
95
+ /**
96
+ * Render one `Also found:` row for a non-session ("other"-bucket) finding as
97
+ * ` <File>:<StartLine> <RuleID>`, where the line number is the manual-scrub
98
+ * locator for the nested transcript. `StartLine` is typed `number` but comes
99
+ * from an unvalidated `parsed as Finding[]` cast over gitleaks subprocess
100
+ * output, so a missing or non-positive value (gitleaks line numbers are
101
+ * 1-indexed) drops the `:<line>` suffix rather than emit a confusing
102
+ * `:undefined` / `:0`.
103
+ */
104
+ export function formatOtherFinding(f: Finding): string {
105
+ const loc = Number.isInteger(f.StartLine) && f.StartLine > 0 ? `:${f.StartLine}` : '';
106
+ return ` ${f.File}${loc} ${f.RuleID}`;
107
+ }
108
+
95
109
  export function buildSessionAwareFatal(
96
110
  bySession: Map<string, Map<string, number>>,
97
111
  other: Finding[],
@@ -110,7 +124,7 @@ export function buildSessionAwareFatal(
110
124
  lines.push(
111
125
  '',
112
126
  'Also found:',
113
- ...other.map((f) => ` ${f.File} ${f.RuleID}`),
127
+ ...other.map(formatOtherFinding),
114
128
  ' Review with: git diff --cached, then unstage manually.',
115
129
  );
116
130
  }
@@ -133,8 +147,10 @@ export function buildSessionAwareFatal(
133
147
  * missing/locked file, or a non-finding runtime failure, since gitleaks v8.x
134
148
  * returns exit 1 for both "leaks found" and runtime errors) it throws a
135
149
  * distinct scan-failed FATAL so the operator does not chase a phantom
136
- * `nomad drop-session` recovery; the forwarded stderr/stdout above carries the
137
- * underlying gitleaks output.
150
+ * `nomad drop-session` recovery. On the leaks-found path the raw gitleaks
151
+ * streams are suppressed (the session-aware FATAL fully describes the findings).
152
+ * On the scan-failed/null-report path the raw stderr/stdout is forwarded so
153
+ * "Review the gitleaks output above." has something to point at.
138
154
  *
139
155
  * ENOENT (gitleaks or git absent) propagates from the helper and is mapped to
140
156
  * the platform-aware install-hint FATAL. Defense-in-depth: the presence probe
package/src/remap.ts CHANGED
@@ -2,7 +2,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'no
2
2
  import { join, relative, sep } from 'node:path';
3
3
 
4
4
  import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
5
- import { log } from './utils.ts';
5
+ import { die, log } from './utils.ts';
6
6
  import { backupBeforeWrite, backupRepoWrite } from './utils.fs.ts';
7
7
  import { encodePath, readJson } from './utils.json.ts';
8
8
 
@@ -98,17 +98,67 @@ export function remapPull(ts: string, opts: { dryRun?: boolean } = {}): { unmapp
98
98
  return { unmapped };
99
99
  }
100
100
 
101
+ /**
102
+ * Build the encoded-key to logical-name reverse map for the current host,
103
+ * failing closed on any `path-map.json` shape that would silently lose session
104
+ * data on push. Both failure modes `die()` (throw `NomadFatal`) before the
105
+ * caller writes anything:
106
+ *
107
+ * - Encoded-path collision: two distinct host paths that `encodePath` maps to
108
+ * the same key (every `/` becomes `-`), which would clobber each other under
109
+ * one repo directory.
110
+ * - Duplicate path: two logical names mapping to the same host path, where only
111
+ * one logical could be pushed and the other's `shared/projects/` copy would
112
+ * be orphaned.
113
+ *
114
+ * @param map - the parsed `path-map.json`
115
+ * @returns reverse lookup from encoded local dir name to logical project name
116
+ */
117
+ function buildReverseMap(map: PathMap): Map<string, string> {
118
+ const reverse = new Map<string, string>();
119
+ const encodedPaths = new Map<string, string>();
120
+ for (const [logical, hosts] of Object.entries(map.projects)) {
121
+ const p = hosts[HOST];
122
+ if (!p || p === 'TBD') continue;
123
+ const encoded = encodePath(p);
124
+ const prior = encodedPaths.get(encoded);
125
+ if (prior !== undefined) {
126
+ if (prior !== p) {
127
+ die(
128
+ `encoded-path collision in path-map.json: "${prior}" and "${p}" both encode to` +
129
+ ` "${encoded}" (encodePath replaces every / with -).` +
130
+ ` Edit path-map.json so the two paths do not encode identically.` +
131
+ ` Run nomad doctor for the full list of collisions.`,
132
+ );
133
+ }
134
+ die(
135
+ `duplicate path in path-map.json: logical names "${reverse.get(encoded)}" and "${logical}"` +
136
+ ` both map to "${p}" for ${HOST}, so only one could be pushed and the other's` +
137
+ ` shared/projects/ copy would be orphaned.` +
138
+ ` Edit path-map.json so each host path maps to a single logical name.`,
139
+ );
140
+ }
141
+ encodedPaths.set(encoded, p);
142
+ reverse.set(encoded, logical);
143
+ }
144
+ return reverse;
145
+ }
146
+
101
147
  /**
102
148
  * Push: copy local path-encoded dirs back to repo under logical names.
103
149
  *
104
150
  * Returns `{ unmapped: N, collisions: M }` where `unmapped` is the count of
105
151
  * `~/.claude/projects/<dir>/` entries that have no path-map reverse-lookup
106
- * for this host. `collisions` is reserved for a future slice's path-encoding
107
- * collision detection and is always `0` here.
152
+ * for this host. `collisions` is always `0` on the success path: any
153
+ * `path-map.json` shape that would silently lose data (an encoded-path
154
+ * collision between two distinct host paths, or two logical names mapping to
155
+ * the same host path) makes `buildReverseMap` `die()` (throw `NomadFatal`) to
156
+ * refuse the push before any `shared/projects/` content is written. Detection
157
+ * runs during the reverse-map build, so it fires under `dryRun` too.
108
158
  *
109
159
  * `opts.dryRun` (default `false`): when `true`, log `would push:` lines
110
- * instead of calling `backupRepoWrite` + `copyDir`. Counts are computed
111
- * identically in both modes.
160
+ * instead of calling `backupRepoWrite` + `copyDir`. Collision detection
161
+ * runs identically in both modes.
112
162
  */
113
163
  export function remapPush(
114
164
  ts: string,
@@ -116,7 +166,6 @@ export function remapPush(
116
166
  ): { unmapped: number; collisions: number } {
117
167
  const dryRun = opts.dryRun === true;
118
168
  let unmapped = 0;
119
- const collisions = 0;
120
169
  const mapPath = join(REPO_HOME, 'path-map.json');
121
170
  if (!existsSync(mapPath)) {
122
171
  log('no path-map.json; skipping session export');
@@ -126,16 +175,14 @@ export function remapPush(
126
175
  const map = readJson<PathMap>(mapPath);
127
176
  const localProjects = join(CLAUDE_HOME, 'projects');
128
177
  const repoProjects = join(REPO_HOME, 'shared', 'projects');
129
- if (!dryRun) mkdirSync(repoProjects, { recursive: true });
130
178
 
131
- const reverse = new Map<string, string>();
132
- for (const [logical, hosts] of Object.entries(map.projects)) {
133
- const p = hosts[HOST];
134
- if (!p || p === 'TBD') continue;
135
- reverse.set(encodePath(p), logical);
136
- }
179
+ const reverse = buildReverseMap(map);
180
+ if (!existsSync(localProjects)) return { unmapped, collisions: 0 };
181
+ // Create the repo destination only after collision detection passes and we
182
+ // know there is something to push, so a failing or no-op push is fully
183
+ // side-effect-free (no empty shared/projects/ left behind).
184
+ if (!dryRun) mkdirSync(repoProjects, { recursive: true });
137
185
 
138
- if (!existsSync(localProjects)) return { unmapped, collisions };
139
186
  for (const dir of readdirSync(localProjects)) {
140
187
  const logical = reverse.get(dir);
141
188
  if (!logical) {
@@ -156,5 +203,5 @@ export function remapPush(
156
203
  copyDirJsonlOnly(join(localProjects, dir), repoDst);
157
204
  log(`pushed ${dir} -> ${logical}`);
158
205
  }
159
- return { unmapped, collisions };
206
+ return { unmapped, collisions: 0 };
160
207
  }