@bevel-software/platform-core-backend 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/modules/workflow/git/git.service.d.ts +14 -0
  2. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  3. package/dist/modules/workflow/git/git.service.js +59 -2
  4. package/dist/modules/workflow/git/git.service.js.map +1 -1
  5. package/dist/modules/workflow/pending-commits.service.d.ts +17 -0
  6. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  7. package/dist/modules/workflow/pending-commits.service.js +33 -0
  8. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  9. package/dist/modules/workflow/workflow.errors.d.ts +26 -0
  10. package/dist/modules/workflow/workflow.errors.d.ts.map +1 -1
  11. package/dist/modules/workflow/workflow.errors.js +38 -0
  12. package/dist/modules/workflow/workflow.errors.js.map +1 -1
  13. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  14. package/dist/modules/workflow/workflow.routes.js +5 -2
  15. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  16. package/dist/modules/workflow/workflow.service.d.ts +28 -1
  17. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  18. package/dist/modules/workflow/workflow.service.js +81 -6
  19. package/dist/modules/workflow/workflow.service.js.map +1 -1
  20. package/package.json +2 -2
  21. package/src/modules/workflow/__tests__/pending-commits.enqueue-if-absent.test.ts +82 -0
  22. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +44 -0
  23. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +111 -2
  24. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +306 -232
  25. package/src/modules/workflow/git/git.service.ts +62 -1
  26. package/src/modules/workflow/pending-commits.service.ts +39 -0
  27. package/src/modules/workflow/workflow.errors.ts +41 -0
  28. package/src/modules/workflow/workflow.routes.ts +5 -2
  29. package/src/modules/workflow/workflow.service.ts +89 -5
@@ -1,232 +1,306 @@
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 { GitService } from '../git.service.js';
6
- import { runGit, gitOut, stubWorkflowHooks, stubWorkspaceService } from './git-test-helpers.js';
7
-
8
- /** See git.service.createBranch.test.ts — same prod-mirroring layout. */
9
- async function seedWorkspace(root: string, workspaceId: string): Promise<{
10
- upstream: string;
11
- repo: string;
12
- }> {
13
- const upstream = path.join(root, 'upstream.git');
14
- await runGit(root, ['init', '--bare', '-b', 'target-company-state', upstream]);
15
-
16
- const seed = path.join(root, '.seed');
17
- await fs.mkdir(seed);
18
- await runGit(seed, ['init', '-b', 'target-company-state']);
19
- await runGit(seed, ['remote', 'add', 'origin', upstream]);
20
- await runGit(seed, ['commit', '--allow-empty', '-m', 'init']);
21
- await runGit(seed, ['push', 'origin', 'target-company-state']);
22
- await runGit(upstream, ['symbolic-ref', 'HEAD', 'refs/heads/target-company-state']);
23
-
24
- const workspaceDir = path.join(root, workspaceId);
25
- const repo = path.join(workspaceDir, 'knowledge-base');
26
- await fs.mkdir(workspaceDir, { recursive: true });
27
- await runGit(root, ['clone', upstream, repo]);
28
- // Give the clone a repo-local committer identity. Production workspaces
29
- // always have one (every commitFile commit relies on it), but the bare CI
30
- // runner has no global git identity — without this, a `pull --rebase` that
31
- // has to *replay* a local commit (the autostash regression below) dies with
32
- // "empty ident name". `GitService.git` runs git with the ambient env, so the
33
- // identity must live in the repo, not just in this test's runGit env.
34
- await runGit(repo, ['config', 'user.email', 'workspace@bevel.test']);
35
- await runGit(repo, ['config', 'user.name', 'bevel Workspace']);
36
- return { upstream, repo };
37
- }
38
-
39
- /** Advance origin by one commit so a pull actually has something to fast-forward. */
40
- async function advanceOrigin(root: string, upstream: string): Promise<void> {
41
- const pusher = path.join(root, '.pusher');
42
- await runGit(root, ['clone', upstream, pusher]);
43
- await fs.writeFile(path.join(pusher, 'remote-change.txt'), 'from origin\n');
44
- await runGit(pusher, ['add', '.']);
45
- await runGit(pusher, ['commit', '-m', 'remote change']);
46
- await runGit(pusher, ['push', 'origin', 'target-company-state']);
47
- }
48
-
49
- describe('GitService.pull', () => {
50
- let root: string;
51
- const workspaceId = 'target-company-state';
52
-
53
- beforeEach(async () => {
54
- root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-pull-'));
55
- });
56
-
57
- afterEach(async () => {
58
- await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
59
- });
60
-
61
- // Regression: a hand edit to the tracked branch can append a second
62
- // `branch.<name>.merge` line, after which a bare `pull --rebase` dies with
63
- // "Cannot rebase onto multiple branches". The explicit `pull origin <branch>`
64
- // plus upstream self-heal must recover and actually pull origin's commit.
65
- it('recovers from a multi-valued branch.<name>.merge config', async () => {
66
- const { upstream, repo } = await seedWorkspace(root, workspaceId);
67
- await advanceOrigin(root, upstream);
68
-
69
- // Corrupt the upstream exactly as an out-of-band re-point would: a second
70
- // merge ref bolted onto the existing one.
71
- await runGit(repo, [
72
- 'config', '--add', 'branch.target-company-state.merge', 'refs/heads/some-other-branch',
73
- ]);
74
- const mergeRefsBefore = (await gitOut(repo, [
75
- 'config', '--get-all', 'branch.target-company-state.merge',
76
- ])).split('\n').filter(Boolean);
77
- expect(mergeRefsBefore.length).toBe(2);
78
-
79
- const svc = new GitService(
80
- stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
81
- stubWorkflowHooks(),
82
- 'knowledge-base',
83
- );
84
-
85
- await expect(svc.pull(workspaceId)).resolves.toBeUndefined();
86
-
87
- // The pull landed origin's commit...
88
- const head = await gitOut(repo, ['log', '-1', '--pretty=%s']);
89
- expect(head).toBe('remote change');
90
- const pulled = await fs.readFile(path.join(repo, 'remote-change.txt'), 'utf8');
91
- expect(pulled.trim()).toBe('from origin');
92
-
93
- // ...and the config self-healed back to a single merge ref.
94
- const mergeRefsAfter = (await gitOut(repo, [
95
- 'config', '--get-all', 'branch.target-company-state.merge',
96
- ])).split('\n').filter(Boolean);
97
- expect(mergeRefsAfter).toEqual(['refs/heads/target-company-state']);
98
- });
99
-
100
- // Regression: the same clone can also accumulate a SECOND
101
- // `remote.origin.fetch` refspec — a re-run `git remote add`, a
102
- // `git remote set-branches --add`, a hand-edited `.git/config` — after which
103
- // one fetch maps the branch in twice and the refresh dies with "Cannot
104
- // rebase onto multiple branches". The pull must land origin's commit anyway
105
- // and collapse the refspec list back to the single canonical one.
106
- it('recovers from duplicated remote.origin.fetch refspecs', async () => {
107
- const { upstream, repo } = await seedWorkspace(root, workspaceId);
108
- await advanceOrigin(root, upstream);
109
-
110
- await runGit(repo, [
111
- 'config', '--add', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*',
112
- ]);
113
- await runGit(repo, [
114
- 'config', '--add', 'remote.origin.fetch',
115
- '+refs/heads/target-company-state:refs/remotes/origin/target-company-state',
116
- ]);
117
- const refspecsBefore = (await gitOut(repo, [
118
- 'config', '--get-all', 'remote.origin.fetch',
119
- ])).split('\n').filter(Boolean);
120
- expect(refspecsBefore.length).toBe(3);
121
-
122
- const svc = new GitService(
123
- stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
124
- stubWorkflowHooks(),
125
- 'knowledge-base',
126
- );
127
-
128
- await expect(svc.pull(workspaceId)).resolves.toBeUndefined();
129
-
130
- // Origin's commit landed...
131
- expect(await gitOut(repo, ['log', '-1', '--pretty=%s'])).toBe('remote change');
132
- // ...and the clone fetches through exactly one refspec again.
133
- const refspecsAfter = (await gitOut(repo, [
134
- 'config', '--get-all', 'remote.origin.fetch',
135
- ])).split('\n').filter(Boolean);
136
- expect(refspecsAfter).toEqual(['+refs/heads/*:refs/remotes/origin/*']);
137
- });
138
-
139
- // The invariant behind the reported failure: `.git/FETCH_HEAD` is a single
140
- // mutable file shared by every git process in the clone, and this app runs
141
- // fetches on the same clone from paths that don't share the workspace mutex.
142
- // A refresh that reads it can be steered by any of them; one that writes it
143
- // can steer them. So the refresh must do neither.
144
- it('neither reads nor writes the shared FETCH_HEAD', async () => {
145
- const { upstream, repo } = await seedWorkspace(root, workspaceId);
146
- await advanceOrigin(root, upstream);
147
-
148
- // A hostile FETCH_HEAD: two for-merge entries, the first pointing at an
149
- // unrelated commit. This is what a concurrent bare `git fetch origin`
150
- // leaves on a clone whose `branch.<name>.merge` has drifted to two values
151
- // — and what made `git pull --rebase` die with "Cannot rebase onto
152
- // multiple branches".
153
- // The stray entry must name a commit the refresh can never legitimately
154
- // land on — this clone hasn't fetched yet, so plain `HEAD` still equals
155
- // `origin/target-company-state` and the assertion below would pass even if
156
- // FETCH_HEAD had been honoured. Park an unrelated commit in the object
157
- // store instead, then step off it.
158
- await runGit(repo, ['commit', '--allow-empty', '-m', 'stray']);
159
- const strayCommit = await gitOut(repo, ['rev-parse', 'HEAD']);
160
- await runGit(repo, ['reset', '--hard', 'HEAD~1']);
161
- const originTip = await gitOut(repo, ['rev-parse', 'origin/target-company-state']);
162
- const hostile =
163
- `${strayCommit}\t\tbranch 'some-other-branch' of ${upstream}\n` +
164
- `${originTip}\t\tbranch 'target-company-state' of ${upstream}\n`;
165
- const fetchHeadPath = path.join(repo, '.git', 'FETCH_HEAD');
166
- await fs.writeFile(fetchHeadPath, hostile);
167
-
168
- const svc = new GitService(
169
- stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
170
- stubWorkflowHooks(),
171
- 'knowledge-base',
172
- );
173
-
174
- await expect(svc.pull(workspaceId)).resolves.toBeUndefined();
175
-
176
- // Landed origin's commit — not the stray head the hostile file names.
177
- expect(await gitOut(repo, ['log', '-1', '--pretty=%s'])).toBe('remote change');
178
- // ...and left the shared file exactly as it found it.
179
- expect(await fs.readFile(fetchHeadPath, 'utf8')).toBe(hostile);
180
- });
181
-
182
- // Regression (BEVA-114): the cooperative push recovery pulls --rebase to
183
- // integrate a diverged origin before retrying the push. A bare
184
- // `pull --rebase` aborts outright when the working tree carries a modified
185
- // tracked file ("cannot pull with rebase: You have unstaged changes") —
186
- // exactly the state the validator's dashboard regeneration leaves behind —
187
- // which strands the branch in an unrecoverable non-fast-forward loop.
188
- // `--autostash` must stash the dirty edit, rebase the local commit onto
189
- // origin, and reapply the edit so the retry push can fast-forward.
190
- it('rebases past a dirty tracked file via autostash', async () => {
191
- const { upstream, repo } = await seedWorkspace(root, workspaceId);
192
-
193
- // A tracked, generated-style artifact shared by both sides as the base.
194
- await fs.writeFile(path.join(repo, 'dash.html'), 'v0\n');
195
- await runGit(repo, ['add', '.']);
196
- await runGit(repo, ['commit', '-m', 'add dashboard']);
197
- await runGit(repo, ['push', 'origin', 'target-company-state']);
198
-
199
- // Origin advances independently...
200
- await advanceOrigin(root, upstream);
201
-
202
- // ...and so does the local branch → the two have diverged and a real
203
- // rebase (not a fast-forward) is required to reconcile them.
204
- await fs.writeFile(path.join(repo, 'local-change.txt'), 'local\n');
205
- await runGit(repo, ['add', '.']);
206
- await runGit(repo, ['commit', '-m', 'local change']);
207
-
208
- // The validator-style regeneration leaves a modified tracked file dirty in
209
- // the working tree — the exact condition that makes a bare `pull --rebase`
210
- // abort with "you have unstaged changes".
211
- await fs.writeFile(path.join(repo, 'dash.html'), 'v1\n');
212
-
213
- const svc = new GitService(
214
- stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
215
- stubWorkflowHooks(),
216
- 'knowledge-base',
217
- );
218
-
219
- await expect(svc.pull(workspaceId)).resolves.toBeUndefined();
220
-
221
- // Both commits are present — the local commit was rebased onto origin's.
222
- const log = await gitOut(repo, ['log', '--pretty=%s']);
223
- expect(log).toContain('remote change');
224
- expect(log).toContain('local change');
225
- const remote = await fs.readFile(path.join(repo, 'remote-change.txt'), 'utf8');
226
- expect(remote.trim()).toBe('from origin');
227
-
228
- // The dirty tracked edit survived — autostash reapplied it after the rebase.
229
- const dash = await fs.readFile(path.join(repo, 'dash.html'), 'utf8');
230
- expect(dash).toBe('v1\n');
231
- });
232
- });
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 { GitService } from '../git.service.js';
6
+ import { PullRebaseConflictError } from '../../workflow.errors.js';
7
+ import { runGit, gitOut, stubWorkflowHooks, stubWorkspaceService } from './git-test-helpers.js';
8
+
9
+ /** See git.service.createBranch.test.ts — same prod-mirroring layout. */
10
+ async function seedWorkspace(root: string, workspaceId: string): Promise<{
11
+ upstream: string;
12
+ repo: string;
13
+ }> {
14
+ const upstream = path.join(root, 'upstream.git');
15
+ await runGit(root, ['init', '--bare', '-b', 'target-company-state', upstream]);
16
+
17
+ const seed = path.join(root, '.seed');
18
+ await fs.mkdir(seed);
19
+ await runGit(seed, ['init', '-b', 'target-company-state']);
20
+ await runGit(seed, ['remote', 'add', 'origin', upstream]);
21
+ await runGit(seed, ['commit', '--allow-empty', '-m', 'init']);
22
+ await runGit(seed, ['push', 'origin', 'target-company-state']);
23
+ await runGit(upstream, ['symbolic-ref', 'HEAD', 'refs/heads/target-company-state']);
24
+
25
+ const workspaceDir = path.join(root, workspaceId);
26
+ const repo = path.join(workspaceDir, 'knowledge-base');
27
+ await fs.mkdir(workspaceDir, { recursive: true });
28
+ await runGit(root, ['clone', upstream, repo]);
29
+ // Give the clone a repo-local committer identity. Production workspaces
30
+ // always have one (every commitFile commit relies on it), but the bare CI
31
+ // runner has no global git identity — without this, a `pull --rebase` that
32
+ // has to *replay* a local commit (the autostash regression below) dies with
33
+ // "empty ident name". `GitService.git` runs git with the ambient env, so the
34
+ // identity must live in the repo, not just in this test's runGit env.
35
+ await runGit(repo, ['config', 'user.email', 'workspace@bevel.test']);
36
+ await runGit(repo, ['config', 'user.name', 'bevel Workspace']);
37
+ return { upstream, repo };
38
+ }
39
+
40
+ /** Advance origin by one commit so a pull actually has something to fast-forward. */
41
+ async function advanceOrigin(root: string, upstream: string): Promise<void> {
42
+ const pusher = path.join(root, '.pusher');
43
+ await runGit(root, ['clone', upstream, pusher]);
44
+ await fs.writeFile(path.join(pusher, 'remote-change.txt'), 'from origin\n');
45
+ await runGit(pusher, ['add', '.']);
46
+ await runGit(pusher, ['commit', '-m', 'remote change']);
47
+ await runGit(pusher, ['push', 'origin', 'target-company-state']);
48
+ }
49
+
50
+ describe('GitService.pull', () => {
51
+ let root: string;
52
+ const workspaceId = 'target-company-state';
53
+
54
+ beforeEach(async () => {
55
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-pull-'));
56
+ });
57
+
58
+ afterEach(async () => {
59
+ await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
60
+ });
61
+
62
+ // Regression: a hand edit to the tracked branch can append a second
63
+ // `branch.<name>.merge` line, after which a bare `pull --rebase` dies with
64
+ // "Cannot rebase onto multiple branches". The explicit `pull origin <branch>`
65
+ // plus upstream self-heal must recover and actually pull origin's commit.
66
+ it('recovers from a multi-valued branch.<name>.merge config', async () => {
67
+ const { upstream, repo } = await seedWorkspace(root, workspaceId);
68
+ await advanceOrigin(root, upstream);
69
+
70
+ // Corrupt the upstream exactly as an out-of-band re-point would: a second
71
+ // merge ref bolted onto the existing one.
72
+ await runGit(repo, [
73
+ 'config', '--add', 'branch.target-company-state.merge', 'refs/heads/some-other-branch',
74
+ ]);
75
+ const mergeRefsBefore = (await gitOut(repo, [
76
+ 'config', '--get-all', 'branch.target-company-state.merge',
77
+ ])).split('\n').filter(Boolean);
78
+ expect(mergeRefsBefore.length).toBe(2);
79
+
80
+ const svc = new GitService(
81
+ stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
82
+ stubWorkflowHooks(),
83
+ 'knowledge-base',
84
+ );
85
+
86
+ await expect(svc.pull(workspaceId)).resolves.toBeUndefined();
87
+
88
+ // The pull landed origin's commit...
89
+ const head = await gitOut(repo, ['log', '-1', '--pretty=%s']);
90
+ expect(head).toBe('remote change');
91
+ const pulled = await fs.readFile(path.join(repo, 'remote-change.txt'), 'utf8');
92
+ expect(pulled.trim()).toBe('from origin');
93
+
94
+ // ...and the config self-healed back to a single merge ref.
95
+ const mergeRefsAfter = (await gitOut(repo, [
96
+ 'config', '--get-all', 'branch.target-company-state.merge',
97
+ ])).split('\n').filter(Boolean);
98
+ expect(mergeRefsAfter).toEqual(['refs/heads/target-company-state']);
99
+ });
100
+
101
+ // Regression: the same clone can also accumulate a SECOND
102
+ // `remote.origin.fetch` refspec — a re-run `git remote add`, a
103
+ // `git remote set-branches --add`, a hand-edited `.git/config` — after which
104
+ // one fetch maps the branch in twice and the refresh dies with "Cannot
105
+ // rebase onto multiple branches". The pull must land origin's commit anyway
106
+ // and collapse the refspec list back to the single canonical one.
107
+ it('recovers from duplicated remote.origin.fetch refspecs', async () => {
108
+ const { upstream, repo } = await seedWorkspace(root, workspaceId);
109
+ await advanceOrigin(root, upstream);
110
+
111
+ await runGit(repo, [
112
+ 'config', '--add', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*',
113
+ ]);
114
+ await runGit(repo, [
115
+ 'config', '--add', 'remote.origin.fetch',
116
+ '+refs/heads/target-company-state:refs/remotes/origin/target-company-state',
117
+ ]);
118
+ const refspecsBefore = (await gitOut(repo, [
119
+ 'config', '--get-all', 'remote.origin.fetch',
120
+ ])).split('\n').filter(Boolean);
121
+ expect(refspecsBefore.length).toBe(3);
122
+
123
+ const svc = new GitService(
124
+ stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
125
+ stubWorkflowHooks(),
126
+ 'knowledge-base',
127
+ );
128
+
129
+ await expect(svc.pull(workspaceId)).resolves.toBeUndefined();
130
+
131
+ // Origin's commit landed...
132
+ expect(await gitOut(repo, ['log', '-1', '--pretty=%s'])).toBe('remote change');
133
+ // ...and the clone fetches through exactly one refspec again.
134
+ const refspecsAfter = (await gitOut(repo, [
135
+ 'config', '--get-all', 'remote.origin.fetch',
136
+ ])).split('\n').filter(Boolean);
137
+ expect(refspecsAfter).toEqual(['+refs/heads/*:refs/remotes/origin/*']);
138
+ });
139
+
140
+ // The invariant behind the reported failure: `.git/FETCH_HEAD` is a single
141
+ // mutable file shared by every git process in the clone, and this app runs
142
+ // fetches on the same clone from paths that don't share the workspace mutex.
143
+ // A refresh that reads it can be steered by any of them; one that writes it
144
+ // can steer them. So the refresh must do neither.
145
+ it('neither reads nor writes the shared FETCH_HEAD', async () => {
146
+ const { upstream, repo } = await seedWorkspace(root, workspaceId);
147
+ await advanceOrigin(root, upstream);
148
+
149
+ // A hostile FETCH_HEAD: two for-merge entries, the first pointing at an
150
+ // unrelated commit. This is what a concurrent bare `git fetch origin`
151
+ // leaves on a clone whose `branch.<name>.merge` has drifted to two values
152
+ // — and what made `git pull --rebase` die with "Cannot rebase onto
153
+ // multiple branches".
154
+ // The stray entry must name a commit the refresh can never legitimately
155
+ // land on — this clone hasn't fetched yet, so plain `HEAD` still equals
156
+ // `origin/target-company-state` and the assertion below would pass even if
157
+ // FETCH_HEAD had been honoured. Park an unrelated commit in the object
158
+ // store instead, then step off it.
159
+ await runGit(repo, ['commit', '--allow-empty', '-m', 'stray']);
160
+ const strayCommit = await gitOut(repo, ['rev-parse', 'HEAD']);
161
+ await runGit(repo, ['reset', '--hard', 'HEAD~1']);
162
+ const originTip = await gitOut(repo, ['rev-parse', 'origin/target-company-state']);
163
+ const hostile =
164
+ `${strayCommit}\t\tbranch 'some-other-branch' of ${upstream}\n` +
165
+ `${originTip}\t\tbranch 'target-company-state' of ${upstream}\n`;
166
+ const fetchHeadPath = path.join(repo, '.git', 'FETCH_HEAD');
167
+ await fs.writeFile(fetchHeadPath, hostile);
168
+
169
+ const svc = new GitService(
170
+ stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
171
+ stubWorkflowHooks(),
172
+ 'knowledge-base',
173
+ );
174
+
175
+ await expect(svc.pull(workspaceId)).resolves.toBeUndefined();
176
+
177
+ // Landed origin's commit — not the stray head the hostile file names.
178
+ expect(await gitOut(repo, ['log', '-1', '--pretty=%s'])).toBe('remote change');
179
+ // ...and left the shared file exactly as it found it.
180
+ expect(await fs.readFile(fetchHeadPath, 'utf8')).toBe(hostile);
181
+ });
182
+
183
+ // Regression (BEVA-114): the cooperative push recovery pulls --rebase to
184
+ // integrate a diverged origin before retrying the push. A bare
185
+ // `pull --rebase` aborts outright when the working tree carries a modified
186
+ // tracked file ("cannot pull with rebase: You have unstaged changes") —
187
+ // exactly the state the validator's dashboard regeneration leaves behind —
188
+ // which strands the branch in an unrecoverable non-fast-forward loop.
189
+ // `--autostash` must stash the dirty edit, rebase the local commit onto
190
+ // origin, and reapply the edit so the retry push can fast-forward.
191
+ it('rebases past a dirty tracked file via autostash', async () => {
192
+ const { upstream, repo } = await seedWorkspace(root, workspaceId);
193
+
194
+ // A tracked, generated-style artifact shared by both sides as the base.
195
+ await fs.writeFile(path.join(repo, 'dash.html'), 'v0\n');
196
+ await runGit(repo, ['add', '.']);
197
+ await runGit(repo, ['commit', '-m', 'add dashboard']);
198
+ await runGit(repo, ['push', 'origin', 'target-company-state']);
199
+
200
+ // Origin advances independently...
201
+ await advanceOrigin(root, upstream);
202
+
203
+ // ...and so does the local branch → the two have diverged and a real
204
+ // rebase (not a fast-forward) is required to reconcile them.
205
+ await fs.writeFile(path.join(repo, 'local-change.txt'), 'local\n');
206
+ await runGit(repo, ['add', '.']);
207
+ await runGit(repo, ['commit', '-m', 'local change']);
208
+
209
+ // The validator-style regeneration leaves a modified tracked file dirty in
210
+ // the working tree — the exact condition that makes a bare `pull --rebase`
211
+ // abort with "you have unstaged changes".
212
+ await fs.writeFile(path.join(repo, 'dash.html'), 'v1\n');
213
+
214
+ const svc = new GitService(
215
+ stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
216
+ stubWorkflowHooks(),
217
+ 'knowledge-base',
218
+ );
219
+
220
+ await expect(svc.pull(workspaceId)).resolves.toBeUndefined();
221
+
222
+ // Both commits are present — the local commit was rebased onto origin's.
223
+ const log = await gitOut(repo, ['log', '--pretty=%s']);
224
+ expect(log).toContain('remote change');
225
+ expect(log).toContain('local change');
226
+ const remote = await fs.readFile(path.join(repo, 'remote-change.txt'), 'utf8');
227
+ expect(remote.trim()).toBe('from origin');
228
+
229
+ // The dirty tracked edit survived — autostash reapplied it after the rebase.
230
+ const dash = await fs.readFile(path.join(repo, 'dash.html'), 'utf8');
231
+ expect(dash).toBe('v1\n');
232
+ });
233
+
234
+ // The production stuck-workspace state: a local commit and an origin commit
235
+ // both rewrote the same file, so replaying the local commit conflicts. The
236
+ // pull must (a) abort the rebase so the clone isn't left in a detached
237
+ // apply state, (b) surface a TYPED error naming the contested paths so the
238
+ // workflow layer can queue background recovery, and (c) leave the local
239
+ // commit intact (it's someone's saved content).
240
+ it('throws PullRebaseConflictError with the conflicted paths and aborts the rebase cleanly', async () => {
241
+ const { upstream, repo } = await seedWorkspace(root, workspaceId);
242
+
243
+ // Shared base version of the contested file.
244
+ await fs.writeFile(path.join(repo, 'PR-7.html'), 'base\n');
245
+ await runGit(repo, ['add', '.']);
246
+ await runGit(repo, ['commit', '-m', 'add overview']);
247
+ await runGit(repo, ['push', 'origin', 'target-company-state']);
248
+
249
+ // Origin rewrites it one way...
250
+ const pusher = path.join(root, '.conflict-pusher');
251
+ await runGit(root, ['clone', upstream, pusher]);
252
+ await fs.writeFile(path.join(pusher, 'PR-7.html'), 'origin version\n');
253
+ await runGit(pusher, ['add', '.']);
254
+ await runGit(pusher, ['commit', '-m', 'origin rewrite']);
255
+ await runGit(pusher, ['push', 'origin', 'target-company-state']);
256
+
257
+ // ...and the local branch rewrites it another way (the stranded commit).
258
+ await fs.writeFile(path.join(repo, 'PR-7.html'), 'local version\n');
259
+ await runGit(repo, ['add', '.']);
260
+ await runGit(repo, ['commit', '-m', 'local rewrite']);
261
+ const localHead = await gitOut(repo, ['rev-parse', 'HEAD']);
262
+
263
+ const svc = new GitService(
264
+ stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
265
+ stubWorkflowHooks(),
266
+ 'knowledge-base',
267
+ );
268
+
269
+ const err = await svc.pull(workspaceId).then(() => null).catch((e: unknown) => e);
270
+ expect(err).toBeInstanceOf(PullRebaseConflictError);
271
+ expect((err as PullRebaseConflictError).branch).toBe('target-company-state');
272
+ expect((err as PullRebaseConflictError).conflictedPaths).toEqual(['PR-7.html']);
273
+
274
+ // The rebase was aborted: no in-progress rebase state, clean tree, and
275
+ // the local commit is still HEAD with its version of the file on disk.
276
+ await expect(
277
+ fs.access(path.join(repo, '.git', 'rebase-merge')),
278
+ ).rejects.toMatchObject({ code: 'ENOENT' });
279
+ expect(await gitOut(repo, ['status', '--porcelain'])).toBe('');
280
+ expect(await gitOut(repo, ['rev-parse', 'HEAD'])).toBe(localHead);
281
+ // \r tolerated: Windows checkouts with autocrlf rewrite the file on the
282
+ // abort's checkout — content identity is what matters here.
283
+ const onDisk = await fs.readFile(path.join(repo, 'PR-7.html'), 'utf8');
284
+ expect(onDisk.replace(/\r/g, '')).toBe('local version\n');
285
+ });
286
+
287
+ // hasUnpushedCommits backs the worker's no-op arm (see runPendingCommit).
288
+ it('hasUnpushedCommits: false when in sync, true once a local commit lands', async () => {
289
+ const { repo } = await seedWorkspace(root, workspaceId);
290
+ const svc = new GitService(
291
+ stubWorkspaceService({ [workspaceId]: path.join(root, workspaceId) }),
292
+ stubWorkflowHooks(),
293
+ 'knowledge-base',
294
+ );
295
+
296
+ await expect(svc.hasUnpushedCommits(workspaceId)).resolves.toBe(false);
297
+
298
+ await fs.writeFile(path.join(repo, 'new.txt'), 'x\n');
299
+ await runGit(repo, ['add', '.']);
300
+ await runGit(repo, ['commit', '-m', 'unpushed']);
301
+ await expect(svc.hasUnpushedCommits(workspaceId)).resolves.toBe(true);
302
+
303
+ await runGit(repo, ['push', 'origin', 'target-company-state']);
304
+ await expect(svc.hasUnpushedCommits(workspaceId)).resolves.toBe(false);
305
+ });
306
+ });