claude-nomad 0.17.0

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.
@@ -0,0 +1,173 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+
5
+ import { REPO_HOME } from './config.ts';
6
+ import { acquireLock, die, fail, log, NomadFatal, releaseLock } from './utils.ts';
7
+
8
+ /**
9
+ * Surgical removal of a contaminated session from the staged tree of
10
+ * `~/claude-nomad/`. Walks `shared/projects/<logical>/<id>.jsonl` at the
11
+ * top level only, classifies each match via `git ls-files --error-unmatch`,
12
+ * and unstages with the appropriate primitive:
13
+ *
14
+ * - tracked-in-HEAD -> `git restore --staged --worktree -- <rel>`
15
+ * - newly-staged -> `git rm --cached -f -- <rel>`
16
+ *
17
+ * Idempotent: files that are not in the index at all are skipped silently
18
+ * rather than treated as errors. Exits 0 on any drop, including an
19
+ * idempotent re-run that finds the matches already absent. Exits 1 with
20
+ * `✗ no staged session matches <id>` (red `✗` fail glyph) only when no
21
+ * `shared/projects/<logical>/<id>.jsonl` exists at all in the shared tree.
22
+ *
23
+ * Defense-in-depth: the id is validated against the same allowlist regex
24
+ * used in `src/resume.ts` before any path composition. argv-array form
25
+ * for every git invocation.
26
+ *
27
+ * NEVER touches `~/.claude/projects/<encoded>/<id>.jsonl`. The local file
28
+ * is preserved so it can race-safely coexist with active Claude Code
29
+ * writers; rotate-and-scrub of the local copy is the user's
30
+ * responsibility.
31
+ *
32
+ * @param id Session id (filename minus `.jsonl`). Must match `[A-Za-z0-9_-]+`
33
+ * with length 1..128.
34
+ * @throws NomadFatal when a lower-level helper translates a git or
35
+ * filesystem failure into a fatal. Caught by the try/catch wrapper
36
+ * which routes it to stderr and sets `process.exitCode = 1`.
37
+ */
38
+ export function cmdDropSession(id: string): void {
39
+ if (id.length === 0 || id.length > 128 || !/^[A-Za-z0-9_-]+$/.test(id)) {
40
+ fail(`invalid session id: ${id}`);
41
+ process.exit(1);
42
+ }
43
+ if (!existsSync(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
44
+
45
+ const handle = acquireLock('drop-session');
46
+ if (handle === null) process.exit(0);
47
+ try {
48
+ const repoProjects = join(REPO_HOME, 'shared', 'projects');
49
+ if (!existsSync(repoProjects)) {
50
+ throw new NomadFatal(`no staged session matches ${id}`);
51
+ }
52
+ // Top-level walk only: for each `shared/projects/<logical>/` child,
53
+ // check whether `<id>.jsonl` exists. No descent into
54
+ // subagents/memory/tool-results subdirectories.
55
+ const matches: string[] = [];
56
+ for (const logical of readdirSync(repoProjects)) {
57
+ const candidate = join(repoProjects, logical, `${id}.jsonl`);
58
+ if (existsSync(candidate)) {
59
+ matches.push(candidate);
60
+ }
61
+ }
62
+ if (matches.length === 0) {
63
+ throw new NomadFatal(`no staged session matches ${id}`);
64
+ }
65
+ for (const m of matches) {
66
+ const rel = relative(REPO_HOME, m);
67
+ // Pitfall 7: skip files that are not in the index at all (the
68
+ // load-bearing guard for the idempotent second-run case, where the
69
+ // first drop already removed the entry from the index but left the
70
+ // working tree file in place).
71
+ if (!isInIndex(rel)) {
72
+ log(`dropped ${rel} (already absent from index)`);
73
+ continue;
74
+ }
75
+ try {
76
+ if (isTrackedInHead(rel)) {
77
+ execFileSync('git', ['restore', '--staged', '--worktree', '--', rel], {
78
+ cwd: REPO_HOME,
79
+ stdio: ['ignore', 'pipe', 'pipe'],
80
+ });
81
+ } else {
82
+ execFileSync('git', ['rm', '--cached', '-f', '--', rel], {
83
+ cwd: REPO_HOME,
84
+ stdio: ['ignore', 'pipe', 'pipe'],
85
+ });
86
+ }
87
+ } catch (err) {
88
+ // Convert raw execFileSync failures (non-zero git exit, EACCES on
89
+ // .git/index, EPERM, etc.) into NomadFatal so the outer catch can
90
+ // emit a clean `✗ ...` line instead of letting the ExecException
91
+ // bubble past nomad.ts's NomadFatal-only dispatcher.
92
+ // The `?? err.message` fallback only fires when git fails without
93
+ // producing stderr (spawn-level error before the process emits
94
+ // anything), which `cmdPush`'s gitleaks probe already rules out
95
+ // for the typical install path. Excluded from coverage.
96
+ const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
97
+ /* c8 ignore next */
98
+ const detail = e.stderr?.toString().trim() ?? e.message;
99
+ throw new NomadFatal(`git failed to unstage ${rel}: ${detail}`);
100
+ }
101
+ log(`dropped ${rel}`);
102
+ }
103
+ } catch (err) {
104
+ // Defensive escape hatch: only fires if a non-NomadFatal error escapes
105
+ // the try block. All execFileSync mutation failures are wrapped in
106
+ // NomadFatal above, file-system helpers swallow their own errors, and
107
+ // readdirSync only throws under a race we do not handle. Excluded from
108
+ // coverage rather than contorting tests to fake an impossible state.
109
+ /* c8 ignore next 3 */
110
+ if (!(err instanceof NomadFatal)) {
111
+ throw err;
112
+ }
113
+ // All NomadFatal paths surfaced here are exit-1 conditions (no staged
114
+ // session matches the id, git mutation failed, etc.); the red `✗`
115
+ // glyph carries the severity uniformly.
116
+ fail(err.message);
117
+ process.exitCode = 1;
118
+ } finally {
119
+ releaseLock(handle);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Is `rel` (repo-relative path) present in the HEAD tree? Wraps
125
+ * `git cat-file -e HEAD:<rel>`: exit 0 means tracked in HEAD,
126
+ * non-zero means either no HEAD exists yet (empty repo) or the path is
127
+ * only in the index (newly-staged-not-in-HEAD). `git ls-files
128
+ * --error-unmatch` is NOT a HEAD-presence check; it matches anything in
129
+ * the index too, which would misclassify newly-staged paths.
130
+ *
131
+ * The catch deliberately collapses three cases to `false`: (a) HEAD has
132
+ * no commit yet (fresh `git init`), (b) HEAD is unresolvable / corrupt
133
+ * (e.g., `.git/refs/heads/main` was deleted manually), and (c) the
134
+ * specific path simply does not exist in a valid HEAD. Git produces the
135
+ * same exit 128 and the same stderr (`fatal: invalid object name 'HEAD'`)
136
+ * for (a) and (b), so a probe-based distinction would require additional
137
+ * git-plumbing reads (`rev-parse --verify HEAD`, `.git/refs/heads/`
138
+ * inspection) that are brittle and break the empty-repo path every
139
+ * existing test runs through. The downstream `git rm --cached -f` is
140
+ * idempotent and produces the user-intended unstage outcome regardless
141
+ * of which case fired, so the collapsed return is intentional. Repo
142
+ * health belongs to `nomad doctor`, not drop-session.
143
+ */
144
+ function isTrackedInHead(rel: string): boolean {
145
+ try {
146
+ execFileSync('git', ['cat-file', '-e', `HEAD:${rel}`], {
147
+ cwd: REPO_HOME,
148
+ stdio: ['ignore', 'pipe', 'pipe'],
149
+ });
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Is `rel` present in the index at all? Wraps `git ls-files -- <rel>` and
158
+ * checks for non-empty stdout. Required for the Pitfall 7 idempotency
159
+ * guard: a second invocation on the same id finds the file on disk (per
160
+ * `existsSync`) but absent from the index, and must NOT call `git rm
161
+ * --cached` on it (which would fail with exit 128).
162
+ */
163
+ function isInIndex(rel: string): boolean {
164
+ try {
165
+ const out = execFileSync('git', ['ls-files', '--', rel], {
166
+ cwd: REPO_HOME,
167
+ stdio: ['ignore', 'pipe', 'pipe'],
168
+ });
169
+ return out.toString().trim() !== '';
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
@@ -0,0 +1,88 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { HOME, HOST, REPO_HOME } from './config.ts';
5
+ import { applySharedLinks, regenerateSettings } from './links.ts';
6
+ import { computePreview } from './preview.ts';
7
+ import { remapPull } from './remap.ts';
8
+ import { emitSummary } from './summary.ts';
9
+ // prettier-ignore
10
+ import { acquireLock, die, fail, freshBackupTs, gitOrFatal, log, NomadFatal, releaseLock } from './utils.ts';
11
+
12
+ /**
13
+ * `nomad pull` command. Acquires the push/pull lock, takes a backup
14
+ * timestamp, runs `git pull --rebase --autostash` in `REPO_HOME`, then
15
+ * applies the three side-effecting sync steps in order:
16
+ * 1. `applySharedLinks` (symlink shared/* into ~/.claude/)
17
+ * 2. `regenerateSettings` (deep-merge base + host-override into settings.json)
18
+ * 3. `remapPull` (copy repo-side session transcripts into host-encoded dirs)
19
+ *
20
+ * `opts.dryRun` (default `false`): when `true`, the lock IS still acquired
21
+ * and `git pull --rebase` still runs (so concurrent invocations cannot race
22
+ * and so the user sees the same network round-trip behavior they would on a
23
+ * real pull). Then `computePreview` runs in place of the three mutating
24
+ * steps. The per-run backup directory under
25
+ * `~/.cache/claude-nomad/backup/<ts>/` is intentionally NOT created (no
26
+ * backups are written under dryRun and an empty dir would pollute the cache).
27
+ *
28
+ * Any `NomadFatal` thrown along the way is caught here so the `finally` block
29
+ * releases the lock before exit (a raw `process.exit()` would skip `finally`
30
+ * and leak the lock, see `NomadFatal` JSDoc). Non-fatal errors rethrow.
31
+ */
32
+ export function cmdPull(opts: { dryRun?: boolean } = {}): void {
33
+ const dryRun = opts.dryRun === true;
34
+ if (!existsSync(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
35
+ // Fire the init-hint FATAL BEFORE acquireLock so an
36
+ // unscaffolded repo never creates a lock file. Keyed off the same signal
37
+ // regenerateSettings uses (shared/settings.base.json), so the two entry
38
+ // points share one phrasing instead of diverging on edits.
39
+ if (!existsSync(join(REPO_HOME, 'shared', 'settings.base.json'))) {
40
+ die("repo not initialized; run 'nomad init' to scaffold");
41
+ }
42
+ const handle = acquireLock('pull');
43
+ if (handle === null) process.exit(0);
44
+ try {
45
+ // Collision-resistant ts: nowTimestamp() is second-resolution, so two
46
+ // pulls in the same wall-clock second would share `ts` and the second's
47
+ // backupBeforeWrite calls (cpSync force:false) would silently no-op.
48
+ const backupBase = join(HOME, '.cache', 'claude-nomad', 'backup');
49
+ const ts = freshBackupTs(backupBase);
50
+ if (!dryRun) {
51
+ // Fail-fast: create backup root BEFORE any mutation. If mkdir fails
52
+ // (out of disk, permission denied), die() throws (NomadFatal) and the
53
+ // outer catch logs + sets exitCode, then finally releases the lock.
54
+ // Skipped under dryRun: no backups are written, and an empty
55
+ // backup-root dir would pollute the cache.
56
+ const backupRoot = join(backupBase, ts);
57
+ try {
58
+ mkdirSync(backupRoot, { recursive: true });
59
+ } catch (err) {
60
+ die(`could not create backup dir: ${(err as Error).message}`);
61
+ }
62
+ }
63
+ log(`pulling on host=${HOST} (backup=${ts}${dryRun ? '; dry-run' : ''})`);
64
+ gitOrFatal(['pull', '--rebase', '--autostash'], 'git pull --rebase', REPO_HOME);
65
+ if (dryRun) {
66
+ const previewResult = computePreview(ts);
67
+ log('dry-run complete; no mutation');
68
+ emitSummary('pull', previewResult.unmapped);
69
+ } else {
70
+ applySharedLinks(ts);
71
+ regenerateSettings(ts);
72
+ const remapResult = remapPull(ts);
73
+ log('pull complete');
74
+ emitSummary('pull', remapResult.unmapped);
75
+ }
76
+ } catch (err) {
77
+ // Catch fatal errors here so the finally block runs and releases the
78
+ // lock. Throwing through process.exit() would skip finally.
79
+ if (err instanceof NomadFatal) {
80
+ fail(err.message);
81
+ process.exitCode = 1;
82
+ } else {
83
+ throw err;
84
+ }
85
+ } finally {
86
+ releaseLock(handle);
87
+ }
88
+ }
@@ -0,0 +1,215 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+
4
+ // prettier-ignore
5
+ import { HOME, HOST, NEVER_SYNC, PUSH_ALLOWED_STATIC, REPO_HOME, type PathMap } from './config.ts';
6
+ import { findGitlinks, probeGitleaks, rebaseBeforePush } from './push-checks.ts';
7
+ import { runGitleaksScan } from './push-gitleaks.ts';
8
+ import { remapPush } from './remap.ts';
9
+ import { emitSummary } from './summary.ts';
10
+ // prettier-ignore
11
+ import { acquireLock, die, fail, freshBackupTs, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal, readJson, releaseLock } from './utils.ts';
12
+
13
+ /**
14
+ * Match `path` against an entry in the push allow-list. Exact match for
15
+ * non-`/`-terminated entries; prefix match for `/`-terminated entries; and
16
+ * a special case for `hosts/`: only `hosts/<name>.json` (single-level,
17
+ * `.json` extension) is allowed, so arbitrary credentials like
18
+ * `hosts/dell-wsl.key` are rejected even though they share the prefix.
19
+ */
20
+ function isAllowed(path: string, allowed: readonly string[]): boolean {
21
+ for (const entry of allowed) {
22
+ if (path === entry) return true;
23
+ if (entry === 'hosts/') {
24
+ if (/^hosts\/[^/]+\.json$/.test(path)) return true;
25
+ continue;
26
+ }
27
+ if (entry.endsWith('/') && path.startsWith(entry)) return true;
28
+ }
29
+ return false;
30
+ }
31
+
32
+ /** True when any path segment matches a `NEVER_SYNC` entry (hard-block list). */
33
+ function isNeverSync(path: string): boolean {
34
+ for (const segment of path.split('/')) {
35
+ if (NEVER_SYNC.has(segment)) return true;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ /**
41
+ * Parse `git status --porcelain=v1 -z` (NUL-delimited) output into a flat
42
+ * list of paths. Handles rename (`R`) and copy (`C`) records, which span
43
+ * two NUL fields (`XY new\0old\0`): both halves are returned so the
44
+ * allow-list can reject either side. `-z` avoids the quoting that LF
45
+ * porcelain applies to paths containing spaces or specials, which would
46
+ * otherwise cause parser misclassification.
47
+ */
48
+ export function parsePorcelainZ(statusPorcelain: string): string[] {
49
+ const records = statusPorcelain.split('\0');
50
+ const paths: string[] = [];
51
+ for (let i = 0; i < records.length; i++) {
52
+ const rec = records[i];
53
+ if (rec === undefined || rec === '') continue;
54
+ // Each record starts with "XY " (2 status chars + 1 space). The path is
55
+ // everything after byte 3. For R/C the NEXT record holds the old path.
56
+ if (rec.length < 4) continue;
57
+ const xy = rec.slice(0, 2);
58
+ const newPath = rec.slice(3);
59
+ paths.push(newPath);
60
+ // Check BOTH XY positions: X is the index status, Y is the working-tree
61
+ // status. Either can carry R (rename) or C (copy), and the old-path record
62
+ // follows the new-path record in -z porcelain regardless of which column
63
+ // detected the rename. Missing the Y-column case (e.g. ` R`) would skip
64
+ // the consume and let the next iteration misread the old path as a new
65
+ // record, smuggling unallowed sources past the allow-list.
66
+ if (/[RC]/.test(xy)) {
67
+ const oldPath = records[i + 1];
68
+ if (oldPath !== undefined && oldPath !== '') paths.push(oldPath);
69
+ i++; // consume the paired old-path record
70
+ }
71
+ }
72
+ return paths;
73
+ }
74
+
75
+ /**
76
+ * Reject any staged path that is not on the push allow-list or that matches a
77
+ * `NEVER_SYNC` entry. Builds the runtime allow-list by combining
78
+ * `PUSH_ALLOWED_STATIC` with one `shared/projects/<logical>/` prefix per entry
79
+ * in `path-map.json`. Logs every violation as a FATAL line so the user sees
80
+ * the full set (not just the first), then throws `NomadFatal` to unwind the
81
+ * caller's try/finally and release the push lock.
82
+ */
83
+ export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
84
+ const allowed = [
85
+ ...PUSH_ALLOWED_STATIC,
86
+ ...Object.keys(map.projects).map((l) => `shared/projects/${l}/`),
87
+ ];
88
+ const neverSyncHits: string[] = [];
89
+ const violations: string[] = [];
90
+ for (const path of parsePorcelainZ(statusPorcelain)) {
91
+ if (isNeverSync(path)) {
92
+ neverSyncHits.push(path);
93
+ } else if (!isAllowed(path, allowed)) {
94
+ violations.push(path);
95
+ }
96
+ }
97
+ if (neverSyncHits.length === 0 && violations.length === 0) return;
98
+ for (const p of neverSyncHits) {
99
+ fail(`${p} is in NEVER_SYNC and must never be pushed`);
100
+ }
101
+ for (const p of violations) {
102
+ fail(`to sync ${p}, add to PUSH_ALLOWED in src/config.ts`);
103
+ }
104
+ throw new NomadFatal('push allow-list violations');
105
+ }
106
+
107
+ /**
108
+ * `nomad push` command. Acquires the lock, runs the four pre-push safety
109
+ * checks in the order from CONTEXT.md, stages, and pushes:
110
+ * 1. `probeGitleaks` (fail fast if the secret scanner isn't on PATH)
111
+ * 2. `rebaseBeforePush` (surface remote conflicts against committed state,
112
+ * not against in-flight `remapPush` copies)
113
+ * 3. `remapPush` (copy host-encoded session dirs into shared logical names)
114
+ * 4. `findGitlinks` walk of `shared/` (refuse to push nested .git entries;
115
+ * runs AFTER `remapPush` so it catches .git dirs copied in from the host)
116
+ * 5. allow-list enforcement on the resulting `git status` (refuse any path
117
+ * not on `PUSH_ALLOWED_STATIC` or matching `NEVER_SYNC`)
118
+ * 6. `git add -A` -> `runGitleaksScan` on staged tree -> `git commit` -> `git push`
119
+ *
120
+ * The gitleaks scan runs AFTER staging so it sees what would actually be
121
+ * pushed, but BEFORE commit so a detection unwinds cleanly without leaving a
122
+ * commit to amend or revert. Any `NomadFatal` is caught here so `finally`
123
+ * releases the lock.
124
+ *
125
+ * `opts.dryRun` (default `false`): when `true`, the network round-trip
126
+ * (`rebaseBeforePush`) still runs so users see what a real push would see,
127
+ * but `remapPush` runs with `dryRun: true` (no session copies into shared/),
128
+ * and the `git add` / `runGitleaksScan` / `git commit` / `git push` quartet
129
+ * is skipped. The allow-list check still classifies the existing `git
130
+ * status` so a pre-existing violation surfaces before the user thinks
131
+ * everything is fine. Mirrors `cmdPull`'s `dryRun` contract.
132
+ */
133
+ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
134
+ const dryRun = opts.dryRun === true;
135
+ if (!existsSync(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
136
+ const handle = acquireLock('push');
137
+ if (handle === null) process.exit(0);
138
+ try {
139
+ log(dryRun ? `pushing on host=${HOST} (dry-run)` : `pushing on host=${HOST}`);
140
+ // Probe at top of flow: fail fast if gitleaks is missing, before any mutation.
141
+ probeGitleaks();
142
+ // Rebase BEFORE any local mutation: surfaces remote conflicts against the
143
+ // user's committed state, not against in-flight remapPush copies. Runs
144
+ // under dryRun too so the network round-trip mirrors a real push.
145
+ rebaseBeforePush();
146
+ // Collision-resistant ts for remapPush's pre-copy snapshot of repo-side state.
147
+ const backupBase = join(HOME, '.cache', 'claude-nomad', 'backup');
148
+ const ts = freshBackupTs(backupBase);
149
+ // remapPush runs BEFORE the empty-status check: it produces the diffs status
150
+ // observes, so swapping the order would short-circuit before anything is staged.
151
+ const remapResult = remapPush(ts, { dryRun });
152
+ // Gitlink walk of shared/ AFTER remapPush so it inspects the post-copy tree.
153
+ // A nested .git copied in from a host's encoded session dir would slip past a
154
+ // pre-remap scan and reach the remote via the shared/projects/<logical>/ prefix.
155
+ // Per-hit FATAL on stderr plus a summarizing throw, mirroring enforceAllowList.
156
+ const sharedDir = join(REPO_HOME, 'shared');
157
+ const gitlinks = findGitlinks(sharedDir);
158
+ if (gitlinks.length > 0) {
159
+ for (const p of gitlinks) {
160
+ const rel = relative(REPO_HOME, p);
161
+ fail(
162
+ `gitlink: ${rel} would push as submodule (run: rm -rf ${rel} or remove the nested repo)`,
163
+ );
164
+ }
165
+ throw new NomadFatal(
166
+ `gitlink trap: ${gitlinks.length} nested .git ${gitlinks.length === 1 ? 'entry' : 'entries'} in shared/; remove before retry`,
167
+ );
168
+ }
169
+ // Routed through the shell-free, untrimmed helper because `sh` would .trim()
170
+ // the leading status-space and shift parsePorcelainZ's offsets.
171
+ const status = gitStatusPorcelainZ(REPO_HOME);
172
+ if (!status) {
173
+ log('nothing to commit');
174
+ emitSummary('push', remapResult.unmapped, remapResult.collisions);
175
+ return;
176
+ }
177
+ const mapPath = join(REPO_HOME, 'path-map.json');
178
+ if (!existsSync(mapPath)) die('path-map.json missing, cannot enforce push allow-list');
179
+ // Route a malformed path-map.json through NomadFatal so finally releases the lock.
180
+ let map: PathMap;
181
+ try {
182
+ map = readJson<PathMap>(mapPath);
183
+ } catch (err) {
184
+ throw new NomadFatal(`could not parse path-map.json: ${(err as Error).message}`);
185
+ }
186
+ enforceAllowList(status, map);
187
+ if (dryRun) {
188
+ // Skip the staging quartet so no commit lands and nothing is pushed.
189
+ // The user has already seen probeGitleaks pass, the rebase result, the
190
+ // remap preview, the gitlink scan, and the allow-list classification.
191
+ log('push: dry-run; skipping git add, gitleaks scan, commit, and push');
192
+ emitSummary('push', remapResult.unmapped, remapResult.collisions);
193
+ return;
194
+ }
195
+ // gitOrFatal uses execFileSync (no shell) so NOMAD_HOST cannot escape quoting.
196
+ gitOrFatal(['add', '-A'], 'git add', REPO_HOME);
197
+ // Gitleaks scan AFTER staging (sees what would push), BEFORE commit (no cleanup
198
+ // needed on detection). The empty-status early return above guarantees the
199
+ // index is non-empty here.
200
+ runGitleaksScan();
201
+ gitOrFatal(['commit', '-m', `chore: sync from ${HOST}`], 'git commit', REPO_HOME);
202
+ gitOrFatal(['push'], 'git push', REPO_HOME);
203
+ log('push complete');
204
+ emitSummary('push', remapResult.unmapped, remapResult.collisions);
205
+ } catch (err) {
206
+ if (err instanceof NomadFatal) {
207
+ fail(err.message);
208
+ process.exitCode = 1;
209
+ } else {
210
+ throw err;
211
+ }
212
+ } finally {
213
+ releaseLock(handle);
214
+ }
215
+ }