@bevel-software/platform-core-backend 0.10.0 → 0.11.1

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 (188) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +12 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +22 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +34 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/modules/access/access-control.interface.d.ts +54 -28
  13. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  14. package/dist/modules/access/access-control.service.d.ts +129 -14
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +435 -66
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  19. package/dist/modules/access/access-declarations.js +5 -3
  20. package/dist/modules/access/access-declarations.js.map +1 -1
  21. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  22. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-mutation.service.js +78 -18
  24. package/dist/modules/access/access-mutation.service.js.map +1 -1
  25. package/dist/modules/access/access-splice.d.ts +31 -4
  26. package/dist/modules/access/access-splice.d.ts.map +1 -1
  27. package/dist/modules/access/access-splice.js +40 -16
  28. package/dist/modules/access/access-splice.js.map +1 -1
  29. package/dist/modules/access/access.routes.d.ts.map +1 -1
  30. package/dist/modules/access/access.routes.js +204 -82
  31. package/dist/modules/access/access.routes.js.map +1 -1
  32. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  33. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  34. package/dist/modules/access/admin-locked-commit.js +277 -0
  35. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  36. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  37. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  38. package/dist/modules/access/admin-route-helpers.js +44 -0
  39. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  40. package/dist/modules/access/capability-registry.d.ts +41 -0
  41. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  42. package/dist/modules/access/capability-registry.js +46 -0
  43. package/dist/modules/access/capability-registry.js.map +1 -0
  44. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  45. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  46. package/dist/modules/access/directory-sync-bot.js +64 -0
  47. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  48. package/dist/modules/access/group-files.d.ts +83 -0
  49. package/dist/modules/access/group-files.d.ts.map +1 -0
  50. package/dist/modules/access/group-files.js +167 -0
  51. package/dist/modules/access/group-files.js.map +1 -0
  52. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  53. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  54. package/dist/modules/access/groups-admin.routes.js +98 -0
  55. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  56. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  57. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  58. package/dist/modules/access/groups-admin.service.js +442 -0
  59. package/dist/modules/access/groups-admin.service.js.map +1 -0
  60. package/dist/modules/access/groups-edit.d.ts +58 -0
  61. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  62. package/dist/modules/access/groups-edit.js +162 -0
  63. package/dist/modules/access/groups-edit.js.map +1 -0
  64. package/dist/modules/access/reference-scan.d.ts +141 -0
  65. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  66. package/dist/modules/access/reference-scan.js +440 -0
  67. package/dist/modules/access/reference-scan.js.map +1 -0
  68. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  69. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  70. package/dist/modules/access/roles-admin.service.js +230 -384
  71. package/dist/modules/access/roles-admin.service.js.map +1 -1
  72. package/dist/modules/access/roles-edit.d.ts +51 -25
  73. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  74. package/dist/modules/access/roles-edit.js +133 -59
  75. package/dist/modules/access/roles-edit.js.map +1 -1
  76. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  77. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  78. package/dist/modules/access/synced-groups-committer.js +139 -0
  79. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  80. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  81. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  82. package/dist/modules/access/synced-groups-writer.js +219 -0
  83. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  84. package/dist/modules/database/core-schema.d.ts +17 -0
  85. package/dist/modules/database/core-schema.d.ts.map +1 -1
  86. package/dist/modules/database/core-schema.js +9 -0
  87. package/dist/modules/database/core-schema.js.map +1 -1
  88. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  89. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  90. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  91. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  92. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  93. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  94. package/dist/modules/mcp/mcp.routes.js +126 -2
  95. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  96. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  97. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  98. package/dist/modules/mcp/mcp.service.js +6 -1
  99. package/dist/modules/mcp/mcp.service.js.map +1 -1
  100. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  101. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/file-lock.service.js +15 -1
  103. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  104. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  105. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  106. package/dist/modules/workflow/git/git.service.js +165 -37
  107. package/dist/modules/workflow/git/git.service.js.map +1 -1
  108. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  109. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  110. package/dist/modules/workflow/locking-filesystem.js +181 -28
  111. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  112. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  113. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  114. package/dist/modules/workflow/pending-commits.service.js +19 -1
  115. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  116. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  117. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  118. package/dist/modules/workflow/workflow.service.js +113 -19
  119. package/dist/modules/workflow/workflow.service.js.map +1 -1
  120. package/kb-template/AGENTS.md +4 -1
  121. package/migrations/0004_file_lock_mode.sql +2 -0
  122. package/migrations/meta/0004_snapshot.json +1564 -0
  123. package/migrations/meta/_journal.json +7 -0
  124. package/package.json +3 -3
  125. package/src/core/create-core-server.ts +13 -0
  126. package/src/core/create-core-services.ts +68 -0
  127. package/src/index.ts +10 -0
  128. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  129. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  130. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  131. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  132. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  133. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  134. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  135. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  136. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  137. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  138. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  139. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  140. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  141. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  142. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  143. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  144. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  145. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  146. package/src/modules/access/access-control.interface.ts +66 -32
  147. package/src/modules/access/access-control.service.ts +536 -73
  148. package/src/modules/access/access-declarations.ts +5 -2
  149. package/src/modules/access/access-mutation.service.ts +88 -14
  150. package/src/modules/access/access-splice.ts +55 -17
  151. package/src/modules/access/access.routes.ts +227 -93
  152. package/src/modules/access/admin-locked-commit.ts +331 -0
  153. package/src/modules/access/admin-route-helpers.ts +55 -0
  154. package/src/modules/access/capability-registry.ts +74 -0
  155. package/src/modules/access/directory-sync-bot.ts +76 -0
  156. package/src/modules/access/group-files.ts +212 -0
  157. package/src/modules/access/groups-admin.routes.ts +113 -0
  158. package/src/modules/access/groups-admin.service.ts +551 -0
  159. package/src/modules/access/groups-edit.ts +187 -0
  160. package/src/modules/access/reference-scan.ts +513 -0
  161. package/src/modules/access/roles-admin.service.ts +290 -418
  162. package/src/modules/access/roles-edit.ts +134 -61
  163. package/src/modules/access/synced-groups-committer.ts +177 -0
  164. package/src/modules/access/synced-groups-writer.ts +303 -0
  165. package/src/modules/database/core-schema.ts +9 -0
  166. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  167. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  168. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  169. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  170. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  171. package/src/modules/mcp/mcp.routes.ts +137 -2
  172. package/src/modules/mcp/mcp.service.ts +6 -1
  173. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  174. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  175. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  176. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  177. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  178. package/src/modules/workflow/file-lock.service.ts +15 -0
  179. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  180. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  181. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  182. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  183. package/src/modules/workflow/git/git.service.ts +174 -35
  184. package/src/modules/workflow/locking-filesystem.ts +188 -26
  185. package/src/modules/workflow/pending-commits.service.ts +27 -1
  186. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  187. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  188. package/src/modules/workflow/workflow.service.ts +140 -20
@@ -126,7 +126,6 @@ function recordingAccessControl(opts: {
126
126
  findEmailByHash: async () => null,
127
127
  kbPrincipals: async () => ({ plugins: [], people: [] }),
128
128
  validateRolesYaml: () => ({ ok: true }),
129
- referencesToRole: async () => [],
130
129
  };
131
130
  return { ac, calls };
132
131
  }
@@ -102,4 +102,136 @@ describe('GitService.commitChanges', () => {
102
102
  const change = await svc.commitChanges(workspaceId, USER, 'no-op');
103
103
  expect(change).toBeNull();
104
104
  });
105
+
106
+ it('onlyPaths scopes the commit — an unrelated dirty file is NOT swept in', async () => {
107
+ const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
108
+ const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), makeValidator(), PROCESS_MAP_DIR);
109
+
110
+ // The batch's own file, plus a CONCURRENT save's bytes whose commit is
111
+ // still queued — the shared per-branch workspace makes this ordinary.
112
+ await fs.writeFile(path.join(repo, 'synced-groups.yaml'), 'groups: {}\n');
113
+ await fs.writeFile(path.join(repo, 'Old.md'), 'someone else mid-save\n');
114
+
115
+ const change = await svc.commitChanges(workspaceId, USER, 'Sync directory groups', [
116
+ `${PROCESS_MAP_DIR}/synced-groups.yaml`,
117
+ ]);
118
+
119
+ expect(change?.sha).toBeTruthy();
120
+ const { stdout: nameStatus } = await execFileAsync('git', ['-C', repo, 'show', '--name-status', '--pretty=format:', 'HEAD']);
121
+ expect(nameStatus).toMatch(/A\s+synced-groups\.yaml/);
122
+ expect(nameStatus).not.toMatch(/Old\.md/);
123
+ // The unrelated file stays dirty for its OWN queued commit.
124
+ const { stdout: status } = await execFileAsync('git', ['-C', repo, 'status', '--porcelain']);
125
+ expect(status).toContain('Old.md');
126
+ });
127
+
128
+ it('onlyPaths keeps a staged RENAME whole — old path deletion rides the scoped commit', async () => {
129
+ const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
130
+ const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), makeValidator(), PROCESS_MAP_DIR);
131
+
132
+ // A staged rename shows as one `R` porcelain record (new-path + old-path).
133
+ // The scope names only the NEW path; the old path must ride along or the
134
+ // rename decays into an add that leaves Old.md alive in HEAD.
135
+ await runGit(repo, ['mv', 'Old.md', 'New.md']);
136
+ const change = await svc.commitChanges(workspaceId, USER, 'Rename via batch', [
137
+ `${PROCESS_MAP_DIR}/New.md`,
138
+ ]);
139
+
140
+ expect(change?.sha).toBeTruthy();
141
+ const { stdout: nameStatus } = await execFileAsync('git', ['-C', repo, 'show', '--name-status', '-M', '--pretty=format:', 'HEAD']);
142
+ expect(nameStatus).toMatch(/R\d*\s+Old\.md\s+New\.md/);
143
+ // Old.md is gone from HEAD — not left behind as a live file.
144
+ const { stdout: lsTree } = await execFileAsync('git', ['-C', repo, 'ls-tree', '-r', '--name-only', 'HEAD']);
145
+ expect(lsTree).not.toContain('Old.md');
146
+ expect(lsTree).toContain('New.md');
147
+ });
148
+
149
+ it('a REAL `git add` failure aborts the scoped commit — only the pathspec miss is tolerated', async () => {
150
+ const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
151
+ const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), makeValidator(), PROCESS_MAP_DIR);
152
+
153
+ await fs.writeFile(path.join(repo, 'Old.md'), 'new bytes\n');
154
+ // Hold git's index lock: `git add` now fails with a real error (index
155
+ // contention), which must PROPAGATE — swallowing it would let the commit
156
+ // record stale index content as this caller's change.
157
+ await fs.writeFile(path.join(repo, '.git', 'index.lock'), '');
158
+ try {
159
+ await expect(
160
+ svc.commitChanges(workspaceId, USER, 'must not land', [`${PROCESS_MAP_DIR}/Old.md`]),
161
+ ).rejects.toThrow(/git (add|commit) failed/);
162
+ } finally {
163
+ await fs.rm(path.join(repo, '.git', 'index.lock'), { force: true });
164
+ }
165
+ // Nothing was committed — still just the seed commit.
166
+ const { stdout } = await execFileAsync('git', ['-C', repo, 'log', '--oneline']);
167
+ expect(stdout.trim().split('\n')).toHaveLength(1);
168
+ });
169
+
170
+ it('a several-hundred-file scoped batch commits (pathspecs ride stdin, not argv)', async () => {
171
+ // ~300 long paths would blow Windows' ~32K argv limit if the add/commit
172
+ // pathspecs were passed on the command line — the scoped path feeds them
173
+ // via `--pathspec-from-file=-` on stdin instead.
174
+ const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
175
+ const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), makeValidator(), PROCESS_MAP_DIR);
176
+
177
+ const dir = 'Product/Knowledge/A Rather Long Folder Name To Inflate The Pathspec Bytes';
178
+ await fs.mkdir(path.join(repo, dir), { recursive: true });
179
+ const batch: string[] = [];
180
+ for (let i = 0; i < 300; i++) {
181
+ const rel = `${dir}/A quite long knowledge node file name number ${String(i).padStart(3, '0')}.md`;
182
+ await fs.writeFile(path.join(repo, rel), `# node ${i}\n`);
183
+ batch.push(`${PROCESS_MAP_DIR}/${rel}`);
184
+ }
185
+ // An unrelated dirty file proves the scope still holds at this size.
186
+ await fs.writeFile(path.join(repo, 'Old.md'), 'someone else mid-save\n');
187
+
188
+ const change = await svc.commitChanges(workspaceId, USER, 'Bulk import (300 nodes)', batch);
189
+
190
+ expect(change?.sha).toBeTruthy();
191
+ const { stdout: names } = await execFileAsync(
192
+ 'git',
193
+ ['-C', repo, 'show', '--name-only', '--pretty=format:', 'HEAD'],
194
+ { maxBuffer: 16 * 1024 * 1024 },
195
+ );
196
+ const committed = names.split('\n').filter(Boolean);
197
+ expect(committed).toHaveLength(300);
198
+ expect(committed).not.toContain('Old.md');
199
+ const { stdout: status } = await execFileAsync('git', ['-C', repo, 'status', '--porcelain']);
200
+ expect(status).toContain('Old.md'); // untouched, still dirty
201
+ });
202
+
203
+ it('a big batch containing a fully-staged deletion still commits (per-path fallback)', async () => {
204
+ const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
205
+ const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), makeValidator(), PROCESS_MAP_DIR);
206
+
207
+ // Stage Old.md's deletion fully — the batched `git add` then has nothing
208
+ // in the worktree to match for it and fails with the ONE expected miss,
209
+ // which must fall back to tolerant per-path adds, not abort the batch.
210
+ await runGit(repo, ['rm', 'Old.md']);
211
+ await fs.writeFile(path.join(repo, 'New-A.md'), 'a\n');
212
+ await fs.writeFile(path.join(repo, 'New-B.md'), 'b\n');
213
+
214
+ const change = await svc.commitChanges(workspaceId, USER, 'Delete + add batch', [
215
+ `${PROCESS_MAP_DIR}/Old.md`,
216
+ `${PROCESS_MAP_DIR}/New-A.md`,
217
+ `${PROCESS_MAP_DIR}/New-B.md`,
218
+ ]);
219
+
220
+ expect(change?.sha).toBeTruthy();
221
+ const { stdout: nameStatus } = await execFileAsync('git', ['-C', repo, 'show', '--name-status', '--pretty=format:', 'HEAD']);
222
+ expect(nameStatus).toMatch(/D\s+Old\.md/);
223
+ expect(nameStatus).toMatch(/A\s+New-A\.md/);
224
+ expect(nameStatus).toMatch(/A\s+New-B\.md/);
225
+ });
226
+
227
+ it('onlyPaths with nothing of its own dirty is a no-op even when other files are dirty', async () => {
228
+ const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
229
+ const svc = new GitService(stubWorkspaceService(workspaceId, workspaceDir), makeValidator(), PROCESS_MAP_DIR);
230
+
231
+ await fs.writeFile(path.join(repo, 'Old.md'), 'someone else mid-save\n');
232
+ const change = await svc.commitChanges(workspaceId, USER, 'Sync directory groups', [
233
+ `${PROCESS_MAP_DIR}/synced-groups.yaml`,
234
+ ]);
235
+ expect(change).toBeNull();
236
+ });
105
237
  });
@@ -136,7 +136,6 @@ function fakeAccessControl(adminEmails: string[]): IAccessControl {
136
136
  findEmailByHash: async () => null,
137
137
  kbPrincipals: async () => ({ plugins: [], people: [] }),
138
138
  validateRolesYaml: () => ({ ok: true }),
139
- referencesToRole: async () => [],
140
139
  canWriteAtRef: async () => null,
141
140
  canWriteBatchAtRef: async () => null,
142
141
  eligibleWritersAtRef: async () => null,
@@ -66,7 +66,6 @@ function makeAccessControl(byRef: WritersByRefAndPath): IAccessControl {
66
66
  findEmailByHash: async () => null,
67
67
  kbPrincipals: async () => ({ plugins: [], people: [] }),
68
68
  validateRolesYaml: () => ({ ok: true }),
69
- referencesToRole: async () => [],
70
69
  };
71
70
  }
72
71
 
@@ -68,8 +68,23 @@ const BOT_EMAIL = 'bevel-workflow@bevel.software';
68
68
  * we keep the new path and discard the old one.
69
69
  */
70
70
  export function parsePorcelainZ(stdout: string): string[] {
71
+ return parsePorcelainZDetailed(stdout).paths;
72
+ }
73
+
74
+ /**
75
+ * Like {@link parsePorcelainZ}, but also reports each rename/copy entry's
76
+ * OLD path (keyed by the new path). A pathspec-scoped commit needs the old
77
+ * path too: committing only the new path of a staged rename records an ADD
78
+ * and leaves the deletion of the old path behind — the rename becomes two
79
+ * files.
80
+ */
81
+ export function parsePorcelainZDetailed(stdout: string): {
82
+ paths: string[];
83
+ renameOldByNew: Map<string, string>;
84
+ } {
71
85
  const tokens = stdout.split('\0').filter((t) => t.length > 0);
72
86
  const paths = new Set<string>();
87
+ const renameOldByNew = new Map<string, string>();
73
88
  let i = 0;
74
89
  while (i < tokens.length) {
75
90
  const token = tokens[i];
@@ -77,12 +92,19 @@ export function parsePorcelainZ(stdout: string): string[] {
77
92
  if (token.length < 4) { i++; continue; }
78
93
  const x = token[0];
79
94
  const y = token[1];
80
- paths.add(token.slice(3));
95
+ const newPath = token.slice(3);
96
+ paths.add(newPath);
81
97
  // Rename or copy in either the index or worktree position carries an
82
- // extra NUL-separated old-path field we must skip.
83
- i += (x === 'R' || x === 'C' || y === 'R' || y === 'C') ? 2 : 1;
98
+ // extra NUL-separated old-path field.
99
+ if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
100
+ const oldPath = tokens[i + 1];
101
+ if (oldPath) renameOldByNew.set(newPath, oldPath);
102
+ i += 2;
103
+ } else {
104
+ i += 1;
105
+ }
84
106
  }
85
- return Array.from(paths);
107
+ return { paths: Array.from(paths), renameOldByNew };
86
108
  }
87
109
 
88
110
  /**
@@ -970,23 +992,30 @@ export class GitService implements IGitService {
970
992
  }
971
993
 
972
994
  /**
973
- * Atomic multi-file commit — commits whatever is currently dirty in the
974
- * working tree as ONE commit attributed to `user`, then returns its
975
- * attribution (or null when the tree is clean — idempotent re-apply). The
976
- * multi-file sibling of `commit()`: same `git add -A` + commit, but WITHOUT
977
- * the one-file-per-change guard — the caller has assembled + written + validated
978
- * a batch (bulk node upload; the role-rename rewrite via `LockingFilesystem`).
995
+ * Atomic multi-file commit — commits the caller's batch as ONE commit
996
+ * attributed to `user`, then returns its attribution (or null when nothing
997
+ * in scope is dirty — idempotent re-apply). The multi-file sibling of
998
+ * `commit()`, WITHOUT the one-file-per-change guard — the caller has
999
+ * assembled + written + validated a batch (bulk node upload; the lock-aware
1000
+ * saves and admin roster writes via `LockingFilesystem` /
1001
+ * `AdminLockedCommits`).
1002
+ *
1003
+ * `onlyPaths` (workspace-relative) scopes staging + commit to the caller's
1004
+ * own files via git pathspecs: on the shared per-branch workspace other
1005
+ * paths may be dirty from a concurrent save whose commit is still queued,
1006
+ * and sweeping them in would land them under this caller's author/summary.
1007
+ * Omitted → the whole dirty set (`git add -A`), for flows that own the
1008
+ * workspace.
979
1009
  *
980
1010
  * Deliberately does NOT write file content itself — disk writes are the
981
- * caller's job. This is the git layer: it only stages + commits what is already
982
- * on disk (the working tree is expected otherwise-clean under save=share, so
983
- * `git add -A` stages exactly the caller's batch). Caller pushes separately
984
- * (mirrors `commitFile` + `push`).
1011
+ * caller's job. This is the git layer: it only stages + commits what is
1012
+ * already on disk. Caller pushes separately (mirrors `commitFile` + `push`).
985
1013
  */
986
1014
  async commitChanges(
987
1015
  workspaceId: string,
988
1016
  user: AuthUser,
989
1017
  summary: string,
1018
+ onlyPaths?: string[],
990
1019
  ): Promise<CommitAttribution | null> {
991
1020
  assertValidAuthor(user);
992
1021
  const subject = summary?.trim();
@@ -1000,8 +1029,27 @@ export class GitService implements IGitService {
1000
1029
 
1001
1030
  // Nothing dirty → no-op (idempotent). Compute touched BEFORE `git add` so
1002
1031
  // the protected-branch gate sees the same path set `commit()` would.
1003
- const { stdout: porcelain } = await this.git(cwd, ['status', '--porcelain=v1', '-z']);
1004
- const touched = parsePorcelainZ(porcelain);
1032
+ // `--untracked-files=all` lists every untracked FILE individually —
1033
+ // without it a brand-new directory shows as one `?? dir/` entry, so a
1034
+ // scoped batch creating files under a new folder would match nothing
1035
+ // against `onlyPaths` and silently no-op.
1036
+ const { stdout: porcelain } = await this.git(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
1037
+ const detailed = parsePorcelainZDetailed(porcelain);
1038
+ let touched = detailed.paths;
1039
+ // Scope to the caller's own paths (see the interface doc): other files
1040
+ // may be dirty from a concurrent same-branch save whose commit is still
1041
+ // queued — sweeping them in would land them under this caller's
1042
+ // author/summary, and gate the commit on paths the caller never touched.
1043
+ // An in-scope RENAME keeps its old path too: a pathspec naming only the
1044
+ // new path records an add and strands the deletion of the old one.
1045
+ if (onlyPaths) {
1046
+ const scope = new Set(onlyPaths.map((p) => this.stripRepoPrefix(p)));
1047
+ touched = touched.filter((p) => scope.has(p));
1048
+ for (const p of touched.slice()) {
1049
+ const oldPath = detailed.renameOldByNew.get(p);
1050
+ if (oldPath && !touched.includes(oldPath)) touched.push(oldPath);
1051
+ }
1052
+ }
1005
1053
  if (touched.length === 0) return null;
1006
1054
 
1007
1055
  // Protected-branch access gate (defence in depth — callers already gate at
@@ -1011,20 +1059,83 @@ export class GitService implements IGitService {
1011
1059
  await this.assertCanWriteAtRef(workspaceId, 'HEAD', user.email, touched);
1012
1060
  }
1013
1061
 
1014
- await this.git(cwd, ['add', '-A']);
1015
- await this.git(cwd, ['commit', `--author=${user.name} <${user.email}>`, '-m', subject]);
1062
+ // Scoped → stage the batch (a brand-new file must be `add`ed before a
1063
+ // pathspec commit will know it), then commit WITH the pathspec: it
1064
+ // records exactly those paths, so neither other dirty files nor an
1065
+ // unrelated entry someone left staged can ride along.
1066
+ //
1067
+ // Both stages feed the pathspecs over STDIN (`--pathspec-from-file=-`,
1068
+ // NUL-separated) instead of argv: a several-hundred-file batch of long
1069
+ // KB paths would otherwise blow Windows' ~32K command-line limit. The
1070
+ // add is ONE spawn for the whole batch; only when git reports the ONE
1071
+ // expected miss — a fully-staged deletion (or the old half of a rename)
1072
+ // has nothing left in the worktree to match, which fails the entire
1073
+ // batched add — do we fall back to per-path adds with the same
1074
+ // tolerance, in argv-safe chunks of one path per spawn.
1075
+ if (onlyPaths) {
1076
+ const pathspecInput = touched.join('\0');
1077
+ const pathspecArgs = ['--pathspec-from-file=-', '--pathspec-file-nul'];
1078
+ const isExpectedMiss = (err: unknown): boolean => {
1079
+ // (LC_ALL=C pins the message to English.)
1080
+ const e = err as Error & { stderr?: string };
1081
+ return /did not match any files/.test(`${e.stderr ?? ''}\n${e.message}`);
1082
+ };
1083
+ try {
1084
+ await this.git(cwd, ['add', '-A', ...pathspecArgs], { input: pathspecInput });
1085
+ } catch (err) {
1086
+ // Any other add failure — index.lock contention, I/O — must abort:
1087
+ // committing without staging would record stale index content as
1088
+ // this caller's change.
1089
+ if (!isExpectedMiss(err)) throw err;
1090
+ for (const p of touched) {
1091
+ try {
1092
+ await this.git(cwd, ['add', '-A', '--', p]);
1093
+ } catch (perPathErr) {
1094
+ if (!isExpectedMiss(perPathErr)) throw perPathErr;
1095
+ }
1096
+ }
1097
+ }
1098
+ await this.git(
1099
+ cwd,
1100
+ ['commit', `--author=${user.name} <${user.email}>`, '-m', subject, ...pathspecArgs],
1101
+ { input: pathspecInput },
1102
+ );
1103
+ } else {
1104
+ await this.git(cwd, ['add', '-A']);
1105
+ await this.git(cwd, ['commit', `--author=${user.name} <${user.email}>`, '-m', subject]);
1106
+ }
1016
1107
 
1017
- const { stdout } = await this.git(cwd, [
1018
- 'log', '-1', '--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
1019
- ]);
1020
- const [sha, authorName, authorEmail, subj, committedAt] = stdout.split('\x00');
1108
+ // CONTRACT: past this point the commit EXISTS, so this method must not
1109
+ // throw — callers (writeAndCommitLocked) treat a throw as "nothing was
1110
+ // committed" and restore their pre-edit bytes, which here would publish
1111
+ // a compensating revert of a commit that DID land. The attribution read
1112
+ // is best-effort; on failure fall back to what we already know.
1113
+ let sha = '';
1114
+ let authorName = user.name;
1115
+ let authorEmail = user.email;
1116
+ let subj = subject;
1117
+ let committedAt = '';
1118
+ try {
1119
+ const { stdout } = await this.git(cwd, [
1120
+ 'log', '-1', '--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
1121
+ ]);
1122
+ [sha, authorName, authorEmail, subj, committedAt] = stdout.split('\x00');
1123
+ } catch {
1124
+ // The commit landed; only its attribution read failed. Give the sha
1125
+ // one simpler second chance — callers use it as the commit id, and an
1126
+ // empty sha would make a landed change unidentifiable downstream.
1127
+ try {
1128
+ const { stdout } = await this.git(cwd, ['rev-parse', 'HEAD']);
1129
+ sha = stdout;
1130
+ } catch { /* keep the fallback attribution */ }
1131
+ }
1021
1132
  this.accessControl?.invalidate(workspaceId);
1022
1133
  return {
1023
1134
  sha: sha?.trim() ?? '',
1024
1135
  authorName: authorName ?? '',
1025
1136
  authorEmail: authorEmail ?? '',
1026
1137
  subject: subj ?? subject,
1027
- committedAt: committedAt?.trim() ?? new Date().toISOString(),
1138
+ committedAt: committedAt?.trim() || new Date().toISOString(),
1028
1139
  };
1029
1140
  });
1030
1141
  }
@@ -1467,18 +1578,26 @@ export class GitService implements IGitService {
1467
1578
  * Used by the pending-commits worker's no-op arm: a clean tree does NOT
1468
1579
  * mean "nothing to share" when a prior best-effort push (the autosave
1469
1580
  * path) failed and left the committed change stranded locally.
1581
+ *
1582
+ * Serialized under the workspace mutex: a pull-rebase in flight moves HEAD
1583
+ * through states where the local commits are momentarily unreachable, and
1584
+ * an unserialized read there answers "nothing unpushed" about work that is
1585
+ * about to be replayed — callers use this as a publication PROOF, so it may
1586
+ * only ever see settled states.
1470
1587
  */
1471
1588
  async hasUnpushedCommits(workspaceId: string): Promise<boolean> {
1472
- const cwd = await this.repoDir(workspaceId);
1473
- const branch = await this.currentBranch(cwd);
1474
- try {
1475
- const { stdout } = await this.git(cwd, [
1476
- 'rev-list', '--count', `refs/remotes/origin/${branch}..HEAD`,
1477
- ]);
1478
- return Number(stdout.trim()) > 0;
1479
- } catch {
1480
- return true;
1481
- }
1589
+ return this.mutex.run(workspaceId, async () => {
1590
+ const cwd = await this.repoDir(workspaceId);
1591
+ const branch = await this.currentBranch(cwd);
1592
+ try {
1593
+ const { stdout } = await this.git(cwd, [
1594
+ 'rev-list', '--count', `refs/remotes/origin/${branch}..HEAD`,
1595
+ ]);
1596
+ return Number(stdout.trim()) > 0;
1597
+ } catch {
1598
+ return true;
1599
+ }
1600
+ });
1482
1601
  }
1483
1602
 
1484
1603
  /**
@@ -2279,9 +2398,21 @@ export class GitService implements IGitService {
2279
2398
  return stdout.trim().length > 0;
2280
2399
  }
2281
2400
 
2282
- private async git(cwd: string, args: string[]): Promise<GitRunResult> {
2401
+ private async git(
2402
+ cwd: string,
2403
+ args: string[],
2404
+ opts?: {
2405
+ /**
2406
+ * Bytes to feed the subprocess on stdin — used by the
2407
+ * `--pathspec-from-file=-` commit/add paths so a several-hundred-file
2408
+ * batch never has to ride the argv (Windows caps a command line at
2409
+ * ~32K chars).
2410
+ */
2411
+ input?: string;
2412
+ },
2413
+ ): Promise<GitRunResult> {
2283
2414
  try {
2284
- const { stdout, stderr } = await execFileAsync('git', args, {
2415
+ const pending = execFileAsync('git', args, {
2285
2416
  cwd,
2286
2417
  // `GIT_LITERAL_PATHSPECS=1` makes git treat every pathspec literally
2287
2418
  // instead of interpreting `[`, `]`, `*`, `?`, `!`, or `:(magic)` as
@@ -2302,6 +2433,14 @@ export class GitService implements IGitService {
2302
2433
  env: { ...process.env, GIT_LITERAL_PATHSPECS: '1', LC_ALL: 'C', LANG: 'C' },
2303
2434
  maxBuffer: 32 * 1024 * 1024,
2304
2435
  });
2436
+ if (opts?.input !== undefined && pending.child.stdin) {
2437
+ // A dying git can close stdin mid-write; the promise below still
2438
+ // rejects with the exit code, which is the error we want to surface.
2439
+ pending.child.stdin.on('error', () => undefined);
2440
+ pending.child.stdin.write(opts.input);
2441
+ pending.child.stdin.end();
2442
+ }
2443
+ const { stdout, stderr } = await pending;
2305
2444
  return { stdout: stdout.toString(), stderr: stderr.toString() };
2306
2445
  } catch (err) {
2307
2446
  const msg = err instanceof Error ? err.message : String(err);