canopycms 0.0.61 → 0.0.62

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 (184) hide show
  1. package/dist/ai/json-to-markdown.js +1 -2
  2. package/dist/ai/json-to-markdown.js.map +1 -1
  3. package/dist/api/__test__/mock-client.d.ts +5 -1
  4. package/dist/api/__test__/mock-client.d.ts.map +1 -1
  5. package/dist/api/__test__/mock-client.js +9 -2
  6. package/dist/api/__test__/mock-client.js.map +1 -1
  7. package/dist/api/admin-branch-health.d.ts +36 -1
  8. package/dist/api/admin-branch-health.d.ts.map +1 -1
  9. package/dist/api/admin-branch-health.js +176 -2
  10. package/dist/api/admin-branch-health.js.map +1 -1
  11. package/dist/api/admin.d.ts +8 -1
  12. package/dist/api/admin.d.ts.map +1 -1
  13. package/dist/api/admin.js.map +1 -1
  14. package/dist/api/branch.d.ts.map +1 -1
  15. package/dist/api/branch.js +16 -2
  16. package/dist/api/branch.js.map +1 -1
  17. package/dist/api/client.d.ts +5 -1
  18. package/dist/api/client.d.ts.map +1 -1
  19. package/dist/api/client.js +6 -0
  20. package/dist/api/client.js.map +1 -1
  21. package/dist/api/comments.d.ts +2 -2
  22. package/dist/api/content.d.ts +11 -5
  23. package/dist/api/content.d.ts.map +1 -1
  24. package/dist/api/content.js +79 -6
  25. package/dist/api/content.js.map +1 -1
  26. package/dist/api/entries.d.ts +10 -0
  27. package/dist/api/entries.d.ts.map +1 -1
  28. package/dist/api/entries.js +40 -15
  29. package/dist/api/entries.js.map +1 -1
  30. package/dist/api/schema.d.ts +4 -4
  31. package/dist/api/schema.d.ts.map +1 -1
  32. package/dist/api/schema.js +13 -13
  33. package/dist/api/schema.js.map +1 -1
  34. package/dist/assets/transform.d.ts.map +1 -1
  35. package/dist/assets/transform.js +19 -6
  36. package/dist/assets/transform.js.map +1 -1
  37. package/dist/branch-health.d.ts +13 -0
  38. package/dist/branch-health.d.ts.map +1 -1
  39. package/dist/branch-health.js +23 -0
  40. package/dist/branch-health.js.map +1 -1
  41. package/dist/cli/cli.js +858 -537
  42. package/dist/cli/generate-ai-content.js +760 -455
  43. package/dist/cli/init.js +25 -7
  44. package/dist/cli/template-files/Dockerfile.cms.template +19 -1
  45. package/dist/cli/template-files/canopycms.config.ts.template +7 -0
  46. package/dist/cli/template-files/cms-stack.ts.template +7 -1
  47. package/dist/client.d.ts +1 -0
  48. package/dist/client.d.ts.map +1 -1
  49. package/dist/client.js.map +1 -1
  50. package/dist/config/schemas/field.d.ts +15 -15
  51. package/dist/config/types.d.ts +2 -2
  52. package/dist/config/types.d.ts.map +1 -1
  53. package/dist/config/types.js +0 -1
  54. package/dist/config/types.js.map +1 -1
  55. package/dist/config/validation.d.ts +9 -0
  56. package/dist/config/validation.d.ts.map +1 -1
  57. package/dist/config/validation.js +14 -0
  58. package/dist/config/validation.js.map +1 -1
  59. package/dist/content-id-index.d.ts +97 -5
  60. package/dist/content-id-index.d.ts.map +1 -1
  61. package/dist/content-id-index.js +170 -18
  62. package/dist/content-id-index.js.map +1 -1
  63. package/dist/content-store.d.ts +81 -7
  64. package/dist/content-store.d.ts.map +1 -1
  65. package/dist/content-store.js +443 -258
  66. package/dist/content-store.js.map +1 -1
  67. package/dist/editor/BranchManager.d.ts.map +1 -1
  68. package/dist/editor/BranchManager.js +18 -6
  69. package/dist/editor/BranchManager.js.map +1 -1
  70. package/dist/editor/CanopyEditor.d.ts.map +1 -1
  71. package/dist/editor/CanopyEditor.js +2 -2
  72. package/dist/editor/CanopyEditor.js.map +1 -1
  73. package/dist/editor/CanopyEditorPage.d.ts +9 -1
  74. package/dist/editor/CanopyEditorPage.d.ts.map +1 -1
  75. package/dist/editor/CanopyEditorPage.js +9 -2
  76. package/dist/editor/CanopyEditorPage.js.map +1 -1
  77. package/dist/editor/Editor.d.ts +9 -1
  78. package/dist/editor/Editor.d.ts.map +1 -1
  79. package/dist/editor/Editor.js +58 -24
  80. package/dist/editor/Editor.js.map +1 -1
  81. package/dist/editor/FormRenderer.d.ts.map +1 -1
  82. package/dist/editor/FormRenderer.js +12 -0
  83. package/dist/editor/FormRenderer.js.map +1 -1
  84. package/dist/editor/admin/SystemHealthPanel.js +6 -2
  85. package/dist/editor/admin/SystemHealthPanel.js.map +1 -1
  86. package/dist/editor/components/EntryCreateModal.d.ts +9 -1
  87. package/dist/editor/components/EntryCreateModal.d.ts.map +1 -1
  88. package/dist/editor/components/EntryCreateModal.js +4 -1
  89. package/dist/editor/components/EntryCreateModal.js.map +1 -1
  90. package/dist/editor/fields/DateTimeField.d.ts +46 -0
  91. package/dist/editor/fields/DateTimeField.d.ts.map +1 -0
  92. package/dist/editor/fields/DateTimeField.js +66 -0
  93. package/dist/editor/fields/DateTimeField.js.map +1 -0
  94. package/dist/editor/fields/NumberField.d.ts +34 -0
  95. package/dist/editor/fields/NumberField.d.ts.map +1 -0
  96. package/dist/editor/fields/NumberField.js +53 -0
  97. package/dist/editor/fields/NumberField.js.map +1 -0
  98. package/dist/editor/fields/NumberListField.d.ts +25 -0
  99. package/dist/editor/fields/NumberListField.d.ts.map +1 -0
  100. package/dist/editor/fields/NumberListField.js +53 -0
  101. package/dist/editor/fields/NumberListField.js.map +1 -0
  102. package/dist/editor/hooks/useBranchManager.d.ts.map +1 -1
  103. package/dist/editor/hooks/useBranchManager.js +115 -26
  104. package/dist/editor/hooks/useBranchManager.js.map +1 -1
  105. package/dist/editor/hooks/useDraftManager.d.ts +8 -0
  106. package/dist/editor/hooks/useDraftManager.d.ts.map +1 -1
  107. package/dist/editor/hooks/useDraftManager.js +261 -21
  108. package/dist/editor/hooks/useDraftManager.js.map +1 -1
  109. package/dist/editor/hooks/useEntryManager.d.ts +21 -0
  110. package/dist/editor/hooks/useEntryManager.d.ts.map +1 -1
  111. package/dist/editor/hooks/useEntryManager.js +25 -2
  112. package/dist/editor/hooks/useEntryManager.js.map +1 -1
  113. package/dist/editor/theme.d.ts +8 -8
  114. package/dist/git-manager.d.ts +51 -7
  115. package/dist/git-manager.d.ts.map +1 -1
  116. package/dist/git-manager.js +105 -14
  117. package/dist/git-manager.js.map +1 -1
  118. package/dist/http/handler.d.ts.map +1 -1
  119. package/dist/http/handler.js +70 -27
  120. package/dist/http/handler.js.map +1 -1
  121. package/dist/http/router.d.ts.map +1 -1
  122. package/dist/http/router.js +40 -3
  123. package/dist/http/router.js.map +1 -1
  124. package/dist/operating-mode/deployment-name-fixtures.d.ts +29 -0
  125. package/dist/operating-mode/deployment-name-fixtures.d.ts.map +1 -0
  126. package/dist/operating-mode/deployment-name-fixtures.js +55 -0
  127. package/dist/operating-mode/deployment-name-fixtures.js.map +1 -0
  128. package/dist/operating-mode/deployment-name.d.ts +9 -0
  129. package/dist/operating-mode/deployment-name.d.ts.map +1 -1
  130. package/dist/operating-mode/deployment-name.js +9 -1
  131. package/dist/operating-mode/deployment-name.js.map +1 -1
  132. package/dist/operating-mode/index.d.ts +2 -1
  133. package/dist/operating-mode/index.d.ts.map +1 -1
  134. package/dist/operating-mode/index.js +4 -1
  135. package/dist/operating-mode/index.js.map +1 -1
  136. package/dist/operating-mode/mode-env.d.ts +58 -0
  137. package/dist/operating-mode/mode-env.d.ts.map +1 -0
  138. package/dist/operating-mode/mode-env.js +97 -0
  139. package/dist/operating-mode/mode-env.js.map +1 -0
  140. package/dist/resolve-canopy-user.d.ts +34 -0
  141. package/dist/resolve-canopy-user.d.ts.map +1 -0
  142. package/dist/resolve-canopy-user.js +55 -0
  143. package/dist/resolve-canopy-user.js.map +1 -0
  144. package/dist/schema/schema-store.d.ts +53 -2
  145. package/dist/schema/schema-store.d.ts.map +1 -1
  146. package/dist/schema/schema-store.js +92 -21
  147. package/dist/schema/schema-store.js.map +1 -1
  148. package/dist/server.d.ts +9 -0
  149. package/dist/server.d.ts.map +1 -1
  150. package/dist/server.js +9 -0
  151. package/dist/server.js.map +1 -1
  152. package/dist/services.d.ts.map +1 -1
  153. package/dist/services.js +40 -6
  154. package/dist/services.js.map +1 -1
  155. package/dist/settings-workspace.d.ts +40 -0
  156. package/dist/settings-workspace.d.ts.map +1 -1
  157. package/dist/settings-workspace.js +139 -151
  158. package/dist/settings-workspace.js.map +1 -1
  159. package/dist/types.d.ts +10 -0
  160. package/dist/types.d.ts.map +1 -1
  161. package/dist/utils/content-write-lock.d.ts +127 -0
  162. package/dist/utils/content-write-lock.d.ts.map +1 -0
  163. package/dist/utils/content-write-lock.js +170 -0
  164. package/dist/utils/content-write-lock.js.map +1 -0
  165. package/dist/utils/error.d.ts +1 -1
  166. package/dist/utils/error.js +1 -1
  167. package/dist/utils/git.d.ts +16 -0
  168. package/dist/utils/git.d.ts.map +1 -1
  169. package/dist/utils/git.js +22 -0
  170. package/dist/utils/git.js.map +1 -1
  171. package/dist/utils/sanitize-href.d.ts +28 -3
  172. package/dist/utils/sanitize-href.d.ts.map +1 -1
  173. package/dist/utils/sanitize-href.js +64 -6
  174. package/dist/utils/sanitize-href.js.map +1 -1
  175. package/dist/validation/entry-link-validator.d.ts +1 -1
  176. package/dist/validation/entry-link-validator.js +3 -3
  177. package/dist/validation/entry-link-validator.js.map +1 -1
  178. package/dist/validation/entry-validator.js +1 -1
  179. package/dist/validation/entry-validator.js.map +1 -1
  180. package/dist/worker/cms-worker.d.ts +13 -0
  181. package/dist/worker/cms-worker.d.ts.map +1 -1
  182. package/dist/worker/cms-worker.js +326 -264
  183. package/dist/worker/cms-worker.js.map +1 -1
  184. package/package.json +2 -2
@@ -12,6 +12,7 @@ import { sanitizeBranchName, RESERVED_SETTINGS_BRANCH_PREFIX } from '../paths/br
12
12
  import { normalizeFilesystemPath } from '../paths/normalize.js';
13
13
  import { GITHUB_TRACKING_REF_PREFIX, gitChildEnv, gitNetworkChildEnv } from '../git-manager.js';
14
14
  import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
15
+ import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
15
16
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
16
17
  import { isNonFastForwardRejection, isStaleLeaseRejection } from '../utils/git.js';
17
18
  import { writeWorkerStatus } from './worker-status.js';
@@ -1465,6 +1466,7 @@ export class CmsWorker {
1465
1466
  durationMs: Date.now() - cycleStartedAt,
1466
1467
  rebased: rebaseSummary.rebased,
1467
1468
  skippedDirty: rebaseSummary.skippedDirty,
1469
+ skippedLocked: rebaseSummary.skippedLocked,
1468
1470
  failed: rebaseSummary.failed,
1469
1471
  tracked: trackedSummary,
1470
1472
  };
@@ -1765,19 +1767,33 @@ export class CmsWorker {
1765
1767
  workerLogWarn(` Failed to record rebase failure for ${branchDir}: ${getErrorMessage(err)}`);
1766
1768
  }
1767
1769
  }
1770
+ /**
1771
+ * Test-only seam: awaited inside `rebaseActiveBranches()`'s conflict round,
1772
+ * after `git rebase` reported conflicted files and BEFORE the
1773
+ * `checkout --theirs` resolution loop overwrites them. No-op in production.
1774
+ *
1775
+ * A test subclass overrides this to land a real `ContentStore` write at
1776
+ * exactly the instant the rebase is mid-flight -- the window the old TOCTOU
1777
+ * comment above the dirty check wrongly called safe -- without any sleeps or
1778
+ * shell rendezvous. See the "Deterministic interleavings" testing pattern in
1779
+ * docs/concurrency.md, and `ContentStore.afterPrePassForTesting()` for the
1780
+ * same idiom on the write side.
1781
+ */
1782
+ async afterConflictDetectedForTesting() { }
1768
1783
  async rebaseActiveBranches() {
1769
1784
  // PR-W1: collected across the loop below and returned as a summary
1770
1785
  // (folded into worker-status.json by syncGit()). Purely additive
1771
1786
  // bookkeeping -- doesn't change any control flow or existing logging.
1772
1787
  const rebased = [];
1773
1788
  const skippedDirty = [];
1789
+ const skippedLocked = [];
1774
1790
  const failed = [];
1775
1791
  let branchDirs;
1776
1792
  try {
1777
1793
  branchDirs = await fs.readdir(this.contentBranchesPath);
1778
1794
  }
1779
1795
  catch {
1780
- return { rebased, skippedDirty, failed };
1796
+ return { rebased, skippedDirty, skippedLocked, failed };
1781
1797
  }
1782
1798
  for (const branchDir of branchDirs) {
1783
1799
  // Known structural entries under content-branches/, not branch
@@ -1839,285 +1855,331 @@ export class CmsWorker {
1839
1855
  config: ['core.editor=true'],
1840
1856
  unsafe: { allowUnsafeEditor: true },
1841
1857
  });
1842
- // Skip dirty branches — editor has unsaved changes that can't be rebased.
1843
- // Note: there's a small TOCTOU window between this check and the rebase start.
1844
- // If an editor saves between here and `git rebase`, the rebase will fail and
1845
- // the catch block will abort safely — the branch stays behind and retries next cycle.
1846
- const dirtyCheck = await branchGit.status();
1847
- if (dirtyCheck.files.length > 0) {
1848
- workerLog(` Skipping ${branchDir}: has uncommitted changes`);
1849
- skippedDirty.push(branchDir);
1850
- continue;
1858
+ // [SYNC-C1] Take the branch's cross-host content-write lock BEFORE the
1859
+ // dirty check, and hold it for the whole rebase.
1860
+ //
1861
+ // The dirty check alone is check-then-act. The old comment here claimed
1862
+ // the residual window was safe ("the rebase will fail and the catch
1863
+ // block will abort safely"), which only holds for a save landing before
1864
+ // `git rebase` STARTS. After that -- a window spanning fetch, replay and
1865
+ // N conflict rounds of awaited git subprocesses on EFS -- a save is
1866
+ // destroyed two ways: `checkout --theirs` below overwrites the
1867
+ // just-saved file with the branch's committed version and the rebase
1868
+ // then SUCCEEDS (nothing logs a failure at all), and `rebase --abort`
1869
+ // hard-resets the tree. The editor already got its 200 either way.
1870
+ //
1871
+ // Zero-retry acquisition, deliberately: on contention this branch is
1872
+ // skipped and retried on the next sync cycle (~5 min), which is the
1873
+ // same principle as the skip-dirty-branches behavior below. Writers get
1874
+ // the patient side of the asymmetry -- see utils/content-write-lock.ts.
1875
+ //
1876
+ // The heartbeat that keeps this lock fresh (proper-lockfile refreshes
1877
+ // every `stale`/2 = 15s) is a timer on this event loop; every git step
1878
+ // below is an awaited subprocess, never a synchronous block, so the
1879
+ // refresh keeps firing for the whole hold.
1880
+ let releaseContentLock;
1881
+ try {
1882
+ releaseContentLock = await tryAcquireContentWriteLock(branchPath);
1851
1883
  }
1852
- // The clone's own ref name: branchDir is the sanitized DIRECTORY
1853
- // name and need not match it. A literal 'HEAD' means a detached
1854
- // clone (e.g. a crashed rebase left one behind) -- nothing below can
1855
- // safely name a ref then, so every publish path stays disarmed,
1856
- // which is the safe direction.
1857
- const branchRef = (await branchGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
1858
- const canPublish = branchRef.length > 0 && branchRef !== 'HEAD';
1859
- // [SYNC-H1] Self-heal: finish an interrupted publish even when this
1860
- // branch is not behind base. Every crash window in the arming
1861
- // sequence below leaves the marker set with the work unfinished, and
1862
- // this is what completes it -- without it, one lost lease race would
1863
- // strand the branch until the base branch happened to advance again.
1864
- // Gated on the loop-top snapshot, so unmarked branches (nearly all
1865
- // of them, every cycle) cost nothing extra.
1866
- if (canPublish && metaFile?.branch.historyRewrittenFrom) {
1867
- await this.reconcilePendingRewrite({
1868
- branchPath,
1869
- branchDir,
1870
- branchRef,
1871
- headSha: (await branchGit.revparse(['HEAD'])).trim(),
1872
- marker: metaFile.branch.historyRewrittenFrom,
1873
- });
1884
+ catch (lockErr) {
1885
+ if (isNodeError(lockErr) && lockErr.code === 'ELOCKED') {
1886
+ workerLog(` Skipping ${branchDir}: content write in progress (retrying next cycle)`);
1887
+ skippedLocked.push(branchDir);
1888
+ continue;
1889
+ }
1890
+ // Anything else (ENOENT on a branch dir deleted mid-cycle, EACCES,
1891
+ // ...) is a real failure: let the outer catch record it.
1892
+ throw lockErr;
1874
1893
  }
1875
- await branchGit.fetch('origin', this.baseBranch);
1876
- // Use rev-list instead of status.behind — status.behind only works when the
1877
- // branch has an upstream tracking branch configured, which isn't guaranteed
1878
- // (checkoutBranch fallback paths create branches without --track).
1879
- // The just-fetched tip, not origin/<base>: branch clones are
1880
- // --single-branch, so no remote-tracking ref exists for a base branch
1881
- // other than the one they were cloned from (see the base-refresh
1882
- // comment above). Pinned to a SHA immediately — FETCH_HEAD is one
1883
- // shared mutable file per repo, repointed by any concurrent fetch.
1884
- const fetchedBaseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
1885
- const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..${fetchedBaseTip}`])).trim(), 10);
1886
- const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
1887
- if (behindCount === 0) {
1888
- // Already in sync. This is the overwhelmingly common outcome per
1889
- // branch per cycle (most branches are caught up most of the
1890
- // time), so skip the save entirely when metadata already reflects
1891
- // a clean state -- every save() now eager-regenerates the branch
1892
- // registry (branch-metadata.ts's invalidateRegistry(), O(branch
1893
- // count) fs reads on EFS), so an unconditional save here turns
1894
- // every rebase cycle into O(N^2) registry work across N branches
1895
- // for what is otherwise a true no-op. Re-load fresh (not the
1896
- // `metaFile` snapshot from before the fetch/rev-list above) so a
1897
- // concurrent editor-driven metadata change during that window
1898
- // isn't clobbered by a stale skip decision.
1899
- const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
1900
- const conflictStatus = currentMeta?.branch.conflictStatus;
1901
- const conflictFiles = currentMeta?.branch.conflictFiles;
1902
- const conflictAlreadyClean = (conflictStatus === undefined || conflictStatus === 'clean') &&
1903
- (conflictFiles === undefined || conflictFiles.length === 0);
1904
- // PR-W2: a lingering rebaseFailure must also be cleared once the
1905
- // branch catches up clean -- otherwise it sticks as a stale
1906
- // warning forever (nothing else touches this branch once it's
1907
- // caught up, so no other save site would ever clear it).
1908
- const alreadyClean = conflictAlreadyClean && currentMeta?.branch.rebaseFailure === undefined;
1909
- if (alreadyClean) {
1894
+ try {
1895
+ // Skip dirty branches — editor has unsaved changes that can't be rebased.
1896
+ // Now inside the lock, so no write can land between this check and the
1897
+ // rebase below.
1898
+ const dirtyCheck = await branchGit.status();
1899
+ if (dirtyCheck.files.length > 0) {
1900
+ workerLog(` Skipping ${branchDir}: has uncommitted changes`);
1901
+ skippedDirty.push(branchDir);
1902
+ continue;
1903
+ }
1904
+ // The clone's own ref name: branchDir is the sanitized DIRECTORY
1905
+ // name and need not match it. A literal 'HEAD' means a detached
1906
+ // clone (e.g. a crashed rebase left one behind) -- nothing below can
1907
+ // safely name a ref then, so every publish path stays disarmed,
1908
+ // which is the safe direction.
1909
+ const branchRef = (await branchGit.revparse(['--abbrev-ref', 'HEAD'])).trim();
1910
+ const canPublish = branchRef.length > 0 && branchRef !== 'HEAD';
1911
+ // [SYNC-H1] Self-heal: finish an interrupted publish even when this
1912
+ // branch is not behind base. Every crash window in the arming
1913
+ // sequence below leaves the marker set with the work unfinished, and
1914
+ // this is what completes it -- without it, one lost lease race would
1915
+ // strand the branch until the base branch happened to advance again.
1916
+ // Gated on the loop-top snapshot, so unmarked branches (nearly all
1917
+ // of them, every cycle) cost nothing extra.
1918
+ if (canPublish && metaFile?.branch.historyRewrittenFrom) {
1919
+ await this.reconcilePendingRewrite({
1920
+ branchPath,
1921
+ branchDir,
1922
+ branchRef,
1923
+ headSha: (await branchGit.revparse(['HEAD'])).trim(),
1924
+ marker: metaFile.branch.historyRewrittenFrom,
1925
+ });
1926
+ }
1927
+ await branchGit.fetch('origin', this.baseBranch);
1928
+ // Use rev-list instead of status.behind — status.behind only works when the
1929
+ // branch has an upstream tracking branch configured, which isn't guaranteed
1930
+ // (checkoutBranch fallback paths create branches without --track).
1931
+ // The just-fetched tip, not origin/<base>: branch clones are
1932
+ // --single-branch, so no remote-tracking ref exists for a base branch
1933
+ // other than the one they were cloned from (see the base-refresh
1934
+ // comment above). Pinned to a SHA immediately — FETCH_HEAD is one
1935
+ // shared mutable file per repo, repointed by any concurrent fetch.
1936
+ const fetchedBaseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
1937
+ const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..${fetchedBaseTip}`])).trim(), 10);
1938
+ const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
1939
+ if (behindCount === 0) {
1940
+ // Already in sync. This is the overwhelmingly common outcome per
1941
+ // branch per cycle (most branches are caught up most of the
1942
+ // time), so skip the save entirely when metadata already reflects
1943
+ // a clean state -- every save() now eager-regenerates the branch
1944
+ // registry (branch-metadata.ts's invalidateRegistry(), O(branch
1945
+ // count) fs reads on EFS), so an unconditional save here turns
1946
+ // every rebase cycle into O(N^2) registry work across N branches
1947
+ // for what is otherwise a true no-op. Re-load fresh (not the
1948
+ // `metaFile` snapshot from before the fetch/rev-list above) so a
1949
+ // concurrent editor-driven metadata change during that window
1950
+ // isn't clobbered by a stale skip decision.
1951
+ const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
1952
+ const conflictStatus = currentMeta?.branch.conflictStatus;
1953
+ const conflictFiles = currentMeta?.branch.conflictFiles;
1954
+ const conflictAlreadyClean = (conflictStatus === undefined || conflictStatus === 'clean') &&
1955
+ (conflictFiles === undefined || conflictFiles.length === 0);
1956
+ // PR-W2: a lingering rebaseFailure must also be cleared once the
1957
+ // branch catches up clean -- otherwise it sticks as a stale
1958
+ // warning forever (nothing else touches this branch once it's
1959
+ // caught up, so no other save site would ever clear it).
1960
+ const alreadyClean = conflictAlreadyClean && currentMeta?.branch.rebaseFailure === undefined;
1961
+ if (alreadyClean) {
1962
+ continue;
1963
+ }
1964
+ await meta.save({
1965
+ branch: {
1966
+ name: branchDir,
1967
+ conflictStatus: 'clean',
1968
+ conflictFiles: [],
1969
+ rebaseFailure: undefined,
1970
+ },
1971
+ });
1972
+ continue;
1973
+ }
1974
+ workerLog(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
1975
+ // Read BOTH sides before rewriting anything: the arming guard after
1976
+ // the rebase compares what remote.git published against what this
1977
+ // clone is about to rebase away. Reading them afterwards would be
1978
+ // useless -- the clone's pre-rebase tip is exactly what disappears.
1979
+ const preRebaseHead = (await branchGit.revparse(['HEAD'])).trim();
1980
+ const publishedSha = canPublish ? await this.readPublishedSha(branchRef) : null;
1981
+ // Resolve-and-continue loop: keep branch version for conflicting files, then continue
1982
+ // Non-conflicting files get main's changes; conflicting files keep branch version.
1983
+ const conflictedFiles = [];
1984
+ let nextAction = 'start';
1985
+ let completed = false;
1986
+ // PR-W1: captured only on the "unexpected error" exit below, for the
1987
+ // failed-summary entry pushed at the `if (!completed)` check.
1988
+ let failureReason;
1989
+ const MAX_ROUNDS = 50; // safety limit against infinite loops
1990
+ for (let round = 0; round < MAX_ROUNDS && !completed; round++) {
1991
+ try {
1992
+ if (nextAction === 'start') {
1993
+ // The pinned base tip fetched above (single-branch clones have
1994
+ // no origin/<base> remote-tracking ref for other branches).
1995
+ await branchGit.rebase([fetchedBaseTip]);
1996
+ }
1997
+ else if (nextAction === 'continue') {
1998
+ await branchGit.rebase(['--continue']);
1999
+ }
2000
+ else {
2001
+ await branchGit.rebase(['--skip']);
2002
+ }
2003
+ completed = true;
2004
+ }
2005
+ catch (rebaseErr) {
2006
+ nextAction = 'continue';
2007
+ const st = await branchGit.status();
2008
+ if (st.conflicted.length > 0) {
2009
+ await this.afterConflictDetectedForTesting();
2010
+ // During rebase, --theirs = the branch being replayed (editor's work).
2011
+ // (git rebase reverses ours/theirs: "ours" is the rebase target, "theirs" is the branch.)
2012
+ for (const file of st.conflicted) {
2013
+ await branchGit.raw(['checkout', '--theirs', file]);
2014
+ await branchGit.add(file);
2015
+ conflictedFiles.push(file);
2016
+ }
2017
+ // nextAction stays 'continue'
2018
+ }
2019
+ else {
2020
+ const msg = rebaseErr instanceof Error ? rebaseErr.message : '';
2021
+ if (msg.toLowerCase().includes('nothing to commit') ||
2022
+ msg.toLowerCase().includes('apply --skip')) {
2023
+ // Empty commit after --theirs resolution — skip it
2024
+ nextAction = 'skip';
2025
+ }
2026
+ else {
2027
+ // Unexpected error — abort and leave branch behind.
2028
+ // We intentionally don't update conflictStatus/conflictFiles here:
2029
+ // the rebase didn't complete so we can't determine the true conflict
2030
+ // state. Previous metadata (possibly stale) is preserved until the
2031
+ // next successful rebase cycle corrects it.
2032
+ workerLogWarn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
2033
+ failureReason = msg || 'Unknown error';
2034
+ await branchGit.rebase(['--abort']).catch(() => { });
2035
+ break;
2036
+ }
2037
+ }
2038
+ }
2039
+ }
2040
+ if (!completed) {
2041
+ // PR-W2 (M1 rider): failureReason is only set on the "unexpected
2042
+ // error" break above -- MAX_ROUNDS exhaustion is a distinct exit
2043
+ // path with no error message of its own, so the warn text must
2044
+ // not conflate the two.
2045
+ workerLogWarn(failureReason !== undefined
2046
+ ? ` Rebase of ${branchDir} aborted due to unexpected error: ${failureReason}`
2047
+ : ` Rebase of ${branchDir} did not complete within ${MAX_ROUNDS} rounds, aborting`);
2048
+ await branchGit.rebase(['--abort']).catch(() => { });
2049
+ const rebaseFailureMessage = failureReason ?? `did not complete within ${MAX_ROUNDS} rounds`;
2050
+ // [HIGH-1] failed[] folds into worker-status.json's
2051
+ // lastGitSync.failed, served to the browser -- failureReason can
2052
+ // be an arbitrary git error message that embeds the bot token.
2053
+ const redactedRebaseFailureMessage = redactCredentials(rebaseFailureMessage);
2054
+ failed.push({
2055
+ branch: branchDir,
2056
+ error: redactedRebaseFailureMessage,
2057
+ });
2058
+ // PR-W2: record once here for the "!completed" exit -- the
2059
+ // unexpected-error break above is NOT disjoint from this block (it
2060
+ // always falls through here), so recording at the break itself
2061
+ // would double-record. The outer catch below is the only other
2062
+ // record site (a distinct, non-overlapping failure class: errors
2063
+ // outside this round loop, e.g. fetch/rev-list failures).
2064
+ await this.recordRebaseFailure(branchPath, branchDir, redactedRebaseFailureMessage);
1910
2065
  continue;
1911
2066
  }
2067
+ // The rebase rewrote the branch clone's working tree — mark ContentStore
2068
+ // ID indexes rooted here stale so lookups rebuild from disk, in this
2069
+ // process and (via the on-disk generation marker) in the Lambda
2070
+ // containers sharing this filesystem.
2071
+ await invalidateBranchContentCaches(branchPath);
2072
+ // Convert file paths to ContentIds — immutable, survives slug renames.
2073
+ // Entry files have IDs in their filename (e.g., "post.slug.a1b2c3d4e5f6.mdx").
2074
+ // .collection.json files have no ID themselves (extractIdFromFilename returns null
2075
+ // for dot-prefixed files), so we extract the ID from the parent directory instead.
2076
+ // The root content directory (e.g., "content/", or "cms/content/" for a
2077
+ // multi-segment contentRoot) has no embedded ID, so we use ROOT_COLLECTION_ID
2078
+ // as a sentinel — but only for the configured contentRoot.
2079
+ //
2080
+ // Two different notions of "parent" are needed below, and conflating them
2081
+ // reproduces the exact bug this comparison guards against (see
2082
+ // schema-store.ts's contentRootName doc comment for the same shape elsewhere):
2083
+ // - `parentDir` (a basename) recovers a SUB-collection's own embedded ID
2084
+ // (e.g. "posts.cNbR5xFm2Kpd" -> "cNbR5xFm2Kpd") — correct as a basename,
2085
+ // since a collection directory carries its ID in its own name, one path
2086
+ // segment.
2087
+ // - `parentPath` (the full relative parent path, normalized) is what must be
2088
+ // compared against `this.contentRoot`, because `contentRoot` is documented
2089
+ // (config/helpers.ts) as allowed to span multiple segments (e.g.
2090
+ // "cms/content"). Comparing a basename ("content") against that full value
2091
+ // is always false, which silently drops the root collection's conflict.
2092
+ // Git reports POSIX-style paths and the configured value may be authored
2093
+ // with either separator, so both sides go through normalizeFilesystemPath
2094
+ // before comparing.
2095
+ const normalizedContentRoot = normalizeFilesystemPath(this.contentRoot);
2096
+ const conflictIds = [...new Set(conflictedFiles)]
2097
+ .map((f) => {
2098
+ const fileId = extractIdFromFilename(path.basename(f));
2099
+ if (fileId)
2100
+ return fileId;
2101
+ const parentDir = path.basename(path.dirname(f));
2102
+ const dirId = extractIdFromFilename(parentDir);
2103
+ if (dirId)
2104
+ return dirId;
2105
+ // Only assign ROOT_COLLECTION_ID when the file's parent directory IS the
2106
+ // configured content root. Other unrecognized paths are filtered out.
2107
+ const parentPath = normalizeFilesystemPath(path.dirname(f));
2108
+ if (path.basename(f) === '.collection.json' && parentPath === normalizedContentRoot) {
2109
+ return ROOT_COLLECTION_ID;
2110
+ }
2111
+ return null;
2112
+ })
2113
+ .filter((id) => id !== null);
2114
+ const conflictIdsDeduped = [...new Set(conflictIds)];
2115
+ const hadConflicts = conflictIdsDeduped.length > 0;
2116
+ workerLog(hadConflicts
2117
+ ? ` Rebased ${branchDir} (kept branch version for ${conflictIdsDeduped.length} conflicting file(s))`
2118
+ : ` Rebased ${branchDir} successfully`);
1912
2119
  await meta.save({
1913
2120
  branch: {
1914
2121
  name: branchDir,
1915
- conflictStatus: 'clean',
1916
- conflictFiles: [],
2122
+ conflictStatus: hadConflicts ? 'conflicts-detected' : 'clean',
2123
+ conflictFiles: conflictIdsDeduped,
2124
+ // PR-W2: the cycle completed successfully -- clear any prior
2125
+ // failure record regardless of conflict outcome.
1917
2126
  rebaseFailure: undefined,
1918
2127
  },
1919
2128
  });
1920
- continue;
1921
- }
1922
- workerLog(`Rebasing ${branchDir} (${behindCount} commits behind)...`);
1923
- // Read BOTH sides before rewriting anything: the arming guard after
1924
- // the rebase compares what remote.git published against what this
1925
- // clone is about to rebase away. Reading them afterwards would be
1926
- // useless -- the clone's pre-rebase tip is exactly what disappears.
1927
- const preRebaseHead = (await branchGit.revparse(['HEAD'])).trim();
1928
- const publishedSha = canPublish ? await this.readPublishedSha(branchRef) : null;
1929
- // Resolve-and-continue loop: keep branch version for conflicting files, then continue
1930
- // Non-conflicting files get main's changes; conflicting files keep branch version.
1931
- const conflictedFiles = [];
1932
- let nextAction = 'start';
1933
- let completed = false;
1934
- // PR-W1: captured only on the "unexpected error" exit below, for the
1935
- // failed-summary entry pushed at the `if (!completed)` check.
1936
- let failureReason;
1937
- const MAX_ROUNDS = 50; // safety limit against infinite loops
1938
- for (let round = 0; round < MAX_ROUNDS && !completed; round++) {
1939
- try {
1940
- if (nextAction === 'start') {
1941
- // The pinned base tip fetched above (single-branch clones have
1942
- // no origin/<base> remote-tracking ref for other branches).
1943
- await branchGit.rebase([fetchedBaseTip]);
1944
- }
1945
- else if (nextAction === 'continue') {
1946
- await branchGit.rebase(['--continue']);
1947
- }
1948
- else {
1949
- await branchGit.rebase(['--skip']);
1950
- }
1951
- completed = true;
1952
- }
1953
- catch (rebaseErr) {
1954
- nextAction = 'continue';
1955
- const st = await branchGit.status();
1956
- if (st.conflicted.length > 0) {
1957
- // During rebase, --theirs = the branch being replayed (editor's work).
1958
- // (git rebase reverses ours/theirs: "ours" is the rebase target, "theirs" is the branch.)
1959
- for (const file of st.conflicted) {
1960
- await branchGit.raw(['checkout', '--theirs', file]);
1961
- await branchGit.add(file);
1962
- conflictedFiles.push(file);
2129
+ // PR-W1: the branch was behind and the rebase completed (with or
2130
+ // without --theirs conflict resolution) -- it moved, so it belongs
2131
+ // in the summary. Branches already up to date `continue`d above and
2132
+ // are deliberately not listed here.
2133
+ rebased.push(branchDir);
2134
+ // [SYNC-H1] The rebase just rewrote this clone's history. If that
2135
+ // history was already published, nothing else will ever reconcile
2136
+ // remote.git (and GitHub) with it -- the editor's next submit would
2137
+ // simply be rejected non-fast-forward. Carry the rewrite forward.
2138
+ if (publishedSha !== null) {
2139
+ if (publishedSha === preRebaseHead) {
2140
+ // ARMING GUARD. remote.git holds EXACTLY what this clone just
2141
+ // rebased away and nothing more, so a lease keyed to it can only
2142
+ // undo our own rewrite.
2143
+ //
2144
+ // The inequality case below is not defensive padding: branch
2145
+ // clones never fetch their own branch (GitManager's clone is
2146
+ // --single-branch and checkoutBranch only checks out an existing
2147
+ // local branch), while reconcileTrackedBranches fast-forwards
2148
+ // remote.git to GitHub's tip. So after a reviewer pushes a fixup
2149
+ // straight to the PR branch, remote.git legitimately holds a
2150
+ // commit this clone has never seen. Leasing on "whatever
2151
+ // remote.git currently holds" would be SATISFIED there and would
2152
+ // delete that fixup from remote.git and then from GitHub,
2153
+ // silently. Keying the lease to the pre-rebase tip turns that
2154
+ // case into the visible divergence it should be.
2155
+ //
2156
+ // Order matters: mark, then push, then queue. A crash after any
2157
+ // step leaves the marker set with the work unfinished, which
2158
+ // reconcilePendingRewrite() completes on a later cycle. Pushing
2159
+ // first would leave remote.git rewritten, GitHub stale and
2160
+ // nothing recorded -- unrecoverable, and landing on exactly the
2161
+ // false "another deployment" diagnosis this change removes.
2162
+ await this.markHistoryRewritten(branchPath, branchDir, publishedSha);
2163
+ if (await this.forcePublishToLocalRemote(branchPath, branchRef, publishedSha)) {
2164
+ await this.enqueueGitHubPush(branchRef);
1963
2165
  }
1964
- // nextAction stays 'continue'
1965
2166
  }
1966
2167
  else {
1967
- const msg = rebaseErr instanceof Error ? rebaseErr.message : '';
1968
- if (msg.toLowerCase().includes('nothing to commit') ||
1969
- msg.toLowerCase().includes('apply --skip')) {
1970
- // Empty commit after --theirs resolution — skip it
1971
- nextAction = 'skip';
1972
- }
1973
- else {
1974
- // Unexpected error — abort and leave branch behind.
1975
- // We intentionally don't update conflictStatus/conflictFiles here:
1976
- // the rebase didn't complete so we can't determine the true conflict
1977
- // state. Previous metadata (possibly stale) is preserved until the
1978
- // next successful rebase cycle corrects it.
1979
- workerLogWarn(` Unexpected rebase error in ${branchDir}: ${msg || 'Unknown error'}`);
1980
- failureReason = msg || 'Unknown error';
1981
- await branchGit.rebase(['--abort']).catch(() => { });
1982
- break;
1983
- }
2168
+ const divergence = `rebased locally, but remote.git holds ${publishedSha} for ${branchRef}, which this ` +
2169
+ `clone never had (a direct push to the branch?). Left untouched -- reconcile it ` +
2170
+ `before submitting again.`;
2171
+ workerLogWarn(` ${branchDir}: ${divergence}`);
2172
+ await this.recordRebaseFailure(branchPath, branchDir, divergence);
1984
2173
  }
1985
2174
  }
1986
2175
  }
1987
- if (!completed) {
1988
- // PR-W2 (M1 rider): failureReason is only set on the "unexpected
1989
- // error" break above -- MAX_ROUNDS exhaustion is a distinct exit
1990
- // path with no error message of its own, so the warn text must
1991
- // not conflate the two.
1992
- workerLogWarn(failureReason !== undefined
1993
- ? ` Rebase of ${branchDir} aborted due to unexpected error: ${failureReason}`
1994
- : ` Rebase of ${branchDir} did not complete within ${MAX_ROUNDS} rounds, aborting`);
1995
- await branchGit.rebase(['--abort']).catch(() => { });
1996
- const rebaseFailureMessage = failureReason ?? `did not complete within ${MAX_ROUNDS} rounds`;
1997
- // [HIGH-1] failed[] folds into worker-status.json's
1998
- // lastGitSync.failed, served to the browser -- failureReason can
1999
- // be an arbitrary git error message that embeds the bot token.
2000
- const redactedRebaseFailureMessage = redactCredentials(rebaseFailureMessage);
2001
- failed.push({
2002
- branch: branchDir,
2003
- error: redactedRebaseFailureMessage,
2176
+ finally {
2177
+ // [SYNC-C1] Released on EVERY exit -- the `continue`s above, a throw
2178
+ // into the outer catch, and the happy path alike. A stranded lock
2179
+ // would wedge every write to this branch until it went stale.
2180
+ await releaseContentLock?.().catch((releaseErr) => {
2181
+ workerLogWarn(` Failed to release content-write lock for ${branchDir}: ${getErrorMessage(releaseErr)}`);
2004
2182
  });
2005
- // PR-W2: record once here for the "!completed" exit -- the
2006
- // unexpected-error break above is NOT disjoint from this block (it
2007
- // always falls through here), so recording at the break itself
2008
- // would double-record. The outer catch below is the only other
2009
- // record site (a distinct, non-overlapping failure class: errors
2010
- // outside this round loop, e.g. fetch/rev-list failures).
2011
- await this.recordRebaseFailure(branchPath, branchDir, redactedRebaseFailureMessage);
2012
- continue;
2013
- }
2014
- // The rebase rewrote the branch clone's working tree — mark ContentStore
2015
- // ID indexes rooted here stale so lookups rebuild from disk, in this
2016
- // process and (via the on-disk generation marker) in the Lambda
2017
- // containers sharing this filesystem.
2018
- await invalidateBranchContentCaches(branchPath);
2019
- // Convert file paths to ContentIds — immutable, survives slug renames.
2020
- // Entry files have IDs in their filename (e.g., "post.slug.a1b2c3d4e5f6.mdx").
2021
- // .collection.json files have no ID themselves (extractIdFromFilename returns null
2022
- // for dot-prefixed files), so we extract the ID from the parent directory instead.
2023
- // The root content directory (e.g., "content/", or "cms/content/" for a
2024
- // multi-segment contentRoot) has no embedded ID, so we use ROOT_COLLECTION_ID
2025
- // as a sentinel — but only for the configured contentRoot.
2026
- //
2027
- // Two different notions of "parent" are needed below, and conflating them
2028
- // reproduces the exact bug this comparison guards against (see
2029
- // schema-store.ts's contentRootName doc comment for the same shape elsewhere):
2030
- // - `parentDir` (a basename) recovers a SUB-collection's own embedded ID
2031
- // (e.g. "posts.cNbR5xFm2Kpd" -> "cNbR5xFm2Kpd") — correct as a basename,
2032
- // since a collection directory carries its ID in its own name, one path
2033
- // segment.
2034
- // - `parentPath` (the full relative parent path, normalized) is what must be
2035
- // compared against `this.contentRoot`, because `contentRoot` is documented
2036
- // (config/helpers.ts) as allowed to span multiple segments (e.g.
2037
- // "cms/content"). Comparing a basename ("content") against that full value
2038
- // is always false, which silently drops the root collection's conflict.
2039
- // Git reports POSIX-style paths and the configured value may be authored
2040
- // with either separator, so both sides go through normalizeFilesystemPath
2041
- // before comparing.
2042
- const normalizedContentRoot = normalizeFilesystemPath(this.contentRoot);
2043
- const conflictIds = [...new Set(conflictedFiles)]
2044
- .map((f) => {
2045
- const fileId = extractIdFromFilename(path.basename(f));
2046
- if (fileId)
2047
- return fileId;
2048
- const parentDir = path.basename(path.dirname(f));
2049
- const dirId = extractIdFromFilename(parentDir);
2050
- if (dirId)
2051
- return dirId;
2052
- // Only assign ROOT_COLLECTION_ID when the file's parent directory IS the
2053
- // configured content root. Other unrecognized paths are filtered out.
2054
- const parentPath = normalizeFilesystemPath(path.dirname(f));
2055
- if (path.basename(f) === '.collection.json' && parentPath === normalizedContentRoot) {
2056
- return ROOT_COLLECTION_ID;
2057
- }
2058
- return null;
2059
- })
2060
- .filter((id) => id !== null);
2061
- const conflictIdsDeduped = [...new Set(conflictIds)];
2062
- const hadConflicts = conflictIdsDeduped.length > 0;
2063
- workerLog(hadConflicts
2064
- ? ` Rebased ${branchDir} (kept branch version for ${conflictIdsDeduped.length} conflicting file(s))`
2065
- : ` Rebased ${branchDir} successfully`);
2066
- await meta.save({
2067
- branch: {
2068
- name: branchDir,
2069
- conflictStatus: hadConflicts ? 'conflicts-detected' : 'clean',
2070
- conflictFiles: conflictIdsDeduped,
2071
- // PR-W2: the cycle completed successfully -- clear any prior
2072
- // failure record regardless of conflict outcome.
2073
- rebaseFailure: undefined,
2074
- },
2075
- });
2076
- // PR-W1: the branch was behind and the rebase completed (with or
2077
- // without --theirs conflict resolution) -- it moved, so it belongs
2078
- // in the summary. Branches already up to date `continue`d above and
2079
- // are deliberately not listed here.
2080
- rebased.push(branchDir);
2081
- // [SYNC-H1] The rebase just rewrote this clone's history. If that
2082
- // history was already published, nothing else will ever reconcile
2083
- // remote.git (and GitHub) with it -- the editor's next submit would
2084
- // simply be rejected non-fast-forward. Carry the rewrite forward.
2085
- if (publishedSha !== null) {
2086
- if (publishedSha === preRebaseHead) {
2087
- // ARMING GUARD. remote.git holds EXACTLY what this clone just
2088
- // rebased away and nothing more, so a lease keyed to it can only
2089
- // undo our own rewrite.
2090
- //
2091
- // The inequality case below is not defensive padding: branch
2092
- // clones never fetch their own branch (GitManager's clone is
2093
- // --single-branch and checkoutBranch only checks out an existing
2094
- // local branch), while reconcileTrackedBranches fast-forwards
2095
- // remote.git to GitHub's tip. So after a reviewer pushes a fixup
2096
- // straight to the PR branch, remote.git legitimately holds a
2097
- // commit this clone has never seen. Leasing on "whatever
2098
- // remote.git currently holds" would be SATISFIED there and would
2099
- // delete that fixup from remote.git and then from GitHub,
2100
- // silently. Keying the lease to the pre-rebase tip turns that
2101
- // case into the visible divergence it should be.
2102
- //
2103
- // Order matters: mark, then push, then queue. A crash after any
2104
- // step leaves the marker set with the work unfinished, which
2105
- // reconcilePendingRewrite() completes on a later cycle. Pushing
2106
- // first would leave remote.git rewritten, GitHub stale and
2107
- // nothing recorded -- unrecoverable, and landing on exactly the
2108
- // false "another deployment" diagnosis this change removes.
2109
- await this.markHistoryRewritten(branchPath, branchDir, publishedSha);
2110
- if (await this.forcePublishToLocalRemote(branchPath, branchRef, publishedSha)) {
2111
- await this.enqueueGitHubPush(branchRef);
2112
- }
2113
- }
2114
- else {
2115
- const divergence = `rebased locally, but remote.git holds ${publishedSha} for ${branchRef}, which this ` +
2116
- `clone never had (a direct push to the branch?). Left untouched -- reconcile it ` +
2117
- `before submitting again.`;
2118
- workerLogWarn(` ${branchDir}: ${divergence}`);
2119
- await this.recordRebaseFailure(branchPath, branchDir, divergence);
2120
- }
2121
2183
  }
2122
2184
  }
2123
2185
  catch (err) {
@@ -2134,7 +2196,7 @@ export class CmsWorker {
2134
2196
  await this.recordRebaseFailure(branchPath, branchDir, redactedMessage);
2135
2197
  }
2136
2198
  }
2137
- return { rebased, skippedDirty, failed };
2199
+ return { rebased, skippedDirty, skippedLocked, failed };
2138
2200
  }
2139
2201
  async refreshAuthCache() {
2140
2202
  if (!this.running || !this.config.refreshAuthCache)