canopycms 0.0.64 → 0.0.66-int.81

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.
@@ -34,10 +34,14 @@ export function resolveUrlPathCandidates(urlPath, contentRoot) {
34
34
  // `index` is not a contrived segment either — it is the slug the index convention requires on
35
35
  // disk, so the collision was structural rather than accidental.
36
36
  //
37
- // This closes the `.../index` spelling, NOT every extra URL: candidate 2 below still resolves an
38
- // index entry at `/<collection>/<entryTypeName>`, because that path is a registered entry-TYPE
39
- // schema item which `buildPaths` delegates to the parent collection. Open, and tracked in
40
- // .claude/future-tasks/readbyurlpath-entry-type-candidate-phantom-url.md.
37
+ // This closes the `.../index` spelling only. The other extra URLs an entry answered at —
38
+ // `/<collection>/<entryTypeName>` via candidate 2, and `/<collection>/<entryTypeName>/<slug>`
39
+ // via candidate 1 — are closed DOWNSTREAM instead, by `readByUrlPath` requiring every
40
+ // candidate's `entryPath` to be a collection (`ReadContentInput.urlAddressableOnly`). They have
41
+ // to be: telling those apart needs the branch's schema, and this module is deliberately pure
42
+ // and schema-free so that the candidate shapes stay a fact about URLs rather than about
43
+ // content. The consequence to keep in mind when reading this file: the candidates below are
44
+ // what is ATTEMPTED, not what can resolve.
41
45
  //
42
46
  // Compared case-INSENSITIVELY, through the shared `isIndexSlug`. This function is the one
43
47
  // consumer that sees a raw, un-normalized URL segment — everything downstream lowercases
@@ -72,6 +72,23 @@ export declare function isStaleLeaseRejection(message: string): boolean;
72
72
  * so callers MUST run git with a locale-pinning env (`gitChildEnv`).
73
73
  */
74
74
  export declare function isMissingRemoteRefFailure(message: string): boolean;
75
+ /**
76
+ * Whether a repository has an INTERRUPTED rebase on disk — the `rebase-merge`
77
+ * (interactive/merge backend) or `rebase-apply` (am backend) state directory
78
+ * git leaves behind when a rebase stops for conflicts or the process dies
79
+ * mid-way.
80
+ *
81
+ * This state is invisible to every other check the worker makes: a clone left
82
+ * mid-rebase reports uncommitted changes, so the sync loop's dirty check skips
83
+ * it as `skippedDirty` on every cycle forever, and `branch-health` sees valid
84
+ * branch.json and scans it as healthy. Nothing self-heals, and recovery
85
+ * previously meant an operator running `git rebase --abort` on EFS by hand.
86
+ *
87
+ * Never throws — a missing or unreadable repo is reported as "no rebase",
88
+ * which is the safe direction for both callers (the worker only ever uses a
89
+ * `true` to justify an abort it holds the content-write lock for).
90
+ */
91
+ export declare function isRebaseInProgress(repoPath: string): Promise<boolean>;
75
92
  /**
76
93
  * Detect the current HEAD branch name for a given repository root.
77
94
  * Returns the branch name, or the provided fallback (default 'main')
package/dist/utils/git.js CHANGED
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
1
3
  import { simpleGit } from 'simple-git';
2
4
  // Matches scheme://... URLs (http, https, ssh, git — case-insensitive).
3
5
  const NETWORK_SCHEME_PATTERN = /^(https?|ssh|git):\/\//i;
@@ -137,6 +139,65 @@ const MISSING_REMOTE_REF_REASONS = ["couldn't find remote ref", "Couldn't find r
137
139
  export function isMissingRemoteRefFailure(message) {
138
140
  return MISSING_REMOTE_REF_REASONS.some((reason) => message.includes(reason));
139
141
  }
142
+ /**
143
+ * Resolve a repository's git directory from its working-tree root, handling
144
+ * both layouts: a real `.git` directory, and a `.git` FILE containing a
145
+ * `gitdir: <path>` pointer (linked worktrees, submodules).
146
+ *
147
+ * Deliberately fs-only rather than `git rev-parse --git-dir`: the callers are
148
+ * a per-branch sync loop and an admin-facing health scan that already walk
149
+ * every branch directory, and neither should pay a subprocess per branch just
150
+ * to find a path.
151
+ */
152
+ async function resolveGitDir(repoPath) {
153
+ const dotGit = path.join(repoPath, '.git');
154
+ let stat;
155
+ try {
156
+ stat = await fs.stat(dotGit);
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ if (stat.isDirectory())
162
+ return dotGit;
163
+ try {
164
+ const pointer = await fs.readFile(dotGit, 'utf-8');
165
+ const match = /^gitdir:\s*(.+)$/m.exec(pointer);
166
+ if (!match)
167
+ return null;
168
+ const target = match[1].trim();
169
+ return path.isAbsolute(target) ? target : path.resolve(repoPath, target);
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
175
+ /**
176
+ * Whether a repository has an INTERRUPTED rebase on disk — the `rebase-merge`
177
+ * (interactive/merge backend) or `rebase-apply` (am backend) state directory
178
+ * git leaves behind when a rebase stops for conflicts or the process dies
179
+ * mid-way.
180
+ *
181
+ * This state is invisible to every other check the worker makes: a clone left
182
+ * mid-rebase reports uncommitted changes, so the sync loop's dirty check skips
183
+ * it as `skippedDirty` on every cycle forever, and `branch-health` sees valid
184
+ * branch.json and scans it as healthy. Nothing self-heals, and recovery
185
+ * previously meant an operator running `git rebase --abort` on EFS by hand.
186
+ *
187
+ * Never throws — a missing or unreadable repo is reported as "no rebase",
188
+ * which is the safe direction for both callers (the worker only ever uses a
189
+ * `true` to justify an abort it holds the content-write lock for).
190
+ */
191
+ export async function isRebaseInProgress(repoPath) {
192
+ const gitDir = await resolveGitDir(repoPath);
193
+ if (!gitDir)
194
+ return false;
195
+ const results = await Promise.all(['rebase-merge', 'rebase-apply'].map((dir) => fs
196
+ .stat(path.join(gitDir, dir))
197
+ .then(() => true)
198
+ .catch(() => false)));
199
+ return results.some(Boolean);
200
+ }
140
201
  /**
141
202
  * Detect the current HEAD branch name for a given repository root.
142
203
  * Returns the branch name, or the provided fallback (default 'main')
@@ -217,6 +217,28 @@ export declare class CmsWorker {
217
217
  * is what makes simple-git reject the promise here.
218
218
  */
219
219
  private verifyBaseBranchExists;
220
+ /**
221
+ * Guarantee a bare repo's config carries NO `remote.origin.url`, and so no
222
+ * embedded bot token.
223
+ *
224
+ * `git clone https://x-access-token:<token>@github.com/...` records that URL
225
+ * verbatim as `remote.origin.url`, and for `remote.git` that config lives on
226
+ * shared EFS. The security model in docs/deploying-to-aws.md -- "If Lambda is
227
+ * compromised, an attacker can read/write content on EFS but cannot push to
228
+ * GitHub", "Secrets stay on the worker" -- is false while that string is
229
+ * there: a compromised Lambda could read the token off EFS and, despite
230
+ * having no egress of its own, exfiltrate it by writing it into branch
231
+ * content the worker then pushes to GitHub.
232
+ *
233
+ * Nothing needs the remote: every push passes the URL explicitly as an
234
+ * argument (see the `git.push(this.buildGitHubUrl(), ...)` call sites), and
235
+ * `verifyBaseBranchExists` reads local refs.
236
+ *
237
+ * VERIFIES rather than assuming: it re-reads the config and throws if the URL
238
+ * survives, because the previous code's `.catch(() => {})` meant a failed
239
+ * scrub was indistinguishable from a successful one.
240
+ */
241
+ private scrubPersistedRemote;
220
242
  /**
221
243
  * Ensure remote.git bare repo exists.
222
244
  * On first run, clone from GitHub as a bare repo.
@@ -14,7 +14,7 @@ import { GITHUB_TRACKING_REF_PREFIX, gitChildEnv, gitNetworkChildEnv } from '../
14
14
  import { resolveDeploymentName } from '../operating-mode/deployment-name.js';
15
15
  import { tryAcquireContentWriteLock } from '../utils/content-write-lock.js';
16
16
  import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
17
- import { isNonFastForwardRejection, isStaleLeaseRejection } from '../utils/git.js';
17
+ import { isNonFastForwardRejection, isRebaseInProgress, isStaleLeaseRejection } from '../utils/git.js';
18
18
  import { writeWorkerStatus } from './worker-status.js';
19
19
  import { workerLog, workerLogWarn, workerLogError } from './log.js';
20
20
  // Re-exported so the AWS entrypoint (packages/canopycms-cdk/worker/index.ts)
@@ -440,6 +440,87 @@ export class CmsWorker {
440
440
  `refs/heads/${this.baseBranch}`,
441
441
  ]);
442
442
  }
443
+ /**
444
+ * Guarantee a bare repo's config carries NO `remote.origin.url`, and so no
445
+ * embedded bot token.
446
+ *
447
+ * `git clone https://x-access-token:<token>@github.com/...` records that URL
448
+ * verbatim as `remote.origin.url`, and for `remote.git` that config lives on
449
+ * shared EFS. The security model in docs/deploying-to-aws.md -- "If Lambda is
450
+ * compromised, an attacker can read/write content on EFS but cannot push to
451
+ * GitHub", "Secrets stay on the worker" -- is false while that string is
452
+ * there: a compromised Lambda could read the token off EFS and, despite
453
+ * having no egress of its own, exfiltrate it by writing it into branch
454
+ * content the worker then pushes to GitHub.
455
+ *
456
+ * Nothing needs the remote: every push passes the URL explicitly as an
457
+ * argument (see the `git.push(this.buildGitHubUrl(), ...)` call sites), and
458
+ * `verifyBaseBranchExists` reads local refs.
459
+ *
460
+ * VERIFIES rather than assuming: it re-reads the config and throws if the URL
461
+ * survives, because the previous code's `.catch(() => {})` meant a failed
462
+ * scrub was indistinguishable from a successful one.
463
+ */
464
+ async scrubPersistedRemote(gitDir) {
465
+ const git = simpleGit({ baseDir: gitDir });
466
+ // `git config --get` exits 1 with no output when the key is absent.
467
+ // simple-git does NOT reliably throw on that -- verified against
468
+ // simple-git 3.36: it resolves with an empty string -- so an empty result
469
+ // must be read as "absent" too. Treating "" as a surviving URL is what
470
+ // made the first version of this reject every clean scrub.
471
+ //
472
+ // 'unreadable' is deliberately distinct from 'absent'. A read that fails
473
+ // for any OTHER reason must not be mistaken for "no token here": that
474
+ // would let the pre-check below short-circuit and skip the scrub entirely,
475
+ // silently leaving a token-bearing config on shared EFS -- the exact
476
+ // outcome this function exists to prevent. Fail closed and attempt the
477
+ // removal instead.
478
+ const readOriginUrl = async () => {
479
+ try {
480
+ const url = (await git.raw(['config', '--get', 'remote.origin.url'])).trim();
481
+ return url === '' ? null : url;
482
+ }
483
+ catch {
484
+ // ANY throw is 'unreadable', never 'absent'. The genuinely-absent case
485
+ // does not reach here at all -- simple-git resolves with '' (verified
486
+ // against 3.36: it only treats a task as failed when stderr is
487
+ // non-empty, and a missing key writes nothing to stderr). So a throw
488
+ // means something actually went wrong, and mapping that to "no token
489
+ // here" would be the one fail-OPEN reading available.
490
+ //
491
+ // An earlier version tried to classify git's exit-1 "key not found"
492
+ // from the message text. That was dead code -- simple-git's GitError
493
+ // message is raw stdout+stderr with no exit-code text -- and its
494
+ // empty-message fallback mapped a hypothetical throw to 'absent',
495
+ // which is exactly the direction this must not fail.
496
+ return 'unreadable';
497
+ }
498
+ };
499
+ const before = await readOriginUrl();
500
+ if (before === null)
501
+ return;
502
+ if (before === 'unreadable') {
503
+ workerLogWarn(` Could not read remote.origin.url in ${gitDir}; attempting the scrub anyway rather than assuming it is absent`);
504
+ }
505
+ try {
506
+ await git.removeRemote('origin');
507
+ }
508
+ catch (err) {
509
+ // Reached only from the 'unreadable' path, where the remote may in fact
510
+ // not exist. Let the verification below decide rather than failing here:
511
+ // it is the authoritative check, and it fails closed.
512
+ workerLogWarn(` removeRemote('origin') failed in ${gitDir}: ${getErrorMessage(err)} -- verifying directly`);
513
+ }
514
+ // Fails closed on BOTH a surviving URL and an unverifiable read: if we
515
+ // cannot prove the token is gone from shared storage, we do not proceed.
516
+ const remaining = await readOriginUrl();
517
+ if (remaining !== null) {
518
+ throw new Error(`Could not confirm the 'origin' remote is gone from ${gitDir} (${remaining === 'unreadable'
519
+ ? 'its config was unreadable'
520
+ : 'its config still records a URL'}). For a token-bearing clone URL that means the GitHub bot token may be persisted on ` +
521
+ `shared storage. Refusing to continue.`);
522
+ }
523
+ }
443
524
  /**
444
525
  * Ensure remote.git bare repo exists.
445
526
  * On first run, clone from GitHub as a bare repo.
@@ -465,6 +546,14 @@ export class CmsWorker {
465
546
  exists = false;
466
547
  }
467
548
  if (exists) {
549
+ // SELF-HEAL, before anything else touches this repo. The previous
550
+ // already-exists path fast-returned without ever re-checking the config,
551
+ // so a token that survived one scrub survived forever -- and a clone
552
+ // interrupted by SIGKILL/power-off between `git clone` and the scrub left
553
+ // a repo whose config already held the token, which additionally hit the
554
+ // "delete remote.git and restart" refusal below and so sat on EFS until
555
+ // an operator acted.
556
+ await this.scrubPersistedRemote(this.remoteGitPath);
468
557
  try {
469
558
  await this.verifyBaseBranchExists(this.remoteGitPath);
470
559
  }
@@ -479,21 +568,30 @@ export class CmsWorker {
479
568
  }
480
569
  workerLog('Initializing remote.git from GitHub...');
481
570
  const git = simpleGit();
482
- await git.clone(this.buildGitHubUrl(), this.remoteGitPath, ['--bare']);
571
+ // Clone under a TEMP name and rename into place only once the token has
572
+ // been scrubbed and the repo verified, so `remote.git` never exists on EFS
573
+ // in a token-bearing state. A crash mid-clone now leaves only this staging
574
+ // directory, which the next boot deletes -- rather than a poisoned
575
+ // `remote.git` that fs.stat cannot distinguish from a healthy one.
576
+ const stagingPath = `${this.remoteGitPath}.cloning`;
577
+ await fs.rm(stagingPath, { recursive: true, force: true });
483
578
  try {
484
- await this.verifyBaseBranchExists(this.remoteGitPath);
579
+ await git.clone(this.buildGitHubUrl(), stagingPath, ['--bare']);
580
+ // Before the rename, so the token is gone from the config the moment the
581
+ // repo becomes reachable under its real name. Throws (rather than
582
+ // swallowing) if the scrub does not take.
583
+ await this.scrubPersistedRemote(stagingPath);
584
+ await this.verifyBaseBranchExists(stagingPath);
485
585
  }
486
586
  catch (err) {
487
- workerLogError(`remote.git base branch verification failed after clone: ${getErrorMessage(err)}`);
587
+ workerLogError(`remote.git clone failed: ${redactCredentials(getErrorMessage(err))}`);
488
588
  // Deleting before throwing is what makes this recoverable: the next
489
589
  // start() sees no remote.git and re-clones, instead of being stuck
490
590
  // forever behind a poisoned bare repo that fs.stat alone can't detect.
491
- await fs.rm(this.remoteGitPath, { recursive: true, force: true });
492
- 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).`);
591
+ await fs.rm(stagingPath, { recursive: true, force: true });
592
+ throw new Error(`remote.git clone of ${this.config.githubOwner}/${this.config.githubRepo} failed or has no branch '${this.baseBranch}' - the GitHub repository may be empty, or the base branch may not exist. Push an initial commit to '${this.baseBranch}' and restart the worker (systemd will retry automatically).`);
493
593
  }
494
- // Remove the origin remote so the token doesn't persist in config
495
- const bareGit = simpleGit({ baseDir: this.remoteGitPath });
496
- await bareGit.removeRemote('origin').catch(() => { });
594
+ await fs.rename(stagingPath, this.remoteGitPath);
497
595
  workerLog('remote.git initialized');
498
596
  }
499
597
  /**
@@ -1908,6 +2006,83 @@ export class CmsWorker {
1908
2006
  throw lockErr;
1909
2007
  }
1910
2008
  try {
2009
+ // Recover an INTERRUPTED rebase before anything else looks at this
2010
+ // tree. A clone left with .git/rebase-merge (or rebase-apply) reports
2011
+ // uncommitted changes, so without this the dirty check below would
2012
+ // classify it `skippedDirty` on every cycle FOREVER while
2013
+ // branch-health scanned it as healthy -- and editors would meanwhile
2014
+ // read, and be able to save over, conflict-marker content.
2015
+ //
2016
+ // An in-progress rebase is always this worker's own abandoned work:
2017
+ // it is the only thing that ever rebases these clones, and it got
2018
+ // here via a crash, an OOM, a spot interruption, or the ASG rolling
2019
+ // the instance (which happens on EVERY `cdk deploy`, while `stop()`
2020
+ // drains for at most taskTimeoutMs).
2021
+ //
2022
+ // NOT LOSSLESS, and it is important not to claim otherwise. `git
2023
+ // rebase --abort` hard-resets tracked files to the pre-rebase head.
2024
+ // While the worker was DOWN nothing held the [SYNC-C1] content-write
2025
+ // lock, so an editor could have saved into this wedged clone and
2026
+ // received a 200; that save is a working-tree modification, and the
2027
+ // abort reverts it. (New, untracked entry files survive; edits to
2028
+ // existing ones do not.) Taking the lock here stops any FURTHER save
2029
+ // racing the abort, but cannot recover one that already landed.
2030
+ //
2031
+ // Aborting anyway is still the right call: the alternative is a
2032
+ // branch wedged forever whose tree serves conflict-marker content to
2033
+ // editors. What must not happen is doing it SILENTLY -- so anything
2034
+ // modified beyond the rebase's own conflict state is logged by path
2035
+ // first, which is the only record an operator would have.
2036
+ if (await isRebaseInProgress(branchPath)) {
2037
+ const preAbort = await branchGit.status().catch(() => null);
2038
+ // Keyed on the WORKING-TREE column only. The two porcelain columns
2039
+ // mean different things here, and conflating them produces a false
2040
+ // data-loss report on essentially every conflict-wedged recovery
2041
+ // (verified against real git, mid-rebase):
2042
+ //
2043
+ // `M ` index=M, wd=' ' -- the interrupted replay's own cleanly
2044
+ // merged files, already STAGED. These
2045
+ // are committed history and survive the
2046
+ // abort untouched. Not collateral.
2047
+ // ` M` index=' ', wd=M -- a working-tree modification nothing
2048
+ // staged: an editor's save landing while
2049
+ // the worker was down. The abort
2050
+ // discards exactly these.
2051
+ // `??` -- untracked; the abort leaves them.
2052
+ //
2053
+ // KNOWN GAP, stated rather than hidden: a save onto one of the
2054
+ // rebase's own conflicted paths (the "saved over conflict-marker
2055
+ // content" case) is excluded below, because the file reads `UU`
2056
+ // whether or not an editor touched it -- status alone cannot tell
2057
+ // the two apart. Those discards go unlogged.
2058
+ const collateral = (preAbort?.files ?? [])
2059
+ .filter((f) => !preAbort?.conflicted.includes(f.path))
2060
+ .filter((f) => f.working_dir !== ' ' && f.working_dir !== '?')
2061
+ .map((f) => f.path);
2062
+ if (collateral.length > 0) {
2063
+ workerLogWarn(` ${branchDir}: aborting the interrupted rebase will DISCARD working-tree changes to ` +
2064
+ `${collateral.length} file(s) saved while the worker was down: ${collateral.join(', ')}`);
2065
+ }
2066
+ workerLogWarn(` ${branchDir}: found an interrupted rebase (this worker's own abandoned work) -- aborting it to recover the branch`);
2067
+ try {
2068
+ await branchGit.rebase(['--abort']);
2069
+ }
2070
+ catch (abortErr) {
2071
+ // Leave it for the next cycle rather than pressing on: every step
2072
+ // below assumes a clean tree.
2073
+ const reason = redactCredentials(`could not abort interrupted rebase: ${getErrorMessage(abortErr)}`);
2074
+ workerLogWarn(` Skipping ${branchDir}: ${reason}`);
2075
+ failed.push({ branch: branchDir, error: reason });
2076
+ // Record on branch metadata too, like the other two failure exits
2077
+ // (`!completed` and the outer catch). Without this a persistently
2078
+ // un-abortable wedge appeared in worker-status.json but never set
2079
+ // a `syncFailureReason`, so the admin branch panel showed nothing
2080
+ // -- and this is precisely the state that needs an operator,
2081
+ // since it is the one the next cycle cannot fix by itself.
2082
+ await this.recordRebaseFailure(branchPath, branchDir, reason);
2083
+ continue;
2084
+ }
2085
+ }
1911
2086
  // Skip dirty branches — editor has unsaved changes that can't be rebased.
1912
2087
  // Now inside the lock, so no write can land between this check and the
1913
2088
  // rebase below.
@@ -2031,11 +2206,59 @@ export class CmsWorker {
2031
2206
  await this.afterConflictDetectedForTesting();
2032
2207
  // During rebase, --theirs = the branch being replayed (editor's work).
2033
2208
  // (git rebase reverses ours/theirs: "ours" is the rebase target, "theirs" is the branch.)
2209
+ //
2210
+ // MODIFY/DELETE conflicts have no "their version" to check out
2211
+ // and must be resolved by staging a delete or an add instead.
2212
+ // `git checkout --theirs` on one exits non-zero ("path ... does
2213
+ // not have their version") and simple-git throws -- and because
2214
+ // this loop body IS the round loop's catch, that throw escapes
2215
+ // the round loop entirely, skipping BOTH `rebase --abort` sites
2216
+ // below and leaving the clone wedged mid-rebase forever. The
2217
+ // index/working-tree code pair identifies which side deleted
2218
+ // (verified against real git, not inferred):
2219
+ //
2220
+ // U/D "deleted by them" -- the BRANCH deleted it, base
2221
+ // modified it. Git leaves base's version in the tree.
2222
+ // Keep-branch-version means honouring the delete: git rm.
2223
+ // D/U "deleted by us" -- base deleted it, the BRANCH
2224
+ // modified it. Git leaves the branch's version in the
2225
+ // tree. Keep-branch-version means keeping it: git add.
2226
+ //
2227
+ // Any per-file resolution that STILL fails routes into the
2228
+ // `!completed` path below (which aborts and records) instead of
2229
+ // escaping -- deliberately NOT a rethrow, since a throw from
2230
+ // here is exactly the bug being fixed.
2231
+ const conflictKind = new Map(st.files.map((f) => [f.path, `${f.index}${f.working_dir}`]));
2232
+ let resolutionFailure;
2034
2233
  for (const file of st.conflicted) {
2035
- await branchGit.raw(['checkout', '--theirs', file]);
2036
- await branchGit.add(file);
2234
+ const kind = conflictKind.get(file);
2235
+ try {
2236
+ if (kind === 'UD') {
2237
+ await branchGit.raw(['rm', '-f', '--', file]);
2238
+ }
2239
+ else if (kind === 'DU') {
2240
+ await branchGit.add(file);
2241
+ }
2242
+ else {
2243
+ await branchGit.raw(['checkout', '--theirs', file]);
2244
+ await branchGit.add(file);
2245
+ }
2246
+ }
2247
+ catch (resolveErr) {
2248
+ resolutionFailure =
2249
+ `failed to resolve conflicted file '${file}' (status ${kind ?? '??'}): ` +
2250
+ getErrorMessage(resolveErr);
2251
+ break;
2252
+ }
2037
2253
  conflictedFiles.push(file);
2038
2254
  }
2255
+ if (resolutionFailure !== undefined) {
2256
+ // Same exit shape as the "unexpected error" branch below: set
2257
+ // failureReason and break, letting the `!completed` block do
2258
+ // the single `rebase --abort` and record the failure once.
2259
+ failureReason = resolutionFailure;
2260
+ break;
2261
+ }
2039
2262
  // nextAction stays 'continue'
2040
2263
  }
2041
2264
  else {
@@ -2232,6 +2455,34 @@ export class CmsWorker {
2232
2455
  }
2233
2456
  }
2234
2457
  finally {
2458
+ // Last-resort guarantee that NO exit path leaves this clone
2459
+ // mid-rebase -- including an unexpected throw from any git step
2460
+ // above, which lands in the outer catch and previously only logged.
2461
+ //
2462
+ // It must live HERE rather than in that outer catch: the catch runs
2463
+ // AFTER this finally has released the content-write lock, so aborting
2464
+ // there would hard-reset a working tree an editor's save could
2465
+ // already be racing -- precisely the [SYNC-C1] hazard the lock
2466
+ // exists to prevent. Inside the finally the lock is still held --
2467
+ // EXCEPT on the narrow path where it was compromised mid-hold, in
2468
+ // which case a newly-admitted writer may already be live and this
2469
+ // abort carries the same exposure as the compromise path's own abort
2470
+ // above. Not special-cased: leaving a clone wedged mid-rebase is the
2471
+ // worse outcome, and the writer in that window is already being told
2472
+ // to retry.
2473
+ //
2474
+ // Guarded on actual rebase state so the happy path and the `continue`
2475
+ // exits cost one stat and do nothing.
2476
+ try {
2477
+ if (await isRebaseInProgress(branchPath)) {
2478
+ workerLogWarn(` ${branchDir}: rebase still in progress on exit -- aborting so the clone is not left wedged`);
2479
+ await branchGit.rebase(['--abort']);
2480
+ }
2481
+ }
2482
+ catch (abortErr) {
2483
+ // Best effort: the next cycle's recovery check retries this.
2484
+ workerLogWarn(` Failed to abort in-progress rebase for ${branchDir}: ${getErrorMessage(abortErr)}`);
2485
+ }
2235
2486
  // [SYNC-C1] Released on EVERY exit -- the `continue`s above, a throw
2236
2487
  // into the outer catch, and the happy path alike. A stranded lock
2237
2488
  // would wedge every write to this branch until it went stale.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
3
3
  "name": "canopycms",
4
- "version": "0.0.64",
4
+ "version": "0.0.66-int.81",
5
5
  "description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -103,6 +103,7 @@
103
103
  "minimatch": "^9.0.7",
104
104
  "minimist": "^1.2.8",
105
105
  "pathe": "^1.1.2",
106
+ "picocolors": "^1.1.1",
106
107
  "proper-lockfile": "^4.1.2",
107
108
  "react-easy-crop": "^6.2.2",
108
109
  "react-split-pane": "^0.1.92",