isomorphic-jj 1.4.2 → 1.7.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.
package/bin/isojj.js CHANGED
@@ -8,10 +8,33 @@
8
8
  */
9
9
 
10
10
  import { createJJ } from '../src/api/repository.js';
11
- import fs from 'fs';
11
+ import nodeFs from 'fs';
12
+ import nodePath from 'path';
13
+ import { fileURLToPath } from 'url';
14
+
15
+ // Flags that are always boolean and must never consume the next token as a
16
+ // value — otherwise `isojj describe --dryRun "fix typo"` would swallow the
17
+ // commit message as --dryRun's value instead of leaving it as a positional.
18
+ const BOOLEAN_FLAGS = new Set([
19
+ 'json',
20
+ 'dryRun',
21
+ 'force',
22
+ 'interactive',
23
+ 'includeResolved',
24
+ 'noTags',
25
+ 'allowNew',
26
+ 'preserveSnapshot',
27
+ ]);
28
+
29
+ /** Coerce a raw flag-value string to a real boolean when it looks like one. */
30
+ function coerceBoolean(value) {
31
+ if (value === 'true') return true;
32
+ if (value === 'false') return false;
33
+ return value;
34
+ }
12
35
 
13
36
  // Parse command line arguments
14
- function parseArgs(argv) {
37
+ export function parseArgs(argv) {
15
38
  const args = argv.slice(2);
16
39
  const command = args[0];
17
40
  const opts = {};
@@ -21,24 +44,34 @@ function parseArgs(argv) {
21
44
 
22
45
  if (arg.startsWith('--')) {
23
46
  const key = arg.slice(2);
24
- const nextArg = args[i + 1];
25
47
 
26
48
  // Handle --flag and --key=value
27
49
  if (arg.includes('=')) {
28
- const [k, v] = arg.slice(2).split('=');
29
- opts[k] = v;
30
- } else if (nextArg && !nextArg.startsWith('-')) {
31
- opts[key] = nextArg;
50
+ const eq = arg.indexOf('=');
51
+ const k = arg.slice(2, eq);
52
+ const v = arg.slice(eq + 1);
53
+ opts[k] = coerceBoolean(v);
54
+ continue;
55
+ }
56
+
57
+ if (BOOLEAN_FLAGS.has(key)) {
58
+ opts[key] = true;
59
+ continue;
60
+ }
61
+
62
+ const nextArg = args[i + 1];
63
+ if (nextArg !== undefined && !nextArg.startsWith('-')) {
64
+ opts[key] = coerceBoolean(nextArg);
32
65
  i++;
33
66
  } else {
34
67
  opts[key] = true;
35
68
  }
36
- } else if (arg.startsWith('-')) {
69
+ } else if (arg.startsWith('-') && arg !== '-') {
37
70
  // Handle short flags like -m "message"
38
71
  const key = arg.slice(1);
39
72
  const nextArg = args[i + 1];
40
73
 
41
- if (nextArg && !nextArg.startsWith('-')) {
74
+ if (nextArg !== undefined && !nextArg.startsWith('-')) {
42
75
  opts[key] = nextArg;
43
76
  i++;
44
77
  } else {
@@ -55,7 +88,7 @@ function parseArgs(argv) {
55
88
  }
56
89
 
57
90
  // Format output for different commands
58
- function formatOutput(command, result) {
91
+ export function formatOutput(command, result, log = console.log) {
59
92
  if (result === undefined || result === null) {
60
93
  return;
61
94
  }
@@ -63,16 +96,16 @@ function formatOutput(command, result) {
63
96
  // Handle special output formatting for common commands
64
97
  if (command === 'log') {
65
98
  if (Array.isArray(result)) {
66
- console.log('\nChange History:\n');
99
+ log('\nChange History:\n');
67
100
  result.forEach((change, idx) => {
68
101
  const shortId = change.changeId.slice(0, 12);
69
102
  const desc = change.description || '(no description)';
70
103
  const author = change.author?.name || 'unknown';
71
104
  const timestamp = change.timestamp ? new Date(change.timestamp).toLocaleString() : '';
72
105
 
73
- console.log(` ${shortId} ${desc}`);
74
- console.log(` ${author} ${timestamp}`);
75
- if (idx < result.length - 1) console.log();
106
+ log(` ${shortId} ${desc}`);
107
+ log(` ${author} ${timestamp}`);
108
+ if (idx < result.length - 1) log('');
76
109
  });
77
110
  return;
78
111
  }
@@ -80,29 +113,31 @@ function formatOutput(command, result) {
80
113
 
81
114
  if (command === 'status') {
82
115
  if (result.workingCopy) {
83
- console.log(`Working copy change: ${result.workingCopy.changeId.slice(0, 12)}`);
84
- console.log(`Parent change(s): ${result.workingCopy.parents.map(p => p.slice(0, 12)).join(', ')}`);
85
- console.log();
116
+ log(`Working copy change: ${result.workingCopy.changeId.slice(0, 12)}`);
117
+ log(
118
+ `Parent change(s): ${(result.workingCopy.parents || []).map((p) => p.slice(0, 12)).join(', ')}`
119
+ );
120
+ log('');
86
121
  }
87
122
 
88
123
  if (result.modified?.length > 0) {
89
- console.log('Modified files:');
90
- result.modified.forEach(file => console.log(` M ${file}`));
124
+ log('Modified files:');
125
+ result.modified.forEach((file) => log(` M ${file}`));
91
126
  }
92
127
 
93
128
  if (result.added?.length > 0) {
94
- console.log('Added files:');
95
- result.added.forEach(file => console.log(` A ${file}`));
129
+ log('Added files:');
130
+ result.added.forEach((file) => log(` A ${file}`));
96
131
  }
97
132
 
98
133
  if (result.removed?.length > 0) {
99
- console.log('Removed files:');
100
- result.removed.forEach(file => console.log(` D ${file}`));
134
+ log('Removed files:');
135
+ result.removed.forEach((file) => log(` D ${file}`));
101
136
  }
102
137
 
103
138
  if (result.conflicts?.length > 0) {
104
- console.log('Conflicted files:');
105
- result.conflicts.forEach(file => console.log(` C ${file}`));
139
+ log('Conflicted files:');
140
+ result.conflicts.forEach((file) => log(` C ${file}`));
106
141
  }
107
142
 
108
143
  return;
@@ -110,82 +145,199 @@ function formatOutput(command, result) {
110
145
 
111
146
  if (command === 'diff') {
112
147
  if (typeof result === 'string') {
113
- console.log(result);
148
+ log(result);
114
149
  return;
115
150
  }
116
151
  }
117
152
 
118
153
  // Default: pretty-print JSON
119
- console.log(JSON.stringify(result, null, 2));
154
+ log(JSON.stringify(result, null, 2));
120
155
  }
121
156
 
122
- // Main CLI handler
123
- async function main() {
124
- try {
125
- const { command, opts } = parseArgs(process.argv);
126
-
127
- if (!command || command === 'help' || command === '--help' || command === '-h') {
128
- console.log(`
157
+ export const HELP_TEXT = `
129
158
  isojj - CLI for isomorphic-jj
130
159
 
131
160
  Usage: isojj <command> [options]
132
161
 
133
- Common commands:
134
- init Initialize a new repository
135
- log [--revset <expr>] Show change history
136
- status Show working copy status
137
- describe -m <message> Set change description
138
- new -m <message> Create new change
139
- diff [--changeId <id>] Show changes
140
-
141
- bookmark.set --name <name> --changeId <id>
162
+ Change management:
163
+ init Initialize a new repository
164
+ status Show working copy status
165
+ log [<revset> | --revset <e>] Show change history
166
+ describe -m <message> Set change description
167
+ new -m <message> Create a new change
168
+ commit -m <message> Describe + start a new change
169
+ diff [--revision <id>] Show changes
170
+ abandon [--revision <id>] Abandon a change
171
+ edit --revision <id> Edit an existing change
172
+
173
+ History editing:
174
+ squash / split / rebase / duplicate / parallelize
175
+ absorb [--dryRun] Absorb working-copy changes into ancestors
176
+ revert --revision <id> Create a change that undoes another (was: backout)
177
+ undo / redo Undo or redo the last operation
178
+
179
+ Files:
180
+ file.list [--revision <id>] List tracked files
181
+ file.show <path> Show file contents
182
+ file.search --pattern <re> Search file contents (jj file search)
183
+ file.annotate --path <p> Blame-style annotation
184
+
185
+ Bookmarks & tags:
186
+ bookmark.set --name <n> --to <id>
187
+ bookmark.advance --name <n> --to <id> Move a bookmark forward only
142
188
  bookmark.list
189
+ tag.set --name <n> --changeId <id> Create or move a tag
190
+ tag.list
143
191
 
144
- absorb [--dryRun] Absorb working copy changes
192
+ Signing:
193
+ sign [--revision <id>] [--backend ssh --key <k>]
194
+ unsign [--revision <id>]
145
195
 
196
+ Bisect:
146
197
  bisect.start --good <id> --bad <id>
147
- bisect.good
148
- bisect.bad
149
- bisect.status
150
- bisect.reset
198
+ bisect.good / bisect.bad / bisect.status / bisect.reset
151
199
 
152
200
  Options:
153
- --dir <path> Repository directory (default: current directory)
201
+ --dir <path> Repository directory (default: current directory).
202
+ Like git/jj, the actual repo root is found by
203
+ searching this directory and its parents for .jj.
154
204
  --json Output raw JSON (skip formatting)
205
+ -m, --message <msg> Message / description
206
+ -r, --revision <id> Target revision
207
+
208
+ Any additional --flags are passed straight through to the API method, so every
209
+ isomorphic-jj method is reachable (e.g. isojj file.search --pattern TODO).
155
210
 
156
211
  Examples:
157
212
  isojj init
158
- isojj log
213
+ isojj log 'author(alice)'
159
214
  isojj describe -m "Fix bug"
160
- isojj new -m "Start new feature"
161
- isojj bookmark.set --name main --changeId abc123
162
- isojj bisect.start --good abc123 --bad def456
163
- `);
164
- process.exit(0);
215
+ isojj revert --revision abc123
216
+ isojj file.search --pattern "TODO"
217
+ isojj bookmark.advance --name main --to def456
218
+ isojj tag.set --name v1.0 --changeId abc123
219
+ `;
220
+
221
+ /**
222
+ * Find the repository root by searching `startDir` and its parents for a
223
+ * `.jj` entry, mirroring how `git`/`jj` locate a repo from any subdirectory.
224
+ *
225
+ * @param {string} startDir - Directory to start searching from
226
+ * @param {any} fs - Filesystem implementation (must expose fs.promises.stat)
227
+ * @returns {Promise<string|null>} The directory containing `.jj`, or null
228
+ */
229
+ export async function findRepoRoot(startDir, fs) {
230
+ let current = nodePath.resolve(startDir);
231
+
232
+ while (true) {
233
+ try {
234
+ await fs.promises.stat(nodePath.join(current, '.jj'));
235
+ return current;
236
+ } catch (error) {
237
+ if (error.code !== 'ENOENT') throw error;
165
238
  }
166
239
 
167
- // Get repository directory
168
- const dir = opts.dir || process.cwd();
240
+ const parent = nodePath.dirname(current);
241
+ if (parent === current) {
242
+ return null; // Reached the filesystem root without finding .jj
243
+ }
244
+ current = parent;
245
+ }
246
+ }
169
247
 
170
- // Initialize repository instance
171
- const jj = await createJJ({ fs, dir });
248
+ /**
249
+ * Dynamically load isomorphic-git (an optional peer dependency) so the CLI
250
+ * can create real Git-backed repositories when it's installed, while still
251
+ * working (storage-only "mock" mode) when it isn't.
252
+ *
253
+ * @param {(specifier: string) => Promise<any>} [importer] - Override for the
254
+ * dynamic `import()` call (used by tests to simulate a missing package
255
+ * without needing real module-resolution mocking).
256
+ * @returns {Promise<{git: any, http: any}>} git/http instances, or undefined
257
+ * for both if isomorphic-git isn't installed.
258
+ */
259
+ export async function loadGitBackend(importer = (specifier) => import(specifier)) {
260
+ try {
261
+ const [{ default: git }, { default: http }] = await Promise.all([
262
+ importer('isomorphic-git'),
263
+ importer('isomorphic-git/http/node'),
264
+ ]);
265
+ return { git, http };
266
+ } catch {
267
+ return { git: undefined, http: undefined };
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Run the CLI for a given argv, returning a process exit code instead of
273
+ * calling process.exit() directly, so this is testable in-process.
274
+ *
275
+ * @param {string[]} argv - Full argv (e.g. process.argv)
276
+ * @param {object} [io]
277
+ * @param {any} [io.fs] - Filesystem implementation (defaults to Node's fs)
278
+ * @param {string} [io.cwd] - Working directory (defaults to process.cwd())
279
+ * @param {(msg: string) => void} [io.stdout] - stdout writer
280
+ * @param {(msg: string) => void} [io.stderr] - stderr writer
281
+ * @param {() => Promise<{git: any, http: any}>} [io.loadGitBackend] - override
282
+ * for loading the optional isomorphic-git backend (used by tests)
283
+ * @returns {Promise<number>} Exit code
284
+ */
285
+ export async function run(argv, io = {}) {
286
+ const fs = io.fs || nodeFs;
287
+ const cwd = io.cwd || process.cwd();
288
+ const stdout = io.stdout || console.log;
289
+ const stderr = io.stderr || console.error;
290
+ const resolveGitBackend = io.loadGitBackend || loadGitBackend;
291
+
292
+ try {
293
+ const { command, opts } = parseArgs(argv);
294
+
295
+ if (command === 'version' || command === '--version' || command === '-v') {
296
+ // Always read package.json via real Node fs — it's outside the
297
+ // repository, so the (possibly injected/mock) repo `fs` doesn't apply.
298
+ const pkg = JSON.parse(
299
+ nodeFs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')
300
+ );
301
+ stdout(`isojj ${pkg.version}`);
302
+ return 0;
303
+ }
304
+
305
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
306
+ stdout(HELP_TEXT);
307
+ return 0;
308
+ }
309
+
310
+ // Resolve the repository directory. Like git/jj, search the given
311
+ // directory (or cwd) and its parents for the .jj marker so the CLI works
312
+ // from any subdirectory of a repo, not just its root.
313
+ const startDir = opts.dir || cwd;
314
+ let dir = startDir;
172
315
 
173
- // If not initializing, load existing repository state
174
316
  if (command !== 'init') {
175
- try {
176
- // Check if repository exists by checking for .jj directory
177
- await fs.promises.stat(`${dir}/.jj`);
178
-
179
- // Load repository state
180
- await jj.graph.load();
181
- await jj.workingCopy.load();
182
- await jj.bookmarks.load();
183
- } catch (error) {
184
- if (error.code === 'ENOENT') {
185
- throw new Error(`Not a jj repository (or any parent up to mount point /)\nStopped at: ${dir}\nRun 'isojj init' to initialize a repository`);
186
- }
187
- throw error;
317
+ const root = await findRepoRoot(startDir, fs);
318
+ if (!root) {
319
+ throw new Error(
320
+ `Not a jj repository (or any parent up to mount point /)\nStopped at: ${startDir}\nRun 'isojj init' to initialize a repository`
321
+ );
188
322
  }
323
+ dir = root;
324
+ }
325
+
326
+ // Wire up the real isomorphic-git backend when it's installed (it's an
327
+ // optional peer dependency), so `isojj init` creates a real .git
328
+ // directory instead of silently falling back to the storage-only "mock"
329
+ // backend. Attempted for every command (not just init) so an already
330
+ // Git-backed repo is loaded consistently.
331
+ const { git, http } = await resolveGitBackend();
332
+
333
+ // Initialize repository instance
334
+ const jj = await createJJ({ fs, dir, git, http });
335
+
336
+ if (command !== 'init') {
337
+ // Load existing repository state
338
+ await jj.graph.load();
339
+ await jj.workingCopy.load();
340
+ await jj.bookmarks.load();
189
341
  }
190
342
 
191
343
  // Handle nested commands (e.g., bisect.start, bookmark.set)
@@ -193,8 +345,8 @@ Examples:
193
345
  let method = jj;
194
346
 
195
347
  for (const part of parts) {
196
- method = method[part];
197
- if (!method) {
348
+ method = method?.[part];
349
+ if (method === undefined || method === null) {
198
350
  throw new Error(`Unknown command: ${command}`);
199
351
  }
200
352
  }
@@ -203,29 +355,36 @@ Examples:
203
355
  throw new Error(`${command} is not a valid command`);
204
356
  }
205
357
 
206
- // Map common CLI flags to API options
207
- const apiOpts = {};
358
+ // Map CLI flags to API options. Every parsed flag is passed through so new
359
+ // commands work without special-casing; a few common shorthands are then
360
+ // normalized on top.
361
+ const { dir: _dir, json: _json, _positional, ...passthrough } = opts;
362
+ const apiOpts = { ...passthrough };
208
363
 
209
- // Common mappings
364
+ // Common shorthand mappings
210
365
  if (opts.m || opts.message) apiOpts.message = opts.m || opts.message;
211
- if (opts.changeId) apiOpts.changeId = opts.changeId;
212
- if (opts.revset) apiOpts.revset = opts.revset;
213
- if (opts.name) apiOpts.name = opts.name;
214
- if (opts.path) apiOpts.path = opts.path;
215
- if (opts.good) apiOpts.good = opts.good;
216
- if (opts.bad) apiOpts.bad = opts.bad;
366
+ if (opts.r || opts.revision) apiOpts.revision = opts.r || opts.revision;
217
367
  if (opts.dryRun) apiOpts.dryRun = true;
218
- if (opts.parents) {
219
- apiOpts.parents = opts.parents.includes(',')
220
- ? opts.parents.split(',')
221
- : [opts.parents];
368
+ if (typeof opts.parents === 'string') {
369
+ apiOpts.parents = opts.parents.includes(',') ? opts.parents.split(',') : [opts.parents];
222
370
  }
223
371
 
224
- // Add positional arguments if any
225
- if (opts._positional?.length > 0) {
226
- // For commands like 'read <path>', use first positional as path
227
- if (command === 'read' || command === 'remove') {
228
- apiOpts.path = opts._positional[0];
372
+ // Positional arguments: map the first positional to the most natural
373
+ // parameter for common file/query commands.
374
+ if (_positional?.length > 0) {
375
+ const first = _positional[0];
376
+ if (
377
+ (command === 'read' || command === 'remove' || command.startsWith('file.')) &&
378
+ !apiOpts.path
379
+ ) {
380
+ apiOpts.path = first;
381
+ } else if (command === 'log' && !apiOpts.revset) {
382
+ apiOpts.revset = first;
383
+ } else if (
384
+ (command === 'describe' || command === 'new' || command === 'commit') &&
385
+ !apiOpts.message
386
+ ) {
387
+ apiOpts.message = first;
229
388
  }
230
389
  }
231
390
 
@@ -234,29 +393,38 @@ Examples:
234
393
 
235
394
  // Format and output result
236
395
  if (opts.json) {
237
- console.log(JSON.stringify(result, null, 2));
396
+ stdout(JSON.stringify(result, null, 2));
238
397
  } else {
239
- formatOutput(command, result);
398
+ formatOutput(command, result, stdout);
240
399
  }
241
400
 
242
- process.exit(0);
401
+ return 0;
243
402
  } catch (error) {
244
- console.error(`Error: ${error.message}`);
403
+ stderr(`Error: ${error.message}`);
245
404
 
246
405
  if (error.code) {
247
- console.error(`Code: ${error.code}`);
406
+ stderr(`Code: ${error.code}`);
248
407
  }
249
408
 
250
409
  if (error.context?.suggestion) {
251
- console.error(`Suggestion: ${error.context.suggestion}`);
410
+ stderr(`Suggestion: ${error.context.suggestion}`);
252
411
  }
253
412
 
254
413
  if (process.env.DEBUG) {
255
- console.error(error.stack);
414
+ stderr(error.stack);
256
415
  }
257
416
 
258
- process.exit(1);
417
+ return 1;
259
418
  }
260
419
  }
261
420
 
262
- main();
421
+ // Only run (and exit the process) when this file is executed directly, not
422
+ // when it's imported by tests. Comparing `import.meta.url` (a file:// URL,
423
+ // e.g. "file:///C:/repo/bin/isojj.js" on Windows) against the raw
424
+ // `process.argv[1]` string (a native path, e.g. "C:\repo\bin\isojj.js") is
425
+ // never equal on Windows — convert the URL to a native path first.
426
+ const isMainModule =
427
+ !!process.argv[1] && fileURLToPath(import.meta.url) === nodePath.resolve(process.argv[1]);
428
+ if (isMainModule) {
429
+ run(process.argv).then((code) => process.exit(code));
430
+ }