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.
Files changed (92) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.en.md +48 -18
  3. package/README.md +49 -18
  4. package/lib/adapters/git.js +8 -0
  5. package/lib/adapters/git.js.map +1 -1
  6. package/lib/adapters/registry.js +107 -2
  7. package/lib/adapters/registry.js.map +1 -1
  8. package/lib/checkpoint.d.ts +3 -0
  9. package/lib/checkpoint.js +29 -0
  10. package/lib/checkpoint.js.map +1 -0
  11. package/lib/client/WorktreeReviewRow.d.ts +1 -1
  12. package/lib/client/WorktreeReviewRow.js +26 -6
  13. package/lib/client/WorktreeReviewRow.js.map +1 -1
  14. package/lib/client/actions.d.ts +49 -4
  15. package/lib/client/actions.js +104 -0
  16. package/lib/client/actions.js.map +1 -1
  17. package/lib/client/console-remote/adapter.js +10 -3
  18. package/lib/client/console-remote/adapter.js.map +1 -1
  19. package/lib/client/review-console/DeliveryProof.d.ts +5 -0
  20. package/lib/client/review-console/DeliveryProof.js +32 -0
  21. package/lib/client/review-console/DeliveryProof.js.map +1 -0
  22. package/lib/client/review-console/PreflightStatus.d.ts +12 -0
  23. package/lib/client/review-console/PreflightStatus.js +31 -0
  24. package/lib/client/review-console/PreflightStatus.js.map +1 -0
  25. package/lib/client/review-console/ReviewActions.d.ts +4 -1
  26. package/lib/client/review-console/ReviewActions.js +657 -31
  27. package/lib/client/review-console/ReviewActions.js.map +1 -1
  28. package/lib/client/review-console/WorktreeReviewPanel.d.ts +3 -1
  29. package/lib/client/review-console/WorktreeReviewPanel.js +35 -5
  30. package/lib/client/review-console/WorktreeReviewPanel.js.map +1 -1
  31. package/lib/client/review-console/preflight-cache.d.ts +21 -0
  32. package/lib/client/review-console/preflight-cache.js +98 -0
  33. package/lib/client/review-console/preflight-cache.js.map +1 -0
  34. package/lib/client/review-console/preview-recovery-cache.d.ts +20 -0
  35. package/lib/client/review-console/preview-recovery-cache.js +103 -0
  36. package/lib/client/review-console/preview-recovery-cache.js.map +1 -0
  37. package/lib/client/review-console/recovery-continuation.d.ts +53 -0
  38. package/lib/client/review-console/recovery-continuation.js +483 -0
  39. package/lib/client/review-console/recovery-continuation.js.map +1 -0
  40. package/lib/client/review-console/review-console.styles.js +36 -4
  41. package/lib/client/review-console/review-console.styles.js.map +1 -1
  42. package/lib/client/target-console/TargetStatusAction.d.ts +4 -2
  43. package/lib/client/target-console/TargetStatusAction.js +196 -23
  44. package/lib/client/target-console/TargetStatusAction.js.map +1 -1
  45. package/lib/client/target-console/WorktreeConsoleView.d.ts +4 -2
  46. package/lib/client/target-console/WorktreeConsoleView.js +33 -73
  47. package/lib/client/target-console/WorktreeConsoleView.js.map +1 -1
  48. package/lib/client/target-console/WorktreeManagerModal.d.ts +13 -0
  49. package/lib/client/target-console/WorktreeManagerModal.js +8 -0
  50. package/lib/client/target-console/WorktreeManagerModal.js.map +1 -0
  51. package/lib/client/target-console/WorktreeReviewStatus.d.ts +3 -1
  52. package/lib/client/target-console/WorktreeReviewStatus.js +35 -15
  53. package/lib/client/target-console/WorktreeReviewStatus.js.map +1 -1
  54. package/lib/client/target-console/index.d.ts +2 -1
  55. package/lib/client/target-console/index.js +4 -3
  56. package/lib/client/target-console/index.js.map +1 -1
  57. package/lib/client/target-console/target-console.styles.js +22 -3
  58. package/lib/client/target-console/target-console.styles.js.map +1 -1
  59. package/lib/client.js +2976 -211
  60. package/lib/console-contract.d.ts +110 -1
  61. package/lib/console-contract.js.map +1 -1
  62. package/lib/console-host/control-plane.d.ts +6 -1
  63. package/lib/console-host/control-plane.js +363 -44
  64. package/lib/console-host/control-plane.js.map +1 -1
  65. package/lib/console-host/errors.d.ts +2 -2
  66. package/lib/console-host/errors.js +21 -5
  67. package/lib/console-host/errors.js.map +1 -1
  68. package/lib/console-host/projection.d.ts +5 -3
  69. package/lib/console-host/projection.js +103 -11
  70. package/lib/console-host/projection.js.map +1 -1
  71. package/lib/console-host/service.d.ts +10 -5
  72. package/lib/console-host/service.js +48 -6
  73. package/lib/console-host/service.js.map +1 -1
  74. package/lib/console-remote/descriptors.js +37 -1
  75. package/lib/console-remote/descriptors.js.map +1 -1
  76. package/lib/console-remote/remote.d.ts +10 -5
  77. package/lib/console-remote/remote.js.map +1 -1
  78. package/lib/console-remote/schemas.d.ts +103 -1
  79. package/lib/console-remote/schemas.js +166 -6
  80. package/lib/console-remote/schemas.js.map +1 -1
  81. package/lib/index.d.ts +13 -2
  82. package/lib/index.js.map +1 -1
  83. package/lib/ports.d.ts +79 -1
  84. package/lib/session-checkout-apply.d.ts +76 -0
  85. package/lib/session-checkout-apply.js +608 -143
  86. package/lib/session-checkout-apply.js.map +1 -1
  87. package/lib/session-checkout-module.js +775 -117
  88. package/lib/session-checkout-module.js.map +1 -1
  89. package/lib/types.d.ts +104 -3
  90. package/lib/types.js.map +1 -1
  91. package/package.json +14 -14
  92. 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 = createHash('sha256')
169
- .update(headOid)
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
- if (current.headRef !== input.receipt.localHeadRef) {
647
- return { status: 'error', error: { code: 'stale_local', message: 'Local branch 已变化,不能自动撤回 Preview' } };
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 rollbackTree = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-rollback', expectedPreviewTreeOid, current.treeOid, rollbackBaselineTreeOid);
700
- if (rollbackTree.status === 'conflict') {
701
- return {
702
- status: 'error',
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 || finalLocal.headOid !== current.headOid) {
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
- const rolledBack = await captureSnapshot(localGitRoot, join(tempRoot, 'rolled-back.index'), null, null);
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 !== rollbackTree.treeOid) {
1093
+ || rolledBack.treeOid !== assessment.targetTreeOid
1094
+ || rolledBack.fingerprint !== expectedFingerprint) {
723
1095
  return {
724
1096
  status: 'error',
725
- error: { code: 'git_error', message: 'Preview 撤回后的 Local snapshot 与安全恢复结果不一致,需要保留现场确认' },
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 adjacentIndex = null;
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
- if (current.headOid !== input.receipt.localHeadOid || current.headRef !== input.receipt.localHeadRef) {
756
- return { status: 'error', error: { code: 'stale_local', message: 'Local branch/HEAD 已变化,不能完成 Preview 提交' } };
757
- }
758
- const previewRemoval = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-finalize-separation', input.receipt.previewWorkingTreeOid, current.treeOid, input.receipt.localWorkingTreeOid);
759
- if (previewRemoval.status === 'conflict') {
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 (input.receipt.changedFiles.length === 0) {
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.fingerprint !== current.fingerprint) {
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 taskTree = await computeTreeMerge(tempRoot, sourceObjects, objectDirectory, localGitRoot, 'preview-task-isolation', input.receipt.localWorkingTreeOid, current.headTreeOid, input.receipt.previewWorkingTreeOid);
782
- if (taskTree.status === 'conflict') {
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
- if (finalLocal.fingerprint !== current.fingerprint || finalLocal.headOid !== current.headOid) {
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', input.receipt.localHeadRef, commitOid, current.headOid]);
1197
+ await runGit(localGitRoot, ['update-ref', branchRef, commitOid, current.headOid]);
819
1198
  refUpdated = true;
820
- await rename(adjacentIndex, realIndexPath);
821
- adjacentIndex = null;
1199
+ await rename(ownedIndexLock, realIndexPath);
1200
+ ownedIndexLock = null;
1201
+ indexReplaced = true;
822
1202
  }
823
1203
  catch (error) {
824
- if (refUpdated) {
825
- try {
826
- await runGit(localGitRoot, ['update-ref', input.receipt.localHeadRef, current.headOid, commitOid]);
827
- }
828
- catch (rollbackError) {
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: 'error',
831
- error: {
832
- code: 'git_error',
833
- message: `Preview 提交写入失败且 ref 无法回滚:${this.errorMessage(error)};${this.errorMessage(rollbackError)}`,
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
- return { status: 'error', error: { code: 'git_error', message: `Preview 提交写入失败,已回滚:${this.errorMessage(error)}` } };
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: 'finished',
842
- changedFiles: [...input.receipt.changedFiles],
843
- commitOid,
844
- nextBaseOid: input.receipt.isolatedSnapshotOid,
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(adjacentIndex);
1314
+ await removeBestEffort(ownedIndexLock);
1315
+ if (!preserveBackup)
1316
+ await removeBestEffort(backupIndex);
852
1317
  if (tempRoot)
853
1318
  await this.cleanup(tempRoot);
854
1319
  }