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