@polymorphism-tech/morph-spec 8.4.0 → 8.5.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/README.md +8 -5
- package/bin/morph-spec.js +13 -10
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +7 -4
- package/docs/QUICKSTART.md +7 -4
- package/framework/CLAUDE.md +6 -5
- package/framework/MORPH.md +3 -1
- package/framework/commands/morph-archive.md +11 -4
- package/framework/schemas/feature.schema.json +2 -2
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +8 -5
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +3 -3
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +4 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/worktree.js +154 -357
- package/src/commands/state/archive.js +148 -70
- package/src/commands/state/create.js +46 -2
- package/src/commands/state/finish.js +422 -0
- package/src/core/state/state-manager.js +2 -2
- package/src/dashboard/scanner/state-scanner.js +1 -1
- package/src/lib/generators/handoff-generator.js +2 -2
- package/src/lib/git/default-branch.js +73 -0
- package/src/lib/worktree/env-allocator.js +1 -1
- package/src/utils/file-copier.js +2 -2
|
@@ -14,24 +14,20 @@
|
|
|
14
14
|
* Subcommands:
|
|
15
15
|
* morph-spec worktree setup <feature> [--fresh] — create + materialize state
|
|
16
16
|
* morph-spec worktree link [--target <path>] [--all] — (re)link shared infra
|
|
17
|
-
* morph-spec worktree finish <feature> [--keep] [--force] — merge-back + cleanup
|
|
18
17
|
* morph-spec worktree list [--json] — list active feature worktrees
|
|
19
18
|
*
|
|
19
|
+
* Closing the lifecycle (integrate + teardown) lives in `morph-spec finish`
|
|
20
|
+
* (src/commands/state/finish.js), which orchestrates archive → merge|PR →
|
|
21
|
+
* teardown for BOTH the worktree and the common flow. This file exports the
|
|
22
|
+
* hardened primitives it uses: `shedRootFeatureScope`, `primaryRootUnexplainedDirt`,
|
|
23
|
+
* and `teardownWorktree` (PROVE-or-DECLINE removal).
|
|
24
|
+
*
|
|
20
25
|
* Exit codes (setup):
|
|
21
26
|
* 0 — success (created) or fail-open (not a git repo, git error)
|
|
22
27
|
* 1 — refused: feature name is not a plain kebab slug
|
|
23
28
|
* 2 — worktree already exists at this exact path (signals resume to caller)
|
|
24
29
|
* 3 — a live worktree for this feature already exists at a DIFFERENT path
|
|
25
30
|
* (anti-dupe guard — refuses to fork a second live instance)
|
|
26
|
-
*
|
|
27
|
-
* Exit codes (finish):
|
|
28
|
-
* 0 — merged successfully (or fail-open in a non-git dir is not applicable here)
|
|
29
|
-
* 1 — refused: invalid feature name, no worktree found, the worktree is on a
|
|
30
|
-
* different branch, Gate 3 not approved, worktree/root dirty, or merge
|
|
31
|
-
* conflict. Every refusal is fail-CLOSED — no merge lands and no worktree
|
|
32
|
-
* is removed. The shed runs before the root's clean check, so a refusal
|
|
33
|
-
* from that point on has moved this feature's root-side scope to the
|
|
34
|
-
* backup it reports; see worktreeFinishCommand.
|
|
35
31
|
*/
|
|
36
32
|
|
|
37
33
|
import { execSync, execFileSync } from 'child_process';
|
|
@@ -238,8 +234,8 @@ const EXCLUDE_MARKER = '# morph-spec: infra links inside worktrees (managed —
|
|
|
238
234
|
* the infra links from `setupInfraLinks` and the fleet env manifest
|
|
239
235
|
* (`.morph/worktree.env`). Same mechanism, same reasons, for both.
|
|
240
236
|
*
|
|
241
|
-
* CRITICAL — without this, `
|
|
242
|
-
* destructive at worst, because git never sees a link as a link: it sees what
|
|
237
|
+
* CRITICAL — without this, `morph-spec finish` (worktree teardown) is unusable on
|
|
238
|
+
* POSIX and destructive at worst, because git never sees a link as a link: it sees what
|
|
243
239
|
* the OS reports. A Windows junction IS a directory, so a `.morph/framework/`
|
|
244
240
|
* rule (trailing slash = directories only) matches it and git ignores it. A
|
|
245
241
|
* POSIX symlink is a FILE (mode 120000), so the very same rule does NOT match
|
|
@@ -631,7 +627,7 @@ export function parseWorktreeList(cwd) {
|
|
|
631
627
|
* @param {string} root - Primary root path
|
|
632
628
|
* @returns {{ path: string, branch: string, expectedBranch: string }|null}
|
|
633
629
|
*/
|
|
634
|
-
function findWorktreeForFeature(feature, root) {
|
|
630
|
+
export function findWorktreeForFeature(feature, root) {
|
|
635
631
|
const expectedBranch = buildBranchName(feature);
|
|
636
632
|
const refName = `refs/heads/${expectedBranch}`;
|
|
637
633
|
const entries = parseWorktreeList(root);
|
|
@@ -654,8 +650,8 @@ function findWorktreeForFeature(feature, root) {
|
|
|
654
650
|
* `.morph/framework`, and the `project.frontendPath` node_modules if
|
|
655
651
|
* configured) WITHOUT following them.
|
|
656
652
|
*
|
|
657
|
-
* CRITICAL — this is what keeps `
|
|
658
|
-
* `git worktree remove` refuses a worktree still holding these ("contains
|
|
653
|
+
* CRITICAL — this is what keeps `morph-spec finish` (worktree teardown) from
|
|
654
|
+
* destroying the project. `git worktree remove` refuses a worktree still holding these ("contains
|
|
659
655
|
* modified or untracked files"), and the `--force` fallback FOLLOWS the
|
|
660
656
|
* junctions and deletes the PRIMARY ROOT's real `.claude/` and
|
|
661
657
|
* `.morph/framework/` — wiping the morph-spec installation (MORPH.md, agents,
|
|
@@ -706,8 +702,8 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
|
|
|
706
702
|
/**
|
|
707
703
|
* Every reparse point anywhere under `worktreePath`, as relative paths.
|
|
708
704
|
*
|
|
709
|
-
* CRITICAL — this is the gate that keeps `
|
|
710
|
-
* project, and it must be exhaustive. BOTH `git worktree remove` and its
|
|
705
|
+
* CRITICAL — this is the gate that keeps `morph-spec finish` (worktree teardown)
|
|
706
|
+
* from destroying the project, and it must be exhaustive. BOTH `git worktree remove` and its
|
|
711
707
|
* `--force` fallback FOLLOW reparse points out of the worktree and delete the
|
|
712
708
|
* PRIMARY ROOT's real files. The plain removal is not the safe one: it refuses
|
|
713
709
|
* only when it sees modified/untracked *tracked-scope* content, so a GITIGNORED
|
|
@@ -795,9 +791,9 @@ const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
|
|
|
795
791
|
*/
|
|
796
792
|
/**
|
|
797
793
|
* Whether a dirty root-side path is EXACTLY a sibling feature's live worktree
|
|
798
|
-
* registration — see the call site (
|
|
799
|
-
* for why this is safe to exempt from the
|
|
800
|
-
* widened hole.
|
|
794
|
+
* registration — see the call site (primaryRootUnexplainedDirt, the root-clean
|
|
795
|
+
* gate `morph-spec finish` uses) for why this is safe to exempt from the
|
|
796
|
+
* whole-root clean gate rather than a widened hole.
|
|
801
797
|
*
|
|
802
798
|
* PROVES it rather than assuming: the path must be exactly that sibling's
|
|
803
799
|
* `feature.json`, must parse, and its `worktree.path` must point at a
|
|
@@ -939,277 +935,152 @@ function backupFeatureScope(root, feature, stamp) {
|
|
|
939
935
|
}
|
|
940
936
|
}
|
|
941
937
|
|
|
942
|
-
// ── finish
|
|
938
|
+
// ── finish primitives (used by `morph-spec finish`) ───────────────────────
|
|
939
|
+
//
|
|
940
|
+
// The old `worktree finish` command dissolved here: `morph-spec finish` (see
|
|
941
|
+
// src/commands/state/finish.js) now orchestrates archive → integrate (merge OR
|
|
942
|
+
// PR) → teardown for BOTH the worktree and the common flow. These exported
|
|
943
|
+
// primitives keep the load-bearing safety (shed with backup, prove-or-decline
|
|
944
|
+
// worktree removal) in one hardened place, shared by every integration path.
|
|
943
945
|
|
|
944
946
|
/**
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
*
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
* nothing is removed. They are NOT no-ops, though: the shed runs before the
|
|
953
|
-
* last one, so a refusal there (or on a merge conflict) has already moved this
|
|
954
|
-
* feature's root-side scope to the backup. Recoverable, and every such refusal
|
|
955
|
-
* reports the path — but "nothing was touched" would be false. Preconditions:
|
|
956
|
-
* 1. Gate 3 (review) approved on the worktree's own feature.json, unless `--force`.
|
|
957
|
-
* 2. The worktree itself has no uncommitted changes (work must be committed
|
|
958
|
-
* to travel with the merge).
|
|
959
|
-
* 3. The primary root has no uncommitted changes (so a merge commit lands cleanly).
|
|
960
|
-
*
|
|
961
|
-
* NOTE: precondition 3 runs AFTER the shed of this feature's root-side scope, so
|
|
962
|
-
* a refusal there is not a no-op — but it is RECOVERABLE: the shed copies that
|
|
963
|
-
* scope to `.git/morph-spec/finish-backup/` first and every refusal after it
|
|
964
|
-
* reports the path.
|
|
965
|
-
*
|
|
966
|
-
* That guarantee covers what the SHED discards (the root-side scope) and, at
|
|
967
|
-
* cleanup, morph's own gitignored files in the worktree. It does NOT cover
|
|
968
|
-
* `--discard-ignored`, which destroys the worktree's non-morph gitignored files
|
|
969
|
-
* outright — that is the flag's stated purpose, the JSON names each file it
|
|
970
|
-
* destroyed, and no default path reaches it.
|
|
971
|
-
*
|
|
972
|
-
* A merge conflict aborts the merge (`git merge --abort`) and refuses —
|
|
973
|
-
* nothing is left half-merged.
|
|
974
|
-
*
|
|
975
|
-
* Success is reported as two INDEPENDENT signals: `finished` is the merge (the
|
|
976
|
-
* actual value), `worktreeRemoved` is the best-effort cleanup. Cleanup declines
|
|
977
|
-
* itself whenever it cannot prove it is safe, so `finished: true` +
|
|
978
|
-
* `worktreeRemoved: false` is a normal, honest outcome — not a failure.
|
|
947
|
+
* Shed the feature's OWN transient root-side bookkeeping so the merge can own
|
|
948
|
+
* this feature's state while the whole-root clean-check keeps guarding UNRELATED
|
|
949
|
+
* work. Backs up FIRST (see backupFeatureScope), then discards — the shed is
|
|
950
|
+
* safe WITHOUT having to be right about what is in scope: if the classification
|
|
951
|
+
* the merge implies turns out wrong, the bytes are still on disk. Worktree-flow
|
|
952
|
+
* only; the common flow committed the archive on the branch, so there is no
|
|
953
|
+
* untracked root-side scaffold to shed.
|
|
979
954
|
*
|
|
955
|
+
* @param {string} root
|
|
980
956
|
* @param {string} feature
|
|
981
|
-
* @
|
|
982
|
-
* `force` waives the Gate 3 precondition ONLY — never the branch check.
|
|
983
|
-
* `discardIgnored` is consent to destroy the worktree's non-morph gitignored
|
|
984
|
-
* files (see the NOTE above); it does not waive the reparse-point gate.
|
|
957
|
+
* @returns {{ shedBackup: string|null }} backup path if anything was shed
|
|
985
958
|
*/
|
|
986
|
-
export
|
|
987
|
-
const cwd = options.cwd || process.cwd();
|
|
988
|
-
const root = resolvePrimaryRoot(cwd);
|
|
989
|
-
|
|
990
|
-
// Refuse anything that is not a plain kebab feature name. `& | ; > ^` are all
|
|
991
|
-
// legal in a git refname but are cmd.exe metacharacters, and every git call
|
|
992
|
-
// below interpolates the name (or the branch built from it). Validating here —
|
|
993
|
-
// not just around the shed — is what keeps a crafted name from steering the
|
|
994
|
-
// `merge`/`branch -d` at the bottom onto a DIFFERENT branch than the one this
|
|
995
|
-
// command validated and reported.
|
|
996
|
-
if (!isValidFeatureName(feature)) {
|
|
997
|
-
console.log(JSON.stringify({ finished: false, error: 'invalid-feature-name', feature }));
|
|
998
|
-
process.exit(1);
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
const found = findWorktreeForFeature(feature, root);
|
|
1002
|
-
if (!found) {
|
|
1003
|
-
console.log(JSON.stringify({ finished: false, error: 'no-worktree', feature }));
|
|
1004
|
-
process.exit(1);
|
|
1005
|
-
}
|
|
1006
|
-
const { path: worktreePath, branch, expectedBranch } = found;
|
|
1007
|
-
|
|
1008
|
-
// Validation 0: the worktree must actually be ON the branch we are about to
|
|
1009
|
-
// merge. The path fallback in findWorktreeForFeature matches by location, so
|
|
1010
|
-
// a worktree the user re-pointed at another branch (or detached) still
|
|
1011
|
-
// matches — and merging the computed `morph/{feature}` then would land a
|
|
1012
|
-
// branch we never validated while destroying a worktree holding unrelated
|
|
1013
|
-
// work. `--force` does NOT waive this: it waives the review gate, not the
|
|
1014
|
-
// question of WHICH branch this command is acting on.
|
|
1015
|
-
if (branch !== expectedBranch) {
|
|
1016
|
-
console.log(JSON.stringify({
|
|
1017
|
-
finished: false,
|
|
1018
|
-
error: 'worktree-branch-mismatch',
|
|
1019
|
-
feature,
|
|
1020
|
-
expectedBranch,
|
|
1021
|
-
actualBranch: branch, // null = detached HEAD
|
|
1022
|
-
path: worktreePath,
|
|
1023
|
-
hint: `o worktree em ${worktreePath} está em ${branch || 'HEAD destacado'}, não em ${expectedBranch} — faça checkout da branch da feature ou finalize essa branch manualmente`,
|
|
1024
|
-
}));
|
|
1025
|
-
process.exit(1);
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
// Validation 1: Gate 3 (review) approved, unless --force.
|
|
1029
|
-
if (!options.force) {
|
|
1030
|
-
let reviewApproved = false;
|
|
1031
|
-
try {
|
|
1032
|
-
const fjPath = join(worktreePath, '.morph', 'features', feature, 'feature.json');
|
|
1033
|
-
if (existsSync(fjPath)) {
|
|
1034
|
-
const fj = JSON.parse(readFileSync(fjPath, 'utf8'));
|
|
1035
|
-
const gates = fj?.approvalGates || fj?.approvals || {};
|
|
1036
|
-
reviewApproved = gates?.review?.approved === true;
|
|
1037
|
-
}
|
|
1038
|
-
} catch { /* reviewApproved stays false — refuse below */ }
|
|
1039
|
-
|
|
1040
|
-
if (!reviewApproved) {
|
|
1041
|
-
console.log(JSON.stringify({ finished: false, error: 'review-not-approved', feature }));
|
|
1042
|
-
process.exit(1);
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
// Validation 2: worktree must be clean (work must be committed to travel with the merge).
|
|
1047
|
-
let worktreeStatus;
|
|
1048
|
-
try {
|
|
1049
|
-
worktreeStatus = execSync('git status --porcelain', { cwd: worktreePath, stdio: 'pipe' }).toString();
|
|
1050
|
-
} catch (err) {
|
|
1051
|
-
console.log(JSON.stringify({ finished: false, error: 'worktree-status-failed', detail: (err.message || '').slice(0, 200), feature }));
|
|
1052
|
-
process.exit(1);
|
|
1053
|
-
}
|
|
1054
|
-
if (worktreeStatus.trim() !== '') {
|
|
1055
|
-
console.log(JSON.stringify({
|
|
1056
|
-
finished: false,
|
|
1057
|
-
error: 'worktree-dirty',
|
|
1058
|
-
hint: 'commit seu trabalho na branch antes do finish',
|
|
1059
|
-
feature,
|
|
1060
|
-
}));
|
|
1061
|
-
process.exit(1);
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
// Shed the feature's OWN transient root-side bookkeeping BEFORE the clean
|
|
1065
|
-
// check. `.morph/features/{feature}/` in the root holds artifacts the merge is
|
|
1066
|
-
// about to replace wholesale: the factory flow (`create --worktree`) scaffolds
|
|
1067
|
-
// it untracked, and `setup` stamps the worktree registration onto a committed
|
|
1068
|
-
// feature.json (a ` M`). Shedding them lets the merge own this feature's state
|
|
1069
|
-
// while the whole-root clean-check keeps guarding UNRELATED work.
|
|
1070
|
-
//
|
|
1071
|
-
// SAFETY — back up FIRST, then shed. An earlier design classified each dirty
|
|
1072
|
-
// path to decide what was safe to discard; every version of that classifier
|
|
1073
|
-
// was wrong in some real flow (an untracked file the branch has at a different
|
|
1074
|
-
// CONTENT; a `??` feature.json that a `M`-gated check never inspected), and a
|
|
1075
|
-
// wrong classifier here destroys unversioned work while reporting success.
|
|
1076
|
-
// Copying the whole scope out of the working tree first makes the shed safe
|
|
1077
|
-
// WITHOUT having to be right about what is in it: if the classification the
|
|
1078
|
-
// merge implies turns out wrong, the bytes are still on disk.
|
|
959
|
+
export function shedRootFeatureScope(root, feature) {
|
|
1079
960
|
const featPathspec = `.morph/features/${feature}`;
|
|
1080
|
-
let shedBackup = null;
|
|
1081
|
-
|
|
1082
961
|
let dirtyInScope = '';
|
|
1083
962
|
try {
|
|
1084
963
|
dirtyInScope = execFileSync('git', ['status', '--porcelain', '-uall', '--', featPathspec], { cwd: root, stdio: 'pipe' }).toString();
|
|
1085
964
|
} catch { /* nothing in scope */ }
|
|
1086
965
|
|
|
1087
|
-
if (dirtyInScope.trim()
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
// Remove the untracked scaffold left in the root by `create --worktree`.
|
|
1096
|
-
// Scoped pathspec + no `-x` keep this away from gitignored `.morph/logs/`.
|
|
1097
|
-
try { execFileSync('git', ['clean', '-fdq', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch { /* nothing untracked to clean */ }
|
|
1098
|
-
}
|
|
966
|
+
if (dirtyInScope.trim() === '') return { shedBackup: null };
|
|
967
|
+
|
|
968
|
+
const shedBackup = backupFeatureScope(root, feature, new Date().toISOString().replace(/[:.]/g, '-'));
|
|
969
|
+
// No backup, no shed. Falling through leaves the root dirty, so the caller's
|
|
970
|
+
// clean-check refuses — the exact fail-closed behaviour from before any shed.
|
|
971
|
+
if (shedBackup) {
|
|
972
|
+
try { execFileSync('git', ['checkout', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch { /* nothing tracked to restore */ }
|
|
973
|
+
try { execFileSync('git', ['clean', '-fdq', '--', featPathspec], { cwd: root, stdio: 'pipe' }); } catch { /* nothing untracked to clean */ }
|
|
1099
974
|
}
|
|
975
|
+
return { shedBackup };
|
|
976
|
+
}
|
|
1100
977
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
// artifact is how `runWorktreeSetup`/`create --worktree` are DESIGNED to
|
|
1114
|
-
// leave the root for that sibling's entire life (decidePersistence in
|
|
1115
|
-
// verify.js and the anti-dupe guard above both read the root's copy for
|
|
1116
|
-
// `.worktree.path`), so refusing THIS feature's finish over it would block
|
|
1117
|
-
// every OTHER feature for as long as any sibling stays open in its own
|
|
1118
|
-
// worktree — the exact "orphaned scaffold collides with finish" friction
|
|
1119
|
-
// from real multi-feature use. Anything else is still unexplained dirt and
|
|
1120
|
-
// still blocks, fail-closed, exactly as before.
|
|
978
|
+
/**
|
|
979
|
+
* The primary root's uncommitted paths that are NOT explained — everything
|
|
980
|
+
* except a SIBLING feature's own LIVE worktree registration (see
|
|
981
|
+
* isLiveSiblingWorktreeRegistration, the exemption that keeps one open sibling
|
|
982
|
+
* from blocking every other feature's finish). `ok:false` means the status call
|
|
983
|
+
* itself failed; an empty `dirt` means clean enough to merge into.
|
|
984
|
+
*
|
|
985
|
+
* @param {string} root
|
|
986
|
+
* @param {string} feature - the feature THIS finish acts on (its own scope is the shed's job)
|
|
987
|
+
* @returns {{ ok: boolean, dirt: string[] }}
|
|
988
|
+
*/
|
|
989
|
+
export function primaryRootUnexplainedDirt(root, feature) {
|
|
1121
990
|
let rootStatusRaw;
|
|
1122
991
|
try {
|
|
1123
|
-
// `--untracked-files=all`, not the default
|
|
1124
|
-
//
|
|
1125
|
-
//
|
|
1126
|
-
// exact `.../feature.json` path isLiveSiblingWorktreeRegistration expects,
|
|
1127
|
-
// silently defeating the exemption for the very shape it exists to allow.
|
|
992
|
+
// `--untracked-files=all`, not the default: the default collapses an
|
|
993
|
+
// entirely-untracked directory into one entry, which would never match the
|
|
994
|
+
// exact `.../feature.json` path isLiveSiblingWorktreeRegistration expects.
|
|
1128
995
|
rootStatusRaw = execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '-z'], { cwd: root, stdio: 'pipe' }).toString();
|
|
1129
|
-
} catch
|
|
1130
|
-
|
|
1131
|
-
process.exit(1);
|
|
996
|
+
} catch {
|
|
997
|
+
return { ok: false, dirt: [] };
|
|
1132
998
|
}
|
|
1133
|
-
const
|
|
999
|
+
const dirt = rootStatusRaw.split('\0').filter((record) => {
|
|
1134
1000
|
if (!record || record.length < 4) return false;
|
|
1135
1001
|
const p = record.slice(3).replace(/\\/g, '/');
|
|
1136
1002
|
return !isLiveSiblingWorktreeRegistration(p, root, feature);
|
|
1137
1003
|
});
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
finished: false,
|
|
1141
|
-
error: 'primary-root-dirty',
|
|
1142
|
-
feature,
|
|
1143
|
-
...backupNote,
|
|
1144
|
-
}));
|
|
1145
|
-
process.exit(1);
|
|
1146
|
-
}
|
|
1004
|
+
return { ok: true, dirt };
|
|
1005
|
+
}
|
|
1147
1006
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1007
|
+
/**
|
|
1008
|
+
* Clear the root-side `.worktree` registration for a finished feature and commit
|
|
1009
|
+
* that cleanup scoped to its own pathspec. Runs whenever the worktree is
|
|
1010
|
+
* REMOVED — including `--pr`, where no merge landed — so a stale registration
|
|
1011
|
+
* never trips the anti-dupe guard or the next finish's root-clean.
|
|
1012
|
+
*
|
|
1013
|
+
* Written DIRECTLY (never saveState, which re-persists every sibling's
|
|
1014
|
+
* feature.json and can dirty the root outside this command's pathspec). The thin
|
|
1015
|
+
* root index carries only {status, updatedAt}, never `worktree`. Fail-open:
|
|
1016
|
+
* metadata cleanup is never a reason to fail an integration that already landed.
|
|
1017
|
+
*
|
|
1018
|
+
* @param {string} root
|
|
1019
|
+
* @param {string} feature
|
|
1020
|
+
*/
|
|
1021
|
+
async function clearRootWorktreeRegistration(root, feature) {
|
|
1022
|
+
const featPathspec = `.morph/features/${feature}`;
|
|
1150
1023
|
try {
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
hint: 'resolva manualmente ou re-execute a feature sequencialmente',
|
|
1162
|
-
feature,
|
|
1163
|
-
...backupNote,
|
|
1164
|
-
}));
|
|
1165
|
-
process.exit(1);
|
|
1166
|
-
}
|
|
1024
|
+
const rootFjPath = join(root, '.morph', 'features', feature, 'feature.json');
|
|
1025
|
+
if (existsSync(rootFjPath)) {
|
|
1026
|
+
const fj = JSON.parse(readFileSync(rootFjPath, 'utf8'));
|
|
1027
|
+
if (fj.worktree) {
|
|
1028
|
+
delete fj.worktree;
|
|
1029
|
+
const { atomicWriteJson } = await import('../../core/state/state-manager.js');
|
|
1030
|
+
atomicWriteJson(rootFjPath, fj);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
} catch { /* fail-open */ }
|
|
1167
1034
|
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1035
|
+
// Clearing that field can itself dirty the root (a committed registration
|
|
1036
|
+
// survives the shed's checkout and the merge). Committing is in contract —
|
|
1037
|
+
// finish already writes commits — and scoped to this feature's path only.
|
|
1038
|
+
try {
|
|
1039
|
+
const dirtyAfter = execFileSync('git', ['status', '--porcelain', '--', featPathspec], { cwd: root, stdio: 'pipe' }).toString();
|
|
1040
|
+
if (dirtyAfter.trim() !== '') {
|
|
1041
|
+
execFileSync('git', ['add', '--', featPathspec], { cwd: root, stdio: 'pipe' });
|
|
1042
|
+
execFileSync('git', ['commit', '-m', `morph-spec finish: clear ${feature} worktree registration`, '--', featPathspec], { cwd: root, stdio: 'pipe' });
|
|
1043
|
+
}
|
|
1044
|
+
} catch { /* fail-open — a dirty root is a nuisance, not a reason to fail */ }
|
|
1045
|
+
}
|
|
1172
1046
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1047
|
+
/**
|
|
1048
|
+
* Remove a feature's worktree after its branch is integrated — PROVE-or-DECLINE.
|
|
1049
|
+
* The single most dangerous step in the lifecycle, so it declines whenever it
|
|
1050
|
+
* cannot prove removal is safe. Two independent ways `git worktree remove`
|
|
1051
|
+
* (plain AND `--force`) destroys data, both ending in an exit-0 removal:
|
|
1052
|
+
* 1. it FOLLOWS reparse points out of the worktree and deletes what they point
|
|
1053
|
+
* at in the primary root (the real `.claude/`, `.morph/framework/`);
|
|
1054
|
+
* 2. it deletes GITIGNORED files outright — a user's `.env` is not disposable,
|
|
1055
|
+
* and the clean-check cannot see them.
|
|
1056
|
+
*
|
|
1057
|
+
* `deleteBranch` decouples `git branch -d` from removal: true for `--merge` (the
|
|
1058
|
+
* branch is integrated), false for `--pr` (the open PR still needs the branch).
|
|
1059
|
+
*
|
|
1060
|
+
* @param {string} worktreePath
|
|
1061
|
+
* @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean }} ctx
|
|
1062
|
+
* @returns {Promise<{ worktreeRemoved: boolean, ignoredBackup: string|null, discarded: string[],
|
|
1063
|
+
* blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null }>}
|
|
1064
|
+
*/
|
|
1065
|
+
export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true }) {
|
|
1066
|
+
// Drop the infra junctions FIRST, without following them — this is what makes
|
|
1067
|
+
// a removal survivable (see unlinkWorktreeInfraLinks). Runs unconditionally.
|
|
1190
1068
|
unlinkWorktreeInfraLinks(worktreePath, root);
|
|
1191
1069
|
|
|
1192
1070
|
const scan = findReparsePoints(worktreePath);
|
|
1193
1071
|
const ignored = classifyIgnoredLeftovers(worktreePath, options);
|
|
1194
1072
|
const blockers = [...scan.points, ...scan.unprovable];
|
|
1195
1073
|
|
|
1196
|
-
// Back up ONLY once the removal is otherwise cleared
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
// `.git/` for a removal that never happens.
|
|
1201
|
-
// - `blockers.length === 0` is a PROOF that no reparse point exists anywhere
|
|
1202
|
-
// under the worktree. cpSync follows junctions on Windows, so running it
|
|
1203
|
-
// before that proof could copy an out-of-repo tree of unbounded size in
|
|
1204
|
-
// under an allowlisted `.morph/` path.
|
|
1074
|
+
// Back up ONLY once the removal is otherwise cleared: nothing is destroyed on
|
|
1075
|
+
// a declined path (copying there is waste), and `blockers.length === 0` proves
|
|
1076
|
+
// no reparse point exists — cpSync follows junctions, so backing up before
|
|
1077
|
+
// that proof could copy an out-of-repo tree of unbounded size.
|
|
1205
1078
|
let ignoredBackup = null;
|
|
1206
1079
|
const clearedToRemove = blockers.length === 0 && ignored.blocking.length === 0;
|
|
1207
1080
|
if (clearedToRemove && ignored.shieldable.length) {
|
|
1208
1081
|
ignoredBackup = backupWorktreeIgnored(worktreePath, root, feature, ignored.shieldable, new Date().toISOString().replace(/[:.]/g, '-'));
|
|
1209
1082
|
}
|
|
1210
|
-
|
|
1211
|
-
const canRemove = clearedToRemove
|
|
1212
|
-
&& (!ignored.shieldable.length || ignoredBackup !== null);
|
|
1083
|
+
const canRemove = clearedToRemove && (!ignored.shieldable.length || ignoredBackup !== null);
|
|
1213
1084
|
|
|
1214
1085
|
let worktreeRemoved = false;
|
|
1215
1086
|
if (canRemove) {
|
|
@@ -1222,120 +1093,46 @@ export async function worktreeFinishCommand(feature, options = {}) {
|
|
|
1222
1093
|
try {
|
|
1223
1094
|
execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: root, stdio: 'pipe' });
|
|
1224
1095
|
worktreeRemoved = true;
|
|
1225
|
-
} catch { /* fail-open — cleanup is best-effort once
|
|
1096
|
+
} catch { /* fail-open — cleanup is best-effort once integration landed */ }
|
|
1226
1097
|
}
|
|
1227
1098
|
}
|
|
1228
1099
|
|
|
1229
|
-
// The branch is still checked out by a worktree we did not remove — deleting
|
|
1230
|
-
// it would fail anyway, and `--worktree prune` has nothing to reclaim.
|
|
1231
1100
|
if (worktreeRemoved) {
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
} catch { /* fail-open */ }
|
|
1239
|
-
|
|
1240
|
-
// Fleet env: the worktree is gone, so no server can be running on its
|
|
1241
|
-
// block — release it for the next feature. ONLY here: a kept/declined
|
|
1242
|
-
// worktree may still be serving on those ports. Fail-open metadata cleanup.
|
|
1101
|
+
// Three independent axes: remove-worktree (done), delete-branch (mode), and
|
|
1102
|
+
// release-env (worktree existed and is gone). `--pr` removes the worktree
|
|
1103
|
+
// but keeps the branch the open PR needs.
|
|
1104
|
+
if (deleteBranch) {
|
|
1105
|
+
try { execFileSync('git', ['branch', '-d', branch], { cwd: root, stdio: 'pipe' }); } catch { /* fail-open */ }
|
|
1106
|
+
}
|
|
1107
|
+
try { execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' }); } catch { /* fail-open */ }
|
|
1108
|
+
// Fleet env: the worktree is gone, so no server can be running on its block.
|
|
1243
1109
|
try {
|
|
1244
1110
|
const { releaseEnvBlock } = await import('../../lib/worktree/env-allocator.js');
|
|
1245
1111
|
const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
1246
1112
|
if (common) releaseEnvBlock(feature, { gitCommonDir: resolve(root, common) });
|
|
1247
1113
|
} catch { /* fail-open */ }
|
|
1114
|
+
await clearRootWorktreeRegistration(root, feature);
|
|
1248
1115
|
}
|
|
1249
1116
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
// and it bites hardest in the parallel multi-worktree flow it was built for.
|
|
1262
|
-
// The thin root index carries only {status, updatedAt}, never `worktree`, so
|
|
1263
|
-
// there is nothing else to persist here.
|
|
1264
|
-
// atomicWriteJson (not a plain writeFileSync): this file is the feature's
|
|
1265
|
-
// AUTHORITATIVE, committed state. A torn write from a crash — or an EPERM
|
|
1266
|
-
// from a Windows AV/indexer holding the handle — would corrupt or fail on the
|
|
1267
|
-
// one copy that matters. tmp+rename with the EPERM retry is what the rest of
|
|
1268
|
-
// the state layer uses; bypassing the sweep must not also bypass that.
|
|
1269
|
-
try {
|
|
1270
|
-
const rootFjPath = join(root, '.morph', 'features', feature, 'feature.json');
|
|
1271
|
-
if (existsSync(rootFjPath)) {
|
|
1272
|
-
const fj = JSON.parse(readFileSync(rootFjPath, 'utf8'));
|
|
1273
|
-
if (fj.worktree) {
|
|
1274
|
-
delete fj.worktree;
|
|
1275
|
-
const { atomicWriteJson } = await import('../../core/state/state-manager.js');
|
|
1276
|
-
atomicWriteJson(rootFjPath, fj);
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
} catch { /* fail-open */ }
|
|
1280
|
-
|
|
1281
|
-
// Clearing that field can itself dirty the root: when `setup`'s registration
|
|
1282
|
-
// was COMMITTED, it lives in HEAD, so the shed's `git checkout` cannot undo it
|
|
1283
|
-
// and it survives the merge — then the rewrite above leaves a ` M`. Left
|
|
1284
|
-
// behind, that stray modification blocks the NEXT feature's finish (the shed
|
|
1285
|
-
// is scoped to its own feature), which is the very bug this command was fixed
|
|
1286
|
-
// to stop hitting. Committing it is in contract: `finish` already writes a
|
|
1287
|
-
// merge commit. Scoped to this feature's path — never sweeps up other work.
|
|
1288
|
-
try {
|
|
1289
|
-
const dirtyAfter = execFileSync('git', ['status', '--porcelain', '--', featPathspec], { cwd: root, stdio: 'pipe' }).toString();
|
|
1290
|
-
if (dirtyAfter.trim() !== '') {
|
|
1291
|
-
execFileSync('git', ['add', '--', featPathspec], { cwd: root, stdio: 'pipe' });
|
|
1292
|
-
execFileSync('git', ['commit', '-m', `morph-spec worktree finish: clear ${feature} worktree registration`, '--', featPathspec], { cwd: root, stdio: 'pipe' });
|
|
1293
|
-
}
|
|
1294
|
-
} catch { /* fail-open — a dirty root is a nuisance, not a reason to fail a landed merge */ }
|
|
1295
|
-
|
|
1296
|
-
const declineReason = ignored.blocking.length
|
|
1297
|
-
? `merge OK. O worktree NÃO foi removido: ele contém arquivo(s) gitignored que existem só ali e o \`git worktree remove\` apagaria (${ignored.blocking.slice(0, 5).join(', ')}${ignored.blocking.length > 5 ? `, +${ignored.blocking.length - 5}` : ''}). Salve o que importa e rode \`git worktree remove --force "${worktreePath}"\` + \`git branch -d ${branch}\`, ou re-execute o finish com \`--discard-ignored\` para descartá-los deliberadamente`
|
|
1298
|
-
: blockers.length
|
|
1299
|
-
? `merge OK. O worktree NÃO foi removido: ele ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que o \`git worktree remove\` seguiria para fora, apagando o alvo na raiz. Remova-os e rode \`git worktree remove "${worktreePath}"\` + \`git branch -d ${branch}\``
|
|
1300
|
-
: ignored.shieldable.length && ignoredBackup === null
|
|
1301
|
-
? `merge OK. O worktree NÃO foi removido: o backup de ${featPathspec} (gitignored do worktree) falhou, e sem ele a remoção seria destrutiva. Rode \`git worktree remove --force "${worktreePath}"\` manualmente se não precisar desse conteúdo`
|
|
1302
|
-
: `merge OK, mas o \`git worktree remove\` falhou. Rode \`git worktree remove "${worktreePath}"\` + \`git branch -d ${branch}\` manualmente`;
|
|
1303
|
-
|
|
1304
|
-
console.log(JSON.stringify({
|
|
1305
|
-
finished: true,
|
|
1306
|
-
feature,
|
|
1307
|
-
merged: branch,
|
|
1117
|
+
const declineReason = worktreeRemoved
|
|
1118
|
+
? null
|
|
1119
|
+
: ignored.blocking.length
|
|
1120
|
+
? `worktree NÃO removido: contém arquivo(s) gitignored que existem só ali e o \`git worktree remove\` apagaria (${ignored.blocking.slice(0, 5).join(', ')}${ignored.blocking.length > 5 ? `, +${ignored.blocking.length - 5}` : ''}). Salve o que importa e rode \`git worktree remove --force "${worktreePath}"\`${deleteBranch ? ` + \`git branch -d ${branch}\`` : ''}, ou re-execute o finish com \`--discard-ignored\` para descartá-los deliberadamente`
|
|
1121
|
+
: blockers.length
|
|
1122
|
+
? `worktree NÃO removido: ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que o \`git worktree remove\` seguiria para fora, apagando o alvo na raiz. Remova-os e rode \`git worktree remove "${worktreePath}"\`${deleteBranch ? ` + \`git branch -d ${branch}\`` : ''}`
|
|
1123
|
+
: ignored.shieldable.length && ignoredBackup === null
|
|
1124
|
+
? `worktree NÃO removido: o backup do gitignored do worktree falhou, e sem ele a remoção seria destrutiva. Rode \`git worktree remove --force "${worktreePath}"\` manualmente se não precisar desse conteúdo`
|
|
1125
|
+
: `o \`git worktree remove\` falhou. Rode \`git worktree remove "${worktreePath}"\` manualmente`;
|
|
1126
|
+
|
|
1127
|
+
return {
|
|
1308
1128
|
worktreeRemoved,
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
discardedIgnored: ignored.discarded,
|
|
1317
|
-
discardedNote: options.discardIgnored
|
|
1318
|
-
? `--discard-ignored: os caminhos acima eram gitignored, existiam só no worktree e foram DESTRUÍDOS sem backup (não são copiados por poderem ser arbitrariamente grandes)`
|
|
1319
|
-
: `artefatos de build reconhecidos (bin/, obj/, node_modules/, *.tsbuildinfo) — descartados por padrão, sem backup (regeneráveis a partir do source)`,
|
|
1320
|
-
}
|
|
1321
|
-
: {}),
|
|
1322
|
-
...(worktreeRemoved
|
|
1323
|
-
? {}
|
|
1324
|
-
: {
|
|
1325
|
-
path: worktreePath,
|
|
1326
|
-
branch,
|
|
1327
|
-
...(blockers.length ? { reparsePoints: blockers } : {}),
|
|
1328
|
-
...(ignored.blocking.length ? { ignoredFiles: ignored.blocking } : {}),
|
|
1329
|
-
cleanupHint: declineReason,
|
|
1330
|
-
// The infra junctions were dropped before this decline (that step runs
|
|
1331
|
-
// unconditionally, since it is what makes a removal survivable). A kept
|
|
1332
|
-
// worktree therefore has no node_modules/.claude until they are relinked
|
|
1333
|
-
// — say so, instead of leaving the user to discover a broken worktree.
|
|
1334
|
-
relinkHint: `as junctions de infra (node_modules, .claude, .morph/framework) foram soltas antes desta recusa — se for continuar trabalhando nesse worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``,
|
|
1335
|
-
}),
|
|
1336
|
-
nextStep: `morph-spec archive ${feature}`,
|
|
1337
|
-
}));
|
|
1338
|
-
process.exit(0);
|
|
1129
|
+
ignoredBackup,
|
|
1130
|
+
discarded: ignored.discarded,
|
|
1131
|
+
blockers,
|
|
1132
|
+
ignoredBlocking: ignored.blocking,
|
|
1133
|
+
shieldable: ignored.shieldable,
|
|
1134
|
+
declineReason,
|
|
1135
|
+
};
|
|
1339
1136
|
}
|
|
1340
1137
|
|
|
1341
1138
|
// ── list ─────────────────────────────────────────────────────────────────
|