@bevel-software/platform-core-backend 0.30.0 → 0.31.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 (156) hide show
  1. package/agent-guide/access-control.md +49 -4
  2. package/agent-guide/html-views.md +76 -2
  3. package/dist/core/catalog-revision.js +3 -3
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +2 -1
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts.map +1 -1
  8. package/dist/core/create-core-services.js +4 -2
  9. package/dist/core/create-core-services.js.map +1 -1
  10. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  11. package/dist/modules/access/access-control.service.js +1 -5
  12. package/dist/modules/access/access-control.service.js.map +1 -1
  13. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  14. package/dist/modules/audit/agent-audit.service.js +66 -6
  15. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  16. package/dist/modules/audit/audit.contract.d.ts +52 -8
  17. package/dist/modules/audit/audit.contract.d.ts.map +1 -1
  18. package/dist/modules/audit/audit.contract.js +37 -1
  19. package/dist/modules/audit/audit.contract.js.map +1 -1
  20. package/dist/modules/audit/audit.routes.d.ts +1 -1
  21. package/dist/modules/audit/audit.routes.d.ts.map +1 -1
  22. package/dist/modules/audit/audit.routes.js +15 -13
  23. package/dist/modules/audit/audit.routes.js.map +1 -1
  24. package/dist/modules/audit/request-audit.d.ts +21 -10
  25. package/dist/modules/audit/request-audit.d.ts.map +1 -1
  26. package/dist/modules/audit/request-audit.js +26 -10
  27. package/dist/modules/audit/request-audit.js.map +1 -1
  28. package/dist/modules/database/core-schema.d.ts +22 -3
  29. package/dist/modules/database/core-schema.d.ts.map +1 -1
  30. package/dist/modules/database/core-schema.js +15 -6
  31. package/dist/modules/database/core-schema.js.map +1 -1
  32. package/dist/modules/kb-fs/index.d.ts +1 -0
  33. package/dist/modules/kb-fs/index.d.ts.map +1 -1
  34. package/dist/modules/kb-fs/index.js +1 -0
  35. package/dist/modules/kb-fs/index.js.map +1 -1
  36. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  37. package/dist/modules/kb-fs/locking-filesystem.js +3 -12
  38. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  39. package/dist/modules/kb-fs/mutex.d.ts +11 -0
  40. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  41. package/dist/modules/kb-fs/mutex.js +14 -0
  42. package/dist/modules/kb-fs/mutex.js.map +1 -1
  43. package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
  44. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  45. package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
  46. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  47. package/dist/modules/mcp/mcp.routes.d.ts +5 -0
  48. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  49. package/dist/modules/mcp/mcp.routes.js +67 -0
  50. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  51. package/dist/modules/mcp/mcp.service.d.ts +107 -27
  52. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  53. package/dist/modules/mcp/mcp.service.js +257 -50
  54. package/dist/modules/mcp/mcp.service.js.map +1 -1
  55. package/dist/modules/mcp/request-context.d.ts +25 -0
  56. package/dist/modules/mcp/request-context.d.ts.map +1 -0
  57. package/dist/modules/mcp/request-context.js +11 -0
  58. package/dist/modules/mcp/request-context.js.map +1 -0
  59. package/dist/modules/mcp/shared-definitions.d.ts +56 -0
  60. package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
  61. package/dist/modules/mcp/shared-definitions.js +117 -0
  62. package/dist/modules/mcp/shared-definitions.js.map +1 -0
  63. package/dist/modules/mcp/surface-cache.d.ts +70 -0
  64. package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
  65. package/dist/modules/mcp/surface-cache.js +104 -0
  66. package/dist/modules/mcp/surface-cache.js.map +1 -0
  67. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  68. package/dist/modules/tool-helpers/tool-context.js +33 -12
  69. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  70. package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
  71. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  72. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  73. package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
  74. package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
  75. package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
  76. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
  77. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  78. package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
  79. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  80. package/dist/modules/workflow/git/git.service.d.ts +113 -4
  81. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  82. package/dist/modules/workflow/git/git.service.js +470 -106
  83. package/dist/modules/workflow/git/git.service.js.map +1 -1
  84. package/dist/modules/workflow/read-on-branch.d.ts +35 -0
  85. package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
  86. package/dist/modules/workflow/read-on-branch.js +58 -0
  87. package/dist/modules/workflow/read-on-branch.js.map +1 -0
  88. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  89. package/dist/modules/workflow/workflow.routes.js +22 -41
  90. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  91. package/dist/modules/workflow/workflow.service.d.ts +48 -8
  92. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/workflow.service.js +163 -16
  94. package/dist/modules/workflow/workflow.service.js.map +1 -1
  95. package/dist/modules/workspace/index.d.ts +2 -0
  96. package/dist/modules/workspace/index.d.ts.map +1 -1
  97. package/dist/modules/workspace/index.js +4 -0
  98. package/dist/modules/workspace/index.js.map +1 -1
  99. package/dist/shared/git.contract.d.ts +7 -0
  100. package/dist/shared/git.contract.d.ts.map +1 -1
  101. package/dist/shared/git.contract.js +9 -0
  102. package/dist/shared/git.contract.js.map +1 -1
  103. package/dist/shared/no-link-below.d.ts +10 -0
  104. package/dist/shared/no-link-below.d.ts.map +1 -0
  105. package/dist/shared/no-link-below.js +30 -0
  106. package/dist/shared/no-link-below.js.map +1 -0
  107. package/migrations/0018_agent_events_page.sql +11 -0
  108. package/migrations/meta/0018_snapshot.json +2429 -0
  109. package/migrations/meta/_journal.json +8 -1
  110. package/package.json +3 -3
  111. package/src/core/catalog-revision.ts +3 -3
  112. package/src/core/create-core-server.ts +2 -1
  113. package/src/core/create-core-services.ts +4 -2
  114. package/src/modules/access/access-control.service.ts +1 -6
  115. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
  116. package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
  117. package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
  118. package/src/modules/audit/agent-audit.service.ts +66 -6
  119. package/src/modules/audit/audit.contract.ts +62 -10
  120. package/src/modules/audit/audit.routes.ts +14 -12
  121. package/src/modules/audit/request-audit.ts +28 -13
  122. package/src/modules/database/core-schema.ts +15 -6
  123. package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
  124. package/src/modules/kb-fs/index.ts +1 -0
  125. package/src/modules/kb-fs/locking-filesystem.ts +3 -12
  126. package/src/modules/kb-fs/mutex.ts +16 -0
  127. package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
  128. package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
  129. package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
  130. package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
  131. package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
  132. package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
  133. package/src/modules/mcp/mcp.routes.ts +68 -0
  134. package/src/modules/mcp/mcp.service.ts +315 -51
  135. package/src/modules/mcp/request-context.ts +34 -0
  136. package/src/modules/mcp/shared-definitions.ts +121 -0
  137. package/src/modules/mcp/surface-cache.ts +154 -0
  138. package/src/modules/tool-helpers/tool-context.ts +37 -18
  139. package/src/modules/tool-helpers/tool.contract.ts +8 -0
  140. package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
  141. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
  142. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
  143. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
  144. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
  145. package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
  146. package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
  147. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
  148. package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
  149. package/src/modules/workflow/git/git.service.ts +537 -106
  150. package/src/modules/workflow/read-on-branch.ts +79 -0
  151. package/src/modules/workflow/workflow.routes.ts +21 -39
  152. package/src/modules/workflow/workflow.service.ts +203 -16
  153. package/src/modules/workspace/index.ts +11 -0
  154. package/src/shared/__tests__/no-link-below.test.ts +45 -0
  155. package/src/shared/git.contract.ts +10 -0
  156. package/src/shared/no-link-below.ts +29 -0
@@ -0,0 +1,721 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { testKbContext } from '../../../../__tests__/kb-context.js';
6
+ import { GitService } from '../git.service.js';
7
+ import { NodeGitRunner } from '../node-git-runner.js';
8
+ import { GitRunError, type IGitRunner } from '../../../../shared/git.contract.js';
9
+ import type { ReservedPushOps } from '@bevel-software/platform-shared';
10
+ import { PullRebaseConflictError, WorkflowDomainError, WorkflowValidationError } from '../../../../shared/domain-errors.js';
11
+ import { runGit, gitOut, stubWorkflowHooks, stubWorkspaceService } from './git-test-helpers.js';
12
+
13
+ /**
14
+ * `mergeIntoBranch`: the agent's merge, over real git. A conflict is described
15
+ * and undone in one reservation — the working tree is never left mid-merge —
16
+ * and a second call with a resolution per path lands a merge commit with both
17
+ * parents. Pushed only when `publish` is asked for.
18
+ */
19
+
20
+ const TARGET = 'target-company-state';
21
+ const DRAFT = 'ada/draft';
22
+ const USER = { id: 'u1', email: 'ada@x.io', name: 'Ada Lovelace' };
23
+ /** The authorizer a resolution needs; here every resolution is the caller's to make. */
24
+ const allow = async (): Promise<void> => {};
25
+ /**
26
+ * The smallest publish a caller can bring: push, and on a rejection replay
27
+ * onto the moved branch (the merge kept) and push once more. The workflow
28
+ * facade brings its `pushWithRecovery` here; this stands in for it to show
29
+ * the moves work inside the merge's reservation.
30
+ */
31
+ const replay = async (ops: ReservedPushOps): Promise<void> => {
32
+ try {
33
+ await ops.push();
34
+ } catch {
35
+ await ops.pullRebase();
36
+ await ops.push();
37
+ }
38
+ };
39
+
40
+ /** Upstream with a target branch and a draft forked from it, and the draft's workspace clone. */
41
+ async function seed(root: string): Promise<{ upstream: string; repo: string; pusher: string }> {
42
+ const upstream = path.join(root, 'upstream.git');
43
+ await runGit(root, ['init', '--bare', '-b', TARGET, upstream]);
44
+ const seed = path.join(root, '.seed');
45
+ await fs.mkdir(seed);
46
+ await runGit(seed, ['init', '-b', TARGET]);
47
+ await runGit(seed, ['remote', 'add', 'origin', upstream]);
48
+ await fs.writeFile(path.join(seed, 'shared.md'), 'line 1\nline 2\nline 3\n');
49
+ await fs.writeFile(path.join(seed, 'other.md'), 'other\n');
50
+ await runGit(seed, ['add', '.']);
51
+ await runGit(seed, ['commit', '-m', 'init']);
52
+ await runGit(seed, ['push', 'origin', TARGET]);
53
+ await runGit(seed, ['checkout', '-b', DRAFT]);
54
+ await runGit(seed, ['push', '-u', 'origin', DRAFT]);
55
+ await runGit(upstream, ['symbolic-ref', 'HEAD', `refs/heads/${TARGET}`]);
56
+
57
+ const workspaceDir = path.join(root, encodeURIComponent(DRAFT));
58
+ const repo = path.join(workspaceDir, 'knowledge-base');
59
+ await fs.mkdir(workspaceDir, { recursive: true });
60
+ await runGit(root, ['-c', 'core.autocrlf=false', 'clone', '-b', DRAFT, upstream, repo]);
61
+ await runGit(repo, ['config', 'user.email', 'workspace@bevel.test']);
62
+ await runGit(repo, ['config', 'user.name', 'bevel Workspace']);
63
+ await runGit(repo, ['config', 'core.autocrlf', 'false']);
64
+ // A second clone, to move branches on the upstream as someone else would.
65
+ const pusher = path.join(root, '.pusher');
66
+ await runGit(root, ['-c', 'core.autocrlf=false', 'clone', upstream, pusher]);
67
+ await runGit(pusher, ['config', 'user.email', 'bob@x.io']);
68
+ await runGit(pusher, ['config', 'user.name', 'Bob']);
69
+ await runGit(pusher, ['config', 'core.autocrlf', 'false']);
70
+ return { upstream, repo, pusher };
71
+ }
72
+
73
+ /** Commit `files` on `branch` in the pusher clone and push. */
74
+ async function pushOn(pusher: string, branch: string, files: Record<string, string | null>, message: string): Promise<string> {
75
+ await runGit(pusher, ['checkout', '-q', branch]);
76
+ await runGit(pusher, ['pull', '-q', '--ff-only', 'origin', branch]).catch(() => undefined);
77
+ for (const [p, content] of Object.entries(files)) {
78
+ if (content === null) await fs.rm(path.join(pusher, p));
79
+ else {
80
+ await fs.mkdir(path.dirname(path.join(pusher, p)), { recursive: true });
81
+ await fs.writeFile(path.join(pusher, p), content);
82
+ }
83
+ }
84
+ await runGit(pusher, ['add', '-A']);
85
+ await runGit(pusher, ['commit', '-q', '-m', message]);
86
+ await runGit(pusher, ['push', '-q', 'origin', branch]);
87
+ return (await gitOut(pusher, ['rev-parse', 'HEAD'])).trim();
88
+ }
89
+
90
+ /** Commit `files` in the draft's own workspace, as a save would (no push). */
91
+ async function commitLocally(repo: string, files: Record<string, string>, message: string): Promise<string> {
92
+ for (const [p, content] of Object.entries(files)) await fs.writeFile(path.join(repo, p), content);
93
+ await runGit(repo, ['add', '-A']);
94
+ await runGit(repo, ['commit', '-q', '-m', message]);
95
+ return (await gitOut(repo, ['rev-parse', 'HEAD'])).trim();
96
+ }
97
+
98
+ describe('GitService.mergeIntoBranch', () => {
99
+ let root: string;
100
+ const workspaceId = encodeURIComponent(DRAFT);
101
+
102
+ beforeEach(async () => {
103
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-merge-into-'));
104
+ });
105
+ afterEach(async () => {
106
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
107
+ });
108
+
109
+ const service = () =>
110
+ new GitService(stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }), stubWorkflowHooks(), testKbContext());
111
+
112
+ it('merges a target that moved into the draft as a merge commit by the user, and pushes nothing', async () => {
113
+ const { repo, pusher } = await seed(root);
114
+ const theirs = await pushOn(pusher, TARGET, { 'other.md': 'other, changed on target\n' }, 'target moves');
115
+ const ours = await commitLocally(repo, { 'mine.md': 'mine\n' }, 'draft work');
116
+
117
+ const outcome = await service().mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 'Merge target', body: 'b' });
118
+
119
+ expect(outcome.kind).toBe('merged');
120
+ if (outcome.kind !== 'merged') return;
121
+ expect(outcome.resolvedPaths).toEqual([]);
122
+ const parents = (await gitOut(repo, ['log', '-1', '--format=%P', outcome.sha])).trim().split(' ');
123
+ expect(parents).toEqual([ours, theirs]);
124
+ expect((await gitOut(repo, ['log', '-1', '--format=%an <%ae>', outcome.sha])).trim()).toBe('Ada Lovelace <ada@x.io>');
125
+ expect(await fs.readFile(path.join(repo, 'other.md'), 'utf-8')).toBe('other, changed on target\n');
126
+ // Local only: the shared draft is where it was.
127
+ expect((await gitOut(pusher, ['ls-remote', 'origin', `refs/heads/${DRAFT}`])).trim()).not.toContain(outcome.sha);
128
+ });
129
+
130
+ it('merges diverged tips whose trees are identical, so the draft records the target\'s history and stops reading as behind it', async () => {
131
+ const { repo, pusher } = await seed(root);
132
+ const theirs = await pushOn(pusher, TARGET, { 'other.md': 'the same change\n' }, 'target changes other');
133
+ const ours = await commitLocally(repo, { 'other.md': 'the same change\n' }, 'draft makes the same change');
134
+
135
+ const outcome = await service().mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 's', body: 'b' });
136
+ expect(outcome.kind).toBe('merged');
137
+ if (outcome.kind !== 'merged') return;
138
+ expect((await gitOut(repo, ['log', '-1', '--format=%P', outcome.sha])).trim().split(' ')).toEqual([ours, theirs]);
139
+ expect((await gitOut(repo, ['rev-parse', `${outcome.sha}^{tree}`])).trim()).toBe((await gitOut(repo, ['rev-parse', `${ours}^{tree}`])).trim());
140
+ await gitOut(repo, ['merge-base', '--is-ancestor', theirs, 'HEAD']);
141
+ });
142
+
143
+ it('merges the commit it authorized, not the ref it was read from, when a fetch moves the ref in between', async () => {
144
+ const { repo, pusher } = await seed(root);
145
+ const theirs = await pushOn(pusher, TARGET, { 'shared.md': 'line 1\nline 2 from target\nline 3\n' }, 'target edits line 2');
146
+ const ours = await commitLocally(repo, { 'shared.md': 'line 1\nline 2 from draft\nline 3\n' }, 'draft edits line 2');
147
+ let moved = '';
148
+ const outcome = await service().mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, {
149
+ subject: 's',
150
+ body: 'b',
151
+ resolutions: [{ path: 'shared.md', content: 'resolved\n' }],
152
+ authorizeResolutions: async (merge) => {
153
+ expect(merge).toEqual({ ours, theirs });
154
+ // The target moves on the host, and a fetch (the timer's sync, another
155
+ // workspace's merge — the refs are the repository's) moves the ref.
156
+ moved = await pushOn(pusher, TARGET, { 'shared.md': 'line 1\nline 2 moved\nline 3\n', 'late.md': 'late\n' }, 'target moves after the fetch');
157
+ await runGit(repo, ['fetch', '-q', 'origin', `+refs/heads/${TARGET}:refs/remotes/origin/${TARGET}`]);
158
+ },
159
+ });
160
+ expect(outcome.kind).toBe('merged');
161
+ if (outcome.kind !== 'merged') return;
162
+ // Merged: the commit that was authorized; the moved tip is not an ancestor and its file is not here.
163
+ expect((await gitOut(repo, ['log', '-1', '--format=%P', outcome.sha])).trim().split(' ')).toEqual([ours, theirs]);
164
+ await expect(gitOut(repo, ['merge-base', '--is-ancestor', moved, outcome.sha])).rejects.toThrow();
165
+ await expect(fs.access(path.join(repo, 'late.md'))).rejects.toThrow();
166
+ });
167
+
168
+ it('answers nothing-to-merge, with the tip, when the draft already holds the target', async () => {
169
+ const { repo } = await seed(root);
170
+ const tip = (await gitOut(repo, ['rev-parse', 'HEAD'])).trim();
171
+ const outcome = await service().mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 's', body: 'b' });
172
+ expect(outcome).toEqual({ kind: 'nothing-to-merge', sha: tip, from: TARGET });
173
+ expect((await gitOut(repo, ['status', '--porcelain'])).trim()).toBe('');
174
+ });
175
+
176
+ it('describes a conflict — base, both sides, the marked-up copy — and leaves the branch exactly as it was', async () => {
177
+ const { repo, pusher } = await seed(root);
178
+ const theirs = await pushOn(pusher, TARGET, { 'shared.md': 'line 1\nline 2 from target\nline 3\n' }, 'target edits line 2');
179
+ const ours = await commitLocally(repo, { 'shared.md': 'line 1\nline 2 from draft\nline 3\n' }, 'draft edits line 2');
180
+ const outcome = await service().mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 's', body: 'b' });
181
+ // The target's commit is known to the workspace only once the merge fetched it.
182
+ const base = (await gitOut(repo, ['merge-base', ours, theirs])).trim();
183
+
184
+ expect(outcome.kind).toBe('conflicts');
185
+ if (outcome.kind !== 'conflicts') return;
186
+ expect(outcome).toMatchObject({ from: TARGET, mergeBase: base, ours, theirs, unresolved: ['shared.md'] });
187
+ expect(outcome.files).toHaveLength(1);
188
+ const [file] = outcome.files;
189
+ expect(file.path).toBe('shared.md');
190
+ expect(file.base).toBe('line 1\nline 2\nline 3\n');
191
+ expect(file.ours).toBe('line 1\nline 2 from draft\nline 3\n');
192
+ expect(file.theirs).toBe('line 1\nline 2 from target\nline 3\n');
193
+ expect(file.merged).toContain('<<<<<<<');
194
+ expect(file.merged).toContain('line 2 from draft');
195
+ expect(file.merged).toContain('line 2 from target');
196
+ expect(file.truncated).toBeUndefined();
197
+ // Undone: no merge in progress, the working copy is the draft's own, HEAD unmoved.
198
+ expect((await gitOut(repo, ['status', '--porcelain'])).trim()).toBe('');
199
+ await expect(gitOut(repo, ['rev-parse', '-q', '--verify', 'MERGE_HEAD'])).rejects.toThrow();
200
+ expect(await fs.readFile(path.join(repo, 'shared.md'), 'utf-8')).toBe('line 1\nline 2 from draft\nline 3\n');
201
+ expect((await gitOut(repo, ['rev-parse', 'HEAD'])).trim()).toBe(ours);
202
+ });
203
+
204
+ it('lands the merge once every conflicted path has a resolution: content, a side, or deletion', async () => {
205
+ const { repo, pusher } = await seed(root);
206
+ // Three conflicts of three kinds: an edit/edit, one the draft deleted
207
+ // while the target edited, and one both added differently.
208
+ const theirs = await pushOn(
209
+ pusher,
210
+ TARGET,
211
+ { 'shared.md': 'line 1\nline 2 from target\nline 3\n', 'other.md': 'other, changed on target\n', 'new.md': 'new from target\n' },
212
+ 'target edits',
213
+ );
214
+ await fs.rm(path.join(repo, 'other.md'));
215
+ await runGit(repo, ['add', '-A']);
216
+ await runGit(repo, ['commit', '-q', '-m', 'draft deletes other']);
217
+ const ours = await commitLocally(repo, { 'shared.md': 'line 1\nline 2 from draft\nline 3\n', 'new.md': 'new from draft\n' }, 'draft edits');
218
+
219
+ const svc = service();
220
+ const first = await svc.mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 's', body: 'b' });
221
+ expect(first.kind).toBe('conflicts');
222
+ if (first.kind !== 'conflicts') return;
223
+ expect(first.unresolved.sort()).toEqual(['new.md', 'other.md', 'shared.md']);
224
+ const other = first.files.find((f) => f.path === 'other.md')!;
225
+ expect(other.ours).toBeNull();
226
+ expect(other.theirs).toBe('other, changed on target\n');
227
+
228
+ const second = await svc.mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, {
229
+ subject: 'Merge target into draft',
230
+ body: 'resolved',
231
+ resolutions: [
232
+ { path: 'shared.md', content: 'line 1\nline 2 from draft and target\nline 3\n' },
233
+ { path: 'knowledge-base/other.md', choose: 'theirs' },
234
+ { path: 'new.md', choose: 'delete' },
235
+ ],
236
+ authorizeResolutions: allow,
237
+ });
238
+ expect(second.kind).toBe('merged');
239
+ if (second.kind !== 'merged') return;
240
+ expect(second.resolvedPaths.sort()).toEqual(['new.md', 'other.md', 'shared.md']);
241
+ const parents = (await gitOut(repo, ['log', '-1', '--format=%P', second.sha])).trim().split(' ');
242
+ expect(parents).toEqual([ours, theirs]);
243
+ expect(await fs.readFile(path.join(repo, 'shared.md'), 'utf-8')).toBe('line 1\nline 2 from draft and target\nline 3\n');
244
+ expect(await fs.readFile(path.join(repo, 'other.md'), 'utf-8')).toBe('other, changed on target\n');
245
+ await expect(fs.access(path.join(repo, 'new.md'))).rejects.toThrow();
246
+ expect((await gitOut(repo, ['status', '--porcelain'])).trim()).toBe('');
247
+ expect((await gitOut(repo, ['show', `${second.sha}:other.md`])).trim()).toBe('other, changed on target');
248
+ await expect(gitOut(repo, ['show', `${second.sha}:new.md`])).rejects.toThrow();
249
+ });
250
+
251
+ it('refuses a resolution for a path that is not conflicted, before anything is written', async () => {
252
+ const { repo, pusher } = await seed(root);
253
+ await pushOn(pusher, TARGET, { 'shared.md': 'line 1\nline 2 from target\nline 3\n' }, 'target edits');
254
+ await commitLocally(repo, { 'shared.md': 'line 1\nline 2 from draft\nline 3\n' }, 'draft edits');
255
+ const head = (await gitOut(repo, ['rev-parse', 'HEAD'])).trim();
256
+
257
+ await expect(
258
+ service().mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, {
259
+ subject: 's',
260
+ body: 'b',
261
+ resolutions: [
262
+ { path: 'shared.md', content: 'resolved\n' },
263
+ { path: 'other.md', content: 'an ordinary edit smuggled in\n' },
264
+ ],
265
+ authorizeResolutions: allow,
266
+ }),
267
+ ).rejects.toThrow(/not conflicted.*other\.md/);
268
+ expect((await gitOut(repo, ['rev-parse', 'HEAD'])).trim()).toBe(head);
269
+ expect((await gitOut(repo, ['status', '--porcelain'])).trim()).toBe('');
270
+ expect(await fs.readFile(path.join(repo, 'other.md'), 'utf-8')).toBe('other\n');
271
+ });
272
+
273
+ it('refuses a resolution that says both content and a side, or neither, before touching git', async () => {
274
+ await seed(root);
275
+ const svc = service();
276
+ await expect(
277
+ svc.mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 's', body: 'b', resolutions: [{ path: 'a.md' }], authorizeResolutions: allow }),
278
+ ).rejects.toBeInstanceOf(WorkflowValidationError);
279
+ await expect(
280
+ svc.mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, {
281
+ subject: 's',
282
+ body: 'b',
283
+ resolutions: [{ path: 'a.md', content: 'x', choose: 'ours' }],
284
+ authorizeResolutions: allow,
285
+ }),
286
+ ).rejects.toBeInstanceOf(WorkflowValidationError);
287
+ });
288
+
289
+ it('refuses resolutions that nobody authorizes, before touching git', async () => {
290
+ const { repo } = await seed(root);
291
+ const head = (await gitOut(repo, ['rev-parse', 'HEAD'])).trim();
292
+ await expect(
293
+ service().mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, {
294
+ subject: 's',
295
+ body: 'b',
296
+ resolutions: [{ path: 'shared.md', content: 'x\n' }],
297
+ } as never),
298
+ ).rejects.toThrow(/authorizeResolutions/);
299
+ expect((await gitOut(repo, ['rev-parse', 'HEAD'])).trim()).toBe(head);
300
+ expect((await gitOut(repo, ['status', '--porcelain'])).trim()).toBe('');
301
+ });
302
+
303
+ it('refuses while the working tree holds edits not committed yet', async () => {
304
+ const { repo } = await seed(root);
305
+ await fs.writeFile(path.join(repo, 'shared.md'), 'unsaved edit\n');
306
+ const err = await service()
307
+ .mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 's', body: 'b' })
308
+ .catch((e: unknown) => e);
309
+ expect(err).toBeInstanceOf(WorkflowDomainError);
310
+ expect((err as WorkflowDomainError).status).toBe(409);
311
+ expect((err as WorkflowDomainError).payload).toMatchObject({ kind: 'branch-busy' });
312
+ expect(await fs.readFile(path.join(repo, 'shared.md'), 'utf-8')).toBe('unsaved edit\n');
313
+ });
314
+
315
+ it('refuses resolutions when nothing conflicts, so none is dropped behind a clean merge', async () => {
316
+ const { repo, pusher } = await seed(root);
317
+ await pushOn(pusher, TARGET, { 'other.md': 'other, changed on target\n' }, 'target moves');
318
+ const head = await commitLocally(repo, { 'mine.md': 'mine\n' }, 'draft work');
319
+ await expect(
320
+ service().mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, {
321
+ subject: 's',
322
+ body: 'b',
323
+ resolutions: [{ path: 'other.md', content: 'smuggled\n' }],
324
+ authorizeResolutions: allow,
325
+ }),
326
+ ).rejects.toThrow(/nothing conflicted/);
327
+ expect((await gitOut(repo, ['rev-parse', 'HEAD'])).trim()).toBe(head);
328
+ expect((await gitOut(repo, ['status', '--porcelain'])).trim()).toBe('');
329
+ await expect(gitOut(repo, ['rev-parse', '-q', '--verify', 'MERGE_HEAD'])).rejects.toThrow();
330
+ });
331
+
332
+ it('undoes the merge when the commit itself fails, and reports the failure', async () => {
333
+ const { repo, pusher } = await seed(root);
334
+ await pushOn(pusher, TARGET, { 'other.md': 'other, changed on target\n' }, 'target moves');
335
+ const head = await commitLocally(repo, { 'mine.md': 'mine\n' }, 'draft work');
336
+ // A hook that refuses every commit: the merge is staged, the commit fails.
337
+ const hooks = path.join(repo, '.git', 'hooks');
338
+ await fs.mkdir(hooks, { recursive: true });
339
+ await fs.writeFile(path.join(hooks, 'pre-commit'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
340
+
341
+ await expect(service().mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 's', body: 'b' })).rejects.toThrow();
342
+ await expect(gitOut(repo, ['rev-parse', '-q', '--verify', 'MERGE_HEAD'])).rejects.toThrow();
343
+ expect((await gitOut(repo, ['status', '--porcelain'])).trim()).toBe('');
344
+ expect((await gitOut(repo, ['rev-parse', 'HEAD'])).trim()).toBe(head);
345
+ expect(await fs.readFile(path.join(repo, 'other.md'), 'utf-8')).toBe('other\n');
346
+ });
347
+
348
+ it('leaves a binary or oversized side out, named per side, and keeps a chosen side byte for byte', async () => {
349
+ const { repo, pusher } = await seed(root);
350
+ const png = (seed: number) => Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, seed, 0xff, 0, 1]);
351
+ const big = 'x'.repeat(250_000) + '\n';
352
+ await fs.writeFile(path.join(repo, 'img.png'), png(1));
353
+ await fs.writeFile(path.join(repo, 'big.md'), 'small\n');
354
+ await runGit(repo, ['add', '-A']);
355
+ await runGit(repo, ['commit', '-q', '-m', 'binary and big']);
356
+ await runGit(repo, ['push', '-q', 'origin', DRAFT]);
357
+ await runGit(pusher, ['fetch', '-q', 'origin']);
358
+ await runGit(pusher, ['checkout', '-q', TARGET]);
359
+ await runGit(pusher, ['merge', '-q', `origin/${DRAFT}`]);
360
+ await fs.writeFile(path.join(pusher, 'img.png'), png(2));
361
+ await fs.writeFile(path.join(pusher, 'big.md'), big);
362
+ await runGit(pusher, ['add', '-A']);
363
+ await runGit(pusher, ['commit', '-q', '-m', 'target changes both']);
364
+ await runGit(pusher, ['push', '-q', 'origin', TARGET]);
365
+ await fs.writeFile(path.join(repo, 'img.png'), png(3));
366
+ await fs.writeFile(path.join(repo, 'big.md'), 'draft small\n');
367
+ await runGit(repo, ['add', '-A']);
368
+ await runGit(repo, ['commit', '-q', '-m', 'draft changes both']);
369
+
370
+ const svc = service();
371
+ const first = await svc.mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 's', body: 'b' });
372
+ expect(first.kind).toBe('conflicts');
373
+ if (first.kind !== 'conflicts') return;
374
+ const img = first.files.find((f) => f.path === 'img.png')!;
375
+ expect(img.truncated?.sort()).toEqual(['base', 'merged', 'ours', 'theirs']);
376
+ expect([img.base, img.ours, img.theirs, img.merged]).toEqual([null, null, null, null]);
377
+ const bigFile = first.files.find((f) => f.path === 'big.md')!;
378
+ expect(bigFile.truncated).toEqual(['theirs', 'merged']);
379
+ expect(bigFile.ours).toBe('draft small\n');
380
+ expect(bigFile.base).toBe('small\n');
381
+
382
+ const second = await svc.mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, {
383
+ subject: 's',
384
+ body: 'b',
385
+ resolutions: [
386
+ { path: 'img.png', choose: 'ours' },
387
+ { path: 'big.md', choose: 'theirs' },
388
+ ],
389
+ authorizeResolutions: allow,
390
+ });
391
+ expect(second.kind).toBe('merged');
392
+ expect(Buffer.compare(await fs.readFile(path.join(repo, 'img.png')), png(3))).toBe(0);
393
+ expect(await fs.readFile(path.join(repo, 'big.md'), 'utf-8')).toBe(big);
394
+ });
395
+
396
+ it('leaves a side that is not well-formed UTF-8 out, NUL or no NUL, so it is never written back changed', async () => {
397
+ const { repo, pusher } = await seed(root);
398
+ // Latin-1 bytes: no NUL anywhere, not UTF-8 either.
399
+ const latin1 = (s: string) => Buffer.from(s, 'latin1');
400
+ await fs.writeFile(path.join(repo, 'legacy.txt'), latin1('caf\xe9 base\n'));
401
+ await runGit(repo, ['add', '-A']);
402
+ await runGit(repo, ['commit', '-q', '-m', 'legacy encoding']);
403
+ await runGit(repo, ['push', '-q', 'origin', DRAFT]);
404
+ await runGit(pusher, ['fetch', '-q', 'origin']);
405
+ await runGit(pusher, ['checkout', '-q', TARGET]);
406
+ await runGit(pusher, ['merge', '-q', `origin/${DRAFT}`]);
407
+ await fs.writeFile(path.join(pusher, 'legacy.txt'), latin1('caf\xe9 target\n'));
408
+ await runGit(pusher, ['add', '-A']);
409
+ await runGit(pusher, ['commit', '-q', '-m', 'target edits legacy']);
410
+ await runGit(pusher, ['push', '-q', 'origin', TARGET]);
411
+ await fs.writeFile(path.join(repo, 'legacy.txt'), latin1('caf\xe9 draft\n'));
412
+ await runGit(repo, ['add', '-A']);
413
+ await runGit(repo, ['commit', '-q', '-m', 'draft edits legacy']);
414
+
415
+ const outcome = await service().mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 's', body: 'b' });
416
+ expect(outcome.kind).toBe('conflicts');
417
+ if (outcome.kind !== 'conflicts') return;
418
+ const legacy = outcome.files.find((f) => f.path === 'legacy.txt')!;
419
+ expect(legacy.truncated?.sort()).toEqual(['base', 'merged', 'ours', 'theirs']);
420
+ expect([legacy.base, legacy.ours, legacy.theirs, legacy.merged]).toEqual([null, null, null, null]);
421
+ });
422
+
423
+ it('lets the merged-in side be read at its branch while the merge is unresolved, though its tip is not on the draft\'s history', async () => {
424
+ const { repo, pusher } = await seed(root);
425
+ const theirs = await pushOn(pusher, TARGET, { 'shared.md': 'line 1\nline 2 from target\nline 3\n' }, 'target edits line 2');
426
+ await commitLocally(repo, { 'shared.md': 'line 1\nline 2 from draft\nline 3\n' }, 'draft edits line 2');
427
+ const svc = service();
428
+ const outcome = await svc.mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 's', body: 'b' });
429
+ expect(outcome.kind).toBe('conflicts');
430
+ // The target's tip is not an ancestor of the draft: a commit read would
431
+ // refuse it, a branch read serves it.
432
+ await expect(gitOut(repo, ['merge-base', '--is-ancestor', theirs, 'HEAD'])).rejects.toThrow();
433
+ expect(await svc.readFileAtBranch(workspaceId, 'shared.md', TARGET)).toEqual({
434
+ sha: theirs,
435
+ ref: `refs/remotes/origin/${TARGET}`,
436
+ content: 'line 1\nline 2 from target\nline 3\n',
437
+ });
438
+ });
439
+
440
+ it('asks the resolution authorizer inside the reservation with the commits fetched, before the merge touches the checkout', async () => {
441
+ const { repo, pusher } = await seed(root);
442
+ const theirs = await pushOn(pusher, TARGET, { 'shared.md': 'line 1\nline 2 from target\nline 3\n' }, 'target edits line 2');
443
+ const ours = await commitLocally(repo, { 'shared.md': 'line 1\nline 2 from draft\nline 3\n' }, 'draft edits line 2');
444
+ const asked: Array<{ merge: unknown; head: string; mergeHead: boolean; sharedOnDisk: string }> = [];
445
+ const svc = service();
446
+ const err = await svc
447
+ .mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, {
448
+ subject: 's',
449
+ body: 'b',
450
+ resolutions: [{ path: 'shared.md', content: 'resolved\n' }],
451
+ authorizeResolutions: async (merge) => {
452
+ // What the checkout holds while the question is asked: the draft's
453
+ // own commit, clean, nothing of the merge on disk yet — so a rule
454
+ // read from it is the draft's, not a conflict-marked one.
455
+ asked.push({
456
+ merge,
457
+ head: (await gitOut(repo, ['rev-parse', 'HEAD'])).trim(),
458
+ mergeHead: await gitOut(repo, ['rev-parse', '-q', '--verify', 'MERGE_HEAD']).then(() => true, () => false),
459
+ sharedOnDisk: await fs.readFile(path.join(repo, 'shared.md'), 'utf-8'),
460
+ });
461
+ throw new WorkflowDomainError('not yours to settle', 403);
462
+ },
463
+ })
464
+ .catch((e: unknown) => e);
465
+ expect(err).toBeInstanceOf(WorkflowDomainError);
466
+ // Asked with the target's tip as fetched by this very merge.
467
+ expect(asked).toEqual([{ merge: { ours, theirs }, head: ours, mergeHead: false, sharedOnDisk: 'line 1\nline 2 from draft\nline 3\n' }]);
468
+ expect((await gitOut(repo, ['rev-parse', 'HEAD'])).trim()).toBe(ours);
469
+ expect((await gitOut(repo, ['status', '--porcelain'])).trim()).toBe('');
470
+ await expect(gitOut(repo, ['rev-parse', '-q', '--verify', 'MERGE_HEAD'])).rejects.toThrow();
471
+ expect(await fs.readFile(path.join(repo, 'shared.md'), 'utf-8')).toBe('line 1\nline 2 from draft\nline 3\n');
472
+ });
473
+
474
+ it('counts a merge as published when the push landed and only the tracking stamp after it failed', async () => {
475
+ const { repo, pusher } = await seed(root);
476
+ await pushOn(pusher, TARGET, { 'other.md': 'other, changed on target\n' }, 'target moves');
477
+ await commitLocally(repo, { 'mine.md': 'mine\n' }, 'draft work');
478
+ const inner = new NodeGitRunner();
479
+ const gitRunner = {
480
+ defaultTimeoutMs: inner.defaultTimeoutMs,
481
+ credentials: inner.credentials,
482
+ run: async (cwd: string, args: string[], opts?: object) => {
483
+ // The stamp `pushUnlocked` writes right after a push that landed.
484
+ if (args[0] === 'config' && args.includes(`branch.${DRAFT}.remote`)) throw new Error('could not lock config file');
485
+ return inner.run(cwd, args, opts as never);
486
+ },
487
+ } as unknown as IGitRunner;
488
+ const svc = new GitService(
489
+ stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
490
+ stubWorkflowHooks(),
491
+ testKbContext(),
492
+ undefined,
493
+ null,
494
+ gitRunner,
495
+ );
496
+ const outcome = await svc.mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 's', body: 'b', publish: (ops) => ops.push() });
497
+ expect(outcome.kind).toBe('merged');
498
+ if (outcome.kind !== 'merged') return;
499
+ expect((await gitOut(pusher, ['ls-remote', 'origin', `refs/heads/${DRAFT}`])).trim()).toContain(outcome.sha);
500
+ });
501
+
502
+ // Symbolic links need a privilege on Windows that a test runner does not have.
503
+ it.skipIf(process.platform === 'win32')('never follows a link out of the checkout, reading or writing a conflicted path', async () => {
504
+ const { repo, pusher } = await seed(root);
505
+ for (const dir of [repo, pusher]) await runGit(dir, ['config', 'core.symlinks', 'true']);
506
+ const outside = path.join(root, 'outside.txt');
507
+ await fs.writeFile(outside, 'outside the checkout\n');
508
+ // Both sides turn `shared.md` into a link — the draft's pointing outside
509
+ // the checkout, the target's at a sibling — so git sees one conflicted
510
+ // path of one type (two links with different targets are not merged;
511
+ // the draft's own link is what the checkout holds while it is open).
512
+ await fs.rm(path.join(repo, 'shared.md'));
513
+ await fs.symlink(outside, path.join(repo, 'shared.md'));
514
+ await runGit(repo, ['add', '-A']);
515
+ await runGit(repo, ['commit', '-q', '-m', 'draft links shared.md outside']);
516
+ await runGit(pusher, ['checkout', '-q', TARGET]);
517
+ await fs.rm(path.join(pusher, 'shared.md'));
518
+ await fs.symlink('other.md', path.join(pusher, 'shared.md'));
519
+ await runGit(pusher, ['add', '-A']);
520
+ await runGit(pusher, ['commit', '-q', '-m', 'target links shared.md at other.md']);
521
+ await runGit(pusher, ['push', '-q', 'origin', TARGET]);
522
+
523
+ const svc = service();
524
+ const first = await svc.mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 's', body: 'b' });
525
+ expect(first.kind).toBe('conflicts');
526
+ if (first.kind !== 'conflicts') return;
527
+ expect(first.unresolved).toEqual(['shared.md']);
528
+ const file = first.files.find((f) => f.path === 'shared.md')!;
529
+ // The link is never followed: the working copy is left out, and each
530
+ // side is the link's own target text as git stores it, not the file
531
+ // outside.
532
+ expect(file.truncated).toContain('merged');
533
+ expect(file.merged).toBeNull();
534
+ // (Git stores the target with forward slashes whatever the host's separator.)
535
+ expect(file.ours?.replaceAll('\\', '/')).toBe(outside.replaceAll('\\', '/'));
536
+ expect(file.theirs).toBe('other.md');
537
+ expect(file.base).toBe('line 1\nline 2\nline 3\n');
538
+ expect(await fs.readFile(outside, 'utf-8')).toBe('outside the checkout\n');
539
+ expect((await fs.lstat(path.join(repo, 'shared.md'))).isSymbolicLink()).toBe(true);
540
+
541
+ const second = await svc.mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, {
542
+ subject: 's',
543
+ body: 'b',
544
+ resolutions: [{ path: 'shared.md', content: 'resolved as a file\n' }],
545
+ authorizeResolutions: allow,
546
+ });
547
+ expect(second.kind).toBe('merged');
548
+ // Written as a path inside the checkout; the file outside is untouched.
549
+ expect((await fs.lstat(path.join(repo, 'shared.md'))).isSymbolicLink()).toBe(false);
550
+ expect(await fs.readFile(path.join(repo, 'shared.md'), 'utf-8')).toBe('resolved as a file\n');
551
+ expect(await fs.readFile(outside, 'utf-8')).toBe('outside the checkout\n');
552
+ });
553
+
554
+ it('publishes the merge inside the same reservation, and after a rejected push replays it onto the moved branch', async () => {
555
+ const { repo, pusher } = await seed(root);
556
+ await pushOn(pusher, TARGET, { 'other.md': 'other, changed on target\n' }, 'target moves');
557
+ await commitLocally(repo, { 'mine.md': 'mine\n' }, 'draft work');
558
+ const svc = service();
559
+
560
+ // What the checkout holds when the publish is asked: the merge commit,
561
+ // committed, the tree clean — the publish runs over a finished merge.
562
+ let seen: { parents: number; dirty: string } | null = null;
563
+ const published = await svc.mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, {
564
+ subject: 's',
565
+ body: 'b',
566
+ publish: async (ops) => {
567
+ seen = {
568
+ parents: (await gitOut(repo, ['log', '-1', '--format=%P', 'HEAD'])).trim().split(' ').length,
569
+ dirty: (await gitOut(repo, ['status', '--porcelain'])).trim(),
570
+ };
571
+ await replay(ops);
572
+ },
573
+ });
574
+ expect(published.kind).toBe('merged');
575
+ if (published.kind !== 'merged') return;
576
+ expect(seen).toEqual({ parents: 2, dirty: '' });
577
+ expect((await gitOut(pusher, ['ls-remote', 'origin', `refs/heads/${DRAFT}`])).trim()).toContain(published.sha);
578
+
579
+ // The draft moves on the host while this workspace holds its next merge.
580
+ const moved = await pushOn(pusher, DRAFT, { 'elsewhere.md': 'pushed elsewhere\n' }, 'draft moves on origin');
581
+ await pushOn(pusher, TARGET, { 'other.md': 'other, changed again\n' }, 'target moves again');
582
+ const again = await svc.mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 'again', body: 'b', publish: replay });
583
+ expect(again.kind).toBe('merged');
584
+ if (again.kind !== 'merged') return;
585
+ // Published, on top of the moved branch, still a merge.
586
+ expect((await gitOut(pusher, ['ls-remote', 'origin', `refs/heads/${DRAFT}`])).trim()).toContain(again.sha);
587
+ expect((await gitOut(repo, ['rev-parse', 'HEAD'])).trim()).toBe(again.sha);
588
+ await gitOut(repo, ['merge-base', '--is-ancestor', moved, again.sha]);
589
+ expect((await gitOut(repo, ['log', '-1', '--format=%P', again.sha])).trim().split(' ')).toHaveLength(2);
590
+ expect(await fs.readFile(path.join(repo, 'elsewhere.md'), 'utf-8')).toBe('pushed elsewhere\n');
591
+ expect(await fs.readFile(path.join(repo, 'other.md'), 'utf-8')).toBe('other, changed again\n');
592
+ });
593
+
594
+ it('lets a replay that conflicts throw out of the publish as the typed pull-conflict error, the merge kept and the checkout clean', async () => {
595
+ const { repo, pusher } = await seed(root);
596
+ await pushOn(pusher, TARGET, { 'other.md': 'other, changed on target\n' }, 'target moves');
597
+ // The draft moves on the host with a change the merge will clash with.
598
+ await pushOn(pusher, DRAFT, { 'other.md': 'other, changed on the draft elsewhere\n' }, 'draft moves on origin');
599
+ await commitLocally(repo, { 'mine.md': 'mine\n' }, 'draft work');
600
+
601
+ const err = await service()
602
+ .mergeIntoBranch(workspaceId, DRAFT, TARGET, USER, { subject: 's', body: 'b', publish: replay })
603
+ .catch((e: unknown) => e);
604
+ expect(err).toBeInstanceOf(PullRebaseConflictError);
605
+ expect((err as PullRebaseConflictError).conflictedPaths).toEqual(['other.md']);
606
+ // The merge commit is on the branch locally, nothing is published, and
607
+ // no rebase is in progress: the replay undid itself.
608
+ const head = (await gitOut(repo, ['rev-parse', 'HEAD'])).trim();
609
+ expect((await gitOut(repo, ['log', '-1', '--format=%P', head])).trim().split(' ')).toHaveLength(2);
610
+ expect((await gitOut(pusher, ['ls-remote', 'origin', `refs/heads/${DRAFT}`])).trim()).not.toContain(head);
611
+ expect((await gitOut(repo, ['status', '--porcelain'])).trim()).toBe('');
612
+ await expect(gitOut(repo, ['rev-parse', '-q', '--verify', 'REBASE_HEAD'])).rejects.toThrow();
613
+ });
614
+
615
+ it('brings the branch\'s own shared state in when it moved while the workspace held commits (from = branch)', async () => {
616
+ const { repo, pusher } = await seed(root);
617
+ const theirs = await pushOn(pusher, DRAFT, { 'other.md': 'pushed from elsewhere\n' }, 'draft moves on origin');
618
+ const ours = await commitLocally(repo, { 'mine.md': 'mine\n' }, 'local save');
619
+
620
+ const outcome = await service().mergeIntoBranch(workspaceId, DRAFT, DRAFT, USER, { subject: 'Merge shared', body: 'b' });
621
+
622
+ expect(outcome.kind).toBe('merged');
623
+ if (outcome.kind !== 'merged') return;
624
+ const parents = (await gitOut(repo, ['log', '-1', '--format=%P', outcome.sha])).trim().split(' ');
625
+ expect(parents).toEqual([ours, theirs]);
626
+ expect(await fs.readFile(path.join(repo, 'other.md'), 'utf-8')).toBe('pushed from elsewhere\n');
627
+ expect(await fs.readFile(path.join(repo, 'mine.md'), 'utf-8')).toBe('mine\n');
628
+ });
629
+ });
630
+
631
+ describe('GitService.readFileAtBranch', () => {
632
+ let root: string;
633
+ const workspaceId = encodeURIComponent(DRAFT);
634
+
635
+ beforeEach(async () => {
636
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-read-at-branch-'));
637
+ });
638
+ afterEach(async () => {
639
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
640
+ });
641
+
642
+ it('reads the file at the other branch\'s published tip, null when absent, and refuses a folder', async () => {
643
+ const { repo, pusher } = await seed(root);
644
+ const theirs = await pushOn(pusher, TARGET, { 'other.md': 'other, changed on target\n', 'docs/a.md': 'a\n' }, 'target edits');
645
+ await runGit(repo, ['fetch', '-q', 'origin']);
646
+ const svc = new GitService(stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }), stubWorkflowHooks(), testKbContext());
647
+
648
+ expect(await svc.readFileAtBranch(workspaceId, 'knowledge-base/other.md', TARGET)).toEqual({
649
+ sha: theirs,
650
+ ref: `refs/remotes/origin/${TARGET}`,
651
+ content: 'other, changed on target\n',
652
+ });
653
+ expect((await svc.readFileAtBranch(workspaceId, 'missing.md', TARGET)).content).toBeNull();
654
+ await expect(svc.readFileAtBranch(workspaceId, 'docs', TARGET)).rejects.toThrow(/directory/);
655
+ await expect(svc.readFileAtBranch(workspaceId, 'other.md', 'no/such/branch')).rejects.toThrow(/unknown branch/);
656
+
657
+ // The caller's verdict is asked with the commit BEFORE the path is
658
+ // described at it: a folder the caller may not read is refused as
659
+ // unreadable, never as a folder.
660
+ const asked: string[][] = [];
661
+ const refuse = async (commits: string[]): Promise<void> => {
662
+ asked.push(commits);
663
+ throw new WorkflowDomainError('not yours to read', 403);
664
+ };
665
+ await expect(svc.readFileAtBranch(workspaceId, 'docs', TARGET, refuse)).rejects.toThrow(/not yours to read/);
666
+ await expect(svc.readFileAtBranch(workspaceId, 'other.md', TARGET, refuse)).rejects.toThrow(/not yours to read/);
667
+ // The target resolves here to its published tip only (no local head of it).
668
+ expect(asked).toEqual([[theirs], [theirs]]);
669
+ const allowed: string[][] = [];
670
+ expect((await svc.readFileAtBranch(workspaceId, 'other.md', TARGET, async (commits) => { allowed.push(commits); })).content).toBe('other, changed on target\n');
671
+ expect(allowed).toEqual([[theirs]]);
672
+ });
673
+
674
+ it('reads the PUBLISHED tip, not a local head that has moved past it', async () => {
675
+ const { repo } = await seed(root);
676
+ const svc = new GitService(stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }), stubWorkflowHooks(), testKbContext());
677
+ const published = (await gitOut(repo, ['rev-parse', `origin/${DRAFT}`])).trim();
678
+ const local = await commitLocally(repo, { 'other.md': 'not shared yet\n' }, 'local only');
679
+ const asked: string[][] = [];
680
+ const read = await svc.readFileAtBranch(workspaceId, 'other.md', DRAFT, async (commits) => { asked.push(commits); });
681
+ expect(read).toEqual({ sha: published, ref: `refs/remotes/origin/${DRAFT}`, content: 'other\n' });
682
+ // The verdict is asked at BOTH commits the branch resolves to — the
683
+ // published tip served, and the local head past it — so a grant at one
684
+ // never outruns a denial at the other.
685
+ expect(asked).toEqual([[published, local]]);
686
+ });
687
+
688
+ it('fails closed when a lookup of the branch\'s other tip fails for any reason but absence: nothing is served, the verdict never asked', async () => {
689
+ const { repo, pusher } = await seed(root);
690
+ await pushOn(pusher, TARGET, { 'other.md': 'other, changed on target\n' }, 'target edits');
691
+ await runGit(repo, ['fetch', '-q', 'origin']);
692
+ const inner = new NodeGitRunner();
693
+ // The secondary lookup (`refs/heads/<target>`, absent here) fails the way
694
+ // a corrupt repository or a deadline fails — not the way an absent ref
695
+ // answers (exit 1, empty stderr).
696
+ let failure: GitRunError | null = null;
697
+ const gitRunner = {
698
+ defaultTimeoutMs: inner.defaultTimeoutMs,
699
+ credentials: inner.credentials,
700
+ run: async (cwd: string, args: string[], opts?: object) => {
701
+ if (failure && args[0] === 'rev-parse' && args.includes(`refs/heads/${TARGET}`)) throw failure;
702
+ return inner.run(cwd, args, opts as never);
703
+ },
704
+ } as unknown as IGitRunner;
705
+ const svc = new GitService(stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }), stubWorkflowHooks(), testKbContext(), undefined, null, gitRunner);
706
+ const asked: string[][] = [];
707
+ const authorize = async (commits: string[]): Promise<void> => { asked.push(commits); };
708
+ for (const f of [
709
+ new GitRunError('git rev-parse failed', { exitCode: 128, stderr: 'fatal: bad object' }),
710
+ new GitRunError('git rev-parse timed out', { timedOut: true }),
711
+ ]) {
712
+ failure = f;
713
+ await expect(svc.readFileAtBranch(workspaceId, 'other.md', TARGET, authorize)).rejects.toBe(f);
714
+ }
715
+ expect(asked).toEqual([]);
716
+ // An absent ref is the one answer that is "no such commit": the read goes on.
717
+ failure = null;
718
+ expect((await svc.readFileAtBranch(workspaceId, 'other.md', TARGET, authorize)).content).toBe('other, changed on target\n');
719
+ expect(asked).toHaveLength(1);
720
+ });
721
+ });