@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.
- package/ci.docs.mdx +344 -83
- package/commands/sync.cmd.ts +77 -0
- package/dist/ci.docs.mdx +344 -83
- package/dist/ci.main.runtime.d.ts +67 -6
- package/dist/ci.main.runtime.js +152 -25
- package/dist/ci.main.runtime.js.map +1 -1
- package/dist/commands/sync.cmd.d.ts +34 -0
- package/dist/commands/sync.cmd.js +57 -0
- package/dist/commands/sync.cmd.js.map +1 -0
- package/dist/git.d.ts +11 -1
- package/dist/git.js +11 -1
- package/dist/git.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +26 -1
- package/dist/index.js.map +1 -1
- package/dist/{preview-1785858957533.js → preview-1785883495880.js} +1 -1
- package/dist/sync/git-host-provider.d.ts +47 -0
- package/dist/sync/git-host-provider.js +54 -0
- package/dist/sync/git-host-provider.js.map +1 -0
- package/dist/sync/git-host-provider.spec.d.ts +1 -0
- package/dist/sync/git-host-provider.spec.js +171 -0
- package/dist/sync/git-host-provider.spec.js.map +1 -0
- package/dist/sync/git-ops.d.ts +171 -0
- package/dist/sync/git-ops.js +354 -0
- package/dist/sync/git-ops.js.map +1 -0
- package/dist/sync/git-ops.spec.d.ts +1 -0
- package/dist/sync/git-ops.spec.js +476 -0
- package/dist/sync/git-ops.spec.js.map +1 -0
- package/dist/sync/github-client.d.ts +79 -0
- package/dist/sync/github-client.js +198 -0
- package/dist/sync/github-client.js.map +1 -0
- package/dist/sync/github-client.spec.d.ts +1 -0
- package/dist/sync/github-client.spec.js +205 -0
- package/dist/sync/github-client.spec.js.map +1 -0
- package/dist/sync/init-scaffold.d.ts +40 -0
- package/dist/sync/init-scaffold.js +326 -0
- package/dist/sync/init-scaffold.js.map +1 -0
- package/dist/sync/init-scaffold.spec.d.ts +1 -0
- package/dist/sync/init-scaffold.spec.js +169 -0
- package/dist/sync/init-scaffold.spec.js.map +1 -0
- package/dist/sync/lane-sync-executor.d.ts +256 -0
- package/dist/sync/lane-sync-executor.js +1249 -0
- package/dist/sync/lane-sync-executor.js.map +1 -0
- package/dist/sync/lane-sync-executor.spec.d.ts +1 -0
- package/dist/sync/lane-sync-executor.spec.js +416 -0
- package/dist/sync/lane-sync-executor.spec.js.map +1 -0
- package/dist/sync/main-sync-executor.d.ts +62 -0
- package/dist/sync/main-sync-executor.js +305 -0
- package/dist/sync/main-sync-executor.js.map +1 -0
- package/dist/sync/sync-config.d.ts +83 -0
- package/dist/sync/sync-config.js +187 -0
- package/dist/sync/sync-config.js.map +1 -0
- package/dist/sync/sync-config.spec.d.ts +1 -0
- package/dist/sync/sync-config.spec.js +254 -0
- package/dist/sync/sync-config.spec.js.map +1 -0
- package/dist/sync/sync-orchestrator.d.ts +76 -0
- package/dist/sync/sync-orchestrator.js +396 -0
- package/dist/sync/sync-orchestrator.js.map +1 -0
- package/dist/sync/sync-orchestrator.spec.d.ts +1 -0
- package/dist/sync/sync-orchestrator.spec.js +155 -0
- package/dist/sync/sync-orchestrator.spec.js.map +1 -0
- package/dist/sync/sync-planner.d.ts +60 -0
- package/dist/sync/sync-planner.js +114 -0
- package/dist/sync/sync-planner.js.map +1 -0
- package/dist/sync/sync-planner.spec.d.ts +1 -0
- package/dist/sync/sync-planner.spec.js +210 -0
- package/dist/sync/sync-planner.spec.js.map +1 -0
- package/dist/sync/sync-state.d.ts +94 -0
- package/dist/sync/sync-state.js +178 -0
- package/dist/sync/sync-state.js.map +1 -0
- package/dist/sync/sync-state.spec.d.ts +1 -0
- package/dist/sync/sync-state.spec.js +271 -0
- package/dist/sync/sync-state.spec.js.map +1 -0
- package/dist/sync/workspace-lane.d.ts +13 -0
- package/dist/sync/workspace-lane.js +27 -0
- package/dist/sync/workspace-lane.js.map +1 -0
- package/package.json +20 -15
- package/sync/git-host-provider.spec.ts +197 -0
- package/sync/git-host-provider.ts +89 -0
- package/sync/git-ops.spec.ts +506 -0
- package/sync/git-ops.ts +334 -0
- package/sync/github-client.spec.ts +174 -0
- package/sync/github-client.ts +209 -0
- package/sync/init-scaffold.spec.ts +142 -0
- package/sync/init-scaffold.ts +340 -0
- package/sync/lane-sync-executor.spec.ts +389 -0
- package/sync/lane-sync-executor.ts +1363 -0
- package/sync/main-sync-executor.ts +321 -0
- package/sync/sync-config.spec.ts +283 -0
- package/sync/sync-config.ts +214 -0
- package/sync/sync-orchestrator.spec.ts +120 -0
- package/sync/sync-orchestrator.ts +386 -0
- package/sync/sync-planner.spec.ts +126 -0
- package/sync/sync-planner.ts +99 -0
- package/sync/sync-state.spec.ts +263 -0
- package/sync/sync-state.ts +176 -0
- package/sync/workspace-lane.ts +20 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { formatWarningSummary } from '@teambit/cli';
|
|
3
|
+
import type { Logger } from '@teambit/logger';
|
|
4
|
+
import type { LanesMain } from '@teambit/lanes';
|
|
5
|
+
import type { CheckoutMain } from '@teambit/checkout';
|
|
6
|
+
import { git } from '../git';
|
|
7
|
+
import type { CiMain } from '../ci.main.runtime';
|
|
8
|
+
import type { CiSyncConfig } from './sync-config';
|
|
9
|
+
import { SYNC_COMMIT_MARKER } from './sync-state';
|
|
10
|
+
import { currentLaneIdStr } from './workspace-lane';
|
|
11
|
+
import type { GitHostProvider } from './git-host-provider';
|
|
12
|
+
import { HALT_SUMMARY_PREFIX, capEntries } from './lane-sync-executor';
|
|
13
|
+
import {
|
|
14
|
+
addAllExceptScopeAndModules,
|
|
15
|
+
branchExistsOnRemote,
|
|
16
|
+
checkoutPristine,
|
|
17
|
+
checkoutPristineRestore,
|
|
18
|
+
commitWithIdentity,
|
|
19
|
+
fetchRemoteHeads,
|
|
20
|
+
gitWithIdentity,
|
|
21
|
+
isNonContentPath,
|
|
22
|
+
} from './git-ops';
|
|
23
|
+
|
|
24
|
+
export type MainSyncDeps = {
|
|
25
|
+
checkout: CheckoutMain;
|
|
26
|
+
lanes: LanesMain;
|
|
27
|
+
/** for reloadWorkspaceFromDisk + switchToLaneForSync */
|
|
28
|
+
ci: CiMain;
|
|
29
|
+
logger: Logger;
|
|
30
|
+
/** undefined => PR operations are logged and skipped; the git half of the sync still runs. */
|
|
31
|
+
gitHost?: GitHostProvider;
|
|
32
|
+
cfg: Required<CiSyncConfig>;
|
|
33
|
+
defaultBranch: string;
|
|
34
|
+
defaultScope: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reconcile the main scope with the repository's default branch. Stateless: `bit checkout head`
|
|
39
|
+
* writes the latest exported versions, so an empty `git status` IS convergence and any diff IS the
|
|
40
|
+
* drift. Under 'pr' the convergence is proposed via `mainSyncBranch`; under 'direct-push' it lands on
|
|
41
|
+
* the default branch with a plain push. Nothing is ever force-pushed in either mode.
|
|
42
|
+
*/
|
|
43
|
+
/** the "nothing to do" line, shared by both modes */
|
|
44
|
+
const CONVERGED_SUMMARY = 'main -> converged (checkout head produced no changes)';
|
|
45
|
+
|
|
46
|
+
export class MainSyncExecutor {
|
|
47
|
+
constructor(private deps: MainSyncDeps) {}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns a summary line (HALT-prefixed on failure) and, like `syncLane`, does not throw — a
|
|
51
|
+
* main-sync failure must not erase the lane results collected before it.
|
|
52
|
+
*/
|
|
53
|
+
async syncMain(opts: { dryRun?: boolean } = {}): Promise<string> {
|
|
54
|
+
const { cfg, defaultBranch, defaultScope, logger } = this.deps;
|
|
55
|
+
const directPush = cfg.mainSync === 'direct-push';
|
|
56
|
+
// In direct-push mode the sync branch (and its PR) is not consulted, moved or deleted.
|
|
57
|
+
const branch = directPush ? defaultBranch : cfg.mainSyncBranch;
|
|
58
|
+
|
|
59
|
+
if (cfg.autoMergeMainSyncPr && !directPush) {
|
|
60
|
+
// Enabling auto-merge is host-specific and not part of the GitHostProvider contract.
|
|
61
|
+
logger.consoleWarning(
|
|
62
|
+
`sync.autoMergeMainSyncPr is enabled in the config, but enabling auto-merge is not implemented yet — ` +
|
|
63
|
+
`the sync PR is opened without it. Use a repository auto-merge rule instead.`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await fetchRemoteHeads();
|
|
69
|
+
// Direct-push always starts from `origin/<defaultBranch>` — the very tip the plain push must
|
|
70
|
+
// fast-forward. In 'pr' mode an existing sync branch is reused so its PR history survives.
|
|
71
|
+
const syncBranchExists = directPush ? false : await branchExistsOnRemote(branch);
|
|
72
|
+
const startPoint = syncBranchExists ? `origin/${branch}` : `origin/${defaultBranch}`;
|
|
73
|
+
logger.console(
|
|
74
|
+
chalk.blue(
|
|
75
|
+
directPush
|
|
76
|
+
? `main -> checking the main scope against ${defaultBranch} (direct-push)`
|
|
77
|
+
: `main -> checking the main scope against ${defaultBranch} (sync branch ${branch})`
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await this.resetToStartPoint(branch, startPoint);
|
|
82
|
+
|
|
83
|
+
if (syncBranchExists) {
|
|
84
|
+
// Catch up with the default branch: the PR must stay mergeable, and `checkout head` on a stale
|
|
85
|
+
// tree would re-commit pre-merge content over the default branch's changes. A merge — never a
|
|
86
|
+
// rebase, never a force-push — is the only non-destructive way to move a branch with an open PR.
|
|
87
|
+
const catchUpErr = await this.catchUpWithDefaultBranch(branch);
|
|
88
|
+
if (catchUpErr) return `${HALT_SUMMARY_PREFIX} main -> ${catchUpErr}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// `checkout head` resolves versions against the CURRENT lane; a lane pointer on this branch
|
|
92
|
+
// would make the "drift" the lane's content. Refuse rather than open a wildly wrong PR.
|
|
93
|
+
// `.bitmap`-derived read: the scope-object read answers "main" on a fresh runner and the guard
|
|
94
|
+
// would silently stop refusing exactly where it ships (see `workspace-lane.ts`).
|
|
95
|
+
const currentLane = currentLaneIdStr(this.deps.lanes);
|
|
96
|
+
if (currentLane) {
|
|
97
|
+
return (
|
|
98
|
+
`${HALT_SUMMARY_PREFIX} main -> the .bitmap on ${startPoint} points at lane ` +
|
|
99
|
+
`"${currentLane}" rather than main, so the main-scope drift cannot be computed`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// `checkoutByCLIValues` rather than `checkout`: it imports current objects first (so `head`
|
|
104
|
+
// resolves to the remote scope's versions) and persists `.bitmap` on destroy.
|
|
105
|
+
// `includeNewFromScope`: a component exported to the scope's main but never in this repo's
|
|
106
|
+
// `.bitmap` would otherwise be invisible to every sync run forever.
|
|
107
|
+
// `mergeStrategy: 'theirs'` is required: unexported source drift makes components "modified",
|
|
108
|
+
// and with no strategy the checkout throws. 'theirs' materializes the exported truth — any
|
|
109
|
+
// reversion of unexported drift is visible in the PR diff. 'ours' is forbidden: it would advance
|
|
110
|
+
// `.bitmap` while keeping the old files, a commit asserting a state the tree does not have.
|
|
111
|
+
const results = await this.deps.checkout.checkoutByCLIValues('', {
|
|
112
|
+
head: true,
|
|
113
|
+
skipNpmInstall: true,
|
|
114
|
+
includeNewFromScope: true,
|
|
115
|
+
mergeStrategy: 'theirs',
|
|
116
|
+
});
|
|
117
|
+
const newFromScope = results.newFromScope ?? [];
|
|
118
|
+
if (newFromScope.length) {
|
|
119
|
+
logger.console(
|
|
120
|
+
chalk.blue(
|
|
121
|
+
`Added ${newFromScope.length} component(s) that exist on ${defaultScope}'s main but not in this ` +
|
|
122
|
+
`workspace: ${newFromScope.join(', ')}`
|
|
123
|
+
)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const drift = await this.driftFiles();
|
|
128
|
+
// Direct-push stays bare: asking the host about `mainSyncBranch`'s PR would be the one
|
|
129
|
+
// interaction with it this mode promises not to make.
|
|
130
|
+
if (!drift.length) return directPush ? CONVERGED_SUMMARY : await this.convergedSummary(branch);
|
|
131
|
+
|
|
132
|
+
logger.console(
|
|
133
|
+
formatWarningSummary(`main -> drift in ${drift.length} file(s): ${drift.slice(0, 20).join(', ')}`)
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (opts.dryRun) {
|
|
137
|
+
// The working tree WAS written (a diff-based check has no other way); `finally` restores it. Safe
|
|
138
|
+
// only because a dry run over a dirty tree is refused before anything runs — the restore rewinds
|
|
139
|
+
// to HEAD, so an uncommitted change here would be gone (`assertCleanForDryRun`).
|
|
140
|
+
logger.console(
|
|
141
|
+
formatWarningSummary(
|
|
142
|
+
directPush
|
|
143
|
+
? `Dry-run: main -> would push the drift directly onto ${branch}`
|
|
144
|
+
: `Dry-run: main -> would push ${branch} and open a sync PR`
|
|
145
|
+
)
|
|
146
|
+
);
|
|
147
|
+
return directPush
|
|
148
|
+
? `main -> drift detected in ${drift.length} file(s) — would push ${branch} directly`
|
|
149
|
+
: `main -> drift detected in ${drift.length} file(s) — would open sync PR`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await addAllExceptScopeAndModules();
|
|
153
|
+
await commitWithIdentity(mainSyncCommitMessage(drift.length));
|
|
154
|
+
// Never force: a rejected push means a concurrent run pushed in between, and in direct-push mode
|
|
155
|
+
// that rejection is the whole safety story for the default branch. Unambiguous refspec — see
|
|
156
|
+
// `lane-sync-executor.commitAllAndPush`.
|
|
157
|
+
await git.push(['origin', `HEAD:refs/heads/${branch}`]);
|
|
158
|
+
logger.console(chalk.green(`Pushed ${branch}`));
|
|
159
|
+
|
|
160
|
+
if (directPush) {
|
|
161
|
+
const shortSha = (await git.revparse(['--short', 'HEAD'])).trim();
|
|
162
|
+
return `main -> direct-push (pushed ${branch} @ ${shortSha})`;
|
|
163
|
+
}
|
|
164
|
+
const prUrl = await this.ensureSyncPr({ branch, driftCount: drift.length, newFromScope });
|
|
165
|
+
return `main -> pushed sync commit to ${branch}${prUrl ? ` (PR ${prUrl})` : ''}`;
|
|
166
|
+
} catch (e: any) {
|
|
167
|
+
// Same contract as the lane executor: a HALTED line, never a throw mid-run.
|
|
168
|
+
return `${HALT_SUMMARY_PREFIX} main -> ${e?.message || e}`;
|
|
169
|
+
} finally {
|
|
170
|
+
await this.restoreWorkspace();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Put the working tree on the sync branch at a pristine copy of `startPoint`. Pristine is
|
|
176
|
+
* load-bearing for a diff-based reconciler: any stray modification would be indistinguishable from
|
|
177
|
+
* main-scope drift. The reload makes `checkout head` read this branch's `.bitmap`.
|
|
178
|
+
*/
|
|
179
|
+
private async resetToStartPoint(branch: string, startPoint: string) {
|
|
180
|
+
await checkoutPristine(branch, startPoint, () => this.deps.ci.reloadWorkspaceFromDisk());
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Merge the default branch into the sync branch so drift is computed against the repository's
|
|
185
|
+
* current state. Returns a reason string on failure; a conflict needs a human (or deleting the sync
|
|
186
|
+
* branch, which the next run re-forks).
|
|
187
|
+
*/
|
|
188
|
+
private async catchUpWithDefaultBranch(branch: string): Promise<string | undefined> {
|
|
189
|
+
const { defaultBranch, logger } = this.deps;
|
|
190
|
+
try {
|
|
191
|
+
// under the identity: a non-fast-forward merge writes a merge COMMIT
|
|
192
|
+
const out = await gitWithIdentity(['merge', '--no-edit', `origin/${defaultBranch}`]);
|
|
193
|
+
logger.console(chalk.blue(`Brought ${branch} up to date with origin/${defaultBranch}: ${out.trim()}`));
|
|
194
|
+
} catch (e: any) {
|
|
195
|
+
await git.raw(['merge', '--abort']).catch(() => undefined);
|
|
196
|
+
return (
|
|
197
|
+
`could not bring the sync branch ${branch} up to date with origin/${defaultBranch}: ${e?.message || e}. ` +
|
|
198
|
+
`Resolve or delete ${branch} (a deleted sync branch is re-forked from ${defaultBranch} on the next run)`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
// The merge may have brought a new `.bitmap` in from the default branch.
|
|
202
|
+
await this.deps.ci.reloadWorkspaceFromDisk();
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The "nothing to do" line — plus the open sync PR, if any: converged means the SYNC branch matches
|
|
208
|
+
* the scope, and the default branch may still differ until that PR merges.
|
|
209
|
+
*/
|
|
210
|
+
private async convergedSummary(branch: string): Promise<string> {
|
|
211
|
+
const { gitHost } = this.deps;
|
|
212
|
+
if (!gitHost) return CONVERGED_SUMMARY;
|
|
213
|
+
const pr = await gitHost.findPrByBranch(branch).catch(() => undefined);
|
|
214
|
+
return pr ? `${CONVERGED_SUMMARY} — open sync PR #${pr.number} still awaits review/merge` : CONVERGED_SUMMARY;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The files `bit checkout head` changed, i.e. the drift. Excludes `.bit`/`node_modules` — the same
|
|
219
|
+
* set the commit paths refuse to touch, keeping "counts as drift" and "gets committed" identical.
|
|
220
|
+
*/
|
|
221
|
+
private async driftFiles(): Promise<string[]> {
|
|
222
|
+
const status = await git.status();
|
|
223
|
+
const paths = status.files.map((file) => file.path).filter((path) => !isNonContentPath(path));
|
|
224
|
+
return [...new Set(paths)];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Make sure the pushed branch has an open PR. A git-host failure only warns: the branch is pushed,
|
|
229
|
+
* which is the load-bearing half, and the next run retries the PR.
|
|
230
|
+
*/
|
|
231
|
+
private async ensureSyncPr({
|
|
232
|
+
branch,
|
|
233
|
+
driftCount,
|
|
234
|
+
newFromScope,
|
|
235
|
+
}: {
|
|
236
|
+
branch: string;
|
|
237
|
+
driftCount: number;
|
|
238
|
+
newFromScope: string[];
|
|
239
|
+
}): Promise<string | undefined> {
|
|
240
|
+
const { gitHost, logger, defaultBranch } = this.deps;
|
|
241
|
+
if (!gitHost) {
|
|
242
|
+
logger.consoleWarning(
|
|
243
|
+
'No configured git host provider (for GitHub: BIT_GITHUB_TOKEN or GITHUB_TOKEN, plus a repository) — ' +
|
|
244
|
+
'pushed sync branch, skipping PR operations'
|
|
245
|
+
);
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const existing = await gitHost.findPrByBranch(branch);
|
|
250
|
+
if (existing) {
|
|
251
|
+
logger.console(chalk.blue(`Sync PR ${existing.htmlUrl} is already open — pushed the new commit onto it`));
|
|
252
|
+
return existing.htmlUrl;
|
|
253
|
+
}
|
|
254
|
+
const created = await gitHost.createPr({
|
|
255
|
+
head: branch,
|
|
256
|
+
base: defaultBranch,
|
|
257
|
+
title: 'Bit sync: update to latest main scope versions',
|
|
258
|
+
body: mainSyncPrBody({ driftCount, newFromScope }),
|
|
259
|
+
});
|
|
260
|
+
logger.console(chalk.green(`Opened sync PR ${created.htmlUrl}`));
|
|
261
|
+
return created.htmlUrl;
|
|
262
|
+
} catch (e: any) {
|
|
263
|
+
logger.consoleWarning(`Could not open or find the sync PR for ${branch}: ${e?.message || e}`);
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Leave git on the default branch and bit on main. Mirrors `LaneSyncExecutor.restoreWorkspace`:
|
|
270
|
+
* best-effort and warn-only so a restore hiccup can't throw out of a `finally` and mask the real
|
|
271
|
+
* error; the clean discards untracked files `checkout head` wrote on the converged/dry-run paths.
|
|
272
|
+
*/
|
|
273
|
+
private async restoreWorkspace() {
|
|
274
|
+
const { logger, defaultBranch } = this.deps;
|
|
275
|
+
try {
|
|
276
|
+
if (currentLaneIdStr(this.deps.lanes)) {
|
|
277
|
+
const switchErr = await this.deps.ci.switchToLaneForSync('main');
|
|
278
|
+
if (switchErr) logger.consoleWarning(`Could not switch the workspace back to main: ${switchErr.message}`);
|
|
279
|
+
}
|
|
280
|
+
await checkoutPristineRestore(defaultBranch, () => this.deps.ci.reloadWorkspaceFromDisk());
|
|
281
|
+
} catch (e: any) {
|
|
282
|
+
logger.consoleWarning(`Could not restore the workspace after the main sync: ${e?.message || e}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The sync commit message: no `Bit-Lane-Head` trailer — main sync keeps no state. */
|
|
288
|
+
function mainSyncCommitMessage(driftCount: number): string {
|
|
289
|
+
return [
|
|
290
|
+
`chore(bit-sync): sync git to latest main scope versions (${driftCount} file(s))`,
|
|
291
|
+
'',
|
|
292
|
+
SYNC_COMMIT_MARKER,
|
|
293
|
+
].join('\n');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function mainSyncPrBody({ driftCount, newFromScope }: { driftCount: number; newFromScope: string[] }): string {
|
|
297
|
+
const lines = [
|
|
298
|
+
'Automated sync PR: the Bit scope moved ahead of this repository. This PR checks the workspace out to the ' +
|
|
299
|
+
'latest exported versions (`bit checkout head --auto-merge-resolve theirs`).',
|
|
300
|
+
'',
|
|
301
|
+
`- files changed: ${driftCount}`,
|
|
302
|
+
'- conflicts between this repository and the scope were resolved **in favour of the scope** — if a file here ' +
|
|
303
|
+
'held changes that were never exported to Bit, this PR reverts them. Review the diff and close the PR ' +
|
|
304
|
+
'instead of merging if that is not what you want.',
|
|
305
|
+
];
|
|
306
|
+
if (newFromScope.length) {
|
|
307
|
+
lines.push(
|
|
308
|
+
`- components added from the scope (${newFromScope.length}, not previously in this repository's \`.bitmap\`):`,
|
|
309
|
+
// Bounded like the lane PR body: an over-long body is rejected by the host outright.
|
|
310
|
+
...capEntries(
|
|
311
|
+
newFromScope.map((id) => ` - \`${id}\``),
|
|
312
|
+
' - '
|
|
313
|
+
)
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
lines.push(
|
|
317
|
+
'',
|
|
318
|
+
'This PR is maintained by `bit ci sync` — re-running the command pushes any further drift onto the same branch.'
|
|
319
|
+
);
|
|
320
|
+
return lines.join('\n');
|
|
321
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import type { CiSyncConfig, LaneTarget } from './sync-config';
|
|
3
|
+
import {
|
|
4
|
+
syncableLaneNameForBranch,
|
|
5
|
+
assertValidBranchName,
|
|
6
|
+
assertValidBranchPrefix,
|
|
7
|
+
isValidGitBranchName,
|
|
8
|
+
validateBranchName,
|
|
9
|
+
resolveSyncConfig,
|
|
10
|
+
laneNameToBranch,
|
|
11
|
+
branchToLaneName,
|
|
12
|
+
isValidLaneName,
|
|
13
|
+
parseLaneTarget,
|
|
14
|
+
shouldSyncLane,
|
|
15
|
+
} from './sync-config';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_SCOPE = 'acme.shop';
|
|
18
|
+
|
|
19
|
+
/** `refs/` in the middle is git-legal and no refspec here doubles it, so only a LEADING one is refused. */
|
|
20
|
+
const VALID = [
|
|
21
|
+
'main',
|
|
22
|
+
'bit-sync/main',
|
|
23
|
+
'lane/my-lane',
|
|
24
|
+
'feature/ABC-123_thing',
|
|
25
|
+
'release-2.0',
|
|
26
|
+
'a',
|
|
27
|
+
'feature/refs/foo',
|
|
28
|
+
'my-refs/foo',
|
|
29
|
+
'lane/refs',
|
|
30
|
+
'refs',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const INVALID = [
|
|
34
|
+
// an option-like leading dash — git would read it as a command-line option
|
|
35
|
+
'--force',
|
|
36
|
+
'-delete',
|
|
37
|
+
// characters and sequences git forbids in a ref, plus control characters
|
|
38
|
+
'a b',
|
|
39
|
+
'a\tb',
|
|
40
|
+
'a..b',
|
|
41
|
+
'a~b',
|
|
42
|
+
'a^b',
|
|
43
|
+
'a:b',
|
|
44
|
+
'a?b',
|
|
45
|
+
'a*b',
|
|
46
|
+
'a[b',
|
|
47
|
+
'a\\b',
|
|
48
|
+
'a@{b',
|
|
49
|
+
'@',
|
|
50
|
+
'a\u0001b',
|
|
51
|
+
'a\u007fb',
|
|
52
|
+
// path-shape rules
|
|
53
|
+
'',
|
|
54
|
+
'/x',
|
|
55
|
+
'x/',
|
|
56
|
+
'a//b',
|
|
57
|
+
'a.',
|
|
58
|
+
'a.lock',
|
|
59
|
+
'.hidden',
|
|
60
|
+
'a/.hidden',
|
|
61
|
+
'a/b.lock',
|
|
62
|
+
// stricter than git on purpose: every push interpolates the value into `refs/heads/<b>`
|
|
63
|
+
'refs/heads/foo',
|
|
64
|
+
'refs/foo',
|
|
65
|
+
'refs/remotes/origin/foo',
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
describe('isValidGitBranchName', () => {
|
|
69
|
+
it('accepts the shapes real configs use', () => {
|
|
70
|
+
VALID.forEach((name) => expect(isValidGitBranchName(name), name).to.equal(true));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects every shape git would refuse, plus the two this module is stricter about', () => {
|
|
74
|
+
INVALID.forEach((name) => expect(isValidGitBranchName(name), JSON.stringify(name)).to.equal(false));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('says what is wrong in the terms the user has to act on', () => {
|
|
78
|
+
expect(validateBranchName('-x')).to.contain('command-line option');
|
|
79
|
+
expect(validateBranchName('refs/heads/main')).to.contain('bare branch name');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('assertValidBranchName', () => {
|
|
84
|
+
it('names the offending config key and value, so the message points at the line to fix', () => {
|
|
85
|
+
let message = '';
|
|
86
|
+
try {
|
|
87
|
+
assertValidBranchName('--force', 'sync.branches["my-lane"]');
|
|
88
|
+
} catch (err: any) {
|
|
89
|
+
message = err.message;
|
|
90
|
+
}
|
|
91
|
+
expect(message).to.contain('sync.branches["my-lane"]');
|
|
92
|
+
expect(message).to.contain('--force');
|
|
93
|
+
expect(message).to.contain('workspace.jsonc');
|
|
94
|
+
expect(() => assertValidBranchName('bit-sync/main', 'sync.mainSyncBranch')).to.not.throw();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('assertValidBranchPrefix', () => {
|
|
99
|
+
it('accepts an empty prefix (the default) and ordinary directory-ish prefixes', () => {
|
|
100
|
+
['', 'lane/', 'bit-'].forEach((prefix) =>
|
|
101
|
+
expect(() => assertValidBranchPrefix(prefix, 'sync.branchPrefix'), prefix).to.not.throw()
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects a prefix that could never start a valid name', () => {
|
|
106
|
+
// 'lane//' — an empty path component in the prefix must not be collapsed away by the check
|
|
107
|
+
['-x', 'a b/', 'a..b/', 'lane//'].forEach((prefix) =>
|
|
108
|
+
expect(() => assertValidBranchPrefix(prefix, 'sync.branchPrefix'), prefix).to.throw('sync.branchPrefix')
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('resolveSyncConfig validates branch names up front', () => {
|
|
114
|
+
/** A bad name must fail at startup naming its config key, not mid-run with git's own ref error. */
|
|
115
|
+
const rejected: Array<[string, Parameters<typeof resolveSyncConfig>[0]]> = [
|
|
116
|
+
['sync.mainSyncBranch', { mainSyncBranch: '--force' }],
|
|
117
|
+
// a fully-qualified ref where a bare branch name belongs
|
|
118
|
+
['sync.mainSyncBranch', { mainSyncBranch: 'refs/heads/bit-sync/main' }],
|
|
119
|
+
['sync.branches["my-lane"]', { branches: { 'my-lane': 'a..b' } }],
|
|
120
|
+
['sync.branchPrefix', { branchPrefix: '-x' }],
|
|
121
|
+
// a `refs/`-rooted prefix double-prefixes every derived branch, not just one configured name
|
|
122
|
+
['sync.branchPrefix', { branchPrefix: 'refs/heads/' }],
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
rejected.forEach(([key, raw]) => {
|
|
126
|
+
it(`rejects ${JSON.stringify(raw)} naming ${key}`, () => {
|
|
127
|
+
expect(() => resolveSyncConfig(raw)).to.throw(key);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('accepts the documented defaults unchanged', () => {
|
|
132
|
+
const cfg = resolveSyncConfig();
|
|
133
|
+
expect(cfg.mainSyncBranch).to.equal('bit-sync/main');
|
|
134
|
+
expect(cfg.branchPrefix).to.equal('');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('laneNameToBranch validates the derived name', () => {
|
|
139
|
+
it('catches a prefix + lane name that only together form something git refuses', () => {
|
|
140
|
+
// `lane/` is a fine prefix on its own; appending a lane name ending the branch in "." is not.
|
|
141
|
+
const cfg = resolveSyncConfig({ branchPrefix: 'lane/' });
|
|
142
|
+
expect(() => laneNameToBranch('x.', cfg)).to.throw('not a valid git branch name');
|
|
143
|
+
expect(laneNameToBranch('my-lane', cfg)).to.equal('lane/my-lane');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('returns an override as configured (already validated by resolveSyncConfig)', () => {
|
|
147
|
+
expect(laneNameToBranch('my-lane', resolveSyncConfig({ branches: { 'my-lane': 'custom/branch' } }))).to.equal(
|
|
148
|
+
'custom/branch'
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('sync-config', () => {
|
|
154
|
+
it('applies defaults', () => {
|
|
155
|
+
expect(resolveSyncConfig(undefined)).to.deep.equal({
|
|
156
|
+
branchPrefix: '',
|
|
157
|
+
branches: {},
|
|
158
|
+
lanes: ['*'],
|
|
159
|
+
mainSyncBranch: 'bit-sync/main',
|
|
160
|
+
autoMergeMainSyncPr: false,
|
|
161
|
+
mainSync: 'pr',
|
|
162
|
+
// a silent policy pick rewrites someone's work, so silence is opt-in
|
|
163
|
+
onConflict: 'halt',
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('accepts every documented value of the union-typed options', () => {
|
|
168
|
+
(['pr', 'direct-push'] as const).forEach((v) => expect(resolveSyncConfig({ mainSync: v }).mainSync).to.equal(v));
|
|
169
|
+
(['halt', 'git-wins', 'lane-wins'] as const).forEach((v) =>
|
|
170
|
+
expect(resolveSyncConfig({ onConflict: v }).onConflict).to.equal(v)
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Config comes from workspace.jsonc, so the union types are not enforced at runtime: a typo must fail
|
|
175
|
+
// at startup naming the key, the value and the options, not fall through to whichever mode a
|
|
176
|
+
// comparison happens to miss.
|
|
177
|
+
const REJECTED: Array<[CiSyncConfig, RegExp]> = [
|
|
178
|
+
[{ mainSync: 'direct' as any }, /sync\.mainSync.*"direct".*"pr".*"direct-push"/],
|
|
179
|
+
[{ onConflict: 'ours' as any }, /sync\.onConflict.*"ours".*"halt".*"git-wins".*"lane-wins"/],
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
REJECTED.forEach(([raw, expected]) => {
|
|
183
|
+
it(`refuses ${JSON.stringify(raw)}, naming the key, the value and the valid options`, () => {
|
|
184
|
+
expect(() => resolveSyncConfig(raw)).to.throw(expected);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('maps lane <-> branch: an explicit override outranks the prefix in both directions', () => {
|
|
189
|
+
const cfg = resolveSyncConfig({ branchPrefix: 'lane/', branches: { 'my-lane': 'custom' } });
|
|
190
|
+
expect(laneNameToBranch('my-lane', cfg)).to.equal('custom');
|
|
191
|
+
expect(branchToLaneName('custom', cfg)).to.equal('my-lane');
|
|
192
|
+
expect(branchToLaneName('lane/other', cfg)).to.equal('other');
|
|
193
|
+
expect(branchToLaneName('unrelated-branch', cfg)).to.equal(undefined);
|
|
194
|
+
// with no prefix and no override, a branch maps to the same-named lane
|
|
195
|
+
expect(branchToLaneName('some-lane', resolveSyncConfig({}))).to.equal('some-lane');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('parseLaneTarget', () => {
|
|
199
|
+
// A lane name cannot contain '/', so the single '/' is always the scope/name boundary; a hosting
|
|
200
|
+
// scope needs no dot (self-hosted and test scopes are bare names).
|
|
201
|
+
const PARSED: Array<[string, LaneTarget]> = [
|
|
202
|
+
['my-lane', { hostScope: DEFAULT_SCOPE, name: 'my-lane' }],
|
|
203
|
+
['other-org.other-scope/my-lane', { hostScope: 'other-org.other-scope', name: 'my-lane' }],
|
|
204
|
+
['bare-scope/my-lane', { hostScope: 'bare-scope', name: 'my-lane' }],
|
|
205
|
+
[' my-lane ', { hostScope: DEFAULT_SCOPE, name: 'my-lane' }],
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
it('resolves a bare name against defaultScope and splits a scope-qualified id', () => {
|
|
209
|
+
PARSED.forEach(([input, expected]) =>
|
|
210
|
+
expect(parseLaneTarget(input, DEFAULT_SCOPE), input).to.deep.equal(expected)
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('refuses malformed targets rather than guessing at them', () => {
|
|
215
|
+
['', ' ', '/', '/my-lane', 'scope/', 'a/b/c', 'scope//lane'].forEach((input) =>
|
|
216
|
+
expect(() => parseLaneTarget(input, DEFAULT_SCOPE), `input: "${input}"`).to.throw()
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('filters lanes by glob list', () => {
|
|
222
|
+
expect(shouldSyncLane('anything', resolveSyncConfig({}))).to.equal(true);
|
|
223
|
+
const cfg = resolveSyncConfig({ lanes: ['feature-*'] });
|
|
224
|
+
expect(shouldSyncLane('feature-x', cfg)).to.equal(true);
|
|
225
|
+
expect(shouldSyncLane('hotfix-y', cfg)).to.equal(false);
|
|
226
|
+
expect(shouldSyncLane('anything', resolveSyncConfig({ lanes: [] }))).to.equal(false);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Pins bit's own rule (create-lane.ts), which this module re-states rather than imports.
|
|
231
|
+
describe('isValidLaneName', () => {
|
|
232
|
+
it('accepts what bit accepts: lowercase alphanumerics plus - _ $ !', () => {
|
|
233
|
+
['my-lane', 'lane_1', 'a', 'x$y', 'w!', 'release-2'].forEach((name) =>
|
|
234
|
+
expect(isValidLaneName(name), name).to.equal(true)
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('rejects uppercase, spaces, dots, the empty string, and the slash a branch name brings in', () => {
|
|
239
|
+
['FEATURE', 'Feature', 'a b', 'a.b', '', 'a+b', 'café', 'feature/foo', 'a/b/c'].forEach((name) =>
|
|
240
|
+
expect(isValidLaneName(name), JSON.stringify(name)).to.equal(false)
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("rejects a name past bit's length limit", () => {
|
|
245
|
+
expect(isValidLaneName('a'.repeat(800))).to.equal(true);
|
|
246
|
+
expect(isValidLaneName('a'.repeat(801))).to.equal(false);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* `--all` maps every remote branch through here; anything it returns becomes a sync target whose branch
|
|
252
|
+
* name is then derived and validated. So the two directions must agree, or an ordinary developer branch
|
|
253
|
+
* halts the run instead of being skipped.
|
|
254
|
+
*/
|
|
255
|
+
describe('syncableLaneNameForBranch round-trips lane and branch validity', () => {
|
|
256
|
+
const cfg = (raw: CiSyncConfig = {}) => resolveSyncConfig({ lanes: ['*'], ...raw });
|
|
257
|
+
|
|
258
|
+
it('maps an ordinary branch to its lane name', () => {
|
|
259
|
+
expect(syncableLaneNameForBranch('my-lane', cfg())).to.equal('my-lane');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('skips a branch whose mapped lane name is valid as a LANE but not usable as a BRANCH', () => {
|
|
263
|
+
// the lane grammar permits `-`, so `-x` is a legal lane name — but git reads it as an option, and
|
|
264
|
+
// `laneNameToBranch` asserts on it. Enumeration must skip such a branch, not adopt it as a target.
|
|
265
|
+
expect(isValidLaneName('-x')).to.equal(true);
|
|
266
|
+
expect(syncableLaneNameForBranch('-x', cfg())).to.equal(undefined);
|
|
267
|
+
expect(syncableLaneNameForBranch('--force', cfg())).to.equal(undefined);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('skips a branch whose name bit forbids as a lane', () => {
|
|
271
|
+
expect(syncableLaneNameForBranch('feature/foo', cfg())).to.equal(undefined);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('honours a configured override, which resolveSyncConfig already validated', () => {
|
|
275
|
+
expect(syncableLaneNameForBranch('release/x', cfg({ branches: { 'lane-x': 'release/x' } }))).to.equal('lane-x');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('applies the round trip through a configured prefix too', () => {
|
|
279
|
+
const prefixed = cfg({ branchPrefix: 'bit-sync/' });
|
|
280
|
+
expect(syncableLaneNameForBranch('bit-sync/my-lane', prefixed)).to.equal('my-lane');
|
|
281
|
+
expect(syncableLaneNameForBranch('my-lane', prefixed)).to.equal(undefined);
|
|
282
|
+
});
|
|
283
|
+
});
|