@shipwithai/loopnote 0.0.0-stage → 2.0.0-bootstrap.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.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +202 -2
  3. package/THIRD_PARTY_NOTICES.md +580 -0
  4. package/dist/bin/loopnote.mjs +5 -0
  5. package/dist/cli/confirm.js +38 -0
  6. package/dist/cli/picker.js +342 -0
  7. package/dist/cli/request.js +120 -0
  8. package/dist/cli/run.js +970 -0
  9. package/dist/contracts/api.js +640 -0
  10. package/dist/contracts/comparison.js +178 -0
  11. package/dist/contracts/draft.js +606 -0
  12. package/dist/contracts/request.js +124 -0
  13. package/dist/domain/anchor.js +122 -0
  14. package/dist/domain/comparison-key.js +51 -0
  15. package/dist/domain/errors.js +43 -0
  16. package/dist/domain/path-bytes.js +82 -0
  17. package/dist/domain/product-identity.js +66 -0
  18. package/dist/domain/source.js +4 -0
  19. package/dist/draft/mutate-draft.js +87 -0
  20. package/dist/export/render-review-markdown.js +163 -0
  21. package/dist/export/review-export.js +273 -0
  22. package/dist/git/availability.js +92 -0
  23. package/dist/git/candidates.js +413 -0
  24. package/dist/git/comparison.js +263 -0
  25. package/dist/git/exact-patch.js +702 -0
  26. package/dist/git/ignore-status.js +23 -0
  27. package/dist/git/inventory.js +160 -0
  28. package/dist/git/numstat.js +111 -0
  29. package/dist/git/objects.js +95 -0
  30. package/dist/git/raw-diff.js +84 -0
  31. package/dist/git/repository.js +144 -0
  32. package/dist/git/runner.js +145 -0
  33. package/dist/git/selector-drift.js +169 -0
  34. package/dist/git/standalone-resolution.js +174 -0
  35. package/dist/git/worktree-content.js +97 -0
  36. package/dist/git/worktree-inventory.js +391 -0
  37. package/dist/native/directory_exchange.node +0 -0
  38. package/dist/server/app.js +171 -0
  39. package/dist/server/attached-completion.js +85 -0
  40. package/dist/server/capabilities.js +1572 -0
  41. package/dist/server/draft-loader.js +134 -0
  42. package/dist/server/draft-recovery.js +5 -0
  43. package/dist/server/draft-store.js +380 -0
  44. package/dist/server/export-store.js +291 -0
  45. package/dist/server/gitignore-capability.js +197 -0
  46. package/dist/server/image-content.js +101 -0
  47. package/dist/server/lifecycle.js +72 -0
  48. package/dist/server/native-exchange-capability.js +45 -0
  49. package/dist/server/patch-snapshot.js +500 -0
  50. package/dist/server/routes.js +418 -0
  51. package/dist/server/security.js +123 -0
  52. package/dist/server/snapshot-fs.js +58 -0
  53. package/dist/server/support-client.js +76 -0
  54. package/dist/server/support-store.js +194 -0
  55. package/dist/server/worktree-snapshot.js +553 -0
  56. package/dist/web/assets/abap-08VXUWAP.js +1 -0
  57. package/dist/web/assets/apex-BWPQTe0t.js +1 -0
  58. package/dist/web/assets/azcli-Bc_sGQ0U.js +1 -0
  59. package/dist/web/assets/bat-i0X4ZdIN.js +1 -0
  60. package/dist/web/assets/bicep-B5-_aFwp.js +2 -0
  61. package/dist/web/assets/cameligo-DMUM7wLl.js +1 -0
  62. package/dist/web/assets/clojure-Cm7r79vr.js +1 -0
  63. package/dist/web/assets/codicon-ngg6Pgfi.ttf +0 -0
  64. package/dist/web/assets/coffee-Ba7i2nA0.js +1 -0
  65. package/dist/web/assets/cpp-C7h46wYY.js +1 -0
  66. package/dist/web/assets/csharp-BKxtCVv1.js +1 -0
  67. package/dist/web/assets/csp-bTuwJoIa.js +1 -0
  68. package/dist/web/assets/css-DIMkf-bt.js +3 -0
  69. package/dist/web/assets/css.worker-yKcq13f2.js +89 -0
  70. package/dist/web/assets/cssMode-CpBTfhMg.js +1 -0
  71. package/dist/web/assets/cypher-CVaqCwHa.js +1 -0
  72. package/dist/web/assets/dart-onAF5SnQ.js +1 -0
  73. package/dist/web/assets/dockerfile-DZFCIeNp.js +1 -0
  74. package/dist/web/assets/ecl-D05T4iGw.js +1 -0
  75. package/dist/web/assets/editor.worker-tytyn86m.js +26 -0
  76. package/dist/web/assets/elixir-6RTg0lbw.js +1 -0
  77. package/dist/web/assets/flow9-C5_-GSwl.js +1 -0
  78. package/dist/web/assets/freemarker2-skQ63wb7.js +3 -0
  79. package/dist/web/assets/fsharp-C8Ef5oNN.js +1 -0
  80. package/dist/web/assets/go-C-y9NEjX.js +1 -0
  81. package/dist/web/assets/graphql-fmXr3nnJ.js +1 -0
  82. package/dist/web/assets/handlebars-CqvPAZY8.js +1 -0
  83. package/dist/web/assets/hcl-CpzslTdj.js +1 -0
  84. package/dist/web/assets/html-BDAqstpo.js +1 -0
  85. package/dist/web/assets/html.worker-DSJ9zmd7.js +502 -0
  86. package/dist/web/assets/htmlMode-BRYA3pnT.js +1 -0
  87. package/dist/web/assets/index-CCeYBqPo.js +1111 -0
  88. package/dist/web/assets/index-CRj9ojWJ.css +1 -0
  89. package/dist/web/assets/ini-sBoK_t0W.js +1 -0
  90. package/dist/web/assets/java-BEtHBSE6.js +1 -0
  91. package/dist/web/assets/javascript-1Ywytw67.js +1 -0
  92. package/dist/web/assets/json.worker-Oa9UrkKE.js +58 -0
  93. package/dist/web/assets/jsonMode-AO1C_0kX.js +7 -0
  94. package/dist/web/assets/julia-Bri6UV-V.js +1 -0
  95. package/dist/web/assets/kotlin-BOotOW0E.js +1 -0
  96. package/dist/web/assets/less-B9JPFI3C.js +2 -0
  97. package/dist/web/assets/lexon-CfSJPG6W.js +1 -0
  98. package/dist/web/assets/liquid-CkkqyyGc.js +1 -0
  99. package/dist/web/assets/lspLanguageFeatures-CYOFEMZp.js +4 -0
  100. package/dist/web/assets/lua-CsQS60Ue.js +1 -0
  101. package/dist/web/assets/m3-D-oSqn_W.js +1 -0
  102. package/dist/web/assets/markdown-Cimd5fb3.js +1 -0
  103. package/dist/web/assets/mdx-Bd3Wlfqw.js +1 -0
  104. package/dist/web/assets/mips-CIPQ_RoX.js +1 -0
  105. package/dist/web/assets/msdax-DauUninz.js +1 -0
  106. package/dist/web/assets/mysql-SOo6toE5.js +1 -0
  107. package/dist/web/assets/objective-c-FvmIjYaQ.js +1 -0
  108. package/dist/web/assets/pascal-DrH0SRf2.js +1 -0
  109. package/dist/web/assets/pascaligo-D-ptJ9y-.js +1 -0
  110. package/dist/web/assets/perl-oz_6vUea.js +1 -0
  111. package/dist/web/assets/pgsql-DTj74zXo.js +1 -0
  112. package/dist/web/assets/php-nr791fC2.js +1 -0
  113. package/dist/web/assets/pla-CopQ2nXW.js +1 -0
  114. package/dist/web/assets/postiats-43DmfD33.js +1 -0
  115. package/dist/web/assets/powerquery-D3hlyOfw.js +1 -0
  116. package/dist/web/assets/powershell-DmHpPYUd.js +1 -0
  117. package/dist/web/assets/protobuf-C531GsRP.js +2 -0
  118. package/dist/web/assets/pug-Z5eAx3Zn.js +1 -0
  119. package/dist/web/assets/python-CET4IumD.js +1 -0
  120. package/dist/web/assets/qsharp-DkqhCAOL.js +1 -0
  121. package/dist/web/assets/r-BwWrilGY.js +1 -0
  122. package/dist/web/assets/razor-d-reaHeT.js +1 -0
  123. package/dist/web/assets/redis-ClamHrr6.js +1 -0
  124. package/dist/web/assets/redshift-DT7zqm-g.js +1 -0
  125. package/dist/web/assets/restructuredtext-BYgofb2h.js +1 -0
  126. package/dist/web/assets/ruby-DezsRK8O.js +1 -0
  127. package/dist/web/assets/rust-DdL9SqIa.js +1 -0
  128. package/dist/web/assets/sb-CcwsVR0C.js +1 -0
  129. package/dist/web/assets/scala-DHpiXF5c.js +1 -0
  130. package/dist/web/assets/scheme-BeGwcela.js +1 -0
  131. package/dist/web/assets/scss-gp-XZpBa.js +3 -0
  132. package/dist/web/assets/shell-CC2rA5mh.js +1 -0
  133. package/dist/web/assets/solidity-BEEn4gHE.js +1 -0
  134. package/dist/web/assets/sophia-CRfGWb83.js +1 -0
  135. package/dist/web/assets/sparql-D_Lu-MrJ.js +1 -0
  136. package/dist/web/assets/sql-NEE52Syq.js +1 -0
  137. package/dist/web/assets/st-DbInun42.js +1 -0
  138. package/dist/web/assets/swift-Bxkupp3x.js +1 -0
  139. package/dist/web/assets/systemverilog-Bz4Y3fRF.js +1 -0
  140. package/dist/web/assets/tcl-DISqw1ZD.js +1 -0
  141. package/dist/web/assets/ts.worker-BtjBFNHX.js +67734 -0
  142. package/dist/web/assets/tsMode-DLxSAtkD.js +11 -0
  143. package/dist/web/assets/twig-De2hgUGE.js +1 -0
  144. package/dist/web/assets/typescript-DprEVA-S.js +1 -0
  145. package/dist/web/assets/typespec-B8J7ngcE.js +1 -0
  146. package/dist/web/assets/vb-DV3o63ZY.js +1 -0
  147. package/dist/web/assets/wgsl-DpFanUEy.js +298 -0
  148. package/dist/web/assets/xml-B7ZwKOhJ.js +1 -0
  149. package/dist/web/assets/yaml-OQ9Asg7B.js +1 -0
  150. package/dist/web/index.html +14 -0
  151. package/package.json +87 -4
@@ -0,0 +1,413 @@
1
+ import { isUtf8 } from 'node:buffer';
2
+ import { isAbsolute } from 'node:path';
3
+ import { GitObjectIdSchema } from '../contracts/comparison.js';
4
+ import { UNAVAILABLE_WORKTREE_REASON, } from '../domain/source.js';
5
+ import { orderByteSequences } from '../domain/path-bytes.js';
6
+ import { discoverGitRepository } from './repository.js';
7
+ import { createGitRunner, GitRunnerError, } from './runner.js';
8
+ const BRANCH_FORMAT = [
9
+ '%(refname)',
10
+ '%(refname:short)',
11
+ '%(objectname)',
12
+ '',
13
+ ].join('%00');
14
+ function stripGitLineTerminator(buffer) {
15
+ if (buffer.at(-1) !== 0x0a) {
16
+ throw new Error('Git line output ended without a line terminator');
17
+ }
18
+ return buffer.subarray(0, -1);
19
+ }
20
+ function splitNul(buffer) {
21
+ if (buffer.length !== 0 && buffer.at(-1) !== 0) {
22
+ throw new Error('Git NUL output ended without a record terminator');
23
+ }
24
+ const fields = [];
25
+ let start = 0;
26
+ for (let index = 0; index < buffer.length; index += 1) {
27
+ if (buffer[index] === 0) {
28
+ fields.push(buffer.subarray(start, index));
29
+ start = index + 1;
30
+ }
31
+ }
32
+ return fields;
33
+ }
34
+ function splitNulLineTerminated(buffer) {
35
+ if (buffer.length === 0) {
36
+ return [];
37
+ }
38
+ const payload = stripGitLineTerminator(buffer);
39
+ if (payload.length === 0) {
40
+ throw new Error('Git NUL output contained no record content');
41
+ }
42
+ return splitNul(payload);
43
+ }
44
+ function parseLocalBranchRef(field, source) {
45
+ if (!isUtf8(field)) {
46
+ throw new Error(`${source} contained invalid UTF-8 branch identity`);
47
+ }
48
+ const refName = field.toString('utf8');
49
+ if (refName.length === 0 ||
50
+ !refName.startsWith('refs/heads/') ||
51
+ refName.length === 'refs/heads/'.length) {
52
+ throw new Error(`${source} contained an invalid local branch identity`);
53
+ }
54
+ return refName;
55
+ }
56
+ function parseBranchRecords(buffer) {
57
+ const fields = splitNulLineTerminated(buffer);
58
+ if (fields.length % 3 !== 0) {
59
+ throw new Error('Git branch output ended with an incomplete record');
60
+ }
61
+ const records = [];
62
+ const seenRefNames = new Set();
63
+ for (let index = 0; index < fields.length; index += 3) {
64
+ let refField = fields[index];
65
+ if (index > 0) {
66
+ if (refField[0] !== 0x0a) {
67
+ throw new Error('Git branch output omitted a record separator');
68
+ }
69
+ refField = refField.subarray(1);
70
+ }
71
+ const labelField = fields[index + 1];
72
+ if (!isUtf8(labelField)) {
73
+ throw new Error('Git branch output contained invalid UTF-8');
74
+ }
75
+ const refName = parseLocalBranchRef(refField, 'Git branch output');
76
+ if (seenRefNames.has(refName)) {
77
+ throw new Error('Git branch output contained duplicate local branch identity');
78
+ }
79
+ seenRefNames.add(refName);
80
+ const label = labelField.toString('utf8');
81
+ const commitOid = GitObjectIdSchema.parse(fields[index + 2].toString('latin1'));
82
+ if (label.length === 0) {
83
+ throw new Error('Git branch output contained an invalid local branch record');
84
+ }
85
+ records.push({ refName, label, commitOid });
86
+ }
87
+ return records;
88
+ }
89
+ function escapeBranchPattern(term) {
90
+ return `*${term.replace(/[\\*?\[\]]/g, '\\$&')}*`;
91
+ }
92
+ function parseAbbreviationRecords(buffer) {
93
+ const fields = splitNulLineTerminated(buffer);
94
+ if (fields.length % 2 !== 0) {
95
+ throw new Error('Git abbreviation output ended with an incomplete record');
96
+ }
97
+ const shortByFull = new Map();
98
+ for (let index = 0; index < fields.length; index += 2) {
99
+ let fullField = fields[index];
100
+ if (index > 0) {
101
+ if (fullField[0] !== 0x0a) {
102
+ throw new Error('Git abbreviation output omitted a record separator');
103
+ }
104
+ fullField = fullField.subarray(1);
105
+ }
106
+ const fullOid = GitObjectIdSchema.parse(fullField.toString('latin1'));
107
+ const shortOid = fields[index + 1].toString('latin1');
108
+ if (!/^[0-9a-f]{12,}$/.test(shortOid) ||
109
+ shortOid.length > fullOid.length ||
110
+ !fullOid.startsWith(shortOid) ||
111
+ shortByFull.has(fullOid)) {
112
+ throw new Error('Git abbreviation output contained an invalid record');
113
+ }
114
+ shortByFull.set(fullOid, shortOid);
115
+ }
116
+ return shortByFull;
117
+ }
118
+ function parseWorktreeRecords(buffer) {
119
+ const records = [];
120
+ let current = {
121
+ detached: false,
122
+ prunable: false,
123
+ bare: false,
124
+ };
125
+ let open = false;
126
+ const seen = new Set();
127
+ const seenPaths = new Set();
128
+ for (const field of splitNul(buffer)) {
129
+ if (field.length === 0) {
130
+ if (!open || current.path === undefined) {
131
+ throw new Error('Git worktree output contained an incomplete record');
132
+ }
133
+ if ((current.detached && current.branchRef !== undefined) ||
134
+ (current.bare &&
135
+ (current.headOid !== undefined ||
136
+ current.branchRef !== undefined ||
137
+ current.detached))) {
138
+ throw new Error('Git worktree output contained a contradictory record');
139
+ }
140
+ if (seenPaths.has(current.path)) {
141
+ throw new Error('Git worktree output contained a duplicate worktree identity');
142
+ }
143
+ seenPaths.add(current.path);
144
+ records.push(current);
145
+ current = { detached: false, prunable: false, bare: false };
146
+ open = false;
147
+ seen.clear();
148
+ continue;
149
+ }
150
+ open = true;
151
+ const separator = field.indexOf(0x20);
152
+ const keyField = separator === -1 ? field : field.subarray(0, separator);
153
+ if (keyField.some((byte) => byte > 0x7f)) {
154
+ throw new Error('Git worktree output contained non-ASCII field name');
155
+ }
156
+ const key = keyField.toString('ascii');
157
+ const value = separator === -1 ? undefined : field.subarray(separator + 1);
158
+ if (seen.size === 0 && key !== 'worktree') {
159
+ throw new Error('Git worktree output omitted leading worktree field');
160
+ }
161
+ if (seen.has(key)) {
162
+ throw new Error('Git worktree output contained a duplicate field');
163
+ }
164
+ seen.add(key);
165
+ switch (key) {
166
+ case 'worktree': {
167
+ if (value === undefined || value.length === 0 || !isUtf8(value)) {
168
+ throw new Error('Git worktree output contained an invalid worktree path');
169
+ }
170
+ const path = value.toString('utf8');
171
+ if (!isAbsolute(path)) {
172
+ throw new Error('Git worktree output contained a non-absolute worktree path');
173
+ }
174
+ current.path = path;
175
+ break;
176
+ }
177
+ case 'HEAD':
178
+ if (value === undefined || value.length === 0) {
179
+ throw new Error('Git worktree output omitted a HEAD value');
180
+ }
181
+ current.headOid = GitObjectIdSchema.parse(value.toString('latin1'));
182
+ break;
183
+ case 'branch':
184
+ if (value === undefined) {
185
+ throw new Error('Git worktree output omitted a branch identity');
186
+ }
187
+ current.branchRef = parseLocalBranchRef(value, 'Git worktree output');
188
+ break;
189
+ case 'detached':
190
+ if (value !== undefined) {
191
+ throw new Error('Git worktree output contained an invalid detached field');
192
+ }
193
+ current.detached = true;
194
+ break;
195
+ case 'prunable':
196
+ current.prunable = true;
197
+ break;
198
+ case 'bare':
199
+ if (value !== undefined) {
200
+ throw new Error('Git worktree output contained an invalid bare field');
201
+ }
202
+ current.bare = true;
203
+ break;
204
+ case 'locked':
205
+ break;
206
+ default:
207
+ throw new Error('Git worktree output contained an unknown field');
208
+ }
209
+ }
210
+ if (open) {
211
+ throw new Error('Git worktree output ended before record separator');
212
+ }
213
+ if (records.length === 0) {
214
+ throw new Error('Git worktree output contained no worktree records');
215
+ }
216
+ return records;
217
+ }
218
+ export async function discoverSourceCandidates(options, dependencies = {}) {
219
+ const runner = dependencies.runner ?? createGitRunner();
220
+ const repository = await discoverGitRepository(options.cwd, runner, options.signal);
221
+ options.signal?.throwIfAborted();
222
+ const worktreeResult = await runner.run(['worktree', 'list', '--porcelain', '-z'], { cwd: repository.root, signal: options.signal });
223
+ options.signal?.throwIfAborted();
224
+ const worktreeRecords = parseWorktreeRecords(worktreeResult.stdout);
225
+ const shortOidByFullOid = new Map();
226
+ const abbreviate = async (oid) => {
227
+ const known = shortOidByFullOid.get(oid);
228
+ if (known !== undefined) {
229
+ return known;
230
+ }
231
+ const pending = (async () => {
232
+ const result = await runner.run(['rev-parse', '--short=12', oid], {
233
+ cwd: repository.root,
234
+ signal: options.signal,
235
+ });
236
+ options.signal?.throwIfAborted();
237
+ const shortOid = stripGitLineTerminator(result.stdout).toString('latin1');
238
+ if (!/^[0-9a-f]{12,}$/.test(shortOid) ||
239
+ shortOid.length > oid.length ||
240
+ !oid.startsWith(shortOid)) {
241
+ throw new Error('Git worktree abbreviation output contained an invalid OID');
242
+ }
243
+ return shortOid;
244
+ })();
245
+ shortOidByFullOid.set(oid, pending);
246
+ return pending;
247
+ };
248
+ let currentBranch;
249
+ const resolveWorktreeIdentity = async (record) => {
250
+ if (record.path === undefined) {
251
+ return undefined;
252
+ }
253
+ // A missing porcelain HEAD is Git's valid unborn-worktree representation.
254
+ let commitOid = record.headOid;
255
+ let availability = record.prunable || record.bare ? 'unavailable' : 'pending';
256
+ if (availability !== 'unavailable') {
257
+ try {
258
+ const headResult = await runner.run(['rev-parse', '--verify', '--end-of-options', 'HEAD^{commit}'], { cwd: record.path, signal: options.signal });
259
+ options.signal?.throwIfAborted();
260
+ commitOid = GitObjectIdSchema.parse(stripGitLineTerminator(headResult.stdout).toString('latin1'));
261
+ }
262
+ catch (error) {
263
+ if (options.signal?.aborted) {
264
+ throw error;
265
+ }
266
+ availability = 'unavailable';
267
+ }
268
+ }
269
+ const branchRef = record.branchRef;
270
+ const shortOid = commitOid === undefined ? undefined : await abbreviate(commitOid);
271
+ options.signal?.throwIfAborted();
272
+ const candidate = {
273
+ kind: 'worktree',
274
+ id: `worktree:${record.path}`,
275
+ label: branchRef === undefined
276
+ ? 'Detached HEAD'
277
+ : branchRef.replace(/^refs\/heads\//, ''),
278
+ path: record.path,
279
+ ...(branchRef === undefined ? {} : { branchRef }),
280
+ detached: record.detached || branchRef === undefined,
281
+ ...(commitOid === undefined || shortOid === undefined
282
+ ? {}
283
+ : { commitOid, shortOid }),
284
+ availability,
285
+ ...(availability === 'unavailable'
286
+ ? { unavailableReason: UNAVAILABLE_WORKTREE_REASON }
287
+ : {}),
288
+ isCurrentCheckout: record.path === repository.root,
289
+ };
290
+ if (record.path === repository.root &&
291
+ branchRef !== undefined &&
292
+ commitOid !== undefined &&
293
+ shortOid !== undefined) {
294
+ currentBranch = Object.freeze({
295
+ kind: 'branch',
296
+ id: `branch:${branchRef}`,
297
+ label: branchRef.replace(/^refs\/heads\//, ''),
298
+ refName: branchRef,
299
+ commitOid,
300
+ shortOid,
301
+ });
302
+ }
303
+ return Object.freeze(candidate);
304
+ };
305
+ const worktreeCandidates = (await Promise.all(worktreeRecords.map(resolveWorktreeIdentity))).filter((candidate) => candidate !== undefined);
306
+ options.signal?.throwIfAborted();
307
+ const initialCandidates = Object.freeze([
308
+ ...(currentBranch === undefined ? [] : [currentBranch]),
309
+ ...worktreeCandidates,
310
+ ]);
311
+ const enrichmentStart = Promise.withResolvers();
312
+ let enrichmentStarted = false;
313
+ const startCandidateEnrichment = () => {
314
+ if (!enrichmentStarted) {
315
+ enrichmentStarted = true;
316
+ setImmediate(enrichmentStart.resolve);
317
+ }
318
+ };
319
+ const candidateEnrichment = enrichmentStart.promise.then(async () => {
320
+ options.signal?.throwIfAborted();
321
+ const enrichedWorktrees = await Promise.all(worktreeCandidates.map(async (candidate) => {
322
+ if (candidate.availability === 'unavailable') {
323
+ return candidate;
324
+ }
325
+ try {
326
+ const statusResult = await runner.run(['status', '--porcelain=v1', '-z', '--untracked-files=normal'], { cwd: candidate.path, signal: options.signal });
327
+ options.signal?.throwIfAborted();
328
+ return Object.freeze({
329
+ ...candidate,
330
+ availability: statusResult.stdout.length === 0
331
+ ? 'clean'
332
+ : 'dirty',
333
+ });
334
+ }
335
+ catch (error) {
336
+ if (options.signal?.aborted) {
337
+ throw error;
338
+ }
339
+ return Object.freeze({
340
+ ...candidate,
341
+ availability: 'unavailable',
342
+ unavailableReason: UNAVAILABLE_WORKTREE_REASON,
343
+ });
344
+ }
345
+ }));
346
+ return Object.freeze([
347
+ ...(currentBranch === undefined ? [] : [currentBranch]),
348
+ ...enrichedWorktrees,
349
+ ]);
350
+ });
351
+ void candidateEnrichment.catch(() => undefined);
352
+ return Object.freeze({
353
+ startCandidateEnrichment,
354
+ initialCandidates,
355
+ candidateEnrichment,
356
+ async searchBranches(term, signal) {
357
+ if (signal?.aborted) {
358
+ throw new GitRunnerError('aborted', 'Git command was cancelled', {
359
+ cause: signal.reason,
360
+ });
361
+ }
362
+ if (term.length === 0) {
363
+ return Object.freeze([]);
364
+ }
365
+ const branchResult = await runner.run([
366
+ 'branch',
367
+ '--list',
368
+ '--ignore-case',
369
+ '--no-color',
370
+ '--sort=refname',
371
+ `--format=${BRANCH_FORMAT}`,
372
+ '--',
373
+ escapeBranchPattern(term),
374
+ ], { cwd: repository.root, signal });
375
+ signal?.throwIfAborted();
376
+ const query = term.toLowerCase();
377
+ const records = parseBranchRecords(branchResult.stdout)
378
+ .filter((record) => record.label.toLowerCase().includes(query))
379
+ .sort((left, right) => orderByteSequences(Buffer.from(left.refName, 'utf8'), Buffer.from(right.refName, 'utf8')));
380
+ signal?.throwIfAborted();
381
+ if (records.length === 0) {
382
+ return Object.freeze([]);
383
+ }
384
+ const requestedOids = [...new Set(records.map((record) => record.commitOid))];
385
+ const abbreviationResult = await runner.run([
386
+ 'log',
387
+ '--no-walk=unsorted',
388
+ '--abbrev=12',
389
+ '--format=%H%x00%h%x00',
390
+ '--stdin',
391
+ ], {
392
+ cwd: repository.root,
393
+ input: Buffer.from(`${requestedOids.join('\n')}\n`, 'ascii'),
394
+ signal,
395
+ });
396
+ signal?.throwIfAborted();
397
+ const shortByFull = parseAbbreviationRecords(abbreviationResult.stdout);
398
+ if (shortByFull.size !== requestedOids.length ||
399
+ requestedOids.some((oid) => !shortByFull.has(oid))) {
400
+ throw new Error('Git abbreviation output did not match requested objects');
401
+ }
402
+ signal?.throwIfAborted();
403
+ return Object.freeze(records.map((record) => Object.freeze({
404
+ kind: 'branch',
405
+ id: `branch:${record.refName}`,
406
+ label: record.label,
407
+ refName: record.refName,
408
+ commitOid: record.commitOid,
409
+ shortOid: shortByFull.get(record.commitOid),
410
+ })));
411
+ },
412
+ });
413
+ }
@@ -0,0 +1,263 @@
1
+ import { ComparisonSelectionSchema, GitObjectIdSchema, PinnedComparisonSchema, } from '../contracts/comparison.js';
2
+ import { BASE_RECOVERY, FATAL_LAUNCH_MESSAGES, HEAD_RECOVERY, LaunchError, } from '../domain/errors.js';
3
+ import { rangeReviewKey } from '../domain/comparison-key.js';
4
+ import { discoverGitRepository } from './repository.js';
5
+ import { createGitRunner, GitRunnerError, } from './runner.js';
6
+ import { createChangedFileInventory } from './inventory.js';
7
+ function parseSingleLine(buffer, fact) {
8
+ const lines = buffer
9
+ .toString('ascii')
10
+ .split('\n')
11
+ .map((line) => line.trim())
12
+ .filter((line) => line.length > 0);
13
+ if (lines.length !== 1) {
14
+ throw new Error(`Expected exactly one ${fact}; Git returned ${lines.length}`);
15
+ }
16
+ return lines[0];
17
+ }
18
+ async function resolveObjectFormat(repositoryRoot, runner, signal) {
19
+ const result = await runner.run(['rev-parse', '--show-object-format=storage'], { cwd: repositoryRoot, signal });
20
+ const objectFormat = parseSingleLine(result.stdout, 'object format');
21
+ if (objectFormat !== 'sha1' && objectFormat !== 'sha256') {
22
+ throw new LaunchError('git-unsupported', FATAL_LAUNCH_MESSAGES.gitUnsupported, { recovery: { kind: 'exit' } });
23
+ }
24
+ return objectFormat;
25
+ }
26
+ function recoveryFor(role) {
27
+ return role === 'base' ? BASE_RECOVERY : HEAD_RECOVERY;
28
+ }
29
+ function endpointMessage(selection, role) {
30
+ return selection.source?.kind === 'worktree'
31
+ ? `The selected ${role} worktree cannot resolve a committed HEAD. Choose another ${role} or repair the worktree with Git.`
32
+ : `The selected ${role} “${selection.label}” no longer resolves to a commit. Choose another ${role} or repair the ref with Git.`;
33
+ }
34
+ async function resolveCommit(selection, role, repositoryRoot, runner, signal) {
35
+ try {
36
+ const result = await runner.run([
37
+ 'rev-parse',
38
+ '--verify',
39
+ '--end-of-options',
40
+ `${selection.revision}^{commit}`,
41
+ ], { cwd: repositoryRoot, signal });
42
+ return GitObjectIdSchema.parse(parseSingleLine(result.stdout, `${role} commit object`));
43
+ }
44
+ catch (error) {
45
+ if (signal?.aborted) {
46
+ throw error;
47
+ }
48
+ throw new LaunchError('endpoint-unavailable', endpointMessage(selection, role), { cause: error, recovery: recoveryFor(role) });
49
+ }
50
+ }
51
+ async function verifyCommit(oid, role, repositoryRoot, runner, signal) {
52
+ try {
53
+ await runner.run(['cat-file', '-e', `${oid}^{commit}`], {
54
+ cwd: repositoryRoot,
55
+ signal,
56
+ });
57
+ }
58
+ catch (error) {
59
+ if (signal?.aborted) {
60
+ throw error;
61
+ }
62
+ throw new LaunchError('object-unavailable', `Required Git object ${oid.slice(0, 12)} is missing or unreadable. Repair the repository's object data with Git, then retry.`, {
63
+ cause: error,
64
+ recovery: role === 'base' ? BASE_RECOVERY : HEAD_RECOVERY,
65
+ });
66
+ }
67
+ }
68
+ function parseMergeBases(buffer, objectFormat) {
69
+ const expectedLength = objectFormat === 'sha1' ? 40 : 64;
70
+ const lines = buffer.length === 0
71
+ ? []
72
+ : buffer
73
+ .toString('ascii')
74
+ .split('\n')
75
+ .map((line) => line.trim())
76
+ .filter((line) => line.length > 0);
77
+ for (const line of lines) {
78
+ if (line.length !== expectedLength ||
79
+ !/^[0-9a-f]+$/u.test(line) ||
80
+ !GitObjectIdSchema.safeParse(line).success) {
81
+ throw new LaunchError('git-unsupported', FATAL_LAUNCH_MESSAGES.gitUnsupported, { recovery: { kind: 'exit' } });
82
+ }
83
+ }
84
+ return lines;
85
+ }
86
+ function freezeComparison(comparison) {
87
+ if (comparison.base.source !== undefined) {
88
+ Object.freeze(comparison.base.source);
89
+ }
90
+ if (comparison.head.source !== undefined) {
91
+ Object.freeze(comparison.head.source);
92
+ }
93
+ if (comparison.range !== undefined) {
94
+ Object.freeze(comparison.range.pathspecs);
95
+ Object.freeze(comparison.range);
96
+ }
97
+ Object.freeze(comparison.base);
98
+ Object.freeze(comparison.head);
99
+ return Object.freeze(comparison);
100
+ }
101
+ async function resolveRangeCommit(revision, role, repositoryRoot, runner, signal) {
102
+ try {
103
+ const result = await runner.run(['rev-parse', '--verify', '--end-of-options', `${revision}^{commit}`], { cwd: repositoryRoot, signal });
104
+ return GitObjectIdSchema.parse(parseSingleLine(result.stdout, `${role} commit object`));
105
+ }
106
+ catch (error) {
107
+ if (signal?.aborted) {
108
+ throw error;
109
+ }
110
+ throw new LaunchError('endpoint-unavailable', `The requested ${role} revision does not resolve to a commit. Check the revision and try again.`, { cause: error, recovery: { kind: 'exit' } });
111
+ }
112
+ }
113
+ async function verifyRangeCommit(oid, repositoryRoot, runner, signal) {
114
+ try {
115
+ await runner.run(['cat-file', '-e', `${oid}^{commit}`], {
116
+ cwd: repositoryRoot,
117
+ signal,
118
+ });
119
+ }
120
+ catch (error) {
121
+ if (signal?.aborted) {
122
+ throw error;
123
+ }
124
+ throw new LaunchError('object-unavailable', `Required Git object ${oid.slice(0, 12)} is missing or unreadable. Repair the repository's object data with Git, then retry.`, { cause: error, recovery: { kind: 'exit' } });
125
+ }
126
+ }
127
+ export async function createPinnedComparison(options, dependencies = {}) {
128
+ if (options.cwd.length === 0) {
129
+ throw new LaunchError('not-worktree', FATAL_LAUNCH_MESSAGES.notWorktree, { recovery: { kind: 'exit' } });
130
+ }
131
+ const baseSelection = ComparisonSelectionSchema.parse(options.base);
132
+ const headSelection = ComparisonSelectionSchema.parse(options.head);
133
+ const runner = dependencies.runner ?? createGitRunner();
134
+ const repository = await discoverGitRepository(options.cwd, runner, options.signal);
135
+ const objectFormat = await resolveObjectFormat(repository.root, runner, options.signal);
136
+ const baseOid = await resolveCommit(baseSelection, 'base', repository.root, runner, options.signal);
137
+ const headOid = await resolveCommit(headSelection, 'head', repository.root, runner, options.signal);
138
+ if (baseOid === headOid) {
139
+ throw new LaunchError('equal-commits', 'Base and head resolve to the same commit. Choose a different head.', { recovery: HEAD_RECOVERY });
140
+ }
141
+ await verifyCommit(baseOid, 'base', repository.root, runner, options.signal);
142
+ await verifyCommit(headOid, 'head', repository.root, runner, options.signal);
143
+ let mergeBaseOutput = Buffer.alloc(0);
144
+ try {
145
+ const mergeBaseResult = await runner.run(['merge-base', '--all', baseOid, headOid], { cwd: repository.root, signal: options.signal });
146
+ mergeBaseOutput = mergeBaseResult.stdout;
147
+ }
148
+ catch (error) {
149
+ if (!(error instanceof GitRunnerError &&
150
+ error.kind === 'exit' &&
151
+ error.exitCode === 1 &&
152
+ error.stderr.length === 0)) {
153
+ throw error;
154
+ }
155
+ }
156
+ const mergeBases = parseMergeBases(mergeBaseOutput, objectFormat);
157
+ if (mergeBases.length === 0) {
158
+ throw new LaunchError('unrelated-histories', 'Base and head have unrelated histories; Git could not find a merge base. Choose a different head or go back to change base.', { recovery: HEAD_RECOVERY });
159
+ }
160
+ if (mergeBases.length > 1) {
161
+ throw new LaunchError('multiple-merge-bases', 'Base and head have multiple merge bases, so this comparison cannot be pinned unambiguously. Choose a different head or go back to change base.', { recovery: HEAD_RECOVERY });
162
+ }
163
+ const mergeBaseOid = mergeBases[0];
164
+ await verifyCommit(baseOid, 'base', repository.root, runner, options.signal);
165
+ await verifyCommit(headOid, 'head', repository.root, runner, options.signal);
166
+ await verifyCommit(mergeBaseOid, 'merge-base', repository.root, runner, options.signal);
167
+ const changedFiles = await createChangedFileInventory({
168
+ repositoryRoot: repository.root,
169
+ mergeBaseOid,
170
+ headOid,
171
+ objectFormat,
172
+ signal: options.signal,
173
+ }, { runner });
174
+ const comparison = PinnedComparisonSchema.parse({
175
+ repositoryRoot: repository.root,
176
+ objectFormat,
177
+ base: {
178
+ label: baseSelection.label,
179
+ oid: baseOid,
180
+ ...(baseSelection.source === undefined
181
+ ? {}
182
+ : { source: baseSelection.source }),
183
+ },
184
+ head: {
185
+ label: headSelection.label,
186
+ oid: headOid,
187
+ ...(headSelection.source === undefined
188
+ ? {}
189
+ : { source: headSelection.source }),
190
+ },
191
+ mergeBaseOid,
192
+ changedFiles,
193
+ hasCommittedChanges: changedFiles.length > 0,
194
+ });
195
+ return freezeComparison(comparison);
196
+ }
197
+ export async function createPinnedRangeComparison(options, dependencies = {}) {
198
+ if (options.cwd.length === 0) {
199
+ throw new LaunchError('not-worktree', FATAL_LAUNCH_MESSAGES.notWorktree, { recovery: { kind: 'exit' } });
200
+ }
201
+ const runner = dependencies.runner ?? createGitRunner();
202
+ const repository = await discoverGitRepository(options.cwd, runner, options.signal);
203
+ const objectFormat = await resolveObjectFormat(repository.root, runner, options.signal);
204
+ const baseOid = await resolveRangeCommit(options.baseRevision, 'base', repository.root, runner, options.signal);
205
+ const headOid = await resolveRangeCommit(options.headRevision, 'head', repository.root, runner, options.signal);
206
+ await verifyRangeCommit(baseOid, repository.root, runner, options.signal);
207
+ await verifyRangeCommit(headOid, repository.root, runner, options.signal);
208
+ try {
209
+ await runner.run(['merge-base', '--is-ancestor', baseOid, headOid], {
210
+ cwd: repository.root,
211
+ signal: options.signal,
212
+ });
213
+ }
214
+ catch (error) {
215
+ if (options.signal?.aborted) {
216
+ throw error;
217
+ }
218
+ if (error instanceof GitRunnerError &&
219
+ error.kind === 'exit' &&
220
+ error.exitCode === 1) {
221
+ throw new LaunchError('non-ancestor-range', 'The requested base is not an ancestor of the requested head. Choose a contiguous range and try again.', { cause: error, recovery: { kind: 'exit' } });
222
+ }
223
+ throw error;
224
+ }
225
+ const pathspecs = Object.freeze([...options.pathspecs]);
226
+ let changedFiles;
227
+ try {
228
+ changedFiles = await createChangedFileInventory({
229
+ repositoryRoot: repository.root,
230
+ mergeBaseOid: baseOid,
231
+ headOid,
232
+ objectFormat,
233
+ pathspecs,
234
+ signal: options.signal,
235
+ }, { runner });
236
+ }
237
+ catch (error) {
238
+ if (pathspecs.length > 0 &&
239
+ error instanceof GitRunnerError &&
240
+ error.kind === 'exit') {
241
+ throw new LaunchError('invalid-pathspec', 'Git rejected the requested pathspec scope. Check native Git pathspec syntax and try again.', { cause: error, recovery: { kind: 'exit' } });
242
+ }
243
+ throw error;
244
+ }
245
+ return freezeComparison(PinnedComparisonSchema.parse({
246
+ repositoryRoot: repository.root,
247
+ objectFormat,
248
+ base: { label: options.baseRevision, oid: baseOid },
249
+ head: { label: options.headRevision, oid: headOid },
250
+ mergeBaseOid: baseOid,
251
+ changedFiles,
252
+ hasCommittedChanges: changedFiles.length > 0,
253
+ range: {
254
+ kind: 'revisions',
255
+ requestedBase: options.baseRevision,
256
+ requestedHead: options.headRevision,
257
+ baseOid,
258
+ headOid,
259
+ pathspecs,
260
+ reviewKey: rangeReviewKey(baseOid, headOid, pathspecs),
261
+ },
262
+ }));
263
+ }