@teambit/ci 1.0.494 → 1.0.497

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 (38) hide show
  1. package/ci.docs.mdx +39 -9
  2. package/dist/ci.docs.mdx +39 -9
  3. package/dist/ci.main.runtime.d.ts +6 -0
  4. package/dist/ci.main.runtime.js +24 -3
  5. package/dist/ci.main.runtime.js.map +1 -1
  6. package/dist/{preview-1786400681343.js → preview-1786718956243.js} +1 -1
  7. package/dist/sync/git-ops.d.ts +41 -0
  8. package/dist/sync/git-ops.js +71 -0
  9. package/dist/sync/git-ops.js.map +1 -1
  10. package/dist/sync/git-ops.spec.js +39 -0
  11. package/dist/sync/git-ops.spec.js.map +1 -1
  12. package/dist/sync/lane-sync-executor.d.ts +81 -15
  13. package/dist/sync/lane-sync-executor.js +315 -50
  14. package/dist/sync/lane-sync-executor.js.map +1 -1
  15. package/dist/sync/lane-sync-executor.spec.js +235 -13
  16. package/dist/sync/lane-sync-executor.spec.js.map +1 -1
  17. package/dist/sync/main-sync-executor.js +30 -6
  18. package/dist/sync/main-sync-executor.js.map +1 -1
  19. package/dist/sync/sync-planner.d.ts +10 -1
  20. package/dist/sync/sync-planner.js +37 -13
  21. package/dist/sync/sync-planner.js.map +1 -1
  22. package/dist/sync/sync-planner.spec.js +44 -2
  23. package/dist/sync/sync-planner.spec.js.map +1 -1
  24. package/dist/sync/sync-state.d.ts +33 -1
  25. package/dist/sync/sync-state.js +60 -3
  26. package/dist/sync/sync-state.js.map +1 -1
  27. package/dist/sync/sync-state.spec.js +28 -0
  28. package/dist/sync/sync-state.spec.js.map +1 -1
  29. package/package.json +20 -20
  30. package/sync/git-ops.spec.ts +60 -0
  31. package/sync/git-ops.ts +72 -0
  32. package/sync/lane-sync-executor.spec.ts +215 -9
  33. package/sync/lane-sync-executor.ts +354 -47
  34. package/sync/main-sync-executor.ts +37 -5
  35. package/sync/sync-planner.spec.ts +36 -1
  36. package/sync/sync-planner.ts +36 -16
  37. package/sync/sync-state.spec.ts +33 -0
  38. package/sync/sync-state.ts +62 -1
@@ -62,6 +62,13 @@ const table: Array<{
62
62
  type: 'close-pr',
63
63
  action: { type: 'close-pr', deleteBranch: false, keepReason: 'unmerged-commits' },
64
64
  },
65
+ {
66
+ // Adoption proved content matched, never that the branch's pre-existing history is disposable.
67
+ name: 'own-live, no dev commits, OUR tip, but it is the adoption ledger commit -> KEEP, never delete',
68
+ input: { ...laneGone('own-live'), hasIndependentHistory: true },
69
+ type: 'close-pr',
70
+ action: { type: 'close-pr', deleteBranch: false, keepReason: 'adopted-branch' },
71
+ },
65
72
  {
66
73
  name: 'own-merged (branch tip already in the default branch) -> close-pr AND delete',
67
74
  input: laneGone('own-merged'),
@@ -75,6 +82,12 @@ const table: Array<{
75
82
  type: 'close-pr',
76
83
  action: { type: 'close-pr', deleteBranch: true },
77
84
  },
85
+ {
86
+ name: 'own-merged deletes even when the tip is the adoption ledger commit — reachability is enough',
87
+ input: { ...laneGone('own-merged'), hasIndependentHistory: true },
88
+ type: 'close-pr',
89
+ action: { type: 'close-pr', deleteBranch: true },
90
+ },
78
91
  {
79
92
  name: 'own-superseded (PR merged, then more commits pushed) -> close-pr but KEEP the branch',
80
93
  input: laneGone('own-superseded'),
@@ -108,9 +121,31 @@ const table: Array<{
108
121
  type: 'import-lane',
109
122
  },
110
123
  {
111
- name: 'existing branch, never synced, has dev commits, lane EXISTS -> halt (ambiguous)',
124
+ name: 'existing branch, never synced, has dev commits, lane EXISTS -> adopt-branch (first contact)',
112
125
  input: { lastSyncedHead: undefined, ownership: 'inherited-or-none', hasDevCommits: true },
126
+ type: 'adopt-branch',
127
+ },
128
+ {
129
+ name: 'existing branch, never synced, has dev commits, but .bitmap names a DIFFERENT lane -> halt',
130
+ input: {
131
+ lastSyncedHead: undefined,
132
+ ownership: 'inherited-or-none',
133
+ hasDevCommits: true,
134
+ branchNamesDifferentLane: true,
135
+ },
113
136
  type: 'halt',
137
+ reason: 'names a different lane',
138
+ },
139
+ {
140
+ // The executor resolves an inherited (merged) different-lane pointer to `false` — history, not a claim.
141
+ name: 'existing branch, has dev commits, .bitmap names a MERGED-AND-INHERITED different lane -> adopt-branch',
142
+ input: {
143
+ lastSyncedHead: undefined,
144
+ ownership: 'inherited-or-none',
145
+ hasDevCommits: true,
146
+ branchNamesDifferentLane: false,
147
+ },
148
+ type: 'adopt-branch',
114
149
  },
115
150
  ];
116
151
 
@@ -29,7 +29,14 @@ export type LaneSyncInput = {
29
29
  * with `isSyncAuthoredMessage`, not the loop guard's substring match.
30
30
  */
31
31
  tipIsSyncCommit: boolean;
32
+ /**
33
+ * The branch had real (non-bit) commits before this reconciler touched it — it was adopted, not
34
+ * manufactured. Can only withhold a delete, never authorize one.
35
+ */
36
+ hasIndependentHistory?: boolean;
32
37
  conflictLabelPresent: boolean;
38
+ /** The branch's committed `.bitmap` names a DIFFERENT lane — another lane's claim, never adopted. */
39
+ branchNamesDifferentLane?: boolean;
33
40
  /** @see LaneOwnershipEvidence — consulted only on the "lane is gone" path. */
34
41
  ownership: LaneOwnershipEvidence;
35
42
  };
@@ -38,13 +45,18 @@ export type LaneSyncInput = {
38
45
  * Why a `close-pr` is keeping the branch rather than deleting it. `tip-advanced-during-run` is the one
39
46
  * the planner never emits: it is discovered when the executor re-reads the branch just before deleting.
40
47
  */
41
- export type BranchKeepReason = 'unmerged-commits' | 'tip-not-a-sync-commit' | 'tip-advanced-during-run';
48
+ export type BranchKeepReason =
49
+ | 'unmerged-commits'
50
+ | 'tip-not-a-sync-commit'
51
+ | 'tip-advanced-during-run'
52
+ | 'adopted-branch';
42
53
 
43
54
  export type LaneSyncAction =
44
55
  | { type: 'noop'; reason: string }
45
56
  | { type: 'import-lane' }
46
57
  | { type: 'export-branch' }
47
58
  | { type: 'merge-diverged' }
59
+ | { type: 'adopt-branch' }
48
60
  // Two variants rather than an optional `keepReason` so the type enforces that a keep always says why.
49
61
  | { type: 'close-pr'; deleteBranch: true }
50
62
  | { type: 'close-pr'; deleteBranch: false; keepReason: BranchKeepReason }
@@ -52,7 +64,7 @@ export type LaneSyncAction =
52
64
 
53
65
  export function planLaneSync(input: LaneSyncInput): LaneSyncAction {
54
66
  const { laneHead, branchExists, lastSyncedHead, hasDevCommits, conflictLabelPresent, ownership } = input;
55
- const { tipIsSyncCommit } = input;
67
+ const { tipIsSyncCommit, hasIndependentHistory, branchNamesDifferentLane } = input;
56
68
  if (conflictLabelPresent) {
57
69
  return { type: 'noop', reason: 'PR is labeled bit-sync-conflict; resolve and remove the label to resume' };
58
70
  }
@@ -60,16 +72,20 @@ export function planLaneSync(input: LaneSyncInput): LaneSyncAction {
60
72
  if (!branchExists) return { type: 'noop', reason: 'lane and branch both absent' };
61
73
  switch (ownership) {
62
74
  case 'own-live':
63
- // DELETION NEEDS BOTH HALVES: `.bitmap` attribution alone is not enough — a developer who commits
64
- // their own `.bitmap` write (bit create / unexported snap / deps set) looks exactly like
65
- // "own-live, nothing above the state commit". The marker can only withhold a deletion, never
66
- // authorize one; a false keep is harmless, a false delete destroys the only copy of the work.
67
- if (!hasDevCommits && tipIsSyncCommit) return { type: 'close-pr', deleteBranch: true };
68
- return {
69
- type: 'close-pr',
70
- deleteBranch: false,
71
- keepReason: hasDevCommits ? 'unmerged-commits' : 'tip-not-a-sync-commit',
72
- };
75
+ // Each check is a separate reason to withhold a delete: dev commits are work that exists
76
+ // nowhere else; a non-sync tip may be a developer's own `.bitmap` write; an adopted branch's
77
+ // pre-existing history was never proven disposable. A false keep is harmless, a false delete
78
+ // destroys the only copy.
79
+ if (hasDevCommits) {
80
+ return { type: 'close-pr', deleteBranch: false, keepReason: 'unmerged-commits' };
81
+ }
82
+ if (!tipIsSyncCommit) {
83
+ return { type: 'close-pr', deleteBranch: false, keepReason: 'tip-not-a-sync-commit' };
84
+ }
85
+ if (hasIndependentHistory) {
86
+ return { type: 'close-pr', deleteBranch: false, keepReason: 'adopted-branch' };
87
+ }
88
+ return { type: 'close-pr', deleteBranch: true };
73
89
  case 'own-merged':
74
90
  // No marker conjunction needed: the tip being an ancestor of the default branch is unforgeable
75
91
  // proof that deleting loses nothing.
@@ -84,10 +100,14 @@ export function planLaneSync(input: LaneSyncInput): LaneSyncAction {
84
100
  if (!branchExists) return { type: 'import-lane' };
85
101
  if (!lastSyncedHead) {
86
102
  if (hasDevCommits) {
87
- return {
88
- type: 'halt',
89
- reason: 'branch has commits but its .bitmap records no state for this lane; cannot tell which side is newer',
90
- };
103
+ if (branchNamesDifferentLane) {
104
+ return {
105
+ type: 'halt',
106
+ reason: 'branch has commits but its .bitmap names a different lane; cannot tell which side is newer',
107
+ };
108
+ }
109
+ // The lane exists but the pair has no sync history: first contact, not a conflict.
110
+ return { type: 'adopt-branch' };
91
111
  }
92
112
  return { type: 'import-lane' };
93
113
  }
@@ -6,6 +6,7 @@ import {
6
6
  branchStateFingerprint,
7
7
  buildSyncCommitMessage,
8
8
  fingerprintIdVersions,
9
+ oldestCommitIsNonSync,
9
10
  hasSyncMarker,
10
11
  isSyncAuthoredMessage,
11
12
  parseBranchBitmap,
@@ -207,6 +208,38 @@ describe('parseDevCommitCount', () => {
207
208
  });
208
209
  });
209
210
 
211
+ describe('oldestCommitIsNonSync', () => {
212
+ it('is false for an empty range — no commits at all', () => {
213
+ expect(oldestCommitIsNonSync('')).to.equal(false);
214
+ });
215
+
216
+ it('is false when the oldest (first, with --reverse) record is bit-authored', () => {
217
+ // A normal bit-created branch: its own first ledger commit, then a later human dev commit and a
218
+ // second ledger commit from export-branch — non-sync work, but never the OLDEST record.
219
+ const log = [LANE_SYNC, 'feat: a later, normal dev commit\n', MAIN_SYNC].join('\x1e');
220
+ expect(oldestCommitIsNonSync(log)).to.equal(false);
221
+ });
222
+
223
+ it('is true when the oldest (first, with --reverse) record is a genuine, non-sync commit', () => {
224
+ // Adoption's exact shape: the branch's own first commit was a human's, before bit ever touched it.
225
+ const log = ['feat: a human created this branch before adoption\n', LANE_SYNC].join('\x1e');
226
+ expect(oldestCommitIsNonSync(log)).to.equal(true);
227
+ });
228
+
229
+ it('ignores a trailing separator with nothing after it', () => {
230
+ const log = `${LANE_SYNC}\x1e`;
231
+ expect(oldestCommitIsNonSync(log)).to.equal(false);
232
+ });
233
+
234
+ // A ledger commit needs the marker AND the Bit-Lane-Head trailer; a human message that merely
235
+ // quotes [bit-sync] on its own line must still read as independent history (a keep, never a delete).
236
+ it('is true when the oldest human commit quotes the [bit-sync] marker but has no ledger trailer', () => {
237
+ const spoof = 'feat: mention our tooling\n\n[bit-sync]\n';
238
+ const log = [spoof, LANE_SYNC].join('\x1e');
239
+ expect(oldestCommitIsNonSync(log)).to.equal(true);
240
+ });
241
+ });
242
+
210
243
  describe('fingerprintIdVersions', () => {
211
244
  it('is a single 40-hex token, and stable under reordering', () => {
212
245
  // A single token survives being written into a commit trailer; order-stability keeps neither
@@ -68,6 +68,12 @@ export function branchStateFingerprint(state: BranchBitmapState, laneComponentId
68
68
  return fingerprintIdVersions(laneComponentIds.map((id) => `${id}@${state.versions[id] ?? ABSENT_ON_BRANCH}`));
69
69
  }
70
70
 
71
+ /**
72
+ * `snapPrCommit`'s return value when there was nothing to snap — its only signal either way. Lives in
73
+ * this leaf module so `ci.main.runtime.ts` and `lane-sync-executor.ts` share it without a cycle.
74
+ */
75
+ export const NO_CHANGES_TO_SNAP = 'No changes detected, nothing to snap';
76
+
71
77
  export const LANE_HEAD_TRAILER = 'Bit-Lane-Head';
72
78
  /**
73
79
  * Marks a commit as machine-generated. Duplicated in the `bit-git-sync` action repo's event router
@@ -94,15 +100,49 @@ export function isSyncAuthoredMessage(message: string): boolean {
94
100
  return new RegExp(`^${SYNC_COMMIT_MARKER.replace(/[[\]]/g, '\\$&')}\\r?$`, 'm').test(message);
95
101
  }
96
102
 
103
+ /** The record separator `hasIndependentHistoryBelowStateCommit`'s `git log --format=%B%x1e` uses. */
104
+ const COMMIT_MESSAGE_RECORD_SEPARATOR = '\x1e';
105
+
106
+ /**
107
+ * Whether the OLDEST record of a `git log --reverse --format=%B%x1e` run is NOT bit-authored — i.e. a
108
+ * human created the branch before this reconciler touched it. Oldest only, not "any": ordinary lane
109
+ * branches carry real dev commits between ledger commits too, and "any" would misclassify them all.
110
+ */
111
+ export function oldestCommitIsNonSync(rawLog: string): boolean {
112
+ const messages = rawLog
113
+ .split(COMMIT_MESSAGE_RECORD_SEPARATOR)
114
+ .map((entry) => entry.trim())
115
+ .filter(Boolean);
116
+ const oldest = messages[0];
117
+ return oldest !== undefined && !isLedgerCommitMessage(oldest);
118
+ }
119
+
120
+ /**
121
+ * Strict probe for "this is one of our LEDGER commits": the sync marker on its own line AND the
122
+ * `Bit-Lane-Head` trailer — `buildSyncCommitMessage` always writes both. The deletion guard reads
123
+ * this instead of `isSyncAuthoredMessage` so a human commit merely quoting `[bit-sync]` still counts
124
+ * as independent history (a false "human" only ever keeps a branch).
125
+ */
126
+ export function isLedgerCommitMessage(message: string): boolean {
127
+ return isSyncAuthoredMessage(message) && new RegExp(`^${LANE_HEAD_TRAILER}: `, 'm').test(message);
128
+ }
129
+
130
+ /**
131
+ * Marks `adopt-branch`'s ledger commit. Audit-only — the deletion guard reads the branch's ancestry
132
+ * instead, since a trailer marks only the one commit it is on and later ledger commits carry none.
133
+ */
134
+ export const ADOPTION_TRAILER = 'Bit-Adopted';
135
+
97
136
  /**
98
137
  * The sync commit's message. Every part is an annotation (audit trail + loop-guard marker); nothing reads
99
138
  * it as state — messages are forgeable and rewritten on squash-merge. State comes from `.bitmap`.
100
139
  */
101
- export function buildSyncCommitMessage(laneIdStr: string, laneHead: string): string {
140
+ export function buildSyncCommitMessage(laneIdStr: string, laneHead: string, opts: { adopted?: boolean } = {}): string {
102
141
  return [
103
142
  `chore(bit-sync): sync lane ${laneIdStr} @ ${laneHead.slice(0, 9)}`,
104
143
  '',
105
144
  `${LANE_HEAD_TRAILER}: ${laneHead}`,
145
+ ...(opts.adopted ? [`${ADOPTION_TRAILER}: true`] : []),
106
146
  SYNC_COMMIT_MARKER,
107
147
  ].join('\n');
108
148
  }
@@ -163,6 +203,27 @@ export async function readBranchSyncState(
163
203
  return { stateCommit, bitmap, hasDevCommits: parseDevCommitCount(count), tipMessage, tipSha };
164
204
  }
165
205
 
206
+ /**
207
+ * Whether `branch`'s committed `.bitmap` is byte-identical (by blob sha) to the one at its merge-base
208
+ * with the default branch — i.e. the branch never asserted a `.bitmap` change of its own, so whatever
209
+ * pointer it carries is INHERITED. Compared at the fork point, not the default branch's current tip,
210
+ * which moves on and would false-block. False on any git error: unreadable must not license adoption.
211
+ */
212
+ export async function branchBitmapUnchangedSinceFork(branch: string, defaultBranch: string): Promise<boolean> {
213
+ try {
214
+ const base = (await git.raw(['merge-base', `origin/${defaultBranch}`, `origin/${branch}`])).trim();
215
+ if (!base) return false;
216
+ const [branchBlob, baseBlob] = await Promise.all([
217
+ git.raw(['rev-parse', `origin/${branch}:./${BIT_MAP}`]),
218
+ git.raw(['rev-parse', `${base}:./${BIT_MAP}`]),
219
+ ]);
220
+ const branchSha = branchBlob.trim();
221
+ return Boolean(branchSha) && branchSha === baseBlob.trim();
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+
166
227
  /**
167
228
  * A file's content at a git ref, or undefined when it isn't there. simple-git's `raw` can resolve with
168
229
  * empty output instead of rejecting on a missing path; `:./` keeps the path cwd-relative in case the