@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.
- package/LICENSE +21 -0
- package/README.md +202 -2
- package/THIRD_PARTY_NOTICES.md +580 -0
- package/dist/bin/loopnote.mjs +5 -0
- package/dist/cli/confirm.js +38 -0
- package/dist/cli/picker.js +342 -0
- package/dist/cli/request.js +120 -0
- package/dist/cli/run.js +970 -0
- package/dist/contracts/api.js +640 -0
- package/dist/contracts/comparison.js +178 -0
- package/dist/contracts/draft.js +606 -0
- package/dist/contracts/request.js +124 -0
- package/dist/domain/anchor.js +122 -0
- package/dist/domain/comparison-key.js +51 -0
- package/dist/domain/errors.js +43 -0
- package/dist/domain/path-bytes.js +82 -0
- package/dist/domain/product-identity.js +66 -0
- package/dist/domain/source.js +4 -0
- package/dist/draft/mutate-draft.js +87 -0
- package/dist/export/render-review-markdown.js +163 -0
- package/dist/export/review-export.js +273 -0
- package/dist/git/availability.js +92 -0
- package/dist/git/candidates.js +413 -0
- package/dist/git/comparison.js +263 -0
- package/dist/git/exact-patch.js +702 -0
- package/dist/git/ignore-status.js +23 -0
- package/dist/git/inventory.js +160 -0
- package/dist/git/numstat.js +111 -0
- package/dist/git/objects.js +95 -0
- package/dist/git/raw-diff.js +84 -0
- package/dist/git/repository.js +144 -0
- package/dist/git/runner.js +145 -0
- package/dist/git/selector-drift.js +169 -0
- package/dist/git/standalone-resolution.js +174 -0
- package/dist/git/worktree-content.js +97 -0
- package/dist/git/worktree-inventory.js +391 -0
- package/dist/native/directory_exchange.node +0 -0
- package/dist/server/app.js +171 -0
- package/dist/server/attached-completion.js +85 -0
- package/dist/server/capabilities.js +1572 -0
- package/dist/server/draft-loader.js +134 -0
- package/dist/server/draft-recovery.js +5 -0
- package/dist/server/draft-store.js +380 -0
- package/dist/server/export-store.js +291 -0
- package/dist/server/gitignore-capability.js +197 -0
- package/dist/server/image-content.js +101 -0
- package/dist/server/lifecycle.js +72 -0
- package/dist/server/native-exchange-capability.js +45 -0
- package/dist/server/patch-snapshot.js +500 -0
- package/dist/server/routes.js +418 -0
- package/dist/server/security.js +123 -0
- package/dist/server/snapshot-fs.js +58 -0
- package/dist/server/support-client.js +76 -0
- package/dist/server/support-store.js +194 -0
- package/dist/server/worktree-snapshot.js +553 -0
- package/dist/web/assets/abap-08VXUWAP.js +1 -0
- package/dist/web/assets/apex-BWPQTe0t.js +1 -0
- package/dist/web/assets/azcli-Bc_sGQ0U.js +1 -0
- package/dist/web/assets/bat-i0X4ZdIN.js +1 -0
- package/dist/web/assets/bicep-B5-_aFwp.js +2 -0
- package/dist/web/assets/cameligo-DMUM7wLl.js +1 -0
- package/dist/web/assets/clojure-Cm7r79vr.js +1 -0
- package/dist/web/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/dist/web/assets/coffee-Ba7i2nA0.js +1 -0
- package/dist/web/assets/cpp-C7h46wYY.js +1 -0
- package/dist/web/assets/csharp-BKxtCVv1.js +1 -0
- package/dist/web/assets/csp-bTuwJoIa.js +1 -0
- package/dist/web/assets/css-DIMkf-bt.js +3 -0
- package/dist/web/assets/css.worker-yKcq13f2.js +89 -0
- package/dist/web/assets/cssMode-CpBTfhMg.js +1 -0
- package/dist/web/assets/cypher-CVaqCwHa.js +1 -0
- package/dist/web/assets/dart-onAF5SnQ.js +1 -0
- package/dist/web/assets/dockerfile-DZFCIeNp.js +1 -0
- package/dist/web/assets/ecl-D05T4iGw.js +1 -0
- package/dist/web/assets/editor.worker-tytyn86m.js +26 -0
- package/dist/web/assets/elixir-6RTg0lbw.js +1 -0
- package/dist/web/assets/flow9-C5_-GSwl.js +1 -0
- package/dist/web/assets/freemarker2-skQ63wb7.js +3 -0
- package/dist/web/assets/fsharp-C8Ef5oNN.js +1 -0
- package/dist/web/assets/go-C-y9NEjX.js +1 -0
- package/dist/web/assets/graphql-fmXr3nnJ.js +1 -0
- package/dist/web/assets/handlebars-CqvPAZY8.js +1 -0
- package/dist/web/assets/hcl-CpzslTdj.js +1 -0
- package/dist/web/assets/html-BDAqstpo.js +1 -0
- package/dist/web/assets/html.worker-DSJ9zmd7.js +502 -0
- package/dist/web/assets/htmlMode-BRYA3pnT.js +1 -0
- package/dist/web/assets/index-CCeYBqPo.js +1111 -0
- package/dist/web/assets/index-CRj9ojWJ.css +1 -0
- package/dist/web/assets/ini-sBoK_t0W.js +1 -0
- package/dist/web/assets/java-BEtHBSE6.js +1 -0
- package/dist/web/assets/javascript-1Ywytw67.js +1 -0
- package/dist/web/assets/json.worker-Oa9UrkKE.js +58 -0
- package/dist/web/assets/jsonMode-AO1C_0kX.js +7 -0
- package/dist/web/assets/julia-Bri6UV-V.js +1 -0
- package/dist/web/assets/kotlin-BOotOW0E.js +1 -0
- package/dist/web/assets/less-B9JPFI3C.js +2 -0
- package/dist/web/assets/lexon-CfSJPG6W.js +1 -0
- package/dist/web/assets/liquid-CkkqyyGc.js +1 -0
- package/dist/web/assets/lspLanguageFeatures-CYOFEMZp.js +4 -0
- package/dist/web/assets/lua-CsQS60Ue.js +1 -0
- package/dist/web/assets/m3-D-oSqn_W.js +1 -0
- package/dist/web/assets/markdown-Cimd5fb3.js +1 -0
- package/dist/web/assets/mdx-Bd3Wlfqw.js +1 -0
- package/dist/web/assets/mips-CIPQ_RoX.js +1 -0
- package/dist/web/assets/msdax-DauUninz.js +1 -0
- package/dist/web/assets/mysql-SOo6toE5.js +1 -0
- package/dist/web/assets/objective-c-FvmIjYaQ.js +1 -0
- package/dist/web/assets/pascal-DrH0SRf2.js +1 -0
- package/dist/web/assets/pascaligo-D-ptJ9y-.js +1 -0
- package/dist/web/assets/perl-oz_6vUea.js +1 -0
- package/dist/web/assets/pgsql-DTj74zXo.js +1 -0
- package/dist/web/assets/php-nr791fC2.js +1 -0
- package/dist/web/assets/pla-CopQ2nXW.js +1 -0
- package/dist/web/assets/postiats-43DmfD33.js +1 -0
- package/dist/web/assets/powerquery-D3hlyOfw.js +1 -0
- package/dist/web/assets/powershell-DmHpPYUd.js +1 -0
- package/dist/web/assets/protobuf-C531GsRP.js +2 -0
- package/dist/web/assets/pug-Z5eAx3Zn.js +1 -0
- package/dist/web/assets/python-CET4IumD.js +1 -0
- package/dist/web/assets/qsharp-DkqhCAOL.js +1 -0
- package/dist/web/assets/r-BwWrilGY.js +1 -0
- package/dist/web/assets/razor-d-reaHeT.js +1 -0
- package/dist/web/assets/redis-ClamHrr6.js +1 -0
- package/dist/web/assets/redshift-DT7zqm-g.js +1 -0
- package/dist/web/assets/restructuredtext-BYgofb2h.js +1 -0
- package/dist/web/assets/ruby-DezsRK8O.js +1 -0
- package/dist/web/assets/rust-DdL9SqIa.js +1 -0
- package/dist/web/assets/sb-CcwsVR0C.js +1 -0
- package/dist/web/assets/scala-DHpiXF5c.js +1 -0
- package/dist/web/assets/scheme-BeGwcela.js +1 -0
- package/dist/web/assets/scss-gp-XZpBa.js +3 -0
- package/dist/web/assets/shell-CC2rA5mh.js +1 -0
- package/dist/web/assets/solidity-BEEn4gHE.js +1 -0
- package/dist/web/assets/sophia-CRfGWb83.js +1 -0
- package/dist/web/assets/sparql-D_Lu-MrJ.js +1 -0
- package/dist/web/assets/sql-NEE52Syq.js +1 -0
- package/dist/web/assets/st-DbInun42.js +1 -0
- package/dist/web/assets/swift-Bxkupp3x.js +1 -0
- package/dist/web/assets/systemverilog-Bz4Y3fRF.js +1 -0
- package/dist/web/assets/tcl-DISqw1ZD.js +1 -0
- package/dist/web/assets/ts.worker-BtjBFNHX.js +67734 -0
- package/dist/web/assets/tsMode-DLxSAtkD.js +11 -0
- package/dist/web/assets/twig-De2hgUGE.js +1 -0
- package/dist/web/assets/typescript-DprEVA-S.js +1 -0
- package/dist/web/assets/typespec-B8J7ngcE.js +1 -0
- package/dist/web/assets/vb-DV3o63ZY.js +1 -0
- package/dist/web/assets/wgsl-DpFanUEy.js +298 -0
- package/dist/web/assets/xml-B7ZwKOhJ.js +1 -0
- package/dist/web/assets/yaml-OQ9Asg7B.js +1 -0
- package/dist/web/index.html +14 -0
- 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
|
+
}
|