@north-light/crouter 0.3.309 → 0.3.310

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.
@@ -11,6 +11,7 @@ import { join } from 'node:path';
11
11
  import { gitSync } from '../../git.js';
12
12
  import { withExclusiveDirectoryLockAsync } from '../../exclusive-lock.js';
13
13
  import { createManagedWorktree, closeManagedWorktree, worktreeLockPathForCommonDir } from '../../worktree.js';
14
+ import { abandonManagedWorktreeAsync } from '../../worktree-mutation-async.js';
14
15
  import { reconcileManagedWorktree } from '../../worktree-sweep.js';
15
16
  import { createApiServer } from '../../../daemon/api/server.js';
16
17
  import { ManagedWorktreeSweepReconciler } from '../../../daemon/reconcilers/managed-worktree-sweep.js';
@@ -476,6 +477,36 @@ test('async close yields while an async repository holder runs, keeping API requ
476
477
  await server.close();
477
478
  }
478
479
  });
480
+ test('async abandon retains clean committed work after its base branch is deleted without a remote (#929)', async () => {
481
+ const repo = mkdtempSync(join(tmpdir(), 'crtr-worktree-929-'));
482
+ try {
483
+ run(['init', '-b', 'main'], repo);
484
+ run(['config', 'user.email', 'test@example.com'], repo);
485
+ run(['config', 'user.name', 'Test'], repo);
486
+ writeFileSync(join(repo, 'README.md'), 'hello\n');
487
+ run(['add', '.'], repo);
488
+ run(['commit', '-m', 'init'], repo);
489
+ const nodeId = 'wt-abandon-missing-base';
490
+ const wt = createManagedWorktree(repo, nodeId);
491
+ createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
492
+ writeFileSync(join(wt.path, 'committed.txt'), 'committed work\n');
493
+ run(['add', 'committed.txt'], wt.path);
494
+ run(['commit', '-m', 'real work'], wt.path);
495
+ const tipSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
496
+ assert.equal(gitSync(['remote'], repo).stdout.trim(), '', 'the repository has no remote that could prove delivery');
497
+ run(['checkout', '--detach'], repo);
498
+ run(['branch', '-D', 'main'], repo);
499
+ const result = await abandonManagedWorktreeAsync(nodeId, 'test-invoker');
500
+ assert.equal(result.branch_deleted, false, 'without containment proof the committed branch must be retained');
501
+ assert.equal(result.tip_sha, tipSha, 'abandon must retain the exact committed tip');
502
+ assert.equal(existsSync(wt.path), false, 'abandon removes only the checkout');
503
+ assert.equal(gitSync(['rev-parse', wt.branch], repo).stdout.trim(), tipSha, 'the retained branch still reaches the committed work');
504
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'abandoned', 'the node records the abandoned disposition');
505
+ }
506
+ finally {
507
+ rmSync(repo, { recursive: true, force: true });
508
+ }
509
+ });
479
510
  test('abandon preserves committed and dirty work, records its retained tip, and releases finalization', async () => {
480
511
  const nodeId = 'wt-abandon';
481
512
  const wt = createManagedWorktree(sourceDir, nodeId);
@@ -247,9 +247,8 @@ export async function abandonManagedWorktreeAsync(nodeId, by) {
247
247
  cleanBase = base;
248
248
  }
249
249
  catch (error) {
250
- if (!(error instanceof WorktreeError) || error.code !== 'missing_base_branch' || head !== current.base_sha)
250
+ if (!(error instanceof WorktreeError) || error.code !== 'missing_base_branch')
251
251
  throw error;
252
- cleanBase = current.base_sha;
253
252
  }
254
253
  const at = new Date().toISOString();
255
254
  const status = await runGit(current.path, ['status', '--porcelain'], 'status_failed', 'Inspect the worktree manually, then retry.');
@@ -76,7 +76,6 @@ export interface AbandonManagedWorktreeResult {
76
76
  worktree_path: string;
77
77
  branch_deleted: boolean;
78
78
  }
79
- export declare function abandonManagedWorktree(nodeId: string, by: string): AbandonManagedWorktreeResult;
80
79
  /** Recovery guidance for a vanished checkout whose refs do not prove that its
81
80
  * work is delivered. This deliberately never recommends `worktree close`:
82
81
  * that command cannot operate on the absent checkout. */
@@ -3,7 +3,6 @@ import { createHash } from 'node:crypto';
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { gitAsync, gitSync } from './git.js';
5
5
  import { crtrHome } from './canvas/paths.js';
6
- import { isPidAlive } from './canvas/pid.js';
7
6
  import { getNode, updateNode } from './canvas/index.js';
8
7
  import { CONFIG_FILE, CRTR_DIR_NAME, STATE_FILE } from '../types.js';
9
8
  import { withExclusiveDirectoryLock } from './exclusive-lock.js';
@@ -658,118 +657,6 @@ export function closeManagedWorktree(nodeId) {
658
657
  // the child checkout instead of the parent that created it.
659
658
  return withRepositoryWorktreeLock(existsSync(wt.path) ? wt.path : wt.repo_root, () => closeManagedWorktreeLocked(nodeId));
660
659
  }
661
- const ABANDON_AUTHOR_NAME = 'crtr-abandon';
662
- const ABANDON_AUTHOR_EMAIL = 'crtr-abandon@localhost';
663
- const ABANDON_SUBJECT = 'crtr-abandon';
664
- function persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by, branch = wt.branch) {
665
- updateNode(nodeId, {
666
- cwd: wt.repo_root,
667
- managed_worktree: {
668
- ...wt,
669
- state: 'abandoned',
670
- abandoned: { branch, tip_sha: tipSha, at, by },
671
- },
672
- });
673
- }
674
- /** The disposal path for `abandon` when the recorded `crtr/*` branch is gone:
675
- * ordinary PR work moved this checkout onto a real branch. Abandon never
676
- * lands that branch and — unlike the ordinary path above — never invents a
677
- * commit on it: a synthetic `crtr-abandon` marker on someone's real PR
678
- * branch is not a trade this code may make on their behalf. So this
679
- * requires the checkout already clean and not mid-operation, then removes
680
- * only the checkout and preserves the actual branch and its commits exactly
681
- * as they stand. The caller already confirmed the node is not running. */
682
- function abandonBranchMovedWorktree(nodeId, wt, actualBranch, by) {
683
- if (isRebaseInProgress(wt.path)) {
684
- throw new WorktreeError('rebase_in_progress', 'this managed worktree is mid-rebase', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr node worktree abandon\`.`);
685
- }
686
- const dirty = statusPorcelain(wt.path);
687
- if (dirty !== '') {
688
- throw new WorktreeError('dirty_worktree', 'managed worktree has uncommitted changes', `The recorded branch ${wt.branch} no longer exists; its checkout is now on ${actualBranch} with uncommitted changes crtr will not commit on your behalf. Commit or stash them in ${wt.path}, then rerun \`crtr node worktree abandon\`.`);
689
- }
690
- if (registeredWorktree(wt.repo_root, wt.path, actualBranch) !== true) {
691
- throw new WorktreeError('wrong_branch', `managed worktree's recorded branch ${wt.branch} no longer exists, and its checkout at ${wt.path} is not registered to ${actualBranch} either`, `Inspect ${wt.path} and its git worktree registration, then ask the user with \`crtr human send\` if you are unsure how to proceed.`);
692
- }
693
- const actualSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'abandon_tip_failed', 'Inspect the worktree manually, then retry.');
694
- // Recheck identity immediately before the destructive step.
695
- if (registeredWorktree(wt.repo_root, wt.path, actualBranch) !== true || currentBranch(wt.path) !== actualBranch) {
696
- throw new WorktreeError('wrong_branch', `managed worktree at ${wt.path} changed while abandoning`, `Inspect ${wt.path}, then rerun \`crtr node worktree abandon\`.`);
697
- }
698
- runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
699
- const at = new Date().toISOString();
700
- persistAbandonedWorktreeState(nodeId, wt, actualSha, at, by, actualBranch);
701
- return { node_id: nodeId, branch: actualBranch, tip_sha: actualSha, worktree_path: wt.path, branch_deleted: false };
702
- }
703
- function createAbandonMarker(wt, parentSha, nodeId, at) {
704
- const treeSha = runGit(wt.repo_root, ['rev-parse', `${parentSha}^{tree}`], 'abandon_tree_failed', 'Inspect the branch manually, then retry.');
705
- const markerSha = runGit(wt.repo_root, ['-c', `user.name=${ABANDON_AUTHOR_NAME}`, '-c', `user.email=${ABANDON_AUTHOR_EMAIL}`, 'commit-tree', treeSha, '-p', parentSha, '-m', ABANDON_SUBJECT, '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the branch manually, then retry.');
706
- runGit(wt.repo_root, ['update-ref', `refs/heads/${wt.branch}`, markerSha, parentSha], 'abandon_marker_failed', 'The branch changed while abandon was starting; inspect the worktree, then retry.');
707
- return markerSha;
708
- }
709
- function abandonManagedWorktreeLocked(nodeId, by) {
710
- const node = getNode(nodeId);
711
- if (node === null) {
712
- throw new WorktreeError('unknown_node', `unknown node: ${nodeId}`, 'Pass a real node id.');
713
- }
714
- const wt = openManagedWorktreeForNode(nodeId);
715
- if (wt === null) {
716
- const { message, next } = noManagedWorktreeMessage(nodeId, 'abandon');
717
- throw new WorktreeError('no_managed_worktree', message, next);
718
- }
719
- if (node.status === 'active' || node.status === 'idle' || isPidAlive(node.pi_pid)) {
720
- throw new WorktreeError('node_running', `node ${nodeId} is still running`, 'Stop the node before abandoning its managed worktree.');
721
- }
722
- if (!existsSync(wt.path)) {
723
- throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, missingWorktreeRecoveryHint(wt));
724
- }
725
- const actualBranch = currentBranch(wt.path);
726
- if (actualBranch !== wt.branch) {
727
- const recordedRefExists = recordedBranchExists(wt.path, wt.branch);
728
- if (recordedRefExists || actualBranch === 'HEAD') {
729
- throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}${actualBranch === 'HEAD' ? ', found detached HEAD' : ''})`, recordedRefExists
730
- ? `Check out ${wt.branch} in ${wt.path}, then rerun \`crtr node worktree abandon\`.`
731
- : `The recorded branch ${wt.branch} no longer exists, and ${wt.path} is in detached HEAD, so there is no branch to preserve. Check out or create a branch there, then rerun \`crtr node worktree abandon\`.`);
732
- }
733
- // The recorded crtr/* branch is gone: ordinary PR work moved this checkout
734
- // onto a real branch.
735
- return abandonBranchMovedWorktree(nodeId, wt, actualBranch, by);
736
- }
737
- const at = new Date().toISOString();
738
- const clean = proveCleanEmptyManagedWorktree(wt);
739
- if (clean !== null && clean.branchSha === wt.base_sha) {
740
- const markerSha = createAbandonMarker(wt, clean.branchSha, nodeId, at);
741
- runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
742
- const deleted = bestEffortDeleteBranchWithContainmentProof(wt, markerSha, `refs/heads/${wt.base_ref}`, clean.baseSha);
743
- if (deleted.branchDeleted) {
744
- persistClosedWorktreeState(nodeId, wt, 'complete');
745
- return { node_id: nodeId, branch: wt.branch, tip_sha: markerSha, worktree_path: wt.path, branch_deleted: true };
746
- }
747
- const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
748
- persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
749
- return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
750
- }
751
- // A clean branch with committed work already has its complete history. The
752
- // abandon record supplies the disposition; an empty provenance commit would
753
- // only move the local branch away from an already-pushed tip.
754
- if (!isRebaseInProgress(wt.path) && statusPorcelain(wt.path) === '') {
755
- runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
756
- const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
757
- persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
758
- return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
759
- }
760
- runGit(wt.path, ['add', '--all'], 'abandon_stage_failed', 'Inspect the worktree manually, then retry.');
761
- runGit(wt.path, ['-c', `user.name=${ABANDON_AUTHOR_NAME}`, '-c', `user.email=${ABANDON_AUTHOR_EMAIL}`, 'commit', '--allow-empty', '-m', ABANDON_SUBJECT, '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the worktree manually, then retry.');
762
- runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
763
- const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
764
- persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
765
- return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
766
- }
767
- export function abandonManagedWorktree(nodeId, by) {
768
- const wt = openManagedWorktreeForNode(nodeId);
769
- if (wt === null)
770
- return abandonManagedWorktreeLocked(nodeId, by);
771
- return withRepositoryWorktreeLock(wt.repo_root, () => abandonManagedWorktreeLocked(nodeId, by));
772
- }
773
660
  // Push-final auto-drop of a clean, zero-commit managed worktree
774
661
  /** Delete a source branch only while the ref that proved it already delivered
775
662
  * is still at the exact inspected SHA. `containingRef` can be the recorded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.309",
3
+ "version": "0.3.310",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.309",
3
+ "version": "0.3.310",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.309",
9
+ "version": "0.3.310",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "workspaces": [
@@ -5206,17 +5206,17 @@
5206
5206
  },
5207
5207
  "packages/crouter-api": {
5208
5208
  "name": "@north-light/crouter-api",
5209
- "version": "0.3.309",
5209
+ "version": "0.3.310",
5210
5210
  "license": "UNLICENSED"
5211
5211
  },
5212
5212
  "packages/crouter-env-docker": {
5213
5213
  "name": "@north-light/crouter-env-docker",
5214
- "version": "0.3.309",
5214
+ "version": "0.3.310",
5215
5215
  "license": "UNLICENSED"
5216
5216
  },
5217
5217
  "packages/crouter-sdk": {
5218
5218
  "name": "@north-light/crouter-sdk",
5219
- "version": "0.3.309",
5219
+ "version": "0.3.310",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
5222
  "@north-light/crouter-api": "^0.3.295"