@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,145 @@
1
+ import { spawn } from 'node:child_process';
2
+ const DEFAULT_TIMEOUT_MS = 10_000;
3
+ const DEFAULT_STDOUT_LIMIT = 1024 * 1024;
4
+ const DEFAULT_STDERR_LIMIT = 64 * 1024;
5
+ const safeGitArguments = [
6
+ '--no-optional-locks',
7
+ '-c',
8
+ 'core.hooksPath=',
9
+ '-c',
10
+ 'core.fsmonitor=false',
11
+ '-c',
12
+ 'diff.external=',
13
+ '-c',
14
+ 'protocol.file.allow=never',
15
+ ];
16
+ export class GitRunnerError extends Error {
17
+ kind;
18
+ exitCode;
19
+ stderr;
20
+ constructor(kind, message, options = {}) {
21
+ super(message, { cause: options.cause });
22
+ this.name = 'GitRunnerError';
23
+ this.kind = kind;
24
+ this.exitCode = options.exitCode ?? null;
25
+ this.stderr = options.stderr ?? Buffer.alloc(0);
26
+ }
27
+ }
28
+ function positiveLimit(value, fallback) {
29
+ if (value === undefined) {
30
+ return fallback;
31
+ }
32
+ if (!Number.isSafeInteger(value) || value <= 0) {
33
+ throw new RangeError('Git runner byte limits and timeouts must be positive integers');
34
+ }
35
+ return value;
36
+ }
37
+ export function createGitRunner(defaults = {}) {
38
+ const defaultTimeoutMs = positiveLimit(defaults.timeoutMs, DEFAULT_TIMEOUT_MS);
39
+ const defaultMaxStdoutBytes = positiveLimit(defaults.maxStdoutBytes, DEFAULT_STDOUT_LIMIT);
40
+ const defaultMaxStderrBytes = positiveLimit(defaults.maxStderrBytes, DEFAULT_STDERR_LIMIT);
41
+ return Object.freeze({
42
+ async run(arguments_, options) {
43
+ const timeoutMs = positiveLimit(options.timeoutMs, defaultTimeoutMs);
44
+ const maxStdoutBytes = positiveLimit(options.maxStdoutBytes, defaultMaxStdoutBytes);
45
+ const maxStderrBytes = positiveLimit(options.maxStderrBytes, defaultMaxStderrBytes);
46
+ if (options.signal?.aborted) {
47
+ throw new GitRunnerError('aborted', 'Git command was cancelled', {
48
+ cause: options.signal.reason,
49
+ });
50
+ }
51
+ return await new Promise((resolve, reject) => {
52
+ const controller = new AbortController();
53
+ let abortKind;
54
+ let terminalFailure;
55
+ let spawnFailure;
56
+ let stdoutBytes = 0;
57
+ let stderrBytes = 0;
58
+ const stdoutChunks = [];
59
+ const stderrChunks = [];
60
+ const abortFromCaller = () => {
61
+ abortKind = 'aborted';
62
+ controller.abort(options.signal?.reason);
63
+ };
64
+ options.signal?.addEventListener('abort', abortFromCaller, {
65
+ once: true,
66
+ });
67
+ const timeout = setTimeout(() => {
68
+ abortKind = 'timeout';
69
+ controller.abort(new Error(`Git command timed out after ${timeoutMs}ms`));
70
+ }, timeoutMs);
71
+ timeout.unref();
72
+ const environment = { ...process.env };
73
+ delete environment.GIT_LITERAL_PATHSPECS;
74
+ delete environment.GIT_GLOB_PATHSPECS;
75
+ delete environment.GIT_NOGLOB_PATHSPECS;
76
+ delete environment.GIT_ICASE_PATHSPECS;
77
+ const child = spawn('git', [...safeGitArguments, ...arguments_], {
78
+ cwd: options.cwd,
79
+ env: {
80
+ ...environment,
81
+ GIT_CONFIG_NOSYSTEM: '1',
82
+ GIT_EXTERNAL_DIFF: '',
83
+ GIT_OPTIONAL_LOCKS: '0',
84
+ GIT_TERMINAL_PROMPT: '0',
85
+ },
86
+ shell: false,
87
+ signal: controller.signal,
88
+ stdio: ['pipe', 'pipe', 'pipe'],
89
+ });
90
+ child.stdin.end(options.input);
91
+ child.stdout.on('data', (chunk) => {
92
+ stdoutBytes += chunk.length;
93
+ if (stdoutBytes > maxStdoutBytes) {
94
+ terminalFailure ??= new GitRunnerError('stdout-limit', `Git stdout exceeded ${maxStdoutBytes} bytes`);
95
+ child.kill('SIGKILL');
96
+ return;
97
+ }
98
+ stdoutChunks.push(chunk);
99
+ });
100
+ child.stderr.on('data', (chunk) => {
101
+ stderrBytes += chunk.length;
102
+ if (stderrBytes > maxStderrBytes) {
103
+ terminalFailure ??= new GitRunnerError('stderr-limit', `Git stderr exceeded ${maxStderrBytes} bytes`);
104
+ child.kill('SIGKILL');
105
+ return;
106
+ }
107
+ stderrChunks.push(chunk);
108
+ });
109
+ child.once('error', (error) => {
110
+ spawnFailure = error;
111
+ });
112
+ child.once('close', (exitCode) => {
113
+ clearTimeout(timeout);
114
+ options.signal?.removeEventListener('abort', abortFromCaller);
115
+ const stderr = Buffer.concat(stderrChunks);
116
+ if (terminalFailure !== undefined) {
117
+ reject(terminalFailure);
118
+ return;
119
+ }
120
+ if (abortKind !== undefined) {
121
+ reject(new GitRunnerError(abortKind, abortKind === 'timeout'
122
+ ? `Git command timed out after ${timeoutMs}ms`
123
+ : 'Git command was cancelled', { cause: controller.signal.reason, stderr }));
124
+ return;
125
+ }
126
+ if (spawnFailure !== undefined) {
127
+ reject(new GitRunnerError('spawn', 'Unable to start the Git executable', {
128
+ cause: spawnFailure,
129
+ stderr,
130
+ }));
131
+ return;
132
+ }
133
+ if (exitCode !== 0) {
134
+ reject(new GitRunnerError('exit', `Git command exited with status ${exitCode ?? 'unknown'}`, { exitCode, stderr }));
135
+ return;
136
+ }
137
+ resolve({
138
+ stdout: Buffer.concat(stdoutChunks),
139
+ stderr,
140
+ });
141
+ });
142
+ });
143
+ },
144
+ });
145
+ }
@@ -0,0 +1,169 @@
1
+ import { GitObjectIdSchema, } from '../contracts/comparison.js';
2
+ import { SelectorDriftResponseSchema, } from '../contracts/api.js';
3
+ import { createGitRunner } from './runner.js';
4
+ const UNAVAILABLE_REASON = 'source-unavailable';
5
+ function splitNul(buffer) {
6
+ const fields = [];
7
+ let start = 0;
8
+ for (let index = 0; index < buffer.length; index += 1) {
9
+ if (buffer[index] === 0) {
10
+ fields.push(buffer.subarray(start, index));
11
+ start = index + 1;
12
+ }
13
+ }
14
+ if (start < buffer.length) {
15
+ fields.push(buffer.subarray(start));
16
+ }
17
+ return fields;
18
+ }
19
+ function parseWorktreeRecords(buffer) {
20
+ const records = [];
21
+ let current = {
22
+ prunable: false,
23
+ bare: false,
24
+ };
25
+ for (const field of splitNul(buffer)) {
26
+ if (field.length === 0) {
27
+ if (current.path !== undefined) {
28
+ records.push(Object.freeze(current));
29
+ }
30
+ current = { prunable: false, bare: false };
31
+ continue;
32
+ }
33
+ const separator = field.indexOf(0x20);
34
+ const key = (separator === -1 ? field : field.subarray(0, separator)).toString('ascii');
35
+ const value = separator === -1 ? undefined : field.subarray(separator + 1).toString('utf8');
36
+ if (key === 'worktree') {
37
+ if (current.path !== undefined) {
38
+ records.push(Object.freeze(current));
39
+ }
40
+ current = { path: value, prunable: false, bare: false };
41
+ }
42
+ else if (key === 'HEAD') {
43
+ current.headOid = value;
44
+ }
45
+ else if (key === 'prunable') {
46
+ current.prunable = true;
47
+ }
48
+ else if (key === 'bare') {
49
+ current.bare = true;
50
+ }
51
+ }
52
+ if (current.path !== undefined) {
53
+ records.push(Object.freeze(current));
54
+ }
55
+ return Object.freeze(records);
56
+ }
57
+ function retainDescriptor(role, endpoint) {
58
+ if (endpoint.source?.kind === 'branch') {
59
+ return Object.freeze({
60
+ kind: 'branch',
61
+ role,
62
+ label: endpoint.label,
63
+ selectorType: 'branch',
64
+ oldOid: endpoint.oid,
65
+ refName: endpoint.source.refName,
66
+ });
67
+ }
68
+ if (endpoint.source?.kind === 'worktree') {
69
+ return Object.freeze({
70
+ kind: 'worktree',
71
+ role,
72
+ label: endpoint.label,
73
+ selectorType: 'worktree',
74
+ oldOid: endpoint.oid,
75
+ path: endpoint.source.path,
76
+ });
77
+ }
78
+ return undefined;
79
+ }
80
+ function unchanged(role) {
81
+ return { kind: 'unchanged', role };
82
+ }
83
+ function unavailable(descriptor) {
84
+ return {
85
+ kind: 'unavailable',
86
+ role: descriptor.role,
87
+ label: descriptor.label,
88
+ selectorType: descriptor.selectorType,
89
+ oldOid: descriptor.oldOid,
90
+ reason: UNAVAILABLE_REASON,
91
+ };
92
+ }
93
+ function statusForCurrentOid(descriptor, currentOid) {
94
+ return currentOid === descriptor.oldOid
95
+ ? unchanged(descriptor.role)
96
+ : {
97
+ kind: 'moved',
98
+ role: descriptor.role,
99
+ label: descriptor.label,
100
+ selectorType: descriptor.selectorType,
101
+ oldOid: descriptor.oldOid,
102
+ newOid: currentOid,
103
+ };
104
+ }
105
+ function parseCommitOid(output) {
106
+ const lines = output
107
+ .toString('ascii')
108
+ .split('\n')
109
+ .map((line) => line.trim())
110
+ .filter((line) => line.length > 0);
111
+ if (lines.length !== 1) {
112
+ return undefined;
113
+ }
114
+ const parsed = GitObjectIdSchema.safeParse(lines[0]);
115
+ return parsed.success ? parsed.data : undefined;
116
+ }
117
+ export function createSelectorDriftObserver(comparison, options = {}) {
118
+ const runner = options.runner ?? createGitRunner();
119
+ const repositoryRoot = comparison.repositoryRoot;
120
+ const descriptors = Object.freeze({
121
+ base: retainDescriptor('base', comparison.base),
122
+ head: retainDescriptor('head', comparison.head),
123
+ });
124
+ const listWorktrees = async () => {
125
+ try {
126
+ const result = await runner.run(['worktree', 'list', '--porcelain', '-z'], { cwd: repositoryRoot });
127
+ return parseWorktreeRecords(result.stdout);
128
+ }
129
+ catch {
130
+ return [];
131
+ }
132
+ };
133
+ const observeBranch = async (descriptor) => {
134
+ try {
135
+ const result = await runner.run(['rev-parse', '--verify', '--end-of-options', `${descriptor.refName}^{commit}`], { cwd: repositoryRoot });
136
+ const currentOid = parseCommitOid(result.stdout);
137
+ return currentOid === undefined ? unavailable(descriptor) : statusForCurrentOid(descriptor, currentOid);
138
+ }
139
+ catch {
140
+ return unavailable(descriptor);
141
+ }
142
+ };
143
+ const observeWorktree = async (descriptor, records) => {
144
+ const record = records.find((candidate) => candidate.path === descriptor.path && !candidate.prunable && !candidate.bare);
145
+ if (record === undefined || record.headOid === undefined) {
146
+ return unavailable(descriptor);
147
+ }
148
+ const currentOid = GitObjectIdSchema.safeParse(record.headOid);
149
+ return currentOid.success ? statusForCurrentOid(descriptor, currentOid.data) : unavailable(descriptor);
150
+ };
151
+ const observe = async (descriptor, role, worktreeRecords) => {
152
+ if (descriptor === undefined) {
153
+ return unchanged(role);
154
+ }
155
+ return descriptor.kind === 'branch'
156
+ ? await observeBranch(descriptor)
157
+ : await observeWorktree(descriptor, worktreeRecords ?? []);
158
+ };
159
+ return Object.freeze({
160
+ async observe() {
161
+ const hasWorktreeDescriptor = descriptors.base?.kind === 'worktree' || descriptors.head?.kind === 'worktree';
162
+ const worktreeRecords = hasWorktreeDescriptor ? await listWorktrees() : undefined;
163
+ return SelectorDriftResponseSchema.parse({
164
+ base: await observe(descriptors.base, 'base', worktreeRecords),
165
+ head: await observe(descriptors.head, 'head', worktreeRecords),
166
+ });
167
+ },
168
+ });
169
+ }
@@ -0,0 +1,174 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createExactPath, decodeBase64url } from '../domain/path-bytes.js';
3
+ import { RENAME_MAP } from '../domain/product-identity.js';
4
+ import { createObjectReader } from './objects.js';
5
+ import { createGitRunner, GitRunnerError } from './runner.js';
6
+ import { buildUntrackedChangedFile } from './worktree-inventory.js';
7
+ const newStateDirSegment = RENAME_MAP.repoStateDir.replace(/\/$/, '');
8
+ export class StandaloneResolutionError extends Error {
9
+ kind;
10
+ constructor(kind, message) {
11
+ super(message);
12
+ this.name = 'StandaloneResolutionError';
13
+ this.kind = kind;
14
+ }
15
+ }
16
+ /**
17
+ * Mirrors `MAX_PATHSPEC_COUNT` (`src/contracts/request.ts:7`), duplicated as
18
+ * a local literal because this phase is the first caller applying that bound
19
+ * to CLI argv rather than the agent-protocol JSON contract.
20
+ */
21
+ const MAX_RAW_STANDALONE_ARGUMENTS = 256;
22
+ /**
23
+ * Mirrors `MAX_SNAPSHOT_FILES` (`src/server/worktree-snapshot.ts`),
24
+ * duplicated as a local literal because `src/git/` never depends on
25
+ * `src/server/`.
26
+ */
27
+ const MAX_RESOLVED_STANDALONE_FILES = 10_000;
28
+ const standaloneFileIdNamespace = randomBytes(32);
29
+ function parseNulDelimitedPaths(output) {
30
+ const paths = [];
31
+ let offset = 0;
32
+ while (offset < output.length) {
33
+ const end = output.indexOf(0, offset);
34
+ if (end === -1) {
35
+ break;
36
+ }
37
+ if (end > offset) {
38
+ paths.push(createExactPath(output.subarray(offset, end)));
39
+ }
40
+ offset = end + 1;
41
+ }
42
+ return Object.freeze(paths);
43
+ }
44
+ function parsePorcelainRecords(output) {
45
+ const records = new Map();
46
+ let offset = 0;
47
+ while (offset < output.length) {
48
+ const end = output.indexOf(0, offset);
49
+ const segmentEnd = end === -1 ? output.length : end;
50
+ if (segmentEnd > offset) {
51
+ const record = output.subarray(offset, segmentEnd).toString('utf8');
52
+ records.set(record.slice(3), record);
53
+ }
54
+ offset = segmentEnd + 1;
55
+ }
56
+ return records;
57
+ }
58
+ async function resolveOneArgument(repositoryRoot, argument, runner) {
59
+ let stdout;
60
+ try {
61
+ ({ stdout } = await runner.run(['ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', argument], { cwd: repositoryRoot }));
62
+ }
63
+ catch (error) {
64
+ if (error instanceof GitRunnerError && error.kind === 'exit' && error.exitCode === 128) {
65
+ throw new StandaloneResolutionError('path-outside-repository', `Argument "${argument}" resolves outside the repository.`);
66
+ }
67
+ throw error;
68
+ }
69
+ const matches = parseNulDelimitedPaths(stdout);
70
+ if (matches.length > 0) {
71
+ return matches;
72
+ }
73
+ let ignored;
74
+ try {
75
+ await runner.run(['check-ignore', '--quiet', '--', argument], { cwd: repositoryRoot });
76
+ ignored = true;
77
+ }
78
+ catch (error) {
79
+ // Exit 0 is the only "ignored" signal `check-ignore` gives. Any other
80
+ // outcome — exit 1 ("not ignored"), or Git's own fatal "pathspec is
81
+ // beyond a symbolic link" rejection (exit 128) when the argument walks
82
+ // through a symlinked directory the ls-files probe above already
83
+ // declined to traverse — all collapse to "not ignored" here.
84
+ if (error instanceof GitRunnerError && error.kind === 'exit') {
85
+ ignored = false;
86
+ }
87
+ else {
88
+ throw error;
89
+ }
90
+ }
91
+ if (ignored) {
92
+ throw new StandaloneResolutionError('pathspec-ignored', `Argument "${argument}" matched only a path excluded via .gitignore; it was not opened.`);
93
+ }
94
+ throw new StandaloneResolutionError('pathspec-no-match', `Argument "${argument}" did not match any file.`);
95
+ }
96
+ function rejectReservedSegments(path) {
97
+ const segments = Buffer.from(decodeBase64url(path.bytesBase64url))
98
+ .toString('latin1')
99
+ .split('/');
100
+ for (const segment of segments) {
101
+ if (segment === '.git' || segment === '.cumpa' || segment === newStateDirSegment) {
102
+ throw new Error(`Resolved path "${path.display}" contains a reserved ".git"/".cumpa"/"${newStateDirSegment}" segment and was rejected.`);
103
+ }
104
+ }
105
+ }
106
+ export async function resolveStandaloneArguments(repositoryRoot, rawArguments, dependencies = {}, options = {}) {
107
+ if (rawArguments.length > MAX_RAW_STANDALONE_ARGUMENTS) {
108
+ throw new Error(`Too many arguments: ${rawArguments.length} exceeds the ${MAX_RAW_STANDALONE_ARGUMENTS}-argument limit.`);
109
+ }
110
+ const runner = dependencies.runner ?? createGitRunner();
111
+ const resolved = new Map();
112
+ for (const argument of rawArguments) {
113
+ let matches;
114
+ try {
115
+ matches = await resolveOneArgument(repositoryRoot, argument, runner);
116
+ }
117
+ catch (error) {
118
+ // Opt-in drift tolerance for the refresh path (CR-01): a single-file
119
+ // standalone argument whose only match has since been deleted/moved
120
+ // should surface as a clean "removed" delta on refresh, matching
121
+ // every other sourceKind's diff-based enumeration, rather than
122
+ // crashing the refresh request. The initial CLI launch in
123
+ // `src/cli/run.ts` never passes `toleratesNoMatch`, so it keeps
124
+ // rejecting typos/missing files at startup.
125
+ if (options.toleratesNoMatch === true &&
126
+ error instanceof Error &&
127
+ /did not match any file/.test(error.message)) {
128
+ continue;
129
+ }
130
+ throw error;
131
+ }
132
+ for (const match of matches) {
133
+ rejectReservedSegments(match);
134
+ resolved.set(match.bytesBase64url, match);
135
+ }
136
+ }
137
+ if (resolved.size > MAX_RESOLVED_STANDALONE_FILES) {
138
+ throw new Error(`Resolved file count ${resolved.size} exceeds the ${MAX_RESOLVED_STANDALONE_FILES}-file limit.`);
139
+ }
140
+ return Object.freeze([...resolved.values()]);
141
+ }
142
+ export async function classifyStandaloneGitState(repositoryRoot, resolvedPaths, dependencies = {}) {
143
+ const states = new Map();
144
+ if (resolvedPaths.length === 0) {
145
+ return states;
146
+ }
147
+ const runner = dependencies.runner ?? createGitRunner();
148
+ const pathArguments = resolvedPaths.map((path) => path.utf8 ?? path.display);
149
+ const { stdout } = await runner.run(['status', '--porcelain=v1', '-z', '--untracked-files=normal', '--', ...pathArguments], { cwd: repositoryRoot });
150
+ const records = parsePorcelainRecords(stdout);
151
+ for (const path of resolvedPaths) {
152
+ const record = records.get(path.utf8 ?? path.display);
153
+ const state = record === undefined
154
+ ? 'unchanged'
155
+ : record.startsWith('??')
156
+ ? 'untracked'
157
+ : 'modified-uncommitted';
158
+ states.set(path.bytesBase64url, state);
159
+ }
160
+ return states;
161
+ }
162
+ export async function createStandaloneChangedFileInventory(repositoryRoot, resolvedPaths, gitStates, objectFormat, dependencies = {}) {
163
+ const runner = dependencies.runner ?? createGitRunner();
164
+ const objectReader = dependencies.objectReader ?? createObjectReader(repositoryRoot, { runner });
165
+ const files = await Promise.all(resolvedPaths.map(async (path) => {
166
+ const file = await buildUntrackedChangedFile(path, objectFormat, standaloneFileIdNamespace, objectReader, repositoryRoot, dependencies.signal);
167
+ const standaloneGitState = gitStates.get(path.bytesBase64url);
168
+ if (standaloneGitState === undefined) {
169
+ throw new RangeError(`Missing Git-state classification for resolved path "${path.display}".`);
170
+ }
171
+ return { ...file, standaloneGitState };
172
+ }));
173
+ return Object.freeze(files);
174
+ }
@@ -0,0 +1,97 @@
1
+ import { blobOid } from './exact-patch.js';
2
+ import { createGitRunner } from './runner.js';
3
+ const hunkHeaderPattern = /^@@ -(0|[1-9][0-9]*)(?:,(0|[1-9][0-9]*))? \+(0|[1-9][0-9]*)(?:,(0|[1-9][0-9]*))? @@(?: .*)?$/u;
4
+ function fail(path, reason) {
5
+ throw new Error(`resolveWorktreeSideContent: ${reason} for "${path}"`);
6
+ }
7
+ /**
8
+ * Walks the single full-context hunk produced by `git diff -U1000000` for one
9
+ * path and rebuilds the postimage (the side after the invoked comparison —
10
+ * the worktree file itself for `scope: 'worktree'`) from its context (' ')
11
+ * and added ('+') lines, exactly mirroring the line-prefix classification in
12
+ * `exact-patch.ts`'s `applyText` (modeled on it, not calling it: this reads
13
+ * git's own already-normalized hunk output directly, it does not replay a
14
+ * submitted patch against a known preimage).
15
+ *
16
+ * `-U1000000` always collapses every edited region of a file into exactly
17
+ * one hunk, so a second `@@` header is a malformed-input signal, not a
18
+ * second hunk to merge.
19
+ */
20
+ function reconstructPostimage(stdout, path) {
21
+ const text = stdout.toString('utf8');
22
+ const lines = text.length === 0 ? [] : text.split('\n');
23
+ if (lines.length > 0 && lines[lines.length - 1] === '') {
24
+ lines.pop();
25
+ }
26
+ const output = [];
27
+ let newCount = 0;
28
+ let newConsumed = 0;
29
+ let hunkFound = false;
30
+ for (let index = 0; index < lines.length; index += 1) {
31
+ const line = lines[index];
32
+ const header = hunkHeaderPattern.exec(line);
33
+ if (header !== null) {
34
+ if (hunkFound)
35
+ fail(path, 'a -U1000000 diff produced more than one hunk');
36
+ hunkFound = true;
37
+ newCount = Number(header[4] ?? 1);
38
+ newConsumed = 0;
39
+ continue;
40
+ }
41
+ if (!hunkFound)
42
+ continue;
43
+ const prefix = line[0];
44
+ if (prefix !== ' ' && prefix !== '+' && prefix !== '-') {
45
+ fail(path, `unrecognized diff hunk line "${line}"`);
46
+ }
47
+ const noNewline = lines[index + 1] === '\';
48
+ if (noNewline)
49
+ index += 1;
50
+ if (prefix === ' ' || prefix === '+') {
51
+ if (newConsumed >= newCount)
52
+ fail(path, 'hunk reported more postimage lines than its header declared');
53
+ newConsumed += 1;
54
+ output.push(`${line.slice(1)}${noNewline ? '' : '\n'}`);
55
+ }
56
+ }
57
+ if (!hunkFound) {
58
+ fail(path, 'git diff reported no change (resolveWorktreeSideContent must only be called for already-changed files)');
59
+ }
60
+ if (newConsumed !== newCount)
61
+ fail(path, 'hunk postimage line count did not match its header');
62
+ return Buffer.from(output.join(''), 'utf8');
63
+ }
64
+ /**
65
+ * Reconstructs the full text of a diff side that crosses the worktree
66
+ * boundary (Unstaged's worktree side, Working-tree's worktree side for
67
+ * tracked files) by reading `git diff`'s own full-context patch output
68
+ * rather than independently `fs.readFile`-ing the worktree file and
69
+ * comparing it against a differently-normalized object-backed side. Git
70
+ * already applies `core.autocrlf`/`.gitattributes` text normalization
71
+ * consistently when it decides what changed and what the patch text looks
72
+ * like; re-deriving that here would risk silently diverging from `git
73
+ * diff`'s own notion of "changed".
74
+ *
75
+ * Precondition: callers must only invoke this for a path already reported
76
+ * as changed by the corresponding `git diff --raw`/`--numstat` inventory
77
+ * call (`createWorktreeChangedFileInventory`). Calling it for a path with no
78
+ * actual diff is a contract violation, not a case this function recovers
79
+ * from — it throws, mirroring how `exact-patch.ts`'s `applyText` assumes a
80
+ * well-formed patch rather than defensively handling a no-op one.
81
+ */
82
+ export async function resolveWorktreeSideContent(options, dependencies = {}) {
83
+ const runner = dependencies.runner ?? createGitRunner();
84
+ const result = await runner.run([
85
+ 'diff',
86
+ ...(options.scope === 'cached' ? ['--cached'] : []),
87
+ '-U1000000',
88
+ '--no-color',
89
+ '--no-ext-diff',
90
+ '--no-textconv',
91
+ ...(options.endpoint === undefined ? [] : [options.endpoint]),
92
+ '--',
93
+ options.path,
94
+ ], { cwd: options.repositoryRoot, signal: options.signal });
95
+ const bytes = reconstructPostimage(result.stdout, options.path);
96
+ return Object.freeze({ bytes, blobOid: blobOid(bytes, options.objectFormat) });
97
+ }