@turns/dev 0.0.8 → 0.0.10
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/dist/file-search.js +182 -0
- package/dist/git-status.js +440 -0
- package/dist/path-resolution.js +13 -2
- package/dist/queue-worker.js +83 -0
- package/package.json +1 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveBrowsePath } from "./file-browsing.js";
|
|
5
|
+
/** Kept in step with the Durable Object's recursive search result limit. */
|
|
6
|
+
export const MAX_FILE_SEARCH_RESULTS = 200;
|
|
7
|
+
/** Typeahead must not leave a filesystem walk running after its browser query
|
|
8
|
+
* has already been superseded. A timed-out walk returns the useful prefix it
|
|
9
|
+
* found before it was stopped. */
|
|
10
|
+
export const FILE_SEARCH_TIMEOUT_MS = 1_000;
|
|
11
|
+
function isoTime(value) {
|
|
12
|
+
return Number.isFinite(value) ? new Date(value).toISOString() : '';
|
|
13
|
+
}
|
|
14
|
+
function pathIsInside(directory, candidate) {
|
|
15
|
+
const relative = path.relative(directory, candidate);
|
|
16
|
+
return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
17
|
+
}
|
|
18
|
+
function fileSearchEntry(directory, candidate) {
|
|
19
|
+
if (!pathIsInside(directory, candidate))
|
|
20
|
+
return null;
|
|
21
|
+
let link;
|
|
22
|
+
let stat;
|
|
23
|
+
try {
|
|
24
|
+
link = fs.lstatSync(candidate);
|
|
25
|
+
stat = fs.statSync(candidate);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const kind = stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other';
|
|
31
|
+
const relative = path.relative(directory, candidate).split(path.sep).join('/');
|
|
32
|
+
return {
|
|
33
|
+
name: path.basename(candidate),
|
|
34
|
+
path: candidate,
|
|
35
|
+
displayName: relative,
|
|
36
|
+
kind,
|
|
37
|
+
...(stat.isFile() ? { size: stat.size } : {}),
|
|
38
|
+
...(Number.isFinite(stat.mtimeMs) ? { modifiedAt: isoTime(stat.mtimeMs) } : {}),
|
|
39
|
+
...(link.isSymbolicLink() ? { symlink: true } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Run one NUL-delimited path enumerator without involving a shell.
|
|
44
|
+
*
|
|
45
|
+
* Queries are matched literally in this process, so punctuation in a browser
|
|
46
|
+
* query cannot become a glob, regular expression, command option, or shell
|
|
47
|
+
* fragment. Killing after the result cap also prevents a broad query from
|
|
48
|
+
* walking a huge tree merely to return entries the client cannot display.
|
|
49
|
+
*/
|
|
50
|
+
function enumeratePaths({ command, args, directory, query, limit, timeoutMs }) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const entries = new Map();
|
|
53
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
54
|
+
let pending = '';
|
|
55
|
+
let stopped = false;
|
|
56
|
+
let timedOut = false;
|
|
57
|
+
let killTimer;
|
|
58
|
+
const stop = () => {
|
|
59
|
+
if (stopped)
|
|
60
|
+
return;
|
|
61
|
+
stopped = true;
|
|
62
|
+
child.kill('SIGTERM');
|
|
63
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 100);
|
|
64
|
+
killTimer.unref?.();
|
|
65
|
+
};
|
|
66
|
+
const consider = (rawPath) => {
|
|
67
|
+
const emitted = String(rawPath || '');
|
|
68
|
+
if (!emitted)
|
|
69
|
+
return;
|
|
70
|
+
const absolute = path.normalize(path.isAbsolute(emitted) ? emitted : path.resolve(directory, emitted));
|
|
71
|
+
if (!pathIsInside(directory, absolute))
|
|
72
|
+
return;
|
|
73
|
+
const relativeParts = path.relative(directory, absolute).split(path.sep);
|
|
74
|
+
// `rg --files` emits regular files rather than directories. Considering
|
|
75
|
+
// every prefix reconstructs matching directory observations too, and it
|
|
76
|
+
// preserves the cache's existing "match anywhere in the relative path"
|
|
77
|
+
// semantics for descendants of a matching directory.
|
|
78
|
+
for (let length = 1; length <= relativeParts.length; length += 1) {
|
|
79
|
+
const relative = relativeParts.slice(0, length).join('/');
|
|
80
|
+
if (!relative.toLowerCase().includes(query))
|
|
81
|
+
continue;
|
|
82
|
+
const candidate = path.join(directory, ...relativeParts.slice(0, length));
|
|
83
|
+
if (!entries.has(candidate)) {
|
|
84
|
+
const entry = fileSearchEntry(directory, candidate);
|
|
85
|
+
if (entry)
|
|
86
|
+
entries.set(candidate, entry);
|
|
87
|
+
}
|
|
88
|
+
if (entries.size >= limit) {
|
|
89
|
+
stop();
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const consume = (chunk, final = false) => {
|
|
95
|
+
pending += chunk;
|
|
96
|
+
let boundary = pending.indexOf('\0');
|
|
97
|
+
while (boundary !== -1) {
|
|
98
|
+
consider(pending.slice(0, boundary));
|
|
99
|
+
pending = pending.slice(boundary + 1);
|
|
100
|
+
boundary = pending.indexOf('\0');
|
|
101
|
+
}
|
|
102
|
+
if (final && pending)
|
|
103
|
+
consider(pending);
|
|
104
|
+
};
|
|
105
|
+
child.stdout.setEncoding('utf8');
|
|
106
|
+
child.stdout.on('data', (chunk) => consume(chunk));
|
|
107
|
+
child.on('error', (error) => {
|
|
108
|
+
clearTimeout(killTimer);
|
|
109
|
+
reject(error);
|
|
110
|
+
});
|
|
111
|
+
const timeout = setTimeout(() => {
|
|
112
|
+
timedOut = true;
|
|
113
|
+
stop();
|
|
114
|
+
}, timeoutMs);
|
|
115
|
+
timeout.unref?.();
|
|
116
|
+
child.on('close', (code) => {
|
|
117
|
+
clearTimeout(timeout);
|
|
118
|
+
clearTimeout(killTimer);
|
|
119
|
+
consume('', true);
|
|
120
|
+
const sorted = [...entries.values()]
|
|
121
|
+
.sort((left, right) => {
|
|
122
|
+
if (left.kind === 'directory' && right.kind !== 'directory')
|
|
123
|
+
return -1;
|
|
124
|
+
if (left.kind !== 'directory' && right.kind === 'directory')
|
|
125
|
+
return 1;
|
|
126
|
+
return left.path.localeCompare(right.path);
|
|
127
|
+
})
|
|
128
|
+
.slice(0, limit);
|
|
129
|
+
// Missing tools are tried by the caller. Permission races and partial
|
|
130
|
+
// walks remain useful if they produced any safe entries.
|
|
131
|
+
if (code === 0 || stopped || timedOut || sorted.length)
|
|
132
|
+
resolve(sorted);
|
|
133
|
+
else
|
|
134
|
+
reject(new Error(`${command} exited with status ${code}.`));
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Find recursive path-name matches on the machine for directory typeahead.
|
|
140
|
+
*
|
|
141
|
+
* Ripgrep is preferred because its parallel directory walker is normally the
|
|
142
|
+
* quickest way to enumerate a project tree. Minimal installations fall back
|
|
143
|
+
* to POSIX `find`; both emit NUL-delimited names and are filtered literally.
|
|
144
|
+
*/
|
|
145
|
+
export async function searchMachinePaths({ requested, query, workingDirectory = '', allowedDirectories = [], limit = MAX_FILE_SEARCH_RESULTS, timeoutMs = FILE_SEARCH_TIMEOUT_MS, tools = ['rg', 'find'], }) {
|
|
146
|
+
const needle = String(query || '')
|
|
147
|
+
.trim()
|
|
148
|
+
.toLowerCase()
|
|
149
|
+
.slice(0, 500);
|
|
150
|
+
const resolved = resolveBrowsePath(String(requested || ''), { workingDirectory, allowedDirectories });
|
|
151
|
+
if (!resolved.stat.isDirectory())
|
|
152
|
+
throw new Error(`Not a searchable directory: ${resolved.path}`);
|
|
153
|
+
if (!needle)
|
|
154
|
+
return { directory: resolved.path, entries: [] };
|
|
155
|
+
const boundedLimit = Math.max(1, Math.min(MAX_FILE_SEARCH_RESULTS, Number(limit) || MAX_FILE_SEARCH_RESULTS));
|
|
156
|
+
const boundedTimeout = Math.max(25, Number(timeoutMs) || FILE_SEARCH_TIMEOUT_MS);
|
|
157
|
+
let lastError = null;
|
|
158
|
+
for (const tool of tools) {
|
|
159
|
+
try {
|
|
160
|
+
const args = tool === 'rg'
|
|
161
|
+
? ['--files', '--hidden', '--no-ignore', '--no-messages', '--null', '--', resolved.path]
|
|
162
|
+
: tool === 'find'
|
|
163
|
+
? [resolved.path, '-print0']
|
|
164
|
+
: [];
|
|
165
|
+
if (!args.length)
|
|
166
|
+
continue;
|
|
167
|
+
const entries = await enumeratePaths({
|
|
168
|
+
command: tool,
|
|
169
|
+
args,
|
|
170
|
+
directory: resolved.path,
|
|
171
|
+
query: needle,
|
|
172
|
+
limit: boundedLimit,
|
|
173
|
+
timeoutMs: boundedTimeout,
|
|
174
|
+
});
|
|
175
|
+
return { directory: resolved.path, entries };
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
lastError = error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
throw lastError instanceof Error ? lastError : new Error('No filesystem search tool is available.');
|
|
182
|
+
}
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveBrowsePath } from "./file-browsing.js";
|
|
5
|
+
/**
|
|
6
|
+
* Asking this machine's git what the tree a reader is browsing looks like.
|
|
7
|
+
*
|
|
8
|
+
* The browser cannot run git, and the daemon will not hand it a shell, so the
|
|
9
|
+
* questions worth asking are enumerated here and answered with fixed argument
|
|
10
|
+
* vectors: a repository path never becomes an option, a pattern, or a shell
|
|
11
|
+
* fragment. Every invocation is bounded in time and in output, because a work
|
|
12
|
+
* tree can be arbitrarily large and a browse must not be able to pin a daemon.
|
|
13
|
+
*
|
|
14
|
+
* The base revision is picked the way a reviewer would. On the default branch
|
|
15
|
+
* there is nothing to have diverged from, so the base is `HEAD` and the answer
|
|
16
|
+
* describes uncommitted work. Anywhere else — a feature branch, or one of the
|
|
17
|
+
* linked worktrees agents are usually given — the base is the merge point with
|
|
18
|
+
* the default branch, making the answer the same range a pull request shows.
|
|
19
|
+
*/
|
|
20
|
+
/** A git call has to finish inside a browse, not eventually. */
|
|
21
|
+
export const GIT_COMMAND_TIMEOUT_MS = 10_000;
|
|
22
|
+
/**
|
|
23
|
+
* Ceilings this daemon answers within, kept in step with the Worker's shared
|
|
24
|
+
* git module rather than imported from it.
|
|
25
|
+
*
|
|
26
|
+
* This package is published on its own and builds with the CLI directory as
|
|
27
|
+
* its root, so a source file here cannot reach into the Worker's tree: an
|
|
28
|
+
* import across that boundary compiles under the repo-wide typecheck and then
|
|
29
|
+
* fails the package build, which is the build that produces what npm ships.
|
|
30
|
+
* The Worker's `MAX_GIT_STATUS_FILES`, `MAX_GIT_TEXT_BYTES`, and
|
|
31
|
+
* `MAX_GIT_UNTRACKED_COUNT_BYTES` are the same three numbers; the reader
|
|
32
|
+
* validates against its own copies, so a daemon that drifted upward would be
|
|
33
|
+
* trimmed rather than believed.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_GIT_STATUS_FILES = 2_000;
|
|
36
|
+
const MAX_GIT_TEXT_BYTES = 4 * 1024 * 1024;
|
|
37
|
+
const MAX_GIT_UNTRACKED_COUNT_BYTES = 2 * 1024 * 1024;
|
|
38
|
+
/** Local branches are tried before remote-tracking ones: an agent working in a
|
|
39
|
+
* linked worktree diverged from the checkout beside it, not from the server. */
|
|
40
|
+
const DEFAULT_BRANCH_REFS = [
|
|
41
|
+
{ ref: 'refs/heads/main', label: 'main' },
|
|
42
|
+
{ ref: 'refs/heads/master', label: 'master' },
|
|
43
|
+
{ ref: 'refs/remotes/origin/main', label: 'origin/main' },
|
|
44
|
+
{ ref: 'refs/remotes/origin/master', label: 'origin/master' },
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* Run one git command and collect a bounded amount of its output.
|
|
48
|
+
*
|
|
49
|
+
* `stderr` is discarded rather than reported: git's diagnostics name paths and
|
|
50
|
+
* refs on someone else's machine, and every failure here has a useful meaning
|
|
51
|
+
* of its own — a directory that is not a repository, a file that did not exist
|
|
52
|
+
* at the base. A non-zero exit is returned rather than thrown so callers can
|
|
53
|
+
* treat "no such ref" as an answer.
|
|
54
|
+
*/
|
|
55
|
+
function runGit(args, { cwd, limit = MAX_GIT_TEXT_BYTES, timeoutMs = GIT_COMMAND_TIMEOUT_MS, }) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
// `--no-pager` and an empty config environment keep a reader's answer from
|
|
58
|
+
// depending on whatever aliases, pagers, or colour settings this machine has.
|
|
59
|
+
const child = spawn('git', ['--no-pager', ...args], {
|
|
60
|
+
cwd,
|
|
61
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
62
|
+
env: { ...process.env, GIT_PAGER: 'cat', GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' },
|
|
63
|
+
});
|
|
64
|
+
const chunks = [];
|
|
65
|
+
let size = 0;
|
|
66
|
+
let truncated = false;
|
|
67
|
+
let stopped = false;
|
|
68
|
+
let killTimer;
|
|
69
|
+
const stop = () => {
|
|
70
|
+
if (stopped)
|
|
71
|
+
return;
|
|
72
|
+
stopped = true;
|
|
73
|
+
child.kill('SIGTERM');
|
|
74
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 100);
|
|
75
|
+
killTimer.unref?.();
|
|
76
|
+
};
|
|
77
|
+
child.stdout.on('data', (chunk) => {
|
|
78
|
+
if (size >= limit)
|
|
79
|
+
return;
|
|
80
|
+
const room = limit - size;
|
|
81
|
+
chunks.push(chunk.length > room ? chunk.subarray(0, room) : chunk);
|
|
82
|
+
size += Math.min(room, chunk.length);
|
|
83
|
+
if (size < limit)
|
|
84
|
+
return;
|
|
85
|
+
truncated = true;
|
|
86
|
+
stop();
|
|
87
|
+
});
|
|
88
|
+
child.on('error', (error) => {
|
|
89
|
+
clearTimeout(timeout);
|
|
90
|
+
clearTimeout(killTimer);
|
|
91
|
+
reject(error);
|
|
92
|
+
});
|
|
93
|
+
const timeout = setTimeout(() => {
|
|
94
|
+
truncated = true;
|
|
95
|
+
stop();
|
|
96
|
+
}, timeoutMs);
|
|
97
|
+
timeout.unref?.();
|
|
98
|
+
child.on('close', (code) => {
|
|
99
|
+
clearTimeout(timeout);
|
|
100
|
+
clearTimeout(killTimer);
|
|
101
|
+
resolve({ code: stopped ? 0 : Number(code ?? 1), stdout: Buffer.concat(chunks).toString('utf8'), truncated });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async function gitLine(args, cwd) {
|
|
106
|
+
const result = await runGit(args, { cwd, limit: 64 * 1024 });
|
|
107
|
+
return result.code === 0 ? result.stdout.trim() : '';
|
|
108
|
+
}
|
|
109
|
+
/** Records of a `-z` stream: NUL-terminated, with a trailing empty remainder. */
|
|
110
|
+
function nulRecords(value) {
|
|
111
|
+
const records = value.split('\u0000');
|
|
112
|
+
if (records.length && records[records.length - 1] === '')
|
|
113
|
+
records.pop();
|
|
114
|
+
return records;
|
|
115
|
+
}
|
|
116
|
+
function absoluteGitDir(root, value) {
|
|
117
|
+
if (!value)
|
|
118
|
+
return '';
|
|
119
|
+
const resolved = path.isAbsolute(value) ? value : path.resolve(root, value);
|
|
120
|
+
try {
|
|
121
|
+
return fs.realpathSync(resolved);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return resolved;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The work tree holding `directory`, and the revision its changes are measured
|
|
129
|
+
* from. Returns null when the directory is not inside a git work tree at all,
|
|
130
|
+
* which is an ordinary answer rather than a failure: most directories are not.
|
|
131
|
+
*/
|
|
132
|
+
export async function readGitBaseRevision(directory) {
|
|
133
|
+
const rootLine = await gitLine(['rev-parse', '--show-toplevel'], directory);
|
|
134
|
+
if (!rootLine)
|
|
135
|
+
return null;
|
|
136
|
+
let root = rootLine;
|
|
137
|
+
try {
|
|
138
|
+
root = fs.realpathSync(rootLine);
|
|
139
|
+
}
|
|
140
|
+
catch { }
|
|
141
|
+
const [gitDir, commonDir, head, branchLine] = await Promise.all([
|
|
142
|
+
gitLine(['rev-parse', '--git-dir'], root),
|
|
143
|
+
gitLine(['rev-parse', '--git-common-dir'], root),
|
|
144
|
+
gitLine(['rev-parse', 'HEAD'], root),
|
|
145
|
+
gitLine(['rev-parse', '--abbrev-ref', 'HEAD'], root),
|
|
146
|
+
]);
|
|
147
|
+
// A repository with no commits has no HEAD to diff against and nothing a
|
|
148
|
+
// changes view could show, so it is reported as "not a comparable tree".
|
|
149
|
+
if (!head)
|
|
150
|
+
return null;
|
|
151
|
+
const branch = branchLine === 'HEAD' ? '' : branchLine;
|
|
152
|
+
const linkedWorkTree = Boolean(gitDir && commonDir && absoluteGitDir(root, gitDir) !== absoluteGitDir(root, commonDir));
|
|
153
|
+
// `origin/HEAD` is the remote's own statement of its default branch and is
|
|
154
|
+
// consulted only after the local branches, which is where a linked worktree
|
|
155
|
+
// actually diverged from.
|
|
156
|
+
const candidates = [...DEFAULT_BRANCH_REFS];
|
|
157
|
+
const originHead = await gitLine(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], root);
|
|
158
|
+
if (originHead)
|
|
159
|
+
candidates.push({ ref: `refs/remotes/${originHead}`, label: originHead });
|
|
160
|
+
let defaultLabel = '';
|
|
161
|
+
for (const candidate of candidates) {
|
|
162
|
+
const resolved = await gitLine(['rev-parse', '--verify', '--quiet', candidate.ref], root);
|
|
163
|
+
if (!resolved)
|
|
164
|
+
continue;
|
|
165
|
+
defaultLabel = candidate.label;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
const defaultShortName = defaultLabel.replace(/^origin\//u, '');
|
|
169
|
+
const onDefaultBranch = Boolean(branch) && (branch === defaultLabel || branch === defaultShortName);
|
|
170
|
+
if (!defaultLabel || onDefaultBranch) {
|
|
171
|
+
return {
|
|
172
|
+
root,
|
|
173
|
+
branch,
|
|
174
|
+
head,
|
|
175
|
+
baseLabel: 'HEAD',
|
|
176
|
+
baseOid: head,
|
|
177
|
+
onDefaultBranch: true,
|
|
178
|
+
linkedWorkTree,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const mergeBase = await gitLine(['merge-base', 'HEAD', defaultLabel], root);
|
|
182
|
+
// Unrelated histories leave nothing to compare with; fall back to HEAD so the
|
|
183
|
+
// reader still sees their uncommitted work rather than an error.
|
|
184
|
+
if (!mergeBase) {
|
|
185
|
+
return { root, branch, head, baseLabel: 'HEAD', baseOid: head, onDefaultBranch: true, linkedWorkTree };
|
|
186
|
+
}
|
|
187
|
+
return { root, branch, head, baseLabel: defaultLabel, baseOid: mergeBase, onDefaultBranch: false, linkedWorkTree };
|
|
188
|
+
}
|
|
189
|
+
/** `add TAB del TAB path`, or `add TAB del TAB` followed by the two paths of a
|
|
190
|
+
* rename. Binary files report `-` for both counts. */
|
|
191
|
+
function parseNumstat(value) {
|
|
192
|
+
const records = nulRecords(value);
|
|
193
|
+
const entries = [];
|
|
194
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
195
|
+
const record = records[index];
|
|
196
|
+
const fields = record.split('\t');
|
|
197
|
+
if (fields.length < 3)
|
|
198
|
+
continue;
|
|
199
|
+
const binary = fields[0] === '-' || fields[1] === '-';
|
|
200
|
+
const additions = binary ? 0 : Number(fields[0]) || 0;
|
|
201
|
+
const deletions = binary ? 0 : Number(fields[1]) || 0;
|
|
202
|
+
let relativePath = fields.slice(2).join('\t');
|
|
203
|
+
let oldRelativePath = '';
|
|
204
|
+
if (!relativePath) {
|
|
205
|
+
oldRelativePath = records[index + 1] || '';
|
|
206
|
+
relativePath = records[index + 2] || '';
|
|
207
|
+
index += 2;
|
|
208
|
+
}
|
|
209
|
+
if (!relativePath)
|
|
210
|
+
continue;
|
|
211
|
+
entries.push({ additions, deletions, binary, relativePath, oldRelativePath });
|
|
212
|
+
}
|
|
213
|
+
return entries;
|
|
214
|
+
}
|
|
215
|
+
/** `status NUL path`, or `Rnn NUL old NUL new` for a rename or copy. */
|
|
216
|
+
function parseNameStatus(value) {
|
|
217
|
+
const records = nulRecords(value);
|
|
218
|
+
const statuses = new Map();
|
|
219
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
220
|
+
const code = records[index] || '';
|
|
221
|
+
if (!code)
|
|
222
|
+
continue;
|
|
223
|
+
if (code.startsWith('R') || code.startsWith('C')) {
|
|
224
|
+
const target = records[index + 2] || '';
|
|
225
|
+
if (target)
|
|
226
|
+
statuses.set(target, code[0]);
|
|
227
|
+
index += 2;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const target = records[index + 1] || '';
|
|
231
|
+
if (target)
|
|
232
|
+
statuses.set(target, code[0]);
|
|
233
|
+
index += 1;
|
|
234
|
+
}
|
|
235
|
+
return statuses;
|
|
236
|
+
}
|
|
237
|
+
function changeStatusFromCode(code, renamed) {
|
|
238
|
+
if (renamed)
|
|
239
|
+
return 'renamed';
|
|
240
|
+
if (code === 'A')
|
|
241
|
+
return 'added';
|
|
242
|
+
if (code === 'D')
|
|
243
|
+
return 'deleted';
|
|
244
|
+
return 'modified';
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Lines a not-yet-tracked file would add.
|
|
248
|
+
*
|
|
249
|
+
* Git has nothing to say about a file it has never seen, and running a diff
|
|
250
|
+
* per untracked path would multiply process spawns by the size of a build
|
|
251
|
+
* directory. Counting newlines here is bounded by size and stops at the first
|
|
252
|
+
* NUL, which is also how the file browser decides a file is not text.
|
|
253
|
+
*/
|
|
254
|
+
function untrackedFileCount(absolutePath) {
|
|
255
|
+
let stat;
|
|
256
|
+
try {
|
|
257
|
+
stat = fs.statSync(absolutePath);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
// Git can name something that is not a regular file — a symlink to a
|
|
263
|
+
// directory, most often a linked `node_modules`. There is no diff for it and
|
|
264
|
+
// reporting it as a zero-line change would put a mark against a row nothing
|
|
265
|
+
// happened in.
|
|
266
|
+
if (!stat.isFile())
|
|
267
|
+
return null;
|
|
268
|
+
if (stat.size > MAX_GIT_UNTRACKED_COUNT_BYTES)
|
|
269
|
+
return { additions: 0, binary: true };
|
|
270
|
+
let contents;
|
|
271
|
+
try {
|
|
272
|
+
contents = fs.readFileSync(absolutePath);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return { additions: 0, binary: false };
|
|
276
|
+
}
|
|
277
|
+
if (contents.includes(0))
|
|
278
|
+
return { additions: 0, binary: true };
|
|
279
|
+
if (!contents.length)
|
|
280
|
+
return { additions: 0, binary: false };
|
|
281
|
+
let lines = 0;
|
|
282
|
+
for (const byte of contents)
|
|
283
|
+
if (byte === 0x0a)
|
|
284
|
+
lines += 1;
|
|
285
|
+
// A final line without its newline is still a line the diff would show.
|
|
286
|
+
return { additions: contents[contents.length - 1] === 0x0a ? lines : lines + 1, binary: false };
|
|
287
|
+
}
|
|
288
|
+
function machinePath(root, relativePath) {
|
|
289
|
+
return path.posix.join(root.split(path.sep).join('/'), relativePath);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Every path in one work tree that differs from its base revision.
|
|
293
|
+
*
|
|
294
|
+
* Tracked differences come from a single `git diff` against the base, which is
|
|
295
|
+
* what makes the answer a branch range rather than a working-copy status:
|
|
296
|
+
* commits made on the branch and edits not yet committed are both in it, in
|
|
297
|
+
* the same way a pull request shows both. Untracked files are added afterwards
|
|
298
|
+
* because git will not describe a file it has not been told about.
|
|
299
|
+
*/
|
|
300
|
+
export async function readMachineGitStatus({ requested, workingDirectory = '', allowedDirectories = [], }) {
|
|
301
|
+
const resolved = resolveBrowsePath(String(requested || ''), { workingDirectory, allowedDirectories });
|
|
302
|
+
const directory = resolved.stat.isDirectory() ? resolved.path : path.dirname(resolved.path);
|
|
303
|
+
const revision = await readGitBaseRevision(directory);
|
|
304
|
+
if (!revision)
|
|
305
|
+
return null;
|
|
306
|
+
const [numstat, nameStatus, untracked] = await Promise.all([
|
|
307
|
+
runGit(['diff', '--numstat', '-z', '--find-renames', '--no-color', revision.baseOid, '--'], { cwd: revision.root }),
|
|
308
|
+
runGit(['diff', '--name-status', '-z', '--find-renames', '--no-color', revision.baseOid, '--'], {
|
|
309
|
+
cwd: revision.root,
|
|
310
|
+
}),
|
|
311
|
+
runGit(['ls-files', '-z', '--others', '--exclude-standard'], { cwd: revision.root }),
|
|
312
|
+
]);
|
|
313
|
+
const statuses = parseNameStatus(nameStatus.stdout);
|
|
314
|
+
const files = [];
|
|
315
|
+
for (const entry of parseNumstat(numstat.stdout)) {
|
|
316
|
+
const code = statuses.get(entry.relativePath) || 'M';
|
|
317
|
+
const status = changeStatusFromCode(code, Boolean(entry.oldRelativePath));
|
|
318
|
+
files.push({
|
|
319
|
+
path: machinePath(revision.root, entry.relativePath),
|
|
320
|
+
relativePath: entry.relativePath,
|
|
321
|
+
status,
|
|
322
|
+
additions: entry.additions,
|
|
323
|
+
deletions: entry.deletions,
|
|
324
|
+
...(entry.binary ? { binary: true } : {}),
|
|
325
|
+
...(entry.oldRelativePath ? { oldRelativePath: entry.oldRelativePath } : {}),
|
|
326
|
+
});
|
|
327
|
+
if (files.length >= MAX_GIT_STATUS_FILES)
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
let truncated = numstat.truncated || nameStatus.truncated || untracked.truncated || files.length >= MAX_GIT_STATUS_FILES;
|
|
331
|
+
for (const relativePath of nulRecords(untracked.stdout)) {
|
|
332
|
+
if (!relativePath)
|
|
333
|
+
continue;
|
|
334
|
+
if (files.length >= MAX_GIT_STATUS_FILES) {
|
|
335
|
+
truncated = true;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
const absolute = machinePath(revision.root, relativePath);
|
|
339
|
+
const counted = untrackedFileCount(absolute);
|
|
340
|
+
if (!counted)
|
|
341
|
+
continue;
|
|
342
|
+
files.push({
|
|
343
|
+
path: absolute,
|
|
344
|
+
relativePath,
|
|
345
|
+
status: 'untracked',
|
|
346
|
+
additions: counted.additions,
|
|
347
|
+
deletions: 0,
|
|
348
|
+
...(counted.binary ? { binary: true } : {}),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
root: revision.root,
|
|
353
|
+
branch: revision.branch,
|
|
354
|
+
head: revision.head,
|
|
355
|
+
baseLabel: revision.baseLabel,
|
|
356
|
+
baseOid: revision.baseOid,
|
|
357
|
+
onDefaultBranch: revision.onDefaultBranch,
|
|
358
|
+
linkedWorkTree: revision.linkedWorkTree,
|
|
359
|
+
files,
|
|
360
|
+
additions: files.reduce((total, file) => total + file.additions, 0),
|
|
361
|
+
deletions: files.reduce((total, file) => total + file.deletions, 0),
|
|
362
|
+
truncated,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* One unified patch for the whole work tree, as a review would read it.
|
|
367
|
+
*
|
|
368
|
+
* Untracked files are appended as separate `--no-index` patches against
|
|
369
|
+
* `/dev/null`, which is the same shape git emits for a new file in an ordinary
|
|
370
|
+
* diff — so the browser's existing unified-diff parser reads both without
|
|
371
|
+
* knowing which came from where. Only as many as fit under the byte ceiling
|
|
372
|
+
* are asked for; a patch this long has already stopped being reviewable.
|
|
373
|
+
*/
|
|
374
|
+
export async function readMachineGitPatch({ requested, workingDirectory = '', allowedDirectories = [], }) {
|
|
375
|
+
const resolved = resolveBrowsePath(String(requested || ''), { workingDirectory, allowedDirectories });
|
|
376
|
+
const directory = resolved.stat.isDirectory() ? resolved.path : path.dirname(resolved.path);
|
|
377
|
+
const revision = await readGitBaseRevision(directory);
|
|
378
|
+
if (!revision)
|
|
379
|
+
return null;
|
|
380
|
+
const tracked = await runGit(['diff', '--patch', '--no-color', '--find-renames', '--no-ext-diff', revision.baseOid, '--'], { cwd: revision.root });
|
|
381
|
+
let patch = tracked.stdout;
|
|
382
|
+
let truncated = tracked.truncated;
|
|
383
|
+
if (!truncated) {
|
|
384
|
+
const untracked = await runGit(['ls-files', '-z', '--others', '--exclude-standard'], { cwd: revision.root });
|
|
385
|
+
truncated = untracked.truncated;
|
|
386
|
+
for (const relativePath of nulRecords(untracked.stdout)) {
|
|
387
|
+
if (!relativePath)
|
|
388
|
+
continue;
|
|
389
|
+
if (patch.length >= MAX_GIT_TEXT_BYTES) {
|
|
390
|
+
truncated = true;
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
// `--no-index` against /dev/null exits 1 on every difference, which is
|
|
394
|
+
// the normal outcome here rather than a failure.
|
|
395
|
+
const added = await runGit(['diff', '--no-color', '--no-ext-diff', '--no-index', '--', '/dev/null', relativePath], { cwd: revision.root, limit: Math.max(0, MAX_GIT_TEXT_BYTES - patch.length) });
|
|
396
|
+
if (added.truncated)
|
|
397
|
+
truncated = true;
|
|
398
|
+
if (added.stdout)
|
|
399
|
+
patch += added.stdout;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
root: revision.root,
|
|
404
|
+
baseLabel: revision.baseLabel,
|
|
405
|
+
baseOid: revision.baseOid,
|
|
406
|
+
patch: patch.slice(0, MAX_GIT_TEXT_BYTES),
|
|
407
|
+
truncated: truncated || patch.length > MAX_GIT_TEXT_BYTES,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* One file as the base revision holds it, for the viewer's inline diff.
|
|
412
|
+
*
|
|
413
|
+
* A file that did not exist at the base is a normal answer — it is what the
|
|
414
|
+
* viewer needs in order to show the whole file as added — so a missing blob
|
|
415
|
+
* reports `found: false` rather than raising. Bytes that are not decodable
|
|
416
|
+
* text are reported as binary instead of being rendered as replacement noise.
|
|
417
|
+
*/
|
|
418
|
+
export async function readMachineGitBaseBlob({ requested, workingDirectory = '', allowedDirectories = [], }) {
|
|
419
|
+
const resolved = resolveBrowsePath(String(requested || ''), { workingDirectory, allowedDirectories });
|
|
420
|
+
if (!resolved.stat.isFile())
|
|
421
|
+
throw new Error(`Not a file: ${resolved.path}`);
|
|
422
|
+
const revision = await readGitBaseRevision(path.dirname(resolved.path));
|
|
423
|
+
if (!revision)
|
|
424
|
+
return null;
|
|
425
|
+
const relative = path.relative(revision.root, resolved.path).split(path.sep).join('/');
|
|
426
|
+
if (!relative || relative.startsWith('..'))
|
|
427
|
+
return null;
|
|
428
|
+
const shown = await runGit(['show', `${revision.baseOid}:${relative}`], { cwd: revision.root });
|
|
429
|
+
const found = shown.code === 0;
|
|
430
|
+
const binary = found && shown.stdout.includes('\u0000');
|
|
431
|
+
return {
|
|
432
|
+
path: resolved.path,
|
|
433
|
+
baseLabel: revision.baseLabel,
|
|
434
|
+
baseOid: revision.baseOid,
|
|
435
|
+
found,
|
|
436
|
+
text: found && !binary ? shown.stdout : '',
|
|
437
|
+
truncated: found && shown.truncated,
|
|
438
|
+
binary,
|
|
439
|
+
};
|
|
440
|
+
}
|
package/dist/path-resolution.js
CHANGED
|
@@ -169,6 +169,17 @@ function uploadHeaders(options) {
|
|
|
169
169
|
? { 'x-turns-dev-user': options.devUser, 'x-turns-dev-role': 'uploader' }
|
|
170
170
|
: { authorization: `Bearer ${options.token}` };
|
|
171
171
|
}
|
|
172
|
+
/** Keep directory separators readable while escaping characters that would
|
|
173
|
+
* otherwise change the URL's path, query, or fragment. */
|
|
174
|
+
function fileUploadUrl(baseUrl, machine, absolutePath) {
|
|
175
|
+
const encoded = absolutePath.split('/').map(encodeURIComponent).join('/');
|
|
176
|
+
const encodedPath = encoded.startsWith('//')
|
|
177
|
+
? `%2F%2F${encoded.slice(2)}`
|
|
178
|
+
: encoded.startsWith('/')
|
|
179
|
+
? encoded.slice(1)
|
|
180
|
+
: encoded;
|
|
181
|
+
return new URL(`/api/file/${encodeURIComponent(machine)}/${encodedPath}`, String(baseUrl));
|
|
182
|
+
}
|
|
172
183
|
/**
|
|
173
184
|
* Put bytes in the account's private prefix, under the path they came from.
|
|
174
185
|
*
|
|
@@ -177,7 +188,7 @@ function uploadHeaders(options) {
|
|
|
177
188
|
* the upload is not part of what was stored.
|
|
178
189
|
*/
|
|
179
190
|
export async function uploadFileBytes({ baseUrl, options, machine, absolutePath, contentType, modifiedAt, body, fetchImpl = fetch, }) {
|
|
180
|
-
const url =
|
|
191
|
+
const url = fileUploadUrl(baseUrl, machine, absolutePath);
|
|
181
192
|
const response = await fetchImpl(url, {
|
|
182
193
|
method: 'PUT',
|
|
183
194
|
headers: {
|
|
@@ -194,7 +205,7 @@ export async function uploadFileBytes({ baseUrl, options, machine, absolutePath,
|
|
|
194
205
|
}
|
|
195
206
|
/** Stream one on-disk file to R2 without retaining its body in the daemon. */
|
|
196
207
|
export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, contentEtag, fetchImpl = fetch, }) {
|
|
197
|
-
const url =
|
|
208
|
+
const url = fileUploadUrl(baseUrl, machine, absolutePath);
|
|
198
209
|
const body = fs.createReadStream(absolutePath);
|
|
199
210
|
try {
|
|
200
211
|
const response = await fetchImpl(url, {
|
package/dist/queue-worker.js
CHANGED
|
@@ -8,6 +8,8 @@ import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentT
|
|
|
8
8
|
import { assertAllowedWorkingDirectory, workingDirectoryIsAllowed } from "./directory-policy.js";
|
|
9
9
|
import { browseMachinePath } from "./file-browsing.js";
|
|
10
10
|
import { applyFileEdit } from "./file-edits.js";
|
|
11
|
+
import { searchMachinePaths } from "./file-search.js";
|
|
12
|
+
import { readMachineGitBaseBlob, readMachineGitPatch, readMachineGitStatus } from "./git-status.js";
|
|
11
13
|
import { nativeDescriptorAgentSessionId, nativeDescriptorWorkingDirectory } from "./local-session-sources.js";
|
|
12
14
|
import { compactRuntimeValue } from "./native-record-compaction.js";
|
|
13
15
|
import { createSecretRedactor } from "./secret-redaction.js";
|
|
@@ -1069,6 +1071,82 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1069
1071
|
});
|
|
1070
1072
|
}
|
|
1071
1073
|
};
|
|
1074
|
+
/** Refresh recursive path-name matches for directory typeahead.
|
|
1075
|
+
*
|
|
1076
|
+
* Like browsing, this is a machine filesystem operation rather than a
|
|
1077
|
+
* harness operation, so stopped sessions remain searchable. The helper
|
|
1078
|
+
* canonicalizes and allowlists the directory before starting a bounded
|
|
1079
|
+
* ripgrep/find walk; only metadata crosses the control socket. */
|
|
1080
|
+
const handleFileSearch = async (message, replyRpc) => {
|
|
1081
|
+
const sessionId = String(message.sessionId || '');
|
|
1082
|
+
try {
|
|
1083
|
+
const searched = await searchMachinePaths({
|
|
1084
|
+
requested: String(message.directory || ''),
|
|
1085
|
+
query: String(message.query || ''),
|
|
1086
|
+
workingDirectory: await browseWorkingDirectory(sessionId),
|
|
1087
|
+
allowedDirectories: options.allowedDirectories || [],
|
|
1088
|
+
});
|
|
1089
|
+
replyRpc.notify({
|
|
1090
|
+
type: 'session.file.search.result',
|
|
1091
|
+
requestId: message.requestId,
|
|
1092
|
+
sessionId,
|
|
1093
|
+
ok: true,
|
|
1094
|
+
...searched,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
catch (error) {
|
|
1098
|
+
replyRpc.notify({
|
|
1099
|
+
type: 'session.file.search.result',
|
|
1100
|
+
requestId: message.requestId,
|
|
1101
|
+
sessionId,
|
|
1102
|
+
ok: false,
|
|
1103
|
+
error: redactor.redactText(error?.message || String(error)),
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
/**
|
|
1108
|
+
* Answer one git question about a path on this machine.
|
|
1109
|
+
*
|
|
1110
|
+
* Like browsing and searching, this is a property of the filesystem rather
|
|
1111
|
+
* than of a harness, so a stopped session's tree remains readable. A path
|
|
1112
|
+
* that is not inside a work tree answers `ok` with nothing attached: the
|
|
1113
|
+
* browser needs to tell "not a repository" apart from "the machine failed",
|
|
1114
|
+
* and only the first should leave its git column quietly empty.
|
|
1115
|
+
*/
|
|
1116
|
+
const handleGitRead = async (message, replyRpc) => {
|
|
1117
|
+
const sessionId = String(message.sessionId || '');
|
|
1118
|
+
const want = message.want === 'diff' ? 'diff' : message.want === 'base' ? 'base' : 'status';
|
|
1119
|
+
try {
|
|
1120
|
+
const input = {
|
|
1121
|
+
requested: String(message.path || ''),
|
|
1122
|
+
workingDirectory: await browseWorkingDirectory(sessionId),
|
|
1123
|
+
allowedDirectories: options.allowedDirectories || [],
|
|
1124
|
+
};
|
|
1125
|
+
const git = want === 'diff'
|
|
1126
|
+
? await readMachineGitPatch(input).then((patch) => (patch ? { patch } : null))
|
|
1127
|
+
: want === 'base'
|
|
1128
|
+
? await readMachineGitBaseBlob(input).then((base) => (base ? { base } : null))
|
|
1129
|
+
: await readMachineGitStatus(input).then((status) => (status ? { status } : null));
|
|
1130
|
+
replyRpc.notify({
|
|
1131
|
+
type: 'session.git.read.result',
|
|
1132
|
+
requestId: message.requestId,
|
|
1133
|
+
sessionId,
|
|
1134
|
+
ok: true,
|
|
1135
|
+
want,
|
|
1136
|
+
git,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
catch (error) {
|
|
1140
|
+
replyRpc.notify({
|
|
1141
|
+
type: 'session.git.read.result',
|
|
1142
|
+
requestId: message.requestId,
|
|
1143
|
+
sessionId,
|
|
1144
|
+
ok: false,
|
|
1145
|
+
want,
|
|
1146
|
+
error: redactor.redactText(error?.message || String(error)),
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1072
1150
|
/**
|
|
1073
1151
|
* Apply one queued browser edit, and report how it went.
|
|
1074
1152
|
*
|
|
@@ -1139,6 +1217,10 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1139
1217
|
void handleInteractionAnswer(message, rpc);
|
|
1140
1218
|
if (message.type === 'session.file.browse.request')
|
|
1141
1219
|
void handleFileBrowse(message, rpc);
|
|
1220
|
+
if (message.type === 'session.file.search.request')
|
|
1221
|
+
void handleFileSearch(message, rpc);
|
|
1222
|
+
if (message.type === 'session.git.read.request')
|
|
1223
|
+
void handleGitRead(message, rpc);
|
|
1142
1224
|
if (message.type === 'session.file.edit.request')
|
|
1143
1225
|
void handleFileEdit(message, rpc);
|
|
1144
1226
|
});
|
|
@@ -1153,6 +1235,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1153
1235
|
type: 'machine.register',
|
|
1154
1236
|
machineId: machine,
|
|
1155
1237
|
machineHostname: os.hostname(),
|
|
1238
|
+
capabilities: { fileSearch: true, git: true },
|
|
1156
1239
|
workerId,
|
|
1157
1240
|
workerInstanceId,
|
|
1158
1241
|
anchorSessionIds: [...anchors.keys()],
|