@agou/dsh-worktrees 0.0.0-stage → 1.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 +40 -0
- package/README.md +86 -2
- package/client.js +958 -0
- package/cordis.patch.yml +6 -0
- package/discovery.js +232 -0
- package/host.js +412 -0
- package/icon.svg +18 -0
- package/index.js +215 -0
- package/lib/git.js +186 -0
- package/lib/manager.js +674 -0
- package/lib/snapshot.js +211 -0
- package/lib/state.js +97 -0
- package/locale/en.json +67 -0
- package/locale/zh.json +67 -0
- package/package.json +41 -4
- package/transport.js +30 -0
package/index.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { createInitialState, validateState } from './lib/manager.js';
|
|
5
|
+
import { WorktreeHost } from './host.js';
|
|
6
|
+
import { registerTransport } from './transport.js';
|
|
7
|
+
|
|
8
|
+
export const name = 'worktrees';
|
|
9
|
+
export const inject = ['storageDomain', 'workspaceRegistry', 'sessionController', 'agents', 'workingDirectory', 'connection', 'settings', 'systemPrompt', 'tools'];
|
|
10
|
+
export const Config = Schema.object({
|
|
11
|
+
directory: Schema.string().description('Absolute directory for newly created managed worktrees.').volatile(),
|
|
12
|
+
maxRetained: Schema.number().min(1).max(Number.MAX_SAFE_INTEGER).step(1).default(15).description('Eligible worktrees retained after protected checkouts are excluded.').volatile(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const stateSchema = z.unknown().transform((value, context) => {
|
|
16
|
+
try { return validateState(value); }
|
|
17
|
+
catch (error) {
|
|
18
|
+
context.addIssue({ code: 'custom', message: error instanceof Error ? error.message : String(error) });
|
|
19
|
+
return z.NEVER;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
const domainSpec = {
|
|
23
|
+
name: 'dsh_worktrees',
|
|
24
|
+
version: 1,
|
|
25
|
+
tables: {},
|
|
26
|
+
global: { schema: stateSchema, initial: createInitialState() },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Strict tool schemas may require unused optional fields to be present.
|
|
30
|
+
const optionalText = maximum => z.preprocess(value => value === '' ? undefined : value, z.string().min(1).max(maximum).optional());
|
|
31
|
+
const optionalRetention = z.preprocess(value => value === 0 ? undefined : value, z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional());
|
|
32
|
+
const optionalRevision = z.preprocess(value => value === -1 ? undefined : value, z.number().int().nonnegative().optional());
|
|
33
|
+
|
|
34
|
+
export function registerInfoTool(ctx, host) {
|
|
35
|
+
const input = z.object({
|
|
36
|
+
action: z.enum(['status', 'list', 'branches']),
|
|
37
|
+
workspace_id: optionalText(256),
|
|
38
|
+
offset: z.number().int().nonnegative().default(0),
|
|
39
|
+
limit: z.number().int().min(1).max(50).default(20),
|
|
40
|
+
}).strict();
|
|
41
|
+
ctx.tools.register({
|
|
42
|
+
name: 'worktree_info',
|
|
43
|
+
description: 'Inspect the current session worktree, list managed worktrees, or list a workspace’s existing Git branches. Reads only; does not create, checkout, restore, delete, or change settings. Lists are paginated with offset and limit.',
|
|
44
|
+
parameters: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
action: { type: 'string', enum: ['status', 'list', 'branches'] },
|
|
48
|
+
workspace_id: { type: 'string', description: 'Registered workspace id. Omit or use an empty string for the current session workspace.' },
|
|
49
|
+
offset: { type: 'integer', minimum: 0, default: 0 },
|
|
50
|
+
limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 },
|
|
51
|
+
},
|
|
52
|
+
required: ['action'],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
},
|
|
55
|
+
output: {
|
|
56
|
+
schema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'], additionalProperties: false },
|
|
57
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
58
|
+
},
|
|
59
|
+
async execute(args, exec) {
|
|
60
|
+
const request = input.parse(args);
|
|
61
|
+
if (!exec.agent) throw new Error('A session is required to inspect its worktree.');
|
|
62
|
+
const workspace = request.workspace_id ? host.workspace(request.workspace_id) : host.workspaceFor(exec.agent.session);
|
|
63
|
+
if (request.action === 'branches') {
|
|
64
|
+
if (!workspace) return { text: 'This session has no registered workspace or managed worktree.' };
|
|
65
|
+
const info = await host.manager.inspectWorkspace(workspace, exec.signal);
|
|
66
|
+
return { text: JSON.stringify({ workspaceId: workspace.workspaceId, currentBranch: info.currentBranch, head: info.head, defaultBase: info.defaultBase, branches: info.branches.slice(request.offset, request.offset + request.limit), total: info.branches.length }) };
|
|
67
|
+
}
|
|
68
|
+
const state = host.manager.list();
|
|
69
|
+
const binding = state.bindings[exec.agent.id];
|
|
70
|
+
const rows = request.action === 'status'
|
|
71
|
+
? (binding?.worktreeId ? [state.worktrees[binding.worktreeId]] : [])
|
|
72
|
+
: Object.values(state.worktrees).filter(record => record.workspaceId === workspace?.workspaceId);
|
|
73
|
+
return { text: JSON.stringify({ workspaceId: workspace?.workspaceId ?? null, binding: binding ?? null, records: rows.slice(request.offset, request.offset + request.limit).map(({ token: _token, ...record }) => record), total: rows.length, config: host.configuration(), revision: host.descriptor().revision }) };
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function registerManagementTool(ctx, host) {
|
|
79
|
+
const input = z.object({
|
|
80
|
+
action: z.enum(['prepare', 'checkout', 'restore', 'remove', 'configure']),
|
|
81
|
+
session_id: optionalText(256),
|
|
82
|
+
workspace_id: optionalText(256),
|
|
83
|
+
worktree_id: optionalText(256),
|
|
84
|
+
mode: z.enum(['local', 'new']).optional(),
|
|
85
|
+
branch: optionalText(4096),
|
|
86
|
+
directory: optionalText(32767),
|
|
87
|
+
max_retained: optionalRetention,
|
|
88
|
+
expected_revision: optionalRevision,
|
|
89
|
+
}).strict();
|
|
90
|
+
ctx.tools.register({
|
|
91
|
+
name: 'worktree_manage',
|
|
92
|
+
description: 'Request a managed worktree operation. prepare selects Local or a detached worktree for a blank session before its first message; it does not send a message. checkout immediately changes the source workspace branch. restore recovers a saved snapshot; remove snapshots first and archives linked chats. configure changes the new-worktree directory or soft retention count. Each mutation uses the Host’s normal approval flow; this tool cannot approve its own request.',
|
|
93
|
+
parameters: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
action: { type: 'string', enum: ['prepare', 'checkout', 'restore', 'remove', 'configure'] },
|
|
97
|
+
session_id: { type: 'string', description: 'Session for prepare. Omit or use an empty string for the calling session; new selection requires a blank session.' },
|
|
98
|
+
workspace_id: { type: 'string', description: 'Registered source workspace for checkout. Omit or use an empty string for the calling session workspace.' },
|
|
99
|
+
worktree_id: { type: 'string', description: 'Managed id from worktree_info, required for restore/remove. Use an empty string for other actions.' },
|
|
100
|
+
mode: { type: 'string', enum: ['local', 'new'], description: 'Required for prepare; ignored for other actions.' },
|
|
101
|
+
branch: { type: 'string', description: 'Existing local branch for checkout, or base ref for prepare(new). Omit or use an empty string when unused.' },
|
|
102
|
+
directory: { type: 'string', description: 'Absolute Host path for future worktrees, for configure. Omit or use an empty string to keep the current directory.' },
|
|
103
|
+
max_retained: { type: 'integer', minimum: 0, description: 'Soft eligible-worktree retention target, for configure. Omit or use 0 to keep the current target.' },
|
|
104
|
+
expected_revision: { type: 'integer', minimum: -1, description: 'Settings revision from worktree_info. Omit or use -1 for the current revision; 0 is a valid explicit revision.' },
|
|
105
|
+
},
|
|
106
|
+
required: ['action'],
|
|
107
|
+
additionalProperties: false,
|
|
108
|
+
},
|
|
109
|
+
output: {
|
|
110
|
+
schema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'], additionalProperties: false },
|
|
111
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
112
|
+
},
|
|
113
|
+
async execute(args, exec) {
|
|
114
|
+
const request = input.parse(args);
|
|
115
|
+
if (!exec.agent) throw new Error('A calling session is required for a worktree operation.');
|
|
116
|
+
let result;
|
|
117
|
+
switch (request.action) {
|
|
118
|
+
case 'prepare': {
|
|
119
|
+
if (!request.mode) throw new Error('prepare requires mode.');
|
|
120
|
+
const caller = { id: `tool:${exec.callId}`, signal: exec.signal };
|
|
121
|
+
const operation = { sessionId: request.session_id ?? exec.agent.id, operationId: exec.callId, choice: { mode: request.mode, ...(request.branch ? { baseRef: request.branch } : {}) } };
|
|
122
|
+
try { result = await host.prepare(operation, caller, exec.signal); }
|
|
123
|
+
finally { await host.settle(operation, caller); }
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case 'checkout': {
|
|
127
|
+
if (!request.branch) throw new Error('checkout requires branch.');
|
|
128
|
+
const workspace = request.workspace_id ? host.workspace(request.workspace_id) : host.workspaceFor(exec.agent.session);
|
|
129
|
+
if (!workspace) throw new Error('This session has no registered workspace.');
|
|
130
|
+
const value = await host.manager.checkout(workspace, request.branch, exec.signal);
|
|
131
|
+
result = { workspaceId: value.workspaceId, currentBranch: value.currentBranch, head: value.head };
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case 'restore':
|
|
135
|
+
case 'remove':
|
|
136
|
+
if (!request.worktree_id) throw new Error(`${request.action} requires worktree_id.`);
|
|
137
|
+
result = await host[request.action](request.worktree_id, exec.signal);
|
|
138
|
+
break;
|
|
139
|
+
case 'configure':
|
|
140
|
+
result = await host.configure({
|
|
141
|
+
...(request.directory ? { directory: request.directory } : {}),
|
|
142
|
+
...(request.max_retained !== undefined ? { maxRetained: request.max_retained } : {}),
|
|
143
|
+
expectedRevision: request.expected_revision ?? host.descriptor().revision,
|
|
144
|
+
});
|
|
145
|
+
break;
|
|
146
|
+
default: throw new Error('Unknown worktree action.');
|
|
147
|
+
}
|
|
148
|
+
return { text: JSON.stringify(result) };
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
152
|
+
const decision = await next();
|
|
153
|
+
if (exec.name !== 'worktree_manage' || decision.kind !== 'allow') return decision;
|
|
154
|
+
const action = input.parse(exec.arguments).action;
|
|
155
|
+
return {
|
|
156
|
+
kind: 'ask',
|
|
157
|
+
reason: `Allow managed worktree operation: ${action}`,
|
|
158
|
+
displayReason: { en: `Allow managed worktree operation: ${action}`, zh: `允许执行工作树操作:${action}` },
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function registerHostEvents(ctx, host) {
|
|
164
|
+
ctx.on('session/event', (session, event) => {
|
|
165
|
+
host.observe(session, event);
|
|
166
|
+
if (event.type === 'turn/end') host.requestCleanup();
|
|
167
|
+
if (event.type === 'working-directory/change') host.requestDiscovery(true);
|
|
168
|
+
});
|
|
169
|
+
ctx.on('domain/changed', change => {
|
|
170
|
+
if (change.domain !== 'workspace') return;
|
|
171
|
+
host.requestCleanup();
|
|
172
|
+
host.requestDiscovery(true);
|
|
173
|
+
});
|
|
174
|
+
ctx.on('agent/created', async ({ agent, signal }) => {
|
|
175
|
+
host.reconciled.delete(agent.id);
|
|
176
|
+
await host.inherit(agent, signal);
|
|
177
|
+
await host.reconcile(agent.session);
|
|
178
|
+
});
|
|
179
|
+
ctx.on('session/disposed', session => {
|
|
180
|
+
host.reconciled.delete(session.id);
|
|
181
|
+
host.accepting.delete(session.id);
|
|
182
|
+
});
|
|
183
|
+
// This guard runs before native ensure() can recover a missing cwd to its original workspace.
|
|
184
|
+
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
|
185
|
+
if (context.agent) await host.assertAvailable(context.agent, context.signal);
|
|
186
|
+
return next();
|
|
187
|
+
}, { prepend: true });
|
|
188
|
+
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
|
189
|
+
await host.assertAvailable(agent, signal);
|
|
190
|
+
return next();
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function apply(ctx, config) {
|
|
195
|
+
if (!path.isAbsolute(config.directory.get())) throw new Error('dsh-worktrees: directory must be an absolute path.');
|
|
196
|
+
const domain = await ctx.storageDomain.open(domainSpec);
|
|
197
|
+
const host = new WorktreeHost(ctx, config, { get: () => domain.global.get(), set: value => domain.global.set(value) });
|
|
198
|
+
ctx.effect(() => async () => {
|
|
199
|
+
await host.dispose();
|
|
200
|
+
await domain.close();
|
|
201
|
+
});
|
|
202
|
+
ctx.effect(() => ctx.settings.configure({ auto: false }, ctx.fiber));
|
|
203
|
+
registerTransport(ctx, host);
|
|
204
|
+
registerHostEvents(ctx, host);
|
|
205
|
+
ctx.systemPrompt.context({
|
|
206
|
+
name: 'worktrees:current',
|
|
207
|
+
order: ctx.systemPrompt.getContextOrder('WORKING_DIRECTORY') + 1,
|
|
208
|
+
interpolate: false,
|
|
209
|
+
required: true,
|
|
210
|
+
text: context => host.context(context.agent),
|
|
211
|
+
});
|
|
212
|
+
registerInfoTool(ctx, host);
|
|
213
|
+
registerManagementTool(ctx, host);
|
|
214
|
+
await host.start();
|
|
215
|
+
}
|
package/lib/git.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const MAX_OUTPUT = 64 * 1024 * 1024;
|
|
7
|
+
const TIMEOUT = 60_000;
|
|
8
|
+
|
|
9
|
+
export class WorktreeError extends Error {
|
|
10
|
+
constructor(code, message, cause) {
|
|
11
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
12
|
+
this.name = 'WorktreeError';
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function fail(code, message) {
|
|
18
|
+
throw new WorktreeError(code, message);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function samePath(left, right) {
|
|
22
|
+
const normalize = value => process.platform === 'win32' ? path.resolve(value).toLowerCase() : path.resolve(value);
|
|
23
|
+
return normalize(left) === normalize(right);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function inside(parent, child) {
|
|
27
|
+
const relative = path.relative(parent, child);
|
|
28
|
+
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function exists(target) {
|
|
32
|
+
try {
|
|
33
|
+
return await fs.lstat(target);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT') return undefined;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function canonical(target) {
|
|
41
|
+
if (typeof target !== 'string' || !path.isAbsolute(target) || target.includes('\0')) {
|
|
42
|
+
fail('INVALID_PATH', 'The directory must be an absolute filesystem path.');
|
|
43
|
+
}
|
|
44
|
+
return fs.realpath(target);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function gitEnvironment(overrides = {}) {
|
|
48
|
+
const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key)));
|
|
49
|
+
return {
|
|
50
|
+
...environment,
|
|
51
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
52
|
+
GIT_NO_LAZY_FETCH: '1',
|
|
53
|
+
GIT_NO_REPLACE_OBJECTS: '1',
|
|
54
|
+
GIT_LFS_SKIP_SMUDGE: '1',
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every Git argument is literal. Repository selectors and injected Git configuration are discarded. */
|
|
60
|
+
export async function git(cwd, args, options = {}) {
|
|
61
|
+
const { signal, allowed = [0], input, env, maxOutput = MAX_OUTPUT, timeout = TIMEOUT } = options;
|
|
62
|
+
signal?.throwIfAborted();
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
let failure;
|
|
65
|
+
let stdoutSize = 0;
|
|
66
|
+
let stderrSize = 0;
|
|
67
|
+
const stdout = [];
|
|
68
|
+
const stderr = [];
|
|
69
|
+
const child = spawn('git', [
|
|
70
|
+
'-c', 'core.hooksPath=/dev/null',
|
|
71
|
+
'-c', 'core.fsmonitor=false',
|
|
72
|
+
'-c', 'core.untrackedCache=false',
|
|
73
|
+
'-c', 'submodule.recurse=false',
|
|
74
|
+
'-c', 'gc.auto=0',
|
|
75
|
+
'-c', 'maintenance.auto=false',
|
|
76
|
+
...args,
|
|
77
|
+
], {
|
|
78
|
+
cwd,
|
|
79
|
+
env: gitEnvironment(env),
|
|
80
|
+
shell: false,
|
|
81
|
+
windowsHide: true,
|
|
82
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
83
|
+
});
|
|
84
|
+
const stop = error => {
|
|
85
|
+
failure ??= error;
|
|
86
|
+
child.kill();
|
|
87
|
+
};
|
|
88
|
+
const abort = () => stop(signal.reason ?? new WorktreeError('ABORTED', 'The Git operation was cancelled.'));
|
|
89
|
+
const timer = setTimeout(() => stop(new WorktreeError('GIT_TIMEOUT', `Git ${args[0]} exceeded ${timeout} ms.`)), timeout);
|
|
90
|
+
timer.unref();
|
|
91
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
92
|
+
if (signal?.aborted) abort();
|
|
93
|
+
child.on('error', error => { failure ??= error; });
|
|
94
|
+
child.stdout.on('data', data => {
|
|
95
|
+
stdoutSize += data.length;
|
|
96
|
+
if (stdoutSize > maxOutput) stop(new WorktreeError('OUTPUT_LIMIT', 'Git output exceeded the safe capture limit.'));
|
|
97
|
+
else stdout.push(data);
|
|
98
|
+
});
|
|
99
|
+
child.stderr.on('data', data => {
|
|
100
|
+
stderrSize += data.length;
|
|
101
|
+
if (stderrSize > maxOutput) stop(new WorktreeError('OUTPUT_LIMIT', 'Git error output exceeded the safe capture limit.'));
|
|
102
|
+
else stderr.push(data);
|
|
103
|
+
});
|
|
104
|
+
child.stdin.on('error', error => {
|
|
105
|
+
if (error.code !== 'EPIPE' && error.code !== 'ERR_STREAM_DESTROYED') stop(error);
|
|
106
|
+
});
|
|
107
|
+
child.on('close', code => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
signal?.removeEventListener('abort', abort);
|
|
110
|
+
const output = Buffer.concat(stdout);
|
|
111
|
+
if (failure) return reject(failure);
|
|
112
|
+
if (!allowed.includes(code)) {
|
|
113
|
+
return reject(new WorktreeError('GIT_FAILED', `Git ${args[0]} failed (${code}): ${Buffer.concat(stderr).toString('utf8').trim().slice(0, 4096)}`));
|
|
114
|
+
}
|
|
115
|
+
resolve({ stdout: output.toString('utf8'), bytes: output, code });
|
|
116
|
+
});
|
|
117
|
+
child.stdin.end(input);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function filterOverrides(cwd, signal) {
|
|
122
|
+
const configured = await git(cwd, ['config', '--null', '--name-only', '--get-regexp', '^filter\\.'], { signal, allowed: [0, 1] });
|
|
123
|
+
const drivers = new Set();
|
|
124
|
+
for (const key of configured.stdout.split('\0')) {
|
|
125
|
+
const separator = key.lastIndexOf('.');
|
|
126
|
+
if (separator > 7 && ['clean', 'smudge', 'process', 'required'].includes(key.slice(separator + 1))) drivers.add(key.slice(7, separator));
|
|
127
|
+
}
|
|
128
|
+
return [...drivers].flatMap(driver => ['clean', 'smudge', 'process', 'required'].flatMap(attribute => [
|
|
129
|
+
'-c', `filter.${driver}.${attribute}=${attribute === 'required' ? 'false' : ''}`,
|
|
130
|
+
]));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function repository(directory, signal) {
|
|
134
|
+
const real = await canonical(directory);
|
|
135
|
+
const root = (await git(real, ['rev-parse', '--show-toplevel'], { signal })).stdout.replace(/\r?\n$/, '');
|
|
136
|
+
const common = (await git(real, ['rev-parse', '--path-format=absolute', '--git-common-dir'], { signal })).stdout.replace(/\r?\n$/, '');
|
|
137
|
+
return { root: await canonical(root), commonDir: await canonical(common) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function commitAt(cwd, ref, signal, optional = false) {
|
|
141
|
+
const result = await git(cwd, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], { signal, allowed: optional ? [0, 128] : [0] });
|
|
142
|
+
if (result.code !== 0) return null;
|
|
143
|
+
const value = result.stdout.trim();
|
|
144
|
+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value)) fail('INVALID_OBJECT', 'Git returned an invalid commit identity.');
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Parse Git's NUL-delimited protocol, rejecting filenames that cannot round-trip safely. */
|
|
149
|
+
export function nulRecords(buffer) {
|
|
150
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
151
|
+
return decoder.decode(buffer).split('\0').filter(Boolean);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function treeEntries(buffer) {
|
|
155
|
+
return nulRecords(buffer).map(line => {
|
|
156
|
+
const match = /^(\d{6}) (blob|tree|commit) ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/.exec(line);
|
|
157
|
+
if (!match) fail('INVALID_TREE', 'Git returned an invalid tree entry.');
|
|
158
|
+
return { mode: match[1], type: match[2], oid: match[3], name: match[4] };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function safeChild(root, name) {
|
|
163
|
+
const parts = typeof name === 'string' ? name.split('/') : [];
|
|
164
|
+
const unsafeWindows = process.platform === 'win32' && parts.some(part => /[:<>"|?*\u0000-\u001f]/.test(part) || /[. ]$/.test(part) || /^(?:con(?:in\$|out\$)?|prn|aux|nul|com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])(?:\.|$)/i.test(part));
|
|
165
|
+
if (!name || name.includes('\\') || unsafeWindows || parts.some(part => !part || part === '.' || part === '..' || part.toLowerCase() === '.git')) {
|
|
166
|
+
fail('UNSAFE_TREE', `The Git tree contains an unsafe path: ${JSON.stringify(name)}.`);
|
|
167
|
+
}
|
|
168
|
+
const target = path.resolve(root, ...name.split('/'));
|
|
169
|
+
if (!inside(root, target) || samePath(root, target)) fail('UNSAFE_TREE', 'The Git tree escapes the checkout directory.');
|
|
170
|
+
return target;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function digest(bytes) {
|
|
174
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function bounded(promise, signal) {
|
|
178
|
+
signal?.throwIfAborted();
|
|
179
|
+
if (!signal) return promise;
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const abort = () => reject(signal.reason ?? new WorktreeError('ABORTED', 'The operation was cancelled.'));
|
|
182
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
183
|
+
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
184
|
+
if (signal.aborted) abort();
|
|
185
|
+
});
|
|
186
|
+
}
|