@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.
- package/dist/clients/attach/viewer.js +394 -394
- package/dist/core/__tests__/integration/worktree-reap.test.js +31 -0
- package/dist/core/worktree-mutation-async.js +1 -2
- package/dist/core/worktree.d.ts +0 -1
- package/dist/core/worktree.js +0 -113
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -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'
|
|
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.');
|
package/dist/core/worktree.d.ts
CHANGED
|
@@ -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. */
|
package/dist/core/worktree.js
CHANGED
|
@@ -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
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
5219
|
+
"version": "0.3.310",
|
|
5220
5220
|
"license": "UNLICENSED",
|
|
5221
5221
|
"dependencies": {
|
|
5222
5222
|
"@north-light/crouter-api": "^0.3.295"
|