@use-the-fork/spindle 0.1.0 → 0.2.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/build/Assets/Blueprints/Catalog.js +7 -2
- package/build/Assets/Blueprints/agents/sdd-implementer.agent.md +6 -6
- package/build/Assets/Blueprints/agents/sdd-orchestrator.agent.md +4 -7
- package/build/Assets/Blueprints/skills/author-agent/SKILL.md +2 -2
- package/build/Assets/Blueprints/skills/author-instruction/SKILL.md +2 -2
- package/build/Assets/Blueprints/skills/author-skill/SKILL.md +2 -2
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/SKILL.md +13 -9
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/assets/implementer-prompt.md +2 -2
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/example-workflow.md +11 -9
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/full-spec.md +33 -13
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/review-inputs.md +7 -7
- package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/SKILL.md +1 -1
- package/build/Assets/Blueprints/skills/sdd-author-design/SKILL.md +2 -2
- package/build/Assets/Blueprints/skills/sdd-implement-spec/SKILL.md +4 -6
- package/build/Assets/Templates/project/ticket-tracker.md +10 -2
- package/build/SubagentDrivenDevelopment/Commands/ChecksRecordCommand.js +27 -5
- package/build/SubagentDrivenDevelopment/Commands/ChecksRequireCommand.js +31 -7
- package/build/SubagentDrivenDevelopment/Commands/CorrectCommand.js +0 -1
- package/build/SubagentDrivenDevelopment/Commands/FinishCommand.js +2 -1
- package/build/SubagentDrivenDevelopment/Commands/ImplementCommand.js +2 -0
- package/build/SubagentDrivenDevelopment/Commands/{ClaimCommand.js → IntegrateCommand.js} +8 -7
- package/build/SubagentDrivenDevelopment/Commands/ReviewCommand.js +9 -9
- package/build/SubagentDrivenDevelopment/Commands/StartCommand.js +23 -0
- package/build/SubagentDrivenDevelopment/Commands/StatusCommand.js +5 -0
- package/build/SubagentDrivenDevelopment/Commands/groups.js +3 -2
- package/build/SubagentDrivenDevelopment/Evidence/GitEvidence.js +188 -37
- package/build/SubagentDrivenDevelopment/Models/Workflow.js +0 -2
- package/build/SubagentDrivenDevelopment/Persistence/AssignmentSchema.js +4 -0
- package/build/SubagentDrivenDevelopment/Persistence/CheckSchema.js +4 -13
- package/build/SubagentDrivenDevelopment/Persistence/LedgerRepository.js +66 -5
- package/build/SubagentDrivenDevelopment/Persistence/ProgressSchema.js +54 -4
- package/build/SubagentDrivenDevelopment/Persistence/ReviewSchema.js +4 -0
- package/build/SubagentDrivenDevelopment/Policies/AssignmentPolicy.js +0 -3
- package/build/SubagentDrivenDevelopment/Policies/CheckPolicy.js +6 -3
- package/build/SubagentDrivenDevelopment/Policies/ReadinessPolicy.js +16 -6
- package/build/SubagentDrivenDevelopment/Policies/ReviewPolicy.js +16 -2
- package/build/SubagentDrivenDevelopment/Policies/WorkflowPolicy.js +8 -8
- package/build/SubagentDrivenDevelopment/Services/AssignmentService.js +73 -51
- package/build/SubagentDrivenDevelopment/Services/ChecksService.js +376 -21
- package/build/SubagentDrivenDevelopment/Services/CorrectionService.js +83 -14
- package/build/SubagentDrivenDevelopment/Services/DeliveryCorrectionService.js +3 -7
- package/build/SubagentDrivenDevelopment/Services/DeliveryService.js +43 -38
- package/build/SubagentDrivenDevelopment/Services/FeatureCreationService.js +1 -1
- package/build/SubagentDrivenDevelopment/Services/FeatureFinalizationService.js +1202 -0
- package/build/SubagentDrivenDevelopment/Services/FeatureInspectionService.js +41 -49
- package/build/SubagentDrivenDevelopment/Services/FeatureWorktreeService.js +544 -0
- package/build/SubagentDrivenDevelopment/Services/ReadinessService.js +2 -2
- package/build/SubagentDrivenDevelopment/Services/ReviewService.js +39 -3
- package/build/SubagentDrivenDevelopment/Services/SubmissionService.js +4 -1
- package/build/SubagentDrivenDevelopment/Services/TicketIntegrationService.js +621 -0
- package/build/SubagentDrivenDevelopment/Services/TicketService.js +3 -0
- package/build/SubagentDrivenDevelopment/Services/TicketWorktreeService.js +317 -0
- package/build/SubagentDrivenDevelopment/Services/WorkflowRecordsCommitter.js +336 -0
- package/build/SubagentDrivenDevelopment/SubagentDrivenDevelopmentServiceProvider.js +18 -8
- package/build/TemplateRendering/Computed/Providers/AuthoringFlowchartProvider.js +1 -1
- package/package.json +2 -2
- package/build/SubagentDrivenDevelopment/Services/IntegrationService.js +0 -258
- /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/CREDITS.MD +0 -0
- /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/examples/before-after.md +0 -0
- /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/references/writing-rules.md +0 -0
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { accessSync, constants, closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { Service } from "../../Foundation/Service.js";
|
|
6
|
+
import { validateAuthor } from "../Policies/InputValidation.js";
|
|
7
|
+
export class FeatureFinalizationService extends Service {
|
|
8
|
+
git;
|
|
9
|
+
ledgers;
|
|
10
|
+
constructor(app, git, ledgers) {
|
|
11
|
+
super(app);
|
|
12
|
+
this.git = git;
|
|
13
|
+
this.ledgers = ledgers;
|
|
14
|
+
}
|
|
15
|
+
finish(feature, options, assertReady) {
|
|
16
|
+
validateAuthor(options.author);
|
|
17
|
+
return this.locked(feature.feature, (common, path) => {
|
|
18
|
+
let journal = this.read(path);
|
|
19
|
+
if (!journal) {
|
|
20
|
+
if (!options.confirmRecords)
|
|
21
|
+
throw new Error("Inspect canonical records and pass --confirm-records.");
|
|
22
|
+
const startupPath = join(common, "spindle-feature-start", `${feature.feature}.json`);
|
|
23
|
+
this.real(startupPath, "file");
|
|
24
|
+
const startupBytes = readFileSync(startupPath);
|
|
25
|
+
const startup = JSON.parse(startupBytes.toString("utf8"));
|
|
26
|
+
this.validateStartup(startup, feature.feature);
|
|
27
|
+
if (this.app.workspaceRoot !== startup.featureWorkspace)
|
|
28
|
+
throw new Error("Begin finish from the recorded feature worktree.");
|
|
29
|
+
this.workspace(startup.originalWorkspace, startup.originalBranch, startup.featureRevision, common);
|
|
30
|
+
this.clean(startup.originalWorkspace, false);
|
|
31
|
+
this.noOperation(startup.originalWorkspace);
|
|
32
|
+
this.noHooks(startup.originalWorkspace);
|
|
33
|
+
this.workspace(startup.featureWorkspace, startup.featureBranch, undefined, common);
|
|
34
|
+
const startupIdentity = lstatSync(startup.featureWorkspace);
|
|
35
|
+
if (!startup.targetIdentity ||
|
|
36
|
+
startupIdentity.dev !== startup.targetIdentity.device ||
|
|
37
|
+
startupIdentity.ino !== startup.targetIdentity.inode)
|
|
38
|
+
throw new Error("Startup feature path identity changed.");
|
|
39
|
+
this.noOperation(startup.featureWorkspace);
|
|
40
|
+
this.noHooks(startup.featureWorkspace);
|
|
41
|
+
this.recordsOnly(startup.featureWorkspace, feature.feature);
|
|
42
|
+
const approval = assertReady();
|
|
43
|
+
const required = approval.ledger.delivery.checks?.required.filter((item) => !item.removedAt) ?? [];
|
|
44
|
+
if (!required.length ||
|
|
45
|
+
new Set(required.map((item) => item.command)).size !== required.length) {
|
|
46
|
+
throw new Error("Declare a nonempty unique final delivery check list before finishing.");
|
|
47
|
+
}
|
|
48
|
+
const head = this.head(startup.featureWorkspace);
|
|
49
|
+
this.ancestor(startup.originalWorkspace, startup.featureRevision, head);
|
|
50
|
+
const ticketStarts = [];
|
|
51
|
+
const ownership = [
|
|
52
|
+
{ workspace: startup.featureWorkspace, branch: startup.featureBranch },
|
|
53
|
+
...approval.ledger.tickets.map((ticket) => {
|
|
54
|
+
if (!ticket.worktree)
|
|
55
|
+
throw new Error(`Ticket ${ticket.id} lacks recorded cleanup ownership.`);
|
|
56
|
+
if (ticket.worktree.branch !== `tickets/${feature.feature}/${ticket.id}`)
|
|
57
|
+
throw new Error("Ticket cleanup branch does not match its exact feature/ticket owner.");
|
|
58
|
+
if (this.head(ticket.worktree.workspace) !== ticket.integration?.commitSha) {
|
|
59
|
+
throw new Error("Ticket branch moved from its recorded integrated commit; preserve it.");
|
|
60
|
+
}
|
|
61
|
+
const path = join(common, "spindle-ticket-start", `${feature.feature}-${ticket.id}.json`);
|
|
62
|
+
this.real(path, "file");
|
|
63
|
+
const bytes = readFileSync(path);
|
|
64
|
+
const start = JSON.parse(bytes.toString("utf8"));
|
|
65
|
+
const stat = lstatSync(ticket.worktree.workspace);
|
|
66
|
+
if (start.version !== 2 ||
|
|
67
|
+
!start.id ||
|
|
68
|
+
start.feature !== feature.feature ||
|
|
69
|
+
start.ticket !== ticket.id ||
|
|
70
|
+
start.owner !== ticket.owner ||
|
|
71
|
+
start.featureWorkspace !== startup.featureWorkspace ||
|
|
72
|
+
start.workspace !== ticket.worktree.workspace ||
|
|
73
|
+
start.branch !== ticket.worktree.branch ||
|
|
74
|
+
!start.directory ||
|
|
75
|
+
start.directory.device !== stat.dev ||
|
|
76
|
+
start.directory.inode !== stat.ino)
|
|
77
|
+
throw new Error("Ticket startup ownership/path provenance failed verification.");
|
|
78
|
+
if (start.baseSha !== ticket.worktree.baseSha) {
|
|
79
|
+
const history = ticket.baselineHistory;
|
|
80
|
+
if (!history?.length ||
|
|
81
|
+
history[0].from !== start.baseSha ||
|
|
82
|
+
history.at(-1).to !== ticket.worktree.baseSha ||
|
|
83
|
+
ticket.baseSha !== ticket.worktree.baseSha) {
|
|
84
|
+
throw new Error("Ticket baseline moved without recorded startup-to-current provenance.");
|
|
85
|
+
}
|
|
86
|
+
this.ancestor(startup.originalWorkspace, start.baseSha, ticket.worktree.baseSha);
|
|
87
|
+
}
|
|
88
|
+
ticketStarts.push({ path, digest: this.digest(bytes) });
|
|
89
|
+
return ticket.worktree;
|
|
90
|
+
}),
|
|
91
|
+
];
|
|
92
|
+
const targets = ownership.map((item) => {
|
|
93
|
+
this.location(item.workspace, startup, common);
|
|
94
|
+
this.workspace(item.workspace, item.branch, undefined, common);
|
|
95
|
+
const revision = this.head(item.workspace);
|
|
96
|
+
this.ancestor(startup.originalWorkspace, revision, head);
|
|
97
|
+
const stat = lstatSync(item.workspace);
|
|
98
|
+
return {
|
|
99
|
+
workspace: item.workspace,
|
|
100
|
+
branch: item.branch,
|
|
101
|
+
allowedParent: dirname(item.workspace),
|
|
102
|
+
revision,
|
|
103
|
+
dev: stat.dev,
|
|
104
|
+
ino: stat.ino,
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
if (new Set(targets.map((item) => item.workspace)).size !== targets.length ||
|
|
108
|
+
new Set(targets.map((item) => item.branch)).size !== targets.length)
|
|
109
|
+
throw new Error("Duplicate cleanup ownership.");
|
|
110
|
+
this.inventory(startup, targets);
|
|
111
|
+
const ledgerBefore = readFileSync(feature.ledger, "utf8");
|
|
112
|
+
const relocated = this.relocate(approval.ledger, startup, targets);
|
|
113
|
+
let retrospective;
|
|
114
|
+
if (approval.retrospective) {
|
|
115
|
+
const source = approval.retrospective.report;
|
|
116
|
+
this.real(source, "file");
|
|
117
|
+
if (!this.inside(join(startup.featureWorkspace, `.scratch/${feature.feature}/reports`), source) ||
|
|
118
|
+
this.digest(readFileSync(source)) !== approval.retrospective.reportSha256)
|
|
119
|
+
throw new Error("Invalid retrospective relocation.");
|
|
120
|
+
const destination = join(startup.originalWorkspace, relative(startup.featureWorkspace, source));
|
|
121
|
+
relocated.mappings.push({
|
|
122
|
+
before: source,
|
|
123
|
+
after: destination,
|
|
124
|
+
sha256: approval.retrospective.reportSha256,
|
|
125
|
+
});
|
|
126
|
+
retrospective = {
|
|
127
|
+
report: destination,
|
|
128
|
+
reportSha256: approval.retrospective.reportSha256,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const repository = lstatSync(common);
|
|
132
|
+
const original = lstatSync(startup.originalWorkspace);
|
|
133
|
+
journal = {
|
|
134
|
+
version: 1,
|
|
135
|
+
id: randomUUID(),
|
|
136
|
+
startup,
|
|
137
|
+
common,
|
|
138
|
+
startupDigest: this.digest(startupBytes),
|
|
139
|
+
repository: { dev: repository.dev, ino: repository.ino },
|
|
140
|
+
original: { dev: original.dev, ino: original.ino },
|
|
141
|
+
author: options.author.trim(),
|
|
142
|
+
approved: approval.identity,
|
|
143
|
+
approvedLedger: approval.ledger,
|
|
144
|
+
required: [],
|
|
145
|
+
records: [],
|
|
146
|
+
finalDeclared: false,
|
|
147
|
+
ledgerBefore,
|
|
148
|
+
ledgerAfter: `${JSON.stringify(relocated.ledger, null, 2)}\n`,
|
|
149
|
+
mappings: relocated.mappings,
|
|
150
|
+
files: [],
|
|
151
|
+
targets,
|
|
152
|
+
phase: "records",
|
|
153
|
+
beforeCommit: head,
|
|
154
|
+
...(retrospective ? { retrospective } : {}),
|
|
155
|
+
ticketStarts,
|
|
156
|
+
};
|
|
157
|
+
journal.files = this.recordFiles(startup.featureWorkspace, feature.feature).map((file) => ({
|
|
158
|
+
path: file,
|
|
159
|
+
sha256: this.digest(readFileSync(join(startup.featureWorkspace, file))),
|
|
160
|
+
}));
|
|
161
|
+
this.persist(path, journal);
|
|
162
|
+
}
|
|
163
|
+
this.verifyJournal(journal, common, feature.feature);
|
|
164
|
+
if (journal.author !== options.author.trim())
|
|
165
|
+
throw new Error("Only the recorded controller may finish this feature.");
|
|
166
|
+
const state = journal.startup;
|
|
167
|
+
if (journal.phase === "records") {
|
|
168
|
+
this.workspace(state.originalWorkspace, state.originalBranch, state.featureRevision, common);
|
|
169
|
+
this.clean(state.originalWorkspace, false);
|
|
170
|
+
this.noOperation(state.originalWorkspace);
|
|
171
|
+
this.targetIdentity(journal.targets[0]);
|
|
172
|
+
this.workspace(state.featureWorkspace, state.featureBranch, undefined, common);
|
|
173
|
+
this.noOperation(state.featureWorkspace);
|
|
174
|
+
this.ready(journal, assertReady);
|
|
175
|
+
this.recordsOnly(state.featureWorkspace, feature.feature);
|
|
176
|
+
this.verifyFiles(journal, state.featureWorkspace);
|
|
177
|
+
const head = this.head(state.featureWorkspace);
|
|
178
|
+
if (head !== journal.beforeCommit) {
|
|
179
|
+
if (!journal.recordTree)
|
|
180
|
+
throw new Error("Feature branch moved outside journalled commit.");
|
|
181
|
+
this.commitShape(state.featureWorkspace, head, [journal.beforeCommit], journal.recordTree);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
if (!journal.recordTree) {
|
|
185
|
+
this.run(state.featureWorkspace, [
|
|
186
|
+
"add",
|
|
187
|
+
"-A",
|
|
188
|
+
"--",
|
|
189
|
+
`:(literal).scratch/${feature.feature}`,
|
|
190
|
+
]);
|
|
191
|
+
journal.recordTree = this.run(state.featureWorkspace, ["write-tree"]);
|
|
192
|
+
this.persist(path, journal);
|
|
193
|
+
}
|
|
194
|
+
this.ready(journal, assertReady);
|
|
195
|
+
this.workspace(state.featureWorkspace, state.featureBranch, journal.beforeCommit, common);
|
|
196
|
+
this.verifyFiles(journal, state.featureWorkspace);
|
|
197
|
+
if (this.run(state.featureWorkspace, ["write-tree"]) !== journal.recordTree ||
|
|
198
|
+
this.run(state.featureWorkspace, ["diff", "--name-only", "-z"]))
|
|
199
|
+
throw new Error("Journalled index or records changed.");
|
|
200
|
+
if (this.tree(state.featureWorkspace, head) !== journal.recordTree) {
|
|
201
|
+
this.run(state.featureWorkspace, [
|
|
202
|
+
"commit",
|
|
203
|
+
"-m",
|
|
204
|
+
`spindle: finalize ${feature.feature} records`,
|
|
205
|
+
]);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
journal.featureCommit = this.head(state.featureWorkspace);
|
|
209
|
+
if (journal.featureCommit !== journal.beforeCommit) {
|
|
210
|
+
this.commitShape(state.featureWorkspace, journal.featureCommit, [journal.beforeCommit], journal.recordTree);
|
|
211
|
+
const paths = this.run(state.featureWorkspace, [
|
|
212
|
+
"diff",
|
|
213
|
+
"--name-only",
|
|
214
|
+
"--no-renames",
|
|
215
|
+
"-z",
|
|
216
|
+
journal.beforeCommit,
|
|
217
|
+
journal.featureCommit,
|
|
218
|
+
])
|
|
219
|
+
.split("\0")
|
|
220
|
+
.filter(Boolean);
|
|
221
|
+
if (paths.some((file) => !this.canonical(file, feature.feature)))
|
|
222
|
+
throw new Error("Record commit contains unrelated paths.");
|
|
223
|
+
}
|
|
224
|
+
this.clean(state.featureWorkspace);
|
|
225
|
+
journal.targets[0].revision = journal.featureCommit;
|
|
226
|
+
journal.phase = "merging";
|
|
227
|
+
this.persist(path, journal);
|
|
228
|
+
}
|
|
229
|
+
if (journal.phase === "merging") {
|
|
230
|
+
this.targetIdentity(journal.targets[0]);
|
|
231
|
+
this.workspace(state.featureWorkspace, state.featureBranch, journal.featureCommit, common);
|
|
232
|
+
this.clean(state.featureWorkspace);
|
|
233
|
+
this.verifyFiles(journal, state.featureWorkspace);
|
|
234
|
+
this.workspace(state.originalWorkspace, state.originalBranch, undefined, common);
|
|
235
|
+
this.ancestor(state.originalWorkspace, state.featureRevision, journal.featureCommit);
|
|
236
|
+
if (this.head(state.originalWorkspace) === state.featureRevision) {
|
|
237
|
+
this.ready(journal, assertReady);
|
|
238
|
+
const mergeHead = this.gitPath(state.originalWorkspace, "MERGE_HEAD");
|
|
239
|
+
if (this.exists(mergeHead)) {
|
|
240
|
+
this.real(mergeHead, "file");
|
|
241
|
+
if (readFileSync(mergeHead, "utf8").trim() !== journal.featureCommit)
|
|
242
|
+
throw new Error("Preserve unrelated in-progress merge.");
|
|
243
|
+
this.noOperation(state.originalWorkspace, true);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
this.clean(state.originalWorkspace, false);
|
|
247
|
+
this.noOperation(state.originalWorkspace);
|
|
248
|
+
this.assertNoIgnoredCollisions(state.originalWorkspace, journal.featureCommit);
|
|
249
|
+
this.run(state.originalWorkspace, [
|
|
250
|
+
"merge",
|
|
251
|
+
"--no-ff",
|
|
252
|
+
"--no-commit",
|
|
253
|
+
"--no-edit",
|
|
254
|
+
"--no-autostash",
|
|
255
|
+
"--no-overwrite-ignore",
|
|
256
|
+
journal.featureCommit,
|
|
257
|
+
]);
|
|
258
|
+
}
|
|
259
|
+
this.mergeIndex(state.originalWorkspace, journal.featureCommit);
|
|
260
|
+
this.workspace(state.originalWorkspace, state.originalBranch, state.featureRevision, common);
|
|
261
|
+
this.run(state.originalWorkspace, ["commit", "-m", `spindle: merge ${feature.feature}`]);
|
|
262
|
+
}
|
|
263
|
+
journal.mergeCommit = this.head(state.originalWorkspace);
|
|
264
|
+
this.commitShape(state.originalWorkspace, journal.mergeCommit, [state.featureRevision, journal.featureCommit], this.tree(state.originalWorkspace, journal.featureCommit));
|
|
265
|
+
this.clean(state.originalWorkspace, false);
|
|
266
|
+
this.noOperation(state.originalWorkspace);
|
|
267
|
+
this.verifyFiles(journal, state.originalWorkspace);
|
|
268
|
+
journal.finalIdentity = {
|
|
269
|
+
...journal.approved,
|
|
270
|
+
headSha: journal.mergeCommit,
|
|
271
|
+
snapshot: this.digest(JSON.stringify([
|
|
272
|
+
journal.id,
|
|
273
|
+
common,
|
|
274
|
+
journal.mergeCommit,
|
|
275
|
+
this.tree(state.originalWorkspace, journal.mergeCommit),
|
|
276
|
+
])),
|
|
277
|
+
};
|
|
278
|
+
journal.phase = "validation";
|
|
279
|
+
this.persist(path, journal);
|
|
280
|
+
}
|
|
281
|
+
if (journal.phase !== "metadata")
|
|
282
|
+
this.finalCheckout(journal);
|
|
283
|
+
if (journal.phase === "validation" && !this.passing(journal))
|
|
284
|
+
return this.pending(journal, path);
|
|
285
|
+
if (journal.phase === "validation" || journal.phase === "metadata")
|
|
286
|
+
this.metadata(journal, path);
|
|
287
|
+
this.cleanup(journal, path);
|
|
288
|
+
return {
|
|
289
|
+
scope: "delivery",
|
|
290
|
+
finished: true,
|
|
291
|
+
feature: state.feature,
|
|
292
|
+
mergeCommit: journal.mergeCommit,
|
|
293
|
+
originalWorkspace: state.originalWorkspace,
|
|
294
|
+
spec: join(state.originalWorkspace, `.scratch/${state.feature}/spec.md`),
|
|
295
|
+
cleanup: "complete",
|
|
296
|
+
checkedCommit: journal.mergeCommit,
|
|
297
|
+
metadataCommit: journal.metadataCommit,
|
|
298
|
+
metadataCommitChecked: false,
|
|
299
|
+
journal: path,
|
|
300
|
+
...(journal.retrospective ? { retrospective: journal.retrospective } : {}),
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
status(feature) {
|
|
305
|
+
return this.locked(feature, (common, path) => {
|
|
306
|
+
const journal = this.read(path);
|
|
307
|
+
if (!journal)
|
|
308
|
+
throw new Error("Feature has no finalization journal.");
|
|
309
|
+
this.verifyJournal(journal, common, feature);
|
|
310
|
+
if (journal.mergeCommit && journal.phase !== "metadata")
|
|
311
|
+
this.finalCheckout(journal);
|
|
312
|
+
return journal.phase === "complete"
|
|
313
|
+
? {
|
|
314
|
+
feature,
|
|
315
|
+
finished: true,
|
|
316
|
+
mergeCommit: journal.mergeCommit,
|
|
317
|
+
metadataCommit: journal.metadataCommit,
|
|
318
|
+
checkedCommit: journal.mergeCommit,
|
|
319
|
+
metadataCommitChecked: false,
|
|
320
|
+
cleanup: "complete",
|
|
321
|
+
journal: path,
|
|
322
|
+
}
|
|
323
|
+
: this.pending(journal, path);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
hasStarted(feature) {
|
|
327
|
+
if (!/^\d{4,}-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(feature))
|
|
328
|
+
throw new Error("Invalid feature identifier.");
|
|
329
|
+
const common = this.run(this.app.workspaceRoot, [
|
|
330
|
+
"rev-parse",
|
|
331
|
+
"--path-format=absolute",
|
|
332
|
+
"--git-common-dir",
|
|
333
|
+
]);
|
|
334
|
+
this.real(common, "directory");
|
|
335
|
+
const path = join(common, "spindle-feature-finalization", `${feature}.json`);
|
|
336
|
+
if (!this.exists(path))
|
|
337
|
+
return false;
|
|
338
|
+
this.real(path, "file");
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
// The merged ledger may still contain feature-worktree paths during recovery.
|
|
342
|
+
recordedFeature(spec) {
|
|
343
|
+
const absolute = isAbsolute(spec) ? resolve(spec) : resolve(this.app.workspaceRoot, spec);
|
|
344
|
+
const path = relative(this.app.workspaceRoot, absolute);
|
|
345
|
+
const match = /^\.scratch\/(\d{4,}-[a-z0-9]+(?:-[a-z0-9]+)*)\/spec\.md$/.exec(path);
|
|
346
|
+
if (!match || !this.hasStarted(match[1]))
|
|
347
|
+
return undefined;
|
|
348
|
+
this.real(absolute, "file");
|
|
349
|
+
const workspace = dirname(absolute);
|
|
350
|
+
this.real(join(workspace, "sdd.json"), "file");
|
|
351
|
+
return { feature: match[1], workspace, spec: absolute, ledger: join(workspace, "sdd.json") };
|
|
352
|
+
}
|
|
353
|
+
record(options) {
|
|
354
|
+
validateAuthor(options.author);
|
|
355
|
+
return this.locked(options.feature, (common, path) => {
|
|
356
|
+
const journal = this.read(path);
|
|
357
|
+
if (!journal)
|
|
358
|
+
throw new Error("No feature merge to validate.");
|
|
359
|
+
this.verifyJournal(journal, common, options.feature);
|
|
360
|
+
if (journal.phase !== "validation")
|
|
361
|
+
throw new Error("Final-check records are only accepted during final validation.");
|
|
362
|
+
if (!journal.finalDeclared)
|
|
363
|
+
throw new Error("Declare the complete final check list with checks require --scope final first.");
|
|
364
|
+
this.finalCheckout(journal);
|
|
365
|
+
if (options.author.trim() !== journal.author ||
|
|
366
|
+
options.merge !== journal.mergeCommit ||
|
|
367
|
+
options.snapshot !== journal.finalIdentity?.snapshot ||
|
|
368
|
+
!journal.required.some((item) => item.command === options.command)) {
|
|
369
|
+
throw new Error("Final check controller, declared command, snapshot or exact merge identity does not match.");
|
|
370
|
+
}
|
|
371
|
+
if (!["passed", "failed", "unavailable"].includes(options.result) ||
|
|
372
|
+
(options.result !== "passed" && !options.details?.trim()))
|
|
373
|
+
throw new Error("Invalid result or missing failure/unavailability details.");
|
|
374
|
+
const record = {
|
|
375
|
+
...journal.finalIdentity,
|
|
376
|
+
command: options.command,
|
|
377
|
+
result: options.result,
|
|
378
|
+
author: options.author.trim(),
|
|
379
|
+
recordedAt: new Date().toISOString(),
|
|
380
|
+
...(options.details ? { details: options.details.trim() } : {}),
|
|
381
|
+
};
|
|
382
|
+
journal.records.push(record);
|
|
383
|
+
this.persist(path, journal);
|
|
384
|
+
return { record, ...this.pending(journal, path), checksPassing: this.passing(journal) };
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
declare(options) {
|
|
388
|
+
validateAuthor(options.author);
|
|
389
|
+
const feature = this.recordedFeature(options.spec);
|
|
390
|
+
if (!feature)
|
|
391
|
+
throw new Error("Final checks require a recorded feature merge.");
|
|
392
|
+
return this.locked(feature.feature, (common, path) => {
|
|
393
|
+
const journal = this.read(path);
|
|
394
|
+
if (!journal)
|
|
395
|
+
throw new Error("No finalization journal.");
|
|
396
|
+
this.verifyJournal(journal, common, feature.feature);
|
|
397
|
+
if (journal.phase !== "validation")
|
|
398
|
+
throw new Error("Final checks may only be declared after the feature merge.");
|
|
399
|
+
this.finalCheckout(journal);
|
|
400
|
+
const commands = options.command.map((command) => command.trim());
|
|
401
|
+
if (options.author.trim() !== journal.author ||
|
|
402
|
+
!commands.length ||
|
|
403
|
+
commands.some((command) => !command) ||
|
|
404
|
+
new Set(commands).size !== commands.length)
|
|
405
|
+
throw new Error("A nonempty unique complete command list from the recorded controller is required.");
|
|
406
|
+
if (journal.finalDeclared) {
|
|
407
|
+
if (JSON.stringify(commands) !==
|
|
408
|
+
JSON.stringify(journal.required.map((requirement) => requirement.command))) {
|
|
409
|
+
throw new Error("The final declared check list is frozen.");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
journal.required = commands.map((command) => ({
|
|
414
|
+
command,
|
|
415
|
+
addedAt: new Date().toISOString(),
|
|
416
|
+
addedBy: journal.author,
|
|
417
|
+
}));
|
|
418
|
+
journal.finalDeclared = true;
|
|
419
|
+
this.persist(path, journal);
|
|
420
|
+
}
|
|
421
|
+
return this.pending(journal, path);
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
ready(journal, gate) {
|
|
425
|
+
const result = gate(structuredClone(journal.approvedLedger));
|
|
426
|
+
if (result.identity.snapshot !== journal.approved.snapshot ||
|
|
427
|
+
result.identity.codeSnapshot !== journal.approved.codeSnapshot ||
|
|
428
|
+
result.identity.requirementsSnapshot !== journal.approved.requirementsSnapshot) {
|
|
429
|
+
throw new Error("Aggregate approval became stale during finalization.");
|
|
430
|
+
}
|
|
431
|
+
if (result.retrospective?.reportSha256 !== journal.retrospective?.reportSha256 ||
|
|
432
|
+
(result.retrospective &&
|
|
433
|
+
!journal.mappings.some((mapping) => mapping.before === result.retrospective.report &&
|
|
434
|
+
mapping.after === journal.retrospective?.report))) {
|
|
435
|
+
throw new Error("Retrospective evidence changed during finalization.");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
relocate(input, startup, targets) {
|
|
439
|
+
const ledger = structuredClone(input);
|
|
440
|
+
const mappings = [];
|
|
441
|
+
const sourceRecords = join(startup.featureWorkspace, `.scratch/${startup.feature}`);
|
|
442
|
+
const destinationRecords = join(startup.originalWorkspace, `.scratch/${startup.feature}`);
|
|
443
|
+
// Historical worktree locations remain in the journal after cleanup.
|
|
444
|
+
for (const ticket of ledger.tickets)
|
|
445
|
+
delete ticket.worktree;
|
|
446
|
+
const visit = (value) => {
|
|
447
|
+
if (!value || typeof value !== "object")
|
|
448
|
+
return;
|
|
449
|
+
if (Array.isArray(value)) {
|
|
450
|
+
value.forEach(visit);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const object = value;
|
|
454
|
+
for (const [key, item] of Object.entries(object)) {
|
|
455
|
+
if (key === "workspace" &&
|
|
456
|
+
typeof item === "string" &&
|
|
457
|
+
targets.some((target) => target.workspace === item)) {
|
|
458
|
+
mappings.push({ before: item, after: "journal:execution-location" });
|
|
459
|
+
delete object[key];
|
|
460
|
+
delete object.branch;
|
|
461
|
+
}
|
|
462
|
+
else if (typeof item === "string" &&
|
|
463
|
+
targets.some((target) => this.inside(target.workspace, item))) {
|
|
464
|
+
if (!isAbsolute(item) ||
|
|
465
|
+
!this.inside(join(sourceRecords, "reports"), item) ||
|
|
466
|
+
!/^[^/]+\.md$/.test(relative(join(sourceRecords, "reports"), item))) {
|
|
467
|
+
throw new Error(`Cannot safely relocate recorded path: ${item}.`);
|
|
468
|
+
}
|
|
469
|
+
this.real(item, "file");
|
|
470
|
+
const digest = this.digest(readFileSync(item));
|
|
471
|
+
if (key === "report" &&
|
|
472
|
+
typeof object.reportSha256 === "string" &&
|
|
473
|
+
object.reportSha256 !== digest) {
|
|
474
|
+
throw new Error("Report digest changed before relocation.");
|
|
475
|
+
}
|
|
476
|
+
const destination = join(destinationRecords, relative(sourceRecords, item));
|
|
477
|
+
mappings.push({ before: item, after: destination, sha256: digest });
|
|
478
|
+
object[key] = destination;
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
if (typeof item === "string" &&
|
|
482
|
+
targets.some((target) => item.includes(target.workspace))) {
|
|
483
|
+
throw new Error("Embedded workflow path cannot be silently rewritten; resolve it before finishing.");
|
|
484
|
+
}
|
|
485
|
+
visit(item);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
visit(ledger);
|
|
490
|
+
return { ledger, mappings };
|
|
491
|
+
}
|
|
492
|
+
passing(journal) {
|
|
493
|
+
return (journal.finalDeclared &&
|
|
494
|
+
journal.required.length > 0 &&
|
|
495
|
+
journal.required.every((requirement) => {
|
|
496
|
+
const record = journal.records
|
|
497
|
+
.filter((item) => item.command === requirement.command)
|
|
498
|
+
.at(-1);
|
|
499
|
+
return (record?.result === "passed" &&
|
|
500
|
+
record.headSha === journal.mergeCommit &&
|
|
501
|
+
record.snapshot === journal.finalIdentity?.snapshot);
|
|
502
|
+
}));
|
|
503
|
+
}
|
|
504
|
+
pending(journal, path) {
|
|
505
|
+
return {
|
|
506
|
+
scope: "delivery",
|
|
507
|
+
feature: journal.startup.feature,
|
|
508
|
+
phase: journal.phase,
|
|
509
|
+
finalValidation: "pending",
|
|
510
|
+
cleanup: "retained",
|
|
511
|
+
mergeCommit: journal.mergeCommit,
|
|
512
|
+
originalWorkspace: journal.startup.originalWorkspace,
|
|
513
|
+
...journal.finalIdentity,
|
|
514
|
+
originalSpec: join(journal.startup.originalWorkspace, `.scratch/${journal.startup.feature}/spec.md`),
|
|
515
|
+
required: journal.required,
|
|
516
|
+
records: journal.records,
|
|
517
|
+
journal: path,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
finalCheckout(journal) {
|
|
521
|
+
const state = journal.startup;
|
|
522
|
+
this.workspace(state.originalWorkspace, state.originalBranch, journal.metadataCommit ?? journal.mergeCommit, journal.common);
|
|
523
|
+
this.clean(state.originalWorkspace, false);
|
|
524
|
+
this.noOperation(state.originalWorkspace);
|
|
525
|
+
this.commitShape(state.originalWorkspace, journal.mergeCommit, [state.featureRevision, journal.featureCommit], this.tree(state.originalWorkspace, journal.featureCommit));
|
|
526
|
+
if (journal.metadataCommit)
|
|
527
|
+
this.metadataShape(journal, journal.metadataCommit);
|
|
528
|
+
this.verifyFiles(journal, state.originalWorkspace);
|
|
529
|
+
for (const mapping of journal.mappings) {
|
|
530
|
+
if (!mapping.sha256)
|
|
531
|
+
continue;
|
|
532
|
+
this.real(mapping.after, "file");
|
|
533
|
+
if (this.digest(readFileSync(mapping.after)) !== mapping.sha256)
|
|
534
|
+
throw new Error("Relocated report verification failed.");
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
metadata(journal, path) {
|
|
538
|
+
if (!this.passing(journal))
|
|
539
|
+
throw new Error("Declared final-merge checks must pass before metadata relocation.");
|
|
540
|
+
const state = journal.startup;
|
|
541
|
+
const root = state.originalWorkspace;
|
|
542
|
+
if (this.app.workspaceRoot !== root)
|
|
543
|
+
throw new Error("Complete metadata relocation and cleanup from the recorded original checkout.");
|
|
544
|
+
this.noHooks(root);
|
|
545
|
+
this.noOperation(root);
|
|
546
|
+
this.recordsOnly(root, state.feature, false);
|
|
547
|
+
this.preflight(journal);
|
|
548
|
+
if (journal.phase === "validation") {
|
|
549
|
+
const ledger = JSON.parse(journal.ledgerAfter);
|
|
550
|
+
const at = new Date().toISOString();
|
|
551
|
+
ledger.delivery.status = "complete";
|
|
552
|
+
ledger.delivery.execution = "complete";
|
|
553
|
+
ledger.delivery.finished = { at, author: journal.author, ...journal.retrospective };
|
|
554
|
+
ledger.delivery.history.push({
|
|
555
|
+
at,
|
|
556
|
+
author: journal.author,
|
|
557
|
+
action: `feature-final-validation-passed:${journal.mergeCommit};journal:${journal.id}`,
|
|
558
|
+
});
|
|
559
|
+
journal.ledgerAfter = `${JSON.stringify(ledger, null, 2)}\n`;
|
|
560
|
+
}
|
|
561
|
+
this.ledgers.validateLedger(JSON.parse(journal.ledgerAfter), state.feature);
|
|
562
|
+
const ledgerPath = `.scratch/${state.feature}/sdd.json`;
|
|
563
|
+
const absoluteLedger = join(root, ledgerPath);
|
|
564
|
+
if (journal.phase === "validation") {
|
|
565
|
+
this.finalCheckout(journal);
|
|
566
|
+
journal.phase = "metadata";
|
|
567
|
+
this.persist(path, journal);
|
|
568
|
+
}
|
|
569
|
+
this.workspace(root, state.originalBranch, undefined, journal.common);
|
|
570
|
+
const head = this.head(root);
|
|
571
|
+
if (head !== journal.mergeCommit) {
|
|
572
|
+
if (!journal.metadataTree)
|
|
573
|
+
throw new Error("Original branch moved outside the journalled metadata commit.");
|
|
574
|
+
this.metadataShape(journal, head);
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
const present = readFileSync(absoluteLedger, "utf8");
|
|
578
|
+
if (present !== journal.ledgerBefore && present !== journal.ledgerAfter) {
|
|
579
|
+
throw new Error("Original ledger changed during relocation; preserve it.");
|
|
580
|
+
}
|
|
581
|
+
for (const entry of this.statusBytes(root, false).split("\0").filter(Boolean)) {
|
|
582
|
+
if (entry.length < 4 ||
|
|
583
|
+
entry[2] !== " " ||
|
|
584
|
+
/[RCU!]/.test(entry.slice(0, 2)) ||
|
|
585
|
+
entry.slice(3) !== ledgerPath)
|
|
586
|
+
throw new Error("Preserve user work during metadata recovery.");
|
|
587
|
+
}
|
|
588
|
+
for (const file of journal.files) {
|
|
589
|
+
if (file.path === ledgerPath)
|
|
590
|
+
continue;
|
|
591
|
+
this.real(join(root, file.path), "file");
|
|
592
|
+
if (this.digest(readFileSync(join(root, file.path))) !== file.sha256)
|
|
593
|
+
throw new Error("Records changed during metadata recovery.");
|
|
594
|
+
}
|
|
595
|
+
if (present === journal.ledgerBefore)
|
|
596
|
+
this.atomicBytes(absoluteLedger, journal.ledgerAfter);
|
|
597
|
+
if (!journal.metadataTree) {
|
|
598
|
+
this.run(root, ["add", "--", `:(literal)${ledgerPath}`]);
|
|
599
|
+
journal.metadataTree = this.run(root, ["write-tree"]);
|
|
600
|
+
this.persist(path, journal);
|
|
601
|
+
}
|
|
602
|
+
this.workspace(root, state.originalBranch, journal.mergeCommit, journal.common);
|
|
603
|
+
if (this.run(root, ["write-tree"]) !== journal.metadataTree ||
|
|
604
|
+
readFileSync(absoluteLedger, "utf8") !== journal.ledgerAfter ||
|
|
605
|
+
this.run(root, ["diff", "--name-only", "-z"]))
|
|
606
|
+
throw new Error("Metadata index/ledger changed; preserve it.");
|
|
607
|
+
const staged = this.run(root, ["diff", "--cached", "--name-only", "--no-renames", "-z"])
|
|
608
|
+
.split("\0")
|
|
609
|
+
.filter(Boolean);
|
|
610
|
+
if (JSON.stringify(staged) !== JSON.stringify([ledgerPath]))
|
|
611
|
+
throw new Error("Only the canonical ledger may enter the metadata commit.");
|
|
612
|
+
this.run(root, ["commit", "-m", `spindle: relocate ${state.feature} workflow provenance`]);
|
|
613
|
+
}
|
|
614
|
+
journal.metadataCommit = this.head(root);
|
|
615
|
+
this.metadataShape(journal, journal.metadataCommit);
|
|
616
|
+
this.clean(root, false);
|
|
617
|
+
this.ledgers.readLedger({
|
|
618
|
+
feature: state.feature,
|
|
619
|
+
workspace: dirname(absoluteLedger),
|
|
620
|
+
ledger: absoluteLedger,
|
|
621
|
+
spec: join(dirname(absoluteLedger), "spec.md"),
|
|
622
|
+
});
|
|
623
|
+
this.persist(path, journal);
|
|
624
|
+
}
|
|
625
|
+
metadataShape(journal, commit) {
|
|
626
|
+
const root = journal.startup.originalWorkspace;
|
|
627
|
+
this.commitShape(root, commit, [journal.mergeCommit], journal.metadataTree);
|
|
628
|
+
const changed = this.run(root, [
|
|
629
|
+
"diff",
|
|
630
|
+
"--name-only",
|
|
631
|
+
"--no-renames",
|
|
632
|
+
"-z",
|
|
633
|
+
journal.mergeCommit,
|
|
634
|
+
commit,
|
|
635
|
+
])
|
|
636
|
+
.split("\0")
|
|
637
|
+
.filter(Boolean);
|
|
638
|
+
if (JSON.stringify(changed) !== JSON.stringify([`.scratch/${journal.startup.feature}/sdd.json`])) {
|
|
639
|
+
throw new Error("Metadata follow-up changed code or non-ledger records; it is not the checked merge.");
|
|
640
|
+
}
|
|
641
|
+
const actual = this.run(root, [
|
|
642
|
+
"show",
|
|
643
|
+
`${commit}:.scratch/${journal.startup.feature}/sdd.json`,
|
|
644
|
+
]);
|
|
645
|
+
if (actual !== journal.ledgerAfter.trim())
|
|
646
|
+
throw new Error("Metadata commit differs from the validated relocation plan.");
|
|
647
|
+
}
|
|
648
|
+
cleanup(journal, path) {
|
|
649
|
+
if (!this.passing(journal))
|
|
650
|
+
throw new Error("Exact merge-bound declared checks have not all passed.");
|
|
651
|
+
this.preflight(journal);
|
|
652
|
+
if (journal.phase === "metadata") {
|
|
653
|
+
journal.phase = "cleanup";
|
|
654
|
+
this.persist(path, journal);
|
|
655
|
+
}
|
|
656
|
+
for (const target of journal.targets) {
|
|
657
|
+
this.finalCheckout(journal);
|
|
658
|
+
this.preflight(journal);
|
|
659
|
+
if (!target.step) {
|
|
660
|
+
target.step = "remove-intent";
|
|
661
|
+
this.persist(path, journal);
|
|
662
|
+
}
|
|
663
|
+
if (target.step === "remove-intent") {
|
|
664
|
+
if (this.exists(target.workspace))
|
|
665
|
+
this.run(journal.startup.originalWorkspace, [
|
|
666
|
+
"worktree",
|
|
667
|
+
"remove",
|
|
668
|
+
"--",
|
|
669
|
+
target.workspace,
|
|
670
|
+
]);
|
|
671
|
+
target.step = "removed";
|
|
672
|
+
this.persist(path, journal);
|
|
673
|
+
}
|
|
674
|
+
if (target.step === "removed") {
|
|
675
|
+
target.step = "delete-intent";
|
|
676
|
+
this.persist(path, journal);
|
|
677
|
+
}
|
|
678
|
+
if (target.step === "delete-intent") {
|
|
679
|
+
this.preflight(journal);
|
|
680
|
+
if (this.branch(journal.startup.originalWorkspace, target.branch)) {
|
|
681
|
+
this.run(journal.startup.originalWorkspace, [
|
|
682
|
+
"update-ref",
|
|
683
|
+
"-d",
|
|
684
|
+
`refs/heads/${target.branch}`,
|
|
685
|
+
target.revision,
|
|
686
|
+
]);
|
|
687
|
+
}
|
|
688
|
+
target.step = "deleted";
|
|
689
|
+
this.persist(path, journal);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
this.preflight(journal);
|
|
693
|
+
journal.phase = "complete";
|
|
694
|
+
this.persist(path, journal);
|
|
695
|
+
}
|
|
696
|
+
preflight(journal) {
|
|
697
|
+
const state = journal.startup;
|
|
698
|
+
this.inventory(state, journal.targets);
|
|
699
|
+
this.noHooks(state.originalWorkspace);
|
|
700
|
+
const registrations = this.registrations(state.originalWorkspace);
|
|
701
|
+
for (const target of journal.targets) {
|
|
702
|
+
this.real(target.allowedParent, "directory");
|
|
703
|
+
const registration = registrations.filter((item) => item.workspace === target.workspace);
|
|
704
|
+
const present = this.exists(target.workspace);
|
|
705
|
+
if (target.step && target.step !== "remove-intent" && (present || registration.length))
|
|
706
|
+
throw new Error("Removed worktree was substituted or re-registered.");
|
|
707
|
+
if (!present || !registration.length) {
|
|
708
|
+
if (!target.step || present || registration.length)
|
|
709
|
+
throw new Error("Worktree disappeared outside recorded cleanup.");
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
this.location(target.workspace, state, journal.common);
|
|
713
|
+
this.targetIdentity(target);
|
|
714
|
+
this.workspace(target.workspace, target.branch, target.revision, journal.common);
|
|
715
|
+
this.clean(target.workspace);
|
|
716
|
+
this.noOperation(target.workspace);
|
|
717
|
+
this.noHooks(target.workspace);
|
|
718
|
+
}
|
|
719
|
+
if (registrations.some((item) => item.branch === `refs/heads/${target.branch}` && item.workspace !== target.workspace))
|
|
720
|
+
throw new Error("Cleanup branch acquired another worktree owner.");
|
|
721
|
+
const revision = this.branch(state.originalWorkspace, target.branch);
|
|
722
|
+
if (revision) {
|
|
723
|
+
if (target.step === "deleted" || revision !== target.revision)
|
|
724
|
+
throw new Error("Cleanup branch moved or reappeared.");
|
|
725
|
+
this.ancestor(state.originalWorkspace, revision, journal.mergeCommit);
|
|
726
|
+
}
|
|
727
|
+
else if (!target.step || !["delete-intent", "deleted"].includes(target.step))
|
|
728
|
+
throw new Error("Branch disappeared outside recorded cleanup.");
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
inventory(state, targets) {
|
|
732
|
+
const owned = (branch) => branch === state.featureBranch || branch.startsWith(`tickets/${state.feature}/`);
|
|
733
|
+
const branches = this.run(state.originalWorkspace, [
|
|
734
|
+
"for-each-ref",
|
|
735
|
+
"--format=%(refname:short)",
|
|
736
|
+
"refs/heads/",
|
|
737
|
+
])
|
|
738
|
+
.split("\n")
|
|
739
|
+
.filter(Boolean);
|
|
740
|
+
if (branches.some((branch) => owned(branch) && !targets.some((target) => target.branch === branch)) ||
|
|
741
|
+
this.registrations(state.originalWorkspace).some((item) => owned(item.branch.replace(/^refs\/heads\//, "")) &&
|
|
742
|
+
!targets.some((target) => target.workspace === item.workspace && `refs/heads/${target.branch}` === item.branch))) {
|
|
743
|
+
throw new Error("Unrecorded feature/ticket worktree or branch; cleanup inventory is incomplete.");
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
recordFiles(root, feature) {
|
|
747
|
+
const prefix = `:(literal).scratch/${feature}`;
|
|
748
|
+
return [
|
|
749
|
+
...new Set([
|
|
750
|
+
...this.run(root, ["ls-files", "-z", "--", prefix]).split("\0"),
|
|
751
|
+
...this.run(root, [
|
|
752
|
+
"ls-files",
|
|
753
|
+
"--others",
|
|
754
|
+
"--exclude-standard",
|
|
755
|
+
"-z",
|
|
756
|
+
"--",
|
|
757
|
+
prefix,
|
|
758
|
+
]).split("\0"),
|
|
759
|
+
].filter((file) => file && this.exists(join(root, file)))),
|
|
760
|
+
].sort();
|
|
761
|
+
}
|
|
762
|
+
verifyFiles(journal, root) {
|
|
763
|
+
const files = this.recordFiles(root, journal.startup.feature);
|
|
764
|
+
if (JSON.stringify(files) !== JSON.stringify(journal.files.map((file) => file.path)))
|
|
765
|
+
throw new Error("Canonical record inventory changed.");
|
|
766
|
+
for (const file of journal.files) {
|
|
767
|
+
this.real(join(root, file.path), "file");
|
|
768
|
+
const expected = journal.metadataCommit &&
|
|
769
|
+
root === journal.startup.originalWorkspace &&
|
|
770
|
+
file.path.endsWith("/sdd.json")
|
|
771
|
+
? this.digest(journal.ledgerAfter)
|
|
772
|
+
: file.sha256;
|
|
773
|
+
if (this.digest(readFileSync(join(root, file.path))) !== expected)
|
|
774
|
+
throw new Error("Canonical records changed after approval.");
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
canonical(file, feature) {
|
|
778
|
+
const prefix = `.scratch/${feature}/`;
|
|
779
|
+
return (file.startsWith(prefix) &&
|
|
780
|
+
/^(?:spec\.md|design\.md|sdd\.json|tickets\/T-\d{4,}\.md|reports\/[^/]+\.md)$/.test(file.slice(prefix.length)));
|
|
781
|
+
}
|
|
782
|
+
recordsOnly(root, feature, rejectIgnored = true) {
|
|
783
|
+
for (const entry of this.statusBytes(root, rejectIgnored).split("\0").filter(Boolean)) {
|
|
784
|
+
if (entry.length < 4 ||
|
|
785
|
+
entry[2] !== " " ||
|
|
786
|
+
/[RCU!]/.test(entry.slice(0, 2)) ||
|
|
787
|
+
!this.canonical(entry.slice(3), feature))
|
|
788
|
+
throw new Error("Preserve unrelated, ignored, renamed or conflicted files; only canonical records may be dirty.");
|
|
789
|
+
if (this.exists(join(root, entry.slice(3))))
|
|
790
|
+
this.real(join(root, entry.slice(3)), "file");
|
|
791
|
+
}
|
|
792
|
+
this.real(join(root, `.scratch/${feature}/sdd.json`), "file");
|
|
793
|
+
this.real(join(root, `.scratch/${feature}/spec.md`), "file");
|
|
794
|
+
const files = this.run(root, ["ls-files", "-z"]).split("\0").filter(Boolean);
|
|
795
|
+
if (files.length) {
|
|
796
|
+
const attributes = this.run(root, [
|
|
797
|
+
"check-attr",
|
|
798
|
+
"-z",
|
|
799
|
+
"filter",
|
|
800
|
+
"merge",
|
|
801
|
+
"--",
|
|
802
|
+
...files,
|
|
803
|
+
]).split("\0");
|
|
804
|
+
for (let index = 0; index + 2 < attributes.length; index += 3) {
|
|
805
|
+
const attribute = attributes[index + 1];
|
|
806
|
+
const value = attributes[index + 2];
|
|
807
|
+
if ((attribute === "filter" && value !== "unspecified" && value !== "unset") ||
|
|
808
|
+
(attribute === "merge" &&
|
|
809
|
+
!["unspecified", "unset", "set", "text", "binary", "union"].includes(value))) {
|
|
810
|
+
throw new Error("Configured record/code filters or custom merge drivers require manual handling.");
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
validateStartup(state, feature) {
|
|
816
|
+
if (state.version !== 1 ||
|
|
817
|
+
state.phase !== "created" ||
|
|
818
|
+
state.feature !== feature ||
|
|
819
|
+
!state.id ||
|
|
820
|
+
state.featureBranch !== `features/${feature}` ||
|
|
821
|
+
!/^[0-9a-f]{40,64}$/.test(state.featureRevision) ||
|
|
822
|
+
!/^[0-9a-f]{40,64}$/.test(state.originalRevision) ||
|
|
823
|
+
!state.originalBranch ||
|
|
824
|
+
state.originalBranch === state.featureBranch ||
|
|
825
|
+
!state.records ||
|
|
826
|
+
!Array.isArray(state.records.paths) ||
|
|
827
|
+
!/^[0-9a-f]{64}$/.test(state.records.digest))
|
|
828
|
+
throw new Error("Incomplete or invalid startup provenance.");
|
|
829
|
+
}
|
|
830
|
+
verifyJournal(journal, common, feature) {
|
|
831
|
+
if (journal.version !== 1 || journal.common !== common || !journal.id)
|
|
832
|
+
throw new Error("Invalid finalization journal.");
|
|
833
|
+
this.validateStartup(journal.startup, feature);
|
|
834
|
+
if (![journal.startup.originalWorkspace, journal.startup.featureWorkspace].includes(this.app.workspaceRoot))
|
|
835
|
+
throw new Error("Finalization must run from a recorded feature/original checkout.");
|
|
836
|
+
const stat = lstatSync(common);
|
|
837
|
+
this.real(journal.startup.originalWorkspace, "directory");
|
|
838
|
+
const original = lstatSync(journal.startup.originalWorkspace);
|
|
839
|
+
if (stat.dev !== journal.repository.dev ||
|
|
840
|
+
stat.ino !== journal.repository.ino ||
|
|
841
|
+
original.dev !== journal.original.dev ||
|
|
842
|
+
original.ino !== journal.original.ino)
|
|
843
|
+
throw new Error("Repository/original checkout identity changed.");
|
|
844
|
+
const startup = join(common, "spindle-feature-start", `${feature}.json`);
|
|
845
|
+
this.real(startup, "file");
|
|
846
|
+
if (this.digest(readFileSync(startup)) !== journal.startupDigest)
|
|
847
|
+
throw new Error("Startup provenance changed.");
|
|
848
|
+
for (const ticket of journal.ticketStarts) {
|
|
849
|
+
this.real(ticket.path, "file");
|
|
850
|
+
if (this.digest(readFileSync(ticket.path)) !== ticket.digest)
|
|
851
|
+
throw new Error("Ticket startup ownership provenance changed.");
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
targetIdentity(target) {
|
|
855
|
+
this.real(target.workspace, "directory");
|
|
856
|
+
const stat = lstatSync(target.workspace);
|
|
857
|
+
if (stat.dev !== target.dev || stat.ino !== target.ino)
|
|
858
|
+
throw new Error("Worktree path substitution.");
|
|
859
|
+
}
|
|
860
|
+
location(path, state, common) {
|
|
861
|
+
this.real(path, "directory");
|
|
862
|
+
if (this.inside(state.originalWorkspace, path) ||
|
|
863
|
+
this.inside(path, state.originalWorkspace) ||
|
|
864
|
+
this.inside(common, path) ||
|
|
865
|
+
this.inside(path, common))
|
|
866
|
+
throw new Error("Worktree path is outside safe allowed locations.");
|
|
867
|
+
}
|
|
868
|
+
workspace(root, branch, revision, common) {
|
|
869
|
+
this.real(root, "directory");
|
|
870
|
+
const marker = join(root, ".git");
|
|
871
|
+
this.real(marker, lstatSync(marker).isDirectory() ? "directory" : "file");
|
|
872
|
+
const admin = this.run(root, ["rev-parse", "--absolute-git-dir"]);
|
|
873
|
+
this.real(admin, "directory");
|
|
874
|
+
if (admin !== common) {
|
|
875
|
+
const backlink = join(admin, "gitdir");
|
|
876
|
+
this.real(backlink, "file");
|
|
877
|
+
if (readFileSync(backlink, "utf8").trim() !== marker ||
|
|
878
|
+
readFileSync(marker, "utf8").trim() !== `gitdir: ${admin}`)
|
|
879
|
+
throw new Error("Git worktree administration substitution.");
|
|
880
|
+
}
|
|
881
|
+
if (this.run(root, ["rev-parse", "--show-toplevel"]) !== root ||
|
|
882
|
+
realpathSync(this.run(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"])) !==
|
|
883
|
+
common ||
|
|
884
|
+
this.run(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) !== branch ||
|
|
885
|
+
(revision && this.head(root) !== revision))
|
|
886
|
+
throw new Error("Original/worktree attached branch, HEAD or repository changed.");
|
|
887
|
+
const registrations = this.registrations(root).filter((item) => item.workspace === root);
|
|
888
|
+
if (registrations.length !== 1 ||
|
|
889
|
+
registrations[0].branch !== `refs/heads/${branch}` ||
|
|
890
|
+
registrations[0].head !== this.head(root) ||
|
|
891
|
+
registrations[0].unsafe)
|
|
892
|
+
throw new Error("Exact worktree registration verification failed.");
|
|
893
|
+
}
|
|
894
|
+
registrations(root) {
|
|
895
|
+
return this.run(root, ["worktree", "list", "--porcelain", "-z"])
|
|
896
|
+
.split("\0\0")
|
|
897
|
+
.filter(Boolean)
|
|
898
|
+
.map((block) => {
|
|
899
|
+
const fields = block.split("\0");
|
|
900
|
+
const get = (key) => fields.find((item) => item.startsWith(`${key} `))?.slice(key.length + 1) ?? "";
|
|
901
|
+
return {
|
|
902
|
+
workspace: get("worktree"),
|
|
903
|
+
branch: get("branch"),
|
|
904
|
+
head: get("HEAD"),
|
|
905
|
+
unsafe: fields.some((item) => /^(?:locked|prunable)(?: |$)/.test(item)),
|
|
906
|
+
};
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
assertNoIgnoredCollisions(root, featureCommit) {
|
|
910
|
+
const ignored = this.run(root, [
|
|
911
|
+
"ls-files",
|
|
912
|
+
"--others",
|
|
913
|
+
"--ignored",
|
|
914
|
+
"--exclude-standard",
|
|
915
|
+
"-z",
|
|
916
|
+
])
|
|
917
|
+
.split("\0")
|
|
918
|
+
.filter(Boolean)
|
|
919
|
+
.map((path) => path.replace(/\/$/, ""));
|
|
920
|
+
const incoming = this.run(root, ["ls-tree", "-r", "--name-only", "-z", featureCommit])
|
|
921
|
+
.split("\0")
|
|
922
|
+
.filter(Boolean);
|
|
923
|
+
const incomingPaths = new Set(incoming);
|
|
924
|
+
const sortedIncoming = [...incoming].sort();
|
|
925
|
+
const collisions = ignored.filter((path) => {
|
|
926
|
+
const components = path.split("/");
|
|
927
|
+
for (let count = 1; count <= components.length; count++) {
|
|
928
|
+
if (incomingPaths.has(components.slice(0, count).join("/")))
|
|
929
|
+
return true;
|
|
930
|
+
}
|
|
931
|
+
const prefix = `${path}/`;
|
|
932
|
+
let low = 0;
|
|
933
|
+
let high = sortedIncoming.length;
|
|
934
|
+
while (low < high) {
|
|
935
|
+
const middle = Math.floor((low + high) / 2);
|
|
936
|
+
if (sortedIncoming[middle] < prefix)
|
|
937
|
+
low = middle + 1;
|
|
938
|
+
else
|
|
939
|
+
high = middle;
|
|
940
|
+
}
|
|
941
|
+
return sortedIncoming[low]?.startsWith(prefix) ?? false;
|
|
942
|
+
});
|
|
943
|
+
// ls-files omits empty ignored directories. Preserve those too when the
|
|
944
|
+
// incoming tree would replace the directory itself with a tracked file.
|
|
945
|
+
if (!collisions.length) {
|
|
946
|
+
for (const target of incoming) {
|
|
947
|
+
const absolute = join(root, target);
|
|
948
|
+
if (!this.exists(absolute) || !lstatSync(absolute).isDirectory())
|
|
949
|
+
continue;
|
|
950
|
+
const result = this.git.run(["check-ignore", "--quiet", "--", target], {
|
|
951
|
+
cwd: root,
|
|
952
|
+
encoding: "utf8",
|
|
953
|
+
});
|
|
954
|
+
if (result.error)
|
|
955
|
+
throw result.error;
|
|
956
|
+
if (result.status === 0)
|
|
957
|
+
collisions.push(target);
|
|
958
|
+
else if (result.status !== 1)
|
|
959
|
+
throw new Error("Cannot verify ignored directory collision safety.");
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (collisions.length) {
|
|
963
|
+
throw new Error(`Ignored original paths would be overwritten by merge; preserve and relocate them manually: ${collisions.join(", ")}.`);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
mergeIndex(root, featureCommit) {
|
|
967
|
+
this.assertNoIgnoredCollisions(root, featureCommit);
|
|
968
|
+
if (this.run(root, ["ls-files", "--unmerged", "-z"]) ||
|
|
969
|
+
this.run(root, ["diff", "--name-only", "-z"]) ||
|
|
970
|
+
this.run(root, ["ls-files", "--others", "--exclude-standard", "-z"]) ||
|
|
971
|
+
this.run(root, ["write-tree"]) !== this.tree(root, featureCommit))
|
|
972
|
+
throw new Error("Preserve conflicts/user merge edits; index differs from the recorded feature tree.");
|
|
973
|
+
}
|
|
974
|
+
commitShape(root, commit, parents, tree) {
|
|
975
|
+
if (JSON.stringify(this.run(root, ["rev-list", "--parents", "-n", "1", commit]).split(" ")) !==
|
|
976
|
+
JSON.stringify([commit, ...parents]) ||
|
|
977
|
+
this.tree(root, commit) !== tree)
|
|
978
|
+
throw new Error("Exact merge/record commit ancestry or tree verification failed.");
|
|
979
|
+
}
|
|
980
|
+
branch(root, name) {
|
|
981
|
+
this.run(root, ["check-ref-format", `refs/heads/${name}`]);
|
|
982
|
+
const result = this.git.run(["show-ref", "--verify", "--hash", `refs/heads/${name}`], {
|
|
983
|
+
cwd: root,
|
|
984
|
+
encoding: "utf8",
|
|
985
|
+
});
|
|
986
|
+
if (result.error)
|
|
987
|
+
throw result.error;
|
|
988
|
+
if (result.status === 0)
|
|
989
|
+
return result.stdout.trim();
|
|
990
|
+
// show-ref --verify returns 128 for a missing ref; use quiet for exact absence.
|
|
991
|
+
const absent = this.git.run(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], {
|
|
992
|
+
cwd: root,
|
|
993
|
+
encoding: "utf8",
|
|
994
|
+
});
|
|
995
|
+
if (absent.error)
|
|
996
|
+
throw absent.error;
|
|
997
|
+
if (absent.status !== 1)
|
|
998
|
+
throw new Error("Unable to verify branch absence.");
|
|
999
|
+
return undefined;
|
|
1000
|
+
}
|
|
1001
|
+
noOperation(root, allowMerge = false) {
|
|
1002
|
+
for (const name of [
|
|
1003
|
+
"MERGE_HEAD",
|
|
1004
|
+
"CHERRY_PICK_HEAD",
|
|
1005
|
+
"REVERT_HEAD",
|
|
1006
|
+
"REBASE_HEAD",
|
|
1007
|
+
"rebase-merge",
|
|
1008
|
+
"rebase-apply",
|
|
1009
|
+
"sequencer",
|
|
1010
|
+
]) {
|
|
1011
|
+
if (!(allowMerge && name === "MERGE_HEAD") && this.exists(this.gitPath(root, name)))
|
|
1012
|
+
throw new Error(`Preserve existing Git operation ${name}.`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
noHooks(root) {
|
|
1016
|
+
for (const hook of [
|
|
1017
|
+
"pre-commit",
|
|
1018
|
+
"prepare-commit-msg",
|
|
1019
|
+
"commit-msg",
|
|
1020
|
+
"post-commit",
|
|
1021
|
+
"pre-merge-commit",
|
|
1022
|
+
"post-merge",
|
|
1023
|
+
"post-checkout",
|
|
1024
|
+
"reference-transaction",
|
|
1025
|
+
]) {
|
|
1026
|
+
const path = this.gitPath(root, `hooks/${hook}`);
|
|
1027
|
+
try {
|
|
1028
|
+
accessSync(path, constants.X_OK);
|
|
1029
|
+
}
|
|
1030
|
+
catch (error) {
|
|
1031
|
+
if (["ENOENT", "EACCES"].includes(error.code ?? ""))
|
|
1032
|
+
continue;
|
|
1033
|
+
throw error;
|
|
1034
|
+
}
|
|
1035
|
+
throw new Error(`Active Git hook requires manual handling: ${path}.`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
statusBytes(root, includeIgnored = true) {
|
|
1039
|
+
return this.run(root, [
|
|
1040
|
+
"status",
|
|
1041
|
+
"--porcelain=v1",
|
|
1042
|
+
"-z",
|
|
1043
|
+
"--untracked-files=all",
|
|
1044
|
+
includeIgnored ? "--ignored=matching" : "--ignored=no",
|
|
1045
|
+
"--ignore-submodules=none",
|
|
1046
|
+
]);
|
|
1047
|
+
}
|
|
1048
|
+
clean(root, rejectIgnored = true) {
|
|
1049
|
+
if (this.statusBytes(root, rejectIgnored))
|
|
1050
|
+
throw new Error(`Preserve dirty checkout${rejectIgnored ? " (including ignored files)" : ""}: ${root}.`);
|
|
1051
|
+
}
|
|
1052
|
+
head(root) {
|
|
1053
|
+
return this.run(root, ["rev-parse", "--verify", "HEAD"]);
|
|
1054
|
+
}
|
|
1055
|
+
tree(root, commit) {
|
|
1056
|
+
return this.run(root, ["rev-parse", "--verify", `${commit}^{tree}`]);
|
|
1057
|
+
}
|
|
1058
|
+
ancestor(root, before, after) {
|
|
1059
|
+
this.run(root, ["merge-base", "--is-ancestor", before, after]);
|
|
1060
|
+
}
|
|
1061
|
+
gitPath(root, name) {
|
|
1062
|
+
return resolve(root, this.run(root, ["rev-parse", "--git-path", name]));
|
|
1063
|
+
}
|
|
1064
|
+
digest(value) {
|
|
1065
|
+
return createHash("sha256").update(value).digest("hex");
|
|
1066
|
+
}
|
|
1067
|
+
inside(parent, child) {
|
|
1068
|
+
if (!isAbsolute(child))
|
|
1069
|
+
return false;
|
|
1070
|
+
const path = relative(parent, child);
|
|
1071
|
+
return !path || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
|
1072
|
+
}
|
|
1073
|
+
exists(path) {
|
|
1074
|
+
try {
|
|
1075
|
+
lstatSync(path);
|
|
1076
|
+
return true;
|
|
1077
|
+
}
|
|
1078
|
+
catch (error) {
|
|
1079
|
+
if (error.code === "ENOENT")
|
|
1080
|
+
return false;
|
|
1081
|
+
throw error;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
real(path, kind) {
|
|
1085
|
+
if (!isAbsolute(path) || resolve(path) !== path)
|
|
1086
|
+
throw new Error("Paths must be normalized absolute paths.");
|
|
1087
|
+
for (let cursor = path;; cursor = dirname(cursor)) {
|
|
1088
|
+
if (lstatSync(cursor).isSymbolicLink())
|
|
1089
|
+
throw new Error("Symlink path substitution.");
|
|
1090
|
+
if (dirname(cursor) === cursor)
|
|
1091
|
+
break;
|
|
1092
|
+
}
|
|
1093
|
+
const stat = lstatSync(path);
|
|
1094
|
+
if (realpathSync(path) !== path || (kind === "file" ? !stat.isFile() : !stat.isDirectory()))
|
|
1095
|
+
throw new Error(`Expected real ${kind}: ${path}.`);
|
|
1096
|
+
}
|
|
1097
|
+
read(path) {
|
|
1098
|
+
if (!this.exists(path))
|
|
1099
|
+
return undefined;
|
|
1100
|
+
this.real(path, "file");
|
|
1101
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1102
|
+
}
|
|
1103
|
+
atomicBytes(path, bytes) {
|
|
1104
|
+
this.real(dirname(path), "directory");
|
|
1105
|
+
if (this.exists(path))
|
|
1106
|
+
this.real(path, "file");
|
|
1107
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
1108
|
+
const descriptor = openSync(temporary, "wx", this.exists(path) ? lstatSync(path).mode & 0o777 : 0o600);
|
|
1109
|
+
try {
|
|
1110
|
+
writeFileSync(descriptor, bytes);
|
|
1111
|
+
fsyncSync(descriptor);
|
|
1112
|
+
}
|
|
1113
|
+
finally {
|
|
1114
|
+
closeSync(descriptor);
|
|
1115
|
+
}
|
|
1116
|
+
renameSync(temporary, path);
|
|
1117
|
+
const directory = openSync(dirname(path), "r");
|
|
1118
|
+
try {
|
|
1119
|
+
fsyncSync(directory);
|
|
1120
|
+
}
|
|
1121
|
+
finally {
|
|
1122
|
+
closeSync(directory);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
persist(path, value) {
|
|
1126
|
+
this.atomicBytes(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
1127
|
+
}
|
|
1128
|
+
locked(feature, action) {
|
|
1129
|
+
if (!/^\d{4,}-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(feature))
|
|
1130
|
+
throw new Error("Invalid feature identifier.");
|
|
1131
|
+
for (const name of [
|
|
1132
|
+
"GIT_DIR",
|
|
1133
|
+
"GIT_WORK_TREE",
|
|
1134
|
+
"GIT_COMMON_DIR",
|
|
1135
|
+
"GIT_INDEX_FILE",
|
|
1136
|
+
"GIT_OBJECT_DIRECTORY",
|
|
1137
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
1138
|
+
"GIT_NAMESPACE",
|
|
1139
|
+
]) {
|
|
1140
|
+
if (process.env[name])
|
|
1141
|
+
throw new Error(`Unset ${name} before finalization.`);
|
|
1142
|
+
}
|
|
1143
|
+
this.real(this.app.workspaceRoot, "directory");
|
|
1144
|
+
const common = this.run(this.app.workspaceRoot, [
|
|
1145
|
+
"rev-parse",
|
|
1146
|
+
"--path-format=absolute",
|
|
1147
|
+
"--git-common-dir",
|
|
1148
|
+
]);
|
|
1149
|
+
this.real(common, "directory");
|
|
1150
|
+
const directory = join(common, "spindle-feature-finalization");
|
|
1151
|
+
if (!this.exists(directory))
|
|
1152
|
+
mkdirSync(directory, { mode: 0o700 });
|
|
1153
|
+
this.real(directory, "directory");
|
|
1154
|
+
const lock = join(directory, `${feature}.lock`);
|
|
1155
|
+
if (this.exists(lock)) {
|
|
1156
|
+
const owner = this.read(lock);
|
|
1157
|
+
throw new Error(`Finalization lock exists; verify stopped owner ${owner?.pid} on ${owner?.host} and remove only ${lock} before resuming.`);
|
|
1158
|
+
}
|
|
1159
|
+
const descriptor = openSync(lock, "wx", 0o600);
|
|
1160
|
+
try {
|
|
1161
|
+
writeFileSync(descriptor, JSON.stringify({ pid: process.pid, host: hostname() }));
|
|
1162
|
+
fsyncSync(descriptor);
|
|
1163
|
+
}
|
|
1164
|
+
finally {
|
|
1165
|
+
closeSync(descriptor);
|
|
1166
|
+
}
|
|
1167
|
+
const identity = lstatSync(lock);
|
|
1168
|
+
try {
|
|
1169
|
+
return action(common, join(directory, `${feature}.json`));
|
|
1170
|
+
}
|
|
1171
|
+
finally {
|
|
1172
|
+
if (this.exists(lock)) {
|
|
1173
|
+
const current = lstatSync(lock);
|
|
1174
|
+
if (!current.isSymbolicLink() &&
|
|
1175
|
+
identity.dev === current.dev &&
|
|
1176
|
+
identity.ino === current.ino)
|
|
1177
|
+
unlinkSync(lock);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
run(root, args) {
|
|
1182
|
+
if (["add", "commit", "merge", "worktree", "update-ref"].includes(args[0]) &&
|
|
1183
|
+
!(args[0] === "worktree" && args[1] === "list"))
|
|
1184
|
+
this.noHooks(root);
|
|
1185
|
+
const result = this.git.run([
|
|
1186
|
+
"-c",
|
|
1187
|
+
"merge.autoStash=false",
|
|
1188
|
+
"-c",
|
|
1189
|
+
"rerere.enabled=false",
|
|
1190
|
+
"-c",
|
|
1191
|
+
"core.fsmonitor=false",
|
|
1192
|
+
"-c",
|
|
1193
|
+
"submodule.recurse=false",
|
|
1194
|
+
...args,
|
|
1195
|
+
], { cwd: root, encoding: "utf8" });
|
|
1196
|
+
if (result.error)
|
|
1197
|
+
throw result.error;
|
|
1198
|
+
if (result.status !== 0)
|
|
1199
|
+
throw new Error(`git ${args[0]} failed; preserve journal and worktrees: ${result.stderr.trim()}`);
|
|
1200
|
+
return result.stdout.includes("\0") ? result.stdout : result.stdout.trim();
|
|
1201
|
+
}
|
|
1202
|
+
}
|