@farmslot/agent-runtime 0.21.3 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/native/host-cleanup-recovery.d.ts +3 -0
- package/dist/native/host-cleanup-recovery.d.ts.map +1 -0
- package/dist/native/host-cleanup-recovery.js +149 -0
- package/dist/native/host-cleanup-recovery.js.map +1 -0
- package/dist/native/manager.d.ts.map +1 -1
- package/dist/native/manager.js +5 -16
- package/dist/native/manager.js.map +1 -1
- package/dist/native/process-tree.d.ts +11 -0
- package/dist/native/process-tree.d.ts.map +1 -1
- package/dist/native/process-tree.js +24 -0
- package/dist/native/process-tree.js.map +1 -1
- package/dist/native/storage.d.ts +3 -0
- package/dist/native/storage.d.ts.map +1 -1
- package/dist/native/storage.js +15 -0
- package/dist/native/storage.js.map +1 -1
- package/dist/native/supervisor.js +7 -2
- package/dist/native/supervisor.js.map +1 -1
- package/dist/task-init/branch-update.d.ts +50 -0
- package/dist/task-init/branch-update.d.ts.map +1 -0
- package/dist/task-init/branch-update.js +728 -0
- package/dist/task-init/branch-update.js.map +1 -0
- package/dist/task-init/index.d.ts +1 -0
- package/dist/task-init/index.d.ts.map +1 -1
- package/dist/task-init/index.js +5 -2
- package/dist/task-init/index.js.map +1 -1
- package/dist/task-init/task-document.d.ts +3 -1
- package/dist/task-init/task-document.d.ts.map +1 -1
- package/dist/task-init/task-document.js +44 -4
- package/dist/task-init/task-document.js.map +1 -1
- package/package.json +7 -5
- package/scripts/branch-update.mjs +35 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
export class BranchMaintenanceRefusal extends Error {
|
|
6
|
+
constructor(code, cause) {
|
|
7
|
+
super(`Branch maintenance refused: ${code}`, { cause });
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function refuse(code) {
|
|
12
|
+
throw new BranchMaintenanceRefusal(code);
|
|
13
|
+
}
|
|
14
|
+
const sha = (value) => /^[a-f0-9]{40}$/i.test(value) ? value : refuse('INVALID_COMMIT');
|
|
15
|
+
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
16
|
+
const quote = (value) => "'" + value.replaceAll("'", "'\"'\"'") + "'";
|
|
17
|
+
export function normalizeMaintenanceBranch(value) {
|
|
18
|
+
return value.replace(/^(?:refs\/remotes\/origin\/|refs\/heads\/|origin\/)/, '');
|
|
19
|
+
}
|
|
20
|
+
function git(repo, args, input, authorEnv) {
|
|
21
|
+
return execFileSync('git', args, {
|
|
22
|
+
cwd: repo,
|
|
23
|
+
...(authorEnv ? { env: { ...process.env, ...authorEnv } } : {}),
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
26
|
+
input,
|
|
27
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
28
|
+
}).trimEnd();
|
|
29
|
+
}
|
|
30
|
+
function ancestor(repo, left, right) {
|
|
31
|
+
try {
|
|
32
|
+
git(repo, ['merge-base', '--is-ancestor', sha(left), sha(right)]);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.status === 1)
|
|
37
|
+
return false;
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function fetchHeads(repo, branch, base) {
|
|
42
|
+
for (const value of [branch, base])
|
|
43
|
+
git(repo, ['check-ref-format', `refs/heads/${value}`]);
|
|
44
|
+
try {
|
|
45
|
+
git(repo, [
|
|
46
|
+
'fetch',
|
|
47
|
+
'origin',
|
|
48
|
+
...new Set([branch, base].map((value) => `+refs/heads/${value}:refs/remotes/origin/${value}`)),
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
throw new BranchMaintenanceRefusal('FETCH_FAILED', error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function ref(repo, branch) {
|
|
56
|
+
return sha(git(repo, ['rev-parse', `refs/remotes/origin/${branch}`]).trim());
|
|
57
|
+
}
|
|
58
|
+
function assertSource(operation, source) {
|
|
59
|
+
if (!ancestor(operation.repo, operation.authorityHeadSha, source))
|
|
60
|
+
refuse('PARENT_SOURCE_CHANGED');
|
|
61
|
+
if (!ancestor(operation.repo, operation.replayBoundarySha, source))
|
|
62
|
+
refuse('PARENT_BOUNDARY_MISSING');
|
|
63
|
+
if (!ancestor(operation.repo, operation.remoteHeadSha, source))
|
|
64
|
+
refuse('REMOTE_CHILD_COMMITS_MISSING');
|
|
65
|
+
if (git(operation.repo, ['rev-list', '--merges', `${operation.authorityHeadSha}..${source}`]))
|
|
66
|
+
refuse('UNVERIFIED_MERGE');
|
|
67
|
+
}
|
|
68
|
+
export function inspectBranchMaintenance(request) {
|
|
69
|
+
const branch = normalizeMaintenanceBranch(request.branch);
|
|
70
|
+
const baseRef = normalizeMaintenanceBranch(request.baseRef);
|
|
71
|
+
fetchHeads(request.repo, branch, baseRef);
|
|
72
|
+
const remoteHeadSha = ref(request.repo, branch);
|
|
73
|
+
const targetSha = ref(request.repo, baseRef);
|
|
74
|
+
// Handoff can precede prepare, so pin the remote source without relying
|
|
75
|
+
// on the checkout currently occupying the slot. Execute verifies that checkout.
|
|
76
|
+
const sourceHeadSha = remoteHeadSha;
|
|
77
|
+
const { authority, ...input } = request;
|
|
78
|
+
const operation = {
|
|
79
|
+
...input,
|
|
80
|
+
branch,
|
|
81
|
+
baseRef,
|
|
82
|
+
sourceHeadSha,
|
|
83
|
+
remoteHeadSha,
|
|
84
|
+
targetSha,
|
|
85
|
+
comparisonBaseSha: sha(authority.comparisonBaseSha),
|
|
86
|
+
replayBoundarySha: sha(authority.replayBoundarySha),
|
|
87
|
+
authorityHeadSha: sha(authority.headSha),
|
|
88
|
+
preparedAt: new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
assertSource(operation, sourceHeadSha);
|
|
91
|
+
// Gateway supplies the settled immutable contribution comparison. Its tree
|
|
92
|
+
// may be synthetic and need not be an ancestor of the recorded replay boundary.
|
|
93
|
+
// Never replace that authority with a live merge-base or the replay boundary.
|
|
94
|
+
if (request.upstreamMergeSha && !ancestor(request.repo, request.upstreamMergeSha, targetSha))
|
|
95
|
+
refuse('RETARGET_PROVENANCE_MISSING');
|
|
96
|
+
return operation;
|
|
97
|
+
}
|
|
98
|
+
function operationHash(operation) {
|
|
99
|
+
return digest(JSON.stringify(operation));
|
|
100
|
+
}
|
|
101
|
+
function receiptPath(operation) {
|
|
102
|
+
return path.resolve(operation.repo, git(operation.repo, [
|
|
103
|
+
'rev-parse',
|
|
104
|
+
'--git-path',
|
|
105
|
+
`farmslot-branch-${digest(operation.id)}.json`,
|
|
106
|
+
]).trim());
|
|
107
|
+
}
|
|
108
|
+
function readReceipt(operation) {
|
|
109
|
+
const file = receiptPath(operation);
|
|
110
|
+
if (!existsSync(file))
|
|
111
|
+
return undefined;
|
|
112
|
+
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
113
|
+
if (value.operationHash !== operationHash(operation))
|
|
114
|
+
refuse('OPERATION_CHANGED');
|
|
115
|
+
assertSource(operation, sha(value.sourceHeadSha));
|
|
116
|
+
sha(value.replayHeadSha);
|
|
117
|
+
sha(value.boundarySha);
|
|
118
|
+
if (!['prepared', 'rebasing', 'completed', 'retired'].includes(value.phase))
|
|
119
|
+
refuse('RECEIPT_INVALID');
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
function recordReceipt(operation, value) {
|
|
123
|
+
const file = receiptPath(operation);
|
|
124
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
125
|
+
const fd = openSync(temp, 'w', 0o600);
|
|
126
|
+
try {
|
|
127
|
+
writeFileSync(fd, JSON.stringify(value));
|
|
128
|
+
fsyncSync(fd);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
closeSync(fd);
|
|
132
|
+
}
|
|
133
|
+
renameSync(temp, file);
|
|
134
|
+
const directory = openSync(path.dirname(file), 'r');
|
|
135
|
+
try {
|
|
136
|
+
fsyncSync(directory);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
closeSync(directory);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function rebaseActive(repo) {
|
|
143
|
+
return ['rebase-merge', 'rebase-apply'].some((name) => existsSync(path.resolve(repo, git(repo, ['rev-parse', '--git-path', name]).trim())));
|
|
144
|
+
}
|
|
145
|
+
function fenceTarget(operation) {
|
|
146
|
+
fetchHeads(operation.repo, operation.branch, operation.baseRef);
|
|
147
|
+
if (ref(operation.repo, operation.baseRef) !== sha(operation.targetSha))
|
|
148
|
+
refuse('TARGET_CHANGED');
|
|
149
|
+
return ref(operation.repo, operation.branch);
|
|
150
|
+
}
|
|
151
|
+
function assertCheckout(operation) {
|
|
152
|
+
if (git(operation.repo, ['branch', '--show-current']).trim() !== operation.branch)
|
|
153
|
+
refuse('BRANCH_CHANGED');
|
|
154
|
+
if (git(operation.repo, ['status', '--porcelain']))
|
|
155
|
+
refuse('UNCOMMITTED_CHANGES');
|
|
156
|
+
}
|
|
157
|
+
function assertOwnedResult(operation, receipt, head) {
|
|
158
|
+
if (!ancestor(operation.repo, operation.targetSha, head))
|
|
159
|
+
refuse('RESULT_TARGET_MISSING');
|
|
160
|
+
if (git(operation.repo, ['rev-list', '--merges', `${operation.targetSha}..${head}`]))
|
|
161
|
+
refuse('UNVERIFIED_RESULT_MERGE');
|
|
162
|
+
const paths = (base, result) => git(operation.repo, ['diff', '--name-only', '-z', base, result, '--'])
|
|
163
|
+
.split('\0')
|
|
164
|
+
.filter(Boolean);
|
|
165
|
+
const owned = new Set([
|
|
166
|
+
...paths(operation.comparisonBaseSha, receipt.sourceHeadSha),
|
|
167
|
+
...(receipt.rebaseResultSha ? paths(receipt.rebaseResultSha, head) : []),
|
|
168
|
+
]);
|
|
169
|
+
if (paths(operation.targetSha, head).some((value) => !owned.has(value)))
|
|
170
|
+
refuse('INHERITED_CHANGES_REPLAYED');
|
|
171
|
+
}
|
|
172
|
+
function journalCommand(operation) {
|
|
173
|
+
// Rebase runs this after each applied commit, including `rebase --continue`.
|
|
174
|
+
// The durable result journal closes the successful-rebase/pre-renewal crash gap.
|
|
175
|
+
const code = `const f=require('node:fs'),p=require('node:path'),c=require('node:child_process');const file=process.argv[1],v=JSON.parse(f.readFileSync(file,'utf8'));if(v.operationHash!==process.argv[2]||v.phase!=='rebasing')throw Error('OPERATION_CHANGED');v.observedHeadSha=c.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim();const tmp=file+'.'+process.pid+'.tmp',fd=f.openSync(tmp,'w',384);try{f.writeFileSync(fd,JSON.stringify(v));f.fsyncSync(fd)}finally{f.closeSync(fd)}f.renameSync(tmp,file);const d=f.openSync(p.dirname(file),'r');try{f.fsyncSync(d)}finally{f.closeSync(d)}`;
|
|
176
|
+
return `${quote(process.execPath)} -e ${quote(code)} ${quote(receiptPath(operation))} ${quote(operationHash(operation))}`;
|
|
177
|
+
}
|
|
178
|
+
function prepareReceipt(operation, head) {
|
|
179
|
+
const repo = operation.repo;
|
|
180
|
+
assertSource(operation, head);
|
|
181
|
+
let boundary = operation.replayBoundarySha;
|
|
182
|
+
let replay = head;
|
|
183
|
+
if (operation.ownerRunId !== 'ordinary' &&
|
|
184
|
+
git(repo, ['rev-list', '--merges', `${boundary}..${head}`])) {
|
|
185
|
+
// A single committed child delta preserves merge resolutions without
|
|
186
|
+
// replaying inherited squash/default commits removed by the new target.
|
|
187
|
+
boundary = operation.comparisonBaseSha;
|
|
188
|
+
const signing = git(repo, ['config', '--type=bool', '--default=false', '--get', 'commit.gpgsign']).trim() ===
|
|
189
|
+
'true';
|
|
190
|
+
replay = sha(git(repo, [
|
|
191
|
+
'commit-tree',
|
|
192
|
+
git(repo, ['rev-parse', `${head}^{tree}`]).trim(),
|
|
193
|
+
'-p',
|
|
194
|
+
boundary,
|
|
195
|
+
...(signing ? ['-S'] : []),
|
|
196
|
+
], 'fix: retain verified child changes during branch maintenance\n').trim());
|
|
197
|
+
}
|
|
198
|
+
const receipt = {
|
|
199
|
+
operationHash: operationHash(operation),
|
|
200
|
+
operation,
|
|
201
|
+
sourceHeadSha: head,
|
|
202
|
+
replayHeadSha: replay,
|
|
203
|
+
boundarySha: boundary,
|
|
204
|
+
phase: 'prepared',
|
|
205
|
+
};
|
|
206
|
+
// Persist before reset or rebase can alter the branch.
|
|
207
|
+
recordReceipt(operation, receipt);
|
|
208
|
+
return receipt;
|
|
209
|
+
}
|
|
210
|
+
function assertGraphResult(operation, head) {
|
|
211
|
+
if (!ancestor(operation.repo, operation.targetSha, head))
|
|
212
|
+
refuse('RESULT_TARGET_MISSING');
|
|
213
|
+
if (head === operation.remoteHeadSha)
|
|
214
|
+
return;
|
|
215
|
+
const parents = git(operation.repo, ['rev-list', '--parents', '-n', '1', head])
|
|
216
|
+
.split(/\s+/)
|
|
217
|
+
.slice(1);
|
|
218
|
+
if (parents.length !== 2 ||
|
|
219
|
+
parents[0] !== operation.remoteHeadSha ||
|
|
220
|
+
parents[1] !== operation.targetSha)
|
|
221
|
+
refuse('GRAPH_MERGE_CHANGED');
|
|
222
|
+
const tree = cleanMergeTree(operation.repo, operation.remoteHeadSha, operation.targetSha);
|
|
223
|
+
if (git(operation.repo, ['rev-parse', `${head}^{tree}`]).trim() !== tree)
|
|
224
|
+
refuse('GRAPH_MERGE_TREE_CHANGED');
|
|
225
|
+
}
|
|
226
|
+
function cleanMergeTree(repo, left, right, base) {
|
|
227
|
+
try {
|
|
228
|
+
return sha(git(repo, [
|
|
229
|
+
'merge-tree',
|
|
230
|
+
'--write-tree',
|
|
231
|
+
...(base ? ['--merge-base', base] : []),
|
|
232
|
+
left,
|
|
233
|
+
right,
|
|
234
|
+
]).trim());
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
throw new BranchMaintenanceRefusal('MERGE_CONFLICT', error);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function executeGraphMaintenance(operation) {
|
|
241
|
+
const receipt = readReceipt(operation);
|
|
242
|
+
if (receipt?.phase === 'retired')
|
|
243
|
+
refuse('OPERATION_RETIRED');
|
|
244
|
+
const remote = fenceTarget(operation);
|
|
245
|
+
if (receipt?.resultHeadSha && receipt.phase === 'completed') {
|
|
246
|
+
if (remote !== operation.remoteHeadSha && remote !== receipt.resultHeadSha)
|
|
247
|
+
refuse('REMOTE_CHILD_CHANGED');
|
|
248
|
+
assertGraphResult(operation, receipt.resultHeadSha);
|
|
249
|
+
return receipt.resultHeadSha;
|
|
250
|
+
}
|
|
251
|
+
if (remote !== operation.remoteHeadSha)
|
|
252
|
+
refuse('REMOTE_CHILD_CHANGED');
|
|
253
|
+
assertSource(operation, remote);
|
|
254
|
+
const prepared = {
|
|
255
|
+
operationHash: operationHash(operation),
|
|
256
|
+
operation,
|
|
257
|
+
sourceHeadSha: remote,
|
|
258
|
+
replayHeadSha: remote,
|
|
259
|
+
boundarySha: operation.replayBoundarySha,
|
|
260
|
+
phase: 'prepared',
|
|
261
|
+
};
|
|
262
|
+
recordReceipt(operation, prepared);
|
|
263
|
+
let result = remote;
|
|
264
|
+
if (!ancestor(operation.repo, operation.targetSha, remote)) {
|
|
265
|
+
let tree;
|
|
266
|
+
try {
|
|
267
|
+
tree = cleanMergeTree(operation.repo, remote, operation.targetSha);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
throw new BranchMaintenanceRefusal('GRAPH_MERGE_CONFLICT', error);
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
result = sha(git(operation.repo, ['commit-tree', '-S', tree, '-p', remote, '-p', operation.targetSha], 'fix: merge the pinned publication base into the stacked branch\n').trim());
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
throw new BranchMaintenanceRefusal('GRAPH_SIGNING_REFUSED', error);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
assertGraphResult(operation, result);
|
|
280
|
+
const prefix = `refs/farmslot/branch-maintenance/${operationHash(operation)}`;
|
|
281
|
+
git(operation.repo, ['update-ref', `${prefix}/source`, remote]);
|
|
282
|
+
git(operation.repo, ['update-ref', `${prefix}/result`, result]);
|
|
283
|
+
recordReceipt(operation, { ...prepared, phase: 'completed', resultHeadSha: result });
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
export function executeBranchMaintenance(operation) {
|
|
287
|
+
if (operation.kind === 'graph-merge')
|
|
288
|
+
return executeGraphMaintenance(operation);
|
|
289
|
+
const repo = operation.repo;
|
|
290
|
+
let receipt = readReceipt(operation);
|
|
291
|
+
if (receipt?.phase === 'retired')
|
|
292
|
+
refuse('OPERATION_RETIRED');
|
|
293
|
+
const remote = fenceTarget(operation);
|
|
294
|
+
if (remote !== operation.remoteHeadSha && remote !== receipt?.resultHeadSha)
|
|
295
|
+
refuse('REMOTE_CHILD_CHANGED');
|
|
296
|
+
if (rebaseActive(repo))
|
|
297
|
+
refuse('RESOLVE_REBASE_CONFLICT');
|
|
298
|
+
assertCheckout(operation);
|
|
299
|
+
let head = sha(git(repo, ['rev-parse', 'HEAD']).trim());
|
|
300
|
+
if (receipt?.phase === 'completed') {
|
|
301
|
+
if (head !== receipt.resultHeadSha)
|
|
302
|
+
refuse('LOCAL_RESULT_CHANGED');
|
|
303
|
+
assertOwnedResult(operation, receipt, head);
|
|
304
|
+
return head;
|
|
305
|
+
}
|
|
306
|
+
if (!receipt)
|
|
307
|
+
receipt = prepareReceipt(operation, head);
|
|
308
|
+
if (receipt.phase === 'rebasing' &&
|
|
309
|
+
head !== receipt.replayHeadSha &&
|
|
310
|
+
head !== receipt.sourceHeadSha) {
|
|
311
|
+
const allAlreadyPresent = head === operation.targetSha &&
|
|
312
|
+
!git(repo, ['cherry', operation.targetSha, receipt.replayHeadSha, receipt.boundarySha])
|
|
313
|
+
.split('\n')
|
|
314
|
+
.some((line) => line.startsWith('+'));
|
|
315
|
+
if (head !== receipt.observedHeadSha && !allAlreadyPresent)
|
|
316
|
+
refuse('REBASE_RESULT_UNVERIFIED');
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
if (head !== receipt.sourceHeadSha && head !== receipt.replayHeadSha)
|
|
320
|
+
refuse('LOCAL_SOURCE_CHANGED');
|
|
321
|
+
receipt = { ...receipt, phase: 'rebasing' };
|
|
322
|
+
recordReceipt(operation, receipt);
|
|
323
|
+
if (head !== receipt.replayHeadSha)
|
|
324
|
+
git(repo, ['reset', '--soft', receipt.replayHeadSha]);
|
|
325
|
+
try {
|
|
326
|
+
git(repo, [
|
|
327
|
+
'rebase',
|
|
328
|
+
'--onto',
|
|
329
|
+
operation.targetSha,
|
|
330
|
+
receipt.boundarySha,
|
|
331
|
+
'--exec',
|
|
332
|
+
journalCommand(operation),
|
|
333
|
+
operation.branch,
|
|
334
|
+
]);
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (rebaseActive(repo))
|
|
338
|
+
throw new BranchMaintenanceRefusal('RESOLVE_REBASE_CONFLICT', error);
|
|
339
|
+
throw new BranchMaintenanceRefusal('REBASE_REFUSED', error);
|
|
340
|
+
}
|
|
341
|
+
head = sha(git(repo, ['rev-parse', 'HEAD']).trim());
|
|
342
|
+
receipt = readReceipt(operation);
|
|
343
|
+
}
|
|
344
|
+
assertOwnedResult(operation, receipt, head);
|
|
345
|
+
recordReceipt(operation, {
|
|
346
|
+
...receipt,
|
|
347
|
+
phase: 'completed',
|
|
348
|
+
rebaseResultSha: head,
|
|
349
|
+
resultHeadSha: head,
|
|
350
|
+
});
|
|
351
|
+
return head;
|
|
352
|
+
}
|
|
353
|
+
export function pushBranchMaintenance(operation) {
|
|
354
|
+
let receipt = readReceipt(operation);
|
|
355
|
+
const remote = fenceTarget(operation);
|
|
356
|
+
if (!receipt?.resultHeadSha || receipt.phase !== 'completed')
|
|
357
|
+
refuse('GUARDED_RESULT_MISSING');
|
|
358
|
+
if (operation.kind === 'rebase')
|
|
359
|
+
assertCheckout(operation);
|
|
360
|
+
const head = operation.kind === 'graph-merge'
|
|
361
|
+
? receipt.resultHeadSha
|
|
362
|
+
: sha(git(operation.repo, ['rev-parse', 'HEAD']).trim());
|
|
363
|
+
if (remote !== operation.remoteHeadSha && remote !== head)
|
|
364
|
+
refuse('REMOTE_CHILD_CHANGED');
|
|
365
|
+
if (head !== receipt.resultHeadSha) {
|
|
366
|
+
if (!ancestor(operation.repo, receipt.resultHeadSha, head) ||
|
|
367
|
+
git(operation.repo, ['rev-list', '--merges', `${receipt.resultHeadSha}..${head}`]))
|
|
368
|
+
refuse('LOCAL_RESULT_CHANGED');
|
|
369
|
+
// Inline fixes may append worker-owned commits after the guarded rebase.
|
|
370
|
+
// Bind that verified continuation before any remote write.
|
|
371
|
+
receipt = { ...receipt, resultHeadSha: head };
|
|
372
|
+
assertOwnedResult(operation, receipt, head);
|
|
373
|
+
recordReceipt(operation, receipt);
|
|
374
|
+
}
|
|
375
|
+
if (operation.kind === 'graph-merge')
|
|
376
|
+
assertGraphResult(operation, head);
|
|
377
|
+
else
|
|
378
|
+
assertOwnedResult(operation, receipt, head);
|
|
379
|
+
if (remote === head) {
|
|
380
|
+
recordReceipt(operation, { ...receipt, pushedHeadSha: head });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (remote !== operation.remoteHeadSha)
|
|
384
|
+
refuse('REMOTE_CHILD_CHANGED');
|
|
385
|
+
try {
|
|
386
|
+
git(operation.repo, [
|
|
387
|
+
'push',
|
|
388
|
+
'origin',
|
|
389
|
+
`${receipt.resultHeadSha}:refs/heads/${operation.branch}`,
|
|
390
|
+
`--force-with-lease=refs/heads/${operation.branch}:${operation.remoteHeadSha}`,
|
|
391
|
+
]);
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
throw new BranchMaintenanceRefusal('PUSH_REFUSED', error);
|
|
395
|
+
}
|
|
396
|
+
recordReceipt(operation, { ...receipt, pushedHeadSha: head });
|
|
397
|
+
}
|
|
398
|
+
export function verifyBranchMaintenance(operation) {
|
|
399
|
+
const receipt = readReceipt(operation);
|
|
400
|
+
fetchHeads(operation.repo, operation.branch, operation.baseRef);
|
|
401
|
+
const headSha = ref(operation.repo, operation.branch);
|
|
402
|
+
const targetChanged = ref(operation.repo, operation.baseRef) !== operation.targetSha;
|
|
403
|
+
if (operation.kind === 'graph-merge') {
|
|
404
|
+
if (headSha === operation.remoteHeadSha) {
|
|
405
|
+
if (targetChanged)
|
|
406
|
+
refuse('TARGET_CHANGED');
|
|
407
|
+
if (!ancestor(operation.repo, operation.targetSha, headSha))
|
|
408
|
+
refuse('GRAPH_UPDATE_PENDING');
|
|
409
|
+
}
|
|
410
|
+
assertGraphResult(operation, headSha);
|
|
411
|
+
return { headSha, replayBoundarySha: operation.replayBoundarySha };
|
|
412
|
+
}
|
|
413
|
+
if (!receipt?.resultHeadSha || receipt.phase !== 'completed') {
|
|
414
|
+
if (targetChanged)
|
|
415
|
+
refuse('TARGET_CHANGED');
|
|
416
|
+
refuse('GUARDED_RESULT_MISSING');
|
|
417
|
+
}
|
|
418
|
+
if (receipt.resultHeadSha !== headSha) {
|
|
419
|
+
if (targetChanged)
|
|
420
|
+
refuse('TARGET_CHANGED');
|
|
421
|
+
refuse('REMOTE_RESULT_CHANGED');
|
|
422
|
+
}
|
|
423
|
+
// Exact remote equality to a changed guarded result also recovers a crash
|
|
424
|
+
// after the leased push and before its local success mark. A no-op requires
|
|
425
|
+
// the shared push's explicit mark because its remote head never changed.
|
|
426
|
+
if (headSha === operation.remoteHeadSha && receipt.pushedHeadSha !== headSha) {
|
|
427
|
+
if (targetChanged)
|
|
428
|
+
refuse('TARGET_CHANGED');
|
|
429
|
+
refuse('RESULT_NOT_PUSHED');
|
|
430
|
+
}
|
|
431
|
+
assertOwnedResult(operation, receipt, headSha);
|
|
432
|
+
return { headSha, replayBoundarySha: operation.targetSha };
|
|
433
|
+
}
|
|
434
|
+
/** Inspect retained input without changing the checkout or replacing its pins. */
|
|
435
|
+
export function validateBranchMaintenance(operation) {
|
|
436
|
+
const receipt = readReceipt(operation);
|
|
437
|
+
if (receipt?.phase === 'retired')
|
|
438
|
+
return { status: 'stale', reason: 'OPERATION_RETIRED' };
|
|
439
|
+
fetchHeads(operation.repo, operation.branch, operation.baseRef);
|
|
440
|
+
const remote = ref(operation.repo, operation.branch);
|
|
441
|
+
if (operation.kind === 'graph-merge' &&
|
|
442
|
+
remote === operation.remoteHeadSha &&
|
|
443
|
+
ref(operation.repo, operation.baseRef) !== operation.targetSha)
|
|
444
|
+
return { status: 'stale', reason: 'TARGET_CHANGED' };
|
|
445
|
+
if (receipt?.phase === 'completed' &&
|
|
446
|
+
receipt.resultHeadSha === remote &&
|
|
447
|
+
(remote !== operation.remoteHeadSha || receipt.pushedHeadSha === remote)) {
|
|
448
|
+
verifyBranchMaintenance(operation);
|
|
449
|
+
return { status: 'pushed' };
|
|
450
|
+
}
|
|
451
|
+
if (ref(operation.repo, operation.baseRef) !== operation.targetSha)
|
|
452
|
+
return { status: 'stale', reason: 'TARGET_CHANGED' };
|
|
453
|
+
if (remote !== operation.remoteHeadSha)
|
|
454
|
+
return { status: 'stale', reason: 'REMOTE_CHILD_CHANGED' };
|
|
455
|
+
return { status: 'pending' };
|
|
456
|
+
}
|
|
457
|
+
function assertRecoveryCheckout(operation, recovery) {
|
|
458
|
+
const repo = operation.repo;
|
|
459
|
+
if (git(repo, ['branch', '--show-current']).trim() !== operation.branch)
|
|
460
|
+
refuse('BRANCH_CHANGED');
|
|
461
|
+
if (git(repo, ['ls-files', '--others', '--exclude-standard']))
|
|
462
|
+
refuse('UNCOMMITTED_CHANGES');
|
|
463
|
+
const matches = (head) => {
|
|
464
|
+
try {
|
|
465
|
+
git(repo, ['diff', '--quiet', head, '--']);
|
|
466
|
+
git(repo, ['diff', '--cached', '--quiet', head, '--']);
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
if (error.status === 1)
|
|
471
|
+
return false;
|
|
472
|
+
throw error;
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
if (!matches(recovery.previousHeadSha) && !matches(recovery.recoveredHeadSha))
|
|
476
|
+
refuse('UNCOMMITTED_CHANGES');
|
|
477
|
+
}
|
|
478
|
+
function retainReceiptHeads(operation, receipt) {
|
|
479
|
+
const prefix = `refs/farmslot/branch-maintenance/${operationHash(operation)}`;
|
|
480
|
+
const heads = {
|
|
481
|
+
source: receipt.sourceHeadSha,
|
|
482
|
+
replay: receipt.replayHeadSha,
|
|
483
|
+
result: receipt.resultHeadSha,
|
|
484
|
+
recovered: receipt.recovery?.recoveredHeadSha,
|
|
485
|
+
};
|
|
486
|
+
for (const [name, head] of Object.entries(heads))
|
|
487
|
+
if (head)
|
|
488
|
+
git(operation.repo, ['update-ref', `${prefix}/${name}`, head]);
|
|
489
|
+
}
|
|
490
|
+
/** Explicitly retire stale input. Preserve every source/result before local recovery. */
|
|
491
|
+
export function retireBranchMaintenance(operation, recoverLocal = false) {
|
|
492
|
+
let receipt = readReceipt(operation);
|
|
493
|
+
if (receipt?.phase === 'retired')
|
|
494
|
+
return { status: 'retired', recoveredHeadSha: receipt.recovery?.recoveredHeadSha };
|
|
495
|
+
const validation = validateBranchMaintenance(operation);
|
|
496
|
+
if (validation.status === 'pushed')
|
|
497
|
+
refuse('OPERATION_ALREADY_PUSHED');
|
|
498
|
+
const repo = operation.repo;
|
|
499
|
+
if (operation.kind === 'graph-merge') {
|
|
500
|
+
recordReceipt(operation, {
|
|
501
|
+
...(receipt ?? {
|
|
502
|
+
operationHash: operationHash(operation),
|
|
503
|
+
operation,
|
|
504
|
+
sourceHeadSha: operation.sourceHeadSha,
|
|
505
|
+
replayHeadSha: operation.sourceHeadSha,
|
|
506
|
+
boundarySha: operation.replayBoundarySha,
|
|
507
|
+
}),
|
|
508
|
+
phase: 'retired',
|
|
509
|
+
retiredReason: validation.reason ?? 'OPERATION_CANCELLED',
|
|
510
|
+
});
|
|
511
|
+
return { status: 'retired' };
|
|
512
|
+
}
|
|
513
|
+
if (!receipt) {
|
|
514
|
+
// Inspect can run before slot prepare. No local replay receipt means this
|
|
515
|
+
// operation has not changed that checkout, so cancellation needs no reset.
|
|
516
|
+
recordReceipt(operation, {
|
|
517
|
+
operationHash: operationHash(operation),
|
|
518
|
+
operation,
|
|
519
|
+
sourceHeadSha: operation.sourceHeadSha,
|
|
520
|
+
replayHeadSha: operation.sourceHeadSha,
|
|
521
|
+
boundarySha: operation.replayBoundarySha,
|
|
522
|
+
phase: 'retired',
|
|
523
|
+
retiredReason: validation.reason ?? 'OPERATION_CANCELLED',
|
|
524
|
+
});
|
|
525
|
+
return { status: 'retired' };
|
|
526
|
+
}
|
|
527
|
+
if (rebaseActive(repo))
|
|
528
|
+
refuse('RESOLVE_REBASE_CONFLICT');
|
|
529
|
+
const currentHead = sha(git(repo, ['rev-parse', 'HEAD']).trim());
|
|
530
|
+
const currentRemote = ref(repo, operation.branch);
|
|
531
|
+
if (currentHead === currentRemote &&
|
|
532
|
+
(currentRemote !== operation.remoteHeadSha ||
|
|
533
|
+
(receipt.phase === 'completed' && currentHead !== receipt.resultHeadSha && !receipt.recovery))) {
|
|
534
|
+
// Prepare replaced this checkout with verified remote content. Retain the
|
|
535
|
+
// abandoned attempt, then start fresh without rewriting any current work.
|
|
536
|
+
assertCheckout(operation);
|
|
537
|
+
retainReceiptHeads(operation, receipt);
|
|
538
|
+
recordReceipt(operation, {
|
|
539
|
+
...receipt,
|
|
540
|
+
phase: 'retired',
|
|
541
|
+
retiredReason: 'CHECKOUT_REPREPARED',
|
|
542
|
+
});
|
|
543
|
+
return { status: 'retired' };
|
|
544
|
+
}
|
|
545
|
+
if (receipt?.recovery)
|
|
546
|
+
assertRecoveryCheckout(operation, receipt.recovery);
|
|
547
|
+
else
|
|
548
|
+
assertCheckout(operation);
|
|
549
|
+
const head = sha(git(repo, ['rev-parse', 'HEAD']).trim());
|
|
550
|
+
if (head === receipt.sourceHeadSha && !receipt.recovery) {
|
|
551
|
+
recordReceipt(operation, {
|
|
552
|
+
...receipt,
|
|
553
|
+
phase: 'retired',
|
|
554
|
+
retiredReason: validation.reason ?? 'OPERATION_CANCELLED',
|
|
555
|
+
});
|
|
556
|
+
return { status: 'retired' };
|
|
557
|
+
}
|
|
558
|
+
if (ref(repo, operation.branch) !== operation.remoteHeadSha)
|
|
559
|
+
refuse('REMOTE_CHILD_CHANGED');
|
|
560
|
+
if (!recoverLocal)
|
|
561
|
+
refuse('LOCAL_RECOVERY_REQUIRED');
|
|
562
|
+
let recovery = receipt.recovery;
|
|
563
|
+
if (!recovery) {
|
|
564
|
+
const originalTree = git(repo, ['rev-parse', `${receipt.sourceHeadSha}^{tree}`]).trim();
|
|
565
|
+
const unstartedReplay = receipt.phase !== 'completed' &&
|
|
566
|
+
head === receipt.replayHeadSha &&
|
|
567
|
+
git(repo, ['rev-parse', `${head}^{tree}`]).trim() === originalTree;
|
|
568
|
+
let recoveredHeadSha = receipt.sourceHeadSha;
|
|
569
|
+
if (!unstartedReplay) {
|
|
570
|
+
if (receipt.phase === 'rebasing' && receipt.observedHeadSha === head) {
|
|
571
|
+
assertOwnedResult(operation, receipt, head);
|
|
572
|
+
receipt = { ...receipt, phase: 'completed', resultHeadSha: head, rebaseResultSha: head };
|
|
573
|
+
recordReceipt(operation, receipt);
|
|
574
|
+
}
|
|
575
|
+
if (receipt.phase !== 'completed' || !receipt.resultHeadSha)
|
|
576
|
+
refuse('REBASE_RESULT_UNVERIFIED');
|
|
577
|
+
if (!ancestor(repo, receipt.resultHeadSha, head) ||
|
|
578
|
+
git(repo, ['rev-list', '--merges', `${receipt.resultHeadSha}..${head}`]))
|
|
579
|
+
refuse('LOCAL_RESULT_CHANGED');
|
|
580
|
+
const continued = { ...receipt, resultHeadSha: head };
|
|
581
|
+
assertOwnedResult(operation, continued, head);
|
|
582
|
+
// Apply only the recorded target-to-child delta onto the old verified
|
|
583
|
+
// comparison. Inherited target bytes disappear; child work appears once.
|
|
584
|
+
const guardedHead = receipt.rebaseResultSha ?? receipt.resultHeadSha;
|
|
585
|
+
const tree = cleanMergeTree(repo, operation.comparisonBaseSha, guardedHead, operation.targetSha);
|
|
586
|
+
if (tree !== originalTree) {
|
|
587
|
+
const signing = git(repo, [
|
|
588
|
+
'config',
|
|
589
|
+
'--type=bool',
|
|
590
|
+
'--default=false',
|
|
591
|
+
'--get',
|
|
592
|
+
'commit.gpgsign',
|
|
593
|
+
]).trim() === 'true';
|
|
594
|
+
recoveredHeadSha = sha(git(repo, ['commit-tree', tree, '-p', receipt.sourceHeadSha, ...(signing ? ['-S'] : [])], 'fix: retain child resolutions while retiring stale branch maintenance\n').trim());
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (!unstartedReplay) {
|
|
598
|
+
const guardedHead = receipt.rebaseResultSha ?? receipt.resultHeadSha;
|
|
599
|
+
for (const commit of git(repo, ['rev-list', '--reverse', `${guardedHead}..${head}`])
|
|
600
|
+
.split('\n')
|
|
601
|
+
.filter(Boolean)) {
|
|
602
|
+
const parent = sha(git(repo, ['rev-parse', `${commit}^`]).trim());
|
|
603
|
+
const tree = cleanMergeTree(repo, recoveredHeadSha, commit, parent);
|
|
604
|
+
const [name, email, date] = git(repo, [
|
|
605
|
+
'show',
|
|
606
|
+
'-s',
|
|
607
|
+
'--format=%an%x00%ae%x00%aI',
|
|
608
|
+
commit,
|
|
609
|
+
]).split('\0');
|
|
610
|
+
const signing = git(repo, [
|
|
611
|
+
'config',
|
|
612
|
+
'--type=bool',
|
|
613
|
+
'--default=false',
|
|
614
|
+
'--get',
|
|
615
|
+
'commit.gpgsign',
|
|
616
|
+
]).trim() === 'true';
|
|
617
|
+
recoveredHeadSha = sha(git(repo, ['commit-tree', tree, '-p', recoveredHeadSha, ...(signing ? ['-S'] : [])], git(repo, ['show', '-s', '--format=%B', commit]) + '\n', { GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date }).trim());
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
recovery = {
|
|
621
|
+
previousHeadSha: head,
|
|
622
|
+
recoveredHeadSha,
|
|
623
|
+
comparisonBaseSha: operation.comparisonBaseSha,
|
|
624
|
+
targetSha: operation.targetSha,
|
|
625
|
+
};
|
|
626
|
+
const prefix = `refs/farmslot/branch-maintenance/${operationHash(operation)}`;
|
|
627
|
+
git(repo, ['update-ref', `${prefix}/source`, receipt.sourceHeadSha]);
|
|
628
|
+
git(repo, ['update-ref', `${prefix}/result`, head]);
|
|
629
|
+
git(repo, ['update-ref', `${prefix}/recovered`, recoveredHeadSha]);
|
|
630
|
+
receipt = { ...receipt, recovery };
|
|
631
|
+
recordReceipt(operation, receipt);
|
|
632
|
+
}
|
|
633
|
+
fetchHeads(repo, operation.branch, operation.baseRef);
|
|
634
|
+
if (ref(repo, operation.branch) !== operation.remoteHeadSha)
|
|
635
|
+
refuse('REMOTE_CHILD_CHANGED');
|
|
636
|
+
if (head !== recovery.previousHeadSha && head !== recovery.recoveredHeadSha)
|
|
637
|
+
refuse('LOCAL_RESULT_CHANGED');
|
|
638
|
+
if (head === recovery.previousHeadSha) {
|
|
639
|
+
git(repo, [
|
|
640
|
+
'update-ref',
|
|
641
|
+
`refs/heads/${operation.branch}`,
|
|
642
|
+
recovery.recoveredHeadSha,
|
|
643
|
+
recovery.previousHeadSha,
|
|
644
|
+
]);
|
|
645
|
+
// retire is the explicit recovery action; both old and new trees are retained.
|
|
646
|
+
}
|
|
647
|
+
git(repo, ['reset', '--hard', recovery.recoveredHeadSha]);
|
|
648
|
+
recordReceipt(operation, {
|
|
649
|
+
...receipt,
|
|
650
|
+
phase: 'retired',
|
|
651
|
+
retiredReason: validation.reason ?? 'OPERATION_CANCELLED',
|
|
652
|
+
});
|
|
653
|
+
return { status: 'retired', recoveredHeadSha: recovery.recoveredHeadSha };
|
|
654
|
+
}
|
|
655
|
+
function ordinaryIdentity(request) {
|
|
656
|
+
return {
|
|
657
|
+
repo: request.repo,
|
|
658
|
+
id: `ordinary-${digest(JSON.stringify([path.resolve(request.repo), normalizeMaintenanceBranch(request.branch), normalizeMaintenanceBranch(request.baseRef)]))}`,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
export function executeOrdinaryBranchMaintenance(request) {
|
|
662
|
+
const identity = ordinaryIdentity(request);
|
|
663
|
+
const file = receiptPath(identity);
|
|
664
|
+
if (existsSync(file)) {
|
|
665
|
+
const saved = JSON.parse(readFileSync(file, 'utf8'));
|
|
666
|
+
if (saved.operation.id !== identity.id || saved.operation.repo !== identity.repo)
|
|
667
|
+
refuse('OPERATION_CHANGED');
|
|
668
|
+
fetchHeads(request.repo, saved.operation.branch, saved.operation.baseRef);
|
|
669
|
+
const remote = ref(request.repo, saved.operation.branch);
|
|
670
|
+
const head = sha(git(request.repo, ['rev-parse', 'HEAD']).trim());
|
|
671
|
+
const published = saved.phase === 'completed' &&
|
|
672
|
+
!!saved.resultHeadSha &&
|
|
673
|
+
(saved.pushedHeadSha === saved.resultHeadSha ||
|
|
674
|
+
(remote === saved.resultHeadSha && remote !== saved.operation.remoteHeadSha));
|
|
675
|
+
if (published) {
|
|
676
|
+
retainReceiptHeads(saved.operation, saved);
|
|
677
|
+
if (!ancestor(request.repo, remote, head))
|
|
678
|
+
refuse('REMOTE_CHILD_COMMITS_MISSING');
|
|
679
|
+
if (remote === saved.resultHeadSha &&
|
|
680
|
+
head === remote &&
|
|
681
|
+
ref(request.repo, saved.operation.baseRef) === saved.operation.targetSha)
|
|
682
|
+
return executeBranchMaintenance(saved.operation);
|
|
683
|
+
// A completed published attempt no longer binds a later child's lease.
|
|
684
|
+
}
|
|
685
|
+
else if (saved.phase !== 'retired') {
|
|
686
|
+
const validation = validateBranchMaintenance(saved.operation);
|
|
687
|
+
if (validation.status === 'pending' &&
|
|
688
|
+
!(saved.phase === 'completed' && head === remote && head !== saved.resultHeadSha))
|
|
689
|
+
return executeBranchMaintenance(saved.operation);
|
|
690
|
+
if (validation.status === 'pushed')
|
|
691
|
+
return saved.resultHeadSha;
|
|
692
|
+
retireBranchMaintenance(saved.operation, true);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const branch = normalizeMaintenanceBranch(request.branch);
|
|
696
|
+
const baseRef = normalizeMaintenanceBranch(request.baseRef);
|
|
697
|
+
fetchHeads(request.repo, branch, baseRef);
|
|
698
|
+
const head = sha(git(request.repo, ['rev-parse', 'HEAD']).trim());
|
|
699
|
+
const boundary = sha(git(request.repo, ['merge-base', ref(request.repo, baseRef), head]).trim());
|
|
700
|
+
const operation = inspectBranchMaintenance({
|
|
701
|
+
...identity,
|
|
702
|
+
ownerRunId: 'ordinary',
|
|
703
|
+
project: '',
|
|
704
|
+
branch: request.branch,
|
|
705
|
+
baseRef: request.baseRef,
|
|
706
|
+
kind: 'rebase',
|
|
707
|
+
authority: {
|
|
708
|
+
headSha: ref(request.repo, branch),
|
|
709
|
+
comparisonBaseSha: boundary,
|
|
710
|
+
replayBoundarySha: boundary,
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
operation.sourceHeadSha = head;
|
|
714
|
+
assertCheckout(operation);
|
|
715
|
+
assertSource(operation, head);
|
|
716
|
+
prepareReceipt(operation, head);
|
|
717
|
+
return executeBranchMaintenance(operation);
|
|
718
|
+
}
|
|
719
|
+
export function pushOrdinaryBranchMaintenance(request) {
|
|
720
|
+
const file = receiptPath(ordinaryIdentity(request));
|
|
721
|
+
if (!existsSync(file))
|
|
722
|
+
refuse('GUARDED_RESULT_MISSING');
|
|
723
|
+
const saved = JSON.parse(readFileSync(file, 'utf8'));
|
|
724
|
+
if (saved.operation.id !== ordinaryIdentity(request).id || saved.operation.repo !== request.repo)
|
|
725
|
+
refuse('OPERATION_CHANGED');
|
|
726
|
+
pushBranchMaintenance(saved.operation);
|
|
727
|
+
}
|
|
728
|
+
//# sourceMappingURL=branch-update.js.map
|