@teambit/ci 1.0.485 → 1.0.487

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.
Files changed (97) hide show
  1. package/ci.docs.mdx +344 -83
  2. package/commands/sync.cmd.ts +77 -0
  3. package/dist/ci.docs.mdx +344 -83
  4. package/dist/ci.main.runtime.d.ts +67 -6
  5. package/dist/ci.main.runtime.js +152 -25
  6. package/dist/ci.main.runtime.js.map +1 -1
  7. package/dist/commands/sync.cmd.d.ts +34 -0
  8. package/dist/commands/sync.cmd.js +57 -0
  9. package/dist/commands/sync.cmd.js.map +1 -0
  10. package/dist/git.d.ts +11 -1
  11. package/dist/git.js +11 -1
  12. package/dist/git.js.map +1 -1
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +26 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/{preview-1785858957533.js → preview-1785883495880.js} +1 -1
  17. package/dist/sync/git-host-provider.d.ts +47 -0
  18. package/dist/sync/git-host-provider.js +54 -0
  19. package/dist/sync/git-host-provider.js.map +1 -0
  20. package/dist/sync/git-host-provider.spec.d.ts +1 -0
  21. package/dist/sync/git-host-provider.spec.js +171 -0
  22. package/dist/sync/git-host-provider.spec.js.map +1 -0
  23. package/dist/sync/git-ops.d.ts +171 -0
  24. package/dist/sync/git-ops.js +354 -0
  25. package/dist/sync/git-ops.js.map +1 -0
  26. package/dist/sync/git-ops.spec.d.ts +1 -0
  27. package/dist/sync/git-ops.spec.js +476 -0
  28. package/dist/sync/git-ops.spec.js.map +1 -0
  29. package/dist/sync/github-client.d.ts +79 -0
  30. package/dist/sync/github-client.js +198 -0
  31. package/dist/sync/github-client.js.map +1 -0
  32. package/dist/sync/github-client.spec.d.ts +1 -0
  33. package/dist/sync/github-client.spec.js +205 -0
  34. package/dist/sync/github-client.spec.js.map +1 -0
  35. package/dist/sync/init-scaffold.d.ts +40 -0
  36. package/dist/sync/init-scaffold.js +326 -0
  37. package/dist/sync/init-scaffold.js.map +1 -0
  38. package/dist/sync/init-scaffold.spec.d.ts +1 -0
  39. package/dist/sync/init-scaffold.spec.js +169 -0
  40. package/dist/sync/init-scaffold.spec.js.map +1 -0
  41. package/dist/sync/lane-sync-executor.d.ts +256 -0
  42. package/dist/sync/lane-sync-executor.js +1249 -0
  43. package/dist/sync/lane-sync-executor.js.map +1 -0
  44. package/dist/sync/lane-sync-executor.spec.d.ts +1 -0
  45. package/dist/sync/lane-sync-executor.spec.js +416 -0
  46. package/dist/sync/lane-sync-executor.spec.js.map +1 -0
  47. package/dist/sync/main-sync-executor.d.ts +62 -0
  48. package/dist/sync/main-sync-executor.js +305 -0
  49. package/dist/sync/main-sync-executor.js.map +1 -0
  50. package/dist/sync/sync-config.d.ts +83 -0
  51. package/dist/sync/sync-config.js +187 -0
  52. package/dist/sync/sync-config.js.map +1 -0
  53. package/dist/sync/sync-config.spec.d.ts +1 -0
  54. package/dist/sync/sync-config.spec.js +254 -0
  55. package/dist/sync/sync-config.spec.js.map +1 -0
  56. package/dist/sync/sync-orchestrator.d.ts +76 -0
  57. package/dist/sync/sync-orchestrator.js +396 -0
  58. package/dist/sync/sync-orchestrator.js.map +1 -0
  59. package/dist/sync/sync-orchestrator.spec.d.ts +1 -0
  60. package/dist/sync/sync-orchestrator.spec.js +155 -0
  61. package/dist/sync/sync-orchestrator.spec.js.map +1 -0
  62. package/dist/sync/sync-planner.d.ts +60 -0
  63. package/dist/sync/sync-planner.js +114 -0
  64. package/dist/sync/sync-planner.js.map +1 -0
  65. package/dist/sync/sync-planner.spec.d.ts +1 -0
  66. package/dist/sync/sync-planner.spec.js +210 -0
  67. package/dist/sync/sync-planner.spec.js.map +1 -0
  68. package/dist/sync/sync-state.d.ts +94 -0
  69. package/dist/sync/sync-state.js +178 -0
  70. package/dist/sync/sync-state.js.map +1 -0
  71. package/dist/sync/sync-state.spec.d.ts +1 -0
  72. package/dist/sync/sync-state.spec.js +271 -0
  73. package/dist/sync/sync-state.spec.js.map +1 -0
  74. package/dist/sync/workspace-lane.d.ts +13 -0
  75. package/dist/sync/workspace-lane.js +27 -0
  76. package/dist/sync/workspace-lane.js.map +1 -0
  77. package/package.json +20 -15
  78. package/sync/git-host-provider.spec.ts +197 -0
  79. package/sync/git-host-provider.ts +89 -0
  80. package/sync/git-ops.spec.ts +506 -0
  81. package/sync/git-ops.ts +334 -0
  82. package/sync/github-client.spec.ts +174 -0
  83. package/sync/github-client.ts +209 -0
  84. package/sync/init-scaffold.spec.ts +142 -0
  85. package/sync/init-scaffold.ts +340 -0
  86. package/sync/lane-sync-executor.spec.ts +389 -0
  87. package/sync/lane-sync-executor.ts +1363 -0
  88. package/sync/main-sync-executor.ts +321 -0
  89. package/sync/sync-config.spec.ts +283 -0
  90. package/sync/sync-config.ts +214 -0
  91. package/sync/sync-orchestrator.spec.ts +120 -0
  92. package/sync/sync-orchestrator.ts +386 -0
  93. package/sync/sync-planner.spec.ts +126 -0
  94. package/sync/sync-planner.ts +99 -0
  95. package/sync/sync-state.spec.ts +263 -0
  96. package/sync/sync-state.ts +176 -0
  97. package/sync/workspace-lane.ts +20 -0
@@ -0,0 +1,506 @@
1
+ import { expect } from 'chai';
2
+ import type { GitConfigIO } from './git-ops';
3
+ import {
4
+ ALL_HEADS_REFSPEC,
5
+ SYNC_EXCLUDED_PATHS,
6
+ addAllExceptScopeAndModules,
7
+ assertCheckoutableBranch,
8
+ checkoutPristine,
9
+ checkoutPristineRestore,
10
+ commitWithIdentity,
11
+ deleteBranchArgs,
12
+ localBranchExists,
13
+ DEFAULT_GIT_USER_EMAIL,
14
+ DEFAULT_GIT_USER_NAME,
15
+ fetchRemoteHeads,
16
+ isNonContentPath,
17
+ isStaleLeaseRejection,
18
+ parseLsRemoteSymref,
19
+ parseOriginHeadRef,
20
+ refetchBranchTip,
21
+ remoteHeadBranch,
22
+ resolveGitIdentity,
23
+ singleHeadRefspec,
24
+ } from './git-ops';
25
+
26
+ /** A read-only stand-in for `git config --get`. There is no setter: nothing may write the config. */
27
+ function fakeConfig(initial: Record<string, string> = {}): GitConfigIO {
28
+ return { get: async (key) => initial[key] };
29
+ }
30
+
31
+ const DEV = { 'user.email': 'dev@example.com', 'user.name': 'A Developer' };
32
+ const BOT_ENV = { GIT_USER_NAME: 'Release Bot', GIT_USER_EMAIL: 'release@acme.example' };
33
+
34
+ /**
35
+ * Precedence: a configured identity outranks the environment (the var means "when there is nobody
36
+ * else"), and an empty value counts as missing on both sides, because git treats it that way too.
37
+ * `env` is always passed explicitly — otherwise a developer who exports GIT_USER_NAME fails the suite.
38
+ */
39
+ const IDENTITY: Array<{
40
+ name: string;
41
+ config?: Record<string, string>;
42
+ env?: Record<string, string>;
43
+ userName: string;
44
+ email: string;
45
+ }> = [
46
+ { name: 'a fresh CI checkout has neither half', userName: DEFAULT_GIT_USER_NAME, email: DEFAULT_GIT_USER_EMAIL },
47
+ {
48
+ name: 'only the email is configured',
49
+ config: { 'user.email': 'dev@example.com' },
50
+ userName: DEFAULT_GIT_USER_NAME,
51
+ email: 'dev@example.com',
52
+ },
53
+ {
54
+ name: 'only the name is configured',
55
+ config: { 'user.name': 'A Developer' },
56
+ userName: 'A Developer',
57
+ email: DEFAULT_GIT_USER_EMAIL,
58
+ },
59
+ { name: 'both are already configured', config: DEV, userName: 'A Developer', email: 'dev@example.com' },
60
+ {
61
+ name: 'the configured values are empty, which git reads as missing',
62
+ config: { 'user.email': '', 'user.name': '' },
63
+ userName: DEFAULT_GIT_USER_NAME,
64
+ email: DEFAULT_GIT_USER_EMAIL,
65
+ },
66
+ {
67
+ name: 'the env vars supply an identity git has none of',
68
+ env: BOT_ENV,
69
+ userName: 'Release Bot',
70
+ email: 'release@acme.example',
71
+ },
72
+ {
73
+ name: 'the env vars are IGNORED because git already has an identity — a local run is never rewritten',
74
+ config: DEV,
75
+ env: BOT_ENV,
76
+ userName: 'A Developer',
77
+ email: 'dev@example.com',
78
+ },
79
+ {
80
+ name: 'only one env var is set, so each key resolves independently',
81
+ env: { GIT_USER_NAME: 'Release Bot' },
82
+ userName: 'Release Bot',
83
+ email: DEFAULT_GIT_USER_EMAIL,
84
+ },
85
+ {
86
+ name: 'an env var is set to empty, which falls back as if unset',
87
+ env: { GIT_USER_NAME: '', GIT_USER_EMAIL: '' },
88
+ userName: DEFAULT_GIT_USER_NAME,
89
+ email: DEFAULT_GIT_USER_EMAIL,
90
+ },
91
+ ];
92
+
93
+ describe('resolveGitIdentity', () => {
94
+ IDENTITY.forEach(({ name, config, env, userName, email }) => {
95
+ it(`resolves the identity when ${name}`, async () => {
96
+ expect(await resolveGitIdentity(fakeConfig(config), env ?? {})).to.deep.equal({ name: userName, email });
97
+ });
98
+ });
99
+
100
+ // Pinning the literals (not the constants) makes cross-repo drift show up as a failing test.
101
+ it('defaults to the identity the scaffolded workflows and the action both document', () => {
102
+ expect(DEFAULT_GIT_USER_NAME).to.equal('bit-sync[bot]');
103
+ expect(DEFAULT_GIT_USER_EMAIL).to.equal('bit-sync[bot]@users.noreply.github.com');
104
+ });
105
+ });
106
+
107
+ // The identity rides on the invocation. A `git config user.*` write would outlive the run and
108
+ // re-author the developer's own later commits in that repository as the bot.
109
+ describe('commitWithIdentity', () => {
110
+ function recorder(config?: Record<string, string>, env: Record<string, string> = {}) {
111
+ const argv: string[][] = [];
112
+ const run = async (args: string[]) => {
113
+ argv.push(args);
114
+ return '';
115
+ };
116
+ return { argv, deps: { run, io: fakeConfig(config), env } };
117
+ }
118
+
119
+ it('carries the identity as -c on the commit itself, and writes no config at all', async () => {
120
+ const { argv, deps } = recorder();
121
+ await commitWithIdentity('chore(bit-sync): sync git to latest main scope versions', deps);
122
+ expect(argv).to.deep.equal([
123
+ [
124
+ '-c',
125
+ `user.name=${DEFAULT_GIT_USER_NAME}`,
126
+ '-c',
127
+ `user.email=${DEFAULT_GIT_USER_EMAIL}`,
128
+ 'commit',
129
+ '-m',
130
+ 'chore(bit-sync): sync git to latest main scope versions',
131
+ ],
132
+ ]);
133
+ // no `config`/`addConfig` call of any shape — and GitConfigIO has no setter to make one with
134
+ expect(argv.some((args) => args.includes('config') || args.includes('addConfig'))).to.equal(false);
135
+ });
136
+
137
+ it('honours a configured identity and appends the extra flags after the message', async () => {
138
+ const { argv, deps } = recorder(DEV, BOT_ENV);
139
+ await commitWithIdentity('chore(bit-sync): sync lane', { ...deps, extraArgs: ['--allow-empty'] });
140
+ expect(argv).to.deep.equal([
141
+ [
142
+ '-c',
143
+ 'user.name=A Developer',
144
+ '-c',
145
+ 'user.email=dev@example.com',
146
+ 'commit',
147
+ '-m',
148
+ 'chore(bit-sync): sync lane',
149
+ '--allow-empty',
150
+ ],
151
+ ]);
152
+ });
153
+ });
154
+
155
+ describe('fetchRemoteHeads', () => {
156
+ it('passes the all-heads refspec explicitly, overriding whatever the checkout configured', async () => {
157
+ const calls: string[][] = [];
158
+ await fetchRemoteHeads(async (args) => {
159
+ calls.push(args);
160
+ });
161
+ expect(calls).to.deep.equal([['fetch', 'origin', ALL_HEADS_REFSPEC]]);
162
+ expect(ALL_HEADS_REFSPEC).to.equal('+refs/heads/*:refs/remotes/origin/*');
163
+ });
164
+ });
165
+
166
+ // A branch deletion is the one irreversible thing the reconciler does, and every input to it was read
167
+ // from refs fetched once per run — so the tip is re-read here, and leased on in the push itself.
168
+ describe('refetchBranchTip', () => {
169
+ it('re-fetches exactly that one head, then reads the remote-tracking ref it just wrote', async () => {
170
+ const argv: string[][] = [];
171
+ const tip = await refetchBranchTip('my-lane', async (args) => {
172
+ argv.push(args);
173
+ return args[0] === 'rev-parse' ? 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1\n' : '';
174
+ });
175
+ expect(argv).to.deep.equal([
176
+ ['fetch', 'origin', '+refs/heads/my-lane:refs/remotes/origin/my-lane'],
177
+ ['rev-parse', 'refs/remotes/origin/my-lane'],
178
+ ]);
179
+ expect(tip).to.equal('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1');
180
+ expect(singleHeadRefspec('my-lane')).to.equal('+refs/heads/my-lane:refs/remotes/origin/my-lane');
181
+ });
182
+
183
+ // A stale sha here would license deleting a commit that is no longer the tip; undefined keeps the branch.
184
+ it('is undefined — never a stale sha — when the fetch fails, the ref is gone, or the output is empty', async () => {
185
+ expect(
186
+ await refetchBranchTip('my-lane', async (args) => {
187
+ if (args[0] === 'fetch') throw new Error("couldn't find remote ref refs/heads/my-lane");
188
+ return 'staaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaale';
189
+ })
190
+ ).to.equal(undefined);
191
+ expect(await refetchBranchTip('my-lane', async () => '')).to.equal(undefined);
192
+ expect(await refetchBranchTip('my-lane', async () => undefined)).to.equal(undefined);
193
+ });
194
+ });
195
+
196
+ describe('deleteBranchArgs', () => {
197
+ const SHA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1';
198
+
199
+ it('leases the delete on the expected sha, keeping the full delete refspec', () => {
200
+ expect(deleteBranchArgs('my-lane', SHA)).to.deep.equal([
201
+ 'origin',
202
+ `--force-with-lease=refs/heads/my-lane:${SHA}`,
203
+ ':refs/heads/my-lane',
204
+ ]);
205
+ });
206
+
207
+ // A bare `--delete <branch>` or a short lease ref would let a branch name be read as an option.
208
+ it('never passes the branch name as a bare argument', () => {
209
+ expect(deleteBranchArgs('-hostile', SHA)).to.deep.equal([
210
+ 'origin',
211
+ `--force-with-lease=refs/heads/-hostile:${SHA}`,
212
+ ':refs/heads/-hostile',
213
+ ]);
214
+ });
215
+
216
+ // Every refusal wording must read as a race. The third one is why this row exists: the local/file
217
+ // transport says "incorrect old value provided", and matching only the first two reported a refused
218
+ // delete as a failed one — caught by CI, whose e2e remotes use that transport.
219
+ it('recognizes every lease-refusal wording, and not an unrelated push failure', () => {
220
+ expect(isStaleLeaseRejection('! [rejected] (delete) -> my-lane (stale info)')).to.equal(true);
221
+ expect(
222
+ isStaleLeaseRejection(`remote: error: cannot lock ref 'refs/heads/my-lane': is at ${SHA} but expected abc123`)
223
+ ).to.equal(true);
224
+ expect(
225
+ isStaleLeaseRejection('!\t:refs/heads/race-lane\t[remote rejected] (incorrect old value provided)')
226
+ ).to.equal(true);
227
+ expect(isStaleLeaseRejection('remote: GH006: Protected branch update failed')).to.equal(false);
228
+ });
229
+ });
230
+
231
+ describe('isNonContentPath', () => {
232
+ it('covers the two paths the executors never treat as workspace content, and nothing adjacent', () => {
233
+ ['.bit', '.bit/objects/ab/cdef', 'node_modules/lodash/index.js'].forEach((p) =>
234
+ expect(isNonContentPath(p), p).to.equal(true)
235
+ );
236
+ ['.bitmap', 'node_modules_backup/x'].forEach((p) => expect(isNonContentPath(p), p).to.equal(false));
237
+ });
238
+ });
239
+
240
+ // Both paths must end with the same index: everything staged except `.bit` / `node_modules`. The
241
+ // fallback exists because `git add` refuses a pathspec naming an ignored path.
242
+ describe('addAllExceptScopeAndModules', () => {
243
+ /** Records argv; `failFastPath` models git refusing the `:(exclude)` form (both trees gitignored). */
244
+ function recorder(failFastPath = false) {
245
+ const argv: string[][] = [];
246
+ const run = async (args: string[]) => {
247
+ argv.push(args);
248
+ if (failFastPath && args.some((arg) => arg.startsWith(':(exclude)'))) {
249
+ throw new Error('The following paths are ignored by one of your .gitignore files:\n.bit\nnode_modules');
250
+ }
251
+ return undefined;
252
+ };
253
+ return { argv, run };
254
+ }
255
+
256
+ it('excludes both trees by pathspec in one command, so git never traverses them', async () => {
257
+ const { argv, run } = recorder();
258
+ await addAllExceptScopeAndModules(run);
259
+ expect(argv).to.deep.equal([['add', '-A', '--', '.', ':(exclude).bit', ':(exclude)node_modules']]);
260
+ });
261
+
262
+ it('falls back to add-then-reset when git refuses the pathspecs', async () => {
263
+ const { argv, run } = recorder(true);
264
+ await addAllExceptScopeAndModules(run);
265
+ expect(argv).to.deep.equal([
266
+ ['add', '-A', '--', '.', ':(exclude).bit', ':(exclude)node_modules'],
267
+ ['add', '-A', '--', '.'],
268
+ ['reset', '-q', '--', '.bit', 'node_modules'],
269
+ ]);
270
+ });
271
+
272
+ // Either shape must cover every excluded path, or a new entry silently stops being excluded.
273
+ it('names every excluded path on both paths', async () => {
274
+ const fast = recorder();
275
+ await addAllExceptScopeAndModules(fast.run);
276
+ const fallback = recorder(true);
277
+ await addAllExceptScopeAndModules(fallback.run);
278
+ const reset = fallback.argv.find((args) => args[0] === 'reset') as string[];
279
+ SYNC_EXCLUDED_PATHS.forEach((path) => {
280
+ expect(fast.argv[0], path).to.include(`:(exclude)${path}`);
281
+ expect(reset, path).to.include(path);
282
+ });
283
+ expect(reset.slice(3)).to.deep.equal(SYNC_EXCLUDED_PATHS);
284
+ });
285
+ });
286
+
287
+ // A wrong default-branch name does not fail loudly — it protects the wrong branch. A slashed name is a
288
+ // NAME, not a path to shorten, and an unrecognised shape must yield undefined so the caller can fall back.
289
+ const ORIGIN_HEAD: Array<[string, string | undefined]> = [
290
+ ['refs/remotes/origin/main\n', 'main'],
291
+ ['refs/remotes/origin/master', 'master'],
292
+ ['refs/remotes/origin/release/main', 'release/main'],
293
+ ['refs/remotes/origin/team/x/main\n', 'team/x/main'],
294
+ ['refs/remotes/origin/feature/a/b/c', 'feature/a/b/c'],
295
+ ['refs/heads/main', undefined],
296
+ ['fatal: ref refs/remotes/origin/HEAD is not a symbolic ref', undefined],
297
+ ['', undefined],
298
+ [' ', undefined],
299
+ ['refs/remotes/origin/', undefined],
300
+ ];
301
+
302
+ describe('parseOriginHeadRef', () => {
303
+ it('reads the branch name whole, or nothing at all', () => {
304
+ ORIGIN_HEAD.forEach(([input, expected]) =>
305
+ expect(parseOriginHeadRef(input), JSON.stringify(input)).to.equal(expected)
306
+ );
307
+ });
308
+ });
309
+
310
+ // These rows assert the ORDER of the three steps: a clean after the reload would leave the workspace
311
+ // holding a `.bitmap` view of a tree that no longer exists.
312
+ describe('checkoutPristine', () => {
313
+ const CLEAN = 'clean -fd -e .bit -e node_modules';
314
+
315
+ /** Records git argv and the reload interleaved, so ordering between the two is assertable. */
316
+ function recorder(revParse = 'abc123\n', containedIn = ' origin/some-branch\n') {
317
+ const steps: string[] = [];
318
+ const run = async (args: string[]) => {
319
+ steps.push(args.join(' '));
320
+ // model the REAL runner: a missing ref resolves with empty output rather than rejecting
321
+ if (args[0] === 'rev-parse') return revParse;
322
+ if (args[0] === 'branch') return containedIn;
323
+ return undefined;
324
+ };
325
+ const reload = async () => {
326
+ steps.push('<reload>');
327
+ };
328
+ return { steps, run, reload };
329
+ }
330
+
331
+ it('with a startPoint: proves the reset is safe, creates-or-resets the branch, cleans, reloads', async () => {
332
+ const { steps, run, reload } = recorder();
333
+ await checkoutPristine('bit-sync/main', 'origin/main', reload, run);
334
+ expect(steps).to.deep.equal([
335
+ 'rev-parse --verify --quiet refs/heads/bit-sync/main',
336
+ 'branch -r --contains refs/heads/bit-sync/main',
337
+ 'checkout -f -B bit-sync/main origin/main',
338
+ CLEAN,
339
+ '<reload>',
340
+ ]);
341
+ });
342
+
343
+ it('without a startPoint: plain forced switch to an existing branch, then cleans, then reloads', async () => {
344
+ const { steps, run, reload } = recorder();
345
+ await checkoutPristine('main', undefined, reload, run);
346
+ expect(steps).to.deep.equal(['checkout -f main', CLEAN, '<reload>']);
347
+ });
348
+
349
+ // `checkout -B` moves an existing local ref; unpushed commits would be orphaned to the reflog.
350
+ it('REFUSES to reset a local branch whose commits no remote contains, before touching the tree', async () => {
351
+ const { steps, run, reload } = recorder('abc123\n', '\n');
352
+ let message = '';
353
+ await checkoutPristine('my-lane', 'origin/my-lane', reload, run).catch((e) => {
354
+ message = e.message;
355
+ });
356
+ expect(message).to.contain('local branch "my-lane" has commits that no remote branch contains');
357
+ expect(steps.some((step) => step.startsWith('checkout'))).to.equal(false);
358
+ expect(steps.some((step) => step.startsWith('clean'))).to.equal(false);
359
+ });
360
+
361
+ // Remote containment admits both legitimate reset shapes; start-point ancestry wrongly refuses one.
362
+ it('allows the reset when any remote branch contains the local tip — even a different one than the start point', async () => {
363
+ const { steps, run, reload } = recorder('abc123\n', ' origin/policy-lane\n');
364
+ await checkoutPristine('policy-lane', 'origin/main', reload, run);
365
+ expect(steps.some((step) => step === 'checkout -f -B policy-lane origin/main')).to.equal(true);
366
+ });
367
+
368
+ it('skips the safety proof when the branch does not exist locally (every CI clone)', async () => {
369
+ const { steps, run, reload } = recorder('');
370
+ await checkoutPristine('my-lane', 'origin/my-lane', reload, run);
371
+ expect(steps.filter((step) => step.startsWith('branch -r'))).to.deep.equal([]);
372
+ expect(steps.some((step) => step === 'checkout -f -B my-lane origin/my-lane')).to.equal(true);
373
+ });
374
+ });
375
+
376
+ describe('parseLsRemoteSymref / remoteHeadBranch', () => {
377
+ const SYMREF_OUT = 'ref: refs/heads/main\tHEAD\n1234abcd\tHEAD\n';
378
+
379
+ it('reads the branch out of the symref line, keeping a slashed name whole', () => {
380
+ expect(parseLsRemoteSymref(SYMREF_OUT)).to.equal('main');
381
+ // same slash discipline as parseOriginHeadRef: `release/main` is a NAME, not a path to shorten
382
+ expect(parseLsRemoteSymref('ref: refs/heads/release/main\tHEAD\nabc\tHEAD\n')).to.equal('release/main');
383
+ });
384
+
385
+ it('returns undefined when the server omits the symref line (protocol v0), rather than guessing', () => {
386
+ expect(parseLsRemoteSymref('1234abcd\tHEAD\n')).to.equal(undefined);
387
+ expect(parseLsRemoteSymref('')).to.equal(undefined);
388
+ });
389
+
390
+ it('asks the remote, not the local refs — and swallows an offline failure into undefined', async () => {
391
+ const argv: string[][] = [];
392
+ expect(
393
+ await remoteHeadBranch(async (args) => {
394
+ argv.push(args);
395
+ return SYMREF_OUT;
396
+ })
397
+ ).to.equal('main');
398
+ expect(argv).to.deep.equal([['ls-remote', '--symref', 'origin', 'HEAD']]);
399
+ expect(
400
+ await remoteHeadBranch(async () => {
401
+ throw new Error('could not read from remote repository');
402
+ })
403
+ ).to.equal(undefined);
404
+ });
405
+ });
406
+
407
+ /**
408
+ * The default branch is derived from the remote, not from validated config, so the primitive that
409
+ * builds the checkout argv is the last line of defence against an option-like name.
410
+ */
411
+ describe('assertCheckoutableBranch', () => {
412
+ it('refuses a name git would read as an option, or reject as a ref, before any argv is built', () => {
413
+ ['-x', '--force', '', 'has space', 'a..b', 'refs/heads/main', 'ends.lock'].forEach((name) => {
414
+ expect(() => assertCheckoutableBranch(name), name).to.throw(/cannot check out/);
415
+ });
416
+ });
417
+
418
+ it('accepts ordinary names, slashed names included', () => {
419
+ ['main', 'master', 'release/main', 'feature/a-b_c', 'trunk'].forEach((name) => {
420
+ expect(() => assertCheckoutableBranch(name), name).to.not.throw();
421
+ });
422
+ });
423
+
424
+ it('is enforced by checkoutPristine itself, so every caller inherits it', async () => {
425
+ const steps: string[] = [];
426
+ let threw = false;
427
+ await checkoutPristine(
428
+ '-x',
429
+ undefined,
430
+ async () => {},
431
+ async (args) => {
432
+ steps.push(args.join(' '));
433
+ }
434
+ ).catch(() => {
435
+ threw = true;
436
+ });
437
+ expect(threw).to.equal(true);
438
+ expect(steps).to.deep.equal([]);
439
+ });
440
+ });
441
+
442
+ describe('localBranchExists', () => {
443
+ // simple-git's `raw` can resolve with empty output on non-zero exits, so all three missing-branch
444
+ // runner behaviors (empty resolve, undefined resolve, rejection) must be false.
445
+ it('asks for the LOCAL ref quietly, and is true only when that ref prints a sha', async () => {
446
+ const argv: string[][] = [];
447
+ expect(
448
+ await localBranchExists('main', async (args) => {
449
+ argv.push(args);
450
+ return 'abc123\n';
451
+ })
452
+ ).to.equal(true);
453
+ expect(argv).to.deep.equal([['rev-parse', '--verify', '--quiet', 'refs/heads/main']]);
454
+ expect(await localBranchExists('main', async () => '')).to.equal(false);
455
+ expect(await localBranchExists('main', async () => undefined)).to.equal(false);
456
+ expect(
457
+ await localBranchExists('main', async () => {
458
+ throw new Error('exit 1');
459
+ })
460
+ ).to.equal(false);
461
+ });
462
+ });
463
+
464
+ // Each shape is wrong in the other environment: `-B origin/<branch>` in a developer's repo resets
465
+ // their default branch; a plain switch in a detached-HEAD CI checkout fails outright.
466
+ describe('checkoutPristineRestore', () => {
467
+ function restoreRecorder(localBranchIsPresent: boolean) {
468
+ const steps: string[] = [];
469
+ const run = async (args: string[]) => {
470
+ steps.push(args.join(' '));
471
+ // model the REAL runner: a missing ref resolves with empty output rather than rejecting
472
+ if (args[0] === 'rev-parse') return localBranchIsPresent ? 'abc123\n' : '';
473
+ if (args[0] === 'branch') return ' origin/some-branch\n';
474
+ return undefined;
475
+ };
476
+ const reload = async () => {
477
+ steps.push('<reload>');
478
+ };
479
+ return { steps, run, reload };
480
+ }
481
+
482
+ it('local branch present: plain forced switch — never a -B that would reset it to origin', async () => {
483
+ const { steps, run, reload } = restoreRecorder(true);
484
+ await checkoutPristineRestore('main', reload, run);
485
+ expect(steps).to.deep.equal([
486
+ 'rev-parse --verify --quiet refs/heads/main',
487
+ 'checkout -f main',
488
+ 'clean -fd -e .bit -e node_modules',
489
+ '<reload>',
490
+ ]);
491
+ });
492
+
493
+ it('local branch absent (detached-HEAD CI): forks it from origin/<branch> instead of failing', async () => {
494
+ const { steps, run, reload } = restoreRecorder(false);
495
+ await checkoutPristineRestore('main', reload, run);
496
+ // the second rev-parse is checkoutPristine's own reset guard re-proving what restore just learned —
497
+ // redundant but harmless, and cheaper than giving the guard a bypass parameter
498
+ expect(steps).to.deep.equal([
499
+ 'rev-parse --verify --quiet refs/heads/main',
500
+ 'rev-parse --verify --quiet refs/heads/main',
501
+ 'checkout -f -B main origin/main',
502
+ 'clean -fd -e .bit -e node_modules',
503
+ '<reload>',
504
+ ]);
505
+ });
506
+ });