@bevel-software/platform-shared 0.20.0 → 0.22.0

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.
@@ -1,4 +1,4 @@
1
- import { DEFAULT_KB_LAYOUT, FIXED_PLATFORM_FILE_NAMES, agentsFileOf, currentKbLayout, reservedRootDirNames, } from './kb-layout.js';
1
+ import { DEFAULT_KB_LAYOUT, FIXED_PLATFORM_FILE_NAMES, agentsFileOf, reservedRootDirNames, } from './kb-layout.js';
2
2
  /**
3
3
  * The files the platform reads as configuration, not content. `access.md`
4
4
  * governs the folder it sits in and `.bevelignore` layers like `.gitignore`,
@@ -6,14 +6,15 @@ import { DEFAULT_KB_LAYOUT, FIXED_PLATFORM_FILE_NAMES, agentsFileOf, currentKbLa
6
6
  * the repository root only, so a nested file of either name is ordinary
7
7
  * content. Moving one changes what the platform enforces, so moves refuse them.
8
8
  *
9
- * A FUNCTION, not a constant, and that is the whole of the configurable-guide
10
- * change on this side: the guide's name is a deployment setting, so the fourth
11
- * platform file is `HEXIS.md` on one deployment and `AGENTS.md` on the next —
12
- * and on the first, a root `AGENTS.md` is the CUSTOMER'S own conventions file,
13
- * which has to move and delete like any page. Every gate asks this rather than
14
- * reading a list captured at module load.
9
+ * A FUNCTION OF THE LAYOUT, not a constant, and that is the whole of the
10
+ * configurable-guide change on this side: the guide's name is a deployment
11
+ * setting, so the fourth platform file is `HEXIS.md` on one deployment and
12
+ * `AGENTS.md` on the next — and on the first, a root `AGENTS.md` is the
13
+ * CUSTOMER'S own conventions file, which has to move and delete like any page.
14
+ * Every gate asks this with the layout it serves rather than reading a list
15
+ * captured at module load.
15
16
  */
16
- export function platformFileNames(layout = currentKbLayout()) {
17
+ export function platformFileNames(layout) {
17
18
  return [...FIXED_PLATFORM_FILE_NAMES, agentsFileOf(layout)];
18
19
  }
19
20
  /**
@@ -25,8 +26,8 @@ export function platformFileNames(layout = currentKbLayout()) {
25
26
  export const PLATFORM_FILE_NAMES = Object.freeze(platformFileNames(DEFAULT_KB_LAYOUT));
26
27
  /** The platform files that are read wherever they sit, not only at the root. */
27
28
  const PLATFORM_FILES_AT_ANY_DEPTH = new Set(['access.md', '.bevelignore']);
28
- /** The names in effect, as a set — rebuilt per call, because the guide's is configurable. */
29
- const platformFiles = () => new Set(platformFileNames());
29
+ /** The names under `layout`, as a set — rebuilt per call, because the guide's is configurable. */
30
+ const platformFiles = (layout) => new Set(platformFileNames(layout));
30
31
  const normalize = (path) => path.replace(/^\.?\/+/, '').replace(/\/+$/, '');
31
32
  const baseName = (path) => {
32
33
  const trimmed = normalize(path);
@@ -47,10 +48,10 @@ const hasTraversal = (path) => normalize(path).split('/').some((segment) => segm
47
48
  * Exact spelling, as the platform reads it: `Access.md` is content. A caller
48
49
  * on a case-insensitive disk passes the path's on-disk spelling.
49
50
  */
50
- export function isPlatformFile(repoRelativePath) {
51
+ export function isPlatformFile(repoRelativePath, layout) {
51
52
  const norm = normalize(repoRelativePath);
52
53
  const name = baseName(norm);
53
- if (!platformFiles().has(name))
54
+ if (!platformFiles(layout).has(name))
54
55
  return false;
55
56
  return PLATFORM_FILES_AT_ANY_DEPTH.has(name) || norm === name;
56
57
  }
@@ -76,11 +77,11 @@ export function platformFileCreationRefusal(pathOrName) {
76
77
  * one takes a whole section of the knowledge base with it. Exact spelling; a
77
78
  * caller on a case-insensitive disk passes the path's on-disk spelling.
78
79
  */
79
- export function isPlatformFolder(repoRelativeDir) {
80
+ export function isPlatformFolder(repoRelativeDir, layout) {
80
81
  const norm = normalize(repoRelativeDir);
81
82
  if (norm === '')
82
83
  return true;
83
- return !norm.includes('/') && reservedRootDirNames().has(norm);
84
+ return !norm.includes('/') && reservedRootDirNames(layout).has(norm);
84
85
  }
85
86
  /** The sentence a delete or move of a platform folder is refused with. */
86
87
  export function platformFolderRefusal(repoRelativeDir) {
@@ -97,15 +98,15 @@ export function platformFolderRefusal(repoRelativeDir) {
97
98
  * of the root's rather than standing in for it, which is why moving the
98
99
  * ROOT's copy into a folder is a move out and not a restore.
99
100
  */
100
- export function isRootPlatformFile(repoRelativePath) {
101
- return platformFiles().has(normalize(repoRelativePath));
101
+ export function isRootPlatformFile(repoRelativePath, layout) {
102
+ return platformFiles(layout).has(normalize(repoRelativePath));
102
103
  }
103
- export function platformRestoreDestination(repoRelativeDestination) {
104
+ export function platformRestoreDestination(repoRelativeDestination, layout) {
104
105
  const norm = normalize(repoRelativeDestination);
105
106
  if (hasTraversal(norm))
106
107
  return null;
107
108
  const name = baseName(norm);
108
- if (!platformFiles().has(name))
109
+ if (!platformFiles(layout).has(name))
109
110
  return null;
110
111
  if (name === 'access.md') {
111
112
  const slash = norm.lastIndexOf('/');
@@ -133,15 +134,15 @@ export function platformRestoreDestination(repoRelativeDestination) {
133
134
  * - the destination is a required location for that same name, so the file
134
135
  * lands under the name the platform reads rather than beside it.
135
136
  */
136
- export function isPlatformRestoreShape(repoRelativeSource, repoRelativeDestination) {
137
+ export function isPlatformRestoreShape(repoRelativeSource, repoRelativeDestination, layout) {
137
138
  if (hasTraversal(repoRelativeSource))
138
139
  return false;
139
140
  const name = baseName(repoRelativeSource);
140
- if (!platformFiles().has(name))
141
+ if (!platformFiles(layout).has(name))
141
142
  return false;
142
- if (isRootPlatformFile(repoRelativeSource))
143
+ if (isRootPlatformFile(repoRelativeSource, layout))
143
144
  return false;
144
- const target = platformRestoreDestination(repoRelativeDestination);
145
+ const target = platformRestoreDestination(repoRelativeDestination, layout);
145
146
  return target !== null && target.name === name;
146
147
  }
147
148
  //# sourceMappingURL=platform-files.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"platform-files.js","sourceRoot":"","sources":["../../src/workspace/platform-files.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,iBAAiB,EACjB,yBAAyB,EACzB,YAAY,EACZ,eAAe,EACf,oBAAoB,GAErB,MAAM,gBAAgB,CAAC;AAExB;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,iBAAiB,CAAC,SAAmB,eAAe,EAAE;IACpE,OAAO,CAAC,GAAG,yBAAyB,EAAE,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAsB,MAAM,CAAC,MAAM,CACjE,iBAAiB,CAAC,iBAAiB,CAAC,CACrC,CAAC;AAEF,gFAAgF;AAChF,MAAM,2BAA2B,GAAG,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,cAAc,CAAC,CAAC,CAAC;AAE3E,6FAA6F;AAC7F,MAAM,aAAa,GAAG,GAAwB,EAAE,CAAC,IAAI,GAAG,CAAC,iBAAiB,EAAE,CAAC,CAAC;AAE9E,MAAM,SAAS,GAAG,CAAC,IAAY,EAAU,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;AAE5F,MAAM,QAAQ,GAAG,CAAC,IAAY,EAAU,EAAE;IACxC,MAAM,OAAO,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;IAChC,OAAO,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;AACrD,CAAC,CAAC;AAEF;;;;;;;;GAQG;AACH,MAAM,YAAY,GAAG,CAAC,IAAY,EAAW,EAAE,CAC7C,SAAS,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,KAAK,IAAI,IAAI,OAAO,KAAK,GAAG,CAAC,CAAC;AAEpF;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,gBAAwB;IACrD,MAAM,IAAI,GAAG,SAAS,CAAC,gBAAgB,CAAC,CAAC;IACzC,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC5B,IAAI,CAAC,aAAa,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IAC7C,OAAO,2BAA2B,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,KAAK,IAAI,CAAC;AAChE,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,mBAAmB,CAAC,UAAkB;IACpD,OAAO,GAAG,QAAQ,CAAC,UAAU,CAAC,8CAA8C,CAAC;AAC/E,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,2BAA2B,CAAC,UAAkB;IAC5D,OAAO,GAAG,QAAQ,CAAC,UAAU,CAAC,iEAAiE,CAAC;AAClG,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,eAAuB;IACtD,MAAM,IAAI,GAAG,SAAS,CAAC,eAAe,CAAC,CAAC;IACxC,IAAI,IAAI,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC;IAC7B,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,oBAAoB,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AACjE,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,qBAAqB,CAAC,eAAuB;IAC3D,MAAM,IAAI,GAAG,SAAS,CAAC,eAAe,CAAC,CAAC;IACxC,OAAO,IAAI,KAAK,EAAE;QAChB,CAAC,CAAC,0EAA0E;QAC5E,CAAC,CAAC,GAAG,IAAI,wDAAwD,CAAC;AACtE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,gBAAwB;IACzD,OAAO,aAAa,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,gBAAgB,CAAC,CAAC,CAAC;AAC1D,CAAC;AAqBD,MAAM,UAAU,0BAA0B,CACxC,uBAA+B;IAE/B,MAAM,IAAI,GAAG,SAAS,CAAC,uBAAuB,CAAC,CAAC;IAChD,IAAI,YAAY,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACpC,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC5B,IAAI,CAAC,aAAa,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC5C,IAAI,IAAI,KAAK,WAAW,EAAE,CAAC;QACzB,MAAM,KAAK,GAAG,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;QACpC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,0BAA0B,EAAE,GAAG,EAAE,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,CAAC;IACnG,CAAC;IACD,OAAO,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACvD,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,sBAAsB,CACpC,kBAA0B,EAC1B,uBAA+B;IAE/B,IAAI,YAAY,CAAC,kBAAkB,CAAC;QAAE,OAAO,KAAK,CAAC;IACnD,MAAM,IAAI,GAAG,QAAQ,CAAC,kBAAkB,CAAC,CAAC;IAC1C,IAAI,CAAC,aAAa,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IAC7C,IAAI,kBAAkB,CAAC,kBAAkB,CAAC;QAAE,OAAO,KAAK,CAAC;IACzD,MAAM,MAAM,GAAG,0BAA0B,CAAC,uBAAuB,CAAC,CAAC;IACnE,OAAO,MAAM,KAAK,IAAI,IAAI,MAAM,CAAC,IAAI,KAAK,IAAI,CAAC;AACjD,CAAC"}
1
+ {"version":3,"file":"platform-files.js","sourceRoot":"","sources":["../../src/workspace/platform-files.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/platform-shared",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Shared types and pure domain utilities of the Bevel core platform (auth, workspace, git/workflow contracts, branch registry).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -277,16 +277,67 @@ export interface PullRequestDetail extends PullRequestSummary {
277
277
  */
278
278
  mergeBaseSha: string | null;
279
279
  /**
280
- * True iff the target holds commits the proposal does not contain — the
281
- * request needs updating. False for anything not open.
280
+ * True iff the target holds commits the proposal does not contain. False
281
+ * for anything not open.
282
+ *
283
+ * This is the honest git fact and nothing more — it says the two branches
284
+ * have diverged, NOT that anything about this request has gone stale. On a
285
+ * knowledge base where every shared save commits to the default branch,
286
+ * it is true again minutes after any update. What the dialog acts on is
287
+ * `needsUpdate`.
282
288
  */
283
289
  behind: boolean;
290
+ /**
291
+ * True iff the request is `behind` AND at least one of the files it
292
+ * changes was also changed on the target since the fork point — the only
293
+ * case where the proposal's diff describes text that has moved under it,
294
+ * and so the only case worth merging the target in for.
295
+ *
296
+ * A target that moved in files this request does not contain leaves the
297
+ * request's diff exactly as true as it was: every "before" side is read at
298
+ * the fork point, and the apply merges against the latest target whether an
299
+ * update ran or not. So a request that is `behind` but not `needsUpdate`
300
+ * opens straight to its files.
301
+ *
302
+ * Rename-safe in the conservative direction: the target's change list is
303
+ * computed without rename detection, so a file the target renamed appears
304
+ * under both names and a request holding either name counts as affected.
305
+ * False whenever `behind` is false, and true (with `behind`) when the two
306
+ * branches share no history at all — there is no fork point to intersect
307
+ * against, so nothing may be assumed unaffected.
308
+ */
309
+ needsUpdate: boolean;
284
310
  /**
285
311
  * True iff the viewer may Update the request (merge its target into it):
286
312
  * the request is open AND the viewer is its author or may apply it. A UX
287
313
  * hint — the update route re-checks the same predicate server-side.
288
314
  */
289
315
  viewerCanUpdate: boolean;
316
+ /**
317
+ * True iff the viewer is this request's author — the person who opened it,
318
+ * hash-matched against the stored `authorId` so the client never has to
319
+ * hash an email (and no raw email is exposed to do it with). A request a
320
+ * person's agent opened belongs to that person. Drives wording that speaks
321
+ * to the author directly ("You can delete it below.") rather than naming
322
+ * them in the third person. False when no viewer was passed, and for a
323
+ * request opened outside this backend (no `authorId`).
324
+ */
325
+ viewerIsAuthor: boolean;
326
+ /**
327
+ * True iff the viewer may delete this request (close it AND retire its
328
+ * branch): the request is not applied AND the viewer is either its author
329
+ * or an admin (`viewerCanBypassMerge` is the proxy — the same
330
+ * `canWriteAtRef('roles.yaml')` predicate the DELETE route enforces).
331
+ * Deliberately NOT `viewerCanCancel`: that one also grants the changed
332
+ * files' owners, who may decline a request but must not destroy someone
333
+ * else's text and branch.
334
+ *
335
+ * Note this is true on a CLOSED request too, mirroring the server: a
336
+ * request withdrawn in another tab still has a leftover branch for the
337
+ * delete to retire. Only an applied one is nobody's to delete. A UX hint —
338
+ * `DELETE /api/workflow/change-requests/:number` re-checks server-side.
339
+ */
340
+ viewerCanDelete: boolean;
290
341
  }
291
342
 
292
343
  /**
@@ -3,20 +3,29 @@
3
3
  * their user-visible display names. Single source of truth for BOTH sides of
4
4
  * the app, so backend enforcement and frontend affordances can never drift.
5
5
  *
6
- * CONFIGURED, NOT IMPORTED. This module used to read `process.env` at import
7
- * time and throw when it found nothing — which made the branch model something
8
- * a deployment had to know before any code could load. On the frontend that
9
- * meant baking it into the bundle at build time (Vite `define`), so the same
10
- * artifact could not serve two deployments and changing a branch name meant a
11
- * rebuild. Both sides now CALL {@link configureBranchModel} during boot:
12
- * the backend from its environment, the browser from `GET /api/config`.
6
+ * TWO SHAPES, ONE RULE SET.
13
7
  *
14
- * The exported bindings stay bindings — `DEFAULT_BRANCH` is still imported and
15
- * read exactly as before at every call site. ES module live bindings mean a
16
- * reader inside a function body sees whatever configuration has been applied by
17
- * the time it runs. What does NOT work is capturing one at module scope
18
- * (`const X = DEFAULT_BRANCH` in a file's top level), which snapshots the value
19
- * at import — before configuration. Use a function there instead.
8
+ * A VALUE ({@link BranchModelValue}), resolved by {@link resolveBranchModel}
9
+ * from the shape `/api/config` serves and the setup screen collects. The pure
10
+ * helpers below (`isProtectedBranch`, `protectedBranchDisplayName`) take the
11
+ * value they judge against, so a caller holding two deployments' models — a
12
+ * server hosting several knowledge bases in one process — asks about each by
13
+ * name. The backend uses ONLY this shape: every service is handed its
14
+ * knowledge base's model at construction and never reads a process-wide one.
15
+ *
16
+ * The BROWSER'S live bindings (`DEFAULT_BRANCH`, `PROTECTED_BRANCHES`, …),
17
+ * applied once by {@link configureBranchModel} from `GET /api/config` before
18
+ * React renders. A page shows one deployment, so one process-wide value is
19
+ * the right shape there, and the bindings let a component read the model
20
+ * without threading it through props. They are for the browser: the backend
21
+ * package forbids importing them (an ESLint rule names each one), and a
22
+ * server that configured them would be setting a value for every knowledge
23
+ * base it serves at once.
24
+ *
25
+ * Reading a live binding works inside a function body, which sees whatever
26
+ * configuration has been applied by the time it runs. What does NOT work is
27
+ * capturing one at module scope (`const X = DEFAULT_BRANCH` in a file's top
28
+ * level), which snapshots the value at import — before configuration.
20
29
  */
21
30
 
22
31
  declare const process: { env: Record<string, string | undefined> };
@@ -39,26 +48,66 @@ function deriveDisplayName(slug: string): string {
39
48
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
40
49
  }
41
50
 
51
+ /** The shape both sides configure from, and the shape `/api/config` serves. */
52
+ export interface BranchModel {
53
+ defaultBranch: string;
54
+ /** Slugs; a comma/space-separated string is accepted for env convenience. */
55
+ protectedBranches: string[] | string;
56
+ }
57
+
58
+ /**
59
+ * A branch model as a VALUE: the resolved, validated form of {@link BranchModel}
60
+ * that everything judging a branch is handed.
61
+ *
62
+ * `defaultBranch` is the branch a logged-in user lands on when they don't
63
+ * explicitly pick one, and the default destination for shared drafts. It is
64
+ * the empty string on the one model that is not configured yet
65
+ * ({@link UNCONFIGURED_BRANCH_MODEL}): a fresh deployment has no branch model
66
+ * until its setup screen is answered, and everything behind that gate reads
67
+ * the model only once it is.
68
+ */
69
+ export interface BranchModelValue {
70
+ readonly defaultBranch: string;
71
+ readonly protectedBranches: ReadonlySet<string>;
72
+ /** Slug → human-readable name, for every protected branch. */
73
+ readonly displayNames: Readonly<Record<string, string>>;
74
+ }
75
+
76
+ /**
77
+ * The model a deployment has before setup has answered for one: no default
78
+ * branch, nothing protected. {@link isBranchModelConfigured} is false for
79
+ * exactly this value.
80
+ */
81
+ export const UNCONFIGURED_BRANCH_MODEL: BranchModelValue = Object.freeze({
82
+ defaultBranch: '',
83
+ protectedBranches: new Set<string>(),
84
+ displayNames: Object.freeze({}),
85
+ });
86
+
87
+ /**
88
+ * Whether a model has been configured — the frontend gates render on this, and
89
+ * the backend's setup gate keeps the app shut until it holds.
90
+ */
91
+ export function isBranchModelConfigured(model: BranchModelValue): boolean {
92
+ return model.defaultBranch !== '';
93
+ }
94
+
42
95
  /**
43
96
  * The branch a logged-in user lands on when they don't explicitly pick one, and
44
- * the default destination for shared drafts.
97
+ * the default destination for shared drafts. BROWSER-SIDE live binding — see
98
+ * this file's header; the backend reads its `BranchModelValue` instead.
45
99
  *
46
100
  * Empty until {@link configureBranchModel} runs. Read it inside a function, not
47
- * at module scope — see this file's header.
101
+ * at module scope.
48
102
  */
49
103
  export let DEFAULT_BRANCH: string = '';
50
104
 
105
+ /** Browser-side live binding — see this file's header. */
51
106
  export let PROTECTED_BRANCH_DISPLAY_NAMES: Readonly<Record<string, string>> = Object.freeze({});
52
107
 
108
+ /** Browser-side live binding — see this file's header. */
53
109
  export let PROTECTED_BRANCHES: ReadonlySet<string> = new Set<string>();
54
110
 
55
- /** The shape both sides configure from, and the shape `/api/config` serves. */
56
- export interface BranchModel {
57
- defaultBranch: string;
58
- /** Slugs; a comma/space-separated string is accepted for env convenience. */
59
- protectedBranches: string[] | string;
60
- }
61
-
62
111
  /**
63
112
  * Apply the branch model. Called once during boot on each side, and validated
64
113
  * here rather than at either call site so the two cannot disagree about what
@@ -96,22 +145,46 @@ export function validateBranchModel(model: BranchModel): string | null {
96
145
  return null;
97
146
  }
98
147
 
99
- export function configureBranchModel(model: BranchModel): void {
148
+ /**
149
+ * Resolve a model into the value everything judges against. Throws on an
150
+ * invalid pair — the same rule {@link validateBranchModel} states — so a
151
+ * value, once held, is known to be a usable one.
152
+ */
153
+ export function resolveBranchModel(model: BranchModel): BranchModelValue {
100
154
  const problem = validateBranchModel(model);
101
155
  if (problem) throw new Error(problem);
102
- const defaultBranch = model.defaultBranch.trim();
103
156
  const list = branchListOf(model);
157
+ return Object.freeze({
158
+ defaultBranch: model.defaultBranch.trim(),
159
+ protectedBranches: new Set(list),
160
+ displayNames: Object.freeze(Object.fromEntries(list.map((slug) => [slug, deriveDisplayName(slug)]))),
161
+ });
162
+ }
104
163
 
105
- DEFAULT_BRANCH = defaultBranch;
106
- PROTECTED_BRANCH_DISPLAY_NAMES = Object.freeze(
107
- Object.fromEntries(list.map((slug) => [slug, deriveDisplayName(slug)])),
108
- );
109
- PROTECTED_BRANCHES = new Set(list);
164
+ /**
165
+ * Apply the model to the BROWSER'S live bindings. Called once during boot,
166
+ * from `GET /api/config`, before anything reads them. Validated by
167
+ * {@link resolveBranchModel}, so the bindings and a value resolved from the
168
+ * same model can never disagree.
169
+ */
170
+ export function configureBranchModel(model: BranchModel): void {
171
+ const value = resolveBranchModel(model);
172
+ DEFAULT_BRANCH = value.defaultBranch;
173
+ PROTECTED_BRANCH_DISPLAY_NAMES = value.displayNames;
174
+ PROTECTED_BRANCHES = value.protectedBranches;
110
175
  }
111
176
 
112
- /** Whether {@link configureBranchModel} has run — the frontend gates render on this. */
113
- export function isBranchModelConfigured(): boolean {
114
- return DEFAULT_BRANCH !== '';
177
+ /**
178
+ * The browser's model as a value — for the code paths that take a
179
+ * {@link BranchModelValue} and run in the browser, where the one model in
180
+ * effect is the configured one.
181
+ */
182
+ export function currentBranchModel(): BranchModelValue {
183
+ return {
184
+ defaultBranch: DEFAULT_BRANCH,
185
+ protectedBranches: PROTECTED_BRANCHES,
186
+ displayNames: PROTECTED_BRANCH_DISPLAY_NAMES,
187
+ };
115
188
  }
116
189
 
117
190
  /**
@@ -125,11 +198,9 @@ export function branchModelFromEnv(): BranchModel {
125
198
  };
126
199
  }
127
200
 
128
- export function isProtectedBranch(name: string | null | undefined): boolean {
129
- return !!name && Object.prototype.hasOwnProperty.call(
130
- PROTECTED_BRANCH_DISPLAY_NAMES,
131
- name,
132
- );
201
+ /** Whether `name` is one of `model`'s protected branches. */
202
+ export function isProtectedBranch(model: BranchModelValue, name: string | null | undefined): boolean {
203
+ return !!name && model.protectedBranches.has(name);
133
204
  }
134
205
 
135
206
  /**
@@ -145,8 +216,11 @@ export function isProtectedBranch(name: string | null | undefined): boolean {
145
216
  * whether to fall back to the raw string or hide the affordance.
146
217
  */
147
218
  export function protectedBranchDisplayName(
219
+ model: BranchModelValue,
148
220
  name: string | null | undefined,
149
221
  ): string | null {
150
222
  if (!name) return null;
151
- return PROTECTED_BRANCH_DISPLAY_NAMES[name] ?? null;
223
+ return Object.prototype.hasOwnProperty.call(model.displayNames, name)
224
+ ? model.displayNames[name]!
225
+ : null;
152
226
  }
package/src/git/types.ts CHANGED
@@ -152,7 +152,20 @@ export interface IGitService {
152
152
  * the distinction: an "already up to date" pull that broadcast anyway would
153
153
  * drop every catalog cache and reload every attached browser for nothing.
154
154
  */
155
- pull(workspaceId: string): Promise<{ treeChanged: boolean }>;
155
+ pull(
156
+ workspaceId: string,
157
+ opts?: {
158
+ /**
159
+ * Replay the local commits with `--rebase-merges`, so a merge commit
160
+ * the clone holds but origin has not seen survives the replay AS a
161
+ * merge instead of being flattened into cherry-picks of its second
162
+ * parent's commits. Only the change-request update asks for it: it is
163
+ * the one caller whose unpushed commit is deliberately a merge, and
164
+ * whose whole point is that the merge reaches the remote intact.
165
+ */
166
+ preserveMerges?: boolean;
167
+ },
168
+ ): Promise<{ treeChanged: boolean }>;
156
169
  /**
157
170
  * The remote sync's pull, observed as ONE serialized operation: where HEAD
158
171
  * was, the pull, where HEAD is, and which repo-relative paths changed
@@ -275,6 +288,21 @@ export interface IGitService {
275
288
  at: { baseSha: string; headSha: string },
276
289
  ): Promise<{ mergeBaseSha: string | null; behind: boolean }>;
277
290
 
291
+ /**
292
+ * Every repo-relative path whose content differs between two commits, as a
293
+ * plain two-dot diff with rename detection OFF — so a rename reports both
294
+ * the path it left and the path it arrived at. Two callers want exactly
295
+ * that conservative answer: the approvals carry-forward (a path that
296
+ * appears loses its approval) and "did the target change a file this
297
+ * request also changes" (a request editing a file the target renamed has
298
+ * to count as affected).
299
+ */
300
+ pathsChangedBetween(
301
+ workspaceId: string,
302
+ fromSha: string,
303
+ toSha: string,
304
+ ): Promise<string[]>;
305
+
278
306
  /**
279
307
  * A file's content at a change request's fork point — a commit that must
280
308
  * be on `baseBranch`'s history. `null` when the path did not exist there.
@@ -30,6 +30,7 @@ import type {
30
30
  ChangeRequest,
31
31
  ChangeRequestComment,
32
32
  ChangeRequestDetail,
33
+ ChangeRequestUpdateResult,
33
34
  ChangeRequestState,
34
35
  ChangedFile,
35
36
  FileApproval,
@@ -424,6 +425,23 @@ export interface IWorkflowService {
424
425
  opts?: { fresh?: boolean },
425
426
  ): Promise<ChangeRequest[]>;
426
427
  getChangeRequest(number: number): Promise<ChangeRequest | null>;
428
+ /**
429
+ * The NEWEST change request from `sourceBranch` by `authorEmail` that is no
430
+ * longer open, or null when every one of theirs on that branch is still
431
+ * open (or there never was one).
432
+ *
433
+ * Why it is asked this narrowly: a request that has been declined, or that
434
+ * its author withdrew, leaves no other trace — `listChangeRequestsAuthoredBy`
435
+ * lists open requests only, and nothing records WHO closed a request. Asking
436
+ * for the last non-open one on a deterministic branch is enough to tell
437
+ * "your last request was not accepted" from "you never asked", without a new
438
+ * column. A MERGED request counts as non-open too: the caller distinguishes
439
+ * the two by whether the access it asked for has landed.
440
+ */
441
+ latestClosedChangeRequest(
442
+ authorEmail: string,
443
+ sourceBranch: string,
444
+ ): Promise<{ number: number; state: ChangeRequestState } | null>;
427
445
  getChangeRequestDetail(
428
446
  number: number,
429
447
  /**
@@ -457,8 +475,16 @@ export interface IWorkflowService {
457
475
  * someone who may apply it (`viewerCanUpdate`) may run it (403 otherwise).
458
476
  * A conflicting merge is aborted, leaving the branch exactly as it was, and
459
477
  * surfaces as `ChangeRequestConflictsError` (409).
478
+ *
479
+ * Answers the refreshed detail plus `updatedPaths`: which files the merge
480
+ * changed on the branch, so a caller showing the pre-update files can
481
+ * replace exactly those.
460
482
  */
461
- updateFromTarget(workspaceId: string, user: AuthUser, number: number): Promise<ChangeRequestDetail>;
483
+ updateFromTarget(
484
+ workspaceId: string,
485
+ user: AuthUser,
486
+ number: number,
487
+ ): Promise<ChangeRequestUpdateResult>;
462
488
 
463
489
  // Comments
464
490
  listComments(number: number): Promise<ChangeRequestComment[]>;
@@ -124,6 +124,22 @@ export type AcquireLockResult =
124
124
  */
125
125
  export type ChangeRequest = PullRequestSummary;
126
126
  export type ChangeRequestDetail = PullRequestDetail;
127
+ /**
128
+ * What an Update hands back: the refreshed detail, plus the repo-relative
129
+ * paths the merge actually changed on the request's branch.
130
+ *
131
+ * The dialog runs its update behind the files it is already showing, so when
132
+ * the update lands it has to replace file content it has read. `updatedPaths`
133
+ * is which — git's own two-dot diff between the branch head before the merge
134
+ * and after it, the same list the approvals carry-forward is decided on. A
135
+ * client that forgot everything instead would re-read every file in the
136
+ * request to replace the one or two the merge touched, and blank the pane the
137
+ * reader is mid-sentence in.
138
+ *
139
+ * Empty when the merge changed nothing on the branch — including the case
140
+ * where there was nothing to merge.
141
+ */
142
+ export type ChangeRequestUpdateResult = ChangeRequestDetail & { updatedPaths: string[] };
127
143
  export type ChangeRequestState = PullRequestState;
128
144
  export type ChangedFile = PullRequestFile;
129
145