@jakobm/peacekeeper 0.0.0-stage → 0.12.1

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,382 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Remove local and remote branches that are fully merged into the locally known
5
+ * the configured remote default branch. A worktree on an eligible local branch is removed first when it
6
+ * has no tracked or untracked changes.
7
+ *
8
+ * Recommended usage:
9
+ *
10
+ * npm exec --no -- peacekeeper cleanup-merged-branches # Dry-run (default).
11
+ * npm exec --no -- peacekeeper cleanup-merged-branches --dry-run # Explicit dry-run.
12
+ * npm exec --no -- peacekeeper cleanup-merged-branches --execute # Apply the displayed cleanup.
13
+ *
14
+ * Every run fetches the configured remote, including dry-runs, so the cleanup
15
+ * plan uses current remote references. Dry-run does not remove anything,
16
+ * but this fetch still updates local remote references. The script never deletes
17
+ * the configured default branch. It skips the current worktree, locked worktrees,
18
+ * and worktrees with tracked or untracked changes. Ignored files do not count as
19
+ * changes. Local and remote deletions are guarded by the exact commit IDs
20
+ * observed while building the cleanup plan, so a branch changed concurrently is
21
+ * preserved.
22
+ */
23
+
24
+ const { execFileSync, spawnSync } = require('node:child_process');
25
+ const fs = require('node:fs');
26
+ const path = require('node:path');
27
+ const { projectConfig } = require('./project-config');
28
+
29
+ function fail(message) {
30
+ console.error(`Error: ${message}`);
31
+ process.exit(1);
32
+ }
33
+
34
+ function runGit(args, options = {}) {
35
+ const result = spawnSync('git', args, {
36
+ cwd: options.cwd,
37
+ encoding: 'utf8',
38
+ stdio: options.stdio || ['ignore', 'pipe', 'pipe'],
39
+ });
40
+
41
+ if (result.status !== 0) {
42
+ const detail = result.stderr?.trim() || result.stdout?.trim();
43
+ throw new Error(detail || `git ${args.join(' ')} failed`);
44
+ }
45
+
46
+ return result.stdout;
47
+ }
48
+
49
+ function parseArguments(args) {
50
+ let execute = false;
51
+
52
+ for (const argument of args) {
53
+ if (argument === '--execute') {
54
+ execute = true;
55
+ } else if (argument === '--dry-run') {
56
+ execute = false;
57
+ } else if (argument === '--help' || argument === '-h') {
58
+ console.log('Usage: cleanup-merged-branches.js [--dry-run | --execute]');
59
+ console.log('');
60
+ console.log('Dry-run is the default. Use --execute to remove eligible worktrees and branches.');
61
+ process.exit(0);
62
+ } else {
63
+ fail(`unknown argument: ${argument}`);
64
+ }
65
+ }
66
+
67
+ return { execute };
68
+ }
69
+
70
+ function mergedBranches(repositoryRoot, namespace) {
71
+ const { remote, defaultBranch } = projectConfig(repositoryRoot).git;
72
+ const output = runGit([
73
+ 'for-each-ref',
74
+ `--merged=refs/remotes/${remote}/${defaultBranch}`,
75
+ '--format=%(refname)\t%(objectname)\t%(symref)',
76
+ namespace,
77
+ ], { cwd: repositoryRoot });
78
+
79
+ return output
80
+ .trimEnd()
81
+ .split('\n')
82
+ .filter(Boolean)
83
+ .map((line) => line.split('\t'))
84
+ .filter(([, , symbolicTarget]) => !symbolicTarget)
85
+ .map(([refName, objectId]) => ({ refName, objectId }));
86
+ }
87
+
88
+ function listWorktrees(repositoryRoot) {
89
+ const fields = runGit(['worktree', 'list', '--porcelain', '-z'], {
90
+ cwd: repositoryRoot,
91
+ }).split('\0');
92
+ const worktrees = [];
93
+ let current = null;
94
+
95
+ for (const field of fields) {
96
+ if (field === '') {
97
+ if (current) {
98
+ worktrees.push(current);
99
+ current = null;
100
+ }
101
+ continue;
102
+ }
103
+
104
+ const separator = field.indexOf(' ');
105
+ const key = separator === -1 ? field : field.slice(0, separator);
106
+ const value = separator === -1 ? '' : field.slice(separator + 1);
107
+
108
+ if (key === 'worktree') {
109
+ current = { path: value };
110
+ } else if (current) {
111
+ current[key] = value;
112
+ }
113
+ }
114
+
115
+ return worktrees;
116
+ }
117
+
118
+ function samePath(first, second) {
119
+ try {
120
+ return fs.realpathSync(first) === fs.realpathSync(second);
121
+ } catch {
122
+ return path.resolve(first) === path.resolve(second);
123
+ }
124
+ }
125
+
126
+ function branchNameFromLocalRef(refName) {
127
+ return refName.slice('refs/heads/'.length);
128
+ }
129
+
130
+ function branchNameFromRemoteRef(refName, remote) {
131
+ return refName.slice(`refs/remotes/${remote}/`.length);
132
+ }
133
+
134
+ function buildPlan(repositoryRoot) {
135
+ const { remote, defaultBranch } = projectConfig(repositoryRoot).git;
136
+ const localBranches = new Map(
137
+ mergedBranches(repositoryRoot, 'refs/heads')
138
+ .map(({ refName, objectId }) => [branchNameFromLocalRef(refName), objectId]),
139
+ );
140
+ const remoteBranches = new Map(
141
+ mergedBranches(repositoryRoot, `refs/remotes/${remote}`)
142
+ .map(({ refName, objectId }) => [branchNameFromRemoteRef(refName, remote), objectId])
143
+ .filter(([branch]) => branch !== 'HEAD'),
144
+ );
145
+
146
+ localBranches.delete(defaultBranch);
147
+ remoteBranches.delete(defaultBranch);
148
+
149
+ const currentRoot = runGit(['rev-parse', '--show-toplevel'], {
150
+ cwd: repositoryRoot,
151
+ }).trim();
152
+ const worktrees = listWorktrees(repositoryRoot);
153
+ const names = [...new Set([...localBranches.keys(), ...remoteBranches.keys()])].sort();
154
+
155
+ return names.map((branch) => {
156
+ const branchWorktrees = localBranches.has(branch)
157
+ ? worktrees.filter((worktree) => worktree.branch === `refs/heads/${branch}`)
158
+ : [];
159
+ let skipReason = null;
160
+
161
+ for (const worktree of branchWorktrees) {
162
+ if (samePath(worktree.path, currentRoot)) {
163
+ skipReason = `it is checked out in the current worktree: ${worktree.path}`;
164
+ break;
165
+ }
166
+ if (Object.hasOwn(worktree, 'locked')) {
167
+ skipReason = `its worktree is locked: ${worktree.path}`;
168
+ break;
169
+ }
170
+
171
+ try {
172
+ const status = runGit(
173
+ ['status', '--porcelain', '--untracked-files=all'],
174
+ { cwd: worktree.path },
175
+ );
176
+ if (status !== '') {
177
+ skipReason = `its worktree has changes: ${worktree.path}`;
178
+ break;
179
+ }
180
+ } catch (error) {
181
+ skipReason = `its worktree could not be inspected: ${worktree.path} (${error.message})`;
182
+ break;
183
+ }
184
+ }
185
+
186
+ return {
187
+ branch,
188
+ local: localBranches.has(branch),
189
+ localObjectId: localBranches.get(branch),
190
+ remote: remoteBranches.has(branch),
191
+ remoteObjectId: remoteBranches.get(branch),
192
+ worktrees: branchWorktrees,
193
+ skipReason,
194
+ };
195
+ });
196
+ }
197
+
198
+ function printAction(execute, dryRunText, executeText, log) {
199
+ log(execute ? executeText : `Would ${dryRunText}`);
200
+ }
201
+
202
+ function localPlanChangedReason(repositoryRoot, item) {
203
+ if (!item.local) return null;
204
+ try {
205
+ const branchRef = `refs/heads/${item.branch}`;
206
+ const tip = runGit(['rev-parse', '--verify', branchRef], { cwd: repositoryRoot }).trim();
207
+ if (tip !== item.localObjectId) return 'its local branch changed after planning';
208
+
209
+ const current = listWorktrees(repositoryRoot);
210
+ const attached = current.filter((worktree) => worktree.branch === branchRef);
211
+ if (attached.length !== item.worktrees.length) return 'its worktrees changed after planning';
212
+
213
+ for (const planned of item.worktrees) {
214
+ const worktree = current.find((candidate) => samePath(candidate.path, planned.path));
215
+ if (!worktree || worktree.branch !== branchRef || worktree.HEAD !== item.localObjectId) {
216
+ return `its worktree changed after planning: ${planned.path}`;
217
+ }
218
+ if (Object.hasOwn(worktree, 'locked')) return `its worktree is locked: ${planned.path}`;
219
+ if (samePath(worktree.path, repositoryRoot)) return `it is checked out in the current worktree: ${planned.path}`;
220
+ if (runGit(['status', '--porcelain', '--untracked-files=all'], { cwd: planned.path }) !== '') {
221
+ return `its worktree has changes: ${planned.path}`;
222
+ }
223
+ }
224
+ } catch (error) {
225
+ return `its local state could not be verified: ${error.message}`;
226
+ }
227
+ return null;
228
+ }
229
+
230
+ function applyPlan(repositoryRoot, plan, execute, log = console.log) {
231
+ const { remote } = projectConfig(repositoryRoot).git;
232
+ let removedWorktrees = 0;
233
+ let deletedLocalBranches = 0;
234
+ let deletedRemoteBranches = 0;
235
+ let skippedBranches = 0;
236
+
237
+ items: for (const item of plan) {
238
+ const skipReason = item.skipReason || (execute && localPlanChangedReason(repositoryRoot, item));
239
+ if (skipReason) {
240
+ log(`Skip ${item.branch}: ${skipReason}`);
241
+ skippedBranches += 1;
242
+ continue;
243
+ }
244
+
245
+ for (const [index, worktree] of item.worktrees.entries()) {
246
+ printAction(
247
+ execute,
248
+ `remove worktree ${worktree.path} (branch ${item.branch})`,
249
+ `Removing worktree ${worktree.path} (branch ${item.branch})`,
250
+ log,
251
+ );
252
+ if (execute) {
253
+ const changedReason = localPlanChangedReason(repositoryRoot, {
254
+ ...item, worktrees: item.worktrees.slice(index),
255
+ });
256
+ if (changedReason) {
257
+ log(`Skip ${item.branch}: ${changedReason}`);
258
+ skippedBranches += 1;
259
+ continue items;
260
+ }
261
+ runGit(['worktree', 'remove', worktree.path], { cwd: repositoryRoot });
262
+ }
263
+ removedWorktrees += 1;
264
+ }
265
+
266
+ if (item.local) {
267
+ printAction(
268
+ execute,
269
+ `delete local branch ${item.branch}`,
270
+ `Deleting local branch ${item.branch}`,
271
+ log,
272
+ );
273
+ if (execute) {
274
+ const changedReason = localPlanChangedReason(repositoryRoot, { ...item, worktrees: [] });
275
+ if (changedReason) {
276
+ log(`Skip ${item.branch}: ${changedReason}`);
277
+ skippedBranches += 1;
278
+ continue;
279
+ }
280
+ runGit([
281
+ 'update-ref',
282
+ '-d',
283
+ `refs/heads/${item.branch}`,
284
+ item.localObjectId,
285
+ ], { cwd: repositoryRoot });
286
+
287
+ const configResult = spawnSync(
288
+ 'git',
289
+ ['config', '--remove-section', `branch.${item.branch}`],
290
+ { cwd: repositoryRoot, encoding: 'utf8' },
291
+ );
292
+ if (configResult.status !== 0 && configResult.status !== 5) {
293
+ console.warn(
294
+ `Warning: could not remove configuration for branch ${item.branch}: `
295
+ + configResult.stderr.trim(),
296
+ );
297
+ }
298
+ }
299
+ deletedLocalBranches += 1;
300
+ }
301
+
302
+ if (item.remote) {
303
+ printAction(
304
+ execute,
305
+ `delete remote branch ${remote}/${item.branch}`,
306
+ `Deleting remote branch ${remote}/${item.branch}`,
307
+ log,
308
+ );
309
+ if (execute) {
310
+ runGit([
311
+ 'push',
312
+ `--force-with-lease=refs/heads/${item.branch}:${item.remoteObjectId}`,
313
+ remote,
314
+ `:refs/heads/${item.branch}`,
315
+ ], {
316
+ cwd: repositoryRoot,
317
+ });
318
+ }
319
+ deletedRemoteBranches += 1;
320
+ }
321
+ }
322
+
323
+ log('');
324
+ log(
325
+ `${execute ? 'Removed' : 'Would remove'} ${removedWorktrees} worktree(s), `
326
+ + `${deletedLocalBranches} local branch(es), and ${deletedRemoteBranches} remote branch(es).`,
327
+ );
328
+ if (skippedBranches > 0) {
329
+ log(`Skipped ${skippedBranches} branch(es).`);
330
+ }
331
+ if (!execute) {
332
+ log('');
333
+ log('To apply this cleanup, run:');
334
+ log(' npm exec --no -- peacekeeper cleanup-merged-branches --execute');
335
+ }
336
+ }
337
+
338
+ function main() {
339
+ const { execute } = parseArguments(process.argv.slice(2));
340
+
341
+ try {
342
+ execFileSync('git', ['--version'], { stdio: 'ignore' });
343
+ } catch {
344
+ fail('required command not found: git');
345
+ }
346
+
347
+ let repositoryRoot;
348
+ try {
349
+ repositoryRoot = runGit(['rev-parse', '--show-toplevel']).trim();
350
+ runGit(['remote', 'get-url', projectConfig(repositoryRoot).git.remote], { cwd: repositoryRoot });
351
+ } catch (error) {
352
+ fail(error.message);
353
+ }
354
+
355
+ console.log(execute ? 'Execute mode.' : 'Dry-run mode. No worktrees or branches will be removed.');
356
+ console.log(`Fetching and pruning ${projectConfig(repositoryRoot).git.remote} before building the cleanup plan.`);
357
+
358
+ try {
359
+ const { remote, defaultBranch } = projectConfig(repositoryRoot).git;
360
+ runGit(['fetch', '--prune', remote], { cwd: repositoryRoot });
361
+ runGit(['show-ref', '--verify', '--quiet', `refs/remotes/${remote}/${defaultBranch}`], {
362
+ cwd: repositoryRoot,
363
+ });
364
+ } catch (error) {
365
+ fail(error.message);
366
+ }
367
+
368
+ console.log('');
369
+
370
+ try {
371
+ const plan = buildPlan(repositoryRoot);
372
+ applyPlan(repositoryRoot, plan, execute);
373
+ } catch (error) {
374
+ fail(error.message);
375
+ }
376
+ }
377
+
378
+ if (require.main === module) {
379
+ main();
380
+ }
381
+
382
+ module.exports = { applyPlan, buildPlan, listWorktrees, parseArguments };
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { repositoryRoot } = require('./project-config');
6
+
7
+ const DEFINITIONS = [
8
+ ['SessionStart', 'checkpoint-codex-session-start', 30, 'Loading Codex checkpoint instructions'],
9
+ ['Stop', 'checkpoint-codex-stop', 30, 'Checking for an uncommitted Codex checkpoint'],
10
+ ['SessionEnd', 'cleanup-ended-session', 3, 'Scheduling merged branch cleanup'],
11
+ ];
12
+ const COMMANDS = DEFINITIONS.map(([, command]) => command);
13
+
14
+ // Recognize complete, standalone invocations only. Shell composition, arguments,
15
+ // environment assignments, and wrappers require inspection instead of replacement.
16
+ function classifyCommand(command) {
17
+ if (typeof command !== 'string') return 'other';
18
+ const related = /peacekeeper|checkpoint-codex-(?:stop|session-start)|cleanup-ended-session/.test(command);
19
+ if (!related) return 'other';
20
+ const text = command.trim();
21
+ const action = COMMANDS.find((name) => text.endsWith(` ${name}`) || text.endsWith(`\t${name}`));
22
+ if (!action) {
23
+ const legacy = /^(?:node\s+)(?:\.\/)?(?:node_modules\/@jakobm\/peacekeeper\/)?scripts\/(checkpoint-codex-stop|checkpoint-codex-session-start|cleanup-ended-session)\.js$/;
24
+ return legacy.test(text) ? 'owned' : 'ambiguous';
25
+ }
26
+ const executable = text.slice(0, -action.length).trim();
27
+ if (/^(?:peacekeeper|npm\s+exec\s+--no\s+--\s+peacekeeper|npx\s+--no-install\s+peacekeeper)$/.test(executable)) {
28
+ return 'owned';
29
+ }
30
+ // Accept the documented Git-root resolution with optional path quotes.
31
+ const gitRoot = '\\$\\(git\\s+rev-parse\\s+--show-toplevel\\)';
32
+ const binPath = '(?:/node_modules/@jakobm/peacekeeper)?/bin/peacekeeper\\.js';
33
+ const relativePath = '(?:\\./)?(?:node_modules/@jakobm/peacekeeper/)?bin/peacekeeper\\.js';
34
+ const paths = [gitRoot + binPath, relativePath];
35
+ const invocations = paths.flatMap((value) => [`"${value}"`, value]);
36
+ invocations.push(`'${relativePath}'`);
37
+ const directPath = new RegExp(`^node\\s+(?:${invocations.join('|')})$`);
38
+ if (directPath.test(executable)) return 'owned';
39
+ return 'ambiguous';
40
+ }
41
+
42
+ function object(value) {
43
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
44
+ }
45
+
46
+ function planHooks(document, localSource = false) {
47
+ if (!object(document) || (document.hooks !== undefined && !object(document.hooks))) {
48
+ throw new Error('hooks.json must contain an object with an optional hooks object');
49
+ }
50
+ const result = structuredClone(document);
51
+ result.hooks ??= {};
52
+ const changes = [];
53
+ for (const [event, groups] of Object.entries(result.hooks)) {
54
+ if (!Array.isArray(groups)) throw new Error(`hooks.${event} must be an array`);
55
+ const keptGroups = [];
56
+ for (const group of groups) {
57
+ if (!object(group) || !Array.isArray(group.hooks)) {
58
+ throw new Error(`hooks.${event} contains an invalid hook group`);
59
+ }
60
+ const kept = [];
61
+ for (const handler of group.hooks) {
62
+ if (!object(handler)) throw new Error(`hooks.${event} contains an invalid handler`);
63
+ if (classifyCommand(handler.commandWindows) !== 'other') {
64
+ throw new Error(`Review Windows-specific peacekeeper hook in ${event}: ${handler.commandWindows}`);
65
+ }
66
+ const classification = classifyCommand(handler.command);
67
+ if (classification === 'ambiguous') {
68
+ throw new Error(`Review possible peacekeeper hook in ${event}: ${handler.command}`);
69
+ }
70
+ if (classification === 'owned') {
71
+ const allowed = ['type', 'command', 'timeout', 'statusMessage'];
72
+ if (handler.type !== 'command' || Object.keys(handler).some((key) => !allowed.includes(key))) {
73
+ throw new Error(`Review customized peacekeeper hook in ${event}: ${handler.command}`);
74
+ }
75
+ changes.push(`Replace existing peacekeeper hook in ${event}`);
76
+ } else {
77
+ kept.push(handler);
78
+ }
79
+ }
80
+ if (kept.length || group.hooks.length === 0) keptGroups.push({ ...group, hooks: kept });
81
+ }
82
+ result.hooks[event] = keptGroups;
83
+ }
84
+ const prefix = localSource ? '' : 'node_modules/@jakobm/peacekeeper/';
85
+ for (const [event, command, timeout, statusMessage] of DEFINITIONS) {
86
+ result.hooks[event] ??= [];
87
+ result.hooks[event].push({ hooks: [{
88
+ type: 'command',
89
+ command: `node "$(git rev-parse --show-toplevel)/${prefix}bin/peacekeeper.js" ${command}`,
90
+ timeout,
91
+ statusMessage,
92
+ }] });
93
+ changes.push(`Install ${event}: ${command}`);
94
+ }
95
+ return { document: result, changes };
96
+ }
97
+
98
+ function install({ cwd = process.cwd(), dryRun = false, log = console.log } = {}) {
99
+ const root = repositoryRoot(cwd);
100
+ const file = path.join(root, '.codex', 'hooks.json');
101
+ let original = null;
102
+ try {
103
+ if (fs.lstatSync(file).isSymbolicLink()) throw new Error('refusing to replace a symlink hooks.json');
104
+ original = fs.readFileSync(file, 'utf8');
105
+ } catch (error) {
106
+ if (error.code !== 'ENOENT') throw error;
107
+ }
108
+ const localSource = fs.realpathSync(root) === fs.realpathSync(path.resolve(__dirname, '..'));
109
+ const plan = planHooks(original === null ? {} : JSON.parse(original), localSource);
110
+ const serialized = `${JSON.stringify(plan.document, null, 2)}\n`;
111
+ if (original !== null && JSON.stringify(JSON.parse(original)) === JSON.stringify(plan.document)) {
112
+ log('Peacekeeper hooks are already up to date.');
113
+ return;
114
+ }
115
+ for (const change of plan.changes) log(change);
116
+ if (dryRun) {
117
+ log(`Would update ${file}`);
118
+ return;
119
+ }
120
+ fs.mkdirSync(path.dirname(file), { recursive: true });
121
+ const temporary = `${file}.${process.pid}.tmp`;
122
+ try {
123
+ fs.writeFileSync(temporary, serialized, {
124
+ flag: 'wx', mode: original === null ? 0o644 : fs.statSync(file).mode & 0o777,
125
+ });
126
+ // Avoid overwriting edits made while constructing the plan.
127
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
128
+ if (current !== original) throw new Error('hooks.json changed during installation; retry');
129
+ fs.renameSync(temporary, file);
130
+ } finally {
131
+ fs.rmSync(temporary, { force: true });
132
+ }
133
+ log(`Updated ${file}. Review and trust the hooks with /hooks in Codex.`);
134
+ }
135
+
136
+ function main() {
137
+ const args = process.argv.slice(2);
138
+ if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
139
+ console.log('Usage: peacekeeper install-hooks [--dry-run]');
140
+ return;
141
+ }
142
+ if (args.length > 1 || (args.length === 1 && args[0] !== '--dry-run')) {
143
+ throw new Error('Usage: peacekeeper install-hooks [--dry-run]');
144
+ }
145
+ install({ dryRun: args.includes('--dry-run') });
146
+ }
147
+
148
+ if (require.main === module) {
149
+ try { main(); } catch (error) {
150
+ console.error(`Error: ${error.message}`);
151
+ process.exitCode = 1;
152
+ }
153
+ }
154
+
155
+ module.exports = { classifyCommand, planHooks, install };
@@ -0,0 +1,80 @@
1
+ const { spawnSync } = require('node:child_process');
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { projectConfig } = require('./project-config');
5
+
6
+ function runGit(args, cwd) {
7
+ const result = spawnSync('git', args, {
8
+ cwd,
9
+ encoding: 'utf8',
10
+ stdio: ['ignore', 'pipe', 'pipe'],
11
+ });
12
+
13
+ if (result.status !== 0) {
14
+ const detail = result.stderr?.trim() || result.stdout?.trim();
15
+ throw new Error(detail || `git ${args.join(' ')} failed`);
16
+ }
17
+
18
+ return result.stdout;
19
+ }
20
+
21
+ function sourceRepositoryRoot(cwd) {
22
+ const output = runGit(['worktree', 'list', '--porcelain', '-z'], cwd);
23
+ const firstField = output.split('\0').find((field) => field.startsWith('worktree '));
24
+
25
+ if (!firstField) {
26
+ throw new Error('could not find the primary Git worktree');
27
+ }
28
+
29
+ return firstField.slice('worktree '.length);
30
+ }
31
+
32
+ function samePath(first, second) {
33
+ try {
34
+ return fs.realpathSync(first) === fs.realpathSync(second);
35
+ } catch {
36
+ return path.resolve(first) === path.resolve(second);
37
+ }
38
+ }
39
+
40
+ function isPrimaryWorktree(cwd) {
41
+ const repositoryRoot = runGit(['rev-parse', '--show-toplevel'], cwd).trim();
42
+ return samePath(repositoryRoot, sourceRepositoryRoot(cwd));
43
+ }
44
+
45
+ function shellQuote(argument) {
46
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(argument)) {
47
+ return argument;
48
+ }
49
+
50
+ return `'${argument.replaceAll("'", `'"'"'`)}'`;
51
+ }
52
+
53
+ function filesystemTimestamp(date) {
54
+ return date.toISOString().replaceAll(':', '-');
55
+ }
56
+
57
+ function logScriptInvocation(options = {}) {
58
+ const argv = options.argv || process.argv;
59
+ const cwd = options.cwd || process.cwd();
60
+ const date = options.date || new Date();
61
+ const processId = options.processId || process.pid;
62
+ const repositoryRoot = sourceRepositoryRoot(cwd);
63
+ const logDirectory = path.join(repositoryRoot, projectConfig(repositoryRoot).loggingDirectory);
64
+ const scriptName = path.basename(argv[1] || 'unknown-script');
65
+ const fileName = `${filesystemTimestamp(date)}-${scriptName}-${processId}.log`;
66
+ const commandLine = argv.map(shellQuote).join(' ');
67
+
68
+ fs.mkdirSync(logDirectory, { recursive: true });
69
+ const logPath = path.join(logDirectory, fileName);
70
+ fs.writeFileSync(logPath, `${commandLine}\n`, { flag: 'wx' });
71
+ return logPath;
72
+ }
73
+
74
+ module.exports = {
75
+ filesystemTimestamp,
76
+ isPrimaryWorktree,
77
+ logScriptInvocation,
78
+ shellQuote,
79
+ sourceRepositoryRoot,
80
+ };