@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,263 @@
|
|
|
1
|
+
import { expect } from 'chai';
|
|
2
|
+
import {
|
|
3
|
+
ABSENT_ON_BRANCH,
|
|
4
|
+
LANE_HEAD_TRAILER,
|
|
5
|
+
SYNC_COMMIT_MARKER,
|
|
6
|
+
branchStateFingerprint,
|
|
7
|
+
buildSyncCommitMessage,
|
|
8
|
+
fingerprintIdVersions,
|
|
9
|
+
hasSyncMarker,
|
|
10
|
+
isSyncAuthoredMessage,
|
|
11
|
+
parseBranchBitmap,
|
|
12
|
+
parseDevCommitCount,
|
|
13
|
+
} from './sync-state';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_SCOPE = 'acme.shop';
|
|
16
|
+
const SNAP_1 = 'a'.repeat(40);
|
|
17
|
+
const SNAP_2 = 'b'.repeat(40);
|
|
18
|
+
|
|
19
|
+
const LANE_SYNC = buildSyncCommitMessage('acme.shop/my-lane', 'a'.repeat(40));
|
|
20
|
+
const MAIN_SYNC = 'chore(bit-sync): sync git to latest main scope versions\n\n[bit-sync]';
|
|
21
|
+
/** `executeMergeDiverged`'s snap message: a LANE message, whose marker is inline and never a tip. */
|
|
22
|
+
const LANE_SNAP = 'merge remote lane acme.shop/my-lane into my-lane [bit-sync]';
|
|
23
|
+
|
|
24
|
+
describe('buildSyncCommitMessage', () => {
|
|
25
|
+
it('names the lane scope-qualified in the subject and carries both annotations', () => {
|
|
26
|
+
expect(LANE_SYNC.split('\n', 1)[0]).to.equal(`chore(bit-sync): sync lane acme.shop/my-lane @ ${'a'.repeat(9)}`);
|
|
27
|
+
expect(LANE_SYNC).to.include(`${LANE_HEAD_TRAILER}: ${'a'.repeat(40)}`);
|
|
28
|
+
expect(LANE_SYNC).to.include(SYNC_COMMIT_MARKER);
|
|
29
|
+
expect(buildSyncCommitMessage('other.scope/my-lane', 'b'.repeat(40))).to.include('sync lane other.scope/my-lane');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `hasSyncMarker` is the permissive loop guard; `isSyncAuthoredMessage` is an input to branch DELETION,
|
|
35
|
+
* so a false positive there costs a developer's branch. Every row states both answers, and the rows
|
|
36
|
+
* where they disagree are the laundering holes the strict probe exists to close.
|
|
37
|
+
*/
|
|
38
|
+
const MESSAGES: Array<[string, string, boolean, boolean]> = [
|
|
39
|
+
// message, hasSyncMarker, isSyncAuthoredMessage
|
|
40
|
+
['what buildSyncCommitMessage produces', LANE_SYNC, true, true],
|
|
41
|
+
['the raw `git log %B` shape, trailing newline and all', `${LANE_SYNC}\n`, true, true],
|
|
42
|
+
['the main-scope sync commit, whose marker is also its own line', MAIN_SYNC, true, true],
|
|
43
|
+
['CRLF line endings', `subject\r\n\r\n${SYNC_COMMIT_MARKER}\r\n`, true, true],
|
|
44
|
+
['a message that merely quotes the marker mid-line', 'revert the [bit-sync] bitmap churn', true, false],
|
|
45
|
+
['the marker quoted mid-line inside a body', 'fix: undo\n\nthis reverts a [bit-sync] commit\n', true, false],
|
|
46
|
+
['the marker mentioned in a subject', 'chore: mention [bit-sync] here', true, false],
|
|
47
|
+
['the inline-marker lane snap message', LANE_SNAP, true, false],
|
|
48
|
+
['an ordinary commit', 'feat: something', false, false],
|
|
49
|
+
['the lane-head trailer on its own', `${LANE_HEAD_TRAILER}: ${'c'.repeat(40)}`, false, false],
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
describe('sync-commit recognition', () => {
|
|
53
|
+
MESSAGES.forEach(([name, message, marker, authored]) => {
|
|
54
|
+
it(`${authored ? 'accepts' : 'REJECTS'} ${name}`, () => {
|
|
55
|
+
expect(hasSyncMarker(message), 'loop guard').to.equal(marker);
|
|
56
|
+
expect(isSyncAuthoredMessage(message), 'deletion gate').to.equal(authored);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** A `.bitmap` in the exact shape bit writes it (schema 17) — what `git show` hands the parser. */
|
|
62
|
+
function bitmapContent({
|
|
63
|
+
lane,
|
|
64
|
+
components = { comp1: SNAP_1, comp2: SNAP_2 },
|
|
65
|
+
prefix = '/**\n * DO NOT EDIT THIS FILE\n */\n\n',
|
|
66
|
+
}: {
|
|
67
|
+
lane?: { scope: string; name: string };
|
|
68
|
+
components?: Record<string, string>;
|
|
69
|
+
prefix?: string;
|
|
70
|
+
} = {}): string {
|
|
71
|
+
const body: Record<string, any> = {};
|
|
72
|
+
Object.entries(components).forEach(([name, version]) => {
|
|
73
|
+
body[name] = { name, scope: DEFAULT_SCOPE, version, mainFile: 'index.js', rootDir: name };
|
|
74
|
+
});
|
|
75
|
+
if (lane) body._bit_lane = { id: lane, exported: true };
|
|
76
|
+
body['$schema-version'] = '17.0.0';
|
|
77
|
+
return `${prefix}${JSON.stringify(body, null, 4)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** a `.bitmap` carrying whatever `_bit_lane` value the caller wants to see rejected */
|
|
81
|
+
function bitmapWithRawLaneKey(laneKey: any): string {
|
|
82
|
+
return JSON.stringify({
|
|
83
|
+
comp1: { name: 'comp1', scope: DEFAULT_SCOPE, version: SNAP_1, mainFile: 'index.js', rootDir: 'comp1' },
|
|
84
|
+
_bit_lane: laneKey,
|
|
85
|
+
'$schema-version': '17.0.0',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const onLane = (name = 'my-lane', scope = DEFAULT_SCOPE) =>
|
|
90
|
+
parseBranchBitmap(bitmapContent({ lane: { scope, name } }), DEFAULT_SCOPE) as NonNullable<
|
|
91
|
+
ReturnType<typeof parseBranchBitmap>
|
|
92
|
+
>;
|
|
93
|
+
|
|
94
|
+
describe('parseBranchBitmap', () => {
|
|
95
|
+
it('reads the lane pointer scope-qualified — the attribution the ownership rule is built on', () => {
|
|
96
|
+
expect(onLane('my-lane', 'other.scope').laneIdStr).to.equal('other.scope/my-lane');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('reads every component at the exact version the branch records', () => {
|
|
100
|
+
expect(onLane().versions).to.deep.equal({
|
|
101
|
+
[`${DEFAULT_SCOPE}/comp1`]: SNAP_1,
|
|
102
|
+
[`${DEFAULT_SCOPE}/comp2`]: SNAP_2,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('survives the comment header bit prefixes every `.bitmap` with', () => {
|
|
107
|
+
// `.bitmap` is JSON-with-comments; a throw here silently degrades every branch to "not ours".
|
|
108
|
+
const state = parseBranchBitmap(
|
|
109
|
+
bitmapContent({ lane: { scope: DEFAULT_SCOPE, name: 'my-lane' }, prefix: '/* generated, do not edit */\n' }),
|
|
110
|
+
DEFAULT_SCOPE
|
|
111
|
+
);
|
|
112
|
+
expect(state?.laneIdStr).to.equal(`${DEFAULT_SCOPE}/my-lane`);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Only a pointer bit marked exported, carrying a scoped id, is attribution. Honouring anything else
|
|
117
|
+
* would read "lane removed" for a lane that never existed remotely and retire a developer branch —
|
|
118
|
+
* and a bare name could still trigger the branch-aliasing halt over a lane that does not exist.
|
|
119
|
+
*/
|
|
120
|
+
const POINTERS: Array<[string, any, string | undefined]> = [
|
|
121
|
+
['bit marked it not exported', { id: { scope: DEFAULT_SCOPE, name: 'foo' }, exported: false }, undefined],
|
|
122
|
+
['`exported` is missing entirely', { id: { scope: DEFAULT_SCOPE, name: 'foo' } }, undefined],
|
|
123
|
+
['it is an empty object', {}, undefined],
|
|
124
|
+
['`id` is a string rather than a {scope, name}', { id: `${DEFAULT_SCOPE}/foo`, exported: true }, undefined],
|
|
125
|
+
['the lane id has no scope', { id: { scope: '', name: 'foo' }, exported: true }, undefined],
|
|
126
|
+
// non-vacuity for the rows above: a well-formed pointer through the same helper DOES attribute
|
|
127
|
+
[
|
|
128
|
+
'bit has marked the lane exported',
|
|
129
|
+
{ id: { scope: DEFAULT_SCOPE, name: 'foo' }, exported: true },
|
|
130
|
+
`${DEFAULT_SCOPE}/foo`,
|
|
131
|
+
],
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
POINTERS.forEach(([name, laneKey, laneIdStr]) => {
|
|
135
|
+
it(`withholds attribution unless the pointer is usable: ${name}`, () => {
|
|
136
|
+
const state = parseBranchBitmap(bitmapWithRawLaneKey(laneKey), DEFAULT_SCOPE);
|
|
137
|
+
expect(state?.laneIdStr).to.equal(laneIdStr);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('reports no lane pointer at all for a `.bitmap` on main — every ordinary developer branch', () => {
|
|
142
|
+
const state = parseBranchBitmap(bitmapContent(), DEFAULT_SCOPE);
|
|
143
|
+
expect(state).to.not.equal(undefined);
|
|
144
|
+
expect(state?.laneIdStr).to.equal(undefined);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/** Every way of not knowing must resolve to the answer that licenses nothing (no branch retired). */
|
|
148
|
+
const UNREADABLE: Array<[string, string | undefined]> = [
|
|
149
|
+
['a `.bitmap` that is not valid JSON', '{ this is not json'],
|
|
150
|
+
['a file git could not produce at all', undefined],
|
|
151
|
+
['an empty file', ''],
|
|
152
|
+
['a whitespace-only file', ' \n '],
|
|
153
|
+
[
|
|
154
|
+
'an entry bit itself refuses — a scoped component with no version',
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
comp1: { name: 'comp1', scope: DEFAULT_SCOPE, mainFile: 'index.js', rootDir: 'comp1' },
|
|
157
|
+
'$schema-version': '17.0.0',
|
|
158
|
+
}),
|
|
159
|
+
],
|
|
160
|
+
[
|
|
161
|
+
'two components claiming the same rootDir',
|
|
162
|
+
JSON.stringify({
|
|
163
|
+
comp1: { name: 'comp1', scope: DEFAULT_SCOPE, version: SNAP_1, mainFile: 'index.js', rootDir: 'shared' },
|
|
164
|
+
comp2: { name: 'comp2', scope: DEFAULT_SCOPE, version: SNAP_2, mainFile: 'index.js', rootDir: 'shared' },
|
|
165
|
+
'$schema-version': '17.0.0',
|
|
166
|
+
}),
|
|
167
|
+
],
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
UNREADABLE.forEach(([name, content]) => {
|
|
171
|
+
it(`degrades to undefined rather than guessing, for ${name}`, () => {
|
|
172
|
+
expect(parseBranchBitmap(content, DEFAULT_SCOPE)).to.equal(undefined);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('falls back to the repository default scope for a component `.bitmap` does not scope', () => {
|
|
177
|
+
// `latest` can never equal a snap hash, so such a pair reads as not-converged — the safe direction.
|
|
178
|
+
const content = JSON.stringify({
|
|
179
|
+
comp1: { name: 'comp1', scope: '', defaultScope: DEFAULT_SCOPE, mainFile: 'index.js', rootDir: 'comp1' },
|
|
180
|
+
'$schema-version': '17.0.0',
|
|
181
|
+
});
|
|
182
|
+
expect(parseBranchBitmap(content, DEFAULT_SCOPE)?.versions).to.deep.equal({
|
|
183
|
+
[`${DEFAULT_SCOPE}/comp1`]: 'latest',
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Only a count git actually printed may report "no dev commits" — that answer is an input to branch
|
|
190
|
+
* retirement, so every unreadable shape must read as "there is unmerged work" and keep the branch.
|
|
191
|
+
*/
|
|
192
|
+
const DEV_COMMIT_COUNTS: Array<[string, string, boolean]> = [
|
|
193
|
+
['a count of commits on top of the state commit', '3', true],
|
|
194
|
+
['the raw output shape, trailing newline and all', '3\n', true],
|
|
195
|
+
['a genuine zero — the only license to retire', '0', false],
|
|
196
|
+
["a genuine zero with git's trailing newline", '0\n', false],
|
|
197
|
+
['empty output, which simple-git resolves with on some non-zero exits', '', true],
|
|
198
|
+
['whitespace-only output', ' \n ', true],
|
|
199
|
+
['git writing an error where the count was expected', 'fatal: bad revision', true],
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
describe('parseDevCommitCount', () => {
|
|
203
|
+
DEV_COMMIT_COUNTS.forEach(([name, raw, hasDevCommits]) => {
|
|
204
|
+
it(`${hasDevCommits ? 'keeps the branch' : 'permits retirement'} for ${name}`, () => {
|
|
205
|
+
expect(parseDevCommitCount(raw), JSON.stringify(raw)).to.equal(hasDevCommits);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('fingerprintIdVersions', () => {
|
|
211
|
+
it('is a single 40-hex token, and stable under reordering', () => {
|
|
212
|
+
// A single token survives being written into a commit trailer; order-stability keeps neither
|
|
213
|
+
// listing order looking like a state change.
|
|
214
|
+
const a = `${DEFAULT_SCOPE}/comp1@${SNAP_1}`;
|
|
215
|
+
const b = `${DEFAULT_SCOPE}/comp2@${SNAP_2}`;
|
|
216
|
+
expect(fingerprintIdVersions([a])).to.match(/^[0-9a-f]{40}$/);
|
|
217
|
+
expect(fingerprintIdVersions([a, b])).to.equal(fingerprintIdVersions([b, a]));
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('branchStateFingerprint', () => {
|
|
222
|
+
const laneIds = [`${DEFAULT_SCOPE}/comp1`, `${DEFAULT_SCOPE}/comp2`];
|
|
223
|
+
|
|
224
|
+
it('equals the lane fingerprint when the branch records every lane component at the lane head', () => {
|
|
225
|
+
// Computed the way `laneHeadFingerprint` computes it — the two sides must be comparable.
|
|
226
|
+
const laneSide = fingerprintIdVersions([`${DEFAULT_SCOPE}/comp1@${SNAP_1}`, `${DEFAULT_SCOPE}/comp2@${SNAP_2}`]);
|
|
227
|
+
expect(branchStateFingerprint(onLane(), laneIds)).to.equal(laneSide);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('differs when the lane moved a component the branch has not caught up with', () => {
|
|
231
|
+
const laneMoved = fingerprintIdVersions([
|
|
232
|
+
`${DEFAULT_SCOPE}/comp1@${SNAP_1}`,
|
|
233
|
+
`${DEFAULT_SCOPE}/comp2@${'c'.repeat(40)}`,
|
|
234
|
+
]);
|
|
235
|
+
expect(branchStateFingerprint(onLane(), laneIds)).to.not.equal(laneMoved);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('counts a lane component the branch does not have at all', () => {
|
|
239
|
+
// Without the placeholder, "the lane grew a component" would fingerprint identically to "converged".
|
|
240
|
+
const grown = [...laneIds, `${DEFAULT_SCOPE}/comp3`];
|
|
241
|
+
expect(branchStateFingerprint(onLane(), grown)).to.not.equal(branchStateFingerprint(onLane(), laneIds));
|
|
242
|
+
expect(branchStateFingerprint(onLane(), grown)).to.equal(
|
|
243
|
+
fingerprintIdVersions([
|
|
244
|
+
`${DEFAULT_SCOPE}/comp1@${SNAP_1}`,
|
|
245
|
+
`${DEFAULT_SCOPE}/comp2@${SNAP_2}`,
|
|
246
|
+
`${DEFAULT_SCOPE}/comp3@${ABSENT_ON_BRANCH}`,
|
|
247
|
+
])
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('ignores components the branch has that are not on the lane', () => {
|
|
252
|
+
// The `.bitmap` also carries non-lane components at their main versions; counting those would make
|
|
253
|
+
// an untouched pair read as diverged after any unrelated release.
|
|
254
|
+
const withExtra = parseBranchBitmap(
|
|
255
|
+
bitmapContent({
|
|
256
|
+
lane: { scope: DEFAULT_SCOPE, name: 'my-lane' },
|
|
257
|
+
components: { comp1: SNAP_1, comp2: SNAP_2, comp9: '0.0.7' },
|
|
258
|
+
}),
|
|
259
|
+
DEFAULT_SCOPE
|
|
260
|
+
) as NonNullable<ReturnType<typeof parseBranchBitmap>>;
|
|
261
|
+
expect(branchStateFingerprint(withExtra, laneIds)).to.equal(branchStateFingerprint(onLane(), laneIds));
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { BitMap } from '@teambit/legacy.bit-map';
|
|
2
|
+
import { BIT_MAP } from '@teambit/legacy.constants';
|
|
3
|
+
import { sha1 } from '@teambit/toolbox.crypto.sha1';
|
|
4
|
+
import { git } from '../git';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sync state is derived from the `.bitmap` committed on a branch — the lane pointer (`_bit_lane`) and the
|
|
8
|
+
* per-component versions — never from commit messages, which are forgeable and rewritten by squash/rebase.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** What a branch's committed `.bitmap` says about the branch's bit state. */
|
|
12
|
+
export type BranchBitmapState = {
|
|
13
|
+
/**
|
|
14
|
+
* The scope-qualified lane id `.bitmap` points at (`<scope>/<name>`), or undefined when there is no
|
|
15
|
+
* usable pointer — none at all (branch on main), or one bit marked not exported.
|
|
16
|
+
*/
|
|
17
|
+
laneIdStr?: string;
|
|
18
|
+
/** `<scope>/<name>` -> version (a snap hash, or a semver tag on main), exactly as `.bitmap` records it. */
|
|
19
|
+
versions: Record<string, string>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse a `.bitmap` file's content — no workspace, no filesystem. Fail-safe: an unreadable file returns
|
|
24
|
+
* undefined, and a pointer that parses but cannot attribute (unscoped id, `exported: false`) withholds
|
|
25
|
+
* `laneIdStr`; either way no lane id licenses nothing, so a parse failure can never authorize a deletion.
|
|
26
|
+
* An unexported pointer is ignored because its lane never existed on any remote and treating it as a
|
|
27
|
+
* mirror would retire the branch.
|
|
28
|
+
*/
|
|
29
|
+
export function parseBranchBitmap(content: string | undefined, defaultScope: string): BranchBitmapState | undefined {
|
|
30
|
+
if (!content || !content.trim()) return undefined;
|
|
31
|
+
try {
|
|
32
|
+
const bitMap = BitMap.loadFromContentWithoutLoadingFiles(Buffer.from(content, 'utf8'), '', '', defaultScope);
|
|
33
|
+
const versions: Record<string, string> = {};
|
|
34
|
+
bitMap.components.forEach((componentMap) => {
|
|
35
|
+
const version = componentMap.id.version;
|
|
36
|
+
// No version = never snapped/tagged; contributes nothing to the fingerprint.
|
|
37
|
+
if (version) versions[componentMap.id.toStringWithoutVersion()] = version;
|
|
38
|
+
});
|
|
39
|
+
// An unscoped lane id cannot be attribution: every comparison target is scope-qualified.
|
|
40
|
+
const laneId = bitMap.isLaneExported ? bitMap.laneId : undefined;
|
|
41
|
+
const laneIdStr = laneId?.scope ? laneId.toString() : undefined;
|
|
42
|
+
return { laneIdStr, versions };
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Fingerprint of `<component-id>@<version>` pairs: sorted (so listing/key order cannot perturb it), then
|
|
50
|
+
* sha1'd (the value doubles as a single-token `Bit-Lane-Head` trailer).
|
|
51
|
+
*/
|
|
52
|
+
export function fingerprintIdVersions(idAtVersion: string[]): string {
|
|
53
|
+
return sha1([...idAtVersion].sort().join('\n'));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Placeholder for a lane component the branch's `.bitmap` lacks. It must participate in the fingerprint,
|
|
58
|
+
* or "the lane grew a component" would fingerprint identically to "converged".
|
|
59
|
+
*/
|
|
60
|
+
export const ABSENT_ON_BRANCH = '<absent>';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What the branch reflects, comparable to the lane's own fingerprint. Only the lane's components count:
|
|
64
|
+
* the `.bitmap` also carries non-lane components at their main versions, and including those would make
|
|
65
|
+
* an untouched pair read as diverged after any unrelated release.
|
|
66
|
+
*/
|
|
67
|
+
export function branchStateFingerprint(state: BranchBitmapState, laneComponentIds: string[]): string {
|
|
68
|
+
return fingerprintIdVersions(laneComponentIds.map((id) => `${id}@${state.versions[id] ?? ABSENT_ON_BRANCH}`));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const LANE_HEAD_TRAILER = 'Bit-Lane-Head';
|
|
72
|
+
/**
|
|
73
|
+
* Marks a commit as machine-generated. Duplicated in the `bit-git-sync` action repo's event router
|
|
74
|
+
* (the loop guard) — changing one copy without the other makes the reconciler re-trigger itself.
|
|
75
|
+
*/
|
|
76
|
+
export const SYNC_COMMIT_MARKER = '[bit-sync]';
|
|
77
|
+
export const CONFLICT_LABEL = 'bit-sync-conflict';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Permissive substring probe for "looks machine-generated" — the loop guard. Must NOT decide anything
|
|
81
|
+
* irreversible; see `isSyncAuthoredMessage`.
|
|
82
|
+
*/
|
|
83
|
+
export function hasSyncMarker(message: string): boolean {
|
|
84
|
+
return message.includes(SYNC_COMMIT_MARKER);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Strict probe for "we wrote this commit": the marker alone on its own line. This is an input to branch
|
|
89
|
+
* deletion, so a developer merely quoting the marker must never count as authorship. `\r?` tolerates CRLF;
|
|
90
|
+
* a recognition failure errs toward keeping the branch.
|
|
91
|
+
*/
|
|
92
|
+
export function isSyncAuthoredMessage(message: string): boolean {
|
|
93
|
+
return new RegExp(`^${SYNC_COMMIT_MARKER.replace(/[[\]]/g, '\\$&')}\\r?$`, 'm').test(message);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The sync commit's message. Every part is an annotation (audit trail + loop-guard marker); nothing reads
|
|
98
|
+
* it as state — messages are forgeable and rewritten on squash-merge. State comes from `.bitmap`.
|
|
99
|
+
*/
|
|
100
|
+
export function buildSyncCommitMessage(laneIdStr: string, laneHead: string): string {
|
|
101
|
+
return [
|
|
102
|
+
`chore(bit-sync): sync lane ${laneIdStr} @ ${laneHead.slice(0, 9)}`,
|
|
103
|
+
'',
|
|
104
|
+
`${LANE_HEAD_TRAILER}: ${laneHead}`,
|
|
105
|
+
SYNC_COMMIT_MARKER,
|
|
106
|
+
].join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type BranchSyncState = {
|
|
110
|
+
/**
|
|
111
|
+
* The newest first-parent commit that changed `.bitmap` — the reachability anchor for branch ownership
|
|
112
|
+
* and the baseline for `hasDevCommits`. Undefined when `.bitmap` was never written on the branch.
|
|
113
|
+
*/
|
|
114
|
+
stateCommit?: string;
|
|
115
|
+
/**
|
|
116
|
+
* The branch tip's `.bitmap`, parsed; undefined when absent/unparseable (treated as "not ours").
|
|
117
|
+
* Reading at the tip equals reading at `stateCommit` — nothing after it changed the file.
|
|
118
|
+
*/
|
|
119
|
+
bitmap?: BranchBitmapState;
|
|
120
|
+
/** Whether the branch carries commits on top of its bit state — work bit has not seen. */
|
|
121
|
+
hasDevCommits: boolean;
|
|
122
|
+
/** The branch tip's full commit message (subject + body), for the sync-marker loop-guard probe. */
|
|
123
|
+
tipMessage: string;
|
|
124
|
+
/**
|
|
125
|
+
* The tip sha every field above was read from. The retirement path re-reads the branch and refuses to
|
|
126
|
+
* delete anything else — the evidence licensed deleting THIS commit, not a later one.
|
|
127
|
+
*/
|
|
128
|
+
tipSha?: string;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Whether a `rev-list --count` output reports commits. An unreadable count answers `true`: `false` is an
|
|
133
|
+
* input to branch retirement, so not knowing must withhold a deletion rather than license one. Empty
|
|
134
|
+
* output is reachable — simple-git's `raw` resolves with it on some non-zero exits.
|
|
135
|
+
*/
|
|
136
|
+
export function parseDevCommitCount(raw: string): boolean {
|
|
137
|
+
const count = Number.parseInt(raw.trim(), 10);
|
|
138
|
+
return Number.isNaN(count) ? true : count > 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Read a remote branch's sync state from its committed `.bitmap`. Assumes `git fetch origin` already ran.
|
|
143
|
+
* `--first-parent` is a correctness requirement: `git log` orders across all parents, so a `.bitmap`
|
|
144
|
+
* change that arrived through a merge would otherwise be picked as this branch's own state commit.
|
|
145
|
+
*/
|
|
146
|
+
export async function readBranchSyncState(
|
|
147
|
+
branch: string,
|
|
148
|
+
defaultBranch: string,
|
|
149
|
+
defaultScope: string
|
|
150
|
+
): Promise<BranchSyncState> {
|
|
151
|
+
const revision = `origin/${branch}`;
|
|
152
|
+
const tipSha = (await git.raw(['rev-parse', revision])).trim() || undefined;
|
|
153
|
+
const tipMessage = (await git.raw(['log', revision, '-n', '1', '--format=%B'])).trimEnd();
|
|
154
|
+
const stateCommit =
|
|
155
|
+
(await git.raw(['log', revision, '--first-parent', '-n', '1', '--format=%H', '--', BIT_MAP])).trim() || undefined;
|
|
156
|
+
|
|
157
|
+
const bitmap = parseBranchBitmap(await readFileAtRef(revision, BIT_MAP), defaultScope);
|
|
158
|
+
|
|
159
|
+
const range = stateCommit ? `${stateCommit}..${revision}` : `origin/${defaultBranch}..${revision}`;
|
|
160
|
+
const count = await git.raw(['rev-list', range, '--count']);
|
|
161
|
+
|
|
162
|
+
return { stateCommit, bitmap, hasDevCommits: parseDevCommitCount(count), tipMessage, tipSha };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A file's content at a git ref, or undefined when it isn't there. simple-git's `raw` can resolve with
|
|
167
|
+
* empty output instead of rejecting on a missing path; `:./` keeps the path cwd-relative in case the
|
|
168
|
+
* workspace is a subdirectory of the repo.
|
|
169
|
+
*/
|
|
170
|
+
async function readFileAtRef(revision: string, filePath: string): Promise<string | undefined> {
|
|
171
|
+
try {
|
|
172
|
+
return await git.raw(['show', `${revision}:./${filePath}`]);
|
|
173
|
+
} catch {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LanesMain } from '@teambit/lanes';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Current lane of this checkout, read from the workspace's `.bitmap` pointer only. Must NOT go through
|
|
5
|
+
* `lanes.getCurrentLane()`: that resolves via the local scope's lane object, which is absent on a cold
|
|
6
|
+
* runner and falsely answers "main". Unlike `parseBranchBitmap`, no `exported` gate applies here — this
|
|
7
|
+
* answers "which lane will the next bit operation act on?", not attribution/retirement.
|
|
8
|
+
*/
|
|
9
|
+
export function currentLaneIdStr(lanes: LanesMain): string | undefined {
|
|
10
|
+
const laneId = lanes.getCurrentLaneId();
|
|
11
|
+
return !laneId || laneId.isDefault() ? undefined : laneId.toString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Ensure the lane the workspace points at is present in the local scope (idempotent; needed on a cold
|
|
16
|
+
* runner before bit-level operations that touch lane objects).
|
|
17
|
+
*/
|
|
18
|
+
export async function ensureCurrentLaneObject(lanes: LanesMain): Promise<void> {
|
|
19
|
+
await lanes.workspace?.importCurrentLaneIfMissing();
|
|
20
|
+
}
|