@teambit/ci 1.0.493 → 1.0.495
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/dist/{preview-1786365057144.js → preview-1786461108763.js} +1 -1
- package/dist/sync/git-ops.d.ts +41 -0
- package/dist/sync/git-ops.js +71 -0
- package/dist/sync/git-ops.js.map +1 -1
- package/dist/sync/git-ops.spec.js +39 -0
- package/dist/sync/git-ops.spec.js.map +1 -1
- package/dist/sync/lane-sync-executor.d.ts +26 -2
- package/dist/sync/lane-sync-executor.js +121 -15
- package/dist/sync/lane-sync-executor.js.map +1 -1
- package/dist/sync/lane-sync-executor.spec.js +111 -13
- package/dist/sync/lane-sync-executor.spec.js.map +1 -1
- package/dist/sync/main-sync-executor.js +30 -6
- package/dist/sync/main-sync-executor.js.map +1 -1
- package/package.json +14 -14
- package/sync/git-ops.spec.ts +60 -0
- package/sync/git-ops.ts +72 -0
- package/sync/lane-sync-executor.spec.ts +108 -9
- package/sync/lane-sync-executor.ts +122 -17
- package/sync/main-sync-executor.ts +37 -5
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teambit/ci",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.495",
|
|
4
4
|
"homepage": "https://bit.cloud/teambit/git/ci",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"componentId": {
|
|
7
7
|
"scope": "teambit.git",
|
|
8
8
|
"name": "ci",
|
|
9
|
-
"version": "1.0.
|
|
9
|
+
"version": "1.0.495"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"chalk": "4.1.2",
|
|
@@ -17,30 +17,30 @@
|
|
|
17
17
|
"@teambit/harmony": "0.4.12",
|
|
18
18
|
"@teambit/cli": "0.0.1364",
|
|
19
19
|
"@teambit/logger": "0.0.1457",
|
|
20
|
+
"@teambit/workspace": "1.0.1099",
|
|
21
|
+
"@teambit/builder": "1.0.1099",
|
|
22
|
+
"@teambit/checkout": "1.0.1100",
|
|
20
23
|
"@teambit/component-id": "1.2.4",
|
|
21
24
|
"@teambit/component.modules.merge-helper": "0.0.88",
|
|
22
25
|
"@teambit/component.snap-distance": "0.0.146",
|
|
26
|
+
"@teambit/config-merger": "0.0.966",
|
|
27
|
+
"@teambit/dependency-resolver": "1.0.1099",
|
|
28
|
+
"@teambit/export": "1.0.1099",
|
|
23
29
|
"@teambit/harmony.modules.concurrency": "0.0.42",
|
|
30
|
+
"@teambit/importer": "1.0.1099",
|
|
24
31
|
"@teambit/lane-id": "0.0.312",
|
|
32
|
+
"@teambit/lanes": "1.0.1120",
|
|
25
33
|
"@teambit/legacy.consumer-component": "0.0.146",
|
|
34
|
+
"@teambit/objects": "0.0.606",
|
|
35
|
+
"@teambit/snapping": "1.0.1099",
|
|
36
|
+
"@teambit/status": "1.0.1124",
|
|
26
37
|
"@teambit/toolbox.promise.map-pool": "0.0.19",
|
|
27
38
|
"@teambit/toolbox.string.random": "0.0.18",
|
|
28
39
|
"@teambit/bit-error": "0.0.404",
|
|
29
40
|
"@teambit/legacy.constants": "0.0.41",
|
|
30
41
|
"@teambit/legacy.scope": "0.0.145",
|
|
31
42
|
"@teambit/legacy.bit-map": "0.0.202",
|
|
32
|
-
"@teambit/toolbox.crypto.sha1": "0.0.20"
|
|
33
|
-
"@teambit/workspace": "1.0.1098",
|
|
34
|
-
"@teambit/builder": "1.0.1098",
|
|
35
|
-
"@teambit/checkout": "1.0.1099",
|
|
36
|
-
"@teambit/config-merger": "0.0.965",
|
|
37
|
-
"@teambit/dependency-resolver": "1.0.1098",
|
|
38
|
-
"@teambit/export": "1.0.1098",
|
|
39
|
-
"@teambit/importer": "1.0.1098",
|
|
40
|
-
"@teambit/lanes": "1.0.1119",
|
|
41
|
-
"@teambit/objects": "0.0.605",
|
|
42
|
-
"@teambit/snapping": "1.0.1098",
|
|
43
|
-
"@teambit/status": "1.0.1123"
|
|
43
|
+
"@teambit/toolbox.crypto.sha1": "0.0.20"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/lodash": "4.14.165",
|
package/sync/git-ops.spec.ts
CHANGED
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
DEFAULT_GIT_USER_NAME,
|
|
15
15
|
fetchRemoteHeads,
|
|
16
16
|
isNonContentPath,
|
|
17
|
+
isNonFastForwardRejection,
|
|
17
18
|
isStaleLeaseRejection,
|
|
19
|
+
redactUrlCredentials,
|
|
18
20
|
parseLsRemoteSymref,
|
|
19
21
|
parseOriginHeadRef,
|
|
20
22
|
refetchBranchTip,
|
|
@@ -228,6 +230,64 @@ describe('deleteBranchArgs', () => {
|
|
|
228
230
|
});
|
|
229
231
|
});
|
|
230
232
|
|
|
233
|
+
describe('isNonFastForwardRejection', () => {
|
|
234
|
+
// The GitHub-style client-side check, in the shape this code actually receives: simple-git appends
|
|
235
|
+
// `--porcelain` to every push, so the rejected ref arrives as a TAB-separated status line, not the
|
|
236
|
+
// human `! [rejected]` wording.
|
|
237
|
+
const CLIENT_SIDE_REJECTION =
|
|
238
|
+
'!\tHEAD:refs/heads/feature\t[rejected] (fetch first)\n' +
|
|
239
|
+
"error: failed to push some refs to '/remote'\n" +
|
|
240
|
+
'hint: Updates were rejected because the remote contains work that you do\n' +
|
|
241
|
+
'hint: not have locally.';
|
|
242
|
+
|
|
243
|
+
// The server-side ref-lock check: a plain push racing a concurrent ref update on the local/file
|
|
244
|
+
// transport surfaces the SAME wording as a lease refusal, with no lease involved (reproduced in
|
|
245
|
+
// ci-sync.e2e.ts's "a rejected sync-commit push" test).
|
|
246
|
+
const SERVER_SIDE_REJECTION =
|
|
247
|
+
'!\tHEAD:refs/heads/import-race-lane\t[remote rejected] (failed to update ref)\n' +
|
|
248
|
+
"remote: error: cannot lock ref 'refs/heads/import-race-lane': is at abc123 but expected def456\n" +
|
|
249
|
+
"error: failed to push some refs to '/remote'";
|
|
250
|
+
|
|
251
|
+
it('recognizes the client-side non-fast-forward wording, porcelain and human', () => {
|
|
252
|
+
expect(isNonFastForwardRejection(CLIENT_SIDE_REJECTION)).to.equal(true);
|
|
253
|
+
expect(isNonFastForwardRejection('!\tmain:refs/heads/main\t[rejected] (non-fast-forward)')).to.equal(true);
|
|
254
|
+
// the human layout (no --porcelain) still matches — the token test is layout-independent
|
|
255
|
+
expect(isNonFastForwardRejection('! [rejected] main -> main (non-fast-forward)')).to.equal(true);
|
|
256
|
+
expect(isNonFastForwardRejection('To /remote\n ! [rejected] HEAD -> feature (fetch first)')).to.equal(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('recognizes the server-side ref-lock wording a plain push can also hit', () => {
|
|
260
|
+
expect(isNonFastForwardRejection(SERVER_SIDE_REJECTION)).to.equal(true);
|
|
261
|
+
// Same wording `isStaleLeaseRejection` matches — a plain push can legitimately race into it too.
|
|
262
|
+
expect(isNonFastForwardRejection('! [rejected] (delete) -> my-lane (stale info)')).to.equal(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('does not mistake a server-side hook decline for a race', () => {
|
|
266
|
+
// `[remote rejected]` alone (no ref-lock/fetch-first wording) — a protected-branch/hook decline,
|
|
267
|
+
// a real problem to surface, not a race to swallow.
|
|
268
|
+
expect(isNonFastForwardRejection('! [remote rejected] main -> main (protected branch hook declined)')).to.equal(
|
|
269
|
+
false
|
|
270
|
+
);
|
|
271
|
+
expect(
|
|
272
|
+
isNonFastForwardRejection('!\tHEAD:refs/heads/main\t[remote rejected] (pre-receive hook declined)')
|
|
273
|
+
).to.equal(false);
|
|
274
|
+
expect(isNonFastForwardRejection('remote: GH006: Protected branch update failed')).to.equal(false);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('redactUrlCredentials', () => {
|
|
279
|
+
it('strips embedded credentials from a push-error remote URL', () => {
|
|
280
|
+
expect(
|
|
281
|
+
redactUrlCredentials("error: failed to push some refs to 'https://x-access-token:ghs_abc123@github.com/o/r'")
|
|
282
|
+
).to.equal("error: failed to push some refs to 'https://***@github.com/o/r'");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('leaves a credential-free message unchanged', () => {
|
|
286
|
+
const message = "error: failed to push some refs to 'https://github.com/o/r'\n ! [rejected] (fetch first)";
|
|
287
|
+
expect(redactUrlCredentials(message)).to.equal(message);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
231
291
|
describe('isNonContentPath', () => {
|
|
232
292
|
it('covers the two paths the executors never treat as workspace content, and nothing adjacent', () => {
|
|
233
293
|
['.bit', '.bit/objects/ab/cdef', 'node_modules/lodash/index.js'].forEach((p) =>
|
package/sync/git-ops.ts
CHANGED
|
@@ -178,6 +178,78 @@ export function isStaleLeaseRejection(message: string): boolean {
|
|
|
178
178
|
return /stale info|force-with-lease|but expected|incorrect old value/i.test(message);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Whether a failed push was a plain non-fast-forward rejection: a concurrent run already advanced
|
|
183
|
+
* `branch`, so ours is now stale. `commitAllAndPush` never uses `--force-with-lease`, yet a race can
|
|
184
|
+
* still surface the SAME wording `isStaleLeaseRejection` matches: git's ref-update transaction rejects
|
|
185
|
+
* an ordinary push exactly like a lease refusal once the remote's ref changed underneath it (confirmed
|
|
186
|
+
* empirically on the local/file transport this repo's e2e remotes use — "cannot lock ref … is at X but
|
|
187
|
+
* expected Y" — no lease involved). A GitHub-style remote instead rejects the ref with a
|
|
188
|
+
* `[rejected] … (fetch first)` or `(non-fast-forward)` line. That line's layout varies: simple-git
|
|
189
|
+
* appends `--porcelain` to every push, so the real shape is tab-separated
|
|
190
|
+
* (`!\tHEAD:refs/heads/x\t[rejected] (fetch first)`), NOT the human `! [rejected] …` — match the
|
|
191
|
+
* `[rejected]` token plus the reason, never the whitespace around them. `[remote rejected]` (a server
|
|
192
|
+
* hook decline, e.g. a protected branch) does not contain the bare `[rejected]` token and is excluded:
|
|
193
|
+
* that is a real problem to surface, not a race to swallow.
|
|
194
|
+
*/
|
|
195
|
+
export function isNonFastForwardRejection(message: string): boolean {
|
|
196
|
+
if (isStaleLeaseRejection(message)) return true;
|
|
197
|
+
return /\[rejected\]/.test(message) && /fetch first|non-fast-forward/i.test(message);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether a rejected plain push was a CONFIRMED race: the wording matches a non-fast-forward rejection
|
|
202
|
+
* AND a re-fetch shows `branch` actually moved off `baseSha` — the tip the rejected push was based on.
|
|
203
|
+
* The wording alone is not proof: the regexes are deliberately broad, so a PERSISTENT, unrelated push
|
|
204
|
+
* failure (bad credentials, a flaky transport) could repeat the same wording forever; unconfirmed
|
|
205
|
+
* answers 'not-a-race' and the caller fails loud. A failed re-fetch (`undefined` tip) is UNKNOWN, not
|
|
206
|
+
* confirmation — never treat not knowing as proof of a race. The one `remoteMoved` expression also
|
|
207
|
+
* covers a brand-new branch (`baseSha === undefined`): a defined tip already proves the branch now
|
|
208
|
+
* exists on the remote. `refetchTip` is injectable so callers stay unit-testable without a real remote.
|
|
209
|
+
*/
|
|
210
|
+
export async function confirmPushRace(
|
|
211
|
+
branch: string,
|
|
212
|
+
baseSha: string | undefined,
|
|
213
|
+
message: string,
|
|
214
|
+
refetchTip: (branch: string) => Promise<string | undefined> = refetchBranchTip
|
|
215
|
+
): Promise<'confirmed-race' | 'not-a-race'> {
|
|
216
|
+
if (!isNonFastForwardRejection(message)) return 'not-a-race';
|
|
217
|
+
const currentTip = await refetchTip(branch);
|
|
218
|
+
const remoteMoved = currentTip !== undefined && currentTip !== baseSha;
|
|
219
|
+
return remoteMoved ? 'confirmed-race' : 'not-a-race';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Strip URL userinfo (`https://user:token@host` → `https://***@host`) from a git message before it
|
|
224
|
+
* reaches a log or a summary: git prints the remote URL in push errors, and a remote may embed
|
|
225
|
+
* credentials. Over-redaction is the safe direction — `ssh://git@host` loses nothing that matters.
|
|
226
|
+
*/
|
|
227
|
+
export function redactUrlCredentials(text: string): string {
|
|
228
|
+
return text.replace(/\/\/[^\s@/]+@/g, '//***@');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Move the local `branch` back to `origin/<branch>`, dropping unpushed commits — the cleanup after a
|
|
233
|
+
* lost push race, so the orphan commit cannot trip `checkoutPristine`'s guard on the clone's next run.
|
|
234
|
+
* `reset --hard` writes the index, so a stale `index.lock` blocks it; the `update-ref` fallback does
|
|
235
|
+
* not touch the index and still moves the ref off the orphan commit (the callers force-clean the tree
|
|
236
|
+
* afterwards, in `restoreWorkspace`). Returns the failure message instead of throwing: the drop is
|
|
237
|
+
* best-effort, and a failed drop must not escalate a confirmed-benign race.
|
|
238
|
+
*/
|
|
239
|
+
export async function dropLocalBranchToRemoteTip(branch: string): Promise<string | undefined> {
|
|
240
|
+
try {
|
|
241
|
+
await git.raw(['reset', '--hard', `origin/${branch}`]);
|
|
242
|
+
return undefined;
|
|
243
|
+
} catch (resetErr: any) {
|
|
244
|
+
try {
|
|
245
|
+
await git.raw(['update-ref', `refs/heads/${branch}`, `refs/remotes/origin/${branch}`]);
|
|
246
|
+
return undefined;
|
|
247
|
+
} catch {
|
|
248
|
+
return String(resetErr?.message || resetErr);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
181
253
|
/** Whether `origin` has the given branch. Assumes a `git fetch` isn't required (uses `ls-remote`). */
|
|
182
254
|
export async function branchExistsOnRemote(branch: string): Promise<boolean> {
|
|
183
255
|
const out = await git.raw(['ls-remote', '--heads', 'origin', branch]);
|
|
@@ -13,9 +13,27 @@ import {
|
|
|
13
13
|
laneHeadFingerprint,
|
|
14
14
|
LaneSyncExecutor,
|
|
15
15
|
laneSyncPrBody,
|
|
16
|
+
racedLedgerPushSummary,
|
|
16
17
|
} from './lane-sync-executor';
|
|
17
18
|
import { resolveSyncConfig } from './sync-config';
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* One stub executor for every seam-based test: real executor code over stubbed deps, no repository.
|
|
22
|
+
* `warnings` collects `consoleWarning` lines for tests that assert on them.
|
|
23
|
+
*/
|
|
24
|
+
function stubExecutor(): { executor: LaneSyncExecutor; warnings: string[] } {
|
|
25
|
+
const warnings: string[] = [];
|
|
26
|
+
const executor = new LaneSyncExecutor({
|
|
27
|
+
lanes: {} as any,
|
|
28
|
+
ci: {} as any,
|
|
29
|
+
logger: { console: () => {}, consoleWarning: (msg: string) => warnings.push(msg), error: () => {} } as any,
|
|
30
|
+
gitHost: undefined,
|
|
31
|
+
cfg: resolveSyncConfig({}),
|
|
32
|
+
defaultScope: 'acme.shop',
|
|
33
|
+
});
|
|
34
|
+
return { executor, warnings };
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
type LaneComponents = Parameters<typeof laneHeadFingerprint>[0];
|
|
20
38
|
|
|
21
39
|
/** A stand-in for `LaneData`'s component entry; the helpers only read the id string, scope and head. */
|
|
@@ -314,16 +332,8 @@ describe('close-pr re-verifies the tip before retiring a branch', () => {
|
|
|
314
332
|
const MOVED_SHA = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2';
|
|
315
333
|
|
|
316
334
|
function retirer(currentTip: string | undefined, pushError?: Error) {
|
|
317
|
-
const warnings
|
|
335
|
+
const { executor, warnings } = stubExecutor();
|
|
318
336
|
const pushes: string[][] = [];
|
|
319
|
-
const executor = new LaneSyncExecutor({
|
|
320
|
-
lanes: {} as any,
|
|
321
|
-
ci: {} as any,
|
|
322
|
-
logger: { console: () => {}, consoleWarning: (msg: string) => warnings.push(msg), error: () => {} } as any,
|
|
323
|
-
gitHost: undefined,
|
|
324
|
-
cfg: resolveSyncConfig({}),
|
|
325
|
-
defaultScope: 'acme.shop',
|
|
326
|
-
});
|
|
327
337
|
(executor as any).currentBranchTip = async () => currentTip;
|
|
328
338
|
(executor as any).pushBranchDeletion = async (branch: string, sha: string) => {
|
|
329
339
|
pushes.push([branch, sha]);
|
|
@@ -377,6 +387,95 @@ describe('close-pr re-verifies the tip before retiring a branch', () => {
|
|
|
377
387
|
});
|
|
378
388
|
});
|
|
379
389
|
|
|
390
|
+
describe('classifyPushRejection confirms a race before calling it one', () => {
|
|
391
|
+
const BASE_SHA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1';
|
|
392
|
+
const MOVED_SHA = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2';
|
|
393
|
+
const REJECTION = new Error('! [rejected] HEAD -> feature (fetch first)\nerror: failed to push some refs');
|
|
394
|
+
|
|
395
|
+
function classifier(currentTip: string | undefined) {
|
|
396
|
+
const { executor } = stubExecutor();
|
|
397
|
+
(executor as any).currentBranchTip = async () => currentTip;
|
|
398
|
+
return (baseSha: string | undefined, e: Error) =>
|
|
399
|
+
(executor as any).classifyPushRejection('my-branch', baseSha, e) as Promise<'confirmed-race' | 'not-a-race'>;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
it('confirms the race when the branch actually moved off the base we pushed against', async () => {
|
|
403
|
+
const classify = classifier(MOVED_SHA);
|
|
404
|
+
expect(await classify(BASE_SHA, REJECTION)).to.equal('confirmed-race');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('confirms the race for a brand-new branch that now exists on the remote', async () => {
|
|
408
|
+
const classify = classifier(MOVED_SHA);
|
|
409
|
+
expect(await classify(undefined, REJECTION)).to.equal('confirmed-race');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Message-text matching alone is not proof: a persistent, unrelated rejection could repeat the same
|
|
413
|
+
// wording forever. Without this re-fetch-and-compare, that would go permanently green.
|
|
414
|
+
it('answers not-a-race when the branch did NOT move — the wording matched but nothing raced', async () => {
|
|
415
|
+
const classify = classifier(BASE_SHA);
|
|
416
|
+
expect(await classify(BASE_SHA, REJECTION)).to.equal('not-a-race');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('answers not-a-race when a brand-new branch still does not exist on the remote', async () => {
|
|
420
|
+
const classify = classifier(undefined);
|
|
421
|
+
expect(await classify(undefined, REJECTION)).to.equal('not-a-race');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// The polarity bug this guards: `currentTip === undefined` here means the re-fetch FAILED (network
|
|
425
|
+
// blip, auth hiccup) — unknown, not evidence the branch moved. `undefined !== baseSha` reads as
|
|
426
|
+
// truthy if compared naively, which would misreport an unanswerable check as a confirmed race and
|
|
427
|
+
// paper over a real, persistent failure. Withhold like every other unknown-answer path in this file.
|
|
428
|
+
it('answers not-a-race when the re-fetch itself fails, even though a base sha is known', async () => {
|
|
429
|
+
const classify = classifier(undefined);
|
|
430
|
+
expect(await classify(BASE_SHA, REJECTION)).to.equal('not-a-race');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('answers not-a-race for a rejection whose wording is not a non-fast-forward race at all', async () => {
|
|
434
|
+
const classify = classifier(MOVED_SHA);
|
|
435
|
+
const unrelated = new Error('remote: GH006: Protected branch update failed');
|
|
436
|
+
expect(await classify(BASE_SHA, unrelated)).to.equal('not-a-race');
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// The live incident's actual shape: the snap/export onto the lane already succeeded — the lane truly
|
|
441
|
+
// moved — and only the branch's OWN ledger-commit push (recordLaneHeadOnBranch) lost the race. Nothing
|
|
442
|
+
// exercised `racedLedgerPushSummary` before this; a wording regression there would have gone unnoticed.
|
|
443
|
+
describe('executeExportBranch reports the ledger-race wording when only the ledger push races', () => {
|
|
444
|
+
it('surfaces "lane updated; branch ledger commit lost the push race" without halting', async () => {
|
|
445
|
+
const { executor } = stubExecutor();
|
|
446
|
+
(executor as any).lastNonSyncCommitMessage = async () => 'feat: some change';
|
|
447
|
+
(executor as any).checkoutFromRemote = async () => {};
|
|
448
|
+
(executor as any).restoreWorkspace = async () => {};
|
|
449
|
+
// The export half already succeeded (undefined = no error, matching this file's `Error | undefined`
|
|
450
|
+
// contract for `snapAndExportOntoLane`); only the ledger commit that follows raced.
|
|
451
|
+
(executor as any).snapAndExportOntoLane = async () => undefined;
|
|
452
|
+
(executor as any).recordLaneHeadOnBranch = async () => ({ status: 'raced' });
|
|
453
|
+
|
|
454
|
+
const summary = await (executor as any).executeExportBranch({
|
|
455
|
+
target: { hostScope: 'acme.shop', name: 'my-lane' },
|
|
456
|
+
laneIdStr: 'acme.shop/my-lane',
|
|
457
|
+
branch: 'my-lane',
|
|
458
|
+
defaultBranch: 'main',
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
expect(summary).to.equal(
|
|
462
|
+
'my-lane -> raced (lane updated; branch ledger commit lost the push race — next run re-plans)'
|
|
463
|
+
);
|
|
464
|
+
expect(summary).to.not.include('HALTED');
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// A destructive auto-resolution (onConflict git-wins/lane-wins) that already exported must not vanish
|
|
469
|
+
// from the summary just because the ledger push raced.
|
|
470
|
+
describe('racedLedgerPushSummary keeps the conflict-policy clause', () => {
|
|
471
|
+
it('carries the merge path’s policyClause through the raced ledger line', () => {
|
|
472
|
+
expect(racedLedgerPushSummary('my-lane', 'conflicts auto-resolved: git-wins on 3 file(s); ')).to.equal(
|
|
473
|
+
'my-lane -> raced (conflicts auto-resolved: git-wins on 3 file(s); lane updated; branch ledger commit ' +
|
|
474
|
+
'lost the push race — next run re-plans)'
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
380
479
|
describe('isProtectedBranch', () => {
|
|
381
480
|
it('refuses the default branch and the main sync branch', () => {
|
|
382
481
|
expect(isProtectedBranch('develop', 'develop', 'bit-sync/main')).to.equal(true);
|
|
@@ -30,10 +30,13 @@ import {
|
|
|
30
30
|
checkoutPristine,
|
|
31
31
|
checkoutPristineRestore,
|
|
32
32
|
commitWithIdentity,
|
|
33
|
+
confirmPushRace,
|
|
33
34
|
deleteBranchArgs,
|
|
35
|
+
dropLocalBranchToRemoteTip,
|
|
34
36
|
fetchRemoteHeads,
|
|
35
37
|
isAncestor,
|
|
36
38
|
isStaleLeaseRejection,
|
|
39
|
+
redactUrlCredentials,
|
|
37
40
|
refetchBranchTip,
|
|
38
41
|
} from './git-ops';
|
|
39
42
|
|
|
@@ -51,6 +54,41 @@ export const HALT_SUMMARY_PREFIX = 'HALTED';
|
|
|
51
54
|
*/
|
|
52
55
|
export const REFUSED_SUMMARY_PREFIX = 'REFUSED';
|
|
53
56
|
|
|
57
|
+
/**
|
|
58
|
+
* A raced verdict: the run lost a push race to a concurrent run and exits green — never a halt, never
|
|
59
|
+
* the `bit-sync-conflict` label; a raced push is not a content conflict. Nothing retries in-run, so
|
|
60
|
+
* convergence waits on the NEXT trigger (webhook or scheduled run): a deployment without the cron can
|
|
61
|
+
* mirror stale content until the next event. One builder so the `-> raced (` stem a consumer routes on
|
|
62
|
+
* lives at one site, like `HALT_SUMMARY_PREFIX`.
|
|
63
|
+
*/
|
|
64
|
+
function racedSummary(laneName: string, detail: string): string {
|
|
65
|
+
return `${laneName} -> raced (${detail})`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `executeImportLane`'s own push (materializing the lane onto the branch) lost the race. Nothing on
|
|
70
|
+
* the LANE changed here — only the branch-side commit failed to land — so the next run's plan is
|
|
71
|
+
* unaffected: it simply retries the same import against the winner's tip.
|
|
72
|
+
*/
|
|
73
|
+
function racedBranchPushSummary(laneName: string): string {
|
|
74
|
+
return racedSummary(laneName, 'branch push lost the race to a concurrent run; next run re-plans');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `recordLaneHeadOnBranch`'s ledger-commit push lost the race, AFTER the snap/export that precedes it
|
|
79
|
+
* already succeeded — the lane genuinely moved. Only the branch's own acknowledgment of that (the
|
|
80
|
+
* `.bitmap` pointer commit) failed to land, so the next run reads the branch as behind the lane it
|
|
81
|
+
* itself just updated and plans `import-lane` to catch it up. `policyClause` carries a destructive
|
|
82
|
+
* auto-resolution (onConflict git-wins/lane-wins) that already exported — it must not vanish from the
|
|
83
|
+
* summary just because the ledger push raced.
|
|
84
|
+
*/
|
|
85
|
+
export function racedLedgerPushSummary(laneName: string, policyClause = ''): string {
|
|
86
|
+
return racedSummary(
|
|
87
|
+
laneName,
|
|
88
|
+
`${policyClause}lane updated; branch ledger commit lost the push race — next run re-plans`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
54
92
|
export type LaneSyncDeps = {
|
|
55
93
|
lanes: LanesMain;
|
|
56
94
|
/** for snapPrCommit + getDefaultBranchName + switchToLaneForSync */
|
|
@@ -583,7 +621,13 @@ export class LaneSyncExecutor {
|
|
|
583
621
|
});
|
|
584
622
|
}
|
|
585
623
|
|
|
586
|
-
await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
|
|
624
|
+
const pushResult = await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
|
|
625
|
+
if (pushResult.raced) {
|
|
626
|
+
// Still ensure the PR: this early return skips the file's only PR-creation site, and the
|
|
627
|
+
// converged state every later run reads plans a bare noop, which never repairs a missing PR.
|
|
628
|
+
if (!pr) await this.openPrForLane({ target, laneIdStr, branch, defaultBranch, laneHead, remoteLane });
|
|
629
|
+
return racedBranchPushSummary(laneName);
|
|
630
|
+
}
|
|
587
631
|
|
|
588
632
|
let prUrl = pr?.htmlUrl;
|
|
589
633
|
if (!pr) {
|
|
@@ -633,8 +677,9 @@ export class LaneSyncExecutor {
|
|
|
633
677
|
});
|
|
634
678
|
}
|
|
635
679
|
|
|
636
|
-
const
|
|
637
|
-
if (
|
|
680
|
+
const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
|
|
681
|
+
if (recorded.status === 'raced') return racedLedgerPushSummary(laneName);
|
|
682
|
+
if (recorded.status === 'unreadable') {
|
|
638
683
|
return await this.executeHalt({
|
|
639
684
|
laneName,
|
|
640
685
|
laneIdStr,
|
|
@@ -643,7 +688,10 @@ export class LaneSyncExecutor {
|
|
|
643
688
|
pr: await this.findPr(branch),
|
|
644
689
|
});
|
|
645
690
|
}
|
|
646
|
-
return
|
|
691
|
+
return (
|
|
692
|
+
`${laneName} -> export-branch (lane ${laneIdStr} @ ${recorded.laneHead.slice(0, 9)}, ` +
|
|
693
|
+
`branch ${branch} updated)`
|
|
694
|
+
);
|
|
647
695
|
} finally {
|
|
648
696
|
await this.restoreWorkspace(defaultBranch);
|
|
649
697
|
}
|
|
@@ -751,13 +799,14 @@ export class LaneSyncExecutor {
|
|
|
751
799
|
}
|
|
752
800
|
|
|
753
801
|
// ---- step 3: record the new lane head on the branch --------------------------------------
|
|
754
|
-
const
|
|
755
|
-
if (
|
|
802
|
+
const recorded = await this.recordLaneHeadOnBranch(target, laneIdStr, branch);
|
|
803
|
+
if (recorded.status === 'raced') return racedLedgerPushSummary(laneName, policyClause);
|
|
804
|
+
if (recorded.status === 'unreadable') {
|
|
756
805
|
return await halt(`lane ${laneIdStr} could not be read back from the remote after the merge export`);
|
|
757
806
|
}
|
|
758
807
|
return (
|
|
759
808
|
`${laneName} -> merge-diverged (${policyClause}merged lane into branch, then exported; lane ${laneIdStr} @ ` +
|
|
760
|
-
`${laneHead.slice(0, 9)}, branch ${branch} updated)`
|
|
809
|
+
`${recorded.laneHead.slice(0, 9)}, branch ${branch} updated)`
|
|
761
810
|
);
|
|
762
811
|
} catch (e: any) {
|
|
763
812
|
// Something unforeseen — halt anyway: no single lane may abort the rest of the run.
|
|
@@ -799,18 +848,21 @@ export class LaneSyncExecutor {
|
|
|
799
848
|
/**
|
|
800
849
|
* Record on the branch which lane state it now mirrors: re-query the lane (the export just moved it,
|
|
801
850
|
* so any earlier fingerprint is stale), commit — crucially the `.bitmap` the snap rewrote — and push.
|
|
802
|
-
*
|
|
851
|
+
* `unreadable` means the lane could no longer be read back, in which case the caller halts;
|
|
852
|
+
* `raced` means a concurrent run's push already got there first, in which case the caller reports
|
|
853
|
+
* a benign noop instead.
|
|
803
854
|
*/
|
|
804
855
|
private async recordLaneHeadOnBranch(
|
|
805
856
|
target: LaneTarget,
|
|
806
857
|
laneIdStr: string,
|
|
807
858
|
branch: string
|
|
808
|
-
): Promise<
|
|
859
|
+
): Promise<{ status: 'unreadable' } | { status: 'raced' } | { status: 'ok'; laneHead: string }> {
|
|
809
860
|
const remoteLane = await this.getRemoteLane(target);
|
|
810
|
-
if (!remoteLane) return
|
|
861
|
+
if (!remoteLane) return { status: 'unreadable' };
|
|
811
862
|
const laneHead = laneHeadFingerprint(remoteLane.components);
|
|
812
|
-
await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
|
|
813
|
-
return
|
|
863
|
+
const pushResult = await this.commitAllAndPush(branch, buildSyncCommitMessage(laneIdStr, laneHead));
|
|
864
|
+
if (pushResult.raced) return { status: 'raced' };
|
|
865
|
+
return { status: 'ok', laneHead };
|
|
814
866
|
}
|
|
815
867
|
|
|
816
868
|
/**
|
|
@@ -1222,15 +1274,68 @@ export class LaneSyncExecutor {
|
|
|
1222
1274
|
/**
|
|
1223
1275
|
* Stage everything, commit with the annotated sync message, and push. NEVER force-pushes: a rejected
|
|
1224
1276
|
* push means someone pushed concurrently and the next run should re-plan rather than clobber.
|
|
1225
|
-
* `--allow-empty` is only insurance against `git commit` failing the lane outright.
|
|
1277
|
+
* `--allow-empty` is only insurance against `git commit` failing the lane outright. Returns whether
|
|
1278
|
+
* the push was rejected by a CONFIRMED race (`classifyPushRejection`) — the caller's cue to report a
|
|
1279
|
+
* benign noop instead of halting; any other push failure still throws.
|
|
1226
1280
|
*/
|
|
1227
|
-
private async commitAllAndPush(branch: string, message: string) {
|
|
1281
|
+
private async commitAllAndPush(branch: string, message: string): Promise<{ raced: boolean }> {
|
|
1282
|
+
// The local knowledge of `origin/<branch>` right now — no fetch, `fetchOnce` already ran for this
|
|
1283
|
+
// whole executor run — is exactly what our about-to-be-made commit is based on. After a rejection,
|
|
1284
|
+
// this is the baseline `classifyPushRejection` checks the remote against to confirm a real race.
|
|
1285
|
+
const baseSha = await this.localRemoteTip(branch);
|
|
1228
1286
|
await addAllExceptScopeAndModules();
|
|
1229
1287
|
await commitWithIdentity(message, { extraArgs: ['--allow-empty'] });
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1288
|
+
try {
|
|
1289
|
+
// `HEAD:refs/heads/<branch>`: a full-ref destination cannot be resolved as a tag or
|
|
1290
|
+
// reinterpreted — the configured branch name is user input (see `sync-config.ts`).
|
|
1291
|
+
await git.push(['origin', `HEAD:refs/heads/${branch}`]);
|
|
1292
|
+
} catch (e: any) {
|
|
1293
|
+
if ((await this.classifyPushRejection(branch, baseSha, e)) !== 'confirmed-race') throw e;
|
|
1294
|
+
// Drop the losing commit: left on refs/heads/<branch>, its orphan sibling would trip
|
|
1295
|
+
// `checkoutPristine`'s guard on this clone's next branch-touching run. The confirmation just
|
|
1296
|
+
// refetched the winner's tip, so `origin/<branch>` resolves.
|
|
1297
|
+
const dropErr = await dropLocalBranchToRemoteTip(branch);
|
|
1298
|
+
if (dropErr) {
|
|
1299
|
+
this.deps.logger.consoleWarning(`Could not drop the unpushed sync commit on ${branch}: ${dropErr}`);
|
|
1300
|
+
}
|
|
1301
|
+
// Keep the original rejection text (redacted: git prints the remote URL, which may embed
|
|
1302
|
+
// credentials): the raced verdict exits green, and without the text a persistent wording-matched
|
|
1303
|
+
// failure on a busy branch would leave no diagnosable trace anywhere.
|
|
1304
|
+
this.deps.logger.console(
|
|
1305
|
+
formatWarningSummary(
|
|
1306
|
+
`Push to ${branch} was rejected and the branch has since moved — a concurrent run got there first; ` +
|
|
1307
|
+
`re-planning on the next sync. The rejection: ${redactUrlCredentials(String(e?.message || e))}`
|
|
1308
|
+
)
|
|
1309
|
+
);
|
|
1310
|
+
return { raced: true };
|
|
1311
|
+
}
|
|
1233
1312
|
this.deps.logger.console(chalk.green(`Pushed ${branch}`));
|
|
1313
|
+
return { raced: false };
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/** `origin/<branch>`'s tip as this local clone currently knows it — no fetch. Undefined if unknown. */
|
|
1317
|
+
private async localRemoteTip(branch: string): Promise<string | undefined> {
|
|
1318
|
+
try {
|
|
1319
|
+
return String(await git.raw(['rev-parse', `origin/${branch}`])).trim() || undefined;
|
|
1320
|
+
} catch {
|
|
1321
|
+
return undefined;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* `confirmPushRace` (git-ops) with this executor's seam: `currentBranchTip` (shared with the close-pr
|
|
1327
|
+
* retirement path) keeps the confirmation unit-testable without a real remote. Residual risk,
|
|
1328
|
+
* accepted: "the branch moved" is not "a sync run won" — on a branch that moves during every run
|
|
1329
|
+
* window, a persistent wording-matched failure still classifies as raced each time. The caller logs
|
|
1330
|
+
* the original rejection text so that shape stays diagnosable. Only answers; the caller decides to
|
|
1331
|
+
* swallow or rethrow.
|
|
1332
|
+
*/
|
|
1333
|
+
private async classifyPushRejection(
|
|
1334
|
+
branch: string,
|
|
1335
|
+
baseSha: string | undefined,
|
|
1336
|
+
e: any
|
|
1337
|
+
): Promise<'confirmed-race' | 'not-a-race'> {
|
|
1338
|
+
return confirmPushRace(branch, baseSha, String(e?.message || e), (b) => this.currentBranchTip(b));
|
|
1234
1339
|
}
|
|
1235
1340
|
|
|
1236
1341
|
private async openPrForLane({
|
|
@@ -16,10 +16,13 @@ import {
|
|
|
16
16
|
checkoutPristine,
|
|
17
17
|
checkoutPristineRestore,
|
|
18
18
|
commitWithIdentity,
|
|
19
|
+
confirmPushRace,
|
|
20
|
+
dropLocalBranchToRemoteTip,
|
|
19
21
|
fetchRemoteHeads,
|
|
20
22
|
gitWithIdentity,
|
|
21
23
|
isNonContentPath,
|
|
22
24
|
isStaleLeaseRejection,
|
|
25
|
+
redactUrlCredentials,
|
|
23
26
|
} from './git-ops';
|
|
24
27
|
|
|
25
28
|
export type MainSyncDeps = {
|
|
@@ -89,7 +92,7 @@ export class MainSyncExecutor {
|
|
|
89
92
|
if (catchUpErr) {
|
|
90
93
|
const refork = await this.reforkIfMachineOwned(branch);
|
|
91
94
|
if (refork === 'human-owned') return `${HALT_SUMMARY_PREFIX} main -> ${catchUpErr}`;
|
|
92
|
-
if (refork === 'raced') return
|
|
95
|
+
if (refork === 'raced') return racedMainSummary(branch);
|
|
93
96
|
}
|
|
94
97
|
}
|
|
95
98
|
|
|
@@ -156,10 +159,34 @@ export class MainSyncExecutor {
|
|
|
156
159
|
|
|
157
160
|
await addAllExceptScopeAndModules();
|
|
158
161
|
await commitWithIdentity(mainSyncCommitMessage(drift.length));
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
// The local knowledge of `origin/<branch>` — the tip this run built on; after a rejection it is
|
|
163
|
+
// the baseline `confirmPushRace` checks the re-fetched remote against.
|
|
164
|
+
const baseSha = await git
|
|
165
|
+
.revparse([`origin/${branch}`])
|
|
166
|
+
.then((sha) => sha.trim() || undefined)
|
|
167
|
+
.catch(() => undefined);
|
|
168
|
+
// Never force. Unambiguous refspec — see `lane-sync-executor.commitAllAndPush`. A rejection means
|
|
169
|
+
// a concurrent run pushed in between; what happens next depends on the mode. Direct-push: the
|
|
170
|
+
// rejection IS the default branch's safety story — stay loud, never swallow. 'pr' mode: the push
|
|
171
|
+
// targets the machine-owned sync branch, where a concurrent-run overlap is the same benign race
|
|
172
|
+
// the lane executor tolerates (`reforkIfMachineOwned` already defers the same way) — confirm it
|
|
173
|
+
// and defer.
|
|
174
|
+
try {
|
|
175
|
+
await git.push(['origin', `HEAD:refs/heads/${branch}`]);
|
|
176
|
+
} catch (e: any) {
|
|
177
|
+
const message = String(e?.message || e);
|
|
178
|
+
if (directPush || (await confirmPushRace(branch, baseSha, message)) !== 'confirmed-race') throw e;
|
|
179
|
+
// Drop the losing commit: left on the local sync branch, its orphan sibling would trip the
|
|
180
|
+
// next run's `checkoutPristine` guard. The original rejection text is kept for diagnosability.
|
|
181
|
+
const dropErr = await dropLocalBranchToRemoteTip(branch);
|
|
182
|
+
if (dropErr) {
|
|
183
|
+
logger.consoleWarning(`Could not drop the unpushed sync commit on ${branch}: ${dropErr}`);
|
|
184
|
+
}
|
|
185
|
+
logger.console(
|
|
186
|
+
formatWarningSummary(`main -> push to ${branch} lost to a concurrent run: ${redactUrlCredentials(message)}`)
|
|
187
|
+
);
|
|
188
|
+
return racedMainSummary(branch);
|
|
189
|
+
}
|
|
163
190
|
logger.console(chalk.green(`Pushed ${branch}`));
|
|
164
191
|
|
|
165
192
|
if (directPush) {
|
|
@@ -329,6 +356,11 @@ export class MainSyncExecutor {
|
|
|
329
356
|
}
|
|
330
357
|
}
|
|
331
358
|
|
|
359
|
+
/** The deferral line both raced shapes share: the catch-up refork race and the sync-branch push race. */
|
|
360
|
+
function racedMainSummary(branch: string): string {
|
|
361
|
+
return `main -> ${branch} was updated by a concurrent run — deferring to it`;
|
|
362
|
+
}
|
|
363
|
+
|
|
332
364
|
/** The sync commit message: no `Bit-Lane-Head` trailer — main sync keeps no state. */
|
|
333
365
|
function mainSyncCommitMessage(driftCount: number): string {
|
|
334
366
|
return [
|