dsh-git-worktree 0.3.2 → 0.6.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/CHANGELOG.md +83 -0
- package/README.en.md +48 -18
- package/README.md +49 -18
- package/lib/adapters/git.js +8 -0
- package/lib/adapters/git.js.map +1 -1
- package/lib/adapters/registry.js +107 -2
- package/lib/adapters/registry.js.map +1 -1
- package/lib/checkpoint.d.ts +3 -0
- package/lib/checkpoint.js +29 -0
- package/lib/checkpoint.js.map +1 -0
- package/lib/client/WorktreeReviewRow.d.ts +1 -1
- package/lib/client/WorktreeReviewRow.js +26 -6
- package/lib/client/WorktreeReviewRow.js.map +1 -1
- package/lib/client/actions.d.ts +49 -4
- package/lib/client/actions.js +104 -0
- package/lib/client/actions.js.map +1 -1
- package/lib/client/console-remote/adapter.js +10 -3
- package/lib/client/console-remote/adapter.js.map +1 -1
- package/lib/client/review-console/DeliveryProof.d.ts +5 -0
- package/lib/client/review-console/DeliveryProof.js +32 -0
- package/lib/client/review-console/DeliveryProof.js.map +1 -0
- package/lib/client/review-console/PreflightStatus.d.ts +12 -0
- package/lib/client/review-console/PreflightStatus.js +31 -0
- package/lib/client/review-console/PreflightStatus.js.map +1 -0
- package/lib/client/review-console/ReviewActions.d.ts +4 -1
- package/lib/client/review-console/ReviewActions.js +657 -31
- package/lib/client/review-console/ReviewActions.js.map +1 -1
- package/lib/client/review-console/WorktreeReviewPanel.d.ts +3 -1
- package/lib/client/review-console/WorktreeReviewPanel.js +35 -5
- package/lib/client/review-console/WorktreeReviewPanel.js.map +1 -1
- package/lib/client/review-console/preflight-cache.d.ts +21 -0
- package/lib/client/review-console/preflight-cache.js +98 -0
- package/lib/client/review-console/preflight-cache.js.map +1 -0
- package/lib/client/review-console/preview-recovery-cache.d.ts +20 -0
- package/lib/client/review-console/preview-recovery-cache.js +103 -0
- package/lib/client/review-console/preview-recovery-cache.js.map +1 -0
- package/lib/client/review-console/recovery-continuation.d.ts +53 -0
- package/lib/client/review-console/recovery-continuation.js +483 -0
- package/lib/client/review-console/recovery-continuation.js.map +1 -0
- package/lib/client/review-console/review-console.styles.js +36 -4
- package/lib/client/review-console/review-console.styles.js.map +1 -1
- package/lib/client/target-console/TargetStatusAction.d.ts +4 -2
- package/lib/client/target-console/TargetStatusAction.js +196 -23
- package/lib/client/target-console/TargetStatusAction.js.map +1 -1
- package/lib/client/target-console/WorktreeConsoleView.d.ts +4 -2
- package/lib/client/target-console/WorktreeConsoleView.js +33 -73
- package/lib/client/target-console/WorktreeConsoleView.js.map +1 -1
- package/lib/client/target-console/WorktreeManagerModal.d.ts +13 -0
- package/lib/client/target-console/WorktreeManagerModal.js +8 -0
- package/lib/client/target-console/WorktreeManagerModal.js.map +1 -0
- package/lib/client/target-console/WorktreeReviewStatus.d.ts +3 -1
- package/lib/client/target-console/WorktreeReviewStatus.js +35 -15
- package/lib/client/target-console/WorktreeReviewStatus.js.map +1 -1
- package/lib/client/target-console/index.d.ts +2 -1
- package/lib/client/target-console/index.js +4 -3
- package/lib/client/target-console/index.js.map +1 -1
- package/lib/client/target-console/target-console.styles.js +22 -3
- package/lib/client/target-console/target-console.styles.js.map +1 -1
- package/lib/client.js +2976 -211
- package/lib/console-contract.d.ts +110 -1
- package/lib/console-contract.js.map +1 -1
- package/lib/console-host/control-plane.d.ts +6 -1
- package/lib/console-host/control-plane.js +363 -44
- package/lib/console-host/control-plane.js.map +1 -1
- package/lib/console-host/errors.d.ts +2 -2
- package/lib/console-host/errors.js +21 -5
- package/lib/console-host/errors.js.map +1 -1
- package/lib/console-host/projection.d.ts +5 -3
- package/lib/console-host/projection.js +103 -11
- package/lib/console-host/projection.js.map +1 -1
- package/lib/console-host/service.d.ts +10 -5
- package/lib/console-host/service.js +48 -6
- package/lib/console-host/service.js.map +1 -1
- package/lib/console-remote/descriptors.js +37 -1
- package/lib/console-remote/descriptors.js.map +1 -1
- package/lib/console-remote/remote.d.ts +10 -5
- package/lib/console-remote/remote.js.map +1 -1
- package/lib/console-remote/schemas.d.ts +103 -1
- package/lib/console-remote/schemas.js +166 -6
- package/lib/console-remote/schemas.js.map +1 -1
- package/lib/index.d.ts +13 -2
- package/lib/index.js.map +1 -1
- package/lib/ports.d.ts +79 -1
- package/lib/session-checkout-apply.d.ts +76 -0
- package/lib/session-checkout-apply.js +608 -143
- package/lib/session-checkout-apply.js.map +1 -1
- package/lib/session-checkout-module.js +775 -117
- package/lib/session-checkout-module.js.map +1 -1
- package/lib/types.d.ts +104 -3
- package/lib/types.js.map +1 -1
- package/package.json +14 -14
- package/scripts/check-publish.mjs +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { realpathSync } from 'node:fs';
|
|
4
|
-
import { copyFile, mkdir, mkdtemp, rename, rm, unlink } from 'node:fs/promises';
|
|
4
|
+
import { copyFile, mkdir, mkdtemp, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { delimiter, isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
7
|
class GitCommandFailure extends Error {
|
|
@@ -130,9 +130,20 @@ async function selectEffectiveApplyBase(checkoutPath, recordedBaseOid, localHead
|
|
|
130
130
|
return { oid: recordedBaseOid, strategy: 'recorded_base' };
|
|
131
131
|
}
|
|
132
132
|
async function changedTreePaths(checkoutPath, baseOid, treeOid, objectDirectory, sourceObjects) {
|
|
133
|
-
const result = await runGit(checkoutPath, ['diff', '--name-only', '-z', '--no-ext-diff', '--no-renames', baseOid, treeOid, '--'], { env: gitObjectEnvironment(objectDirectory, sourceObjects) });
|
|
133
|
+
const result = await runGit(checkoutPath, ['diff', '--name-only', '-z', '--no-ext-diff', '--no-renames', baseOid, treeOid, '--'], { env: objectDirectory && sourceObjects ? gitObjectEnvironment(objectDirectory, sourceObjects) : undefined });
|
|
134
134
|
return parseNullSeparated(result.stdout);
|
|
135
135
|
}
|
|
136
|
+
function snapshotFingerprint(headOid, headRef, indexEntries, treeOid) {
|
|
137
|
+
return createHash('sha256')
|
|
138
|
+
.update(headOid)
|
|
139
|
+
.update('\0')
|
|
140
|
+
.update(headRef ?? 'DETACHED')
|
|
141
|
+
.update('\0')
|
|
142
|
+
.update(indexEntries)
|
|
143
|
+
.update('\0')
|
|
144
|
+
.update(treeOid)
|
|
145
|
+
.digest('hex');
|
|
146
|
+
}
|
|
136
147
|
async function captureSnapshot(checkoutPath, indexPath, objectDirectory, sourceObjects) {
|
|
137
148
|
const headOid = stdoutText(await runGit(checkoutPath, ['rev-parse', 'HEAD']));
|
|
138
149
|
const headTreeOid = stdoutText(await runGit(checkoutPath, ['rev-parse', `${headOid}^{tree}`]));
|
|
@@ -165,16 +176,8 @@ async function captureSnapshot(checkoutPath, indexPath, objectDirectory, sourceO
|
|
|
165
176
|
// 先还原真实 index 的 staged 语义,再叠加 working tree,得到完整最终状态。
|
|
166
177
|
await runGit(checkoutPath, ['add', '-A', '--', '.'], { env });
|
|
167
178
|
const treeOid = stdoutText(await runGit(checkoutPath, ['write-tree'], { env }));
|
|
168
|
-
const fingerprint =
|
|
169
|
-
|
|
170
|
-
.update('\0')
|
|
171
|
-
.update(headRef ?? 'DETACHED')
|
|
172
|
-
.update('\0')
|
|
173
|
-
.update(indexEntries)
|
|
174
|
-
.update('\0')
|
|
175
|
-
.update(treeOid)
|
|
176
|
-
.digest('hex');
|
|
177
|
-
return { fingerprint, headOid, headRef, headTreeOid, indexTreeOid, treeOid };
|
|
179
|
+
const fingerprint = snapshotFingerprint(headOid, headRef, indexEntries, treeOid);
|
|
180
|
+
return { fingerprint, headOid, headRef, headTreeOid, indexTreeOid, treeOid, indexEntries };
|
|
178
181
|
}
|
|
179
182
|
async function createSnapshotCommit(checkoutPath, treeOid, parentOid, objectDirectory, sourceObjects, label) {
|
|
180
183
|
const args = ['commit-tree', treeOid, ...(parentOid ? ['-p', parentOid] : [])];
|
|
@@ -349,9 +352,135 @@ async function prepareIndexFromPatch(checkoutPath, indexPath, baseOid, patch) {
|
|
|
349
352
|
async function createUserCommit(checkoutPath, treeOid, parentOid, commitMessage) {
|
|
350
353
|
return stdoutText(await runGit(checkoutPath, ['commit-tree', treeOid, '-p', parentOid], { input: `${commitMessage.trim()}\n` }));
|
|
351
354
|
}
|
|
355
|
+
async function prepareRollbackAssessment(tempRoot, sourceObjects, objectDirectory, localGitRoot, current, receipt) {
|
|
356
|
+
if (current.headRef !== receipt.localHeadRef) {
|
|
357
|
+
return { status: 'blocked', code: 'stale_local', message: 'Local branch 已变化,不能自动撤回 Preview' };
|
|
358
|
+
}
|
|
359
|
+
if (current.headOid !== receipt.localHeadOid && !await isAncestor(localGitRoot, receipt.localHeadOid, current.headOid)) {
|
|
360
|
+
return { status: 'blocked', code: 'stale_local', message: 'Local HEAD 不是 Preview 基线的安全快进,不能自动撤回' };
|
|
361
|
+
}
|
|
362
|
+
let rollbackBaselineTreeOid = receipt.localWorkingTreeOid;
|
|
363
|
+
let expectedPreviewTreeOid = receipt.previewWorkingTreeOid;
|
|
364
|
+
if (current.headOid !== receipt.localHeadOid) {
|
|
365
|
+
const receiptHeadTreeOid = stdoutText(await runGit(localGitRoot, ['rev-parse', `${receipt.localHeadOid}^{tree}`]));
|
|
366
|
+
const advancedBaseline = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-rollback-advanced-baseline', receiptHeadTreeOid, current.headTreeOid, receipt.localWorkingTreeOid);
|
|
367
|
+
if (advancedBaseline.status === 'conflict') {
|
|
368
|
+
return {
|
|
369
|
+
status: 'blocked', code: 'preview_modified',
|
|
370
|
+
message: `Local 新提交与 Preview 前的本地修改冲突,无法安全撤回:${advancedBaseline.conflictingFiles.join('、')}`,
|
|
371
|
+
conflictingFiles: [...advancedBaseline.conflictingFiles],
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const previewAbsence = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-rollback-preview-absence', receipt.previewWorkingTreeOid, advancedBaseline.treeOid, receipt.localWorkingTreeOid);
|
|
375
|
+
if (previewAbsence.status === 'conflict') {
|
|
376
|
+
return {
|
|
377
|
+
status: 'blocked', code: 'preview_modified',
|
|
378
|
+
message: `Local 新提交与 Preview 任务增量冲突,无法安全撤回:${previewAbsence.conflictingFiles.join('、')}`,
|
|
379
|
+
conflictingFiles: [...previewAbsence.conflictingFiles],
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
if (previewAbsence.treeOid !== advancedBaseline.treeOid) {
|
|
383
|
+
return {
|
|
384
|
+
status: 'blocked', code: 'preview_modified',
|
|
385
|
+
message: 'Local 新提交已经包含部分或全部 Preview 增量,不能通过撤回工作区改动来改写已提交历史',
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const advancedPreview = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-rollback-advanced-preview', receipt.localWorkingTreeOid, advancedBaseline.treeOid, receipt.previewWorkingTreeOid);
|
|
389
|
+
if (advancedPreview.status === 'conflict') {
|
|
390
|
+
return {
|
|
391
|
+
status: 'blocked', code: 'preview_modified',
|
|
392
|
+
message: `Local 新提交与 Preview 任务增量冲突,无法安全撤回:${advancedPreview.conflictingFiles.join('、')}`,
|
|
393
|
+
conflictingFiles: [...advancedPreview.conflictingFiles],
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
rollbackBaselineTreeOid = advancedBaseline.treeOid;
|
|
397
|
+
expectedPreviewTreeOid = advancedPreview.treeOid;
|
|
398
|
+
}
|
|
399
|
+
const rollbackTree = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-rollback', expectedPreviewTreeOid, current.treeOid, rollbackBaselineTreeOid);
|
|
400
|
+
if (rollbackTree.status === 'conflict') {
|
|
401
|
+
return {
|
|
402
|
+
status: 'blocked', code: 'preview_modified',
|
|
403
|
+
message: `Local 在 Preview 区域出现额外修改,无法安全撤回:${rollbackTree.conflictingFiles.join('、')}`,
|
|
404
|
+
conflictingFiles: [...rollbackTree.conflictingFiles],
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return { status: 'safe', targetTreeOid: rollbackTree.treeOid };
|
|
408
|
+
}
|
|
409
|
+
async function prepareFinalizeAssessment(tempRoot, sourceObjects, objectDirectory, localGitRoot, current, receipt) {
|
|
410
|
+
if (!receipt.localHeadRef?.startsWith('refs/heads/')) {
|
|
411
|
+
return { status: 'blocked', code: 'operation_not_allowed', message: 'Local 当前不是普通分支,不能自动创建任务提交' };
|
|
412
|
+
}
|
|
413
|
+
if (current.headRef !== receipt.localHeadRef) {
|
|
414
|
+
return { status: 'blocked', code: 'stale_local', message: 'Local branch 已变化,不能完成 Preview 提交' };
|
|
415
|
+
}
|
|
416
|
+
if (current.headOid !== receipt.localHeadOid && !await isAncestor(localGitRoot, receipt.localHeadOid, current.headOid)) {
|
|
417
|
+
return { status: 'blocked', code: 'stale_local', message: 'Local HEAD 不是 Preview 基线的安全快进,不能完成 Preview 提交' };
|
|
418
|
+
}
|
|
419
|
+
const previewRemoval = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-finalize-separation', receipt.previewWorkingTreeOid, current.treeOid, receipt.localWorkingTreeOid);
|
|
420
|
+
if (previewRemoval.status === 'conflict') {
|
|
421
|
+
return {
|
|
422
|
+
status: 'blocked', code: 'preview_modified',
|
|
423
|
+
message: `Local 在 Preview 区域出现额外修改,无法可靠提交:${previewRemoval.conflictingFiles.join('、')}`,
|
|
424
|
+
conflictingFiles: [...previewRemoval.conflictingFiles],
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const taskTree = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-task-isolation', receipt.localWorkingTreeOid, current.headTreeOid, receipt.previewWorkingTreeOid);
|
|
428
|
+
if (taskTree.status === 'conflict') {
|
|
429
|
+
return {
|
|
430
|
+
status: 'blocked', code: 'commit_isolation_conflict',
|
|
431
|
+
message: `Preview 任务增量无法与最新 Local HEAD 可靠拆分:${taskTree.conflictingFiles.join('、')}`,
|
|
432
|
+
conflictingFiles: [...taskTree.conflictingFiles],
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
if (taskTree.treeOid === current.headTreeOid && receipt.changedFiles.length > 0) {
|
|
436
|
+
return {
|
|
437
|
+
status: 'blocked', code: 'preview_modified',
|
|
438
|
+
message: 'Preview 任务增量已经进入 Local HEAD;不会创建重复或空提交',
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
const finalIndexTree = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-index-preservation', current.headTreeOid, taskTree.treeOid, current.indexTreeOid);
|
|
442
|
+
if (finalIndexTree.status === 'conflict') {
|
|
443
|
+
return {
|
|
444
|
+
status: 'blocked', code: 'commit_isolation_conflict',
|
|
445
|
+
message: `Preview 提交与 Local staged 修改无法可靠分离:${finalIndexTree.conflictingFiles.join('、')}`,
|
|
446
|
+
conflictingFiles: [...finalIndexTree.conflictingFiles],
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
status: 'safe', taskTreeOid: taskTree.treeOid, finalIndexTreeOid: finalIndexTree.treeOid,
|
|
451
|
+
expectedWorkingTreeOid: current.treeOid,
|
|
452
|
+
commitRequired: receipt.changedFiles.length > 0 && taskTree.treeOid !== current.headTreeOid,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
352
455
|
async function resolveIndexPath(checkoutPath) {
|
|
353
456
|
return resolve(checkoutPath, stdoutText(await runGit(checkoutPath, ['rev-parse', '--path-format=absolute', '--git-path', 'index'])));
|
|
354
457
|
}
|
|
458
|
+
async function inspectIndexFileTree(checkoutPath, indexPath) {
|
|
459
|
+
try {
|
|
460
|
+
const handle = await open(indexPath, 'r');
|
|
461
|
+
await handle.close();
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
if (error.code === 'ENOENT')
|
|
465
|
+
return { exists: false };
|
|
466
|
+
throw error;
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
const treeOid = stdoutText(await runGit(checkoutPath, ['write-tree'], { env: { GIT_INDEX_FILE: indexPath } }));
|
|
470
|
+
return { exists: true, treeOid };
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return { exists: true, treeOid: null };
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async function checkpointLockMarkerOwned(markerPath, commitOid) {
|
|
477
|
+
try {
|
|
478
|
+
return (await readFile(markerPath, 'utf8')).trim() === commitOid;
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
355
484
|
async function removeBestEffort(path) {
|
|
356
485
|
if (!path)
|
|
357
486
|
return;
|
|
@@ -362,12 +491,263 @@ async function removeBestEffort(path) {
|
|
|
362
491
|
// 临时 index 不存在或已完成 rename 时无需处理。
|
|
363
492
|
}
|
|
364
493
|
}
|
|
494
|
+
async function writeIndexLock(indexPath, contents) {
|
|
495
|
+
const lockPath = `${indexPath}.lock`;
|
|
496
|
+
let handle = null;
|
|
497
|
+
try {
|
|
498
|
+
handle = await open(lockPath, 'wx');
|
|
499
|
+
await handle.writeFile(contents);
|
|
500
|
+
await handle.sync();
|
|
501
|
+
await handle.close();
|
|
502
|
+
handle = null;
|
|
503
|
+
return lockPath;
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
try {
|
|
507
|
+
await handle?.close();
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// 原始错误优先;finally 会清理由本次调用创建的 lock。
|
|
511
|
+
}
|
|
512
|
+
if (handle)
|
|
513
|
+
await removeBestEffort(lockPath);
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
function isIndexLockContention(error) {
|
|
518
|
+
return error !== null
|
|
519
|
+
&& typeof error === 'object'
|
|
520
|
+
&& 'code' in error
|
|
521
|
+
&& error.code === 'EEXIST';
|
|
522
|
+
}
|
|
365
523
|
class DefaultSessionCheckoutApplyEngine {
|
|
366
524
|
options;
|
|
367
525
|
plans = new Map();
|
|
368
526
|
constructor(options) {
|
|
369
527
|
this.options = options;
|
|
370
528
|
}
|
|
529
|
+
async checkpoint(input) {
|
|
530
|
+
const commitMessage = input.commitMessage.trim();
|
|
531
|
+
if (!commitMessage || commitMessage.length > 500 || !OID_PATTERN.test(input.expectedHeadOid) || !input.expectedFingerprint.trim()) {
|
|
532
|
+
return { status: 'error', error: { code: 'invalid_input', message: 'Checkpoint 输入无效' } };
|
|
533
|
+
}
|
|
534
|
+
let tempRoot = null;
|
|
535
|
+
let adjacentIndex = null;
|
|
536
|
+
let indexLockMarker = null;
|
|
537
|
+
let preserveRecoveryEvidence = false;
|
|
538
|
+
try {
|
|
539
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'dsh-checkpoint-'));
|
|
540
|
+
const isolatedGitRoot = await resolveGitRoot(input.isolatedPath);
|
|
541
|
+
const projectPathPrefix = projectPrefix(isolatedGitRoot, input.isolatedPath);
|
|
542
|
+
if (projectPathPrefix === null) {
|
|
543
|
+
return { status: 'error', error: { code: 'invalid_input', message: 'Checkpoint 项目目录不属于当前 Worktree' } };
|
|
544
|
+
}
|
|
545
|
+
const snapshot = await captureSnapshot(isolatedGitRoot, join(tempRoot, 'checkpoint.index'), null, null);
|
|
546
|
+
if (snapshot.headRef !== null) {
|
|
547
|
+
return { status: 'error', error: { code: 'operation_not_allowed', message: 'Checkpoint 只允许写入 detached managed Worktree' } };
|
|
548
|
+
}
|
|
549
|
+
if (snapshot.headOid !== input.expectedHeadOid || snapshot.fingerprint !== input.expectedFingerprint) {
|
|
550
|
+
return { status: 'error', error: { code: 'stale_isolated', message: 'Worktree 在准备验收后发生变化,不能保存阶段' } };
|
|
551
|
+
}
|
|
552
|
+
const changedPaths = await changedTreePaths(isolatedGitRoot, snapshot.headOid, snapshot.treeOid, null, null);
|
|
553
|
+
if (changedPaths.some((path) => !isProjectPath(path, projectPathPrefix))) {
|
|
554
|
+
return { status: 'error', error: { code: 'invalid_input', message: 'Worktree 包含项目根目录外的变更,不能保存阶段' } };
|
|
555
|
+
}
|
|
556
|
+
const changedFiles = changedPaths.map((path) => toProjectPath(path, projectPathPrefix)).sort();
|
|
557
|
+
if (changedFiles.length === 0) {
|
|
558
|
+
return { status: 'error', error: { code: 'operation_not_allowed', message: '当前阶段没有可保存的新修改' } };
|
|
559
|
+
}
|
|
560
|
+
const commitOid = await createUserCommit(isolatedGitRoot, snapshot.treeOid, snapshot.headOid, commitMessage);
|
|
561
|
+
await input.beforeCommit?.({ commitOid, parentOid: snapshot.headOid, indexTreeOid: snapshot.indexTreeOid, changedFiles });
|
|
562
|
+
const finalIndexPath = join(tempRoot, 'clean.index');
|
|
563
|
+
await prepareIndexFromPatch(isolatedGitRoot, finalIndexPath, commitOid, Buffer.alloc(0));
|
|
564
|
+
const realIndexPath = await resolveIndexPath(isolatedGitRoot);
|
|
565
|
+
const indexLockPath = `${realIndexPath}.lock`;
|
|
566
|
+
indexLockMarker = `${indexLockPath}.dsh-${commitOid}`;
|
|
567
|
+
await writeFile(indexLockMarker, `${commitOid}\n`, { flag: 'wx' });
|
|
568
|
+
const indexLock = await open(indexLockPath, 'wx');
|
|
569
|
+
adjacentIndex = indexLockPath;
|
|
570
|
+
await indexLock.close();
|
|
571
|
+
await copyFile(finalIndexPath, adjacentIndex);
|
|
572
|
+
await this.options.beforeFinalIsolatedValidation?.();
|
|
573
|
+
const finalSnapshot = await captureSnapshot(isolatedGitRoot, join(tempRoot, 'final-checkpoint.index'), null, null);
|
|
574
|
+
if (finalSnapshot.headOid !== snapshot.headOid || finalSnapshot.fingerprint !== snapshot.fingerprint) {
|
|
575
|
+
return { status: 'error', error: { code: 'stale_isolated', message: 'Worktree 在保存阶段前发生变化,请重新准备验收' } };
|
|
576
|
+
}
|
|
577
|
+
let headUpdated = false;
|
|
578
|
+
try {
|
|
579
|
+
await runGit(isolatedGitRoot, ['update-ref', '--no-deref', 'HEAD', commitOid, snapshot.headOid]);
|
|
580
|
+
headUpdated = true;
|
|
581
|
+
await rename(adjacentIndex, realIndexPath);
|
|
582
|
+
adjacentIndex = null;
|
|
583
|
+
await removeBestEffort(indexLockMarker);
|
|
584
|
+
indexLockMarker = null;
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
if (headUpdated) {
|
|
588
|
+
try {
|
|
589
|
+
await runGit(isolatedGitRoot, ['update-ref', '--no-deref', 'HEAD', snapshot.headOid, commitOid]);
|
|
590
|
+
}
|
|
591
|
+
catch (rollbackError) {
|
|
592
|
+
preserveRecoveryEvidence = true;
|
|
593
|
+
return {
|
|
594
|
+
status: 'error',
|
|
595
|
+
error: {
|
|
596
|
+
code: 'git_error',
|
|
597
|
+
message: `Checkpoint index 写入失败且 HEAD 无法回滚:${this.errorMessage(error)};${this.errorMessage(rollbackError)}`,
|
|
598
|
+
recoveryState: 'uncertain',
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
status: 'error',
|
|
605
|
+
error: {
|
|
606
|
+
code: 'git_error',
|
|
607
|
+
message: `Checkpoint 写入失败,已回滚:${this.errorMessage(error)}`,
|
|
608
|
+
recoveryState: 'unchanged',
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
await this.options.afterIsolatedWriteBeforeVerification?.();
|
|
614
|
+
const completed = await captureSnapshot(isolatedGitRoot, join(tempRoot, 'completed.index'), null, null);
|
|
615
|
+
if (completed.headOid !== commitOid
|
|
616
|
+
|| completed.headRef !== null
|
|
617
|
+
|| completed.headTreeOid !== snapshot.treeOid
|
|
618
|
+
|| completed.indexTreeOid !== snapshot.treeOid
|
|
619
|
+
|| completed.treeOid !== snapshot.treeOid) {
|
|
620
|
+
return {
|
|
621
|
+
status: 'error',
|
|
622
|
+
error: { code: 'git_error', message: 'Checkpoint 已写入,但 Worktree 未收敛到 clean 状态', recoveryState: 'uncertain' },
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
status: 'checkpointed',
|
|
627
|
+
commitOid,
|
|
628
|
+
parentOid: snapshot.headOid,
|
|
629
|
+
changedFiles,
|
|
630
|
+
isolatedFingerprint: completed.fingerprint,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
return {
|
|
635
|
+
status: 'error',
|
|
636
|
+
error: {
|
|
637
|
+
code: 'git_error',
|
|
638
|
+
message: `Checkpoint 写后无法完成验证,需要保留现场确认:${this.errorMessage(error)}`,
|
|
639
|
+
recoveryState: 'uncertain',
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
const stale = isIndexLockContention(error);
|
|
646
|
+
return {
|
|
647
|
+
status: 'error',
|
|
648
|
+
error: stale
|
|
649
|
+
? { code: 'stale_isolated', message: 'Worktree index 正在被其他 Git 操作更新,请重试' }
|
|
650
|
+
: { code: 'git_error', message: this.errorMessage(error) },
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
finally {
|
|
654
|
+
if (!preserveRecoveryEvidence) {
|
|
655
|
+
await removeBestEffort(adjacentIndex);
|
|
656
|
+
await removeBestEffort(indexLockMarker);
|
|
657
|
+
}
|
|
658
|
+
if (tempRoot)
|
|
659
|
+
await this.cleanup(tempRoot);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
async recoverCheckpoint(input) {
|
|
663
|
+
if (!OID_PATTERN.test(input.commitOid) || !OID_PATTERN.test(input.parentOid) || !OID_PATTERN.test(input.expectedIndexTreeOid)) {
|
|
664
|
+
return { status: 'error', error: { code: 'invalid_input', message: 'Checkpoint 恢复 OID 无效' } };
|
|
665
|
+
}
|
|
666
|
+
let tempRoot = null;
|
|
667
|
+
let ownedIndexLock = null;
|
|
668
|
+
let ownedLockMarker = null;
|
|
669
|
+
try {
|
|
670
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'dsh-checkpoint-recovery-'));
|
|
671
|
+
const isolatedGitRoot = await resolveGitRoot(input.isolatedPath);
|
|
672
|
+
const realIndexPath = await resolveIndexPath(isolatedGitRoot);
|
|
673
|
+
const indexLockPath = `${realIndexPath}.lock`;
|
|
674
|
+
const indexLockMarker = `${indexLockPath}.dsh-${input.commitOid}`;
|
|
675
|
+
const targetTreeOid = stdoutText(await runGit(isolatedGitRoot, ['rev-parse', `${input.commitOid}^{tree}`]));
|
|
676
|
+
const existingLock = await inspectIndexFileTree(isolatedGitRoot, indexLockPath);
|
|
677
|
+
const markerOwned = await checkpointLockMarkerOwned(indexLockMarker, input.commitOid);
|
|
678
|
+
const snapshot = await captureSnapshot(isolatedGitRoot, join(tempRoot, 'current.index'), null, null);
|
|
679
|
+
if (snapshot.headRef !== null) {
|
|
680
|
+
return { status: 'error', error: { code: 'stale_isolated', message: 'Worktree 已不再是 detached HEAD' } };
|
|
681
|
+
}
|
|
682
|
+
if (snapshot.headOid === input.parentOid) {
|
|
683
|
+
if (existingLock.exists) {
|
|
684
|
+
if (!markerOwned || (existingLock.treeOid !== null && existingLock.treeOid !== targetTreeOid)) {
|
|
685
|
+
return { status: 'error', error: { code: 'stale_isolated', message: '遗留 index.lock 无法证明属于当前 Checkpoint,已保留现场' } };
|
|
686
|
+
}
|
|
687
|
+
await unlink(indexLockPath);
|
|
688
|
+
}
|
|
689
|
+
if (markerOwned)
|
|
690
|
+
await unlink(indexLockMarker);
|
|
691
|
+
return { status: 'checkpoint_aborted', isolatedFingerprint: snapshot.fingerprint };
|
|
692
|
+
}
|
|
693
|
+
if (snapshot.headOid !== input.commitOid) {
|
|
694
|
+
return { status: 'error', error: { code: 'stale_isolated', message: 'Worktree HEAD 与待恢复 Checkpoint 不一致' } };
|
|
695
|
+
}
|
|
696
|
+
if (snapshot.treeOid !== snapshot.headTreeOid) {
|
|
697
|
+
return { status: 'error', error: { code: 'stale_isolated', message: 'Worktree 在 Checkpoint 中断后出现新修改,不能自动恢复 index' } };
|
|
698
|
+
}
|
|
699
|
+
if (snapshot.indexTreeOid !== input.expectedIndexTreeOid && snapshot.indexTreeOid !== snapshot.headTreeOid) {
|
|
700
|
+
return { status: 'error', error: { code: 'stale_isolated', message: 'Checkpoint 中断后 index 出现新 staged 修改,不能自动覆盖' } };
|
|
701
|
+
}
|
|
702
|
+
const cleanIndexPath = join(tempRoot, 'clean.index');
|
|
703
|
+
await prepareIndexFromPatch(isolatedGitRoot, cleanIndexPath, input.commitOid, Buffer.alloc(0));
|
|
704
|
+
if (existingLock.exists) {
|
|
705
|
+
if (!markerOwned || (existingLock.treeOid !== null && existingLock.treeOid !== targetTreeOid)) {
|
|
706
|
+
return { status: 'error', error: { code: 'stale_isolated', message: '遗留 index.lock 与 Checkpoint 目标不一致,已保留现场' } };
|
|
707
|
+
}
|
|
708
|
+
if (existingLock.treeOid === null)
|
|
709
|
+
await copyFile(cleanIndexPath, indexLockPath);
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
if (!markerOwned) {
|
|
713
|
+
await writeFile(indexLockMarker, `${input.commitOid}\n`, { flag: 'wx' });
|
|
714
|
+
ownedLockMarker = indexLockMarker;
|
|
715
|
+
}
|
|
716
|
+
const indexLock = await open(indexLockPath, 'wx');
|
|
717
|
+
ownedIndexLock = indexLockPath;
|
|
718
|
+
await indexLock.close();
|
|
719
|
+
await copyFile(cleanIndexPath, indexLockPath);
|
|
720
|
+
}
|
|
721
|
+
await this.options.beforeFinalIsolatedValidation?.();
|
|
722
|
+
const lockedSnapshot = await captureSnapshot(isolatedGitRoot, join(tempRoot, 'locked-current.index'), null, null);
|
|
723
|
+
if (lockedSnapshot.headOid !== snapshot.headOid
|
|
724
|
+
|| lockedSnapshot.fingerprint !== snapshot.fingerprint
|
|
725
|
+
|| (lockedSnapshot.indexTreeOid !== input.expectedIndexTreeOid && lockedSnapshot.indexTreeOid !== lockedSnapshot.headTreeOid)) {
|
|
726
|
+
return { status: 'error', error: { code: 'stale_isolated', message: 'Checkpoint 恢复加锁前 Worktree 或 index 已变化' } };
|
|
727
|
+
}
|
|
728
|
+
await rename(indexLockPath, realIndexPath);
|
|
729
|
+
ownedIndexLock = null;
|
|
730
|
+
await removeBestEffort(indexLockMarker);
|
|
731
|
+
ownedLockMarker = null;
|
|
732
|
+
const completed = await captureSnapshot(isolatedGitRoot, join(tempRoot, 'completed.index'), null, null);
|
|
733
|
+
if (completed.headOid !== input.commitOid
|
|
734
|
+
|| completed.headRef !== null
|
|
735
|
+
|| completed.indexTreeOid !== completed.headTreeOid
|
|
736
|
+
|| completed.treeOid !== completed.headTreeOid) {
|
|
737
|
+
return { status: 'error', error: { code: 'git_error', message: 'Checkpoint index 恢复后仍未收敛到 clean 状态', recoveryState: 'uncertain' } };
|
|
738
|
+
}
|
|
739
|
+
return { status: 'checkpoint_recovered', isolatedFingerprint: completed.fingerprint };
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
return { status: 'error', error: { code: 'git_error', message: this.errorMessage(error), recoveryState: 'uncertain' } };
|
|
743
|
+
}
|
|
744
|
+
finally {
|
|
745
|
+
await removeBestEffort(ownedIndexLock);
|
|
746
|
+
await removeBestEffort(ownedLockMarker);
|
|
747
|
+
if (tempRoot)
|
|
748
|
+
await this.cleanup(tempRoot);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
371
751
|
async inspectReview(input) {
|
|
372
752
|
if (!OID_PATTERN.test(input.baseOid)) {
|
|
373
753
|
return { status: 'error', error: { code: 'invalid_input', message: 'Session Base OID 格式无效' } };
|
|
@@ -634,6 +1014,36 @@ class DefaultSessionCheckoutApplyEngine {
|
|
|
634
1014
|
await this.cleanup(tempRoot);
|
|
635
1015
|
}
|
|
636
1016
|
}
|
|
1017
|
+
async assessPreviewRecovery(input) {
|
|
1018
|
+
let tempRoot = null;
|
|
1019
|
+
try {
|
|
1020
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'domi-preview-recovery-preflight-'));
|
|
1021
|
+
const localGitRoot = await resolveGitRoot(input.localPath);
|
|
1022
|
+
const objectDirectory = join(tempRoot, 'objects');
|
|
1023
|
+
await mkdir(objectDirectory, { recursive: true });
|
|
1024
|
+
const sourceObjects = await sourceObjectDirectory(localGitRoot);
|
|
1025
|
+
const current = await captureSnapshot(localGitRoot, join(tempRoot, 'current.index'), objectDirectory, sourceObjects);
|
|
1026
|
+
const rollback = await prepareRollbackAssessment(tempRoot, sourceObjects, objectDirectory, localGitRoot, current, input.receipt);
|
|
1027
|
+
const finalize = await prepareFinalizeAssessment(tempRoot, sourceObjects, objectDirectory, localGitRoot, current, input.receipt);
|
|
1028
|
+
return {
|
|
1029
|
+
localFingerprint: current.fingerprint,
|
|
1030
|
+
localHeadOid: current.headOid,
|
|
1031
|
+
localHeadRef: current.headRef,
|
|
1032
|
+
localHeadTreeOid: current.headTreeOid,
|
|
1033
|
+
localIndexTreeOid: current.indexTreeOid,
|
|
1034
|
+
localWorkingTreeOid: current.treeOid,
|
|
1035
|
+
rollback,
|
|
1036
|
+
finalize,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
catch (error) {
|
|
1040
|
+
return { status: 'error', error: { code: 'git_error', message: this.errorMessage(error) } };
|
|
1041
|
+
}
|
|
1042
|
+
finally {
|
|
1043
|
+
if (tempRoot)
|
|
1044
|
+
await this.cleanup(tempRoot);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
637
1047
|
async rollback(input) {
|
|
638
1048
|
let tempRoot = null;
|
|
639
1049
|
try {
|
|
@@ -643,86 +1053,52 @@ class DefaultSessionCheckoutApplyEngine {
|
|
|
643
1053
|
await mkdir(objectDirectory, { recursive: true });
|
|
644
1054
|
const sourceObjects = await sourceObjectDirectory(localGitRoot);
|
|
645
1055
|
const current = await captureSnapshot(localGitRoot, join(tempRoot, 'current.index'), objectDirectory, sourceObjects);
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
if (current.headOid !== input.receipt.localHeadOid
|
|
650
|
-
&& !await isAncestor(localGitRoot, input.receipt.localHeadOid, current.headOid)) {
|
|
651
|
-
return { status: 'error', error: { code: 'stale_local', message: 'Local HEAD 不是 Preview 基线的安全快进,不能自动撤回' } };
|
|
652
|
-
}
|
|
653
|
-
let rollbackBaselineTreeOid = input.receipt.localWorkingTreeOid;
|
|
654
|
-
let expectedPreviewTreeOid = input.receipt.previewWorkingTreeOid;
|
|
655
|
-
if (current.headOid !== input.receipt.localHeadOid) {
|
|
656
|
-
const receiptHeadTreeOid = stdoutText(await runGit(localGitRoot, ['rev-parse', `${input.receipt.localHeadOid}^{tree}`]));
|
|
657
|
-
const advancedBaseline = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-rollback-advanced-baseline', receiptHeadTreeOid, current.headTreeOid, input.receipt.localWorkingTreeOid);
|
|
658
|
-
if (advancedBaseline.status === 'conflict') {
|
|
659
|
-
return {
|
|
660
|
-
status: 'error',
|
|
661
|
-
error: {
|
|
662
|
-
code: 'preview_modified',
|
|
663
|
-
message: `Local 新提交与 Preview 前的本地修改冲突,无法安全撤回:${advancedBaseline.conflictingFiles.join('、')}`,
|
|
664
|
-
},
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
const previewAbsence = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-rollback-preview-absence', input.receipt.previewWorkingTreeOid, advancedBaseline.treeOid, input.receipt.localWorkingTreeOid);
|
|
668
|
-
if (previewAbsence.status === 'conflict') {
|
|
669
|
-
return {
|
|
670
|
-
status: 'error',
|
|
671
|
-
error: {
|
|
672
|
-
code: 'preview_modified',
|
|
673
|
-
message: `Local 新提交与 Preview 任务增量冲突,无法安全撤回:${previewAbsence.conflictingFiles.join('、')}`,
|
|
674
|
-
},
|
|
675
|
-
};
|
|
676
|
-
}
|
|
677
|
-
if (previewAbsence.treeOid !== advancedBaseline.treeOid) {
|
|
678
|
-
return {
|
|
679
|
-
status: 'error',
|
|
680
|
-
error: {
|
|
681
|
-
code: 'preview_modified',
|
|
682
|
-
message: 'Local 新提交已经包含部分或全部 Preview 增量,不能通过撤回工作区改动来改写已提交历史',
|
|
683
|
-
},
|
|
684
|
-
};
|
|
685
|
-
}
|
|
686
|
-
const advancedPreview = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-rollback-advanced-preview', input.receipt.localWorkingTreeOid, advancedBaseline.treeOid, input.receipt.previewWorkingTreeOid);
|
|
687
|
-
if (advancedPreview.status === 'conflict') {
|
|
688
|
-
return {
|
|
689
|
-
status: 'error',
|
|
690
|
-
error: {
|
|
691
|
-
code: 'preview_modified',
|
|
692
|
-
message: `Local 新提交与 Preview 任务增量冲突,无法安全撤回:${advancedPreview.conflictingFiles.join('、')}`,
|
|
693
|
-
},
|
|
694
|
-
};
|
|
695
|
-
}
|
|
696
|
-
rollbackBaselineTreeOid = advancedBaseline.treeOid;
|
|
697
|
-
expectedPreviewTreeOid = advancedPreview.treeOid;
|
|
1056
|
+
const assessment = await prepareRollbackAssessment(tempRoot, sourceObjects, objectDirectory, localGitRoot, current, input.receipt);
|
|
1057
|
+
if (assessment.status === 'blocked') {
|
|
1058
|
+
return { status: 'error', error: { code: assessment.code, message: assessment.message } };
|
|
698
1059
|
}
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
error: {
|
|
704
|
-
code: 'preview_modified',
|
|
705
|
-
message: `Local 在 Preview 区域出现额外修改,无法安全撤回:${rollbackTree.conflictingFiles.join('、')}`,
|
|
706
|
-
},
|
|
707
|
-
};
|
|
708
|
-
}
|
|
709
|
-
const rollbackPatch = await treePatch(localGitRoot, current.treeOid, rollbackTree.treeOid, objectDirectory, sourceObjects);
|
|
1060
|
+
const rollbackPatch = await treePatch(localGitRoot, current.treeOid, assessment.targetTreeOid, objectDirectory, sourceObjects);
|
|
1061
|
+
// Journal persistence may await registry I/O, so it must happen before the authoritative
|
|
1062
|
+
// final Local CAS. From the snapshot below to git apply there is no callback/yield seam.
|
|
1063
|
+
await input.beforeWrite?.();
|
|
710
1064
|
await this.options.beforeFinalLocalValidation?.();
|
|
711
1065
|
const finalLocal = await captureSnapshot(localGitRoot, join(tempRoot, 'final-local.index'), objectDirectory, sourceObjects);
|
|
712
|
-
if (finalLocal.fingerprint !== current.fingerprint
|
|
1066
|
+
if (finalLocal.fingerprint !== current.fingerprint
|
|
1067
|
+
|| finalLocal.headOid !== current.headOid
|
|
1068
|
+
|| finalLocal.headRef !== current.headRef) {
|
|
713
1069
|
return { status: 'error', error: { code: 'stale_local', message: 'Local 在撤回 Preview 前发生变化,请重试' } };
|
|
714
1070
|
}
|
|
715
1071
|
if (rollbackPatch.length > 0) {
|
|
716
1072
|
await runGit(localGitRoot, ['apply', '--binary', '--whitespace=nowarn'], { input: rollbackPatch });
|
|
717
1073
|
}
|
|
718
|
-
|
|
1074
|
+
let rolledBack;
|
|
1075
|
+
try {
|
|
1076
|
+
await this.options.afterLocalWriteBeforeVerification?.();
|
|
1077
|
+
rolledBack = await captureSnapshot(localGitRoot, join(tempRoot, 'rolled-back.index'), null, null);
|
|
1078
|
+
}
|
|
1079
|
+
catch (error) {
|
|
1080
|
+
return {
|
|
1081
|
+
status: 'error',
|
|
1082
|
+
error: {
|
|
1083
|
+
code: 'git_error',
|
|
1084
|
+
message: `Preview 撤回写后无法完成验证,需要保留现场确认:${this.errorMessage(error)}`,
|
|
1085
|
+
recoveryState: 'uncertain',
|
|
1086
|
+
},
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
const expectedFingerprint = snapshotFingerprint(current.headOid, current.headRef, current.indexEntries, assessment.targetTreeOid);
|
|
719
1090
|
if (rolledBack.headOid !== current.headOid
|
|
720
1091
|
|| rolledBack.headRef !== current.headRef
|
|
721
1092
|
|| rolledBack.indexTreeOid !== current.indexTreeOid
|
|
722
|
-
|| rolledBack.treeOid !==
|
|
1093
|
+
|| rolledBack.treeOid !== assessment.targetTreeOid
|
|
1094
|
+
|| rolledBack.fingerprint !== expectedFingerprint) {
|
|
723
1095
|
return {
|
|
724
1096
|
status: 'error',
|
|
725
|
-
error: {
|
|
1097
|
+
error: {
|
|
1098
|
+
code: 'git_error',
|
|
1099
|
+
message: 'Preview 撤回后的 Local snapshot 与安全恢复结果不一致,需要保留现场确认',
|
|
1100
|
+
recoveryState: 'uncertain',
|
|
1101
|
+
},
|
|
726
1102
|
};
|
|
727
1103
|
}
|
|
728
1104
|
return { status: 'preview_rolled_back', changedFiles: [...input.receipt.changedFiles] };
|
|
@@ -740,11 +1116,10 @@ class DefaultSessionCheckoutApplyEngine {
|
|
|
740
1116
|
if (!commitMessage) {
|
|
741
1117
|
return { status: 'error', error: { code: 'invalid_input', message: '提交信息不能为空' } };
|
|
742
1118
|
}
|
|
743
|
-
if (!input.receipt.localHeadRef?.startsWith('refs/heads/')) {
|
|
744
|
-
return { status: 'error', error: { code: 'operation_not_allowed', message: 'Local 当前不是普通分支,不能自动创建任务提交' } };
|
|
745
|
-
}
|
|
746
1119
|
let tempRoot = null;
|
|
747
|
-
let
|
|
1120
|
+
let ownedIndexLock = null;
|
|
1121
|
+
let backupIndex = null;
|
|
1122
|
+
let preserveBackup = false;
|
|
748
1123
|
try {
|
|
749
1124
|
tempRoot = await mkdtemp(join(tmpdir(), 'domi-preview-finalize-'));
|
|
750
1125
|
const localGitRoot = await resolveGitRoot(input.localPath);
|
|
@@ -752,23 +1127,18 @@ class DefaultSessionCheckoutApplyEngine {
|
|
|
752
1127
|
await mkdir(objectDirectory, { recursive: true });
|
|
753
1128
|
const sourceObjects = await sourceObjectDirectory(localGitRoot);
|
|
754
1129
|
const current = await captureSnapshot(localGitRoot, join(tempRoot, 'current.index'), objectDirectory, sourceObjects);
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
return {
|
|
761
|
-
status: 'error',
|
|
762
|
-
error: {
|
|
763
|
-
code: 'preview_modified',
|
|
764
|
-
message: `Local 在 Preview 区域出现额外修改,无法可靠提交:${previewRemoval.conflictingFiles.join('、')}`,
|
|
765
|
-
},
|
|
766
|
-
};
|
|
1130
|
+
const realIndexPath = await resolveIndexPath(localGitRoot);
|
|
1131
|
+
const originalIndexBytes = await readFile(realIndexPath);
|
|
1132
|
+
const assessment = await prepareFinalizeAssessment(tempRoot, sourceObjects, objectDirectory, localGitRoot, current, input.receipt);
|
|
1133
|
+
if (assessment.status === 'blocked') {
|
|
1134
|
+
return { status: 'error', error: { code: assessment.code, message: assessment.message } };
|
|
767
1135
|
}
|
|
768
|
-
if (
|
|
1136
|
+
if (!assessment.commitRequired) {
|
|
769
1137
|
await this.options.beforeFinalLocalValidation?.();
|
|
770
1138
|
const finalLocal = await captureSnapshot(localGitRoot, join(tempRoot, 'final-empty.index'), objectDirectory, sourceObjects);
|
|
771
|
-
if (finalLocal.
|
|
1139
|
+
if (finalLocal.headOid !== current.headOid
|
|
1140
|
+
|| finalLocal.headRef !== current.headRef
|
|
1141
|
+
|| finalLocal.fingerprint !== current.fingerprint) {
|
|
772
1142
|
return { status: 'error', error: { code: 'stale_local', message: 'Local 在完成 Preview 前发生变化,请重试' } };
|
|
773
1143
|
}
|
|
774
1144
|
return {
|
|
@@ -778,77 +1148,172 @@ class DefaultSessionCheckoutApplyEngine {
|
|
|
778
1148
|
nextBaseOid: input.receipt.isolatedSnapshotOid,
|
|
779
1149
|
};
|
|
780
1150
|
}
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
return {
|
|
784
|
-
status: 'error',
|
|
785
|
-
error: {
|
|
786
|
-
code: 'commit_isolation_conflict',
|
|
787
|
-
message: `Preview 任务增量无法与 Local HEAD 可靠拆分:${taskTree.conflictingFiles.join('、')}`,
|
|
788
|
-
},
|
|
789
|
-
};
|
|
790
|
-
}
|
|
791
|
-
const finalIndexTree = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-index-preservation', current.headTreeOid, taskTree.treeOid, current.indexTreeOid);
|
|
792
|
-
if (finalIndexTree.status === 'conflict') {
|
|
793
|
-
return {
|
|
794
|
-
status: 'error',
|
|
795
|
-
error: {
|
|
796
|
-
code: 'commit_isolation_conflict',
|
|
797
|
-
message: `Preview 提交与 Local staged 修改无法可靠分离:${finalIndexTree.conflictingFiles.join('、')}`,
|
|
798
|
-
},
|
|
799
|
-
};
|
|
800
|
-
}
|
|
801
|
-
const taskPatch = await treePatch(localGitRoot, current.headTreeOid, taskTree.treeOid, objectDirectory, sourceObjects);
|
|
802
|
-
const finalIndexPatch = await treePatch(localGitRoot, taskTree.treeOid, finalIndexTree.treeOid, objectDirectory, sourceObjects);
|
|
1151
|
+
const taskPatch = await treePatch(localGitRoot, current.headTreeOid, assessment.taskTreeOid, objectDirectory, sourceObjects);
|
|
1152
|
+
const finalIndexPatch = await treePatch(localGitRoot, assessment.taskTreeOid, assessment.finalIndexTreeOid, objectDirectory, sourceObjects);
|
|
803
1153
|
const actualTaskTreeOid = await prepareIndexFromPatch(localGitRoot, join(tempRoot, 'task.index'), current.headOid, taskPatch);
|
|
1154
|
+
if (actualTaskTreeOid !== assessment.taskTreeOid) {
|
|
1155
|
+
return { status: 'error', error: { code: 'git_error', message: 'Preview 任务提交 tree 与恢复评估不一致' } };
|
|
1156
|
+
}
|
|
804
1157
|
const commitOid = await createUserCommit(localGitRoot, actualTaskTreeOid, current.headOid, commitMessage);
|
|
805
1158
|
const finalIndexPath = join(tempRoot, 'final.index');
|
|
806
|
-
await prepareIndexFromPatch(localGitRoot, finalIndexPath, commitOid, finalIndexPatch);
|
|
1159
|
+
const actualFinalIndexTreeOid = await prepareIndexFromPatch(localGitRoot, finalIndexPath, commitOid, finalIndexPatch);
|
|
1160
|
+
if (actualFinalIndexTreeOid !== assessment.finalIndexTreeOid) {
|
|
1161
|
+
return { status: 'error', error: { code: 'git_error', message: 'Preview 最终 index tree 与恢复评估不一致' } };
|
|
1162
|
+
}
|
|
1163
|
+
const expectedIndexEntries = (await runGit(localGitRoot, ['ls-files', '--stage', '-z'], { env: { GIT_INDEX_FILE: finalIndexPath } })).stdout;
|
|
1164
|
+
const intendedIndexBytes = await readFile(finalIndexPath);
|
|
1165
|
+
const expectedFingerprint = snapshotFingerprint(commitOid, current.headRef, expectedIndexEntries, assessment.expectedWorkingTreeOid);
|
|
807
1166
|
await input.beforeCommit?.(commitOid);
|
|
1167
|
+
const branchRef = current.headRef;
|
|
1168
|
+
if (!branchRef?.startsWith('refs/heads/')) {
|
|
1169
|
+
return { status: 'error', error: { code: 'operation_not_allowed', message: 'Local 当前不是普通分支,不能自动创建任务提交' } };
|
|
1170
|
+
}
|
|
1171
|
+
backupIndex = `${realIndexPath}.domi-backup-${randomUUID()}`;
|
|
1172
|
+
await copyFile(realIndexPath, backupIndex);
|
|
808
1173
|
await this.options.beforeFinalLocalValidation?.();
|
|
1174
|
+
try {
|
|
1175
|
+
// Use Git's canonical index.lock protocol. While this lock exists, concurrent git add/reset
|
|
1176
|
+
// cannot mutate the real index between the authoritative snapshot and our atomic replacement.
|
|
1177
|
+
ownedIndexLock = await writeIndexLock(realIndexPath, intendedIndexBytes);
|
|
1178
|
+
}
|
|
1179
|
+
catch (error) {
|
|
1180
|
+
if (isIndexLockContention(error)) {
|
|
1181
|
+
return { status: 'error', error: { code: 'stale_local', message: 'Local index 正在被其他 Git 操作更新,请重试' } };
|
|
1182
|
+
}
|
|
1183
|
+
throw error;
|
|
1184
|
+
}
|
|
809
1185
|
const finalLocal = await captureSnapshot(localGitRoot, join(tempRoot, 'final-local.index'), objectDirectory, sourceObjects);
|
|
810
|
-
|
|
1186
|
+
const finalIndexBytes = await readFile(realIndexPath);
|
|
1187
|
+
if (finalLocal.headOid !== current.headOid
|
|
1188
|
+
|| finalLocal.headRef !== current.headRef
|
|
1189
|
+
|| finalLocal.fingerprint !== current.fingerprint
|
|
1190
|
+
|| !finalIndexBytes.equals(originalIndexBytes)) {
|
|
811
1191
|
return { status: 'error', error: { code: 'stale_local', message: 'Local 在 Preview 提交写入前发生变化,请重试' } };
|
|
812
1192
|
}
|
|
813
|
-
const realIndexPath = await resolveIndexPath(localGitRoot);
|
|
814
|
-
adjacentIndex = `${realIndexPath}.domi-${randomUUID()}`;
|
|
815
|
-
await copyFile(finalIndexPath, adjacentIndex);
|
|
816
1193
|
let refUpdated = false;
|
|
1194
|
+
let indexReplaced = false;
|
|
1195
|
+
let writeError = null;
|
|
817
1196
|
try {
|
|
818
|
-
await runGit(localGitRoot, ['update-ref',
|
|
1197
|
+
await runGit(localGitRoot, ['update-ref', branchRef, commitOid, current.headOid]);
|
|
819
1198
|
refUpdated = true;
|
|
820
|
-
await rename(
|
|
821
|
-
|
|
1199
|
+
await rename(ownedIndexLock, realIndexPath);
|
|
1200
|
+
ownedIndexLock = null;
|
|
1201
|
+
indexReplaced = true;
|
|
822
1202
|
}
|
|
823
1203
|
catch (error) {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1204
|
+
writeError = error;
|
|
1205
|
+
}
|
|
1206
|
+
if (!writeError) {
|
|
1207
|
+
try {
|
|
1208
|
+
await this.options.afterLocalWriteBeforeVerification?.();
|
|
1209
|
+
const committed = await captureSnapshot(localGitRoot, join(tempRoot, 'committed.index'), null, null);
|
|
1210
|
+
const committedIndexBytes = await readFile(realIndexPath);
|
|
1211
|
+
if (committed.headOid === commitOid
|
|
1212
|
+
&& committed.headRef === branchRef
|
|
1213
|
+
&& committed.indexTreeOid === assessment.finalIndexTreeOid
|
|
1214
|
+
&& committed.treeOid === assessment.expectedWorkingTreeOid
|
|
1215
|
+
&& committed.fingerprint === expectedFingerprint
|
|
1216
|
+
&& committedIndexBytes.equals(intendedIndexBytes)) {
|
|
1217
|
+
await removeBestEffort(backupIndex);
|
|
1218
|
+
backupIndex = null;
|
|
829
1219
|
return {
|
|
830
|
-
status: '
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
},
|
|
1220
|
+
status: 'finished',
|
|
1221
|
+
changedFiles: [...input.receipt.changedFiles],
|
|
1222
|
+
commitOid,
|
|
1223
|
+
nextBaseOid: input.receipt.isolatedSnapshotOid,
|
|
835
1224
|
};
|
|
836
1225
|
}
|
|
1226
|
+
writeError = new Error('Preview 提交写后验证失败');
|
|
1227
|
+
}
|
|
1228
|
+
catch (error) {
|
|
1229
|
+
writeError = error;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
const rollbackErrors = [];
|
|
1233
|
+
if (refUpdated && !indexReplaced && ownedIndexLock) {
|
|
1234
|
+
// The ref moved but the index replacement did not. We still own index.lock, so no Git
|
|
1235
|
+
// index writer can race the ref CAS rollback; the real index is already the original one.
|
|
1236
|
+
try {
|
|
1237
|
+
await runGit(localGitRoot, ['update-ref', branchRef, current.headOid, commitOid]);
|
|
1238
|
+
refUpdated = false;
|
|
1239
|
+
await removeBestEffort(ownedIndexLock);
|
|
1240
|
+
ownedIndexLock = null;
|
|
1241
|
+
}
|
|
1242
|
+
catch (error) {
|
|
1243
|
+
rollbackErrors.push(`ref: ${this.errorMessage(error)}`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
else if (refUpdated && indexReplaced && backupIndex) {
|
|
1247
|
+
// Compensation is itself a CAS transaction. Never restore the old backup over an index
|
|
1248
|
+
// that changed after Domi's write; preserve both the user's index and our backup instead.
|
|
1249
|
+
try {
|
|
1250
|
+
const backupBytes = await readFile(backupIndex);
|
|
1251
|
+
ownedIndexLock = await writeIndexLock(realIndexPath, backupBytes);
|
|
1252
|
+
const currentIndexBytes = await readFile(realIndexPath);
|
|
1253
|
+
if (!currentIndexBytes.equals(intendedIndexBytes)) {
|
|
1254
|
+
rollbackErrors.push('index: Local index 在 Preview 写入后发生变化,拒绝覆盖并发 staged 状态');
|
|
1255
|
+
}
|
|
1256
|
+
else {
|
|
1257
|
+
await runGit(localGitRoot, ['update-ref', branchRef, current.headOid, commitOid]);
|
|
1258
|
+
refUpdated = false;
|
|
1259
|
+
await rename(ownedIndexLock, realIndexPath);
|
|
1260
|
+
ownedIndexLock = null;
|
|
1261
|
+
indexReplaced = false;
|
|
1262
|
+
}
|
|
837
1263
|
}
|
|
838
|
-
|
|
1264
|
+
catch (error) {
|
|
1265
|
+
rollbackErrors.push(`compensation: ${this.errorMessage(error)}`);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
else if (refUpdated) {
|
|
1269
|
+
rollbackErrors.push('ref: 缺少可验证的 index 补偿证据,拒绝部分回滚');
|
|
1270
|
+
}
|
|
1271
|
+
let restored = false;
|
|
1272
|
+
if (rollbackErrors.length === 0) {
|
|
1273
|
+
try {
|
|
1274
|
+
const restoredLocal = await captureSnapshot(localGitRoot, join(tempRoot, 'restored.index'), null, null);
|
|
1275
|
+
const restoredIndexBytes = await readFile(realIndexPath);
|
|
1276
|
+
restored = restoredLocal.headOid === current.headOid
|
|
1277
|
+
&& restoredLocal.headRef === current.headRef
|
|
1278
|
+
&& restoredLocal.indexTreeOid === current.indexTreeOid
|
|
1279
|
+
&& restoredLocal.treeOid === current.treeOid
|
|
1280
|
+
&& restoredLocal.fingerprint === current.fingerprint
|
|
1281
|
+
&& restoredIndexBytes.equals(originalIndexBytes);
|
|
1282
|
+
}
|
|
1283
|
+
catch (error) {
|
|
1284
|
+
rollbackErrors.push(`verification: ${this.errorMessage(error)}`);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
if (restored) {
|
|
1288
|
+
await removeBestEffort(backupIndex);
|
|
1289
|
+
backupIndex = null;
|
|
1290
|
+
return {
|
|
1291
|
+
status: 'error',
|
|
1292
|
+
error: {
|
|
1293
|
+
code: 'git_error',
|
|
1294
|
+
message: `Preview 提交写入未能验证,已完整回滚:${this.errorMessage(writeError)}`,
|
|
1295
|
+
recoveryState: 'unchanged',
|
|
1296
|
+
},
|
|
1297
|
+
};
|
|
839
1298
|
}
|
|
1299
|
+
preserveBackup = true;
|
|
1300
|
+
const rollbackDetail = rollbackErrors.length > 0 ? `;${rollbackErrors.join(';')}` : '';
|
|
840
1301
|
return {
|
|
841
|
-
status: '
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1302
|
+
status: 'error',
|
|
1303
|
+
error: {
|
|
1304
|
+
code: 'git_error',
|
|
1305
|
+
message: `Preview 提交写入后无法证明成功或完整回滚,需要保留现场确认:${this.errorMessage(writeError)}${rollbackDetail}`,
|
|
1306
|
+
recoveryState: 'uncertain',
|
|
1307
|
+
},
|
|
845
1308
|
};
|
|
846
1309
|
}
|
|
847
1310
|
catch (error) {
|
|
848
1311
|
return { status: 'error', error: { code: 'git_error', message: this.errorMessage(error) } };
|
|
849
1312
|
}
|
|
850
1313
|
finally {
|
|
851
|
-
await removeBestEffort(
|
|
1314
|
+
await removeBestEffort(ownedIndexLock);
|
|
1315
|
+
if (!preserveBackup)
|
|
1316
|
+
await removeBestEffort(backupIndex);
|
|
852
1317
|
if (tempRoot)
|
|
853
1318
|
await this.cleanup(tempRoot);
|
|
854
1319
|
}
|