@polymorphism-tech/morph-spec 8.22.0 → 8.24.1
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 +2 -2
- package/bin/morph-spec.js +27 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +1 -1
- package/framework/MORPH.md +2 -2
- package/framework/agents.json +1 -0
- package/framework/docs/CLI.md +5 -3
- package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +118 -0
- package/framework/hooks/shared/reparse-scan.js +100 -0
- 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 +2 -2
- 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 +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +14 -2
- package/framework/skills/core/morph-plan/SKILL.md +6 -4
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +29 -3
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +28 -0
- package/framework/standards/backend/dotnet/fluent-validation-vsa.md +5 -2
- package/framework/standards/backend/dotnet/testing.md +127 -0
- package/package.json +1 -1
- package/src/commands/project/worktree.js +250 -86
- package/src/commands/state/approve.js +11 -0
- package/src/commands/state/archive.js +12 -3
- package/src/commands/state/finish.js +30 -16
- package/src/commands/state/handoff.js +3 -2
- package/src/commands/state/usecase.js +77 -0
- package/src/commands/validation/verify.js +6 -0
- package/src/lib/generators/handoff-generator.js +2 -2
- package/src/lib/specs/usecase-promoter.js +135 -19
- package/src/lib/specs/usecase-registry.js +237 -0
- package/src/lib/validators/traceability-check.js +198 -0
- package/src/lib/validators/verify-runner.js +14 -0
- package/src/lib/worktree/worktree-audit.js +6 -2
- package/src/utils/hooks-installer.js +12 -8
|
@@ -4,7 +4,7 @@ description: Localiza um standard específico via busca server-side (morph-spec
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: quando precisar consultar um standard específico durante a implementação
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.24.1"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-standards
|
|
@@ -4,7 +4,7 @@ description: Conduz a fase opcional de UI/UX — design system, mockups, compone
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao tomar o ramo uiux (morph-spec advance --design), depois do Gate 1 e antes do morph-plan
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.24.1"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-uiux
|
|
@@ -1779,6 +1779,34 @@
|
|
|
1779
1779
|
"data-ingestion": "#microsoftextensionsdataingestion-preview--pipeline-pattern-para-adoção-futura"
|
|
1780
1780
|
},
|
|
1781
1781
|
"digest": "Microsoft.Extensions.VectorData.Abstractions (GA) is the provider-agnostic vector-store abstraction; Microsoft.Extensions.DataIngestion (preview) is the companion ETL pipeline. Concrete connectors (Postgres, Qdrant, SQL Server...) ship under Microsoft.SemanticKernel.Connectors.* package names despite having nothing to do with Semantic Kernel — an explicit exception to ai-agents-setup's SK ban. Default RAG storage stays ai-agents-rag-custom-pgvector; adopt VectorData only for genuine multi-backend portability, since the Postgres connector is still prerelease."
|
|
1782
|
+
},
|
|
1783
|
+
{
|
|
1784
|
+
"id": "backend-dotnet-testing",
|
|
1785
|
+
"name": "testing",
|
|
1786
|
+
"path": "backend/dotnet/testing.md",
|
|
1787
|
+
"category": "backend",
|
|
1788
|
+
"subcategory": "dotnet",
|
|
1789
|
+
"tags": [
|
|
1790
|
+
"backend",
|
|
1791
|
+
"dotnet"
|
|
1792
|
+
],
|
|
1793
|
+
"stacks": [
|
|
1794
|
+
"dotnet"
|
|
1795
|
+
],
|
|
1796
|
+
"aliases": [
|
|
1797
|
+
"xunit",
|
|
1798
|
+
"TimeProvider",
|
|
1799
|
+
"FakeTimeProvider",
|
|
1800
|
+
"fake clock",
|
|
1801
|
+
"integration test",
|
|
1802
|
+
"ProblemDetails",
|
|
1803
|
+
"error body assertion"
|
|
1804
|
+
],
|
|
1805
|
+
"anchors": {
|
|
1806
|
+
"clock": "#rule-1--inject-timeprovider-fake-it-with-faketimeprovider-in-tests",
|
|
1807
|
+
"errorbody": "#rule-2--error-path-tests-assert-the-response-body-not-just-the-status-code"
|
|
1808
|
+
},
|
|
1809
|
+
"digest": "Inject TimeProvider (never DateTime.UtcNow / DateOnly.FromDateTime(DateTime.UtcNow) directly in a handler or validator) and fake it with FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) in integration tests — deterministic, no midnight-UTC flake, boundary dates (today vs. yesterday) become facts about the fake clock instead of when the suite happens to run. xUnit + WebApplicationFactory error-path tests (400/404/409) must assert the ProblemDetails response BODY (title/detail/violated field or error code), never just the HTTP status code — a status-only assertion lets the wrong validation rule or the wrong error branch pass silently as long as the status matches."
|
|
1782
1810
|
}
|
|
1783
1811
|
],
|
|
1784
1812
|
"sharedAliases": {
|
|
@@ -23,7 +23,10 @@ One validator per request DTO. Lives in the same feature folder as the handler:
|
|
|
23
23
|
// Features/BookFeature/CreateBook/CreateBookValidator.cs
|
|
24
24
|
public class CreateBookValidator : AbstractValidator<CreateBookRequest>
|
|
25
25
|
{
|
|
26
|
-
|
|
26
|
+
// TimeProvider injected, never DateTime.UtcNow read directly — see
|
|
27
|
+
// backend-dotnet-testing (Rule 1): a validator that reads the real clock
|
|
28
|
+
// cannot be tested deterministically with FakeTimeProvider.
|
|
29
|
+
public CreateBookValidator(TimeProvider timeProvider)
|
|
27
30
|
{
|
|
28
31
|
RuleFor(c => c.Title)
|
|
29
32
|
.NotEmpty().WithMessage("Title is required")
|
|
@@ -41,7 +44,7 @@ public class CreateBookValidator : AbstractValidator<CreateBookRequest>
|
|
|
41
44
|
|
|
42
45
|
RuleFor(c => c.PublishedYear)
|
|
43
46
|
.GreaterThan(1000).WithMessage("Published year must be a valid year")
|
|
44
|
-
.LessThanOrEqualTo(
|
|
47
|
+
.LessThanOrEqualTo(timeProvider.GetUtcNow().Year)
|
|
45
48
|
.WithMessage("Published year cannot be in the future");
|
|
46
49
|
}
|
|
47
50
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# .NET Integration Testing — VSA
|
|
2
|
+
|
|
3
|
+
> **Scope:** dotnet-vsa
|
|
4
|
+
> **Layer:** 0 (always load)
|
|
5
|
+
> **Keywords:** testing, xunit, integration test, TimeProvider, FakeTimeProvider, WebApplicationFactory, ProblemDetails, error body assertion
|
|
6
|
+
> **Load When:** writing or reviewing tests for a dotnet VSA project
|
|
7
|
+
|
|
8
|
+
**Verified against:** .NET 10 + xUnit 2 + `Microsoft.Extensions.TimeProvider.Testing`. Last-verified: 2026-08-12.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Overview
|
|
13
|
+
|
|
14
|
+
Integration tests exercise the real ASP.NET Core pipeline (`WebApplicationFactory`) against
|
|
15
|
+
an in-memory/test database — the pattern used across `backend-dotnet-vsa-handler-patterns`'
|
|
16
|
+
handler examples. Two failure modes recur in generated test suites and each has a dedicated
|
|
17
|
+
rule below: tests that read the real system clock instead of an injected, fakeable one; and
|
|
18
|
+
error-path tests that assert only the HTTP status code, leaving the actual failure contract
|
|
19
|
+
(the response body) unverified.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Rule 1 — Inject `TimeProvider`, fake it with `FakeTimeProvider` in tests
|
|
24
|
+
|
|
25
|
+
Any logic that compares against "now" (a past-date validation, an expiry check, a due-date
|
|
26
|
+
default) MUST read the clock through an injected `TimeProvider` — never `DateTime.UtcNow` or
|
|
27
|
+
`DateOnly.FromDateTime(DateTime.UtcNow)` directly in a handler or validator. A validator that
|
|
28
|
+
reads the real clock makes "today" a moving target: a test asserting "today is valid, yesterday
|
|
29
|
+
is rejected" flakes for real around UTC midnight, and is untestable for the "exactly at the
|
|
30
|
+
boundary" case at all.
|
|
31
|
+
|
|
32
|
+
```csharp
|
|
33
|
+
// Features/VisitFeature/CloseVisit/CloseVisitValidator.cs
|
|
34
|
+
public sealed class CloseVisitValidator : AbstractValidator<CloseVisitRequest>
|
|
35
|
+
{
|
|
36
|
+
public CloseVisitValidator(TimeProvider timeProvider)
|
|
37
|
+
{
|
|
38
|
+
RuleFor(c => c.FollowUpDate)
|
|
39
|
+
.Must(date => date!.Value >= Today(timeProvider))
|
|
40
|
+
.WithErrorCode(VisitErrors.FollowUpDateInPast.Code)
|
|
41
|
+
.WithMessage(VisitErrors.FollowUpDateInPast.Description)
|
|
42
|
+
.When(c => c.FollowUpDate.HasValue);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private static DateOnly Today(TimeProvider timeProvider) =>
|
|
46
|
+
DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime);
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Register the real clock once in `Program.cs`:
|
|
51
|
+
|
|
52
|
+
```csharp
|
|
53
|
+
builder.Services.AddSingleton(TimeProvider.System);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
In tests, swap it for `Microsoft.Extensions.TimeProvider.Testing`'s `FakeTimeProvider` —
|
|
57
|
+
deterministic, no dependency on when the suite happens to run:
|
|
58
|
+
|
|
59
|
+
```csharp
|
|
60
|
+
// tests/.../Infrastructure/VetClinicApiFactory.cs
|
|
61
|
+
public sealed class VetClinicApiFactory : WebApplicationFactory<Program>
|
|
62
|
+
{
|
|
63
|
+
public FakeTimeProvider TimeProvider { get; } = new(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero));
|
|
64
|
+
|
|
65
|
+
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
|
|
66
|
+
builder.ConfigureServices(services =>
|
|
67
|
+
{
|
|
68
|
+
services.RemoveAll<TimeProvider>();
|
|
69
|
+
services.AddSingleton<TimeProvider>(TimeProvider);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```csharp
|
|
75
|
+
// The boundary case ("today is accepted, yesterday is not") becomes a fact about
|
|
76
|
+
// the fake clock, not about when CI happens to run.
|
|
77
|
+
DateOnly today = DateOnly.FromDateTime(factory.TimeProvider.GetUtcNow().UtcDateTime);
|
|
78
|
+
DateOnly yesterday = today.AddDays(-1);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Rule 2 — Error-path tests assert the response BODY, not just the status code
|
|
84
|
+
|
|
85
|
+
A test that only checks `response.StatusCode` proves the endpoint returns *a* 400/404/409 — it
|
|
86
|
+
proves nothing about *why*, and lets the wrong validation rule or the wrong error code pass
|
|
87
|
+
silently as long as the status happens to match. Every error-path test MUST also assert the
|
|
88
|
+
`ProblemDetails` body: at minimum `title` (or `type`) and the specific field/reason that
|
|
89
|
+
triggered the failure.
|
|
90
|
+
|
|
91
|
+
```csharp
|
|
92
|
+
// Wrong — proves only that SOMETHING was rejected, not what:
|
|
93
|
+
HttpResponseMessage response = await client.PostAsJsonAsync($"visits/{id}/close", body);
|
|
94
|
+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```csharp
|
|
98
|
+
// Right — the body is the actual contract a client integrates against:
|
|
99
|
+
HttpResponseMessage response = await client.PostAsJsonAsync($"visits/{id}/close", body);
|
|
100
|
+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
101
|
+
|
|
102
|
+
ProblemDetails? problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
|
103
|
+
Assert.NotNull(problem);
|
|
104
|
+
Assert.Contains("FollowUpDate", problem!.Extensions["errors"]!.ToString());
|
|
105
|
+
Assert.Equal(VisitErrors.FollowUpDateInPast.Code, GetErrorCode(problem));
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The same discipline applies to 404 (`NotFound` — assert the entity/id named in the problem
|
|
109
|
+
detail) and 409 (`Conflict` — assert the conflict reason, e.g. `"Visit.AlreadyClosed"`, not just
|
|
110
|
+
that a conflict happened). A status-only assertion is a regression magnet: swapping
|
|
111
|
+
`VisitErrors.NotFound` for `VisitErrors.AlreadyClosed` in the wrong branch still returns 404/409
|
|
112
|
+
respectively and the test suite stays green.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Anti-Patterns
|
|
117
|
+
|
|
118
|
+
| Do NOT | Do Instead |
|
|
119
|
+
|--------|------------|
|
|
120
|
+
| Read `DateTime.UtcNow` / `DateOnly.FromDateTime(DateTime.UtcNow)` directly in a handler or validator | Inject `TimeProvider`, read `timeProvider.GetUtcNow()` |
|
|
121
|
+
| Leave a test's "past date" derived from the real system clock (`DateTime.UtcNow.AddDays(-1)`) | Swap `TimeProvider` for `FakeTimeProvider` in the test factory and derive dates from it |
|
|
122
|
+
| Assert only `response.StatusCode` on an error path | Also deserialize and assert the `ProblemDetails` body (title/detail/violated field or error code) |
|
|
123
|
+
| Treat a passing status-only test as proof the error message/code is correct | Assert the specific error code/field — a wrong branch can still return the right status |
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
*MORPH-SPEC by Polymorphism Tech*
|
package/package.json
CHANGED
|
@@ -31,10 +31,13 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { execSync, execFileSync } from 'child_process';
|
|
34
|
-
import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, appendFileSync, lstatSync, unlinkSync, rmdirSync,
|
|
35
|
-
import { join, resolve, dirname
|
|
34
|
+
import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, appendFileSync, lstatSync, unlinkSync, rmdirSync, cpSync } from 'fs';
|
|
35
|
+
import { join, resolve, dirname } from 'path';
|
|
36
36
|
import chalk from 'chalk';
|
|
37
37
|
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
38
|
+
// The reparse scan lives in hooks/shared because the PreToolUse guardrail needs
|
|
39
|
+
// the SAME answer this file does, and hooks can never import from `src/`.
|
|
40
|
+
import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.js';
|
|
38
41
|
|
|
39
42
|
/**
|
|
40
43
|
* The directory new worktrees are created under, relative to the primary root.
|
|
@@ -740,64 +743,6 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
|
|
|
740
743
|
return removed;
|
|
741
744
|
}
|
|
742
745
|
|
|
743
|
-
/**
|
|
744
|
-
* Every reparse point anywhere under `worktreePath`, as relative paths.
|
|
745
|
-
*
|
|
746
|
-
* CRITICAL — this is the gate that keeps `morph-spec finish` (worktree teardown)
|
|
747
|
-
* from destroying the project, and it must be exhaustive. BOTH `git worktree remove` and its
|
|
748
|
-
* `--force` fallback FOLLOW reparse points out of the worktree and delete the
|
|
749
|
-
* PRIMARY ROOT's real files. The plain removal is not the safe one: it refuses
|
|
750
|
-
* only when it sees modified/untracked *tracked-scope* content, so a GITIGNORED
|
|
751
|
-
* junction (`node_modules`, `.claude`) leaves `git status` empty, the removal
|
|
752
|
-
* proceeds, exits 0 — and the target is gone.
|
|
753
|
-
*
|
|
754
|
-
* So the check cannot live in the `catch` of the plain removal (unreachable on
|
|
755
|
-
* exactly the destructive path), and it cannot be a fixed list of the links
|
|
756
|
-
* `setup` creates: a nested link inside a real `node_modules` (npm link, npm
|
|
757
|
-
* workspaces) is invisible to any top-level inventory. We scan the whole tree
|
|
758
|
-
* and NEVER recurse INTO a reparse point — descending one would walk the target
|
|
759
|
-
* we are trying to protect (and could not terminate on a cycle).
|
|
760
|
-
*
|
|
761
|
-
* An empty result is NOT the same as a safe one: a directory we could not read
|
|
762
|
-
* or an entry we could not lstat might BE (or contain) a reparse point. Those
|
|
763
|
-
* answer `unprovable: true`, and the caller must treat that exactly like a found
|
|
764
|
-
* link — decline. "I saw nothing" and "I proved there is nothing" are different
|
|
765
|
-
* claims, and only the second one may authorize a removal.
|
|
766
|
-
*
|
|
767
|
-
* @param {string} dir
|
|
768
|
-
* @param {string} [base] - root of the scan, for relative reporting
|
|
769
|
-
* @param {{points: string[], unprovable: string[]}} [acc]
|
|
770
|
-
* @returns {{points: string[], unprovable: string[]}} both empty = provably clean
|
|
771
|
-
*/
|
|
772
|
-
function findReparsePoints(dir, base = dir, acc = { points: [], unprovable: [] }) {
|
|
773
|
-
let entries;
|
|
774
|
-
try {
|
|
775
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
776
|
-
} catch {
|
|
777
|
-
// Unreadable (ACL, lock, EMFILE): we cannot prove what is in here.
|
|
778
|
-
acc.unprovable.push((relative(base, dir) || '.').replace(/\\/g, '/'));
|
|
779
|
-
return acc;
|
|
780
|
-
}
|
|
781
|
-
for (const entry of entries) {
|
|
782
|
-
const p = join(dir, entry.name);
|
|
783
|
-
let st;
|
|
784
|
-
try {
|
|
785
|
-
st = lstatSync(p);
|
|
786
|
-
} catch {
|
|
787
|
-
acc.unprovable.push(relative(base, p).replace(/\\/g, '/'));
|
|
788
|
-
continue;
|
|
789
|
-
}
|
|
790
|
-
if (st.isSymbolicLink()) {
|
|
791
|
-
// Forward slashes: every other path this command reports is normalized,
|
|
792
|
-
// and these end up in JSON the caller may compare or display.
|
|
793
|
-
acc.points.push(relative(base, p).replace(/\\/g, '/'));
|
|
794
|
-
continue; // do NOT descend — that is the target, not the worktree
|
|
795
|
-
}
|
|
796
|
-
if (st.isDirectory() && entry.name !== '.git') findReparsePoints(p, base, acc);
|
|
797
|
-
}
|
|
798
|
-
return acc;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
746
|
/**
|
|
802
747
|
* Gitignored paths a morph worktree owns and that are safe to remove — but ONLY
|
|
803
748
|
* once backed up (see backupWorktreeIgnored). `.morph/state.json` is provably
|
|
@@ -1085,6 +1030,60 @@ async function clearRootWorktreeRegistration(root, feature) {
|
|
|
1085
1030
|
} catch { /* fail-open — a dirty root is a nuisance, not a reason to fail */ }
|
|
1086
1031
|
}
|
|
1087
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Why `git worktree remove` failed, from its stderr.
|
|
1035
|
+
*
|
|
1036
|
+
* The distinction that matters is `in-use` — on Windows a process whose cwd is
|
|
1037
|
+
* inside the worktree (another Claude Code instance, VS Code, a dev server, a
|
|
1038
|
+
* stray terminal) pins the directory and the removal cannot proceed. That is a
|
|
1039
|
+
* human-actionable cause with a specific fix ("close it"), and printing it is
|
|
1040
|
+
* the difference between a message the user can act on and one that just says
|
|
1041
|
+
* "failed" — which is what made a raw `git worktree remove --force` look like
|
|
1042
|
+
* the only way forward.
|
|
1043
|
+
*
|
|
1044
|
+
* @param {string} stderr
|
|
1045
|
+
* @returns {{ kind: 'in-use'|'unknown', detail: string }}
|
|
1046
|
+
*/
|
|
1047
|
+
function classifyRemovalError(stderr) {
|
|
1048
|
+
const text = String(stderr || '').trim();
|
|
1049
|
+
const inUse = /permission denied|EPERM|EBUSY|being used by another process|resource busy|access is denied|is locked/i.test(text);
|
|
1050
|
+
// First stderr line only: the rest is git's usual advice block, and this lands
|
|
1051
|
+
// in a JSON field a human reads.
|
|
1052
|
+
const detail = text.split(/\r?\n/).find((l) => l.trim()) || '';
|
|
1053
|
+
return { kind: inUse ? 'in-use' : 'unknown', detail: detail.slice(0, 200) };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Run the removal, with one retry round after a short pause.
|
|
1058
|
+
*
|
|
1059
|
+
* The retry exists for the transient Windows handle (antivirus/indexer touching
|
|
1060
|
+
* a file mid-removal) — the same class the repo already retries in
|
|
1061
|
+
* `atomicWriteJson`. It deliberately does NOT help the `in-use` case: a held
|
|
1062
|
+
* directory stays held, and that one needs the human, not another attempt.
|
|
1063
|
+
*
|
|
1064
|
+
* `--force` is safe HERE and only here: the caller proved `blockers.length ===
|
|
1065
|
+
* 0` before calling, so there is nothing left for it to traverse out of.
|
|
1066
|
+
*
|
|
1067
|
+
* @param {string} worktreePath
|
|
1068
|
+
* @param {string} root
|
|
1069
|
+
* @returns {Promise<{ removed: boolean, failure: {kind: string, detail: string}|null }>}
|
|
1070
|
+
*/
|
|
1071
|
+
async function attemptWorktreeRemoval(worktreePath, root) {
|
|
1072
|
+
let lastStderr = '';
|
|
1073
|
+
for (let round = 0; round < 2; round++) {
|
|
1074
|
+
for (const args of [['worktree', 'remove', worktreePath], ['worktree', 'remove', '--force', worktreePath]]) {
|
|
1075
|
+
try {
|
|
1076
|
+
execFileSync('git', args, { cwd: root, stdio: 'pipe' });
|
|
1077
|
+
return { removed: true, failure: null };
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
lastStderr = err?.stderr ? String(err.stderr) : (err?.message || '');
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (round === 0) await new Promise((r) => setTimeout(r, 200));
|
|
1083
|
+
}
|
|
1084
|
+
return { removed: false, failure: classifyRemovalError(lastStderr) };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1088
1087
|
/**
|
|
1089
1088
|
* Remove a feature's worktree after its branch is integrated — PROVE-or-DECLINE.
|
|
1090
1089
|
* The single most dangerous step in the lifecycle, so it declines whenever it
|
|
@@ -1095,15 +1094,22 @@ async function clearRootWorktreeRegistration(root, feature) {
|
|
|
1095
1094
|
* 2. it deletes GITIGNORED files outright — a user's `.env` is not disposable,
|
|
1096
1095
|
* and the clean-check cannot see them.
|
|
1097
1096
|
*
|
|
1097
|
+
* A non-removal comes in two flavours the caller must not conflate — see the
|
|
1098
|
+
* rollback comment below. `refused` (we proved it unsafe) restores the infra
|
|
1099
|
+
* junctions; a FAILURE (git errored) deliberately leaves them down, reported in
|
|
1100
|
+
* `infraLeftUnlinked`.
|
|
1101
|
+
*
|
|
1098
1102
|
* `deleteBranch` decouples `git branch -d` from removal: true for `--merge` (the
|
|
1099
1103
|
* branch is integrated), false for `--pr` (the open PR still needs the branch).
|
|
1100
1104
|
*
|
|
1101
1105
|
* @param {string} worktreePath
|
|
1102
1106
|
* @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean }} ctx
|
|
1103
1107
|
* @returns {Promise<{ worktreeRemoved: boolean, ignoredBackup: string|null, discarded: string[],
|
|
1104
|
-
* blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null
|
|
1108
|
+
* blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null,
|
|
1109
|
+
* relinked: boolean, refused: boolean, infraLeftUnlinked: string[],
|
|
1110
|
+
* removalFailure: {kind: string, detail: string}|null }>}
|
|
1105
1111
|
*/
|
|
1106
|
-
export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true }) {
|
|
1112
|
+
export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false }) {
|
|
1107
1113
|
// Drop the infra junctions FIRST, without following them — this is what makes
|
|
1108
1114
|
// a removal survivable (see unlinkWorktreeInfraLinks). Runs unconditionally.
|
|
1109
1115
|
//
|
|
@@ -1130,18 +1136,11 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1130
1136
|
const canRemove = clearedToRemove && (!ignored.shieldable.length || ignoredBackup !== null);
|
|
1131
1137
|
|
|
1132
1138
|
let worktreeRemoved = false;
|
|
1139
|
+
let removalFailure = null;
|
|
1133
1140
|
if (canRemove) {
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
} catch {
|
|
1138
|
-
// Nothing is left to traverse (proven above), so --force can only act
|
|
1139
|
-
// inside the worktree.
|
|
1140
|
-
try {
|
|
1141
|
-
execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: root, stdio: 'pipe' });
|
|
1142
|
-
worktreeRemoved = true;
|
|
1143
|
-
} catch { /* fail-open — cleanup is best-effort once integration landed */ }
|
|
1144
|
-
}
|
|
1141
|
+
const attempt = await attemptWorktreeRemoval(worktreePath, root);
|
|
1142
|
+
worktreeRemoved = attempt.removed;
|
|
1143
|
+
removalFailure = attempt.failure; // null once removed
|
|
1145
1144
|
}
|
|
1146
1145
|
|
|
1147
1146
|
if (worktreeRemoved) {
|
|
@@ -1161,30 +1160,58 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1161
1160
|
await clearRootWorktreeRegistration(root, feature);
|
|
1162
1161
|
}
|
|
1163
1162
|
|
|
1164
|
-
// Rollback (A4)
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1163
|
+
// Rollback (A4) — ONLY on a REFUSAL, never on a FAILURE. The two look alike
|
|
1164
|
+
// (worktree still there, junctions dropped) and must be treated as opposites:
|
|
1165
|
+
//
|
|
1166
|
+
// REFUSED (`!canRemove`): we proved removal unsafe, so the user keeps
|
|
1167
|
+
// working in this worktree. Leaving the junctions down is "um estado pior
|
|
1168
|
+
// que não ter tentado" — put them back.
|
|
1169
|
+
// FAILED (`canRemove` but git errored): removal WAS the user's intent and
|
|
1170
|
+
// the only obstacle is a lock. Re-linking here would restore the exact
|
|
1171
|
+
// hazard that destroyed installations — the retry (ours, theirs, or a raw
|
|
1172
|
+
// `git worktree remove --force` pasted from an old transcript) would then
|
|
1173
|
+
// follow the junctions out. Leaving them down makes EVERY subsequent
|
|
1174
|
+
// attempt safe, because there is nothing left to traverse.
|
|
1175
|
+
//
|
|
1176
|
+
// Fail-open on the refusal path: a failed re-link reports `relinked: false`
|
|
1177
|
+
// and the caller falls back to telling the user to run `worktree link --all`.
|
|
1178
|
+
const refused = !canRemove;
|
|
1171
1179
|
let relinked = false;
|
|
1172
|
-
if (
|
|
1180
|
+
if (refused && unlinked.length && existsSync(worktreePath)) {
|
|
1173
1181
|
try {
|
|
1174
1182
|
setupInfraLinks(worktreePath, root);
|
|
1175
1183
|
relinked = true;
|
|
1176
1184
|
} catch { relinked = false; }
|
|
1177
1185
|
}
|
|
1178
|
-
|
|
1186
|
+
// What the caller must tell the user about on the FAILURE path: the worktree
|
|
1187
|
+
// survived without its shared infra, on purpose.
|
|
1188
|
+
const infraLeftUnlinked = (!worktreeRemoved && !refused) ? unlinked : [];
|
|
1189
|
+
|
|
1190
|
+
// Every branch names `morph-spec worktree remove` and NEVER `git worktree
|
|
1191
|
+
// remove`. The raw command follows the junctions this function manages, and a
|
|
1192
|
+
// decline is exactly the moment a frustrated reader copies whatever command
|
|
1193
|
+
// the tool just printed — that copy is what emptied a primary root's
|
|
1194
|
+
// `.claude/` and `.morph/framework/`. `worktree remove` re-runs this same
|
|
1195
|
+
// PROVE-or-DECLINE path and accepts the same `--discard-ignored` consent, so
|
|
1196
|
+
// there is no decline left where the raw command would be the right answer.
|
|
1197
|
+
// A worktree with no feature identity (the ones `isolation: "worktree"`
|
|
1198
|
+
// creates) can only be re-addressed by path — naming a feature that does not
|
|
1199
|
+
// resolve would hand the reader a command that FAILS, and a failing safe
|
|
1200
|
+
// command is exactly how people end up back at the raw one.
|
|
1201
|
+
const removeCmd = (addressByTarget || !isValidFeatureName(feature))
|
|
1202
|
+
? `morph-spec worktree remove --target "${worktreePath}"`
|
|
1203
|
+
: `morph-spec worktree remove ${feature}`;
|
|
1179
1204
|
const declineReason = worktreeRemoved
|
|
1180
1205
|
? null
|
|
1181
1206
|
: ignored.blocking.length
|
|
1182
|
-
? `worktree NÃO removido: contém arquivo(s) gitignored que existem só ali e
|
|
1207
|
+
? `worktree NÃO removido: contém arquivo(s) gitignored que existem só ali e a remoção apagaria (${ignored.blocking.slice(0, 5).join(', ')}${ignored.blocking.length > 5 ? `, +${ignored.blocking.length - 5}` : ''}). Salve o que importa e rode \`${removeCmd}\`, ou \`${removeCmd} --discard-ignored\` para descartá-los deliberadamente`
|
|
1183
1208
|
: blockers.length
|
|
1184
|
-
? `worktree NÃO removido: ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que
|
|
1209
|
+
? `worktree NÃO removido: ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que a remoção seguiria para fora, apagando o alvo na raiz. Remova-os e rode \`${removeCmd}\``
|
|
1185
1210
|
: ignored.shieldable.length && ignoredBackup === null
|
|
1186
|
-
? `worktree NÃO removido: o backup do gitignored do worktree falhou, e sem ele a remoção seria destrutiva.
|
|
1187
|
-
:
|
|
1211
|
+
? `worktree NÃO removido: o backup do gitignored do worktree falhou, e sem ele a remoção seria destrutiva. Libere espaço/permissão em \`.git/morph-spec/\` e rode \`${removeCmd}\`, ou \`${removeCmd} --discard-ignored\` se não precisar desse conteúdo`
|
|
1212
|
+
: removalFailure?.kind === 'in-use'
|
|
1213
|
+
? `worktree NÃO removido: o diretório está EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd em "${worktreePath}"). A infra compartilhada ficou DESMONTADA de propósito — feche o que está usando o diretório e rode \`${removeCmd}\`. Se preferir continuar trabalhando nele, restaure a infra com \`morph-spec worktree link --all\``
|
|
1214
|
+
: `worktree NÃO removido: a remoção falhou${removalFailure?.detail ? ` (${removalFailure.detail})` : ''}. A infra compartilhada ficou DESMONTADA de propósito — rode \`${removeCmd}\` para tentar de novo, ou \`morph-spec worktree link --all\` para voltar a trabalhar nele`;
|
|
1188
1215
|
|
|
1189
1216
|
return {
|
|
1190
1217
|
worktreeRemoved,
|
|
@@ -1195,7 +1222,144 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1195
1222
|
shieldable: ignored.shieldable,
|
|
1196
1223
|
declineReason,
|
|
1197
1224
|
relinked,
|
|
1225
|
+
refused,
|
|
1226
|
+
infraLeftUnlinked,
|
|
1227
|
+
removalFailure,
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Is `cwd` the worktree we are about to remove, or inside it?
|
|
1233
|
+
*
|
|
1234
|
+
* Hoisted out of `finish` so BOTH callers share one answer. Windows holds a
|
|
1235
|
+
* lock on a process's working directory, so a removal issued from inside the
|
|
1236
|
+
* target fails — and in `finish`'s case it fails AFTER the archive is committed
|
|
1237
|
+
* and the branch merged, leaving an orphan directory and a de-registered
|
|
1238
|
+
* worktree. Refusing up front costs one `cd`.
|
|
1239
|
+
*
|
|
1240
|
+
* @param {string} cwd
|
|
1241
|
+
* @param {string} worktreePath
|
|
1242
|
+
* @returns {boolean}
|
|
1243
|
+
*/
|
|
1244
|
+
export function cwdIsInsideWorktree(cwd, worktreePath) {
|
|
1245
|
+
const cwdNorm = resolve(cwd).replace(/\\/g, '/');
|
|
1246
|
+
const wtNorm = resolve(worktreePath).replace(/\\/g, '/');
|
|
1247
|
+
return cwdNorm === wtNorm || cwdNorm.startsWith(`${wtNorm}/`);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// ── remove ───────────────────────────────────────────────────────────────
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Command handler for `morph-spec worktree remove <feature>` /
|
|
1254
|
+
* `morph-spec worktree remove --target <path>`.
|
|
1255
|
+
*
|
|
1256
|
+
* WHY THIS EXISTS: `teardownWorktree` — the only removal that drops the infra
|
|
1257
|
+
* junctions before letting git near the directory — used to be reachable ONLY
|
|
1258
|
+
* through `morph-spec finish`. So every message that had to tell a user how to
|
|
1259
|
+
* remove a worktree pointed at raw `git worktree remove`, which follows those
|
|
1260
|
+
* junctions out and empties the primary root's `.claude/` and
|
|
1261
|
+
* `.morph/framework/`. This command is the safe answer those messages now give.
|
|
1262
|
+
*
|
|
1263
|
+
* Two ways to address the target, because there are two kinds of worktree:
|
|
1264
|
+
* <feature> — a morph worktree, resolved through `findWorktreeForFeature`
|
|
1265
|
+
* --target <path> — any registered worktree, including the ones the Agent
|
|
1266
|
+
* tool's `isolation: "worktree"` creates, which have no
|
|
1267
|
+
* feature registration for the lookup to find.
|
|
1268
|
+
*
|
|
1269
|
+
* It removes ONLY. No archive, no merge, and the branch survives by default —
|
|
1270
|
+
* closing a feature is still `morph-spec finish`.
|
|
1271
|
+
*
|
|
1272
|
+
* @param {string|undefined} featureArg
|
|
1273
|
+
* @param {{ target?: string, discardIgnored?: boolean, deleteBranch?: boolean, cwd?: string }} [options]
|
|
1274
|
+
*/
|
|
1275
|
+
export async function worktreeRemoveCommand(featureArg, options = {}) {
|
|
1276
|
+
const cwdArg = options.cwd || process.cwd();
|
|
1277
|
+
const emit = (obj, code) => { console.log(JSON.stringify(obj)); process.exit(code); };
|
|
1278
|
+
|
|
1279
|
+
if (!isGitRepo(cwdArg)) emit({ removed: false, error: 'not-a-git-repo' }, 1);
|
|
1280
|
+
const root = resolvePrimaryRoot(cwdArg);
|
|
1281
|
+
|
|
1282
|
+
let worktreePath = null;
|
|
1283
|
+
let feature = featureArg || null;
|
|
1284
|
+
let branch = null;
|
|
1285
|
+
// Can a re-run be addressed as `remove <feature>`? Only when the feature name
|
|
1286
|
+
// is what resolved the worktree in the first place.
|
|
1287
|
+
let featureAddressable = !options.target;
|
|
1288
|
+
|
|
1289
|
+
if (options.target) {
|
|
1290
|
+
worktreePath = resolve(options.target).replace(/\\/g, '/');
|
|
1291
|
+
// The target MUST be a registered worktree. Without this the command would
|
|
1292
|
+
// hand an arbitrary path to `git worktree remove` — and `--target ../..`
|
|
1293
|
+
// is the same path-traversal class already fixed in delete/archive.
|
|
1294
|
+
const entry = parseWorktreeList(root).find((e) => e.path === worktreePath);
|
|
1295
|
+
if (!entry) {
|
|
1296
|
+
emit({ removed: false, error: 'not-a-registered-worktree', target: worktreePath, root,
|
|
1297
|
+
hint: 'o path precisa ser um worktree registrado neste repo — confira `morph-spec worktree list`' }, 1);
|
|
1298
|
+
}
|
|
1299
|
+
if (worktreePath === root) {
|
|
1300
|
+
emit({ removed: false, error: 'refuses-primary-root', path: worktreePath,
|
|
1301
|
+
hint: 'a raiz primária nunca é removível por este comando' }, 1);
|
|
1302
|
+
}
|
|
1303
|
+
branch = entry.branch ? entry.branch.replace(/^refs\/heads\//, '') : null;
|
|
1304
|
+
// Best-effort feature identity — used for the backup/env-release bookkeeping
|
|
1305
|
+
// inside the teardown. A `morph/{f}` branch means `remove <feature>` would
|
|
1306
|
+
// resolve too, so re-run advice may name it; anything else stays --target.
|
|
1307
|
+
if (!feature && branch?.startsWith('morph/')) {
|
|
1308
|
+
feature = branch.slice('morph/'.length);
|
|
1309
|
+
featureAddressable = isValidFeatureName(feature);
|
|
1310
|
+
}
|
|
1311
|
+
} else {
|
|
1312
|
+
if (!isValidFeatureName(feature)) {
|
|
1313
|
+
emit({ removed: false, error: 'invalid-feature-name', feature: feature ?? null,
|
|
1314
|
+
hint: 'informe <feature> (kebab-case) ou use --target <path>' }, 1);
|
|
1315
|
+
}
|
|
1316
|
+
const found = findWorktreeForFeature(feature, root);
|
|
1317
|
+
if (!found || found.path.replace(/\\/g, '/') === root) {
|
|
1318
|
+
emit({ removed: false, error: 'no-worktree-for-feature', feature, root,
|
|
1319
|
+
hint: `nenhum worktree separado para "${feature}" — confira \`morph-spec worktree list\`` }, 1);
|
|
1320
|
+
}
|
|
1321
|
+
worktreePath = found.path;
|
|
1322
|
+
branch = found.branch;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Same guard finish applies, for the same Windows reason — and this command
|
|
1326
|
+
// is MORE likely to hit it: the natural moment to run it is right after
|
|
1327
|
+
// working inside the worktree.
|
|
1328
|
+
if (cwdIsInsideWorktree(cwdArg, worktreePath)) {
|
|
1329
|
+
emit({ removed: false, error: 'cwd-inside-worktree', path: worktreePath, root,
|
|
1330
|
+
hint: `saia do worktree antes de removê-lo — o processo trava o próprio diretório de trabalho. Rode de novo a partir de "${root}"` }, 1);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
const teardown = await teardownWorktree(worktreePath, {
|
|
1334
|
+
root,
|
|
1335
|
+
feature: feature || 'worktree',
|
|
1336
|
+
branch,
|
|
1337
|
+
options: { discardIgnored: options.discardIgnored === true },
|
|
1338
|
+
// Removing a worktree is not closing a feature: the branch is the work, and
|
|
1339
|
+
// deleting it here would discard unintegrated commits. Opt-in only.
|
|
1340
|
+
deleteBranch: options.deleteBranch === true,
|
|
1341
|
+
// Re-run advice must address the worktree the way it was addressable here:
|
|
1342
|
+
// a `--target` worktree has no feature name that would resolve.
|
|
1343
|
+
addressByTarget: !featureAddressable,
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
const out = {
|
|
1347
|
+
removed: teardown.worktreeRemoved,
|
|
1348
|
+
path: worktreePath,
|
|
1349
|
+
...(branch ? { branch } : {}),
|
|
1350
|
+
...(feature ? { feature } : {}),
|
|
1198
1351
|
};
|
|
1352
|
+
if (teardown.worktreeRemoved) {
|
|
1353
|
+
if (teardown.discarded.length) out.discardedIgnored = teardown.discarded;
|
|
1354
|
+
if (teardown.ignoredBackup) out.ignoredBackup = teardown.ignoredBackup;
|
|
1355
|
+
} else {
|
|
1356
|
+
out.refused = teardown.refused;
|
|
1357
|
+
if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
|
|
1358
|
+
if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
|
|
1359
|
+
if (teardown.infraLeftUnlinked.length) out.infraLeftUnlinked = teardown.infraLeftUnlinked;
|
|
1360
|
+
out.hint = teardown.declineReason;
|
|
1361
|
+
}
|
|
1362
|
+
emit(out, teardown.worktreeRemoved ? 0 : 1);
|
|
1199
1363
|
}
|
|
1200
1364
|
|
|
1201
1365
|
// ── list ─────────────────────────────────────────────────────────────────
|
|
@@ -325,6 +325,17 @@ export async function approveCommand(featureName, gateName, options = {}) {
|
|
|
325
325
|
...(selfAssessed ? { selfAssessed: true } : {}),
|
|
326
326
|
});
|
|
327
327
|
|
|
328
|
+
// Fleet handoff (mirrors advance.js): approving a gate is exactly when a
|
|
329
|
+
// feature hands over between sessions too — `approve` is the real path
|
|
330
|
+
// Gate 2 took in the field (`approve <f> plan --mode auto`), not just
|
|
331
|
+
// `advance`, so a handoff.md left stale after approve was the common
|
|
332
|
+
// case, not the edge case. Same helper, same fail-open contract: a
|
|
333
|
+
// regeneration failure must never fail the approval itself.
|
|
334
|
+
try {
|
|
335
|
+
const { writeHandoff } = await import('../../lib/generators/handoff-generator.js');
|
|
336
|
+
writeHandoff(featureName, process.cwd());
|
|
337
|
+
} catch { /* fail-open */ }
|
|
338
|
+
|
|
328
339
|
if (selfAssessed) {
|
|
329
340
|
console.log(chalk.yellow('\n⚠️ Gate 3 aprovado SEM avaliador independente (--self-assessed).'));
|
|
330
341
|
console.log(chalk.gray(' Registrado em feature.json → approvalGates.review.selfAssessed.'));
|