@shipwithai/loopnote 0.0.0-stage → 2.0.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,23 @@
1
+ import { createGitRunner, GitRunnerError } from './runner.js';
2
+ import { RENAME_MAP } from '../domain/product-identity.js';
3
+ const loopnoteIgnoreProbe = `${RENAME_MAP.repoStateDir}.${RENAME_MAP.slug}-ignore-probe`;
4
+ const loopnoteIgnoreArguments = [
5
+ 'check-ignore',
6
+ '--no-index',
7
+ '--quiet',
8
+ '--',
9
+ loopnoteIgnoreProbe,
10
+ ];
11
+ export async function inspectLoopnoteIgnore(options, dependencies = {}) {
12
+ const runner = dependencies.runner ?? createGitRunner();
13
+ try {
14
+ await runner.run(loopnoteIgnoreArguments, { cwd: options.repositoryRoot });
15
+ return Object.freeze({ kind: 'ignored' });
16
+ }
17
+ catch (error) {
18
+ if (error instanceof GitRunnerError && error.kind === 'exit' && error.exitCode === 1) {
19
+ return Object.freeze({ kind: 'not-ignored' });
20
+ }
21
+ return Object.freeze({ kind: 'unavailable' });
22
+ }
23
+ }
@@ -0,0 +1,160 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { ChangedFileSchema } from '../contracts/comparison.js';
3
+ import { decodeBase64url } from '../domain/path-bytes.js';
4
+ import { RENAME_MAP } from '../domain/product-identity.js';
5
+ import { classifyAvailability } from './availability.js';
6
+ import { createObjectReader } from './objects.js';
7
+ import { joinDiffStats, parseNumstat } from './numstat.js';
8
+ import { parseRawDiff } from './raw-diff.js';
9
+ import { createGitRunner } from './runner.js';
10
+ const emptyPathspecs = Object.freeze([]);
11
+ const processFileIdNamespace = randomBytes(32);
12
+ const knownModes = Object.freeze({
13
+ '000000': true,
14
+ '040000': true,
15
+ '100644': true,
16
+ '100755': true,
17
+ '120000': true,
18
+ '160000': true,
19
+ });
20
+ const statusKindByCode = Object.freeze({
21
+ A: 'added',
22
+ C: 'copied',
23
+ D: 'deleted',
24
+ M: 'modified',
25
+ R: 'renamed',
26
+ T: 'type-changed',
27
+ });
28
+ const sharedDiffOptions = [
29
+ '-z',
30
+ '--find-renames=50%',
31
+ '--find-copies=50%',
32
+ '--find-copies-harder',
33
+ '--no-ext-diff',
34
+ '--no-textconv',
35
+ ];
36
+ const cumpaPath = Buffer.from('.cumpa', 'ascii');
37
+ const cumpaPathPrefix = Buffer.from('.cumpa/', 'ascii');
38
+ const newStateDirPath = Buffer.from(RENAME_MAP.repoStateDir.replace(/\/$/, ''), 'ascii');
39
+ const newStateDirPathPrefix = Buffer.from(RENAME_MAP.repoStateDir, 'ascii');
40
+ export function isLoopnoteInternalPath(path) {
41
+ if (path === undefined) {
42
+ return false;
43
+ }
44
+ const bytes = decodeBase64url(path.bytesBase64url);
45
+ if (bytes.byteLength === cumpaPath.byteLength) {
46
+ if (bytes.every((byte, index) => byte === cumpaPath[index]))
47
+ return true;
48
+ }
49
+ else if (bytes.byteLength >= cumpaPathPrefix.byteLength &&
50
+ cumpaPathPrefix.every((byte, index) => byte === bytes[index])) {
51
+ return true;
52
+ }
53
+ if (bytes.byteLength === newStateDirPath.byteLength) {
54
+ return bytes.every((byte, index) => byte === newStateDirPath[index]);
55
+ }
56
+ return (bytes.byteLength >= newStateDirPathPrefix.byteLength &&
57
+ newStateDirPathPrefix.every((byte, index) => byte === bytes[index]));
58
+ }
59
+ export function recordIdentity(record) {
60
+ return JSON.stringify([
61
+ record.status,
62
+ record.similarity,
63
+ record.oldMode,
64
+ record.newMode,
65
+ record.oldBlobOid,
66
+ record.newBlobOid,
67
+ ...record.paths.map((path) => path.bytesBase64url),
68
+ ]);
69
+ }
70
+ export function opaqueFileId(namespace, record) {
71
+ const digest = createHash('sha256')
72
+ .update(namespace)
73
+ .update(Buffer.from([0]))
74
+ .update(recordIdentity(record), 'ascii')
75
+ .digest('base64url');
76
+ return `file_${digest}`;
77
+ }
78
+ export function validateObjectFormat(record, objectFormat) {
79
+ const expectedLength = objectFormat === 'sha1' ? 40 : 64;
80
+ if (record.oldBlobOid.length !== expectedLength ||
81
+ record.newBlobOid.length !== expectedLength) {
82
+ throw new Error(`Raw diff object ID length does not match repository ${objectFormat} format`);
83
+ }
84
+ }
85
+ export function inventoryPaths(record) {
86
+ if (record.paths.length === 2) {
87
+ return { oldPath: record.paths[0], newPath: record.paths[1] };
88
+ }
89
+ const path = record.paths[0];
90
+ return {
91
+ ...(record.oldMode === '000000' ? {} : { oldPath: path }),
92
+ ...(record.newMode === '000000' ? {} : { newPath: path }),
93
+ };
94
+ }
95
+ export async function createChangedFileInventory(options, dependencies = {}) {
96
+ const pathspecTail = Object.freeze([
97
+ '--',
98
+ ...(options.pathspecs ?? emptyPathspecs),
99
+ ]);
100
+ const runner = dependencies.runner ?? createGitRunner();
101
+ const [rawResult, numstatResult] = await Promise.all([
102
+ runner.run([
103
+ 'diff',
104
+ '--raw',
105
+ sharedDiffOptions[0],
106
+ '--no-abbrev',
107
+ ...sharedDiffOptions.slice(1),
108
+ options.mergeBaseOid,
109
+ options.headOid,
110
+ ...pathspecTail,
111
+ ], { cwd: options.repositoryRoot, signal: options.signal }),
112
+ runner.run([
113
+ 'diff',
114
+ '--numstat',
115
+ ...sharedDiffOptions,
116
+ options.mergeBaseOid,
117
+ options.headOid,
118
+ ...pathspecTail,
119
+ ], { cwd: options.repositoryRoot, signal: options.signal }),
120
+ ]);
121
+ const joined = joinDiffStats(parseRawDiff(rawResult.stdout), parseNumstat(numstatResult.stdout));
122
+ const reviewable = joined.filter(({ diff }) => {
123
+ const { oldPath, newPath } = inventoryPaths(diff);
124
+ return !isLoopnoteInternalPath(oldPath) && !isLoopnoteInternalPath(newPath);
125
+ });
126
+ const namespace = options.fileIdNamespace ?? processFileIdNamespace;
127
+ if (namespace.byteLength === 0) {
128
+ throw new RangeError('File ID namespace must not be empty');
129
+ }
130
+ const objectReader = dependencies.objectReader ??
131
+ createObjectReader(options.repositoryRoot, { runner });
132
+ const files = await Promise.all(reviewable.map(async ({ diff, stats }) => {
133
+ validateObjectFormat(diff, options.objectFormat);
134
+ const knownStatus = statusKindByCode[diff.status];
135
+ const hasUnsupportedMetadata = knownStatus === undefined ||
136
+ knownModes[diff.oldMode] !== true ||
137
+ knownModes[diff.newMode] !== true;
138
+ const statusKind = hasUnsupportedMetadata
139
+ ? 'unsupported'
140
+ : knownStatus;
141
+ const file = {
142
+ id: opaqueFileId(namespace, diff),
143
+ status: {
144
+ code: diff.status,
145
+ kind: statusKind,
146
+ similarity: diff.similarity,
147
+ },
148
+ oldMode: diff.oldMode,
149
+ newMode: diff.newMode,
150
+ oldBlobOid: diff.oldBlobOid,
151
+ newBlobOid: diff.newBlobOid,
152
+ ...inventoryPaths(diff),
153
+ additions: stats.additions,
154
+ deletions: stats.deletions,
155
+ };
156
+ const availability = await classifyAvailability(file, objectReader, options.signal);
157
+ return ChangedFileSchema.parse({ ...file, availability });
158
+ }));
159
+ return Object.freeze(files);
160
+ }
@@ -0,0 +1,111 @@
1
+ import { createExactPath } from '../domain/path-bytes.js';
2
+ function malformedNumstat(message) {
3
+ return new Error(`Malformed numstat: ${message}`);
4
+ }
5
+ function readNulField(output, offset) {
6
+ const end = output.indexOf(0, offset);
7
+ if (end === -1) {
8
+ throw malformedNumstat('record is not NUL terminated');
9
+ }
10
+ return {
11
+ field: output.subarray(offset, end),
12
+ nextOffset: end + 1,
13
+ };
14
+ }
15
+ function parseCount(value) {
16
+ if (value.length === 1 && value[0] === 0x2d) {
17
+ return null;
18
+ }
19
+ const text = value.toString('ascii');
20
+ if (!/^(?:0|[1-9][0-9]*)$/.test(text)) {
21
+ throw malformedNumstat('count is neither a decimal integer nor -');
22
+ }
23
+ const count = Number.parseInt(text, 10);
24
+ if (!Number.isSafeInteger(count)) {
25
+ throw malformedNumstat('count exceeds the safe integer range');
26
+ }
27
+ return count;
28
+ }
29
+ function pathIdentity(paths) {
30
+ return JSON.stringify(paths.map((path) => path.bytesBase64url));
31
+ }
32
+ export function parseNumstat(output) {
33
+ if (output.length === 0) {
34
+ return Object.freeze([]);
35
+ }
36
+ if (output[output.length - 1] !== 0) {
37
+ throw malformedNumstat('output is not NUL terminated');
38
+ }
39
+ const records = [];
40
+ let offset = 0;
41
+ while (offset < output.length) {
42
+ const recordField = readNulField(output, offset);
43
+ offset = recordField.nextOffset;
44
+ const firstTab = recordField.field.indexOf(0x09);
45
+ const secondTab = firstTab === -1 ? -1 : recordField.field.indexOf(0x09, firstTab + 1);
46
+ if (firstTab <= 0 || secondTab <= firstTab + 1) {
47
+ throw malformedNumstat('record is missing count separators');
48
+ }
49
+ const additions = parseCount(recordField.field.subarray(0, firstTab));
50
+ const deletions = parseCount(recordField.field.subarray(firstTab + 1, secondTab));
51
+ if ((additions === null) !== (deletions === null)) {
52
+ throw malformedNumstat('binary counts must both be -');
53
+ }
54
+ const pathBytes = recordField.field.subarray(secondTab + 1);
55
+ let paths;
56
+ if (pathBytes.length > 0) {
57
+ paths = Object.freeze([createExactPath(pathBytes)]);
58
+ }
59
+ else {
60
+ if (offset >= output.length) {
61
+ throw malformedNumstat('rename/copy record is missing its source path');
62
+ }
63
+ const oldPathField = readNulField(output, offset);
64
+ offset = oldPathField.nextOffset;
65
+ if (offset >= output.length) {
66
+ throw malformedNumstat('rename/copy record is missing its destination path');
67
+ }
68
+ const newPathField = readNulField(output, offset);
69
+ offset = newPathField.nextOffset;
70
+ if (oldPathField.field.length === 0 || newPathField.field.length === 0) {
71
+ throw malformedNumstat('rename/copy path is empty');
72
+ }
73
+ paths = Object.freeze([
74
+ createExactPath(oldPathField.field),
75
+ createExactPath(newPathField.field),
76
+ ]);
77
+ }
78
+ records.push(Object.freeze({ additions, deletions, paths }));
79
+ }
80
+ return Object.freeze(records);
81
+ }
82
+ export function joinDiffStats(diffRecords, statRecords) {
83
+ const statByIdentity = new Map();
84
+ for (const stats of statRecords) {
85
+ const identity = pathIdentity(stats.paths);
86
+ if (statByIdentity.has(identity)) {
87
+ throw new Error(`Duplicate numstat identity: ${identity}`);
88
+ }
89
+ statByIdentity.set(identity, stats);
90
+ }
91
+ const seenDiffIdentities = new Set();
92
+ const joined = [];
93
+ for (const diff of diffRecords) {
94
+ const identity = pathIdentity(diff.paths);
95
+ if (seenDiffIdentities.has(identity)) {
96
+ throw new Error(`Duplicate raw diff identity: ${identity}`);
97
+ }
98
+ seenDiffIdentities.add(identity);
99
+ const stats = statByIdentity.get(identity);
100
+ if (stats === undefined) {
101
+ throw new Error(`Missing numstat record for raw diff identity: ${identity}`);
102
+ }
103
+ joined.push(Object.freeze({ diff, stats }));
104
+ }
105
+ for (const identity of statByIdentity.keys()) {
106
+ if (!seenDiffIdentities.has(identity)) {
107
+ throw new Error(`Numstat record has no raw diff match: ${identity}`);
108
+ }
109
+ }
110
+ return Object.freeze(joined);
111
+ }
@@ -0,0 +1,95 @@
1
+ import { createGitRunner } from './runner.js';
2
+ const BATCH_HEADER_LIMIT = 256;
3
+ const objectIdPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
4
+ function validateObjectId(oid) {
5
+ if (!objectIdPattern.test(oid)) {
6
+ throw new TypeError('Git object reads require one full lowercase hexadecimal object ID');
7
+ }
8
+ }
9
+ function parseBatchHeader(stdout, requestedOid) {
10
+ const terminator = stdout.indexOf(0);
11
+ if (terminator < 0 || terminator >= BATCH_HEADER_LIMIT) {
12
+ throw new Error('Git cat-file returned an invalid or oversized batch header');
13
+ }
14
+ const header = stdout.toString('ascii', 0, terminator);
15
+ if (header === `${requestedOid} missing`) {
16
+ return Object.freeze({ kind: 'missing', contentOffset: terminator + 1 });
17
+ }
18
+ const fields = header.split(' ');
19
+ if (fields.length !== 3 ||
20
+ fields[0] !== requestedOid ||
21
+ fields[1] === undefined ||
22
+ fields[1].length === 0 ||
23
+ fields[2] === undefined ||
24
+ !/^(?:0|[1-9][0-9]*)$/.test(fields[2])) {
25
+ throw new Error('Git cat-file returned malformed object metadata');
26
+ }
27
+ const size = Number(fields[2]);
28
+ if (!Number.isSafeInteger(size)) {
29
+ throw new Error('Git cat-file reported an unsafe object size');
30
+ }
31
+ return Object.freeze({
32
+ kind: 'available',
33
+ contentOffset: terminator + 1,
34
+ objectType: fields[1],
35
+ size,
36
+ });
37
+ }
38
+ export function createObjectReader(repositoryRoot, dependencies = {}) {
39
+ const runner = dependencies.runner ?? createGitRunner();
40
+ return Object.freeze({
41
+ async inspect(oid, signal) {
42
+ validateObjectId(oid);
43
+ const result = await runner.run(['cat-file', '--batch-command', '-Z'], {
44
+ cwd: repositoryRoot,
45
+ input: Buffer.from(`info ${oid}\0`, 'ascii'),
46
+ maxStdoutBytes: BATCH_HEADER_LIMIT,
47
+ signal,
48
+ });
49
+ const header = parseBatchHeader(result.stdout, oid);
50
+ if (header.contentOffset !== result.stdout.byteLength) {
51
+ throw new Error('Git cat-file returned trailing bytes for object metadata');
52
+ }
53
+ return header.kind === 'missing'
54
+ ? Object.freeze({ kind: 'missing' })
55
+ : Object.freeze({
56
+ kind: 'available',
57
+ objectType: header.objectType,
58
+ size: header.size,
59
+ });
60
+ },
61
+ async read(oid, options) {
62
+ validateObjectId(oid);
63
+ if (!Number.isSafeInteger(options.maxBytes) ||
64
+ options.maxBytes < 0 ||
65
+ options.maxBytes > Number.MAX_SAFE_INTEGER - BATCH_HEADER_LIMIT) {
66
+ throw new RangeError('Object read limit must be a non-negative safe integer');
67
+ }
68
+ const result = await runner.run(['cat-file', '--batch-command', '-Z'], {
69
+ cwd: repositoryRoot,
70
+ input: Buffer.from(`contents ${oid}\0`, 'ascii'),
71
+ maxStdoutBytes: options.maxBytes + BATCH_HEADER_LIMIT,
72
+ signal: options.signal,
73
+ });
74
+ const header = parseBatchHeader(result.stdout, oid);
75
+ if (header.kind === 'missing') {
76
+ if (header.contentOffset !== result.stdout.byteLength) {
77
+ throw new Error('Git cat-file returned trailing bytes for a missing object');
78
+ }
79
+ return Object.freeze({ kind: 'missing' });
80
+ }
81
+ if (header.size > options.maxBytes) {
82
+ throw new Error('Git cat-file object exceeds the bounded read limit');
83
+ }
84
+ const contentEnd = header.contentOffset + header.size;
85
+ if (result.stdout.byteLength !== contentEnd + 1 ||
86
+ result.stdout[contentEnd] !== 0) {
87
+ throw new Error('Git cat-file returned malformed object content framing');
88
+ }
89
+ return Object.freeze({
90
+ kind: 'available',
91
+ bytes: result.stdout.subarray(header.contentOffset, contentEnd),
92
+ });
93
+ },
94
+ });
95
+ }
@@ -0,0 +1,84 @@
1
+ import { createExactPath } from '../domain/path-bytes.js';
2
+ function malformedRawDiff(message) {
3
+ return new Error(`Malformed raw diff: ${message}`);
4
+ }
5
+ function readNulField(output, offset) {
6
+ const end = output.indexOf(0, offset);
7
+ if (end === -1) {
8
+ throw malformedRawDiff('record is not NUL terminated');
9
+ }
10
+ return {
11
+ field: output.subarray(offset, end),
12
+ nextOffset: end + 1,
13
+ };
14
+ }
15
+ export function parseRawDiff(output) {
16
+ if (output.length === 0) {
17
+ return Object.freeze([]);
18
+ }
19
+ if (output[output.length - 1] !== 0) {
20
+ throw malformedRawDiff('output is not NUL terminated');
21
+ }
22
+ const records = [];
23
+ let offset = 0;
24
+ while (offset < output.length) {
25
+ const headerField = readNulField(output, offset);
26
+ offset = headerField.nextOffset;
27
+ const header = headerField.field.toString('ascii');
28
+ const match = /^:([0-7]{6}) ([0-7]{6}) ((?:[0-9a-f]{40}|[0-9a-f]{64})) ((?:[0-9a-f]{40}|[0-9a-f]{64})) ([A-Z])([0-9]{1,3})?$/.exec(header);
29
+ if (match === null) {
30
+ throw malformedRawDiff('invalid raw record header');
31
+ }
32
+ const [, oldMode, newMode, oldBlobOid, newBlobOid, status, similarityText,] = match;
33
+ if (oldBlobOid.length !== newBlobOid.length) {
34
+ throw malformedRawDiff('object IDs use different formats');
35
+ }
36
+ const hasPair = status === 'R' || status === 'C';
37
+ if (hasPair !== (similarityText !== undefined)) {
38
+ throw malformedRawDiff(hasPair
39
+ ? 'rename/copy record is missing similarity'
40
+ : 'non-rename/copy record includes similarity');
41
+ }
42
+ const similarity = similarityText === undefined ? null : Number.parseInt(similarityText, 10);
43
+ if (similarity !== null && similarity > 100) {
44
+ throw malformedRawDiff('similarity is outside 0-100');
45
+ }
46
+ if (offset >= output.length) {
47
+ throw malformedRawDiff('record is missing its path');
48
+ }
49
+ const firstPathField = readNulField(output, offset);
50
+ offset = firstPathField.nextOffset;
51
+ if (firstPathField.field.length === 0) {
52
+ throw malformedRawDiff('path is empty');
53
+ }
54
+ const firstPath = createExactPath(firstPathField.field);
55
+ let paths;
56
+ if (hasPair) {
57
+ if (offset >= output.length) {
58
+ throw malformedRawDiff('rename/copy record is missing its destination path');
59
+ }
60
+ const secondPathField = readNulField(output, offset);
61
+ offset = secondPathField.nextOffset;
62
+ if (secondPathField.field.length === 0) {
63
+ throw malformedRawDiff('destination path is empty');
64
+ }
65
+ paths = Object.freeze([
66
+ firstPath,
67
+ createExactPath(secondPathField.field),
68
+ ]);
69
+ }
70
+ else {
71
+ paths = Object.freeze([firstPath]);
72
+ }
73
+ records.push(Object.freeze({
74
+ oldMode: oldMode,
75
+ newMode: newMode,
76
+ oldBlobOid: oldBlobOid,
77
+ newBlobOid: newBlobOid,
78
+ status: status,
79
+ similarity,
80
+ paths,
81
+ }));
82
+ }
83
+ return Object.freeze(records);
84
+ }
@@ -0,0 +1,144 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { FATAL_LAUNCH_MESSAGES, LaunchError, } from '../domain/errors.js';
3
+ import { GitRunnerError } from './runner.js';
4
+ const MINIMUM_GIT_VERSION = Object.freeze({
5
+ major: 2,
6
+ minor: 43,
7
+ patch: 0,
8
+ });
9
+ function singleLine(buffer, fact) {
10
+ const value = buffer.toString('utf8').trim();
11
+ if (value.length === 0 || value.includes('\n') || value.includes('\0')) {
12
+ throw new Error(`Git returned an invalid ${fact}`);
13
+ }
14
+ return value;
15
+ }
16
+ function versionIsSupported(versionOutput) {
17
+ const match = /^git version (\d+)\.(\d+)\.(\d+)(?:\s|$)/u.exec(singleLine(versionOutput, 'version'));
18
+ if (match === null) {
19
+ return false;
20
+ }
21
+ const [major, minor, patch] = match.slice(1).map(Number);
22
+ return (major > MINIMUM_GIT_VERSION.major ||
23
+ (major === MINIMUM_GIT_VERSION.major &&
24
+ (minor > MINIMUM_GIT_VERSION.minor ||
25
+ (minor === MINIMUM_GIT_VERSION.minor &&
26
+ patch >= MINIMUM_GIT_VERSION.patch))));
27
+ }
28
+ async function requireSupportedGit(cwd, runner, signal) {
29
+ try {
30
+ const version = await runner.run(['--version'], { cwd, signal });
31
+ if (!versionIsSupported(version.stdout)) {
32
+ throw new LaunchError('git-unsupported', FATAL_LAUNCH_MESSAGES.gitUnsupported, { recovery: { kind: 'exit' } });
33
+ }
34
+ }
35
+ catch (error) {
36
+ if (error instanceof LaunchError) {
37
+ throw error;
38
+ }
39
+ if (error instanceof GitRunnerError && error.kind === 'spawn') {
40
+ throw new LaunchError('git-missing', FATAL_LAUNCH_MESSAGES.gitMissing, {
41
+ cause: error,
42
+ recovery: { kind: 'exit' },
43
+ });
44
+ }
45
+ if (signal?.aborted) {
46
+ throw error;
47
+ }
48
+ throw new LaunchError('git-unsupported', FATAL_LAUNCH_MESSAGES.gitUnsupported, { cause: error, recovery: { kind: 'exit' } });
49
+ }
50
+ }
51
+ async function probeMachineProtocols(repositoryRoot, headOid, runner, signal) {
52
+ const commands = [
53
+ ['worktree', 'list', '--porcelain', '-z'],
54
+ ['for-each-ref', '--count=1', '--format=%(refname)%00', 'refs/heads'],
55
+ ['merge-base', '--all', headOid, headOid],
56
+ [
57
+ 'diff',
58
+ '--raw',
59
+ '-z',
60
+ '--no-abbrev',
61
+ '--no-ext-diff',
62
+ '--no-textconv',
63
+ headOid,
64
+ headOid,
65
+ '--',
66
+ ],
67
+ [
68
+ 'diff',
69
+ '--numstat',
70
+ '-z',
71
+ '--no-ext-diff',
72
+ '--no-textconv',
73
+ headOid,
74
+ headOid,
75
+ '--',
76
+ ],
77
+ ['cat-file', '--batch-command', '-Z'],
78
+ ];
79
+ try {
80
+ for (const arguments_ of commands) {
81
+ await runner.run(arguments_, { cwd: repositoryRoot, signal });
82
+ }
83
+ }
84
+ catch (error) {
85
+ if (signal?.aborted) {
86
+ throw error;
87
+ }
88
+ throw new LaunchError('git-unsupported', FATAL_LAUNCH_MESSAGES.gitUnsupported, { cause: error, recovery: { kind: 'exit' } });
89
+ }
90
+ }
91
+ export async function discoverGitRepository(cwd, runner, signal) {
92
+ await requireSupportedGit(cwd, runner, signal);
93
+ let topLevelResult;
94
+ try {
95
+ topLevelResult = await runner.run(['rev-parse', '--path-format=absolute', '--show-toplevel'], { cwd, signal });
96
+ }
97
+ catch (topLevelError) {
98
+ if (signal?.aborted) {
99
+ throw topLevelError;
100
+ }
101
+ try {
102
+ const bare = await runner.run(['rev-parse', '--is-bare-repository'], {
103
+ cwd,
104
+ signal,
105
+ });
106
+ if (singleLine(bare.stdout, 'bare-repository state') === 'true') {
107
+ throw new LaunchError('bare-repository', FATAL_LAUNCH_MESSAGES.bareRepository, { cause: topLevelError, recovery: { kind: 'exit' } });
108
+ }
109
+ }
110
+ catch (bareError) {
111
+ if (bareError instanceof LaunchError || signal?.aborted) {
112
+ throw bareError;
113
+ }
114
+ }
115
+ throw new LaunchError('not-worktree', FATAL_LAUNCH_MESSAGES.notWorktree, { cause: topLevelError, recovery: { kind: 'exit' } });
116
+ }
117
+ let root;
118
+ try {
119
+ root = await realpath(singleLine(topLevelResult.stdout, 'worktree root'));
120
+ }
121
+ catch (error) {
122
+ throw new LaunchError('not-worktree', FATAL_LAUNCH_MESSAGES.notWorktree, { cause: error, recovery: { kind: 'exit' } });
123
+ }
124
+ const bareResult = await runner.run(['rev-parse', '--is-bare-repository'], {
125
+ cwd: root,
126
+ signal,
127
+ });
128
+ if (singleLine(bareResult.stdout, 'bare-repository state') !== 'false') {
129
+ throw new LaunchError('bare-repository', FATAL_LAUNCH_MESSAGES.bareRepository, { recovery: { kind: 'exit' } });
130
+ }
131
+ let headOid;
132
+ try {
133
+ const head = await runner.run(['rev-parse', '--verify', '--end-of-options', 'HEAD^{commit}'], { cwd: root, signal });
134
+ headOid = singleLine(head.stdout, 'HEAD commit');
135
+ }
136
+ catch (error) {
137
+ if (signal?.aborted) {
138
+ throw error;
139
+ }
140
+ throw new LaunchError('empty-repository', FATAL_LAUNCH_MESSAGES.emptyRepository, { cause: error, recovery: { kind: 'exit' } });
141
+ }
142
+ await probeMachineProtocols(root, headOid, runner, signal);
143
+ return Object.freeze({ root, headOid });
144
+ }