@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.
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +5 -2
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/database/connection.d.ts.map +1 -1
- package/dist/modules/database/connection.js +11 -0
- package/dist/modules/database/connection.js.map +1 -1
- package/dist/modules/kb-fs/file-change-notifier.d.ts +51 -26
- package/dist/modules/kb-fs/file-change-notifier.d.ts.map +1 -1
- package/dist/modules/kb-fs/file-change-notifier.js +6 -8
- package/dist/modules/kb-fs/file-change-notifier.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +0 -7
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +4 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/onboarding/starter-pack.service.d.ts +1 -3
- package/dist/modules/onboarding/starter-pack.service.d.ts.map +1 -1
- package/dist/modules/onboarding/starter-pack.service.js +1 -1
- package/dist/modules/onboarding/starter-pack.service.js.map +1 -1
- package/dist/modules/version-control/git/git.service.d.ts +18 -0
- package/dist/modules/version-control/git/git.service.d.ts.map +1 -1
- package/dist/modules/version-control/git/git.service.js +53 -2
- package/dist/modules/version-control/git/git.service.js.map +1 -1
- package/dist/modules/version-control/version-control-hooks.d.ts +29 -2
- package/dist/modules/version-control/version-control-hooks.d.ts.map +1 -1
- package/dist/modules/version-control/version-control-hooks.js +17 -0
- package/dist/modules/version-control/version-control-hooks.js.map +1 -1
- package/dist/modules/version-control/version-control.service.d.ts +0 -15
- package/dist/modules/version-control/version-control.service.d.ts.map +1 -1
- package/dist/modules/version-control/version-control.service.js +4 -13
- package/dist/modules/version-control/version-control.service.js.map +1 -1
- package/dist/shared/fire-and-forget.d.ts +14 -0
- package/dist/shared/fire-and-forget.d.ts.map +1 -0
- package/dist/shared/fire-and-forget.js +23 -0
- package/dist/shared/fire-and-forget.js.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/capturing-logger.ts +36 -0
- package/src/__tests__/real-access.ts +16 -0
- package/src/__tests__/real-auth.ts +13 -0
- package/src/__tests__/real-readers.ts +27 -0
- package/src/__tests__/real-version-control.ts +22 -0
- package/src/core/__tests__/commit-announcements-wired-once.test.ts +53 -0
- package/src/core/create-core-services.ts +4 -2
- package/src/modules/access/__tests__/access-requests.routes.test.ts +3 -3
- package/src/modules/access/__tests__/groups-admin.service.test.ts +20 -20
- package/src/modules/access/__tests__/roles-admin.service.test.ts +12 -12
- package/src/modules/access/__tests__/roles-capabilities.test.ts +11 -11
- package/src/modules/access/__tests__/roles.routes.test.ts +8 -8
- package/src/modules/access/__tests__/synced-groups-committer.test.ts +2 -2
- package/src/modules/access/__tests__/user-access-removal.service.test.ts +6 -6
- package/src/modules/database/__tests__/connection.pg.test.ts +55 -0
- package/src/modules/database/__tests__/connection.test.ts +30 -0
- package/src/modules/database/connection.ts +12 -0
- package/src/modules/embed/__tests__/embed.service.test.ts +6 -20
- package/src/modules/kb-fs/__tests__/file-change-notifier.test.ts +23 -4
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +8 -49
- package/src/modules/kb-fs/__tests__/move-with-edits.test.ts +73 -10
- package/src/modules/kb-fs/file-change-notifier.ts +55 -32
- package/src/modules/kb-fs/locking-filesystem.ts +4 -18
- package/src/modules/onboarding/__tests__/starter-pack.service.test.ts +4 -4
- package/src/modules/onboarding/starter-pack.service.ts +2 -4
- package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +46 -21
- package/src/modules/plugins/__tests__/plugins.routes.test.ts +6 -6
- package/src/modules/version-control/__tests__/version-control.service.facade.test.ts +4 -4
- package/src/modules/version-control/__tests__/version-control.service.releaseLock.test.ts +43 -33
- package/src/modules/version-control/git/__tests__/git.service.commitLanded.test.ts +199 -0
- package/src/modules/version-control/git/__tests__/git.service.mergeChangeRequest.test.ts +79 -2
- package/src/modules/version-control/git/git.service.ts +55 -2
- package/src/modules/version-control/version-control-hooks.ts +41 -2
- package/src/modules/version-control/version-control.service.ts +4 -12
- package/src/shared/__tests__/fire-and-forget.test.ts +39 -0
- 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/
|
|
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
|
-
|
|
2128
|
-
|
|
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
|
-
*
|
|
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).
|
|
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
|
+
}
|