@bevel-software/platform-core-backend 0.36.1 → 0.37.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.
Files changed (71) hide show
  1. package/dist/core/create-core-services.d.ts.map +1 -1
  2. package/dist/core/create-core-services.js +5 -2
  3. package/dist/core/create-core-services.js.map +1 -1
  4. package/dist/modules/database/connection.d.ts.map +1 -1
  5. package/dist/modules/database/connection.js +11 -0
  6. package/dist/modules/database/connection.js.map +1 -1
  7. package/dist/modules/kb-fs/file-change-notifier.d.ts +51 -26
  8. package/dist/modules/kb-fs/file-change-notifier.d.ts.map +1 -1
  9. package/dist/modules/kb-fs/file-change-notifier.js +6 -8
  10. package/dist/modules/kb-fs/file-change-notifier.js.map +1 -1
  11. package/dist/modules/kb-fs/locking-filesystem.d.ts +0 -7
  12. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  13. package/dist/modules/kb-fs/locking-filesystem.js +4 -12
  14. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  15. package/dist/modules/onboarding/starter-pack.service.d.ts +1 -3
  16. package/dist/modules/onboarding/starter-pack.service.d.ts.map +1 -1
  17. package/dist/modules/onboarding/starter-pack.service.js +1 -1
  18. package/dist/modules/onboarding/starter-pack.service.js.map +1 -1
  19. package/dist/modules/version-control/git/git.service.d.ts +18 -0
  20. package/dist/modules/version-control/git/git.service.d.ts.map +1 -1
  21. package/dist/modules/version-control/git/git.service.js +53 -2
  22. package/dist/modules/version-control/git/git.service.js.map +1 -1
  23. package/dist/modules/version-control/version-control-hooks.d.ts +29 -2
  24. package/dist/modules/version-control/version-control-hooks.d.ts.map +1 -1
  25. package/dist/modules/version-control/version-control-hooks.js +17 -0
  26. package/dist/modules/version-control/version-control-hooks.js.map +1 -1
  27. package/dist/modules/version-control/version-control.service.d.ts +0 -15
  28. package/dist/modules/version-control/version-control.service.d.ts.map +1 -1
  29. package/dist/modules/version-control/version-control.service.js +4 -13
  30. package/dist/modules/version-control/version-control.service.js.map +1 -1
  31. package/dist/shared/fire-and-forget.d.ts +14 -0
  32. package/dist/shared/fire-and-forget.d.ts.map +1 -0
  33. package/dist/shared/fire-and-forget.js +23 -0
  34. package/dist/shared/fire-and-forget.js.map +1 -0
  35. package/package.json +3 -3
  36. package/src/__tests__/capturing-logger.ts +36 -0
  37. package/src/__tests__/real-access.ts +16 -0
  38. package/src/__tests__/real-auth.ts +13 -0
  39. package/src/__tests__/real-readers.ts +27 -0
  40. package/src/__tests__/real-version-control.ts +22 -0
  41. package/src/core/__tests__/commit-announcements-wired-once.test.ts +53 -0
  42. package/src/core/create-core-services.ts +4 -2
  43. package/src/modules/access/__tests__/access-requests.routes.test.ts +3 -3
  44. package/src/modules/access/__tests__/groups-admin.service.test.ts +20 -20
  45. package/src/modules/access/__tests__/roles-admin.service.test.ts +12 -12
  46. package/src/modules/access/__tests__/roles-capabilities.test.ts +11 -11
  47. package/src/modules/access/__tests__/roles.routes.test.ts +8 -8
  48. package/src/modules/access/__tests__/synced-groups-committer.test.ts +2 -2
  49. package/src/modules/access/__tests__/user-access-removal.service.test.ts +6 -6
  50. package/src/modules/database/__tests__/connection.pg.test.ts +55 -0
  51. package/src/modules/database/__tests__/connection.test.ts +30 -0
  52. package/src/modules/database/connection.ts +12 -0
  53. package/src/modules/embed/__tests__/embed.service.test.ts +6 -20
  54. package/src/modules/kb-fs/__tests__/file-change-notifier.test.ts +23 -4
  55. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +8 -49
  56. package/src/modules/kb-fs/__tests__/move-with-edits.test.ts +73 -10
  57. package/src/modules/kb-fs/file-change-notifier.ts +55 -32
  58. package/src/modules/kb-fs/locking-filesystem.ts +4 -18
  59. package/src/modules/onboarding/__tests__/starter-pack.service.test.ts +4 -4
  60. package/src/modules/onboarding/starter-pack.service.ts +2 -4
  61. package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +46 -21
  62. package/src/modules/plugins/__tests__/plugins.routes.test.ts +6 -6
  63. package/src/modules/version-control/__tests__/version-control.service.facade.test.ts +4 -4
  64. package/src/modules/version-control/__tests__/version-control.service.releaseLock.test.ts +43 -33
  65. package/src/modules/version-control/git/__tests__/git.service.commitLanded.test.ts +199 -0
  66. package/src/modules/version-control/git/__tests__/git.service.mergeChangeRequest.test.ts +79 -2
  67. package/src/modules/version-control/git/git.service.ts +55 -2
  68. package/src/modules/version-control/version-control-hooks.ts +41 -2
  69. package/src/modules/version-control/version-control.service.ts +4 -12
  70. package/src/shared/__tests__/fire-and-forget.test.ts +39 -0
  71. package/src/shared/fire-and-forget.ts +26 -0
@@ -0,0 +1,199 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import type { FilesChange } from '@bevel-software/platform-shared';
6
+ import { testKbContext } from '../../../../__tests__/kb-context.js';
7
+ import type { IGitRunner } from '../../../../shared/git.contract.js';
8
+ import { VersionControlHooks } from '../../version-control-hooks.js';
9
+ import { GitService } from '../git.service.js';
10
+ import { NodeGitRunner } from '../node-git-runner.js';
11
+ import { gitOut, runGit, stubWorkspaceService } from './git-test-helpers.js';
12
+
13
+ /**
14
+ * Every commit the git service lands is reported once through
15
+ * `VersionControlHooks.onCommitLanded` — the one place a commit is announced
16
+ * from, so no committer can be forgotten. Each way of committing is driven
17
+ * here on a real repository: the one-file `commit`, `commitFile`, a scoped
18
+ * `commitChanges`, a `commitChanges` that finds its scope clean, and a merge.
19
+ * Paths arrive workspace-relative (the notifier sorts them), as the person.
20
+ */
21
+ const KB = 'knowledge-base';
22
+ const WS = 'ws-landed';
23
+ const USER = { id: 'u-alice', name: 'Alice', email: 'alice@bevel.software' };
24
+
25
+ describe('GitService reports every commit it lands through onCommitLanded', () => {
26
+ let root: string;
27
+ let repo: string;
28
+ let git: GitService;
29
+ let landed: FilesChange[];
30
+
31
+ const write = async (rel: string, text: string) => {
32
+ const abs = path.join(repo, rel);
33
+ await fs.mkdir(path.dirname(abs), { recursive: true });
34
+ await fs.writeFile(abs, text);
35
+ };
36
+ /** As the notifier will hand it on: paths sorted. */
37
+ const heard = () => landed.map((c) => ({ ...c, paths: [...c.paths].sort() }));
38
+
39
+ beforeEach(async () => {
40
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-landed-'));
41
+ const workspaceDir = path.join(root, WS);
42
+ repo = path.join(workspaceDir, KB);
43
+ await fs.mkdir(repo, { recursive: true });
44
+ await runGit(repo, ['init', '-b', 'main']);
45
+ await runGit(repo, ['config', 'user.email', 'test@bevel.local']);
46
+ await runGit(repo, ['config', 'user.name', 'Test Runner']);
47
+ await write('Old.md', 'old\n');
48
+ await runGit(repo, ['add', '-A']);
49
+ await runGit(repo, ['commit', '-m', 'seed']);
50
+
51
+ const hooks = new VersionControlHooks();
52
+ hooks.onCommitValidation(vi.fn(async () => ({ ok: true, mustFix: [], warnings: [], rawOutput: '' })));
53
+ landed = [];
54
+ hooks.onCommitLanded((change) => landed.push(change));
55
+ git = new GitService(stubWorkspaceService({ [WS]: workspaceDir }), hooks, testKbContext({ kbDirName: KB }));
56
+ });
57
+
58
+ afterEach(async () => {
59
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
60
+ });
61
+
62
+ it('commit: the one file the change touched', async () => {
63
+ await write('A.md', 'a\n');
64
+ await git.commit(WS, USER, { summary: 'add A' });
65
+ expect(heard()).toEqual([{ workspaceId: WS, branch: 'main', paths: [`${KB}/A.md`], byUser: USER }]);
66
+ });
67
+
68
+ it('commit: every file of a new folder, and both ends of a rename — the commit, not the status listing, names them', async () => {
69
+ await write('Folder/one.md', '1\n');
70
+ await write('Folder/two.md', '2\n');
71
+ await git.commit(WS, USER, { summary: 'add a folder' });
72
+ expect(heard().map((c) => c.paths)).toEqual([[`${KB}/Folder/one.md`, `${KB}/Folder/two.md`]]);
73
+
74
+ landed.length = 0;
75
+ await runGit(repo, ['mv', 'Old.md', 'New.md']);
76
+ await git.commit(WS, USER, { summary: 'rename' });
77
+ expect(heard().map((c) => c.paths)).toEqual([[`${KB}/New.md`, `${KB}/Old.md`]]);
78
+ });
79
+
80
+ it('commitChanges without a scope: both ends of a rename', async () => {
81
+ await runGit(repo, ['mv', 'Old.md', 'Moved.md']);
82
+ await write('K.md', 'k\n');
83
+ await git.commitChanges(WS, USER, 'move and add');
84
+ expect(heard().map((c) => c.paths)).toEqual([[`${KB}/K.md`, `${KB}/Moved.md`, `${KB}/Old.md`]]);
85
+ });
86
+
87
+ it('commitFile: the file named, once — a non-ASCII name verbatim', async () => {
88
+ await write('B.md', 'b\n');
89
+ await git.commitFile(WS, USER, `${KB}/B.md`);
90
+ await write('Ünïcode ñ.md', 'ü\n');
91
+ await git.commitFile(WS, USER, `${KB}/Ünïcode ñ.md`);
92
+ expect(heard().map((c) => c.paths)).toEqual([[`${KB}/B.md`], [`${KB}/Ünïcode ñ.md`]]);
93
+ });
94
+
95
+ it('a commit is reported with the paths known before it when the read of its files fails', async () => {
96
+ const real = new NodeGitRunner();
97
+ const diffTreeFails: IGitRunner = {
98
+ defaultTimeoutMs: real.defaultTimeoutMs,
99
+ credentials: real.credentials,
100
+ run: ((cwd: string, args: string[], opts?: unknown) =>
101
+ args[0] === 'diff-tree' ? Promise.reject(new Error('diff-tree unavailable')) : real.run(cwd, args, opts as never)) as IGitRunner['run'],
102
+ };
103
+ const hooks = new VersionControlHooks();
104
+ hooks.onCommitValidation(vi.fn(async () => ({ ok: true, mustFix: [], warnings: [], rawOutput: '' })));
105
+ const heardHere: FilesChange[] = [];
106
+ hooks.onCommitLanded((change) => heardHere.push(change));
107
+ const fragile = new GitService(stubWorkspaceService({ [WS]: path.join(root, WS) }), hooks, testKbContext({ kbDirName: KB }), undefined, null, diffTreeFails);
108
+
109
+ await write('L.md', 'l\n');
110
+ await fragile.commit(WS, USER, { summary: 'add L' });
111
+ await write('M.md', 'm\n');
112
+ await write('N.md', 'n\n');
113
+ await fragile.commitChanges(WS, USER, 'add M and N', [`${KB}/M.md`, `${KB}/N.md`]);
114
+ await write('O.md', 'o\n');
115
+ await fragile.commitFile(WS, USER, `${KB}/O.md`);
116
+
117
+ // The status listing stands in: the files are still named, the report still made.
118
+ expect(heardHere.map((c) => [...c.paths].sort())).toEqual([[`${KB}/L.md`], [`${KB}/M.md`, `${KB}/N.md`], [`${KB}/O.md`]]);
119
+ });
120
+
121
+ it('commitChanges: exactly the scoped dirty set — a dirty path outside the scope stays out', async () => {
122
+ await write('C.md', 'c\n');
123
+ await write('D.md', 'd\n');
124
+ await write('E.md', 'e\n');
125
+ await git.commitChanges(WS, USER, 'batch', [`${KB}/C.md`, `${KB}/D.md`]);
126
+ expect(heard()).toEqual([{ workspaceId: WS, branch: 'main', paths: [`${KB}/C.md`, `${KB}/D.md`], byUser: USER }]);
127
+ // E is still the caller's uncommitted business.
128
+ expect(await gitOut(repo, ['status', '--porcelain'])).toContain('E.md');
129
+ });
130
+
131
+ it('commitChanges on a clean scope lands nothing and reports nothing', async () => {
132
+ expect(await git.commitChanges(WS, USER, 'nothing', [`${KB}/Old.md`])).toBeNull();
133
+ expect(landed).toEqual([]);
134
+ });
135
+
136
+ it('a merge: every path the merge brought onto the branch, as the person who merged', async () => {
137
+ await runGit(repo, ['checkout', '-b', 'topic']);
138
+ await write('F.md', 'f\n');
139
+ await write('Old.md', 'old, edited on topic\n');
140
+ await runGit(repo, ['add', '-A']);
141
+ await runGit(repo, ['commit', '-m', 'topic work']);
142
+ await runGit(repo, ['checkout', 'main']);
143
+ // Main moves too, so the merge is a real merge commit, not a fast-forward.
144
+ await write('G.md', 'g\n');
145
+ await runGit(repo, ['add', '-A']);
146
+ await runGit(repo, ['commit', '-m', 'main work']);
147
+ // The merge reads the other side as `origin/<branch>`: this repository is its own remote.
148
+ await runGit(repo, ['remote', 'add', 'origin', repo]);
149
+
150
+ const outcome = await git.mergeIntoBranch(WS, 'main', 'topic', USER, { subject: 'Merge topic', body: '' });
151
+ expect(outcome.kind).toBe('merged');
152
+ expect(heard()).toEqual([{ workspaceId: WS, branch: 'main', paths: [`${KB}/F.md`, `${KB}/Old.md`], byUser: USER }]);
153
+ });
154
+
155
+ it('a commit is reported even when the attribution read after it fails — the commit exists, so the reactors hear of it', async () => {
156
+ // A runner like the real one, except that `git log` fails: what a disk or
157
+ // process fault between the commit and its attribution read looks like.
158
+ const real = new NodeGitRunner();
159
+ const logFails: IGitRunner = {
160
+ defaultTimeoutMs: real.defaultTimeoutMs,
161
+ credentials: real.credentials,
162
+ run: ((cwd: string, args: string[], opts?: unknown) =>
163
+ args[0] === 'log' ? Promise.reject(new Error('git log unavailable')) : real.run(cwd, args, opts as never)) as IGitRunner['run'],
164
+ };
165
+ const hooks = new VersionControlHooks();
166
+ hooks.onCommitValidation(vi.fn(async () => ({ ok: true, mustFix: [], warnings: [], rawOutput: '' })));
167
+ const heardHere: FilesChange[] = [];
168
+ hooks.onCommitLanded((change) => heardHere.push(change));
169
+ const fragile = new GitService(stubWorkspaceService({ [WS]: path.join(root, WS) }), hooks, testKbContext({ kbDirName: KB }), undefined, null, logFails);
170
+ const before = Number(await gitOut(repo, ['rev-list', '--count', 'HEAD']));
171
+
172
+ await write('I.md', 'i\n');
173
+ await expect(fragile.commitFile(WS, USER, `${KB}/I.md`)).rejects.toThrow('git log unavailable');
174
+ await write('J.md', 'j\n');
175
+ await expect(fragile.commit(WS, USER, { summary: 'add J' })).rejects.toThrow('git log unavailable');
176
+
177
+ // Both commits landed, and both were reported before the read that failed.
178
+ expect(Number(await gitOut(repo, ['rev-list', '--count', 'HEAD']))).toBe(before + 2);
179
+ expect(heardHere.map((c) => c.paths)).toEqual([[`${KB}/I.md`], [`${KB}/J.md`]]);
180
+ });
181
+
182
+ it('a hook that throws, or whose promise rejects, is isolated: the commit stands and the other hooks still hear of it', async () => {
183
+ const hooks = new VersionControlHooks();
184
+ hooks.onCommitValidation(vi.fn(async () => ({ ok: true, mustFix: [], warnings: [], rawOutput: '' })));
185
+ const others: FilesChange[] = [];
186
+ hooks.onCommitLanded(() => {
187
+ throw new Error('reactor exploded');
188
+ });
189
+ hooks.onCommitLanded(async () => Promise.reject(new Error('reactor exploded later')));
190
+ hooks.onCommitLanded((change) => others.push(change));
191
+ const loud = new GitService(stubWorkspaceService({ [WS]: path.join(root, WS) }), hooks, testKbContext({ kbDirName: KB }));
192
+
193
+ await write('H.md', 'h\n');
194
+ const change = await loud.commitFile(WS, USER, `${KB}/H.md`);
195
+ expect(change?.sha).toMatch(/^[0-9a-f]{40}$/);
196
+ expect(await gitOut(repo, ['status', '--porcelain'])).toBe('');
197
+ expect(others.map((c) => c.paths)).toEqual([[`${KB}/H.md`]]);
198
+ });
199
+ });
@@ -5,10 +5,12 @@ import { promisify } from 'node:util';
5
5
  import fs from 'node:fs/promises';
6
6
  import path from 'node:path';
7
7
  import os from 'node:os';
8
- import type { AuthUser } from '@bevel-software/platform-shared';
9
- import type { WorkspaceService } from '../../../workspace/workspace.service.js';
8
+ import type { AuthUser, FilesChange } from '@bevel-software/platform-shared';
9
+ import type { WorkspaceService } from '../../../workspace/index.js';
10
+ import type { IGitRunner } from '../../../../shared/git.contract.js';
10
11
  import { VersionControlHooks } from '../../version-control-hooks.js';
11
12
  import { GitService } from '../git.service.js';
13
+ import { NodeGitRunner } from '../node-git-runner.js';
12
14
  import { mergeCommitSubject } from '../merge-commit.js';
13
15
 
14
16
  const execFileAsync = promisify(execFile);
@@ -119,6 +121,81 @@ describe('GitService.mergeChangeRequest', () => {
119
121
  expect(log).toContain('Add feature (#1)');
120
122
  });
121
123
 
124
+ describe('reports the merge it lands through onCommitLanded, once, with every path it brought in', () => {
125
+ const KB = 'knowledge-base';
126
+ const sorted = (changes: FilesChange[]) => changes.map((c) => ({ ...c, paths: [...c.paths].sort() }));
127
+ function listening(baseWsId: string, baseRepo: string, runner?: IGitRunner) {
128
+ const hooks = new VersionControlHooks();
129
+ const landed: FilesChange[] = [];
130
+ hooks.onCommitLanded((change) => landed.push(change));
131
+ const git = new GitService(stubWorkspaceService(baseWsId, baseRepo), hooks, testKbContext(), undefined, null, runner);
132
+ return { git, landed };
133
+ }
134
+
135
+ it('names both ends of a rename and a non-ASCII name verbatim, after the push landed', async () => {
136
+ const { upstream, baseWsId, baseRepo } = await seed(root, { 'base.md': 'base\n', 'keep.md': 'keep\n' });
137
+ await pushFeatureBranch(root, upstream, 'alice/rename', async (dir) => {
138
+ await runGit(dir, ['mv', 'base.md', 'renamed.md']);
139
+ await fs.writeFile(path.join(dir, 'Ünïcode ñ.md'), 'ü\n');
140
+ });
141
+ const { git, landed } = listening(baseWsId, baseRepo);
142
+
143
+ const result = await git.mergeChangeRequest(baseWsId, 'alice/rename', BASE, { subject: 'Rename (#2)', body: '' }, USER);
144
+
145
+ expect(result.kind).toBe('merged');
146
+ expect(sorted(landed)).toEqual([
147
+ { workspaceId: baseWsId, branch: BASE, byUser: USER, paths: [`${KB}/base.md`, `${KB}/renamed.md`, `${KB}/Ünïcode ñ.md`] },
148
+ ]);
149
+ });
150
+
151
+ it('reports nothing when there was nothing to merge', async () => {
152
+ const { upstream, baseWsId, baseRepo } = await seed(root, { 'base.md': 'base\n' });
153
+ await pushFeatureBranch(root, upstream, 'alice/add', async (dir) => {
154
+ await fs.writeFile(path.join(dir, 'feature.md'), 'new content\n');
155
+ });
156
+ const { git, landed } = listening(baseWsId, baseRepo);
157
+ await git.mergeChangeRequest(baseWsId, 'alice/add', BASE, { subject: 'Add (#3)', body: '' }, USER);
158
+ expect(landed).toHaveLength(1);
159
+
160
+ // Again: the target already holds the source; no commit, no report.
161
+ const again = await git.mergeChangeRequest(baseWsId, 'alice/add', BASE, { subject: 'Add (#3)', body: '' }, USER);
162
+ expect(again.kind).toBe('merged');
163
+ expect(landed).toHaveLength(1);
164
+ });
165
+
166
+ it('reports once when the first push is rejected and the retry lands — the attempt that was reset away is not announced', async () => {
167
+ const { upstream, seedDir, baseWsId, baseRepo } = await seed(root, { 'base.md': 'base\n' });
168
+ await pushFeatureBranch(root, upstream, 'alice/add', async (dir) => {
169
+ await fs.writeFile(path.join(dir, 'feature.md'), 'new content\n');
170
+ });
171
+ // A runner like the real one, except that just before the first push a
172
+ // teammate's commit lands on the target, so that push is rejected and
173
+ // the merge is redone on the new tip.
174
+ const real = new NodeGitRunner();
175
+ let pushes = 0;
176
+ const racing: IGitRunner = {
177
+ defaultTimeoutMs: real.defaultTimeoutMs,
178
+ credentials: real.credentials,
179
+ run: (async (cwd: string, args: string[], opts?: unknown) => {
180
+ if (args[0] === 'push' && pushes++ === 0) {
181
+ await fs.writeFile(path.join(seedDir, 'teammate.md'), 'meanwhile\n');
182
+ await runGit(seedDir, ['add', '-A']);
183
+ await runGit(seedDir, ['commit', '-m', 'teammate']);
184
+ await runGit(seedDir, ['push', 'origin', BASE]);
185
+ }
186
+ return real.run(cwd, args, opts as never);
187
+ }) as IGitRunner['run'],
188
+ };
189
+ const { git, landed } = listening(baseWsId, baseRepo, racing);
190
+
191
+ const result = await git.mergeChangeRequest(baseWsId, 'alice/add', BASE, { subject: 'Add (#4)', body: '' }, USER);
192
+
193
+ expect(result.kind).toBe('merged');
194
+ expect(pushes).toBe(2);
195
+ expect(sorted(landed)).toEqual([{ workspaceId: baseWsId, branch: BASE, byUser: USER, paths: [`${KB}/feature.md`] }]);
196
+ });
197
+ });
198
+
122
199
  // cubic P1 on #347. When the target already contains the source there is
123
200
  // nothing to merge, so no commit is written and `sha` is the TARGET TIP —
124
201
  // whatever landed on the target last, which in a deployment that lands
@@ -344,6 +344,36 @@ export class GitService implements IGitService {
344
344
  return this.kb.kbDirName;
345
345
  }
346
346
 
347
+ /**
348
+ * Every commit this service lands is reported here, once, to the hooks the
349
+ * composition root registered (`VersionControlHooks.onCommitLanded`): a
350
+ * single-file commit, a batch, a merge. Paths go out workspace-relative, as
351
+ * the notifier's contract has them. The hooks run isolated, so a reactor can
352
+ * neither fail nor unwind the commit it hears of. This is the ONE place a
353
+ * commit is announced from — a new way of committing reports here or it is
354
+ * not announced at all, which a test pins.
355
+ */
356
+ private landed(workspaceId: string, branch: string, user: AuthUser, repoRelativePaths: readonly string[]): void {
357
+ this.hooks.commitLanded({
358
+ workspaceId,
359
+ branch,
360
+ paths: repoRelativePaths.map((p) => `${this.kbDirName}/${p}`),
361
+ byUser: user,
362
+ });
363
+ }
364
+
365
+ /**
366
+ * The files the commit at HEAD changed, repo-relative, read from the commit
367
+ * itself rather than from the status listing that preceded it: every file
368
+ * of a new folder (status shows the folder), both ends of a rename (status
369
+ * shows one entry). A read that fails must not lose the report, so callers
370
+ * fall back to what they knew before the commit.
371
+ */
372
+ private async committedPaths(cwd: string): Promise<string[]> {
373
+ const { stdout } = await this.git(cwd, ['diff-tree', '--root', '--no-commit-id', '--name-only', '--no-renames', '-r', '-z', 'HEAD']);
374
+ return stdout.split('\0').filter(Boolean);
375
+ }
376
+
347
377
  /**
348
378
  * The credentials the runner authenticates with — for the services above
349
379
  * this one that scrub a message git or the host echoed a token into.
@@ -1082,6 +1112,10 @@ export class GitService implements IGitService {
1082
1112
  args.push('-m', req.description.trim());
1083
1113
  }
1084
1114
  await this.git(cwd, args);
1115
+ // The commit exists from here on: reported now, before the attribution
1116
+ // read, which can fail without unmaking it — with the files the commit
1117
+ // holds, or the status listing if even that read fails.
1118
+ this.landed(workspaceId, branch, user, await this.committedPaths(cwd).catch(() => touched));
1085
1119
 
1086
1120
  const { stdout } = await this.git(cwd, [
1087
1121
  'log',
@@ -1246,6 +1280,10 @@ export class GitService implements IGitService {
1246
1280
  `--author=${user.name} <${user.email}>`,
1247
1281
  '-m', subject,
1248
1282
  ]);
1283
+ // The commit exists from here on: reported now, before the attribution
1284
+ // read, which can fail without unmaking it — with the files the commit
1285
+ // holds (the one path, as it was staged), or that path if the read fails.
1286
+ this.landed(workspaceId, branch, user, await this.committedPaths(cwd).catch(() => [repoRelativePath]));
1249
1287
 
1250
1288
  const { stdout } = await this.git(cwd, [
1251
1289
  'log',
@@ -1383,6 +1421,10 @@ export class GitService implements IGitService {
1383
1421
  // committed" and restore their pre-edit bytes, which here would publish
1384
1422
  // a compensating revert of a commit that DID land. The attribution read
1385
1423
  // is best-effort; on failure fall back to what we already know.
1424
+ // The commit exists: report it, with the files it holds — both ends of a
1425
+ // rename, every file of a new folder — or the scoped status listing if
1426
+ // that read fails. Neither the read nor the hooks can throw here.
1427
+ this.landed(workspaceId, branch, user, await this.committedPaths(cwd).catch(() => touched));
1386
1428
  let sha = '';
1387
1429
  let authorName = user.name;
1388
1430
  let authorEmail = user.email;
@@ -1811,8 +1853,13 @@ export class GitService implements IGitService {
1811
1853
  opts: { subject: string; body: string; publish?: (ops: ReservedPushOps) => Promise<void> },
1812
1854
  resolvedPaths: string[],
1813
1855
  ): Promise<MergeIntoBranchOutcome> {
1856
+ // What this merge brings onto the branch: the index holds the merged tree.
1857
+ // NUL-delimited and with renames as a delete plus an add, so every path is
1858
+ // named verbatim, both ends of a rename included.
1859
+ const { stdout: merged } = await this.git(cwd, ['diff', '--cached', '--name-only', '--no-renames', '-z']);
1814
1860
  await this.git(cwd, ['commit', `--author=${user.name} <${user.email}>`, '-m', opts.subject, '-m', opts.body]);
1815
1861
  this.accessControl?.invalidate(workspaceId);
1862
+ this.landed(workspaceId, branch, user, merged.split('\0').filter(Boolean));
1816
1863
  // The publish runs here, inside the reservation the merge was made in,
1817
1864
  // over this checkout's own push and pull-rebase (the merge kept as a
1818
1865
  // merge) — the caller's policy, the git layer's moves. Whatever it
@@ -2124,8 +2171,10 @@ export class GitService implements IGitService {
2124
2171
  // finalize the row (a transient database fault between the push and the
2125
2172
  // update) has one, and it is the only place its file list survives — so
2126
2173
  // it is looked for rather than assumed absent (cubic P2 on #347).
2127
- const { stdout: staged } = await this.git(cwd, ['diff', '--cached', '--name-only']);
2128
- if (staged.trim() === '') {
2174
+ // NUL-delimited and with renames as a delete plus an add, so every path
2175
+ // the merge touches is named verbatim, both ends of a rename included.
2176
+ const { stdout: staged } = await this.git(cwd, ['diff', '--cached', '--name-only', '--no-renames', '-z']);
2177
+ if (staged === '') {
2129
2178
  const { stdout: sha } = await this.git(cwd, ['rev-parse', 'HEAD']);
2130
2179
  await this.git(cwd, ['merge', '--abort']).catch(() => undefined);
2131
2180
  const own =
@@ -2145,6 +2194,10 @@ export class GitService implements IGitService {
2145
2194
 
2146
2195
  try {
2147
2196
  await this.git(cwd, ['push', 'origin', `HEAD:refs/heads/${targetBranch}`]);
2197
+ // This merge commit is provisional until its push lands: a rejected
2198
+ // push resets the checkout and the next attempt commits anew. So it
2199
+ // is reported here, once, for the attempt that landed on the target.
2200
+ this.landed(baseWorkspaceId, targetBranch, user, staged.split('\0').filter(Boolean));
2148
2201
  this.accessControl?.invalidate(baseWorkspaceId);
2149
2202
  // A commit of this request's own, with the subject the reader verifies.
2150
2203
  return { kind: 'merged' as const, sha: sha.trim(), mergeCommit: sha.trim() };
@@ -1,4 +1,8 @@
1
- import type { AuthUser, ValidationReport } from '@bevel-software/platform-shared';
1
+ import type { AuthUser, FilesChange, ValidationReport } from '@bevel-software/platform-shared';
2
+ import { callFireAndForget, type FireAndForgetListener } from '../../shared/fire-and-forget.js';
3
+ import { logger } from '../../shared/logging.js';
4
+
5
+ const log = logger('version-control');
2
6
 
3
7
  /**
4
8
  * Version-control lifecycle hooks — the generic seam that replaced the
@@ -7,7 +11,17 @@ import type { AuthUser, ValidationReport } from '@bevel-software/platform-shared
7
11
  * composition root, right after `createCoreServices` returns. Core registers
8
12
  * none, so a core-only deployment runs every hook point as a no-op.
9
13
  *
10
- * Three hook kinds, with deliberately different failure semantics:
14
+ * Four hook kinds, with deliberately different failure semantics:
15
+ *
16
+ * - `commitLanded` — INFORMATIONAL. Runs in `GitService` when a commit it
17
+ * made is final: right after `git commit` for a save, an autosave
18
+ * checkpoint, a batch, a branch merge and a `commit_change`; after the
19
+ * push lands for a change-request merge, whose commit is redone on the new
20
+ * tip if the push is rejected because the target advanced (any other
21
+ * refusal throws, and nothing is reported). Carries the commit's
22
+ * workspace-relative paths and the person. The one place a commit is reported from; the composition root
23
+ * hands it to the file-change notifier. A hook that throws, or whose
24
+ * promise rejects, is logged and can neither fail nor unwind the commit.
11
25
  *
12
26
  * - `commitValidation` — ADVISORY. Runs at commit time in `GitService`
13
27
  * (`commit` / `commitFile`), exactly where the injected `IKbValidator`
@@ -82,6 +96,15 @@ export type AgentReadHook = (ctx: AgentOperationContext) => Promise<void>;
82
96
  /** Blocking pre-write hook: throw to refuse the write; return to allow. */
83
97
  export type PreWriteHook = (ctx: AgentOperationContext) => Promise<void>;
84
98
 
99
+ /**
100
+ * Told of every commit the git service lands — a single-file save, an autosave
101
+ * checkpoint, a batch, a merge, a `commit_change` — when it lands: pushed, or
102
+ * awaiting its push. The one place a commit is reported from, so no committer
103
+ * can be forgotten; the composition root hands it to the file-change notifier.
104
+ * Fire-and-forget: a hook may answer a promise, which nobody awaits.
105
+ */
106
+ export type CommitLandedHook = FireAndForgetListener<FilesChange>;
107
+
85
108
  /**
86
109
  * The registry instance. ONE per composition (created in
87
110
  * `createCoreServices`, shared by `GitService` and exposed as
@@ -91,6 +114,7 @@ export class VersionControlHooks {
91
114
  private readonly commitValidation: CommitValidationHook[] = [];
92
115
  private readonly agentRead: AgentReadHook[] = [];
93
116
  private readonly preWrite: PreWriteHook[] = [];
117
+ private readonly commitLandedHooks: CommitLandedHook[] = [];
94
118
 
95
119
  /** Register an advisory commit-time validation hook. */
96
120
  onCommitValidation(hook: CommitValidationHook): void {
@@ -107,6 +131,21 @@ export class VersionControlHooks {
107
131
  this.preWrite.push(hook);
108
132
  }
109
133
 
134
+ onCommitLanded(hook: CommitLandedHook): void {
135
+ this.commitLandedHooks.push(hook);
136
+ }
137
+
138
+ /**
139
+ * Report a landed commit to every hook, each isolated: a reactor that throws,
140
+ * or whose promise rejects, is logged and cannot fail — or unwind — the
141
+ * commit it was told of.
142
+ */
143
+ commitLanded(change: FilesChange): void {
144
+ for (const hook of this.commitLandedHooks) {
145
+ callFireAndForget(hook, change, log, 'a commit-landed hook failed:');
146
+ }
147
+ }
148
+
110
149
  /**
111
150
  * The registered commit-validation hooks, for the caller (`GitService`) to
112
151
  * iterate with its own advisory catch-and-log semantics.
@@ -83,7 +83,6 @@ import { canonicalFileIdentity } from '../../shared/canonical-file-identity.js';
83
83
  import type { PendingCommitsService } from './pending-commits.service.js';
84
84
  import type { VersionControlEventBus } from './event-bus.js';
85
85
  import { describeSyncFailure, sanitizeError } from './sanitize-error.js';
86
- import type { FileChangeNotifier } from '../kb-fs/index.js';
87
86
  import { VersionControlHooks } from './version-control-hooks.js';
88
87
  import {
89
88
  ChangeRequestHooks,
@@ -304,13 +303,6 @@ export class VersionControlService implements IVersionControlService {
304
303
  * boot the composition root always wires one.
305
304
  */
306
305
  private readonly events?: VersionControlEventBus,
307
- /**
308
- * In-process post-commit hook (distinct from the SSE `events` bus): fires
309
- * after a file's change is committed + pushed so backend services (catalog
310
- * invalidation, id-repair) react without the per-file TTL wait. Optional,
311
- * like `events`.
312
- */
313
- private readonly fileChanges?: FileChangeNotifier,
314
306
  /**
315
307
  * Version-control lifecycle-hook registry (see `version-control-hooks.ts`). PUBLIC so
316
308
  * the composition root registers module-owned behavior against it
@@ -1777,11 +1769,14 @@ export class VersionControlService implements IVersionControlService {
1777
1769
  // which is exactly how a diverged workspace ends up stuck forever.
1778
1770
  // So: if the branch is ahead of its remote-tracking ref, push (with
1779
1771
  // the same cooperative recovery, so a conflicting divergence throws
1780
- // and the worker's ladder takes over). Nothing to emit either way —
1772
+ // and the worker's ladder takes over). No `file-changed` either way —
1781
1773
  // there's no new sha.
1782
1774
  if (await this.git.hasUnpushedCommits(workspaceId)) {
1783
1775
  await this.pushWithRecovery(workspaceId, branch, targetPath, user, opts);
1784
1776
  }
1777
+ // Nothing landed here, so nothing is announced: the git layer reports
1778
+ // each commit as it lands (an autosave checkpoint, a batch whose push
1779
+ // retry this row is), and this row made none.
1785
1780
  return;
1786
1781
  }
1787
1782
  await this.pushWithRecovery(workspaceId, branch, targetPath, user, opts);
@@ -1794,9 +1789,6 @@ export class VersionControlService implements IVersionControlService {
1794
1789
  byUserId: user.id,
1795
1790
  byUserName: user.name,
1796
1791
  });
1797
- // Post-commit, post-lock-release: the one chokepoint every single-file write
1798
- // converges on. Backend reactors (catalog invalidation, id-repair) hook here.
1799
- this.fileChanges?.emit({ workspaceId, branch, paths: [targetPath], byUser: user });
1800
1792
  }
1801
1793
 
1802
1794
  /**
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { capturingLogger } from '../../__tests__/capturing-logger.js';
3
+ import { callFireAndForget } from '../fire-and-forget.js';
4
+
5
+ const settled = () => new Promise<void>((resolve) => setImmediate(resolve));
6
+ const FAILED = 'a listener failed:';
7
+
8
+ describe('callFireAndForget — nothing a listener does reaches the caller', () => {
9
+ it('a listener that throws is logged as failed, and the call returns', () => {
10
+ const { logger, lines } = capturingLogger();
11
+ expect(() =>
12
+ callFireAndForget(
13
+ () => {
14
+ throw new Error('reactor exploded');
15
+ },
16
+ 'event',
17
+ logger,
18
+ FAILED,
19
+ ),
20
+ ).not.toThrow();
21
+ expect(lines).toEqual([{ level: 'error', message: FAILED, fields: { err: expect.objectContaining({ message: 'reactor exploded' }) } }]);
22
+ });
23
+
24
+ it('a listener whose promise rejects is logged as failed — a rejection the try/catch alone would miss', async () => {
25
+ const { logger, lines } = capturingLogger();
26
+ callFireAndForget(async () => Promise.reject(new Error('reactor exploded later')), 'event', logger, FAILED);
27
+ expect(lines).toEqual([]);
28
+ await settled();
29
+ expect(lines).toEqual([{ level: 'error', message: FAILED, fields: { err: expect.objectContaining({ message: 'reactor exploded later' }) } }]);
30
+ });
31
+
32
+ it('a listener that returns, or whose promise resolves, logs nothing', async () => {
33
+ const { logger, lines } = capturingLogger();
34
+ callFireAndForget(() => undefined, 'event', logger, FAILED);
35
+ callFireAndForget(async () => undefined, 'event', logger, FAILED);
36
+ await settled();
37
+ expect(lines).toEqual([]);
38
+ });
39
+ });
@@ -0,0 +1,26 @@
1
+ import type { ILogger } from './logger.contract.js';
2
+
3
+ /** A listener told of something after the fact: it may answer a promise, which nobody awaits. */
4
+ export type FireAndForgetListener<E> = (event: E) => void | Promise<void>;
5
+
6
+ /**
7
+ * Call a fire-and-forget listener so that nothing it does reaches the caller:
8
+ * a throw is caught, a returned promise's rejection is caught, and either is
9
+ * logged as `failed`. The caller's own effect — the commit that landed, the
10
+ * change that was announced — stands whatever the listener does. The one way
11
+ * core dispatches such listeners (the commit-landed hooks, the file-change
12
+ * notifier), so the rule is written once; the awaited, channel-answering kind
13
+ * is `ListenerSeam`.
14
+ */
15
+ export function callFireAndForget<E>(listener: FireAndForgetListener<E>, event: E, log: ILogger, failed: string): void {
16
+ try {
17
+ const answer: unknown = listener(event);
18
+ if (isPromiseLike(answer)) answer.then(undefined, (err: unknown) => log.error(failed, { err }));
19
+ } catch (err) {
20
+ log.error(failed, { err });
21
+ }
22
+ }
23
+
24
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
25
+ return typeof value === 'object' && value !== null && typeof (value as PromiseLike<unknown>).then === 'function';
26
+ }