canopycms 0.0.58 → 0.0.60

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 (134) hide show
  1. package/dist/api/__test__/mock-client.js +4 -4
  2. package/dist/api/__test__/mock-client.js.map +1 -1
  3. package/dist/api/branch-merge.d.ts.map +1 -1
  4. package/dist/api/branch-merge.js +2 -6
  5. package/dist/api/branch-merge.js.map +1 -1
  6. package/dist/api/branch-withdraw.d.ts.map +1 -1
  7. package/dist/api/branch-withdraw.js +19 -4
  8. package/dist/api/branch-withdraw.js.map +1 -1
  9. package/dist/api/entries.d.ts.map +1 -1
  10. package/dist/api/entries.js +22 -3
  11. package/dist/api/entries.js.map +1 -1
  12. package/dist/api/groups.d.ts +3 -0
  13. package/dist/api/groups.d.ts.map +1 -1
  14. package/dist/api/groups.js +89 -68
  15. package/dist/api/groups.js.map +1 -1
  16. package/dist/api/permissions.d.ts +1 -0
  17. package/dist/api/permissions.d.ts.map +1 -1
  18. package/dist/api/permissions.js +23 -20
  19. package/dist/api/permissions.js.map +1 -1
  20. package/dist/api/schema.d.ts.map +1 -1
  21. package/dist/api/schema.js +29 -15
  22. package/dist/api/schema.js.map +1 -1
  23. package/dist/authorization/groups/index.d.ts +1 -1
  24. package/dist/authorization/groups/index.d.ts.map +1 -1
  25. package/dist/authorization/groups/index.js +1 -1
  26. package/dist/authorization/groups/index.js.map +1 -1
  27. package/dist/authorization/groups/loader.d.ts +25 -4
  28. package/dist/authorization/groups/loader.d.ts.map +1 -1
  29. package/dist/authorization/groups/loader.js +39 -20
  30. package/dist/authorization/groups/loader.js.map +1 -1
  31. package/dist/authorization/groups/schema.d.ts +8 -7
  32. package/dist/authorization/groups/schema.d.ts.map +1 -1
  33. package/dist/authorization/groups/schema.js +8 -4
  34. package/dist/authorization/groups/schema.js.map +1 -1
  35. package/dist/authorization/index.d.ts +4 -3
  36. package/dist/authorization/index.d.ts.map +1 -1
  37. package/dist/authorization/index.js +6 -3
  38. package/dist/authorization/index.js.map +1 -1
  39. package/dist/authorization/path.d.ts +8 -0
  40. package/dist/authorization/path.d.ts.map +1 -1
  41. package/dist/authorization/path.js +16 -1
  42. package/dist/authorization/path.js.map +1 -1
  43. package/dist/authorization/permissions/index.d.ts +1 -1
  44. package/dist/authorization/permissions/index.d.ts.map +1 -1
  45. package/dist/authorization/permissions/index.js +1 -1
  46. package/dist/authorization/permissions/index.js.map +1 -1
  47. package/dist/authorization/permissions/loader.d.ts +18 -4
  48. package/dist/authorization/permissions/loader.d.ts.map +1 -1
  49. package/dist/authorization/permissions/loader.js +35 -24
  50. package/dist/authorization/permissions/loader.js.map +1 -1
  51. package/dist/authorization/permissions/schema.d.ts +8 -7
  52. package/dist/authorization/permissions/schema.d.ts.map +1 -1
  53. package/dist/authorization/permissions/schema.js +8 -4
  54. package/dist/authorization/permissions/schema.js.map +1 -1
  55. package/dist/authorization/settings-file-store.d.ts +127 -0
  56. package/dist/authorization/settings-file-store.d.ts.map +1 -0
  57. package/dist/authorization/settings-file-store.js +156 -0
  58. package/dist/authorization/settings-file-store.js.map +1 -0
  59. package/dist/branch-metadata.d.ts +11 -0
  60. package/dist/branch-metadata.d.ts.map +1 -1
  61. package/dist/branch-metadata.js +22 -0
  62. package/dist/branch-metadata.js.map +1 -1
  63. package/dist/branch-workspace.d.ts.map +1 -1
  64. package/dist/branch-workspace.js +1 -0
  65. package/dist/branch-workspace.js.map +1 -1
  66. package/dist/cli/cli.d.ts +20 -0
  67. package/dist/cli/cli.d.ts.map +1 -1
  68. package/dist/cli/cli.js +9618 -246
  69. package/dist/cli/cli.js.map +1 -1
  70. package/dist/cli/generate-ai-content.js +101 -12
  71. package/dist/cli/init.d.ts.map +1 -1
  72. package/dist/cli/init.js +25 -3
  73. package/dist/cli/init.js.map +1 -1
  74. package/dist/cli/migrate.d.ts.map +1 -1
  75. package/dist/cli/migrate.js +39 -11
  76. package/dist/cli/migrate.js.map +1 -1
  77. package/dist/cli/template-files/Dockerfile.cms.template +33 -1
  78. package/dist/cli/template-files/deploy-cms.yml.template +9 -1
  79. package/dist/cli/template-files/dockerignore.template +13 -0
  80. package/dist/cli/template-files/middleware-clerk.ts.template +12 -5
  81. package/dist/cli/template-files/middleware.ts.template +10 -5
  82. package/dist/cli/templates.d.ts +1 -0
  83. package/dist/cli/templates.d.ts.map +1 -1
  84. package/dist/cli/templates.js +3 -0
  85. package/dist/cli/templates.js.map +1 -1
  86. package/dist/config/index.d.ts +1 -1
  87. package/dist/config/index.d.ts.map +1 -1
  88. package/dist/config/index.js.map +1 -1
  89. package/dist/config/schemas/config.d.ts +68 -6
  90. package/dist/config/schemas/config.d.ts.map +1 -1
  91. package/dist/config/schemas/config.js +32 -2
  92. package/dist/config/schemas/config.js.map +1 -1
  93. package/dist/config/types.d.ts +31 -1
  94. package/dist/config/types.d.ts.map +1 -1
  95. package/dist/context.d.ts +3 -0
  96. package/dist/context.d.ts.map +1 -1
  97. package/dist/context.js +25 -3
  98. package/dist/context.js.map +1 -1
  99. package/dist/editor/BranchManager.d.ts +3 -0
  100. package/dist/editor/BranchManager.d.ts.map +1 -1
  101. package/dist/editor/BranchManager.js +18 -5
  102. package/dist/editor/BranchManager.js.map +1 -1
  103. package/dist/editor/hooks/useBranchManager.d.ts +3 -1
  104. package/dist/editor/hooks/useBranchManager.d.ts.map +1 -1
  105. package/dist/editor/hooks/useBranchManager.js +2 -0
  106. package/dist/editor/hooks/useBranchManager.js.map +1 -1
  107. package/dist/editor/hooks/useGroupManager.d.ts.map +1 -1
  108. package/dist/editor/hooks/useGroupManager.js +13 -2
  109. package/dist/editor/hooks/useGroupManager.js.map +1 -1
  110. package/dist/editor/hooks/usePermissionManager.d.ts.map +1 -1
  111. package/dist/editor/hooks/usePermissionManager.js +13 -2
  112. package/dist/editor/hooks/usePermissionManager.js.map +1 -1
  113. package/dist/git-manager.d.ts +39 -0
  114. package/dist/git-manager.d.ts.map +1 -1
  115. package/dist/git-manager.js +104 -8
  116. package/dist/git-manager.js.map +1 -1
  117. package/dist/schema/schema-store.d.ts +98 -0
  118. package/dist/schema/schema-store.d.ts.map +1 -1
  119. package/dist/schema/schema-store.js +187 -29
  120. package/dist/schema/schema-store.js.map +1 -1
  121. package/dist/settings-workspace.d.ts.map +1 -1
  122. package/dist/settings-workspace.js +1 -0
  123. package/dist/settings-workspace.js.map +1 -1
  124. package/dist/types.d.ts +13 -0
  125. package/dist/types.d.ts.map +1 -1
  126. package/dist/utils/git.d.ts +19 -0
  127. package/dist/utils/git.d.ts.map +1 -1
  128. package/dist/utils/git.js +47 -0
  129. package/dist/utils/git.js.map +1 -1
  130. package/dist/worker/cms-worker.d.ts +64 -0
  131. package/dist/worker/cms-worker.d.ts.map +1 -1
  132. package/dist/worker/cms-worker.js +318 -28
  133. package/dist/worker/cms-worker.js.map +1 -1
  134. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"git.js","sourceRoot":"","sources":["../../src/utils/git.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAItC;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,QAAgB,EAChB,WAAmB,MAAM;IAEzB,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,SAAS,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,CAAA;QAC5C,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,QAAQ,CAAC,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QAClE,OAAO,IAAI,IAAI,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAA;IAClD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,QAAQ,CAAA;IACjB,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,OAKvC;IACC,IAAI,OAAO,CAAC,iBAAiB;QAAE,OAAO,OAAO,CAAC,iBAAiB,CAAA;IAC/D,IAAI,OAAO,CAAC,IAAI,KAAK,KAAK,EAAE,CAAC;QAC3B,OAAO,gBAAgB,CAAC,OAAO,CAAC,UAAU,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC,CAAA;IAC9D,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC"}
1
+ {"version":3,"file":"git.js","sourceRoot":"","sources":["../../src/utils/git.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAItC,wEAAwE;AACxE,MAAM,sBAAsB,GAAG,yBAAyB,CAAA;AACxD,6EAA6E;AAC7E,8EAA8E;AAC9E,6DAA6D;AAC7D,MAAM,wBAAwB,GAAG,uBAAuB,CAAA;AACxD,4EAA4E;AAC5E,4EAA4E;AAC5E,MAAM,sBAAsB,GAAG,IAAI,CAAA;AACnC,uEAAuE;AACvE,6EAA6E;AAC7E,8EAA8E;AAC9E,uEAAuE;AACvE,8BAA8B;AAC9B,MAAM,qBAAqB,GAAG,iBAAiB,CAAA;AAC/C,MAAM,0BAA0B,GAAG,aAAa,CAAA;AAEhD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAW;IAC5C,IAAI,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,KAAK,CAAA;IACzC,IAAI,sBAAsB,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IACjD,IAAI,wBAAwB,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IACnD,IAAI,sBAAsB,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IACjD,IAAI,qBAAqB,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,KAAK,CAAA;IACjD,OAAO,0BAA0B,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;AAC7C,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,QAAgB,EAChB,WAAmB,MAAM;IAEzB,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,SAAS,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,CAAA;QAC5C,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,QAAQ,CAAC,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QAClE,OAAO,IAAI,IAAI,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAA;IAClD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,QAAQ,CAAA;IACjB,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,OAKvC;IACC,IAAI,OAAO,CAAC,iBAAiB;QAAE,OAAO,OAAO,CAAC,iBAAiB,CAAA;IAC/D,IAAI,OAAO,CAAC,IAAI,KAAK,KAAK,EAAE,CAAC;QAC3B,OAAO,gBAAgB,CAAC,OAAO,CAAC,UAAU,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC,CAAA;IAC9D,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC"}
@@ -92,6 +92,7 @@ export declare class CmsWorker {
92
92
  private remoteGitPath;
93
93
  private contentBranchesPath;
94
94
  private baseBranch;
95
+ private sanitizedBaseBranch;
95
96
  private activeTimeouts;
96
97
  private running;
97
98
  private activeOperations;
@@ -139,9 +140,37 @@ export declare class CmsWorker {
139
140
  * preventing overlapping executions.
140
141
  */
141
142
  private scheduleLoop;
143
+ /**
144
+ * Whether the bare repo at `gitDir` has a local `refs/heads/<baseBranch>`.
145
+ *
146
+ * Uses an explicit `--git-dir` invocation rather than `simpleGit({ baseDir
147
+ * })` so this also works in sandboxed/CI git environments that set
148
+ * `safe.bareRepository=explicit` (which refuses cwd-based discovery of
149
+ * bare repos but expressly allows `--git-dir` — see
150
+ * GitManager.bareRemoteHasBranch for the same pattern).
151
+ *
152
+ * Deliberately omits `--quiet`: simple-git only treats a task as failed
153
+ * when the process both exits non-zero AND writes to stderr
154
+ * (isTaskError), so a quiet, silent-on-failure `--verify` would leave a
155
+ * missing branch indistinguishable from success. Without `--quiet`,
156
+ * `rev-parse --verify` writes its "fatal: ..." to stderr on failure, which
157
+ * is what makes simple-git reject the promise here.
158
+ */
159
+ private verifyBaseBranchExists;
142
160
  /**
143
161
  * Ensure remote.git bare repo exists.
144
162
  * On first run, clone from GitHub as a bare repo.
163
+ *
164
+ * Empty-remote guard: simple-git's bare clone of an EMPTY GitHub repo (no
165
+ * commits, or a base branch that's never been pushed) exits 0 and produces
166
+ * a refs-less bare repo — HEAD points at an unborn branch. `fs.stat`
167
+ * cannot distinguish this from a healthy clone, so left unchecked it
168
+ * silently poisons remote.git: every later branch operation (Lambda-side
169
+ * clone provisioning, worker pushes) breaks, and the fs.stat short-circuit
170
+ * means it never heals on its own. We verify the base branch exists right
171
+ * after cloning and again on the already-exists fast path, since a
172
+ * previous run could have left a poisoned remote.git behind before this
173
+ * guard existed.
145
174
  */
146
175
  private ensureRemoteGit;
147
176
  /**
@@ -180,6 +209,41 @@ export declare class CmsWorker {
180
209
  */
181
210
  private pushSettingsBranches;
182
211
  syncGit(): Promise<void>;
212
+ /**
213
+ * Fast-forward the base branch's own working-tree clone
214
+ * (content-branches/<baseBranch>) to match origin/<baseBranch>.
215
+ *
216
+ * Previously this clone was refreshed only incidentally, by the generic
217
+ * rebase loop below (rebaseActiveBranches): for a branch with status
218
+ * 'editing', rebasing onto origin/<baseBranch> degenerates to a
219
+ * fast-forward when the clone IS the base branch. But that loop's skip
220
+ * paths -- a dirty tree, a missing .git -- are silent, which is the
221
+ * suspected live failure mode: a wedged base clone with no diagnosable
222
+ * signal in the logs. This dedicated step makes the refresh explicit,
223
+ * ff-only, and loud, so a stuck base view (an editor forking a new branch
224
+ * "from base" that's actually a stale snapshot) is diagnosable from logs.
225
+ * This runs every sync cycle so the drift window is bounded by
226
+ * gitSyncInterval.
227
+ *
228
+ * ff-only on purpose: this clone must stay a linear mirror of
229
+ * origin/<baseBranch>, so a merge that isn't a fast-forward (diverged
230
+ * local history) is treated as a should-never-happen condition and left
231
+ * untouched rather than force-resolved.
232
+ */
233
+ private refreshBaseBranchWorkspace;
234
+ /**
235
+ * Poll GitHub for a submitted/approved branch's PR resolution.
236
+ *
237
+ * submitted/approved branches sit outside the rebase loop and get no
238
+ * other signal that their PR resolved on GitHub -- nothing pushes a
239
+ * merge/close webhook back into the branch workspace. merged ->
240
+ * auto-archive via buildMergedBranchUpdate (shared with the manual
241
+ * markAsMerged API so both paths produce identical archived-branch
242
+ * metadata). closed-without-merge -> record pullRequestState only; an
243
+ * admin decides the workflow transition from there. Best-effort: any
244
+ * failure here is logged and swallowed, retried next sync cycle.
245
+ */
246
+ private pollMergeState;
183
247
  private rebaseActiveBranches;
184
248
  refreshAuthCache(): Promise<void>;
185
249
  }
@@ -1 +1 @@
1
- {"version":3,"file":"cms-worker.d.ts","sourceRoot":"","sources":["../../src/worker/cms-worker.ts"],"names":[],"mappings":"AAsBA;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;AAEpD,MAAM,WAAW,eAAe;IAC9B,+DAA+D;IAC/D,aAAa,EAAE,MAAM,CAAA;IACrB,0CAA0C;IAC1C,WAAW,EAAE,MAAM,CAAA;IACnB,2CAA2C;IAC3C,UAAU,EAAE,MAAM,CAAA;IAClB,qDAAqD;IACrD,WAAW,EAAE,MAAM,CAAA;IACnB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,kBAAkB,CAAA;IACrC,qDAAqD;IACrD,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,uDAAuD;IACvD,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,kEAAkE;IAClE,wBAAwB,CAAC,EAAE,MAAM,CAAA;IACjC,yCAAyC;IACzC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mDAAmD;IACnD,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,gDAAgD;IAChD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAMD;;;;GAIG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;CAAG;AAEhD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAO5D;AA2DD;;;;;;;;;;GAUG;AACH,qBAAa,SAAS;IAkBR,OAAO,CAAC,MAAM;IAjB1B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,aAAa,CAAQ;IAC7B,OAAO,CAAC,mBAAmB,CAAQ;IACnC,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,cAAc,CAA4B;IAClD,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,gBAAgB,CAA2B;IACnD,OAAO,CAAC,gBAAgB,CAAQ;IAChC,OAAO,CAAC,aAAa,CAAQ;IAC7B,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,YAAY,CAAQ;IAC5B,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,aAAa,CAAqC;IAC1D,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,GAAG,CAAqB;gBAEZ,MAAM,EAAE,eAAe;IAcrC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA0CtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAmB3B;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;YACW,WAAW;YAyBX,WAAW;IAWzB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAkBpB;;;OAGG;YACW,eAAe;IAe7B;;;;;OAKG;IACG,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IAwCvC;;;;;;;;OAQG;YACW,sBAAsB;YAqBtB,WAAW;IAkHzB;;;OAGG;YACW,oBAAoB;IA4BlC;;;OAGG;YACW,6BAA6B;IAsB3C,OAAO,CAAC,cAAc;YAIR,kBAAkB;IAahC;;;OAGG;YACW,oBAAoB;IAqB5B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YA4BhB,oBAAoB;IAiN5B,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;CAYxC"}
1
+ {"version":3,"file":"cms-worker.d.ts","sourceRoot":"","sources":["../../src/worker/cms-worker.ts"],"names":[],"mappings":"AA6BA;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;AAEpD,MAAM,WAAW,eAAe;IAC9B,+DAA+D;IAC/D,aAAa,EAAE,MAAM,CAAA;IACrB,0CAA0C;IAC1C,WAAW,EAAE,MAAM,CAAA;IACnB,2CAA2C;IAC3C,UAAU,EAAE,MAAM,CAAA;IAClB,qDAAqD;IACrD,WAAW,EAAE,MAAM,CAAA;IACnB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,kBAAkB,CAAA;IACrC,qDAAqD;IACrD,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,uDAAuD;IACvD,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,kEAAkE;IAClE,wBAAwB,CAAC,EAAE,MAAM,CAAA;IACjC,yCAAyC;IACzC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mDAAmD;IACnD,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,8CAA8C;IAC9C,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,gDAAgD;IAChD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAMD;;;;GAIG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;CAAG;AAEhD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAO5D;AA2DD;;;;;;;;;;GAUG;AACH,qBAAa,SAAS;IAwBR,OAAO,CAAC,MAAM;IAvB1B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,aAAa,CAAQ;IAC7B,OAAO,CAAC,mBAAmB,CAAQ;IACnC,OAAO,CAAC,UAAU,CAAQ;IAM1B,OAAO,CAAC,mBAAmB,CAAqB;IAChD,OAAO,CAAC,cAAc,CAA4B;IAClD,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,gBAAgB,CAA2B;IACnD,OAAO,CAAC,gBAAgB,CAAQ;IAChC,OAAO,CAAC,aAAa,CAAQ;IAC7B,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,YAAY,CAAQ;IAC5B,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,aAAa,CAAqC;IAC1D,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,GAAG,CAAqB;gBAEZ,MAAM,EAAE,eAAe;IAerC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA2DtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAmB3B;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;YACW,WAAW;YAyBX,WAAW;IAWzB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAkBpB;;;;;;;;;;;;;;;OAeG;YACW,sBAAsB;IAUpC;;;;;;;;;;;;;;OAcG;YACW,eAAe;IAiD7B;;;;;OAKG;IACG,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;IAwCvC;;;;;;;;OAQG;YACW,sBAAsB;YAqBtB,WAAW;IAkHzB;;;OAGG;YACW,oBAAoB;IA2ClC;;;OAGG;YACW,6BAA6B;IAsB3C,OAAO,CAAC,cAAc;YAIR,kBAAkB;IAahC;;;OAGG;YACW,oBAAoB;IAqB5B,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IA8B9B;;;;;;;;;;;;;;;;;;;;OAoBG;YACW,0BAA0B;IAgHxC;;;;;;;;;;;OAWG;YACW,cAAc;YAkDd,oBAAoB;IA6O5B,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC;CAYxC"}
@@ -4,10 +4,11 @@ import { simpleGit } from 'simple-git';
4
4
  import lockfile from 'proper-lockfile';
5
5
  import { dequeueTask, completeTask, failTask, retryTask, recoverOrphanedTasks, cleanupOldTasks, cmsTaskQueueLogger, } from './task-queue.js';
6
6
  import { createOrUpdatePullRequest, createCanopyOctokit } from '../github-service.js';
7
- import { getBranchMetadataFileManager, BranchMetadataFileManager } from '../branch-metadata.js';
7
+ import { getBranchMetadataFileManager, BranchMetadataFileManager, buildMergedBranchUpdate, } from '../branch-metadata.js';
8
8
  import { extractIdFromFilename } from '../content-id-index.js';
9
9
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
10
10
  import { ROOT_COLLECTION_ID } from '../paths/types.js';
11
+ import { sanitizeBranchName } from '../paths/branch.js';
11
12
  import { getErrorMessage, isNodeError } from '../utils/error.js';
12
13
  const DEFAULT_TASK_TIMEOUT = 60_000;
13
14
  const DEFAULT_MAX_RETRIES = 3;
@@ -135,6 +136,7 @@ export class CmsWorker {
135
136
  this.remoteGitPath = path.join(config.workspacePath, 'remote.git');
136
137
  this.contentBranchesPath = path.join(config.workspacePath, 'content-branches');
137
138
  this.baseBranch = config.baseBranch ?? 'main';
139
+ this.sanitizedBaseBranch = sanitizeBranchName(this.baseBranch);
138
140
  this.maxTasksPerCycle = config.maxTasksPerCycle ?? 10;
139
141
  this.taskTimeoutMs = config.taskTimeoutMs ?? DEFAULT_TASK_TIMEOUT;
140
142
  this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
@@ -147,19 +149,37 @@ export class CmsWorker {
147
149
  console.log('CMS Worker starting...');
148
150
  // Acquire lock to prevent concurrent workers
149
151
  await this.acquireLock();
150
- // Ensure remote.git exists (init bare repo if first run)
151
- await this.ensureRemoteGit();
152
- // Recover any orphaned tasks from a previous crash
153
- const recovered = await recoverOrphanedTasks(this.taskDir, undefined, this.log);
154
- if (recovered > 0) {
155
- console.log(`Recovered ${recovered} orphaned task(s)`);
156
- }
157
- // Run initial sync + cache refresh immediately
158
- const initialTasks = [this.syncGit()];
159
- if (this.config.refreshAuthCache) {
160
- initialTasks.push(this.refreshAuthCache());
152
+ // Everything below runs while holding the cross-host worker lock. A
153
+ // failure here (most notably the empty-remote guard inside
154
+ // ensureRemoteGit) means the process is about to exit, and systemd
155
+ // (Restart=always) will retry — but a still-held lock would make every
156
+ // retry fail with ELOCKED for up to lockStaleMs. Release before
157
+ // rethrowing so the next start() (this process's retry, or another host)
158
+ // can acquire immediately. We deliberately do NOT reorder ensureRemoteGit
159
+ // ahead of acquireLock: two hosts cold-starting at once would then both
160
+ // race to `git clone --bare` into the same remoteGitPath (the same class
161
+ // of race that acquireProvisioningLock guards against for workspace
162
+ // clones elsewhere) — acquiring the lock first is what already
163
+ // serializes that.
164
+ try {
165
+ // Ensure remote.git exists (init bare repo if first run)
166
+ await this.ensureRemoteGit();
167
+ // Recover any orphaned tasks from a previous crash
168
+ const recovered = await recoverOrphanedTasks(this.taskDir, undefined, this.log);
169
+ if (recovered > 0) {
170
+ console.log(`Recovered ${recovered} orphaned task(s)`);
171
+ }
172
+ // Run initial sync + cache refresh immediately
173
+ const initialTasks = [this.syncGit()];
174
+ if (this.config.refreshAuthCache) {
175
+ initialTasks.push(this.refreshAuthCache());
176
+ }
177
+ await Promise.allSettled(initialTasks);
178
+ }
179
+ catch (err) {
180
+ await this.releaseLock();
181
+ throw err;
161
182
  }
162
- await Promise.allSettled(initialTasks);
163
183
  // Start recurring task loops using setTimeout chaining
164
184
  // (avoids setInterval overlap when tasks take longer than the interval)
165
185
  const taskInterval = this.config.taskPollInterval ?? 5_000;
@@ -276,24 +296,86 @@ export class CmsWorker {
276
296
  };
277
297
  run();
278
298
  }
299
+ /**
300
+ * Whether the bare repo at `gitDir` has a local `refs/heads/<baseBranch>`.
301
+ *
302
+ * Uses an explicit `--git-dir` invocation rather than `simpleGit({ baseDir
303
+ * })` so this also works in sandboxed/CI git environments that set
304
+ * `safe.bareRepository=explicit` (which refuses cwd-based discovery of
305
+ * bare repos but expressly allows `--git-dir` — see
306
+ * GitManager.bareRemoteHasBranch for the same pattern).
307
+ *
308
+ * Deliberately omits `--quiet`: simple-git only treats a task as failed
309
+ * when the process both exits non-zero AND writes to stderr
310
+ * (isTaskError), so a quiet, silent-on-failure `--verify` would leave a
311
+ * missing branch indistinguishable from success. Without `--quiet`,
312
+ * `rev-parse --verify` writes its "fatal: ..." to stderr on failure, which
313
+ * is what makes simple-git reject the promise here.
314
+ */
315
+ async verifyBaseBranchExists(gitDir) {
316
+ await simpleGit().raw([
317
+ '--git-dir',
318
+ gitDir,
319
+ 'rev-parse',
320
+ '--verify',
321
+ `refs/heads/${this.baseBranch}`,
322
+ ]);
323
+ }
279
324
  /**
280
325
  * Ensure remote.git bare repo exists.
281
326
  * On first run, clone from GitHub as a bare repo.
327
+ *
328
+ * Empty-remote guard: simple-git's bare clone of an EMPTY GitHub repo (no
329
+ * commits, or a base branch that's never been pushed) exits 0 and produces
330
+ * a refs-less bare repo — HEAD points at an unborn branch. `fs.stat`
331
+ * cannot distinguish this from a healthy clone, so left unchecked it
332
+ * silently poisons remote.git: every later branch operation (Lambda-side
333
+ * clone provisioning, worker pushes) breaks, and the fs.stat short-circuit
334
+ * means it never heals on its own. We verify the base branch exists right
335
+ * after cloning and again on the already-exists fast path, since a
336
+ * previous run could have left a poisoned remote.git behind before this
337
+ * guard existed.
282
338
  */
283
339
  async ensureRemoteGit() {
340
+ let exists;
284
341
  try {
285
342
  await fs.stat(this.remoteGitPath);
286
- return; // Already exists
343
+ exists = true;
287
344
  }
288
345
  catch {
289
- console.log('Initializing remote.git from GitHub...');
290
- const git = simpleGit();
291
- await git.clone(this.buildGitHubUrl(), this.remoteGitPath, ['--bare']);
292
- // Remove the origin remote so the token doesn't persist in config
293
- const bareGit = simpleGit({ baseDir: this.remoteGitPath });
294
- await bareGit.removeRemote('origin').catch(() => { });
295
- console.log('remote.git initialized');
346
+ exists = false;
347
+ }
348
+ if (exists) {
349
+ try {
350
+ await this.verifyBaseBranchExists(this.remoteGitPath);
351
+ }
352
+ catch (err) {
353
+ console.error(`remote.git base branch verification failed: ${getErrorMessage(err)}`);
354
+ // Do NOT auto-delete: an existing remote.git could hold unpushed
355
+ // canopycms-settings-* branches or other state worth preserving.
356
+ // Deletion here is the operator's call, not ours.
357
+ throw new Error(`remote.git at ${this.remoteGitPath} has no branch '${this.baseBranch}' (likely cloned while the GitHub repo was empty). Delete ${this.remoteGitPath} and restart the worker to re-clone.`);
358
+ }
359
+ return; // Already exists and has the base branch
360
+ }
361
+ console.log('Initializing remote.git from GitHub...');
362
+ const git = simpleGit();
363
+ await git.clone(this.buildGitHubUrl(), this.remoteGitPath, ['--bare']);
364
+ try {
365
+ await this.verifyBaseBranchExists(this.remoteGitPath);
296
366
  }
367
+ catch (err) {
368
+ console.error(`remote.git base branch verification failed after clone: ${getErrorMessage(err)}`);
369
+ // Deleting before throwing is what makes this recoverable: the next
370
+ // start() sees no remote.git and re-clones, instead of being stuck
371
+ // forever behind a poisoned bare repo that fs.stat alone can't detect.
372
+ await fs.rm(this.remoteGitPath, { recursive: true, force: true });
373
+ throw new Error(`remote.git clone of ${this.config.githubOwner}/${this.config.githubRepo} has no branch '${this.baseBranch}' - the GitHub repository is empty or the base branch does not exist. Push an initial commit to '${this.baseBranch}' and restart the worker (systemd will retry automatically).`);
374
+ }
375
+ // Remove the origin remote so the token doesn't persist in config
376
+ const bareGit = simpleGit({ baseDir: this.remoteGitPath });
377
+ await bareGit.removeRemote('origin').catch(() => { });
378
+ console.log('remote.git initialized');
297
379
  }
298
380
  /**
299
381
  * Process queued tasks from Lambda.
@@ -492,8 +574,21 @@ export class CmsWorker {
492
574
  };
493
575
  if (result.prUrl)
494
576
  updates.pullRequestUrl = result.prUrl;
495
- if (result.prNumber)
577
+ if (result.prNumber) {
496
578
  updates.pullRequestNumber = result.prNumber;
579
+ // As soon as the PR exists the field should read 'open' -- without
580
+ // this it would stay absent until the next poll cycle observes it.
581
+ // Exception: the git-sync loop may have archived this branch (PR
582
+ // merged/closed) while this task was in flight; a late task
583
+ // completion must not downgrade that terminal state back to 'open'.
584
+ const current = await BranchMetadataFileManager.loadOnly(branchPath);
585
+ const terminal = current?.branch.status === 'archived' ||
586
+ current?.branch.pullRequestState === 'merged' ||
587
+ current?.branch.pullRequestState === 'closed';
588
+ if (!terminal) {
589
+ updates.pullRequestState = 'open';
590
+ }
591
+ }
497
592
  await meta.save({ branch: updates });
498
593
  }
499
594
  catch (err) {
@@ -580,10 +675,178 @@ export class CmsWorker {
580
675
  // Push settings branches to GitHub (belt-and-suspenders for task queue).
581
676
  // Ensures settings reach GitHub even if a task queue entry is lost.
582
677
  await this.pushSettingsBranches(git);
678
+ await this.refreshBaseBranchWorkspace();
583
679
  await this.rebaseActiveBranches();
584
680
  // Periodically clean up old completed/failed tasks
585
681
  await cleanupOldTasks(this.taskDir, undefined, this.log);
586
682
  }
683
+ /**
684
+ * Fast-forward the base branch's own working-tree clone
685
+ * (content-branches/<baseBranch>) to match origin/<baseBranch>.
686
+ *
687
+ * Previously this clone was refreshed only incidentally, by the generic
688
+ * rebase loop below (rebaseActiveBranches): for a branch with status
689
+ * 'editing', rebasing onto origin/<baseBranch> degenerates to a
690
+ * fast-forward when the clone IS the base branch. But that loop's skip
691
+ * paths -- a dirty tree, a missing .git -- are silent, which is the
692
+ * suspected live failure mode: a wedged base clone with no diagnosable
693
+ * signal in the logs. This dedicated step makes the refresh explicit,
694
+ * ff-only, and loud, so a stuck base view (an editor forking a new branch
695
+ * "from base" that's actually a stale snapshot) is diagnosable from logs.
696
+ * This runs every sync cycle so the drift window is bounded by
697
+ * gitSyncInterval.
698
+ *
699
+ * ff-only on purpose: this clone must stay a linear mirror of
700
+ * origin/<baseBranch>, so a merge that isn't a fast-forward (diverged
701
+ * local history) is treated as a should-never-happen condition and left
702
+ * untouched rather than force-resolved.
703
+ */
704
+ async refreshBaseBranchWorkspace() {
705
+ // Sanitized name for the workspace directory (a base branch containing
706
+ // e.g. '/' would otherwise stat a wrong nested path here forever).
707
+ const basePath = path.join(this.contentBranchesPath, this.sanitizedBaseBranch);
708
+ const gitDir = path.join(basePath, '.git');
709
+ try {
710
+ let gitDirStat;
711
+ try {
712
+ gitDirStat = await fs.stat(gitDir);
713
+ }
714
+ catch {
715
+ gitDirStat = null;
716
+ }
717
+ if (!gitDirStat || !gitDirStat.isDirectory()) {
718
+ console.log(`Base branch workspace (${this.baseBranch}): not yet provisioned, skipping refresh`);
719
+ return;
720
+ }
721
+ const baseGit = simpleGit({
722
+ baseDir: basePath,
723
+ // Keep git non-interactive during the merge so it never blocks on an
724
+ // editor. simple-git >=3.32 blocks setting core.editor unless
725
+ // explicitly opted in; the value here is a hardcoded literal
726
+ // ("true", the shell no-op), not user input, so enabling
727
+ // allowUnsafeEditor carries no injection risk (same as the rebase
728
+ // loop's git config below).
729
+ config: ['core.editor=true'],
730
+ unsafe: { allowUnsafeEditor: true },
731
+ // DEP-H1: a hung fetch/merge against this EFS-backed clone would
732
+ // stall the sync loop forever (scheduleLoop only reschedules after
733
+ // completion). The block timeout is inactivity-based, so a
734
+ // slow-but-flowing transfer is unaffected -- same as syncGit()'s
735
+ // remote handle.
736
+ timeout: { block: this.taskTimeoutMs },
737
+ });
738
+ // Nothing makes this clone read-only. A direct edit here (or a stray
739
+ // process) wedges every editor's view of the base branch until an
740
+ // operator intervenes, so a dirty tree is loud, not a quiet skip.
741
+ // Only TRACKED changes block the refresh: a stray untracked file (e.g.
742
+ // runtime metadata missing from .git/info/exclude) must not wedge the
743
+ // fast-forward forever — if an untracked file would collide with
744
+ // incoming content, the --ff-only merge below refuses on its own and
745
+ // that failure is already logged loudly.
746
+ const status = await baseGit.status();
747
+ const trackedDirty = status.files.filter((f) => f.index !== '?' || f.working_dir !== '?');
748
+ if (trackedDirty.length > 0) {
749
+ console.error(`Base branch workspace (${this.baseBranch}) has uncommitted changes -- skipping refresh. Dirty files: ${trackedDirty.map((f) => f.path).join(', ')}`);
750
+ return;
751
+ }
752
+ // Raw (unsanitized) name from here on: these are git ref operations
753
+ // against origin/<baseBranch>, not filesystem paths, so they must use
754
+ // the same name GitHub knows the branch by.
755
+ await baseGit.fetch('origin', this.baseBranch);
756
+ // Use rev-list instead of status.behind for the same reason as the
757
+ // rebase loop below: status.behind only works with an upstream
758
+ // tracking branch configured, which isn't guaranteed here.
759
+ const behindCount = parseInt((await baseGit.raw(['rev-list', '--count', `HEAD..origin/${this.baseBranch}`])).trim(), 10);
760
+ if (behindCount > 0) {
761
+ try {
762
+ await baseGit.merge(['--ff-only', `origin/${this.baseBranch}`]);
763
+ }
764
+ catch (err) {
765
+ console.error(`Base branch workspace (${this.baseBranch}) failed to fast-forward (diverged local history?): ${getErrorMessage(err)}`);
766
+ return;
767
+ }
768
+ await invalidateBranchContentCaches(basePath);
769
+ }
770
+ // Hygiene: conflictStatus/conflictFiles are meaningless for the base
771
+ // branch's own metadata and may be left over from before the base was
772
+ // excluded from rebaseActiveBranches()'s conflict-resolution loop.
773
+ // Reuse the already-clean no-op-save guard from that loop: save()
774
+ // eager-regenerates the branch registry (O(branch count) EFS reads),
775
+ // so skip it when there's nothing to clear.
776
+ const currentMeta = await BranchMetadataFileManager.loadOnly(basePath);
777
+ const conflictStatus = currentMeta?.branch.conflictStatus;
778
+ const conflictFiles = currentMeta?.branch.conflictFiles;
779
+ const alreadyClean = (conflictStatus === undefined || conflictStatus === 'clean') &&
780
+ (conflictFiles === undefined || conflictFiles.length === 0);
781
+ if (!alreadyClean) {
782
+ const meta = getBranchMetadataFileManager(basePath, this.contentBranchesPath);
783
+ await meta.save({
784
+ branch: { name: this.baseBranch, conflictStatus: 'clean', conflictFiles: [] },
785
+ });
786
+ }
787
+ // One concise per-cycle line -- the diagnostic for the next live deploy.
788
+ console.log(behindCount > 0
789
+ ? `Base branch workspace (${this.baseBranch}): fast-forwarded ${behindCount} commit(s)`
790
+ : `Base branch workspace (${this.baseBranch}): up to date`);
791
+ }
792
+ catch (err) {
793
+ console.error(`Base branch workspace (${this.baseBranch}) refresh failed: ${getErrorMessage(err)}`);
794
+ }
795
+ }
796
+ /**
797
+ * Poll GitHub for a submitted/approved branch's PR resolution.
798
+ *
799
+ * submitted/approved branches sit outside the rebase loop and get no
800
+ * other signal that their PR resolved on GitHub -- nothing pushes a
801
+ * merge/close webhook back into the branch workspace. merged ->
802
+ * auto-archive via buildMergedBranchUpdate (shared with the manual
803
+ * markAsMerged API so both paths produce identical archived-branch
804
+ * metadata). closed-without-merge -> record pullRequestState only; an
805
+ * admin decides the workflow transition from there. Best-effort: any
806
+ * failure here is logged and swallowed, retried next sync cycle.
807
+ */
808
+ async pollMergeState(branchDir, branchPath, metaFile) {
809
+ const prNumber = metaFile?.branch.pullRequestNumber;
810
+ if (!prNumber)
811
+ return;
812
+ const controller = new AbortController();
813
+ const timer = setTimeout(() => controller.abort(), this.taskTimeoutMs);
814
+ try {
815
+ const { data } = await this.octokit.pulls.get({
816
+ owner: this.config.githubOwner,
817
+ repo: this.config.githubRepo,
818
+ pull_number: prNumber,
819
+ request: { signal: controller.signal },
820
+ });
821
+ if (data.merged) {
822
+ const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
823
+ // Use GitHub's actual merge time when available; buildMergedBranchUpdate
824
+ // falls back to "now" (its default `now` param) when merged_at is absent.
825
+ await meta.save({
826
+ branch: buildMergedBranchUpdate(branchDir, data.merged_at ? new Date(data.merged_at) : undefined),
827
+ });
828
+ console.log(` PR #${prNumber} for ${branchDir} is merged -> archived`);
829
+ return;
830
+ }
831
+ const newState = data.state === 'closed' ? 'closed' : 'open';
832
+ // Re-load fresh (not the loop-top `metaFile` snapshot passed in) -- a
833
+ // concurrent Lambda write (e.g. an editor re-submitting) may have
834
+ // landed since that snapshot was taken.
835
+ const currentMeta = await BranchMetadataFileManager.loadOnly(branchPath);
836
+ if (currentMeta?.branch.pullRequestState === newState)
837
+ return;
838
+ const meta = getBranchMetadataFileManager(branchPath, this.contentBranchesPath);
839
+ await meta.save({ branch: { name: branchDir, pullRequestState: newState } });
840
+ console.log(` PR #${prNumber} for ${branchDir}: pullRequestState -> ${newState}`);
841
+ }
842
+ catch (err) {
843
+ // Non-fatal: transient GitHub/network errors are retried next cycle.
844
+ console.warn(` Failed to poll PR #${prNumber} for ${branchDir}: ${getErrorMessage(err)}`);
845
+ }
846
+ finally {
847
+ clearTimeout(timer);
848
+ }
849
+ }
587
850
  async rebaseActiveBranches() {
588
851
  let branchDirs;
589
852
  try {
@@ -593,14 +856,35 @@ export class CmsWorker {
593
856
  return;
594
857
  }
595
858
  for (const branchDir of branchDirs) {
859
+ // Known structural entries under content-branches/, not branch
860
+ // workspaces: branches.json (registry snapshot) and dot-prefixed
861
+ // entries (.canopy-meta/, transient lock dirs). Skip them silently so
862
+ // the no-.git skip logs below don't fire for them every cycle.
863
+ // Everything else still logs loudly on skip.
864
+ if (branchDir.startsWith('.') || branchDir === 'branches.json') {
865
+ continue;
866
+ }
596
867
  const branchPath = path.join(this.contentBranchesPath, branchDir);
597
868
  const gitDir = path.join(branchPath, '.git');
598
869
  try {
599
870
  const stat = await fs.stat(gitDir);
600
- if (!stat.isDirectory())
871
+ if (!stat.isDirectory()) {
872
+ console.log(` Skipping ${branchDir}: .git is not a directory`);
601
873
  continue;
874
+ }
602
875
  }
603
876
  catch {
877
+ console.log(` Skipping ${branchDir}: no .git directory (not a branch workspace)`);
878
+ continue;
879
+ }
880
+ // The base branch's own clone is refreshed ff-only by
881
+ // refreshBaseBranchWorkspace() earlier in syncGit(). Routing it
882
+ // through this conflict-resolution rebase loop could rewrite its
883
+ // history (the --theirs loop below) and stamp meaningless conflict
884
+ // metadata on it. Compare sanitized-vs-sanitized: branchDir is a
885
+ // filesystem name (already sanitized), this.baseBranch is raw.
886
+ if (branchDir === this.sanitizedBaseBranch) {
887
+ console.log(` Skipping ${branchDir}: base branch (refreshed separately)`);
604
888
  continue;
605
889
  }
606
890
  try {
@@ -608,11 +892,17 @@ export class CmsWorker {
608
892
  const metaFile = await BranchMetadataFileManager.loadOnly(branchPath);
609
893
  const branchStatus = metaFile?.branch.status;
610
894
  // Skip branches that shouldn't be mutated:
611
- // - submitted/approved: in review, don't rewrite history under an active PR
612
- // - archived: already merged, no reason to rebase
613
- if (branchStatus === 'submitted' ||
614
- branchStatus === 'approved' ||
615
- branchStatus === 'archived') {
895
+ // - submitted/approved: in review, don't rewrite history under an
896
+ // active PR -- but do poll GitHub for the PR's resolution, since
897
+ // nothing else tells the worker a merge/close happened.
898
+ // - archived: already merged, no reason to rebase and no PR left to
899
+ // poll (avoid the wasted API call).
900
+ if (branchStatus === 'submitted' || branchStatus === 'approved') {
901
+ console.log(` Skipping ${branchDir} (${branchStatus})`);
902
+ await this.pollMergeState(branchDir, branchPath, metaFile);
903
+ continue;
904
+ }
905
+ if (branchStatus === 'archived') {
616
906
  console.log(` Skipping ${branchDir} (${branchStatus})`);
617
907
  continue;
618
908
  }