@rungs/cli 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/cli.js +2184 -478
- package/dist/cli.js.map +4 -4
- package/modules/README.md +25 -3
- package/modules/adr/files/{{path}}/README.md +1 -1
- package/modules/adr/gates/adr.toml +1 -1
- package/modules/adr/module.toml +1 -1
- package/modules/audit/fragments/AGENTS.md +2 -2
- package/modules/audit/module.toml +1 -1
- package/modules/audit/skills/assess/SKILL.md +1 -1
- package/modules/backlog/files/docs/{{root}}/BACKLOG.md +1 -1
- package/modules/backlog/files/docs/{{root}}/README.md +2 -2
- package/modules/backlog/files/docs/{{root}}/archive/README.md +1 -1
- package/modules/backlog/files/docs/{{root}}/items/README.md +1 -1
- package/modules/backlog/fragments/AGENTS.md +2 -2
- package/modules/backlog/module.toml +1 -1
- package/modules/backlog/skills/work-item/SKILL.md +1 -1
- package/modules/ci/files/{{workflow_path}} +3 -3
- package/modules/ci/module.toml +1 -1
- package/modules/concurrency/files/docs/concurrent-sessions.md +66 -18
- package/modules/concurrency/fragments/AGENTS.md +5 -4
- package/modules/concurrency/fragments/gitattributes +2 -2
- package/modules/concurrency/gates/concurrency.toml +3 -3
- package/modules/concurrency/module.toml +1 -1
- package/modules/doc-authority/files/{{registry_path}} +1 -1
- package/modules/doc-authority/module.toml +1 -1
- package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +1 -1
- package/modules/findings/gates/findings.toml +5 -0
- package/modules/findings/module.toml +1 -1
- package/modules/findings/skills/record-finding/SKILL.md +1 -1
- package/modules/gates/files/.ai/gates.toml +1 -1
- package/modules/gates/fragments/AGENTS.md +6 -5
- package/modules/gates/module.toml +1 -1
- package/modules/instructions/files/.ai/rules/README.md +2 -2
- package/modules/instructions/files/.ai/rungs.mjs +52 -0
- package/modules/instructions/files/AGENTS.md +4 -2
- package/modules/instructions/files/CLAUDE.md +1 -1
- package/modules/instructions/fragments/AGENTS.md +2 -2
- package/modules/instructions/gates/core.toml +2 -2
- package/modules/instructions/module.toml +1 -1
- package/modules/release/files/{{changelog_dir}}/CONSUMED_THROUGH +1 -0
- package/modules/release/gates/release.toml +169 -17
- package/modules/release/module.toml +9 -5
- package/modules/release/skills/cut-release/SKILL.md +43 -15
- package/modules/session/files/{{archive}}/README.md +1 -1
- package/modules/session/files/{{path}} +2 -2
- package/modules/session/module.toml +1 -1
- package/modules/specs/files/{{path}}/README.md +2 -2
- package/modules/specs/module.toml +1 -1
- package/modules/workflows/module.toml +1 -1
- package/modules/workflows/rules/planning-tiers.md +1 -1
- package/package.json +3 -2
- package/src/add.ts +204 -48
- package/src/backlog.ts +354 -48
- package/src/check.ts +54 -33
- package/src/cli.ts +196 -69
- package/src/concurrency.ts +628 -42
- package/src/detect.ts +11 -3
- package/src/emitted-path.ts +274 -0
- package/src/engine-table.ts +66 -0
- package/src/engines.ts +18 -29
- package/src/engines2.ts +403 -20
- package/src/engines3.ts +111 -20
- package/src/explain.ts +3 -7
- package/src/help.ts +43 -0
- package/src/lifecycle.ts +86 -27
- package/src/manifest.ts +41 -5
- package/src/render.ts +106 -21
- package/src/selftest.ts +87 -10
- package/src/storage-key.ts +20 -0
- package/src/substitute.ts +47 -5
- package/src/text.ts +11 -0
- package/src/types.ts +16 -3
- package/src/version-source.ts +144 -0
package/src/concurrency.ts
CHANGED
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
* 3. Never hold the integration branch. Everything runs from a throwaway
|
|
14
14
|
* worktree, which the module already gates for.
|
|
15
15
|
*/
|
|
16
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
16
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
18
|
import { hostname } from 'node:os';
|
|
19
19
|
import { join, resolve, dirname, basename } from 'node:path';
|
|
20
20
|
import { tmpdir } from 'node:os';
|
|
21
21
|
import { installedParams } from './check.ts';
|
|
22
|
+
import { canonicalCaselessSegmentEqual } from './storage-key.ts';
|
|
22
23
|
|
|
23
24
|
export interface LoopParams {
|
|
24
25
|
integration: string;
|
|
@@ -61,6 +62,356 @@ function revParse(root: string, ref: string): string | null {
|
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
interface GitWorktree {
|
|
66
|
+
path: string;
|
|
67
|
+
branch?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface GitBranchRef {
|
|
71
|
+
ref: string;
|
|
72
|
+
oid: string;
|
|
73
|
+
symref?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type GitRefFormat = 'files' | 'reftable';
|
|
77
|
+
|
|
78
|
+
/** @internal Exported only so the legacy-Git compatibility boundary is directly testable. */
|
|
79
|
+
export function parseGitRefFormatOutput(output: string | undefined): GitRefFormat {
|
|
80
|
+
// Git versions predating `--show-ref-format` can treat it as a revision-like
|
|
81
|
+
// unknown option: they echo the literal argument and exit successfully. Those
|
|
82
|
+
// versions predate reftable, so that response has the same meaning as a failed
|
|
83
|
+
// query. Do not generalize the fallback: a real future backend must fail closed.
|
|
84
|
+
if (output === undefined || output === '--show-ref-format') return 'files';
|
|
85
|
+
if (output === 'files' || output === 'reftable') return output;
|
|
86
|
+
throw new Error(`unsupported Git ref format '${output}'`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read worktree paths without line parsing.
|
|
91
|
+
*
|
|
92
|
+
* A worktree path may contain whitespace or a newline. `--porcelain -z` makes
|
|
93
|
+
* NUL the only record delimiter, while branch refs cannot contain NUL, so the
|
|
94
|
+
* two fields can be associated without quoting or shell interpretation.
|
|
95
|
+
*/
|
|
96
|
+
function gitWorktrees(root: string): GitWorktree[] {
|
|
97
|
+
const out = execFileSync('git', ['worktree', 'list', '--porcelain', '-z'], {
|
|
98
|
+
cwd: root,
|
|
99
|
+
stdio: 'pipe',
|
|
100
|
+
encoding: 'utf8',
|
|
101
|
+
});
|
|
102
|
+
const rows: GitWorktree[] = [];
|
|
103
|
+
let row: GitWorktree | undefined;
|
|
104
|
+
|
|
105
|
+
for (const field of out.split('\0')) {
|
|
106
|
+
if (!field) {
|
|
107
|
+
if (row) rows.push(row);
|
|
108
|
+
row = undefined;
|
|
109
|
+
} else if (field.startsWith('worktree ')) {
|
|
110
|
+
// Be defensive about malformed output missing its blank record separator.
|
|
111
|
+
if (row) rows.push(row);
|
|
112
|
+
row = { path: field.slice('worktree '.length) };
|
|
113
|
+
} else if (row && field.startsWith('branch ')) {
|
|
114
|
+
row.branch = field.slice('branch '.length);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (row) rows.push(row);
|
|
118
|
+
return rows;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Enumerate the branch names Git actually stores instead of asking its ref
|
|
123
|
+
* resolver whether a constructed spelling happens to work on this filesystem.
|
|
124
|
+
*
|
|
125
|
+
* Windows can resolve `refs/heads/MAIN` through a loose `refs/heads/main`, and
|
|
126
|
+
* `update-ref` follows a symbolic branch ref by default. Either would make the
|
|
127
|
+
* worktree-holder check and the ref update talk about different branches. Ref
|
|
128
|
+
* names cannot contain tabs or newlines, so this argv-only format is unambiguous.
|
|
129
|
+
*/
|
|
130
|
+
function gitLocalBranchRefs(root: string): GitBranchRef[] {
|
|
131
|
+
const out = git(root, ['for-each-ref', '--format=%(refname)%09%(objectname)%09%(symref)', 'refs/heads/']);
|
|
132
|
+
const refs = new Map<string, GitBranchRef>();
|
|
133
|
+
if (out) {
|
|
134
|
+
for (const line of out.split('\n')) {
|
|
135
|
+
const [ref, oid, symref] = line.split('\t');
|
|
136
|
+
refs.set(ref, { ref, oid, ...(symref ? { symref } : {}) });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// `for-each-ref` deliberately omits a symbolic ref whose target does not
|
|
141
|
+
// exist. It is still operator-visible state and `update-ref --no-deref
|
|
142
|
+
// create` would replace it, so include every valid loose branch-ref file.
|
|
143
|
+
// Loose refs override packed refs with the same exact spelling, just as Git
|
|
144
|
+
// resolves them. Directory entries are read without following OS aliases.
|
|
145
|
+
const common = resolve(root, git(root, ['rev-parse', '--git-common-dir']));
|
|
146
|
+
const heads = join(common, 'refs', 'heads');
|
|
147
|
+
const visit = (directory: string, prefix: string): void => {
|
|
148
|
+
if (!existsSync(directory)) return;
|
|
149
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
150
|
+
const shortName = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
151
|
+
const path = join(directory, entry.name);
|
|
152
|
+
if (entry.isDirectory()) {
|
|
153
|
+
visit(path, shortName);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const ref = `refs/heads/${shortName}`;
|
|
157
|
+
// A lock is Git's coordination artifact, not a stored ref. Other entries
|
|
158
|
+
// are inspected conservatively; no per-ref subprocess is needed even in
|
|
159
|
+
// repositories with hundreds of branches.
|
|
160
|
+
if (entry.name.endsWith('.lock')) continue;
|
|
161
|
+
if (!entry.isFile()) throw new Error(`branch ref '${ref}' is not a regular loose-ref file`);
|
|
162
|
+
const value = readFileSync(path, 'utf8').replace(/[\r\n]+$/, '');
|
|
163
|
+
if (value.startsWith('ref: ')) {
|
|
164
|
+
refs.set(ref, { ref, oid: '', symref: value.slice('ref: '.length) });
|
|
165
|
+
} else {
|
|
166
|
+
// Git already enumerated every usable direct loose ref. The raw walk
|
|
167
|
+
// exists only to surface omitted symrefs; synthesizing an OID from an
|
|
168
|
+
// unenumerated file could report a malformed/unresolvable ref as safe.
|
|
169
|
+
const enumerated = refs.get(ref);
|
|
170
|
+
if (
|
|
171
|
+
!enumerated ||
|
|
172
|
+
enumerated.symref ||
|
|
173
|
+
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value) ||
|
|
174
|
+
enumerated.oid.toLowerCase() !== value.toLowerCase()
|
|
175
|
+
) throw new Error(`branch ref '${ref}' has an unreadable or unresolved loose-ref value`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
let refFormatOutput: string | undefined;
|
|
180
|
+
try {
|
|
181
|
+
refFormatOutput = git(root, ['rev-parse', '--show-ref-format']);
|
|
182
|
+
} catch {
|
|
183
|
+
// `--show-ref-format` predates reftable support. A Git without the query
|
|
184
|
+
// only has the files backend this scanner was written for.
|
|
185
|
+
}
|
|
186
|
+
const refFormat = parseGitRefFormatOutput(refFormatOutput);
|
|
187
|
+
if (refFormat === 'files') visit(heads, '');
|
|
188
|
+
return [...refs.values()];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface DirectRef {
|
|
192
|
+
ref: string;
|
|
193
|
+
oid: string | null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function symbolicRefTarget(root: string, ref: string): string | null {
|
|
197
|
+
try {
|
|
198
|
+
return git(root, ['symbolic-ref', '--quiet', ref]);
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
type DirectRefResolution =
|
|
205
|
+
| { value: DirectRef; error?: never }
|
|
206
|
+
| { value?: never; error: string };
|
|
207
|
+
|
|
208
|
+
function refStorageCollides(left: string, right: string): boolean {
|
|
209
|
+
const leftSegments = left.split('/');
|
|
210
|
+
const rightSegments = right.split('/');
|
|
211
|
+
const shared = Math.min(leftSegments.length, rightSegments.length);
|
|
212
|
+
for (let index = 0; index < shared; index++) {
|
|
213
|
+
if (!canonicalCaselessSegmentEqual(leftSegments[index], rightSegments[index])) return false;
|
|
214
|
+
// A spelling difference in a directory shared by two otherwise different
|
|
215
|
+
// refs is itself a Windows/APFS alias. The later leaf difference does not
|
|
216
|
+
// make the filesystem path unambiguous.
|
|
217
|
+
if (
|
|
218
|
+
leftSegments[index] !== rightSegments[index] &&
|
|
219
|
+
index < leftSegments.length - 1 &&
|
|
220
|
+
index < rightSegments.length - 1
|
|
221
|
+
) return true;
|
|
222
|
+
}
|
|
223
|
+
// Every segment of the shorter ref matched: the names are aliases, or one is
|
|
224
|
+
// a directory/file prefix of the other.
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Resolve one configured spelling against stored refs without filesystem aliasing or symref dereferencing. */
|
|
229
|
+
function exactDirectRef(
|
|
230
|
+
root: string,
|
|
231
|
+
stored: GitBranchRef[],
|
|
232
|
+
shortName: string,
|
|
233
|
+
role: 'integration' | 'green',
|
|
234
|
+
required: boolean,
|
|
235
|
+
): DirectRefResolution {
|
|
236
|
+
const wanted = `refs/heads/${shortName}`;
|
|
237
|
+
const label = role === 'integration'
|
|
238
|
+
? `configured integration branch '${shortName}'`
|
|
239
|
+
: `configured green ref '${shortName}'`;
|
|
240
|
+
if (!gitOk(root, ['check-ref-format', wanted])) {
|
|
241
|
+
return { error: `${label} is not a valid direct local branch ref; land is refused.` };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The files and reftable backends both omit dangling symrefs from
|
|
245
|
+
// `for-each-ref`; query the exact logical key before calling it creatable.
|
|
246
|
+
const exactSymbolicTarget = symbolicRefTarget(root, wanted);
|
|
247
|
+
if (exactSymbolicTarget) {
|
|
248
|
+
return { error: `${label} is symbolic (${wanted} -> ${exactSymbolicTarget}); land requires a direct local branch ref and is refused.` };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const alias = stored.find((entry) => entry.ref !== wanted && refStorageCollides(entry.ref, wanted));
|
|
252
|
+
if (alias) {
|
|
253
|
+
return { error: `${label} collides with case-aliased or directory/file-conflicting stored ref '${alias.ref}'; remove the ambiguity and retry.` };
|
|
254
|
+
}
|
|
255
|
+
const exact = stored.find((entry) => entry.ref === wanted);
|
|
256
|
+
if (!exact) {
|
|
257
|
+
if (required) return { error: `${label} has no exact stored local ref '${wanted}'; land is refused.` };
|
|
258
|
+
return { value: { ref: wanted, oid: null } };
|
|
259
|
+
}
|
|
260
|
+
if (exact.symref) {
|
|
261
|
+
return {
|
|
262
|
+
error: `${label} is symbolic (${exact.ref} -> ${exact.symref}); land requires a direct local branch ref and is refused.`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return { value: { ref: exact.ref, oid: exact.oid } };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
interface ManagedRef extends DirectRef {
|
|
269
|
+
holders: string[];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface ManagedRefs {
|
|
273
|
+
integration: ManagedRef;
|
|
274
|
+
green: ManagedRef;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
type ManagedRefsResolution =
|
|
278
|
+
| { value: ManagedRefs; error?: never }
|
|
279
|
+
| { value?: never; error: string };
|
|
280
|
+
|
|
281
|
+
/** Prove canonical/direct identity and checkout state for both refs the final transaction mutates. */
|
|
282
|
+
function managedRefsState(root: string, integration: string, green: string): ManagedRefsResolution {
|
|
283
|
+
try {
|
|
284
|
+
const stored = gitLocalBranchRefs(root);
|
|
285
|
+
const worktrees = gitWorktrees(root);
|
|
286
|
+
const integrationRef = exactDirectRef(root, stored, integration, 'integration', true);
|
|
287
|
+
if (!integrationRef.value) return integrationRef;
|
|
288
|
+
const greenRef = exactDirectRef(root, stored, green, 'green', false);
|
|
289
|
+
if (!greenRef.value) return greenRef;
|
|
290
|
+
const withHolders = (ref: DirectRef): ManagedRef => ({
|
|
291
|
+
...ref,
|
|
292
|
+
holders: worktrees
|
|
293
|
+
.filter((worktree) => worktree.branch !== undefined && refStorageCollides(worktree.branch, ref.ref))
|
|
294
|
+
.map((worktree) => worktree.path),
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
value: {
|
|
298
|
+
integration: withHolders(integrationRef.value),
|
|
299
|
+
green: withHolders(greenRef.value),
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
} catch {
|
|
303
|
+
return {
|
|
304
|
+
error: 'cannot enumerate local branch refs and worktrees; managed-ref identity or checkout state is unknown, so land is refused.',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
interface RecoveryRefResult {
|
|
310
|
+
name?: string;
|
|
311
|
+
error?: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Preserve a verified merge without overwriting, dereferencing or deleting operator state.
|
|
316
|
+
*
|
|
317
|
+
* The merge-derived candidate makes retries deterministic. A numeric suffix is needed only
|
|
318
|
+
* when that exact candidate is occupied by distinct work or held by a worktree.
|
|
319
|
+
*/
|
|
320
|
+
function createDirectRef(root: string, ref: string, oid: string): void {
|
|
321
|
+
// `create` is the strongest public Git precondition for an absent ref and
|
|
322
|
+
// `no-deref` protects a symbolic target. Supported Git versions disagree on
|
|
323
|
+
// whether a racing dangling symref is absent (replace its name) or occupied
|
|
324
|
+
// (refuse). Neither behavior follows the target, but the public protocol
|
|
325
|
+
// cannot portably CAS the name's direct-versus-symbolic type; that raw-Git
|
|
326
|
+
// micro-race is documented as a residual boundary.
|
|
327
|
+
const input = `option no-deref\0create ${ref}\0${oid}\0`;
|
|
328
|
+
execFileSync('git', ['update-ref', '--stdin', '-z', '-m', 'rungs park verified merge'], {
|
|
329
|
+
cwd: root,
|
|
330
|
+
stdio: 'pipe',
|
|
331
|
+
input: Buffer.from(input, 'utf8'),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function parkVerifiedMerge(
|
|
336
|
+
root: string,
|
|
337
|
+
preferred: string,
|
|
338
|
+
merged: string,
|
|
339
|
+
reserved: ReadonlySet<string>,
|
|
340
|
+
): RecoveryRefResult {
|
|
341
|
+
const derived = `${preferred}-${merged}`;
|
|
342
|
+
const flat = `rungs-park-${merged}`;
|
|
343
|
+
|
|
344
|
+
for (let index = 0; index < 1000; index++) {
|
|
345
|
+
const candidate = index === 0
|
|
346
|
+
? preferred
|
|
347
|
+
: index === 1
|
|
348
|
+
? derived
|
|
349
|
+
: index === 2
|
|
350
|
+
? flat
|
|
351
|
+
: `${flat}-${index - 2}`;
|
|
352
|
+
const wanted = `refs/heads/${candidate}`;
|
|
353
|
+
const reservedCollision = [...reserved]
|
|
354
|
+
.some((name) => refStorageCollides(wanted, `refs/heads/${name}`));
|
|
355
|
+
if (reservedCollision || !gitOk(root, ['check-ref-format', wanted])) continue;
|
|
356
|
+
|
|
357
|
+
// Inspect again after a failed create-only CAS so a concurrent creator of
|
|
358
|
+
// this same merge is reused while distinct work sends us to the next name.
|
|
359
|
+
for (let inspection = 0; inspection < 2; inspection++) {
|
|
360
|
+
let stored: GitBranchRef[];
|
|
361
|
+
let worktrees: GitWorktree[];
|
|
362
|
+
try {
|
|
363
|
+
stored = gitLocalBranchRefs(root);
|
|
364
|
+
worktrees = gitWorktrees(root);
|
|
365
|
+
} catch {
|
|
366
|
+
return { error: 'cannot enumerate refs and worktrees, so no recovery ref can be created safely.' };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const exact = stored.find((entry) => entry.ref === wanted);
|
|
370
|
+
const alias = stored.find((entry) => entry.ref !== wanted && refStorageCollides(entry.ref, wanted));
|
|
371
|
+
const exactSymbolicTarget = symbolicRefTarget(root, wanted);
|
|
372
|
+
const held = worktrees.some(
|
|
373
|
+
(worktree) => worktree.branch !== undefined && refStorageCollides(worktree.branch, wanted),
|
|
374
|
+
);
|
|
375
|
+
if (alias || exact?.symref || exactSymbolicTarget || held) break;
|
|
376
|
+
if (exact) {
|
|
377
|
+
if (exact.oid === merged) return { name: candidate };
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
createDirectRef(root, wanted, merged);
|
|
383
|
+
return { name: candidate };
|
|
384
|
+
} catch {
|
|
385
|
+
// One re-inspection distinguishes a benign same-merge race from a
|
|
386
|
+
// collision. Never turn the retry into an overwrite.
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { error: `could not allocate an unheld collision-free recovery ref below '${preferred}'.` };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Advance both markers as one no-dereference, expected-old Git ref transaction. */
|
|
394
|
+
function advanceVerifiedRefs(
|
|
395
|
+
root: string,
|
|
396
|
+
integration: DirectRef,
|
|
397
|
+
green: DirectRef,
|
|
398
|
+
merged: string,
|
|
399
|
+
): void {
|
|
400
|
+
const input = [
|
|
401
|
+
'option no-deref\0',
|
|
402
|
+
`update ${integration.ref}\0${merged}\0${integration.oid}\0`,
|
|
403
|
+
'option no-deref\0',
|
|
404
|
+
green.oid === null
|
|
405
|
+
? `create ${green.ref}\0${merged}\0`
|
|
406
|
+
: `update ${green.ref}\0${merged}\0${green.oid}\0`,
|
|
407
|
+
].join('');
|
|
408
|
+
execFileSync('git', ['update-ref', '--stdin', '-z', '-m', 'rungs land verified merge'], {
|
|
409
|
+
cwd: root,
|
|
410
|
+
stdio: 'pipe',
|
|
411
|
+
input: Buffer.from(input, 'utf8'),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
64
415
|
export interface Result {
|
|
65
416
|
ok: boolean;
|
|
66
417
|
lines: string[];
|
|
@@ -200,11 +551,81 @@ export interface GateOutcome {
|
|
|
200
551
|
* "inherited". Measured — a branch adding `./also-missing.md` on top of an
|
|
201
552
|
* already-red link gate landed clean. Attribution is per finding.
|
|
202
553
|
*/
|
|
203
|
-
failing: { id: string; findings: string[] }[];
|
|
554
|
+
failing: { id: string; findings: (string | { identity: string; diagnostic: string })[] }[];
|
|
204
555
|
}
|
|
205
556
|
|
|
206
557
|
export type LandRunner = (dir: string, only?: ReadonlySet<string>) => GateOutcome;
|
|
207
558
|
|
|
559
|
+
type GateFailure = GateOutcome['failing'][number];
|
|
560
|
+
type GateFailureFinding = GateFailure['findings'][number];
|
|
561
|
+
|
|
562
|
+
const findingIdentity = (finding: GateFailureFinding) =>
|
|
563
|
+
typeof finding === 'string' ? finding : finding.identity;
|
|
564
|
+
|
|
565
|
+
const findingDiagnostic = (finding: GateFailureFinding) =>
|
|
566
|
+
typeof finding === 'string' ? finding : finding.diagnostic;
|
|
567
|
+
|
|
568
|
+
function failingIdentities(outcome: GateOutcome): Map<string, Set<string>> {
|
|
569
|
+
return new Map(outcome.failing.map((failure) => [
|
|
570
|
+
failure.id,
|
|
571
|
+
new Set(failure.findings.map(findingIdentity)),
|
|
572
|
+
]));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function coversGateIds(outcome: GateOutcome, ids: ReadonlySet<string>): boolean {
|
|
576
|
+
const failedIds = new Set(outcome.failing.map((failure) => failure.id));
|
|
577
|
+
return [...failedIds].every((id) => ids.has(id)) && failedIds.size + outcome.pass >= ids.size;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
type ControlEligibility = { ok: true } | { ok: false; reason: string };
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* The invoking checkout can prove an inherited failure only when its tracked
|
|
584
|
+
* tree is exactly the captured integration tree. Ignored dependencies are the
|
|
585
|
+
* reason this control exists; non-ignored work makes it untrustworthy.
|
|
586
|
+
*/
|
|
587
|
+
function exactControlEligibility(root: string, integrationOid: string): ControlEligibility {
|
|
588
|
+
try {
|
|
589
|
+
let attached = '';
|
|
590
|
+
try {
|
|
591
|
+
attached = execFileSync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], {
|
|
592
|
+
cwd: root,
|
|
593
|
+
stdio: 'pipe',
|
|
594
|
+
encoding: 'utf8',
|
|
595
|
+
}).trim();
|
|
596
|
+
} catch (error: any) {
|
|
597
|
+
// Exit 1 is Git's documented detached-HEAD answer. Anything else means
|
|
598
|
+
// the checkout identity itself could not be established.
|
|
599
|
+
if (error?.status !== 1) {
|
|
600
|
+
return { ok: false, reason: 'the invoking worktree HEAD attachment state could not be read' };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (attached) {
|
|
604
|
+
return { ok: false, reason: `the invoking worktree is attached to '${attached}', not detached` };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const head = revParse(root, 'HEAD');
|
|
608
|
+
if (head !== integrationOid) {
|
|
609
|
+
return {
|
|
610
|
+
ok: false,
|
|
611
|
+
reason: `the invoking worktree is at ${head?.slice(0, 8) ?? 'an unreadable HEAD'}, not integration ${integrationOid.slice(0, 8)}`,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const status = execFileSync(
|
|
616
|
+
'git',
|
|
617
|
+
['--no-optional-locks', 'status', '--porcelain=v1', '-z', '--untracked-files=all'],
|
|
618
|
+
{ cwd: root, stdio: 'pipe' },
|
|
619
|
+
);
|
|
620
|
+
if (status.length) {
|
|
621
|
+
return { ok: false, reason: 'the invoking worktree has tracked, staged or non-ignored untracked changes' };
|
|
622
|
+
}
|
|
623
|
+
return { ok: true };
|
|
624
|
+
} catch {
|
|
625
|
+
return { ok: false, reason: 'the invoking worktree state could not be verified' };
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
208
629
|
export function land(root: string, branch: string, runner: LandRunner, dryRun = false): Result {
|
|
209
630
|
const { integration, greenRef, integPrefix } = loopParams(root);
|
|
210
631
|
const lines: string[] = [];
|
|
@@ -212,9 +633,35 @@ export function land(root: string, branch: string, runner: LandRunner, dryRun =
|
|
|
212
633
|
if (!branch) return { ok: false, lines: ['a branch name is required: `rungs land <branch>`'] };
|
|
213
634
|
const head = revParse(root, `refs/heads/${branch}`);
|
|
214
635
|
if (!head) return { ok: false, lines: [`branch '${branch}' does not exist`] };
|
|
215
|
-
const
|
|
636
|
+
const preferredParked = `${integPrefix}${branch}`;
|
|
637
|
+
const initialManaged = managedRefsState(root, integration, greenRef);
|
|
638
|
+
if (!initialManaged.value) return { ok: false, lines: [initialManaged.error] };
|
|
639
|
+
const { integration: initialIntegration, green: initialGreen } = initialManaged.value;
|
|
640
|
+
if (initialIntegration.ref === initialGreen.ref) {
|
|
641
|
+
return {
|
|
642
|
+
ok: false,
|
|
643
|
+
lines: [`configured integration branch '${integration}' and green ref '${greenRef}' resolve to the same direct ref; land requires two distinct managed refs.`],
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
const before = initialIntegration.oid;
|
|
216
647
|
if (!before) return { ok: false, lines: [`'${integration}' does not resolve`] };
|
|
217
648
|
|
|
649
|
+
// ADR-0009 rule 3 is a mutation precondition, not merely a gate installed in
|
|
650
|
+
// some consumers. Keep this before even inspecting the coordination lock: a
|
|
651
|
+
// known-invalid land must not replace a stale lock or create any artifact.
|
|
652
|
+
const heldManagedRef = [initialIntegration, initialGreen].find((ref) => ref.holders.length);
|
|
653
|
+
if (heldManagedRef) {
|
|
654
|
+
const name = heldManagedRef.ref.slice('refs/heads/'.length);
|
|
655
|
+
return {
|
|
656
|
+
ok: false,
|
|
657
|
+
lines: [
|
|
658
|
+
`'${name}' is checked out in ${heldManagedRef.holders.length} worktree(s), so land is refused:`,
|
|
659
|
+
...heldManagedRef.holders.map((path) => ` ${path}`),
|
|
660
|
+
'Switch each listed worktree to another branch or detach it (`git switch --detach`), then retry.',
|
|
661
|
+
],
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
218
665
|
// A real lock: it names its holder and start time, and is taken over if that
|
|
219
666
|
// holder is gone. A lock nobody can break is a lock somebody deletes.
|
|
220
667
|
const lp = lockPath(root);
|
|
@@ -234,14 +681,14 @@ export function land(root: string, branch: string, runner: LandRunner, dryRun =
|
|
|
234
681
|
}
|
|
235
682
|
}
|
|
236
683
|
if (dryRun) {
|
|
237
|
-
lines.push(`would merge ${branch} (${head.slice(0, 8)}) onto ${integration} (${before.slice(0, 8)}) via ${
|
|
684
|
+
lines.push(`would merge ${branch} (${head.slice(0, 8)}) onto ${integration} (${before.slice(0, 8)}) via ${preferredParked}, verify, then atomically advance ${integration} and ${greenRef}.`);
|
|
238
685
|
return { ok: true, lines };
|
|
239
686
|
}
|
|
240
687
|
|
|
241
688
|
const lock: Lock = { pid: process.pid, host: hostname(), started: new Date().toISOString(), branch };
|
|
242
689
|
writeFileSync(lp, JSON.stringify(lock));
|
|
243
690
|
const scratch = mkdtempSync(join(tmpdir(), 'rungs-land-'));
|
|
244
|
-
|
|
691
|
+
let preserveScratch = false;
|
|
245
692
|
|
|
246
693
|
try {
|
|
247
694
|
// Rule 3: a throwaway worktree, detached. The integration branch is never
|
|
@@ -269,6 +716,33 @@ export function land(root: string, branch: string, runner: LandRunner, dryRun =
|
|
|
269
716
|
const res = runner(scratch);
|
|
270
717
|
lines.push(`merged tree ${merged.slice(0, 8)} — ${res.pass} pass · ${res.failing.length} fail`);
|
|
271
718
|
|
|
719
|
+
const refuseWithRecovery = (details: string[], guidance: string): Result => {
|
|
720
|
+
const recovery = parkVerifiedMerge(
|
|
721
|
+
root,
|
|
722
|
+
preferredParked,
|
|
723
|
+
merged,
|
|
724
|
+
new Set([integration, greenRef, branch]),
|
|
725
|
+
);
|
|
726
|
+
if (!recovery.name) {
|
|
727
|
+
// Last-resort preservation: an unenumerable or completely blocked ref
|
|
728
|
+
// namespace must not turn refusal into data loss. The detached scratch
|
|
729
|
+
// stays registered until the operator resolves the condition.
|
|
730
|
+
git(scratch, ['reset', '--hard', merged]);
|
|
731
|
+
preserveScratch = true;
|
|
732
|
+
return {
|
|
733
|
+
ok: false,
|
|
734
|
+
lines: [...lines, ...details,
|
|
735
|
+
`The verified merge ${merged} could not be parked safely: ${recovery.error}`,
|
|
736
|
+
`Its detached scratch worktree is retained at ${scratch}. Resolve the ref/worktree state, create a recovery branch at that exact commit, then remove the scratch explicitly.`],
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
return {
|
|
740
|
+
ok: false,
|
|
741
|
+
lines: [...lines, ...details,
|
|
742
|
+
`Your verified merge is parked on '${recovery.name}'. ${guidance}`],
|
|
743
|
+
};
|
|
744
|
+
};
|
|
745
|
+
|
|
272
746
|
if (res.failing.length) {
|
|
273
747
|
// **Attribution.** A gate that is red for reasons you did not cause and
|
|
274
748
|
// cannot fix is a gate you learn to bypass, and a bypassed gate reports
|
|
@@ -290,41 +764,116 @@ export function land(root: string, branch: string, runner: LandRunner, dryRun =
|
|
|
290
764
|
|
|
291
765
|
// A gate that did not run at the base cannot be attributed at all. We do
|
|
292
766
|
// not land on an unknown, so that blocks.
|
|
293
|
-
const
|
|
294
|
-
|
|
767
|
+
const baseAttributable = base !== null && coversGateIds(base, ids);
|
|
768
|
+
|
|
769
|
+
// The same detached scratch supplies both sides of the branch comparison.
|
|
770
|
+
// That controls content, not runtime: ignored dependencies can be absent
|
|
771
|
+
// from both and manufacture an identical failure. A clean detached
|
|
772
|
+
// invoking checkout at the exact integration OID is the independent
|
|
773
|
+
// control that can establish whether the base is genuinely red.
|
|
774
|
+
let control: GateOutcome | null = null;
|
|
775
|
+
let controlIssue = '';
|
|
776
|
+
const eligibleBefore = exactControlEligibility(root, before);
|
|
777
|
+
if (!eligibleBefore.ok) {
|
|
778
|
+
controlIssue = eligibleBefore.reason;
|
|
779
|
+
} else {
|
|
780
|
+
try {
|
|
781
|
+
control = runner(root, ids);
|
|
782
|
+
const eligibleAfter = exactControlEligibility(root, before);
|
|
783
|
+
if (!eligibleAfter.ok) {
|
|
784
|
+
control = null;
|
|
785
|
+
controlIssue = `the invoking control changed while gates ran: ${eligibleAfter.reason}`;
|
|
786
|
+
}
|
|
787
|
+
} catch {
|
|
788
|
+
control = null;
|
|
789
|
+
controlIssue = 'the exact integration control could not be gated';
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (control && !coversGateIds(control, ids)) {
|
|
793
|
+
control = null;
|
|
794
|
+
controlIssue = 'some failing gates did not run in the exact integration control';
|
|
795
|
+
}
|
|
295
796
|
|
|
296
|
-
const
|
|
297
|
-
const
|
|
797
|
+
const controlAttributable = control !== null;
|
|
798
|
+
const baseFindings = base ? failingIdentities(base) : new Map<string, Set<string>>();
|
|
799
|
+
const controlFindings = control ? failingIdentities(control) : new Map<string, Set<string>>();
|
|
800
|
+
|
|
801
|
+
const introduced: GateFailure[] = [];
|
|
802
|
+
const inherited: GateFailure[] = [];
|
|
803
|
+
const unverified: GateFailure[] = [];
|
|
804
|
+
const addFinding = (target: GateFailure[], id: string, finding: GateFailureFinding) => {
|
|
805
|
+
let failure = target.find((entry) => entry.id === id);
|
|
806
|
+
if (!failure) {
|
|
807
|
+
failure = { id, findings: [] };
|
|
808
|
+
target.push(failure);
|
|
809
|
+
}
|
|
810
|
+
failure.findings.push(finding);
|
|
811
|
+
};
|
|
298
812
|
for (const f of res.failing) {
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
813
|
+
const atBase = baseAttributable ? baseFindings.get(f.id) ?? new Set<string>() : null;
|
|
814
|
+
const inControl = controlAttributable ? controlFindings.get(f.id) ?? new Set<string>() : null;
|
|
815
|
+
for (const finding of f.findings) {
|
|
816
|
+
const identity = findingIdentity(finding);
|
|
817
|
+
// Per finding: a gate already red at the base does not excuse a new
|
|
818
|
+
// violation. A base match becomes inherited only with independent
|
|
819
|
+
// confirmation from the exact integration control.
|
|
820
|
+
if (!atBase || !atBase.has(identity)) {
|
|
821
|
+
addFinding(introduced, f.id, finding);
|
|
822
|
+
} else if (!inControl || !inControl.has(identity)) {
|
|
823
|
+
addFinding(unverified, f.id, finding);
|
|
824
|
+
} else {
|
|
825
|
+
addFinding(inherited, f.id, finding);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
305
828
|
}
|
|
306
829
|
|
|
307
830
|
for (const f of inherited) {
|
|
308
|
-
lines.push(` inherited ${f.id}${f.findings[0] ? ` — ${f.findings[0]}` : ''}`);
|
|
831
|
+
lines.push(` inherited ${f.id}${f.findings[0] ? ` — ${findingDiagnostic(f.findings[0])}` : ''}`);
|
|
309
832
|
}
|
|
310
833
|
for (const f of introduced) {
|
|
311
|
-
lines.push(` INTRODUCED ${f.id}${f.findings[0] ? ` — ${f.findings[0]}` : ''}`);
|
|
312
|
-
for (const extra of f.findings.slice(1, 4)) lines.push(` ${extra}`);
|
|
834
|
+
lines.push(` INTRODUCED ${f.id}${f.findings[0] ? ` — ${findingDiagnostic(f.findings[0])}` : ''}`);
|
|
835
|
+
for (const extra of f.findings.slice(1, 4)) lines.push(` ${findingDiagnostic(extra)}`);
|
|
313
836
|
}
|
|
314
837
|
if (base === null) {
|
|
315
838
|
lines.push(' The merge base could not be gated, so nothing here is attributable and all of it blocks.');
|
|
316
|
-
} else if (!
|
|
839
|
+
} else if (!baseAttributable) {
|
|
317
840
|
lines.push(' Some gates could not be attributed against the merge base, so they block. We do not land on an unknown.');
|
|
318
841
|
}
|
|
319
842
|
|
|
843
|
+
if (controlIssue) {
|
|
844
|
+
lines.push(` CONTROL UNAVAILABLE — ${controlIssue}; inherited failure cannot be established.`);
|
|
845
|
+
}
|
|
846
|
+
for (const f of unverified) {
|
|
847
|
+
if (controlAttributable) {
|
|
848
|
+
const mismatch = (controlFindings.get(f.id)?.size ?? 0) > 0
|
|
849
|
+
? 'the detached base scratch and exact integration control reported different findings'
|
|
850
|
+
: 'the exact integration control passed while the detached base scratch failed';
|
|
851
|
+
lines.push(` UNVERIFIED ${f.id} — ${mismatch}; the scratch environment cannot establish inheritance.`);
|
|
852
|
+
if (f.findings[0]) {
|
|
853
|
+
lines.push(` ${findingDiagnostic(f.findings[0]).replace(/\n/g, '\n ')}`);
|
|
854
|
+
}
|
|
855
|
+
} else {
|
|
856
|
+
lines.push(` UNVERIFIED ${f.id}${f.findings[0] ? ` — ${findingDiagnostic(f.findings[0])}` : ''}`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
320
860
|
if (introduced.length) {
|
|
321
861
|
// Rule 2: park it, do not discard it. The merge is the expensive part
|
|
322
862
|
// and throwing it away means doing it again to see the same failure.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
863
|
+
return refuseWithRecovery(
|
|
864
|
+
[`${introduced.length} introduced by this branch. ${integration} and ${greenRef} were not advanced.`],
|
|
865
|
+
'Fix it there and land again; recovery-ref cleanup remains operator-owned.',
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (unverified.length || !baseAttributable || !controlAttributable) {
|
|
870
|
+
const unverifiedCount = unverified.reduce((total, failure) => total + failure.findings.length, 0);
|
|
871
|
+
return refuseWithRecovery(
|
|
872
|
+
[
|
|
873
|
+
`${unverifiedCount || res.failing.length} failure(s) could not be verified as inherited. ${integration} and ${greenRef} were not advanced.`,
|
|
874
|
+
],
|
|
875
|
+
`Run land from a clean detached checkout at ${integration} (${before.slice(0, 8)}) with the gate runtime available, then retry.`,
|
|
326
876
|
);
|
|
327
|
-
return { ok: false, lines };
|
|
328
877
|
}
|
|
329
878
|
|
|
330
879
|
lines.push(
|
|
@@ -335,34 +884,71 @@ export function land(root: string, branch: string, runner: LandRunner, dryRun =
|
|
|
335
884
|
git(scratch, ['reset', '--hard', merged]);
|
|
336
885
|
}
|
|
337
886
|
|
|
338
|
-
//
|
|
339
|
-
//
|
|
887
|
+
// The runner is arbitrary repository code. Re-establish every managed-ref
|
|
888
|
+
// identity and holder fact at the mutation boundary, not just integration.
|
|
889
|
+
const lateManaged = managedRefsState(root, integration, greenRef);
|
|
890
|
+
if (!lateManaged.value) {
|
|
891
|
+
return refuseWithRecovery(
|
|
892
|
+
[
|
|
893
|
+
'managed-ref identity and checkout state could not be revalidated after verification, so the atomic advance is refused.',
|
|
894
|
+
` ${lateManaged.error}`,
|
|
895
|
+
],
|
|
896
|
+
`Re-run \`rungs land ${branch}\` after ref identity and checkout state can be verified.`,
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
const { integration: lateIntegration, green: lateGreen } = lateManaged.value;
|
|
900
|
+
const lateHolder = [lateIntegration, lateGreen].find((ref) => ref.holders.length);
|
|
901
|
+
if (lateHolder) {
|
|
902
|
+
const name = lateHolder.ref.slice('refs/heads/'.length);
|
|
903
|
+
return refuseWithRecovery(
|
|
904
|
+
[
|
|
905
|
+
`'${name}' became checked out in ${lateHolder.holders.length} worktree(s) while this land was verifying, so the atomic ref advance is refused:`,
|
|
906
|
+
...lateHolder.holders.map((path) => ` ${path}`),
|
|
907
|
+
'Switch each listed worktree to another branch or detach it (`git switch --detach`), then retry.',
|
|
908
|
+
],
|
|
909
|
+
`Re-run \`rungs land ${branch}\` to rebuild the merge after releasing the branch.`,
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
if (lateIntegration.oid !== initialIntegration.oid || lateGreen.oid !== initialGreen.oid) {
|
|
913
|
+
const moved = [
|
|
914
|
+
...(lateIntegration.oid !== initialIntegration.oid ? [integration] : []),
|
|
915
|
+
...(lateGreen.oid !== initialGreen.oid ? [greenRef] : []),
|
|
916
|
+
];
|
|
917
|
+
return refuseWithRecovery(
|
|
918
|
+
[`${moved.join(' and ')} moved while this land was verifying, so the atomic advance was refused rather than overwriting concurrent work.`],
|
|
919
|
+
`Re-run \`rungs land ${branch}\` to rebuild the merge on the new managed-ref state.`,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Rule 1: one compare-and-swap transaction. If either expected-old OID
|
|
924
|
+
// loses, Git commits neither update. `--no-deref` prevents a last-instant
|
|
925
|
+
// symref swap from redirecting a write into its target; Git cannot CAS the
|
|
926
|
+
// direct-vs-symbolic type itself, so that raw-Git micro-race is the explicit
|
|
927
|
+
// residual boundary documented by the module and WI-079.
|
|
340
928
|
try {
|
|
341
|
-
|
|
929
|
+
advanceVerifiedRefs(root, initialIntegration, initialGreen, merged);
|
|
342
930
|
} catch {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
`${integration} moved while this land was verifying, so the advance was refused rather than overwriting it.`,
|
|
348
|
-
`Your verified merge is parked on '${parked}'. Re-run \`rungs land ${branch}\` to rebuild it on the new tip.`],
|
|
349
|
-
};
|
|
931
|
+
return refuseWithRecovery(
|
|
932
|
+
[`${integration} or ${greenRef} moved, became symbolic, or could not be locked while this land was verifying. The atomic managed-ref transaction was refused, so Rungs did not partially update either ref.`],
|
|
933
|
+
`Re-run \`rungs land ${branch}\` to rebuild the merge after inspecting the competing ref state.`,
|
|
934
|
+
);
|
|
350
935
|
}
|
|
351
|
-
|
|
352
|
-
lines.push(
|
|
353
|
-
if (revParse(root, `refs/heads/${parked}`)) git(root, ['update-ref', '-d', `refs/heads/${parked}`]);
|
|
936
|
+
lines.push(`${integration} and ${greenRef} → ${merged.slice(0, 8)} in one atomic verified-ref transaction.`);
|
|
937
|
+
lines.push('Existing recovery refs are retained; cleanup remains an explicit operator decision.');
|
|
354
938
|
return { ok: true, lines };
|
|
355
939
|
} finally {
|
|
356
940
|
// The scratch worktree is ours and only ours, so removing it is not rule 2's
|
|
357
941
|
// "never destroy" — that is about the operator's branches and worktrees.
|
|
358
|
-
|
|
359
|
-
git(root, ['worktree', 'remove', '--force', scratch]);
|
|
360
|
-
} catch {
|
|
361
|
-
rmSync(scratch, { recursive: true, force: true });
|
|
942
|
+
if (!preserveScratch) {
|
|
362
943
|
try {
|
|
363
|
-
git(root, ['worktree', '
|
|
944
|
+
git(root, ['worktree', 'remove', '--force', scratch]);
|
|
364
945
|
} catch {
|
|
365
|
-
|
|
946
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
947
|
+
try {
|
|
948
|
+
git(root, ['worktree', 'prune']);
|
|
949
|
+
} catch {
|
|
950
|
+
/* leaving a stale worktree record is not worth failing a successful land */
|
|
951
|
+
}
|
|
366
952
|
}
|
|
367
953
|
}
|
|
368
954
|
try {
|