dsh-git-worktree 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -0
  3. package/README.zh.md +37 -0
  4. package/lib/adapters/files.d.ts +10 -0
  5. package/lib/adapters/files.js +101 -0
  6. package/lib/adapters/files.js.map +1 -0
  7. package/lib/adapters/git.d.ts +18 -0
  8. package/lib/adapters/git.js +172 -0
  9. package/lib/adapters/git.js.map +1 -0
  10. package/lib/adapters/lookup.d.ts +28 -0
  11. package/lib/adapters/lookup.js +41 -0
  12. package/lib/adapters/lookup.js.map +1 -0
  13. package/lib/adapters/registry.d.ts +15 -0
  14. package/lib/adapters/registry.js +242 -0
  15. package/lib/adapters/registry.js.map +1 -0
  16. package/lib/adapters/safe-file.d.ts +19 -0
  17. package/lib/adapters/safe-file.js +94 -0
  18. package/lib/adapters/safe-file.js.map +1 -0
  19. package/lib/commands.d.ts +12 -0
  20. package/lib/commands.js +121 -0
  21. package/lib/commands.js.map +1 -0
  22. package/lib/index.d.ts +76 -0
  23. package/lib/index.js +93 -0
  24. package/lib/index.js.map +1 -0
  25. package/lib/managed-worktree-path.d.ts +27 -0
  26. package/lib/managed-worktree-path.js +53 -0
  27. package/lib/managed-worktree-path.js.map +1 -0
  28. package/lib/ports.d.ts +204 -0
  29. package/lib/ports.js +10 -0
  30. package/lib/ports.js.map +1 -0
  31. package/lib/session-checkout-apply.d.ts +110 -0
  32. package/lib/session-checkout-apply.js +753 -0
  33. package/lib/session-checkout-apply.js.map +1 -0
  34. package/lib/session-checkout-module.d.ts +3 -0
  35. package/lib/session-checkout-module.js +2122 -0
  36. package/lib/session-checkout-module.js.map +1 -0
  37. package/lib/tools.d.ts +12 -0
  38. package/lib/tools.js +384 -0
  39. package/lib/tools.js.map +1 -0
  40. package/lib/types.d.ts +275 -0
  41. package/lib/types.js +40 -0
  42. package/lib/types.js.map +1 -0
  43. package/package.json +60 -0
@@ -0,0 +1,753 @@
1
+ /**
2
+ * dsh-git-worktree apply engine, ported from Domi's session-checkout-apply
3
+ * with the Local preview surface removed (preview/rollback/finalize). The
4
+ * remaining surface is the deterministic merge pipeline: plan (conflict
5
+ * detection with ancestry-aware base selection), apply (patch Local with a
6
+ * verified plan), and finish (commit the task delta onto Local HEAD while
7
+ * preserving the user's own staged and working state). All merge computation
8
+ * runs in temporary checkouts over shared object stores; only the final
9
+ * verified patch touches the real Local worktree, and ref/index updates use
10
+ * CAS so a crash leaves a recoverable journal.
11
+ *
12
+ * Ports note: `runGit` still spawns git directly (plugin code is trusted host
13
+ * code, not sandboxed agent code); tests inject a real git in temp repos.
14
+ * @module dsh-git-worktree/session-checkout-apply
15
+ */
16
+ import { spawn } from 'node:child_process';
17
+ import { createHash, randomUUID } from 'node:crypto';
18
+ import { realpathSync } from 'node:fs';
19
+ import { copyFile, mkdir, mkdtemp, rename, rm, unlink } from 'node:fs/promises';
20
+ import { tmpdir } from 'node:os';
21
+ import { delimiter, isAbsolute, join, relative, resolve } from 'node:path';
22
+ class GitCommandFailure extends Error {
23
+ args;
24
+ result;
25
+ constructor(args, result) {
26
+ super(result.stderr || `git 命令退出码为 ${result.exitCode}`);
27
+ this.args = args;
28
+ this.result = result;
29
+ }
30
+ }
31
+ const GIT_TIMEOUT_MS = 30_000;
32
+ const OID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
33
+ async function runGit(cwd, args, options = {}) {
34
+ return await new Promise((resolveResult, reject) => {
35
+ const child = spawn('git', ['-c', 'core.quotePath=false', ...args], {
36
+ cwd,
37
+ env: {
38
+ ...process.env,
39
+ GIT_TERMINAL_PROMPT: '0',
40
+ ...options.env,
41
+ },
42
+ stdio: ['pipe', 'pipe', 'pipe'],
43
+ });
44
+ const stdoutChunks = [];
45
+ const stderrChunks = [];
46
+ let settled = false;
47
+ const finish = (error, result) => {
48
+ if (settled)
49
+ return;
50
+ settled = true;
51
+ clearTimeout(timeout);
52
+ if (error)
53
+ reject(error);
54
+ else if (result)
55
+ resolveResult(result);
56
+ };
57
+ child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
58
+ child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
59
+ child.on('error', (error) => finish(error));
60
+ child.on('close', (exitCode) => {
61
+ const result = {
62
+ exitCode: exitCode ?? -1,
63
+ stdout: Buffer.concat(stdoutChunks),
64
+ stderr: Buffer.concat(stderrChunks).toString('utf8').trim(),
65
+ };
66
+ const allowed = options.allowedExitCodes ?? [0];
67
+ finish(allowed.includes(result.exitCode) ? null : new GitCommandFailure(args, result), result);
68
+ });
69
+ const timeout = setTimeout(() => {
70
+ child.kill('SIGTERM');
71
+ finish(new Error(`git 命令超时:${args[0] ?? 'unknown'}`));
72
+ }, GIT_TIMEOUT_MS);
73
+ if (options.input !== undefined)
74
+ child.stdin.end(options.input);
75
+ else
76
+ child.stdin.end();
77
+ });
78
+ }
79
+ function stdoutText(result) {
80
+ return result.stdout.toString('utf8').trim();
81
+ }
82
+ function parseNullSeparated(output) {
83
+ return output
84
+ .toString('utf8')
85
+ .split('\0')
86
+ .filter((value) => value.length > 0);
87
+ }
88
+ function gitObjectEnvironment(objectDirectory, sourceObjectDirectory) {
89
+ const existingAlternates = process.env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
90
+ return {
91
+ GIT_OBJECT_DIRECTORY: objectDirectory,
92
+ GIT_ALTERNATE_OBJECT_DIRECTORIES: [sourceObjectDirectory, existingAlternates]
93
+ .filter((value) => Boolean(value))
94
+ .join(delimiter),
95
+ };
96
+ }
97
+ async function sourceObjectDirectory(checkoutPath) {
98
+ const output = stdoutText(await runGit(checkoutPath, ['rev-parse', '--path-format=absolute', '--git-path', 'objects']));
99
+ return resolve(checkoutPath, output);
100
+ }
101
+ async function resolveGitRoot(checkoutPath) {
102
+ const output = stdoutText(await runGit(checkoutPath, ['rev-parse', '--show-toplevel']));
103
+ return resolve(checkoutPath, output);
104
+ }
105
+ function canonicalExistingPath(path) {
106
+ try {
107
+ return realpathSync.native(resolve(path));
108
+ }
109
+ catch {
110
+ return resolve(path);
111
+ }
112
+ }
113
+ function projectPrefix(gitRoot, projectRoot) {
114
+ const prefix = relative(canonicalExistingPath(gitRoot), canonicalExistingPath(projectRoot))
115
+ .replace(/\\/g, '/')
116
+ .replace(/\/$/, '');
117
+ if (prefix === '..' || prefix.startsWith('../') || isAbsolute(prefix))
118
+ return null;
119
+ return prefix;
120
+ }
121
+ function isProjectPath(repoPath, prefix) {
122
+ return !prefix || repoPath.startsWith(`${prefix}/`);
123
+ }
124
+ function toProjectPath(repoPath, prefix) {
125
+ return prefix ? repoPath.slice(prefix.length + 1) : repoPath;
126
+ }
127
+ async function isAncestor(checkoutPath, olderOid, newerOid) {
128
+ const result = await runGit(checkoutPath, ['merge-base', '--is-ancestor', olderOid, newerOid], { allowedExitCodes: [0, 1] });
129
+ return result.exitCode === 0;
130
+ }
131
+ async function selectEffectiveApplyBase(checkoutPath, recordedBaseOid, localHeadOid, isolatedHeadOid) {
132
+ // recorded base 必须能证明位于两侧真实提交历史中,才允许利用 ancestry 前移。
133
+ // 上次 Apply 的内部 snapshot 通常不在任一分支历史中,因此第二次 Apply 会保守使用 recorded base。
134
+ const recordedInLocal = await isAncestor(checkoutPath, recordedBaseOid, localHeadOid);
135
+ const recordedInIsolated = await isAncestor(checkoutPath, recordedBaseOid, isolatedHeadOid);
136
+ if (!recordedInLocal || !recordedInIsolated) {
137
+ return { oid: recordedBaseOid, strategy: 'recorded_base' };
138
+ }
139
+ if (await isAncestor(checkoutPath, localHeadOid, isolatedHeadOid)) {
140
+ return { oid: localHeadOid, strategy: 'isolated_contains_local_head' };
141
+ }
142
+ if (await isAncestor(checkoutPath, isolatedHeadOid, localHeadOid)) {
143
+ return { oid: isolatedHeadOid, strategy: 'local_contains_isolated_head' };
144
+ }
145
+ return { oid: recordedBaseOid, strategy: 'recorded_base' };
146
+ }
147
+ async function changedTreePaths(checkoutPath, baseOid, treeOid, objectDirectory, sourceObjects) {
148
+ const result = await runGit(checkoutPath, ['diff', '--name-only', '-z', '--no-ext-diff', '--no-renames', baseOid, treeOid, '--'], { env: gitObjectEnvironment(objectDirectory, sourceObjects) });
149
+ return parseNullSeparated(result.stdout);
150
+ }
151
+ async function captureSnapshot(checkoutPath, indexPath, objectDirectory, sourceObjects) {
152
+ const headOid = stdoutText(await runGit(checkoutPath, ['rev-parse', 'HEAD']));
153
+ const headTreeOid = stdoutText(await runGit(checkoutPath, ['rev-parse', `${headOid}^{tree}`]));
154
+ const symbolic = await runGit(checkoutPath, ['symbolic-ref', '--quiet', 'HEAD'], { allowedExitCodes: [0, 1] });
155
+ const headRef = symbolic.exitCode === 0 ? stdoutText(symbolic) : null;
156
+ const indexEntries = (await runGit(checkoutPath, ['ls-files', '--stage', '-z'])).stdout;
157
+ const stagedPatch = (await runGit(checkoutPath, [
158
+ 'diff',
159
+ '--cached',
160
+ '--binary',
161
+ '--full-index',
162
+ '--no-ext-diff',
163
+ '--no-textconv',
164
+ '--no-renames',
165
+ headOid,
166
+ '--',
167
+ ])).stdout;
168
+ const env = {
169
+ ...(objectDirectory && sourceObjects ? gitObjectEnvironment(objectDirectory, sourceObjects) : {}),
170
+ GIT_INDEX_FILE: indexPath,
171
+ };
172
+ await runGit(checkoutPath, ['read-tree', headOid], { env });
173
+ if (stagedPatch.length > 0) {
174
+ await runGit(checkoutPath, ['apply', '--cached', '--binary', '--whitespace=nowarn'], {
175
+ env,
176
+ input: stagedPatch,
177
+ });
178
+ }
179
+ const indexTreeOid = stdoutText(await runGit(checkoutPath, ['write-tree'], { env }));
180
+ // 先还原真实 index 的 staged 语义,再叠加 working tree,得到完整最终状态。
181
+ await runGit(checkoutPath, ['add', '-A', '--', '.'], { env });
182
+ const treeOid = stdoutText(await runGit(checkoutPath, ['write-tree'], { env }));
183
+ const fingerprint = createHash('sha256')
184
+ .update(headOid)
185
+ .update('\0')
186
+ .update(headRef ?? 'DETACHED')
187
+ .update('\0')
188
+ .update(indexEntries)
189
+ .update('\0')
190
+ .update(treeOid)
191
+ .digest('hex');
192
+ return { fingerprint, headOid, headRef, headTreeOid, indexTreeOid, treeOid };
193
+ }
194
+ async function createSnapshotCommit(checkoutPath, treeOid, parentOid, objectDirectory, sourceObjects, label) {
195
+ const args = ['commit-tree', treeOid, ...(parentOid ? ['-p', parentOid] : [])];
196
+ const result = await runGit(checkoutPath, args, {
197
+ env: {
198
+ ...(objectDirectory && sourceObjects ? gitObjectEnvironment(objectDirectory, sourceObjects) : {}),
199
+ GIT_AUTHOR_NAME: 'dsh-git-worktree Apply',
200
+ GIT_AUTHOR_EMAIL: 'dsh-worktree-apply@localhost',
201
+ GIT_COMMITTER_NAME: 'dsh-git-worktree Apply',
202
+ GIT_COMMITTER_EMAIL: 'dsh-worktree-apply@localhost',
203
+ },
204
+ input: `${label}\n`,
205
+ });
206
+ return stdoutText(result);
207
+ }
208
+ async function computeMerge(tempRoot, sourceObjects, objectDirectory, baseOid, local, isolated, checkoutPath, projectPathPrefix) {
209
+ const localCommit = await createSnapshotCommit(checkoutPath, local.treeOid, baseOid, objectDirectory, sourceObjects, 'Local snapshot');
210
+ const isolatedCommit = await createSnapshotCommit(checkoutPath, isolated.treeOid, baseOid, objectDirectory, sourceObjects, 'Isolated snapshot');
211
+ const mergePath = join(tempRoot, 'merge checkout');
212
+ const hooksPath = join(tempRoot, 'disabled hooks');
213
+ await mkdir(mergePath, { recursive: true });
214
+ await mkdir(hooksPath, { recursive: true });
215
+ await runGit(mergePath, ['init', '--template=']);
216
+ await runGit(mergePath, ['config', 'core.autocrlf', 'false']);
217
+ await runGit(mergePath, ['config', 'core.hooksPath', hooksPath]);
218
+ await runGit(mergePath, ['config', 'user.name', 'dsh-git-worktree Apply']);
219
+ await runGit(mergePath, ['config', 'user.email', 'dsh-worktree-apply@localhost']);
220
+ const mergeEnv = {
221
+ GIT_ALTERNATE_OBJECT_DIRECTORIES: [objectDirectory, sourceObjects].join(delimiter),
222
+ };
223
+ await runGit(mergePath, ['checkout', '--detach', localCommit], { env: mergeEnv });
224
+ const merge = await runGit(mergePath, ['merge', '--no-commit', '--no-ff', isolatedCommit], {
225
+ env: mergeEnv,
226
+ allowedExitCodes: [0, 1],
227
+ });
228
+ if (merge.exitCode === 1) {
229
+ const conflicts = await runGit(mergePath, ['diff', '--name-only', '--diff-filter=U', '-z'], { env: mergeEnv });
230
+ const conflictingFiles = parseNullSeparated(conflicts.stdout)
231
+ .filter((path) => isProjectPath(path, projectPathPrefix))
232
+ .map((path) => toProjectPath(path, projectPathPrefix))
233
+ .sort();
234
+ if (conflictingFiles.length === 0)
235
+ throw new GitCommandFailure(['merge'], merge);
236
+ return { status: 'conflict', conflictingFiles };
237
+ }
238
+ const mergedTree = stdoutText(await runGit(mergePath, ['write-tree'], { env: mergeEnv }));
239
+ const changed = await runGit(mergePath, ['diff', '--name-only', '-z', '--no-ext-diff', '--no-renames', local.treeOid, mergedTree, '--'], { env: mergeEnv });
240
+ const patch = (await runGit(mergePath, [
241
+ 'diff',
242
+ '--binary',
243
+ '--full-index',
244
+ '--no-ext-diff',
245
+ '--no-textconv',
246
+ '--no-renames',
247
+ local.treeOid,
248
+ mergedTree,
249
+ '--',
250
+ ], { env: mergeEnv })).stdout;
251
+ const changedFiles = parseNullSeparated(changed.stdout)
252
+ .filter((path) => isProjectPath(path, projectPathPrefix))
253
+ .map((path) => toProjectPath(path, projectPathPrefix))
254
+ .sort();
255
+ const centralIndex = join(tempRoot, 'merged-central.index');
256
+ const centralEnv = {
257
+ ...gitObjectEnvironment(objectDirectory, sourceObjects),
258
+ GIT_INDEX_FILE: centralIndex,
259
+ };
260
+ await runGit(checkoutPath, ['read-tree', local.treeOid], { env: centralEnv });
261
+ if (patch.length > 0) {
262
+ await runGit(checkoutPath, ['apply', '--cached', '--binary', '--whitespace=nowarn'], {
263
+ env: centralEnv,
264
+ input: patch,
265
+ });
266
+ }
267
+ const centralMergedTree = stdoutText(await runGit(checkoutPath, ['write-tree'], { env: centralEnv }));
268
+ return { status: 'ready', changedFiles, patch, mergedTreeOid: centralMergedTree };
269
+ }
270
+ async function computeTreeMerge(tempRoot, sourceObjects, objectDirectory, checkoutPath, label, baseTreeOid, oursTreeOid, theirsTreeOid) {
271
+ const baseCommit = await createSnapshotCommit(checkoutPath, baseTreeOid, null, objectDirectory, sourceObjects, `${label} base`);
272
+ const oursCommit = await createSnapshotCommit(checkoutPath, oursTreeOid, baseCommit, objectDirectory, sourceObjects, `${label} ours`);
273
+ const theirsCommit = await createSnapshotCommit(checkoutPath, theirsTreeOid, baseCommit, objectDirectory, sourceObjects, `${label} theirs`);
274
+ const mergePath = join(tempRoot, label.replace(/[^a-z0-9_-]+/gi, '-'));
275
+ const hooksPath = join(tempRoot, `${label}-disabled-hooks`.replace(/[^a-z0-9_-]+/gi, '-'));
276
+ await mkdir(mergePath, { recursive: true });
277
+ await mkdir(hooksPath, { recursive: true });
278
+ await runGit(mergePath, ['init', '--template=']);
279
+ await runGit(mergePath, ['config', 'core.autocrlf', 'false']);
280
+ await runGit(mergePath, ['config', 'core.hooksPath', hooksPath]);
281
+ await runGit(mergePath, ['config', 'user.name', 'dsh-git-worktree Apply']);
282
+ await runGit(mergePath, ['config', 'user.email', 'dsh-worktree-apply@localhost']);
283
+ const env = {
284
+ GIT_ALTERNATE_OBJECT_DIRECTORIES: [objectDirectory, sourceObjects].join(delimiter),
285
+ };
286
+ await runGit(mergePath, ['checkout', '--detach', oursCommit], { env });
287
+ const merge = await runGit(mergePath, ['merge', '--no-commit', '--no-ff', theirsCommit], {
288
+ env,
289
+ allowedExitCodes: [0, 1],
290
+ });
291
+ if (merge.exitCode === 1) {
292
+ const conflicts = await runGit(mergePath, ['diff', '--name-only', '--diff-filter=U', '-z'], { env });
293
+ const conflictingFiles = parseNullSeparated(conflicts.stdout).sort();
294
+ if (conflictingFiles.length === 0)
295
+ throw new GitCommandFailure(['merge'], merge);
296
+ return { status: 'conflict', conflictingFiles };
297
+ }
298
+ const mergedTreeOid = stdoutText(await runGit(mergePath, ['write-tree'], { env }));
299
+ const mergedPatch = (await runGit(mergePath, [
300
+ 'diff',
301
+ '--binary',
302
+ '--full-index',
303
+ '--no-ext-diff',
304
+ '--no-textconv',
305
+ '--no-renames',
306
+ oursTreeOid,
307
+ mergedTreeOid,
308
+ '--',
309
+ ], { env })).stdout;
310
+ const centralIndex = join(tempRoot, `${label}-central.index`.replace(/[^a-z0-9_.-]+/gi, '-'));
311
+ const centralEnv = {
312
+ ...gitObjectEnvironment(objectDirectory, sourceObjects),
313
+ GIT_INDEX_FILE: centralIndex,
314
+ };
315
+ await runGit(checkoutPath, ['read-tree', oursTreeOid], { env: centralEnv });
316
+ if (mergedPatch.length > 0) {
317
+ await runGit(checkoutPath, ['apply', '--cached', '--binary', '--whitespace=nowarn'], {
318
+ env: centralEnv,
319
+ input: mergedPatch,
320
+ });
321
+ }
322
+ return { status: 'ready', treeOid: stdoutText(await runGit(checkoutPath, ['write-tree'], { env: centralEnv })) };
323
+ }
324
+ async function treePatch(checkoutPath, fromTreeOid, toTreeOid, objectDirectory, sourceObjects) {
325
+ return (await runGit(checkoutPath, [
326
+ 'diff',
327
+ '--binary',
328
+ '--full-index',
329
+ '--no-ext-diff',
330
+ '--no-textconv',
331
+ '--no-renames',
332
+ fromTreeOid,
333
+ toTreeOid,
334
+ '--',
335
+ ], { env: gitObjectEnvironment(objectDirectory, sourceObjects) })).stdout;
336
+ }
337
+ function pathsMatch(left, right) {
338
+ const normalizedLeft = canonicalExistingPath(left);
339
+ const normalizedRight = canonicalExistingPath(right);
340
+ return process.platform === 'win32'
341
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
342
+ : normalizedLeft === normalizedRight;
343
+ }
344
+ function planMatches(stored, supplied) {
345
+ return stored.revision === supplied.revision
346
+ && stored.localFingerprint === supplied.localFingerprint
347
+ && stored.isolatedFingerprint === supplied.isolatedFingerprint
348
+ && stored.effectiveBaseOid === supplied.effectiveBaseOid
349
+ && stored.baseStrategy === supplied.baseStrategy
350
+ && stored.localHeadOid === supplied.localHeadOid
351
+ && stored.localHeadRef === supplied.localHeadRef
352
+ && stored.isolatedHeadOid === supplied.isolatedHeadOid
353
+ && stored.changedFiles.length === supplied.changedFiles.length
354
+ && stored.changedFiles.every((path, index) => path === supplied.changedFiles[index]);
355
+ }
356
+ async function prepareIndexFromPatch(checkoutPath, indexPath, baseOid, patch) {
357
+ const env = { GIT_INDEX_FILE: indexPath };
358
+ await runGit(checkoutPath, ['read-tree', baseOid], { env });
359
+ if (patch.length > 0) {
360
+ await runGit(checkoutPath, ['apply', '--cached', '--binary', '--whitespace=nowarn'], { env, input: patch });
361
+ }
362
+ return stdoutText(await runGit(checkoutPath, ['write-tree'], { env }));
363
+ }
364
+ async function createUserCommit(checkoutPath, treeOid, parentOid, commitMessage) {
365
+ return stdoutText(await runGit(checkoutPath, ['commit-tree', treeOid, '-p', parentOid], { input: `${commitMessage.trim()}\n` }));
366
+ }
367
+ async function resolveIndexPath(checkoutPath) {
368
+ return resolve(checkoutPath, stdoutText(await runGit(checkoutPath, ['rev-parse', '--path-format=absolute', '--git-path', 'index'])));
369
+ }
370
+ async function removeBestEffort(path) {
371
+ if (!path)
372
+ return;
373
+ try {
374
+ await unlink(path);
375
+ }
376
+ catch {
377
+ // 临时 index 不存在或已完成 rename 时无需处理。
378
+ }
379
+ }
380
+ class DefaultSessionCheckoutApplyEngine {
381
+ options;
382
+ plans = new Map();
383
+ constructor(options) {
384
+ this.options = options;
385
+ }
386
+ async inspectReview(input) {
387
+ if (!OID_PATTERN.test(input.baseOid)) {
388
+ return { status: 'error', error: { code: 'invalid_input', message: 'Session Base OID 格式无效' } };
389
+ }
390
+ let tempRoot = null;
391
+ try {
392
+ tempRoot = await mkdtemp(join(tmpdir(), 'dsh-wt-review-'));
393
+ const objectDirectory = join(tempRoot, 'objects');
394
+ await mkdir(objectDirectory, { recursive: true });
395
+ const localGitRoot = await resolveGitRoot(input.localPath);
396
+ const isolatedGitRoot = await resolveGitRoot(input.isolatedPath);
397
+ const localObjects = await sourceObjectDirectory(localGitRoot);
398
+ const isolatedObjects = await sourceObjectDirectory(isolatedGitRoot);
399
+ if (!pathsMatch(localObjects, isolatedObjects)) {
400
+ return { status: 'error', error: { code: 'invalid_input', message: 'Local 与 Isolated 不属于同一 Git 仓库' } };
401
+ }
402
+ const localPrefix = projectPrefix(localGitRoot, input.localPath);
403
+ const isolatedPrefix = projectPrefix(isolatedGitRoot, input.isolatedPath);
404
+ if (localPrefix === null || isolatedPrefix === null || localPrefix !== isolatedPrefix) {
405
+ return { status: 'error', error: { code: 'invalid_input', message: 'Local 与 Isolated 的项目子目录不一致' } };
406
+ }
407
+ await runGit(localGitRoot, ['cat-file', '-e', `${input.baseOid}^{commit}`]);
408
+ const isolated = await captureSnapshot(isolatedGitRoot, join(tempRoot, 'isolated.index'), objectDirectory, localObjects);
409
+ const changedFiles = await changedTreePaths(isolatedGitRoot, input.baseOid, isolated.treeOid, objectDirectory, localObjects);
410
+ if (changedFiles.some((path) => !isProjectPath(path, localPrefix))) {
411
+ return { status: 'error', error: { code: 'invalid_input', message: 'Isolated 包含项目根目录外的变更,不能准备验收' } };
412
+ }
413
+ return {
414
+ status: 'ready',
415
+ isolatedFingerprint: isolated.fingerprint,
416
+ isolatedHeadOid: isolated.headOid,
417
+ changedFiles: changedFiles.map((path) => toProjectPath(path, localPrefix)).sort(),
418
+ };
419
+ }
420
+ catch (error) {
421
+ return { status: 'error', error: { code: 'git_error', message: this.errorMessage(error) } };
422
+ }
423
+ finally {
424
+ if (tempRoot)
425
+ await this.cleanup(tempRoot);
426
+ }
427
+ }
428
+ async preflight(input) {
429
+ return this.calculatePlan(input, false);
430
+ }
431
+ async plan(input) {
432
+ return this.calculatePlan(input, true);
433
+ }
434
+ async calculatePlan(input, persistPlan) {
435
+ if (!OID_PATTERN.test(input.baseOid)) {
436
+ return { status: 'error', error: { code: 'invalid_input', message: 'Session Base OID 格式无效' } };
437
+ }
438
+ let tempRoot = null;
439
+ try {
440
+ tempRoot = await mkdtemp(join(tmpdir(), 'dsh-wt-plan-'));
441
+ const objectDirectory = join(tempRoot, 'objects');
442
+ await mkdir(objectDirectory, { recursive: true });
443
+ const localGitRoot = await resolveGitRoot(input.localPath);
444
+ const isolatedGitRoot = await resolveGitRoot(input.isolatedPath);
445
+ const localObjects = await sourceObjectDirectory(localGitRoot);
446
+ const isolatedObjects = await sourceObjectDirectory(isolatedGitRoot);
447
+ if (!pathsMatch(localObjects, isolatedObjects)) {
448
+ return { status: 'error', error: { code: 'invalid_input', message: 'Local 与 Isolated 不属于同一 Git 仓库' } };
449
+ }
450
+ const localPrefix = projectPrefix(localGitRoot, input.localPath);
451
+ const isolatedPrefix = projectPrefix(isolatedGitRoot, input.isolatedPath);
452
+ if (localPrefix === null || isolatedPrefix === null || localPrefix !== isolatedPrefix) {
453
+ return { status: 'error', error: { code: 'invalid_input', message: 'Local 与 Isolated 的项目子目录不一致' } };
454
+ }
455
+ await runGit(localGitRoot, ['cat-file', '-e', `${input.baseOid}^{commit}`]);
456
+ const local = await captureSnapshot(localGitRoot, join(tempRoot, 'local.index'), objectDirectory, localObjects);
457
+ const isolated = await captureSnapshot(isolatedGitRoot, join(tempRoot, 'isolated.index'), objectDirectory, localObjects);
458
+ const effectiveBase = await selectEffectiveApplyBase(localGitRoot, input.baseOid, local.headOid, isolated.headOid);
459
+ const isolatedChangedPaths = await changedTreePaths(isolatedGitRoot, effectiveBase.oid, isolated.treeOid, objectDirectory, localObjects);
460
+ if (isolatedChangedPaths.some((path) => !isProjectPath(path, localPrefix))) {
461
+ return {
462
+ status: 'error',
463
+ error: { code: 'invalid_input', message: 'Isolated 包含项目根目录外的变更,已拒绝 Apply' },
464
+ };
465
+ }
466
+ const merge = await computeMerge(tempRoot, localObjects, objectDirectory, effectiveBase.oid, local, isolated, localGitRoot, localPrefix);
467
+ const revision = randomUUID();
468
+ if (merge.status === 'conflict') {
469
+ return {
470
+ status: 'conflict',
471
+ revision,
472
+ localFingerprint: local.fingerprint,
473
+ isolatedFingerprint: isolated.fingerprint,
474
+ effectiveBaseOid: effectiveBase.oid,
475
+ baseStrategy: effectiveBase.strategy,
476
+ localHeadOid: local.headOid,
477
+ isolatedHeadOid: isolated.headOid,
478
+ conflictingFiles: merge.conflictingFiles,
479
+ };
480
+ }
481
+ const plan = {
482
+ revision,
483
+ localFingerprint: local.fingerprint,
484
+ isolatedFingerprint: isolated.fingerprint,
485
+ effectiveBaseOid: effectiveBase.oid,
486
+ baseStrategy: effectiveBase.strategy,
487
+ localHeadOid: local.headOid,
488
+ localHeadRef: local.headRef,
489
+ isolatedHeadOid: isolated.headOid,
490
+ changedFiles: merge.changedFiles,
491
+ };
492
+ if (persistPlan) {
493
+ this.plans.set(revision, {
494
+ input: { ...input },
495
+ patch: merge.patch,
496
+ plan: { ...plan, changedFiles: [...plan.changedFiles] },
497
+ scope: {
498
+ isolatedGitRoot,
499
+ localGitRoot,
500
+ projectPrefix: localPrefix,
501
+ sourceObjects: localObjects,
502
+ },
503
+ });
504
+ }
505
+ return { status: 'ready', plan: { ...plan, changedFiles: [...plan.changedFiles] } };
506
+ }
507
+ catch (error) {
508
+ return { status: 'error', error: { code: 'git_error', message: this.errorMessage(error) } };
509
+ }
510
+ finally {
511
+ if (tempRoot)
512
+ await this.cleanup(tempRoot);
513
+ }
514
+ }
515
+ async apply(plan) {
516
+ const stored = this.plans.get(plan.revision);
517
+ if (!stored || !planMatches(stored.plan, plan)) {
518
+ return { status: 'error', error: { code: 'invalid_plan', message: 'Apply plan 不存在、已使用或已被修改' } };
519
+ }
520
+ let tempRoot = null;
521
+ try {
522
+ tempRoot = await mkdtemp(join(tmpdir(), 'dsh-wt-apply-'));
523
+ const objectDirectory = join(tempRoot, 'objects');
524
+ await mkdir(objectDirectory, { recursive: true });
525
+ const sourceObjects = await sourceObjectDirectory(stored.scope.localGitRoot);
526
+ if (!pathsMatch(sourceObjects, stored.scope.sourceObjects)) {
527
+ return { status: 'error', error: { code: 'invalid_plan', message: 'Apply plan 的 Git 仓库身份已变化' } };
528
+ }
529
+ const local = await captureSnapshot(stored.scope.localGitRoot, join(tempRoot, 'local.index'), objectDirectory, sourceObjects);
530
+ if (local.headOid !== stored.plan.localHeadOid || local.fingerprint !== stored.plan.localFingerprint) {
531
+ return { status: 'error', error: { code: 'stale_local', message: 'Local 在 plan 后发生变化,请重新计算' } };
532
+ }
533
+ const isolated = await captureSnapshot(stored.scope.isolatedGitRoot, join(tempRoot, 'isolated.index'), objectDirectory, sourceObjects);
534
+ if (isolated.headOid !== stored.plan.isolatedHeadOid || isolated.fingerprint !== stored.plan.isolatedFingerprint) {
535
+ return { status: 'error', error: { code: 'stale_isolated', message: 'Isolated 在 plan 后发生变化,请重新计算' } };
536
+ }
537
+ // 将已审核的 Isolated 最终状态写成无 ref 的内部 commit,供同一 checkout 后续 Apply 去重。
538
+ // 使用独立 index,不改变 Isolated 的真实 staged/working tree。
539
+ const persistentIsolated = await captureSnapshot(stored.scope.isolatedGitRoot, join(tempRoot, 'persistent-isolated.index'), null, null);
540
+ if (persistentIsolated.fingerprint !== stored.plan.isolatedFingerprint) {
541
+ return { status: 'error', error: { code: 'stale_isolated', message: 'Isolated 在 plan 后发生变化,请重新计算' } };
542
+ }
543
+ const nextBaseOid = await createSnapshotCommit(stored.scope.isolatedGitRoot, persistentIsolated.treeOid, stored.plan.effectiveBaseOid, null, null, 'Applied Isolated snapshot');
544
+ await this.options.beforeFinalLocalValidation?.();
545
+ const finalLocal = await captureSnapshot(stored.scope.localGitRoot, join(tempRoot, 'final-local.index'), objectDirectory, sourceObjects);
546
+ if (finalLocal.headOid !== stored.plan.localHeadOid || finalLocal.fingerprint !== stored.plan.localFingerprint) {
547
+ return {
548
+ status: 'error',
549
+ error: { code: 'stale_local', message: 'Local 在 Apply 写入前发生变化,请重新计算' },
550
+ };
551
+ }
552
+ if (stored.patch.length > 0) {
553
+ await runGit(stored.scope.localGitRoot, ['apply', '--binary', '--whitespace=nowarn'], { input: stored.patch });
554
+ }
555
+ this.plans.delete(plan.revision);
556
+ return { status: 'applied', changedFiles: [...stored.plan.changedFiles], nextBaseOid };
557
+ }
558
+ catch (error) {
559
+ return { status: 'error', error: { code: 'git_error', message: this.errorMessage(error) } };
560
+ }
561
+ finally {
562
+ if (tempRoot)
563
+ await this.cleanup(tempRoot);
564
+ }
565
+ }
566
+ async finish(plan, options) {
567
+ const stored = this.plans.get(plan.revision);
568
+ if (!stored || !planMatches(stored.plan, plan)) {
569
+ return { status: 'error', error: { code: 'invalid_plan', message: 'Finish plan 不存在、已使用或已被修改' } };
570
+ }
571
+ const commitMessage = options.commitMessage.trim();
572
+ if (!commitMessage) {
573
+ return { status: 'error', error: { code: 'invalid_input', message: '提交信息不能为空' } };
574
+ }
575
+ if (!stored.plan.localHeadRef?.startsWith('refs/heads/')) {
576
+ return {
577
+ status: 'error',
578
+ error: { code: 'operation_not_allowed', message: 'Local 当前不是普通分支,不能自动创建任务提交' },
579
+ };
580
+ }
581
+ let tempRoot = null;
582
+ let adjacentIndex = null;
583
+ try {
584
+ tempRoot = await mkdtemp(join(tmpdir(), 'dsh-wt-finish-'));
585
+ const objectDirectory = join(tempRoot, 'objects');
586
+ await mkdir(objectDirectory, { recursive: true });
587
+ const sourceObjects = await sourceObjectDirectory(stored.scope.localGitRoot);
588
+ if (!pathsMatch(sourceObjects, stored.scope.sourceObjects)) {
589
+ return { status: 'error', error: { code: 'invalid_plan', message: 'Finish plan 的 Git 仓库身份已变化' } };
590
+ }
591
+ const local = await captureSnapshot(stored.scope.localGitRoot, join(tempRoot, 'local.index'), objectDirectory, sourceObjects);
592
+ if (local.headOid !== stored.plan.localHeadOid
593
+ || local.headRef !== stored.plan.localHeadRef
594
+ || local.fingerprint !== stored.plan.localFingerprint) {
595
+ return { status: 'error', error: { code: 'stale_local', message: 'Local 在 plan 后发生变化,请重新计算' } };
596
+ }
597
+ const isolated = await captureSnapshot(stored.scope.isolatedGitRoot, join(tempRoot, 'isolated.index'), objectDirectory, sourceObjects);
598
+ if (isolated.headOid !== stored.plan.isolatedHeadOid || isolated.fingerprint !== stored.plan.isolatedFingerprint) {
599
+ return { status: 'error', error: { code: 'stale_isolated', message: 'Isolated 在 plan 后发生变化,请重新计算' } };
600
+ }
601
+ const merge = await computeMerge(tempRoot, sourceObjects, objectDirectory, stored.plan.effectiveBaseOid, local, isolated, stored.scope.localGitRoot, stored.scope.projectPrefix);
602
+ if (merge.status === 'conflict') {
603
+ return {
604
+ status: 'error',
605
+ error: { code: 'invalid_plan', message: 'Finish 复验得到与已审核 plan 不一致的冲突' },
606
+ };
607
+ }
608
+ if (merge.changedFiles.length !== stored.plan.changedFiles.length
609
+ || merge.changedFiles.some((path, index) => path !== stored.plan.changedFiles[index])) {
610
+ return { status: 'error', error: { code: 'invalid_plan', message: 'Finish 复验的文件集合已变化' } };
611
+ }
612
+ const persistentIsolated = await captureSnapshot(stored.scope.isolatedGitRoot, join(tempRoot, 'persistent-isolated.index'), null, null);
613
+ if (persistentIsolated.fingerprint !== stored.plan.isolatedFingerprint) {
614
+ return { status: 'error', error: { code: 'stale_isolated', message: 'Isolated 在 plan 后发生变化,请重新计算' } };
615
+ }
616
+ const nextBaseOid = await createSnapshotCommit(stored.scope.isolatedGitRoot, persistentIsolated.treeOid, stored.plan.effectiveBaseOid, null, null, 'Finished Isolated snapshot');
617
+ if (merge.changedFiles.length === 0) {
618
+ await this.options.beforeFinalLocalValidation?.();
619
+ const finalLocal = await captureSnapshot(stored.scope.localGitRoot, join(tempRoot, 'final-local.index'), objectDirectory, sourceObjects);
620
+ if (finalLocal.fingerprint !== stored.plan.localFingerprint) {
621
+ return { status: 'error', error: { code: 'stale_local', message: 'Local 在 Finish 前发生变化,请重新计算' } };
622
+ }
623
+ this.plans.delete(plan.revision);
624
+ return { status: 'finished', changedFiles: [], commitOid: null, nextBaseOid };
625
+ }
626
+ // C = H + (M - L):把任务增量从完整 Local 快照 L 中剥离,重放到旧 HEAD H。
627
+ const taskTree = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, stored.scope.localGitRoot, 'finish-task-isolation', local.treeOid, local.headTreeOid, merge.mergedTreeOid);
628
+ if (taskTree.status === 'conflict') {
629
+ return {
630
+ status: 'error',
631
+ error: {
632
+ code: 'commit_isolation_conflict',
633
+ message: `任务增量无法与 Local 原有修改可靠拆分:${taskTree.conflictingFiles.join('、')}`,
634
+ },
635
+ };
636
+ }
637
+ // S' = C + (S - H):在任务提交 C 上恢复用户原有 staged 状态。
638
+ const finalIndexTree = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, stored.scope.localGitRoot, 'finish-index-preservation', local.headTreeOid, taskTree.treeOid, local.indexTreeOid);
639
+ if (finalIndexTree.status === 'conflict') {
640
+ return {
641
+ status: 'error',
642
+ error: {
643
+ code: 'commit_isolation_conflict',
644
+ message: `任务提交与 Local 原有 staged 修改无法可靠分离:${finalIndexTree.conflictingFiles.join('、')}`,
645
+ },
646
+ };
647
+ }
648
+ const taskPatch = await treePatch(stored.scope.localGitRoot, local.headTreeOid, taskTree.treeOid, objectDirectory, sourceObjects);
649
+ const finalIndexPatch = await treePatch(stored.scope.localGitRoot, taskTree.treeOid, finalIndexTree.treeOid, objectDirectory, sourceObjects);
650
+ const taskIndexPath = join(tempRoot, 'task-commit.index');
651
+ const actualTaskTreeOid = await prepareIndexFromPatch(stored.scope.localGitRoot, taskIndexPath, local.headOid, taskPatch);
652
+ const commitOid = await createUserCommit(stored.scope.localGitRoot, actualTaskTreeOid, local.headOid, commitMessage);
653
+ const finalIndexPath = join(tempRoot, 'preserved-local.index');
654
+ await prepareIndexFromPatch(stored.scope.localGitRoot, finalIndexPath, commitOid, finalIndexPatch);
655
+ await this.options.beforeFinalLocalValidation?.();
656
+ const finalLocal = await captureSnapshot(stored.scope.localGitRoot, join(tempRoot, 'final-local-validation.index'), objectDirectory, sourceObjects);
657
+ if (finalLocal.headOid !== stored.plan.localHeadOid
658
+ || finalLocal.headRef !== stored.plan.localHeadRef
659
+ || finalLocal.fingerprint !== stored.plan.localFingerprint) {
660
+ return { status: 'error', error: { code: 'stale_local', message: 'Local 在 Finish 写入前发生变化,请重新计算' } };
661
+ }
662
+ const realIndexPath = await resolveIndexPath(stored.scope.localGitRoot);
663
+ adjacentIndex = `${realIndexPath}.dsh-wt-${randomUUID()}`;
664
+ await copyFile(finalIndexPath, adjacentIndex);
665
+ let worktreePatched = false;
666
+ let refUpdated = false;
667
+ try {
668
+ if (merge.patch.length > 0) {
669
+ await runGit(stored.scope.localGitRoot, ['apply', '--binary', '--whitespace=nowarn'], { input: merge.patch });
670
+ worktreePatched = true;
671
+ }
672
+ await runGit(stored.scope.localGitRoot, [
673
+ 'update-ref',
674
+ stored.plan.localHeadRef,
675
+ commitOid,
676
+ local.headOid,
677
+ ]);
678
+ refUpdated = true;
679
+ await rename(adjacentIndex, realIndexPath);
680
+ adjacentIndex = null;
681
+ }
682
+ catch (error) {
683
+ const rollbackErrors = [];
684
+ if (refUpdated) {
685
+ try {
686
+ await runGit(stored.scope.localGitRoot, [
687
+ 'update-ref',
688
+ stored.plan.localHeadRef,
689
+ local.headOid,
690
+ commitOid,
691
+ ]);
692
+ }
693
+ catch (rollbackError) {
694
+ rollbackErrors.push(`ref: ${this.errorMessage(rollbackError)}`);
695
+ }
696
+ }
697
+ if (worktreePatched) {
698
+ try {
699
+ await runGit(stored.scope.localGitRoot, ['apply', '-R', '--binary', '--whitespace=nowarn'], { input: merge.patch });
700
+ }
701
+ catch (rollbackError) {
702
+ rollbackErrors.push(`worktree: ${this.errorMessage(rollbackError)}`);
703
+ }
704
+ }
705
+ const detail = this.errorMessage(error);
706
+ if (rollbackErrors.length > 0) {
707
+ return {
708
+ status: 'error',
709
+ error: {
710
+ code: 'git_error',
711
+ message: `Finish 写入失败且无法证明完整回滚:${detail};${rollbackErrors.join(';')}`,
712
+ },
713
+ };
714
+ }
715
+ return { status: 'error', error: { code: 'git_error', message: `Finish 写入失败,已回滚:${detail}` } };
716
+ }
717
+ this.plans.delete(plan.revision);
718
+ return {
719
+ status: 'finished',
720
+ changedFiles: [...stored.plan.changedFiles],
721
+ commitOid,
722
+ nextBaseOid,
723
+ };
724
+ }
725
+ catch (error) {
726
+ return { status: 'error', error: { code: 'git_error', message: this.errorMessage(error) } };
727
+ }
728
+ finally {
729
+ await removeBestEffort(adjacentIndex);
730
+ if (tempRoot)
731
+ await this.cleanup(tempRoot);
732
+ }
733
+ }
734
+ errorMessage(error) {
735
+ if (error instanceof GitCommandFailure) {
736
+ return `Git 操作失败(${error.args[0] ?? 'unknown'}):${error.message}`;
737
+ }
738
+ return error instanceof Error ? error.message : '未知 Git 错误';
739
+ }
740
+ async cleanup(path) {
741
+ try {
742
+ await rm(path, { recursive: true, force: true, maxRetries: 2 });
743
+ }
744
+ catch (error) {
745
+ // 清理失败不能掩盖 plan/apply 的主结果。
746
+ console.warn('[session-checkout-apply] 临时目录清理失败:', error);
747
+ }
748
+ }
749
+ }
750
+ export function createSessionCheckoutApplyEngine(options = {}) {
751
+ return new DefaultSessionCheckoutApplyEngine(options);
752
+ }
753
+ //# sourceMappingURL=session-checkout-apply.js.map