@optique/git 0.10.7 → 1.0.0-dev.1109

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/README.md CHANGED
@@ -30,7 +30,8 @@ Quick start
30
30
 
31
31
  ~~~~ typescript
32
32
  import { gitBranch, gitTag, gitCommit } from "@optique/git";
33
- import { argument, option, object } from "@optique/core/primitives";
33
+ import { object } from "@optique/core/constructs";
34
+ import { argument, option } from "@optique/core/primitives";
34
35
  import { parseAsync } from "@optique/core/parser";
35
36
 
36
37
  const parser = object({
@@ -53,7 +54,8 @@ Use `createGitParsers()` to create parsers for a different repository:
53
54
 
54
55
  ~~~~ typescript
55
56
  import { createGitParsers } from "@optique/git";
56
- import { argument, object } from "@optique/core/primitives";
57
+ import { object } from "@optique/core/constructs";
58
+ import { argument, option } from "@optique/core/primitives";
57
59
  import { parseAsync } from "@optique/core/parser";
58
60
 
59
61
  const git = createGitParsers({ dir: "/path/to/repo" });
@@ -79,7 +81,8 @@ existing branch in the repository.
79
81
 
80
82
  ~~~~ typescript
81
83
  import { gitBranch } from "@optique/git";
82
- import { argument, object } from "@optique/core/primitives";
84
+ import { object } from "@optique/core/constructs";
85
+ import { argument } from "@optique/core/primitives";
83
86
  import { parseAsync } from "@optique/core/parser";
84
87
 
85
88
  const parser = object({
@@ -102,7 +105,8 @@ existing branch on the specified remote.
102
105
 
103
106
  ~~~~ typescript
104
107
  import { gitRemoteBranch } from "@optique/git";
105
- import { option, object } from "@optique/core/primitives";
108
+ import { object } from "@optique/core/constructs";
109
+ import { option } from "@optique/core/primitives";
106
110
  import { parseAsync } from "@optique/core/parser";
107
111
 
108
112
  const parser = object({
@@ -120,7 +124,8 @@ in the repository.
120
124
 
121
125
  ~~~~ typescript
122
126
  import { gitTag } from "@optique/git";
123
- import { option, object } from "@optique/core/primitives";
127
+ import { object } from "@optique/core/constructs";
128
+ import { option } from "@optique/core/primitives";
124
129
  import { parseAsync } from "@optique/core/parser";
125
130
 
126
131
  const parser = object({
@@ -138,7 +143,8 @@ remote in the repository.
138
143
 
139
144
  ~~~~ typescript
140
145
  import { gitRemote } from "@optique/git";
141
- import { option, object } from "@optique/core/primitives";
146
+ import { object } from "@optique/core/constructs";
147
+ import { option } from "@optique/core/primitives";
142
148
  import { parseAsync } from "@optique/core/parser";
143
149
 
144
150
  const parser = object({
@@ -156,7 +162,8 @@ A value parser for commit SHAs. Validates that the input is a valid commit SHA
156
162
 
157
163
  ~~~~ typescript
158
164
  import { gitCommit } from "@optique/git";
159
- import { option, object } from "@optique/core/primitives";
165
+ import { object } from "@optique/core/constructs";
166
+ import { option } from "@optique/core/primitives";
160
167
  import { parseAsync } from "@optique/core/parser";
161
168
 
162
169
  const parser = object({
@@ -174,7 +181,8 @@ that the input resolves to a valid Git reference.
174
181
 
175
182
  ~~~~ typescript
176
183
  import { gitRef } from "@optique/git";
177
- import { option, object } from "@optique/core/primitives";
184
+ import { object } from "@optique/core/constructs";
185
+ import { option } from "@optique/core/primitives";
178
186
  import { parseAsync } from "@optique/core/parser";
179
187
 
180
188
  const parser = object({
@@ -192,7 +200,8 @@ created by the factory share the same filesystem and directory options.
192
200
 
193
201
  ~~~~ typescript
194
202
  import { createGitParsers } from "@optique/git";
195
- import { argument, option, object } from "@optique/core/primitives";
203
+ import { object } from "@optique/core/constructs";
204
+ import { argument, option } from "@optique/core/primitives";
196
205
  import { parseAsync } from "@optique/core/parser";
197
206
 
198
207
  const git = createGitParsers({ dir: "/path/to/repo" });
@@ -224,7 +233,8 @@ the user's input prefix.
224
233
 
225
234
  ~~~~ typescript
226
235
  import { gitBranch } from "@optique/git";
227
- import { argument, object } from "@optique/core/primitives";
236
+ import { object } from "@optique/core/constructs";
237
+ import { argument } from "@optique/core/primitives";
228
238
 
229
239
  const parser = object({
230
240
  branch: argument(gitBranch()),
package/dist/index.cjs CHANGED
@@ -37,15 +37,16 @@ const logger = (0, __logtape_logtape.getLogger)(["optique", "git"]);
37
37
  * Write methods are implemented as stubs that return rejected promises,
38
38
  * enforcing the read-only contract and preventing accidental writes.
39
39
  */
40
+ const readOnlyFsMethod = () => Promise.reject(/* @__PURE__ */ new Error("gitFs is read-only for write operations."));
40
41
  const gitFs = {
41
42
  readFile: node_fs_promises.readFile,
42
- writeFile: () => Promise.reject(/* @__PURE__ */ new Error("gitFs is read-only: writeFile is disabled.")),
43
- mkdir: () => Promise.reject(/* @__PURE__ */ new Error("gitFs is read-only: mkdir is disabled.")),
44
- rmdir: () => Promise.reject(/* @__PURE__ */ new Error("gitFs is read-only: rmdir is disabled.")),
45
- unlink: () => Promise.reject(/* @__PURE__ */ new Error("gitFs is read-only: unlink is disabled.")),
43
+ writeFile: readOnlyFsMethod,
44
+ mkdir: readOnlyFsMethod,
45
+ rmdir: readOnlyFsMethod,
46
+ unlink: readOnlyFsMethod,
46
47
  readdir: node_fs_promises.readdir,
47
48
  readlink: node_fs_promises.readlink,
48
- symlink: () => Promise.reject(/* @__PURE__ */ new Error("gitFs is read-only: symlink is disabled.")),
49
+ symlink: readOnlyFsMethod,
49
50
  stat: node_fs_promises.stat,
50
51
  lstat: node_fs_promises.lstat
51
52
  };
@@ -75,8 +76,55 @@ function listFailureMessage(error, dir, errors, fallback) {
75
76
  }
76
77
  /** Default depth for commit suggestions. */
77
78
  const DEFAULT_SUGGESTION_DEPTH = 15;
79
+ /**
80
+ * Computes the shortest unique short OID for each given full OID within the
81
+ * provided set, starting from a minimum length. When two or more OIDs share
82
+ * the same short prefix, their prefixes are lengthened until each is unique
83
+ * (up to the full 40-char OID).
84
+ *
85
+ * Note: this only disambiguates within the given set, not against the entire
86
+ * object database. In the rare case that a suggested short OID collides with
87
+ * an older commit outside the suggestion window, the parser will report a
88
+ * clear "ambiguous" error prompting the user to type more characters.
89
+ */
90
+ function uniqueShortOids(oids, minLength) {
91
+ const result = /* @__PURE__ */ new Map();
92
+ const lengths = /* @__PURE__ */ new Map();
93
+ for (const oid of oids) lengths.set(oid, minLength);
94
+ let remaining = new Set(oids);
95
+ while (remaining.size > 0) {
96
+ const groups = /* @__PURE__ */ new Map();
97
+ for (const oid of remaining) {
98
+ const len = lengths.get(oid);
99
+ const short = oid.slice(0, len);
100
+ const group = groups.get(short);
101
+ if (group != null) group.push(oid);
102
+ else groups.set(short, [oid]);
103
+ }
104
+ const nextRemaining = /* @__PURE__ */ new Set();
105
+ for (const [short, group] of groups) if (group.length === 1) result.set(group[0], short);
106
+ else for (const oid of group) {
107
+ const currentLen = lengths.get(oid);
108
+ if (currentLen >= oid.length) result.set(oid, oid);
109
+ else {
110
+ lengths.set(oid, currentLen + 1);
111
+ nextRemaining.add(oid);
112
+ }
113
+ }
114
+ remaining = nextRemaining;
115
+ }
116
+ return result;
117
+ }
78
118
  function createAsyncValueParser(options, metavar, parseFn, suggestFn) {
79
119
  (0, __optique_core_nonempty.ensureNonEmptyString)(metavar);
120
+ if (options?.suggestionDepth !== void 0) {
121
+ if (!Number.isInteger(options.suggestionDepth) || options.suggestionDepth < 1) {
122
+ const depth = options.suggestionDepth;
123
+ const repr = typeof depth === "number" ? String(depth) : typeof depth === "string" ? JSON.stringify(depth) : `${typeof depth} ${String(depth)}`;
124
+ throw new RangeError(`Invalid suggestionDepth (must be a positive integer): ${repr}`);
125
+ }
126
+ }
127
+ const validatedDepth = options?.suggestionDepth ?? DEFAULT_SUGGESTION_DEPTH;
80
128
  return {
81
129
  $mode: "async",
82
130
  metavar,
@@ -89,10 +137,7 @@ function createAsyncValueParser(options, metavar, parseFn, suggestFn) {
89
137
  },
90
138
  async *suggest(prefix) {
91
139
  const dir = getRepoDir(options?.dir);
92
- if (suggestFn) {
93
- const depth = options?.suggestionDepth ?? DEFAULT_SUGGESTION_DEPTH;
94
- yield* suggestFn(dir, prefix, depth);
95
- }
140
+ if (suggestFn) yield* suggestFn(dir, prefix, validatedDepth);
96
141
  }
97
142
  };
98
143
  }
@@ -104,6 +149,7 @@ function createAsyncValueParser(options, metavar, parseFn, suggestFn) {
104
149
  *
105
150
  * @param options Configuration options for the parser.
106
151
  * @returns A value parser that accepts existing branch names.
152
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
107
153
  * @since 0.9.0
108
154
  *
109
155
  * @example
@@ -169,6 +215,7 @@ function gitBranch(options) {
169
215
  * @param remote The remote name to validate against.
170
216
  * @param options Configuration options for the parser.
171
217
  * @returns A value parser that accepts existing remote branch names.
218
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
172
219
  * @since 0.9.0
173
220
  *
174
221
  * @example
@@ -192,6 +239,27 @@ function gitRemoteBranch(remote, options) {
192
239
  success: true,
193
240
  value: input
194
241
  };
242
+ if (branches.length === 0) {
243
+ const remotes = await isomorphic_git.listRemotes({
244
+ fs: gitFs,
245
+ dir
246
+ });
247
+ const names = remotes.map((r) => r.remote);
248
+ if (!names.includes(remote)) {
249
+ if (errors?.remoteNotFound) return {
250
+ success: false,
251
+ error: errors.remoteNotFound(remote, names)
252
+ };
253
+ if (errors?.notFound) return {
254
+ success: false,
255
+ error: errors.notFound(input, branches)
256
+ };
257
+ return {
258
+ success: false,
259
+ error: __optique_core_message.message`Remote ${(0, __optique_core_message.value)(remote)} does not exist. Available remotes: ${(0, __optique_core_message.valueSet)(names)}`
260
+ };
261
+ }
262
+ }
195
263
  if (errors?.notFound) return {
196
264
  success: false,
197
265
  error: errors.notFound(input, branches)
@@ -233,6 +301,7 @@ function gitRemoteBranch(remote, options) {
233
301
  *
234
302
  * @param options Configuration options for the parser.
235
303
  * @returns A value parser that accepts existing tag names.
304
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
236
305
  * @since 0.9.0
237
306
  */
238
307
  function gitTag(options) {
@@ -286,6 +355,7 @@ function gitTag(options) {
286
355
  *
287
356
  * @param options Configuration options for the parser.
288
357
  * @returns A value parser that accepts existing remote names.
358
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
289
359
  * @since 0.9.0
290
360
  */
291
361
  function gitRemote(options) {
@@ -343,6 +413,7 @@ function gitRemote(options) {
343
413
  *
344
414
  * @param options Configuration options for the parser.
345
415
  * @returns A value parser that accepts existing commit SHAs.
416
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
346
417
  * @since 0.9.0
347
418
  */
348
419
  function gitCommit(options) {
@@ -369,7 +440,7 @@ function gitCommit(options) {
369
440
  value: oid
370
441
  };
371
442
  } catch (e) {
372
- if (hasErrorCode(e, "AmbiguousShortOidError")) return {
443
+ if (hasErrorCode(e, "AmbiguousShortOidError") || hasErrorCode(e, "AmbiguousError")) return {
373
444
  success: false,
374
445
  error: __optique_core_message.message`Commit SHA ${(0, __optique_core_message.value)(input)} is ambiguous. Provide more characters to disambiguate.`
375
446
  };
@@ -389,8 +460,11 @@ function gitCommit(options) {
389
460
  dir,
390
461
  depth
391
462
  });
392
- for (const commit of commits) if (commit.oid.startsWith(prefix)) {
393
- const shortOid = commit.oid.slice(0, 7);
463
+ const matching = commits.filter((c) => c.oid.startsWith(prefix));
464
+ const minLen = Math.max(7, prefix.length);
465
+ const shortOids = uniqueShortOids(matching.map((c) => c.oid), minLen);
466
+ for (const commit of matching) {
467
+ const shortOid = shortOids.get(commit.oid);
394
468
  const firstLine = commit.commit.message.split("\n")[0];
395
469
  yield {
396
470
  kind: "literal",
@@ -415,6 +489,7 @@ function gitCommit(options) {
415
489
  *
416
490
  * @param options Configuration options for the parser.
417
491
  * @returns A value parser that accepts any git reference.
492
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
418
493
  * @since 0.9.0
419
494
  */
420
495
  function gitRef(options) {
@@ -443,7 +518,7 @@ function gitRef(options) {
443
518
  value: oid
444
519
  };
445
520
  } catch (e) {
446
- if (hasErrorCode(e, "AmbiguousShortOidError")) return {
521
+ if (hasErrorCode(e, "AmbiguousShortOidError") || hasErrorCode(e, "AmbiguousError")) return {
447
522
  success: false,
448
523
  error: __optique_core_message.message`Reference ${(0, __optique_core_message.value)(input)} is ambiguous. Provide more characters to disambiguate.`
449
524
  };
@@ -473,16 +548,28 @@ function gitRef(options) {
473
548
  depth
474
549
  })
475
550
  ]);
476
- for (const branch of branches) if (branch.startsWith(prefix)) yield {
477
- kind: "literal",
478
- text: branch
479
- };
480
- for (const tag of tags) if (tag.startsWith(prefix)) yield {
481
- kind: "literal",
482
- text: tag
483
- };
484
- for (const commit of commits) if (commit.oid.startsWith(prefix)) {
485
- const shortOid = commit.oid.slice(0, 7);
551
+ const seen = /* @__PURE__ */ new Set();
552
+ for (const branch of branches) if (branch.startsWith(prefix) && !seen.has(branch)) {
553
+ seen.add(branch);
554
+ yield {
555
+ kind: "literal",
556
+ text: branch
557
+ };
558
+ }
559
+ for (const tag of tags) if (tag.startsWith(prefix) && !seen.has(tag)) {
560
+ seen.add(tag);
561
+ yield {
562
+ kind: "literal",
563
+ text: tag
564
+ };
565
+ }
566
+ const matching = commits.filter((c) => c.oid.startsWith(prefix));
567
+ const minLen = Math.max(7, prefix.length);
568
+ const shortOids = uniqueShortOids(matching.map((c) => c.oid), minLen);
569
+ for (const commit of matching) {
570
+ const shortOid = shortOids.get(commit.oid);
571
+ if (seen.has(shortOid)) continue;
572
+ seen.add(shortOid);
486
573
  const firstLine = commit.commit.message.split("\n")[0];
487
574
  yield {
488
575
  kind: "literal",
@@ -503,7 +590,9 @@ function gitRef(options) {
503
590
  * Creates a set of git parsers with shared configuration.
504
591
  *
505
592
  * @param options Shared configuration for the parsers.
506
- * @returns An object containing git parsers.
593
+ * @returns An object containing git parsers. Each returned method may throw
594
+ * a {@link RangeError} if the merged `suggestionDepth` is not a positive
595
+ * integer.
507
596
  * @since 0.9.0
508
597
  */
509
598
  function createGitParsers(options) {
package/dist/index.d.cts CHANGED
@@ -29,7 +29,9 @@ interface GitParserOptions {
29
29
  errors?: GitParserErrors;
30
30
  /**
31
31
  * Maximum number of recent commits to include in shell completion suggestions.
32
- * Only applies to `gitCommit()` and `gitRef()` parsers.
32
+ * Only affects suggestions from `gitCommit()` and `gitRef()` parsers, but
33
+ * is validated by all git parser functions.
34
+ * Must be a positive integer.
33
35
  * Defaults to 15.
34
36
  *
35
37
  * @since 0.9.0
@@ -66,6 +68,16 @@ interface GitParserErrors {
66
68
  * @returns A custom error message.
67
69
  */
68
70
  invalidFormat?: (input: string) => Message;
71
+ /**
72
+ * Error message when the remote does not exist.
73
+ * Only used by {@link gitRemoteBranch}.
74
+ *
75
+ * @param remote The remote name that was not found.
76
+ * @param availableRemotes List of available remote names.
77
+ * @returns A custom error message.
78
+ * @since 1.0.0
79
+ */
80
+ remoteNotFound?: (remote: string, availableRemotes: readonly string[]) => Message;
69
81
  }
70
82
  /**
71
83
  * Git parsers factory interface.
@@ -77,6 +89,7 @@ interface GitParsers {
77
89
  * Creates a value parser that validates local branch names.
78
90
  * @param options Configuration options for the parser.
79
91
  * @returns A value parser that accepts existing branch names.
92
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
80
93
  */
81
94
  branch(options?: GitParserOptions): ValueParser<"async", string>;
82
95
  /**
@@ -84,24 +97,28 @@ interface GitParsers {
84
97
  * @param remote The remote name to validate against.
85
98
  * @param options Configuration options for the parser.
86
99
  * @returns A value parser that accepts existing remote branch names.
100
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
87
101
  */
88
102
  remoteBranch(remote: string, options?: GitParserOptions): ValueParser<"async", string>;
89
103
  /**
90
104
  * Creates a value parser that validates tag names.
91
105
  * @param options Configuration options for the parser.
92
106
  * @returns A value parser that accepts existing tag names.
107
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
93
108
  */
94
109
  tag(options?: GitParserOptions): ValueParser<"async", string>;
95
110
  /**
96
111
  * Creates a value parser that validates remote names.
97
112
  * @param options Configuration options for the parser.
98
113
  * @returns A value parser that accepts existing remote names.
114
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
99
115
  */
100
116
  remote(options?: GitParserOptions): ValueParser<"async", string>;
101
117
  /**
102
118
  * Creates a value parser that validates commit SHAs.
103
119
  * @param options Configuration options for the parser.
104
120
  * @returns A value parser that accepts existing commit SHAs.
121
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
105
122
  */
106
123
  commit(options?: GitParserOptions): ValueParser<"async", string>;
107
124
  /**
@@ -109,6 +126,7 @@ interface GitParsers {
109
126
  * Accepts branch names, tag names, or commit SHAs.
110
127
  * @param options Configuration options for the parser.
111
128
  * @returns A value parser that accepts any git reference.
129
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
112
130
  */
113
131
  ref(options?: GitParserOptions): ValueParser<"async", string>;
114
132
  }
@@ -120,6 +138,7 @@ interface GitParsers {
120
138
  *
121
139
  * @param options Configuration options for the parser.
122
140
  * @returns A value parser that accepts existing branch names.
141
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
123
142
  * @since 0.9.0
124
143
  *
125
144
  * @example
@@ -140,6 +159,7 @@ declare function gitBranch(options?: GitParserOptions): ValueParser<"async", str
140
159
  * @param remote The remote name to validate against.
141
160
  * @param options Configuration options for the parser.
142
161
  * @returns A value parser that accepts existing remote branch names.
162
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
143
163
  * @since 0.9.0
144
164
  *
145
165
  * @example
@@ -156,6 +176,7 @@ declare function gitRemoteBranch(remote: string, options?: GitParserOptions): Va
156
176
  *
157
177
  * @param options Configuration options for the parser.
158
178
  * @returns A value parser that accepts existing tag names.
179
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
159
180
  * @since 0.9.0
160
181
  */
161
182
  declare function gitTag(options?: GitParserOptions): ValueParser<"async", string>;
@@ -164,6 +185,7 @@ declare function gitTag(options?: GitParserOptions): ValueParser<"async", string
164
185
  *
165
186
  * @param options Configuration options for the parser.
166
187
  * @returns A value parser that accepts existing remote names.
188
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
167
189
  * @since 0.9.0
168
190
  */
169
191
  declare function gitRemote(options?: GitParserOptions): ValueParser<"async", string>;
@@ -175,6 +197,7 @@ declare function gitRemote(options?: GitParserOptions): ValueParser<"async", str
175
197
  *
176
198
  * @param options Configuration options for the parser.
177
199
  * @returns A value parser that accepts existing commit SHAs.
200
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
178
201
  * @since 0.9.0
179
202
  */
180
203
  declare function gitCommit(options?: GitParserOptions): ValueParser<"async", string>;
@@ -186,6 +209,7 @@ declare function gitCommit(options?: GitParserOptions): ValueParser<"async", str
186
209
  *
187
210
  * @param options Configuration options for the parser.
188
211
  * @returns A value parser that accepts any git reference.
212
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
189
213
  * @since 0.9.0
190
214
  */
191
215
  declare function gitRef(options?: GitParserOptions): ValueParser<"async", string>;
@@ -193,7 +217,9 @@ declare function gitRef(options?: GitParserOptions): ValueParser<"async", string
193
217
  * Creates a set of git parsers with shared configuration.
194
218
  *
195
219
  * @param options Shared configuration for the parsers.
196
- * @returns An object containing git parsers.
220
+ * @returns An object containing git parsers. Each returned method may throw
221
+ * a {@link RangeError} if the merged `suggestionDepth` is not a positive
222
+ * integer.
197
223
  * @since 0.9.0
198
224
  */
199
225
  declare function createGitParsers(options?: GitParserOptions): GitParsers;
package/dist/index.d.ts CHANGED
@@ -29,7 +29,9 @@ interface GitParserOptions {
29
29
  errors?: GitParserErrors;
30
30
  /**
31
31
  * Maximum number of recent commits to include in shell completion suggestions.
32
- * Only applies to `gitCommit()` and `gitRef()` parsers.
32
+ * Only affects suggestions from `gitCommit()` and `gitRef()` parsers, but
33
+ * is validated by all git parser functions.
34
+ * Must be a positive integer.
33
35
  * Defaults to 15.
34
36
  *
35
37
  * @since 0.9.0
@@ -66,6 +68,16 @@ interface GitParserErrors {
66
68
  * @returns A custom error message.
67
69
  */
68
70
  invalidFormat?: (input: string) => Message;
71
+ /**
72
+ * Error message when the remote does not exist.
73
+ * Only used by {@link gitRemoteBranch}.
74
+ *
75
+ * @param remote The remote name that was not found.
76
+ * @param availableRemotes List of available remote names.
77
+ * @returns A custom error message.
78
+ * @since 1.0.0
79
+ */
80
+ remoteNotFound?: (remote: string, availableRemotes: readonly string[]) => Message;
69
81
  }
70
82
  /**
71
83
  * Git parsers factory interface.
@@ -77,6 +89,7 @@ interface GitParsers {
77
89
  * Creates a value parser that validates local branch names.
78
90
  * @param options Configuration options for the parser.
79
91
  * @returns A value parser that accepts existing branch names.
92
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
80
93
  */
81
94
  branch(options?: GitParserOptions): ValueParser<"async", string>;
82
95
  /**
@@ -84,24 +97,28 @@ interface GitParsers {
84
97
  * @param remote The remote name to validate against.
85
98
  * @param options Configuration options for the parser.
86
99
  * @returns A value parser that accepts existing remote branch names.
100
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
87
101
  */
88
102
  remoteBranch(remote: string, options?: GitParserOptions): ValueParser<"async", string>;
89
103
  /**
90
104
  * Creates a value parser that validates tag names.
91
105
  * @param options Configuration options for the parser.
92
106
  * @returns A value parser that accepts existing tag names.
107
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
93
108
  */
94
109
  tag(options?: GitParserOptions): ValueParser<"async", string>;
95
110
  /**
96
111
  * Creates a value parser that validates remote names.
97
112
  * @param options Configuration options for the parser.
98
113
  * @returns A value parser that accepts existing remote names.
114
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
99
115
  */
100
116
  remote(options?: GitParserOptions): ValueParser<"async", string>;
101
117
  /**
102
118
  * Creates a value parser that validates commit SHAs.
103
119
  * @param options Configuration options for the parser.
104
120
  * @returns A value parser that accepts existing commit SHAs.
121
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
105
122
  */
106
123
  commit(options?: GitParserOptions): ValueParser<"async", string>;
107
124
  /**
@@ -109,6 +126,7 @@ interface GitParsers {
109
126
  * Accepts branch names, tag names, or commit SHAs.
110
127
  * @param options Configuration options for the parser.
111
128
  * @returns A value parser that accepts any git reference.
129
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
112
130
  */
113
131
  ref(options?: GitParserOptions): ValueParser<"async", string>;
114
132
  }
@@ -120,6 +138,7 @@ interface GitParsers {
120
138
  *
121
139
  * @param options Configuration options for the parser.
122
140
  * @returns A value parser that accepts existing branch names.
141
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
123
142
  * @since 0.9.0
124
143
  *
125
144
  * @example
@@ -140,6 +159,7 @@ declare function gitBranch(options?: GitParserOptions): ValueParser<"async", str
140
159
  * @param remote The remote name to validate against.
141
160
  * @param options Configuration options for the parser.
142
161
  * @returns A value parser that accepts existing remote branch names.
162
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
143
163
  * @since 0.9.0
144
164
  *
145
165
  * @example
@@ -156,6 +176,7 @@ declare function gitRemoteBranch(remote: string, options?: GitParserOptions): Va
156
176
  *
157
177
  * @param options Configuration options for the parser.
158
178
  * @returns A value parser that accepts existing tag names.
179
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
159
180
  * @since 0.9.0
160
181
  */
161
182
  declare function gitTag(options?: GitParserOptions): ValueParser<"async", string>;
@@ -164,6 +185,7 @@ declare function gitTag(options?: GitParserOptions): ValueParser<"async", string
164
185
  *
165
186
  * @param options Configuration options for the parser.
166
187
  * @returns A value parser that accepts existing remote names.
188
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
167
189
  * @since 0.9.0
168
190
  */
169
191
  declare function gitRemote(options?: GitParserOptions): ValueParser<"async", string>;
@@ -175,6 +197,7 @@ declare function gitRemote(options?: GitParserOptions): ValueParser<"async", str
175
197
  *
176
198
  * @param options Configuration options for the parser.
177
199
  * @returns A value parser that accepts existing commit SHAs.
200
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
178
201
  * @since 0.9.0
179
202
  */
180
203
  declare function gitCommit(options?: GitParserOptions): ValueParser<"async", string>;
@@ -186,6 +209,7 @@ declare function gitCommit(options?: GitParserOptions): ValueParser<"async", str
186
209
  *
187
210
  * @param options Configuration options for the parser.
188
211
  * @returns A value parser that accepts any git reference.
212
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
189
213
  * @since 0.9.0
190
214
  */
191
215
  declare function gitRef(options?: GitParserOptions): ValueParser<"async", string>;
@@ -193,7 +217,9 @@ declare function gitRef(options?: GitParserOptions): ValueParser<"async", string
193
217
  * Creates a set of git parsers with shared configuration.
194
218
  *
195
219
  * @param options Shared configuration for the parsers.
196
- * @returns An object containing git parsers.
220
+ * @returns An object containing git parsers. Each returned method may throw
221
+ * a {@link RangeError} if the merged `suggestionDepth` is not a positive
222
+ * integer.
197
223
  * @since 0.9.0
198
224
  */
199
225
  declare function createGitParsers(options?: GitParserOptions): GitParsers;
package/dist/index.js CHANGED
@@ -15,15 +15,16 @@ const logger = getLogger(["optique", "git"]);
15
15
  * Write methods are implemented as stubs that return rejected promises,
16
16
  * enforcing the read-only contract and preventing accidental writes.
17
17
  */
18
+ const readOnlyFsMethod = () => Promise.reject(/* @__PURE__ */ new Error("gitFs is read-only for write operations."));
18
19
  const gitFs = {
19
20
  readFile: fs.readFile,
20
- writeFile: () => Promise.reject(/* @__PURE__ */ new Error("gitFs is read-only: writeFile is disabled.")),
21
- mkdir: () => Promise.reject(/* @__PURE__ */ new Error("gitFs is read-only: mkdir is disabled.")),
22
- rmdir: () => Promise.reject(/* @__PURE__ */ new Error("gitFs is read-only: rmdir is disabled.")),
23
- unlink: () => Promise.reject(/* @__PURE__ */ new Error("gitFs is read-only: unlink is disabled.")),
21
+ writeFile: readOnlyFsMethod,
22
+ mkdir: readOnlyFsMethod,
23
+ rmdir: readOnlyFsMethod,
24
+ unlink: readOnlyFsMethod,
24
25
  readdir: fs.readdir,
25
26
  readlink: fs.readlink,
26
- symlink: () => Promise.reject(/* @__PURE__ */ new Error("gitFs is read-only: symlink is disabled.")),
27
+ symlink: readOnlyFsMethod,
27
28
  stat: fs.stat,
28
29
  lstat: fs.lstat
29
30
  };
@@ -53,8 +54,55 @@ function listFailureMessage(error, dir, errors, fallback) {
53
54
  }
54
55
  /** Default depth for commit suggestions. */
55
56
  const DEFAULT_SUGGESTION_DEPTH = 15;
57
+ /**
58
+ * Computes the shortest unique short OID for each given full OID within the
59
+ * provided set, starting from a minimum length. When two or more OIDs share
60
+ * the same short prefix, their prefixes are lengthened until each is unique
61
+ * (up to the full 40-char OID).
62
+ *
63
+ * Note: this only disambiguates within the given set, not against the entire
64
+ * object database. In the rare case that a suggested short OID collides with
65
+ * an older commit outside the suggestion window, the parser will report a
66
+ * clear "ambiguous" error prompting the user to type more characters.
67
+ */
68
+ function uniqueShortOids(oids, minLength) {
69
+ const result = /* @__PURE__ */ new Map();
70
+ const lengths = /* @__PURE__ */ new Map();
71
+ for (const oid of oids) lengths.set(oid, minLength);
72
+ let remaining = new Set(oids);
73
+ while (remaining.size > 0) {
74
+ const groups = /* @__PURE__ */ new Map();
75
+ for (const oid of remaining) {
76
+ const len = lengths.get(oid);
77
+ const short = oid.slice(0, len);
78
+ const group = groups.get(short);
79
+ if (group != null) group.push(oid);
80
+ else groups.set(short, [oid]);
81
+ }
82
+ const nextRemaining = /* @__PURE__ */ new Set();
83
+ for (const [short, group] of groups) if (group.length === 1) result.set(group[0], short);
84
+ else for (const oid of group) {
85
+ const currentLen = lengths.get(oid);
86
+ if (currentLen >= oid.length) result.set(oid, oid);
87
+ else {
88
+ lengths.set(oid, currentLen + 1);
89
+ nextRemaining.add(oid);
90
+ }
91
+ }
92
+ remaining = nextRemaining;
93
+ }
94
+ return result;
95
+ }
56
96
  function createAsyncValueParser(options, metavar, parseFn, suggestFn) {
57
97
  ensureNonEmptyString(metavar);
98
+ if (options?.suggestionDepth !== void 0) {
99
+ if (!Number.isInteger(options.suggestionDepth) || options.suggestionDepth < 1) {
100
+ const depth = options.suggestionDepth;
101
+ const repr = typeof depth === "number" ? String(depth) : typeof depth === "string" ? JSON.stringify(depth) : `${typeof depth} ${String(depth)}`;
102
+ throw new RangeError(`Invalid suggestionDepth (must be a positive integer): ${repr}`);
103
+ }
104
+ }
105
+ const validatedDepth = options?.suggestionDepth ?? DEFAULT_SUGGESTION_DEPTH;
58
106
  return {
59
107
  $mode: "async",
60
108
  metavar,
@@ -67,10 +115,7 @@ function createAsyncValueParser(options, metavar, parseFn, suggestFn) {
67
115
  },
68
116
  async *suggest(prefix) {
69
117
  const dir = getRepoDir(options?.dir);
70
- if (suggestFn) {
71
- const depth = options?.suggestionDepth ?? DEFAULT_SUGGESTION_DEPTH;
72
- yield* suggestFn(dir, prefix, depth);
73
- }
118
+ if (suggestFn) yield* suggestFn(dir, prefix, validatedDepth);
74
119
  }
75
120
  };
76
121
  }
@@ -82,6 +127,7 @@ function createAsyncValueParser(options, metavar, parseFn, suggestFn) {
82
127
  *
83
128
  * @param options Configuration options for the parser.
84
129
  * @returns A value parser that accepts existing branch names.
130
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
85
131
  * @since 0.9.0
86
132
  *
87
133
  * @example
@@ -147,6 +193,7 @@ function gitBranch(options) {
147
193
  * @param remote The remote name to validate against.
148
194
  * @param options Configuration options for the parser.
149
195
  * @returns A value parser that accepts existing remote branch names.
196
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
150
197
  * @since 0.9.0
151
198
  *
152
199
  * @example
@@ -170,6 +217,27 @@ function gitRemoteBranch(remote, options) {
170
217
  success: true,
171
218
  value: input
172
219
  };
220
+ if (branches.length === 0) {
221
+ const remotes = await git.listRemotes({
222
+ fs: gitFs,
223
+ dir
224
+ });
225
+ const names = remotes.map((r) => r.remote);
226
+ if (!names.includes(remote)) {
227
+ if (errors?.remoteNotFound) return {
228
+ success: false,
229
+ error: errors.remoteNotFound(remote, names)
230
+ };
231
+ if (errors?.notFound) return {
232
+ success: false,
233
+ error: errors.notFound(input, branches)
234
+ };
235
+ return {
236
+ success: false,
237
+ error: message`Remote ${value(remote)} does not exist. Available remotes: ${valueSet(names)}`
238
+ };
239
+ }
240
+ }
173
241
  if (errors?.notFound) return {
174
242
  success: false,
175
243
  error: errors.notFound(input, branches)
@@ -211,6 +279,7 @@ function gitRemoteBranch(remote, options) {
211
279
  *
212
280
  * @param options Configuration options for the parser.
213
281
  * @returns A value parser that accepts existing tag names.
282
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
214
283
  * @since 0.9.0
215
284
  */
216
285
  function gitTag(options) {
@@ -264,6 +333,7 @@ function gitTag(options) {
264
333
  *
265
334
  * @param options Configuration options for the parser.
266
335
  * @returns A value parser that accepts existing remote names.
336
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
267
337
  * @since 0.9.0
268
338
  */
269
339
  function gitRemote(options) {
@@ -321,6 +391,7 @@ function gitRemote(options) {
321
391
  *
322
392
  * @param options Configuration options for the parser.
323
393
  * @returns A value parser that accepts existing commit SHAs.
394
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
324
395
  * @since 0.9.0
325
396
  */
326
397
  function gitCommit(options) {
@@ -347,7 +418,7 @@ function gitCommit(options) {
347
418
  value: oid
348
419
  };
349
420
  } catch (e) {
350
- if (hasErrorCode(e, "AmbiguousShortOidError")) return {
421
+ if (hasErrorCode(e, "AmbiguousShortOidError") || hasErrorCode(e, "AmbiguousError")) return {
351
422
  success: false,
352
423
  error: message`Commit SHA ${value(input)} is ambiguous. Provide more characters to disambiguate.`
353
424
  };
@@ -367,8 +438,11 @@ function gitCommit(options) {
367
438
  dir,
368
439
  depth
369
440
  });
370
- for (const commit of commits) if (commit.oid.startsWith(prefix)) {
371
- const shortOid = commit.oid.slice(0, 7);
441
+ const matching = commits.filter((c) => c.oid.startsWith(prefix));
442
+ const minLen = Math.max(7, prefix.length);
443
+ const shortOids = uniqueShortOids(matching.map((c) => c.oid), minLen);
444
+ for (const commit of matching) {
445
+ const shortOid = shortOids.get(commit.oid);
372
446
  const firstLine = commit.commit.message.split("\n")[0];
373
447
  yield {
374
448
  kind: "literal",
@@ -393,6 +467,7 @@ function gitCommit(options) {
393
467
  *
394
468
  * @param options Configuration options for the parser.
395
469
  * @returns A value parser that accepts any git reference.
470
+ * @throws {RangeError} If `suggestionDepth` is not a positive integer.
396
471
  * @since 0.9.0
397
472
  */
398
473
  function gitRef(options) {
@@ -421,7 +496,7 @@ function gitRef(options) {
421
496
  value: oid
422
497
  };
423
498
  } catch (e) {
424
- if (hasErrorCode(e, "AmbiguousShortOidError")) return {
499
+ if (hasErrorCode(e, "AmbiguousShortOidError") || hasErrorCode(e, "AmbiguousError")) return {
425
500
  success: false,
426
501
  error: message`Reference ${value(input)} is ambiguous. Provide more characters to disambiguate.`
427
502
  };
@@ -451,16 +526,28 @@ function gitRef(options) {
451
526
  depth
452
527
  })
453
528
  ]);
454
- for (const branch of branches) if (branch.startsWith(prefix)) yield {
455
- kind: "literal",
456
- text: branch
457
- };
458
- for (const tag of tags) if (tag.startsWith(prefix)) yield {
459
- kind: "literal",
460
- text: tag
461
- };
462
- for (const commit of commits) if (commit.oid.startsWith(prefix)) {
463
- const shortOid = commit.oid.slice(0, 7);
529
+ const seen = /* @__PURE__ */ new Set();
530
+ for (const branch of branches) if (branch.startsWith(prefix) && !seen.has(branch)) {
531
+ seen.add(branch);
532
+ yield {
533
+ kind: "literal",
534
+ text: branch
535
+ };
536
+ }
537
+ for (const tag of tags) if (tag.startsWith(prefix) && !seen.has(tag)) {
538
+ seen.add(tag);
539
+ yield {
540
+ kind: "literal",
541
+ text: tag
542
+ };
543
+ }
544
+ const matching = commits.filter((c) => c.oid.startsWith(prefix));
545
+ const minLen = Math.max(7, prefix.length);
546
+ const shortOids = uniqueShortOids(matching.map((c) => c.oid), minLen);
547
+ for (const commit of matching) {
548
+ const shortOid = shortOids.get(commit.oid);
549
+ if (seen.has(shortOid)) continue;
550
+ seen.add(shortOid);
464
551
  const firstLine = commit.commit.message.split("\n")[0];
465
552
  yield {
466
553
  kind: "literal",
@@ -481,7 +568,9 @@ function gitRef(options) {
481
568
  * Creates a set of git parsers with shared configuration.
482
569
  *
483
570
  * @param options Shared configuration for the parsers.
484
- * @returns An object containing git parsers.
571
+ * @returns An object containing git parsers. Each returned method may throw
572
+ * a {@link RangeError} if the merged `suggestionDepth` is not a positive
573
+ * integer.
485
574
  * @since 0.9.0
486
575
  */
487
576
  function createGitParsers(options) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optique/git",
3
- "version": "0.10.7",
3
+ "version": "1.0.0-dev.1109+fa132665",
4
4
  "description": "Git value parsers for Optique",
5
5
  "keywords": [
6
6
  "CLI",
@@ -59,7 +59,7 @@
59
59
  "dependencies": {
60
60
  "@logtape/logtape": "^1.2.2",
61
61
  "isomorphic-git": "^1.36.1",
62
- "@optique/core": "0.10.7"
62
+ "@optique/core": "1.0.0-dev.1109+fa132665"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@types/node": "^20.19.9",