@renseiai/agentfactory 0.8.12 → 0.8.13

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 (101) hide show
  1. package/dist/src/config/repository-config.d.ts +24 -0
  2. package/dist/src/config/repository-config.d.ts.map +1 -1
  3. package/dist/src/config/repository-config.js +21 -0
  4. package/dist/src/config/repository-config.test.js +202 -0
  5. package/dist/src/governor/decision-engine.d.ts +2 -0
  6. package/dist/src/governor/decision-engine.d.ts.map +1 -1
  7. package/dist/src/governor/decision-engine.js +7 -0
  8. package/dist/src/governor/decision-engine.test.js +63 -0
  9. package/dist/src/governor/governor-types.d.ts +2 -1
  10. package/dist/src/governor/governor-types.d.ts.map +1 -1
  11. package/dist/src/index.d.ts +1 -0
  12. package/dist/src/index.d.ts.map +1 -1
  13. package/dist/src/index.js +1 -0
  14. package/dist/src/merge-queue/conflict-resolver.d.ts +62 -0
  15. package/dist/src/merge-queue/conflict-resolver.d.ts.map +1 -0
  16. package/dist/src/merge-queue/conflict-resolver.js +168 -0
  17. package/dist/src/merge-queue/conflict-resolver.test.d.ts +2 -0
  18. package/dist/src/merge-queue/conflict-resolver.test.d.ts.map +1 -0
  19. package/dist/src/merge-queue/conflict-resolver.test.js +405 -0
  20. package/dist/src/merge-queue/lock-file-regeneration.d.ts +14 -0
  21. package/dist/src/merge-queue/lock-file-regeneration.d.ts.map +1 -0
  22. package/dist/src/merge-queue/lock-file-regeneration.js +82 -0
  23. package/dist/src/merge-queue/lock-file-regeneration.test.d.ts +2 -0
  24. package/dist/src/merge-queue/lock-file-regeneration.test.d.ts.map +1 -0
  25. package/dist/src/merge-queue/lock-file-regeneration.test.js +236 -0
  26. package/dist/src/merge-queue/merge-worker.d.ts +79 -0
  27. package/dist/src/merge-queue/merge-worker.d.ts.map +1 -0
  28. package/dist/src/merge-queue/merge-worker.js +221 -0
  29. package/dist/src/merge-queue/merge-worker.test.d.ts +2 -0
  30. package/dist/src/merge-queue/merge-worker.test.d.ts.map +1 -0
  31. package/dist/src/merge-queue/merge-worker.test.js +883 -0
  32. package/dist/src/merge-queue/strategies/index.d.ts +19 -0
  33. package/dist/src/merge-queue/strategies/index.d.ts.map +1 -0
  34. package/dist/src/merge-queue/strategies/index.js +30 -0
  35. package/dist/src/merge-queue/strategies/merge-commit-strategy.d.ts +14 -0
  36. package/dist/src/merge-queue/strategies/merge-commit-strategy.d.ts.map +1 -0
  37. package/dist/src/merge-queue/strategies/merge-commit-strategy.js +58 -0
  38. package/dist/src/merge-queue/strategies/rebase-strategy.d.ts +14 -0
  39. package/dist/src/merge-queue/strategies/rebase-strategy.d.ts.map +1 -0
  40. package/dist/src/merge-queue/strategies/rebase-strategy.js +62 -0
  41. package/dist/src/merge-queue/strategies/squash-strategy.d.ts +14 -0
  42. package/dist/src/merge-queue/strategies/squash-strategy.d.ts.map +1 -0
  43. package/dist/src/merge-queue/strategies/squash-strategy.js +59 -0
  44. package/dist/src/merge-queue/strategies/strategies.test.d.ts +2 -0
  45. package/dist/src/merge-queue/strategies/strategies.test.d.ts.map +1 -0
  46. package/dist/src/merge-queue/strategies/strategies.test.js +354 -0
  47. package/dist/src/merge-queue/strategies/types.d.ts +62 -0
  48. package/dist/src/merge-queue/strategies/types.d.ts.map +1 -0
  49. package/dist/src/merge-queue/strategies/types.js +7 -0
  50. package/dist/src/orchestrator/parse-work-result.d.ts.map +1 -1
  51. package/dist/src/orchestrator/parse-work-result.js +22 -0
  52. package/dist/src/orchestrator/parse-work-result.test.js +49 -0
  53. package/dist/src/providers/index.d.ts +1 -0
  54. package/dist/src/providers/index.d.ts.map +1 -1
  55. package/dist/src/providers/plugin-types.d.ts +177 -0
  56. package/dist/src/providers/plugin-types.d.ts.map +1 -0
  57. package/dist/src/providers/plugin-types.js +10 -0
  58. package/dist/src/providers/plugin-types.test.d.ts +2 -0
  59. package/dist/src/providers/plugin-types.test.d.ts.map +1 -0
  60. package/dist/src/providers/plugin-types.test.js +810 -0
  61. package/dist/src/registry/index.d.ts +4 -0
  62. package/dist/src/registry/index.d.ts.map +1 -0
  63. package/dist/src/registry/index.js +2 -0
  64. package/dist/src/registry/loader.d.ts +25 -0
  65. package/dist/src/registry/loader.d.ts.map +1 -0
  66. package/dist/src/registry/loader.js +88 -0
  67. package/dist/src/registry/node-type-registry.d.ts +52 -0
  68. package/dist/src/registry/node-type-registry.d.ts.map +1 -0
  69. package/dist/src/registry/node-type-registry.js +130 -0
  70. package/dist/src/registry/types.d.ts +65 -0
  71. package/dist/src/registry/types.d.ts.map +1 -0
  72. package/dist/src/registry/types.js +10 -0
  73. package/dist/src/workflow/expression/ast.d.ts +1 -1
  74. package/dist/src/workflow/expression/ast.d.ts.map +1 -1
  75. package/dist/src/workflow/expression/context.d.ts +4 -0
  76. package/dist/src/workflow/expression/context.d.ts.map +1 -1
  77. package/dist/src/workflow/expression/context.js +5 -1
  78. package/dist/src/workflow/expression/evaluator.d.ts.map +1 -1
  79. package/dist/src/workflow/expression/evaluator.js +24 -1
  80. package/dist/src/workflow/expression/evaluator.test.js +174 -0
  81. package/dist/src/workflow/expression/expression.test.js +140 -1
  82. package/dist/src/workflow/expression/helpers.d.ts +4 -0
  83. package/dist/src/workflow/expression/helpers.d.ts.map +1 -1
  84. package/dist/src/workflow/expression/helpers.js +51 -0
  85. package/dist/src/workflow/expression/index.d.ts +14 -0
  86. package/dist/src/workflow/expression/index.d.ts.map +1 -1
  87. package/dist/src/workflow/expression/index.js +28 -1
  88. package/dist/src/workflow/expression/lexer.d.ts.map +1 -1
  89. package/dist/src/workflow/expression/lexer.js +43 -0
  90. package/dist/src/workflow/expression/parser.js +1 -1
  91. package/dist/src/workflow/index.d.ts +3 -3
  92. package/dist/src/workflow/index.d.ts.map +1 -1
  93. package/dist/src/workflow/index.js +4 -2
  94. package/dist/src/workflow/workflow-loader.d.ts +8 -2
  95. package/dist/src/workflow/workflow-loader.d.ts.map +1 -1
  96. package/dist/src/workflow/workflow-loader.js +21 -2
  97. package/dist/src/workflow/workflow-types.d.ts +781 -12
  98. package/dist/src/workflow/workflow-types.d.ts.map +1 -1
  99. package/dist/src/workflow/workflow-types.js +248 -3
  100. package/dist/src/workflow/workflow-types.test.js +621 -1
  101. package/package.json +3 -2
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conflict-resolver.test.d.ts","sourceRoot":"","sources":["../../../src/merge-queue/conflict-resolver.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,405 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('child_process', () => ({
3
+ exec: vi.fn(),
4
+ }));
5
+ import { exec } from 'child_process';
6
+ import { ConflictResolver } from './conflict-resolver.js';
7
+ const mockExec = vi.mocked(exec);
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+ function makeContext(overrides = {}) {
12
+ return {
13
+ repoPath: '/repo',
14
+ worktreePath: '/repo/.worktrees/wt-1',
15
+ sourceBranch: 'feature/SUP-100',
16
+ targetBranch: 'main',
17
+ prNumber: 42,
18
+ issueIdentifier: 'SUP-100',
19
+ conflictFiles: ['src/index.ts', 'src/utils.ts'],
20
+ ...overrides,
21
+ };
22
+ }
23
+ function makeConfig(overrides = {}) {
24
+ return {
25
+ mergirafEnabled: true,
26
+ escalationStrategy: 'notify',
27
+ ...overrides,
28
+ };
29
+ }
30
+ /**
31
+ * Set up mockExec to respond to sequential calls.
32
+ * Each entry maps a pattern (substring match on the command) to a response.
33
+ * Commands not matching any pattern will reject with an error.
34
+ */
35
+ function mockExecSequence(responses) {
36
+ const queue = [...responses];
37
+ mockExec.mockImplementation((cmd, _opts, callback) => {
38
+ const cb = typeof _opts === 'function' ? _opts : callback;
39
+ const cmdStr = typeof cmd === 'string' ? cmd : '';
40
+ const idx = queue.findIndex((r) => cmdStr.includes(r.pattern));
41
+ if (idx >= 0) {
42
+ const match = queue[idx];
43
+ queue.splice(idx, 1);
44
+ if (match.error) {
45
+ cb?.(match.error, { stdout: match.stdout ?? '', stderr: match.stderr ?? '' });
46
+ }
47
+ else {
48
+ cb?.(null, { stdout: match.stdout ?? '', stderr: match.stderr ?? '' });
49
+ }
50
+ }
51
+ else {
52
+ cb?.(null, { stdout: '', stderr: '' });
53
+ }
54
+ return {};
55
+ });
56
+ }
57
+ /**
58
+ * Simple mock where all exec calls succeed with empty stdout.
59
+ */
60
+ function mockExecSuccess() {
61
+ mockExec.mockImplementation((_cmd, _opts, callback) => {
62
+ const cb = typeof _opts === 'function' ? _opts : callback;
63
+ cb?.(null, { stdout: '', stderr: '' });
64
+ return {};
65
+ });
66
+ }
67
+ /**
68
+ * Mock that routes grep calls (conflict marker checks) to return specific results,
69
+ * and allows other commands (git add, git rebase, git diff) to succeed.
70
+ */
71
+ function mockConflictChecks(fileResults, opts) {
72
+ mockExec.mockImplementation((cmd, _opts, callback) => {
73
+ const cb = typeof _opts === 'function' ? _opts : callback;
74
+ const cmdStr = typeof cmd === 'string' ? cmd : '';
75
+ if (cmdStr.includes('grep -c')) {
76
+ // Find which file this grep is checking
77
+ const file = Object.keys(fileResults).find((f) => cmdStr.includes(`"${f}"`));
78
+ if (file !== undefined) {
79
+ if (fileResults[file]) {
80
+ // Has conflict markers — grep returns count > 0
81
+ cb?.(null, { stdout: '3\n', stderr: '' });
82
+ }
83
+ else {
84
+ // No conflict markers — grep exits with code 1
85
+ cb?.(new Error('grep: exit code 1'), { stdout: '0\n', stderr: '' });
86
+ }
87
+ }
88
+ else {
89
+ cb?.(new Error('grep: exit code 1'), { stdout: '0\n', stderr: '' });
90
+ }
91
+ }
92
+ else if (cmdStr.includes('git rebase --continue')) {
93
+ if (opts?.rebaseError) {
94
+ cb?.(opts.rebaseError, { stdout: '', stderr: opts.rebaseError.message });
95
+ }
96
+ else {
97
+ cb?.(null, { stdout: '', stderr: '' });
98
+ }
99
+ }
100
+ else if (cmdStr.includes('git diff')) {
101
+ cb?.(null, { stdout: opts?.diffOutput ?? 'diff --git a/file\n+change', stderr: '' });
102
+ }
103
+ else {
104
+ // git add or other commands succeed
105
+ cb?.(null, { stdout: '', stderr: '' });
106
+ }
107
+ return {};
108
+ });
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // Tests
112
+ // ---------------------------------------------------------------------------
113
+ describe('ConflictResolver', () => {
114
+ beforeEach(() => {
115
+ vi.clearAllMocks();
116
+ });
117
+ // -------------------------------------------------------------------------
118
+ // resolve() routing
119
+ // -------------------------------------------------------------------------
120
+ describe('resolve()', () => {
121
+ it('goes straight to escalation when mergiraf is disabled', async () => {
122
+ mockExecSuccess();
123
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false }));
124
+ const ctx = makeContext();
125
+ const result = await resolver.resolve(ctx);
126
+ expect(result.method).toBe('escalation');
127
+ expect(result.escalationAction).toBe('notify');
128
+ expect(result.unresolvedFiles).toEqual(ctx.conflictFiles);
129
+ // Should never call grep for conflict markers
130
+ const grepCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('grep'));
131
+ expect(grepCalls).toHaveLength(0);
132
+ });
133
+ it('attempts mergiraf auto-resolution first when enabled', async () => {
134
+ // All files have conflicts — mergiraf fails to resolve
135
+ mockConflictChecks({
136
+ 'src/index.ts': true,
137
+ 'src/utils.ts': true,
138
+ });
139
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
140
+ const ctx = makeContext();
141
+ const result = await resolver.resolve(ctx);
142
+ // Should have called grep for conflict markers
143
+ const grepCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('grep'));
144
+ expect(grepCalls).toHaveLength(2);
145
+ // Falls through to escalation
146
+ expect(result.escalationAction).toBe('notify');
147
+ });
148
+ });
149
+ // -------------------------------------------------------------------------
150
+ // attemptMergiraf
151
+ // -------------------------------------------------------------------------
152
+ describe('attemptMergiraf (via resolve)', () => {
153
+ it('resolves all files and returns resolved status', async () => {
154
+ // No conflict markers in any file — mergiraf resolved them all
155
+ mockConflictChecks({
156
+ 'src/index.ts': false,
157
+ 'src/utils.ts': false,
158
+ });
159
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
160
+ const ctx = makeContext();
161
+ const result = await resolver.resolve(ctx);
162
+ expect(result.status).toBe('resolved');
163
+ expect(result.method).toBe('mergiraf');
164
+ expect(result.resolvedFiles).toEqual(['src/index.ts', 'src/utils.ts']);
165
+ expect(result.unresolvedFiles).toBeUndefined();
166
+ // git add called for each resolved file
167
+ const addCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('git add'));
168
+ expect(addCalls).toHaveLength(2);
169
+ // git rebase --continue called
170
+ const rebaseCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('git rebase --continue'));
171
+ expect(rebaseCalls).toHaveLength(1);
172
+ });
173
+ it('resolves some files and escalates with remaining', async () => {
174
+ // index.ts resolved, utils.ts still has conflicts
175
+ mockConflictChecks({
176
+ 'src/index.ts': false,
177
+ 'src/utils.ts': true,
178
+ });
179
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true, escalationStrategy: 'notify' }));
180
+ const ctx = makeContext();
181
+ const result = await resolver.resolve(ctx);
182
+ // Mergiraf partial resolution falls through to escalation
183
+ expect(result.status).toBe('escalated');
184
+ expect(result.method).toBe('escalation');
185
+ expect(result.escalationAction).toBe('notify');
186
+ // The escalation context should only contain the unresolved file
187
+ expect(result.unresolvedFiles).toEqual(['src/utils.ts']);
188
+ });
189
+ it('resolves no files and escalates with all files', async () => {
190
+ mockConflictChecks({
191
+ 'src/index.ts': true,
192
+ 'src/utils.ts': true,
193
+ });
194
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true, escalationStrategy: 'park' }));
195
+ const ctx = makeContext();
196
+ const result = await resolver.resolve(ctx);
197
+ expect(result.status).toBe('parked');
198
+ expect(result.method).toBe('escalation');
199
+ expect(result.escalationAction).toBe('park');
200
+ expect(result.unresolvedFiles).toEqual(['src/index.ts', 'src/utils.ts']);
201
+ });
202
+ it('handles rebase --continue failure gracefully', async () => {
203
+ // All files resolved but rebase continue fails
204
+ mockConflictChecks({
205
+ 'src/index.ts': false,
206
+ 'src/utils.ts': false,
207
+ }, { rebaseError: new Error('rebase failed: could not apply commit') });
208
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
209
+ const ctx = makeContext();
210
+ const result = await resolver.resolve(ctx);
211
+ // Mergiraf reports escalated due to rebase failure, then resolve()
212
+ // falls through to the configured escalation strategy (notify)
213
+ expect(result.status).toBe('escalated');
214
+ expect(result.method).toBe('escalation');
215
+ expect(result.escalationAction).toBe('notify');
216
+ // The conflict files from mergiraf's failure are forwarded to escalation
217
+ expect(result.unresolvedFiles).toEqual(['src/index.ts', 'src/utils.ts']);
218
+ });
219
+ });
220
+ // -------------------------------------------------------------------------
221
+ // fileHasConflictMarkers
222
+ // -------------------------------------------------------------------------
223
+ describe('fileHasConflictMarkers (via resolve)', () => {
224
+ it('returns true when conflict markers are present', async () => {
225
+ // Set up: one file with markers, one without
226
+ mockConflictChecks({
227
+ 'src/index.ts': true,
228
+ 'src/utils.ts': false,
229
+ });
230
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
231
+ const ctx = makeContext();
232
+ const result = await resolver.resolve(ctx);
233
+ // index.ts was NOT resolved (has markers), utils.ts was resolved
234
+ // Partial resolution — goes to escalation for index.ts
235
+ expect(result.unresolvedFiles).toEqual(['src/index.ts']);
236
+ });
237
+ it('returns false when grep exits with code 1 (no markers)', async () => {
238
+ // All files return grep exit 1 (no markers)
239
+ mockConflictChecks({
240
+ 'src/index.ts': false,
241
+ 'src/utils.ts': false,
242
+ });
243
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
244
+ const ctx = makeContext({ conflictFiles: ['src/index.ts'] });
245
+ const result = await resolver.resolve(ctx);
246
+ expect(result.status).toBe('resolved');
247
+ expect(result.method).toBe('mergiraf');
248
+ });
249
+ });
250
+ // -------------------------------------------------------------------------
251
+ // Escalation strategies
252
+ // -------------------------------------------------------------------------
253
+ describe('escalation strategies', () => {
254
+ it('reassign returns escalated with diff context', async () => {
255
+ const diffOutput = 'diff --git a/src/index.ts\n+++ b/src/index.ts\n@@ conflict @@';
256
+ mockConflictChecks({}, { diffOutput });
257
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false, escalationStrategy: 'reassign' }));
258
+ const ctx = makeContext();
259
+ const result = await resolver.resolve(ctx);
260
+ expect(result.status).toBe('escalated');
261
+ expect(result.method).toBe('escalation');
262
+ expect(result.escalationAction).toBe('reassign');
263
+ expect(result.unresolvedFiles).toEqual(ctx.conflictFiles);
264
+ expect(result.message).toContain('SUP-100');
265
+ expect(result.message).toContain('PR #42');
266
+ expect(result.message).toContain('Agent should resolve and re-submit');
267
+ expect(result.message).toContain('Diff:');
268
+ expect(result.message).toContain(diffOutput);
269
+ });
270
+ it('notify returns escalated with file list', async () => {
271
+ mockExecSuccess();
272
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false, escalationStrategy: 'notify' }));
273
+ const ctx = makeContext();
274
+ const result = await resolver.resolve(ctx);
275
+ expect(result.status).toBe('escalated');
276
+ expect(result.method).toBe('escalation');
277
+ expect(result.escalationAction).toBe('notify');
278
+ expect(result.unresolvedFiles).toEqual(ctx.conflictFiles);
279
+ expect(result.message).toContain('Merge conflict on SUP-100 PR #42');
280
+ expect(result.message).toContain('src/index.ts');
281
+ expect(result.message).toContain('src/utils.ts');
282
+ });
283
+ it('park returns parked status', async () => {
284
+ mockExecSuccess();
285
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false, escalationStrategy: 'park' }));
286
+ const ctx = makeContext();
287
+ const result = await resolver.resolve(ctx);
288
+ expect(result.status).toBe('parked');
289
+ expect(result.method).toBe('escalation');
290
+ expect(result.escalationAction).toBe('park');
291
+ expect(result.unresolvedFiles).toEqual(ctx.conflictFiles);
292
+ expect(result.message).toContain('PR #42 parked');
293
+ expect(result.message).toContain('auto-retry');
294
+ });
295
+ it('reassign includes truncated diff when git diff output is available', async () => {
296
+ const longDiff = 'x'.repeat(6000);
297
+ mockConflictChecks({}, { diffOutput: longDiff });
298
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false, escalationStrategy: 'reassign' }));
299
+ const ctx = makeContext();
300
+ const result = await resolver.resolve(ctx);
301
+ // The diff should be truncated to 5000 chars
302
+ expect(result.message).toBeDefined();
303
+ expect(result.message.includes('Diff:')).toBe(true);
304
+ // The diff portion within the message should be at most 5000 chars
305
+ const diffStart = result.message.indexOf('Diff:\n') + 'Diff:\n'.length;
306
+ const diffPortion = result.message.slice(diffStart);
307
+ expect(diffPortion.length).toBeLessThanOrEqual(5000);
308
+ });
309
+ it('reassign handles git diff failure gracefully', async () => {
310
+ // Make git diff fail
311
+ mockExec.mockImplementation((cmd, _opts, callback) => {
312
+ const cb = typeof _opts === 'function' ? _opts : callback;
313
+ const cmdStr = typeof cmd === 'string' ? cmd : '';
314
+ if (cmdStr.includes('git diff')) {
315
+ cb?.(new Error('diff failed'), { stdout: '', stderr: '' });
316
+ }
317
+ else {
318
+ cb?.(null, { stdout: '', stderr: '' });
319
+ }
320
+ return {};
321
+ });
322
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false, escalationStrategy: 'reassign' }));
323
+ const ctx = makeContext();
324
+ const result = await resolver.resolve(ctx);
325
+ expect(result.status).toBe('escalated');
326
+ expect(result.escalationAction).toBe('reassign');
327
+ expect(result.message).toContain('(unable to generate diff)');
328
+ });
329
+ });
330
+ // -------------------------------------------------------------------------
331
+ // rebase --continue
332
+ // -------------------------------------------------------------------------
333
+ describe('rebase --continue', () => {
334
+ it('is called after all conflicts are resolved by mergiraf', async () => {
335
+ mockConflictChecks({
336
+ 'src/index.ts': false,
337
+ 'src/utils.ts': false,
338
+ });
339
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
340
+ const ctx = makeContext();
341
+ await resolver.resolve(ctx);
342
+ const rebaseCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('git rebase --continue'));
343
+ expect(rebaseCalls).toHaveLength(1);
344
+ // Verify GIT_EDITOR=true is set to avoid editor prompt
345
+ const rebaseOpts = rebaseCalls[0][1];
346
+ expect(rebaseOpts.env?.GIT_EDITOR).toBe('true');
347
+ });
348
+ it('is NOT called when some files remain unresolved', async () => {
349
+ mockConflictChecks({
350
+ 'src/index.ts': false,
351
+ 'src/utils.ts': true,
352
+ });
353
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
354
+ const ctx = makeContext();
355
+ await resolver.resolve(ctx);
356
+ const rebaseCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('git rebase --continue'));
357
+ expect(rebaseCalls).toHaveLength(0);
358
+ });
359
+ it('failure results in escalation with descriptive message', async () => {
360
+ mockConflictChecks({ 'src/index.ts': false }, { rebaseError: new Error('Could not apply abc123') });
361
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true, escalationStrategy: 'notify' }));
362
+ const ctx = makeContext({ conflictFiles: ['src/index.ts'] });
363
+ const result = await resolver.resolve(ctx);
364
+ // When rebase --continue fails, attemptMergiraf returns 'escalated',
365
+ // which causes resolve() to fall through to the escalation strategy.
366
+ // The final result comes from the notify escalation.
367
+ expect(result.status).toBe('escalated');
368
+ expect(result.method).toBe('escalation');
369
+ expect(result.escalationAction).toBe('notify');
370
+ expect(result.message).toContain('Merge conflict on SUP-100 PR #42');
371
+ });
372
+ });
373
+ // -------------------------------------------------------------------------
374
+ // Edge cases
375
+ // -------------------------------------------------------------------------
376
+ describe('edge cases', () => {
377
+ it('handles empty conflictFiles list', async () => {
378
+ mockExecSuccess();
379
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false }));
380
+ const ctx = makeContext({ conflictFiles: [] });
381
+ const result = await resolver.resolve(ctx);
382
+ expect(result.status).toBe('escalated');
383
+ expect(result.unresolvedFiles).toEqual([]);
384
+ });
385
+ it('handles single conflict file', async () => {
386
+ mockConflictChecks({ 'src/index.ts': false });
387
+ const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
388
+ const ctx = makeContext({ conflictFiles: ['src/index.ts'] });
389
+ const result = await resolver.resolve(ctx);
390
+ expect(result.status).toBe('resolved');
391
+ expect(result.resolvedFiles).toEqual(['src/index.ts']);
392
+ });
393
+ it('defaults to notify escalation for unknown strategy', async () => {
394
+ mockExecSuccess();
395
+ // Force an unknown strategy to test the default branch
396
+ const resolver = new ConflictResolver(makeConfig({
397
+ mergirafEnabled: false,
398
+ escalationStrategy: 'unknown',
399
+ }));
400
+ const ctx = makeContext();
401
+ const result = await resolver.resolve(ctx);
402
+ expect(result.escalationAction).toBe('notify');
403
+ });
404
+ });
405
+ });
@@ -0,0 +1,14 @@
1
+ export type PackageManager = 'pnpm' | 'npm' | 'yarn' | 'bun' | 'none';
2
+ export interface RegenerationResult {
3
+ success: boolean;
4
+ lockFile: string;
5
+ packageManager: PackageManager;
6
+ error?: string;
7
+ }
8
+ export declare class LockFileRegeneration {
9
+ shouldRegenerate(packageManager: PackageManager, lockFileRegenerate: boolean): boolean;
10
+ getLockFileName(packageManager: PackageManager): string | null;
11
+ regenerate(worktreePath: string, packageManager: PackageManager): Promise<RegenerationResult>;
12
+ ensureGitAttributes(repoPath: string, packageManager: PackageManager): Promise<void>;
13
+ }
14
+ //# sourceMappingURL=lock-file-regeneration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lock-file-regeneration.d.ts","sourceRoot":"","sources":["../../../src/merge-queue/lock-file-regeneration.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAA;AAuBrE,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;IAChB,cAAc,EAAE,cAAc,CAAA;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,qBAAa,oBAAoB;IAC/B,gBAAgB,CAAC,cAAc,EAAE,cAAc,EAAE,kBAAkB,EAAE,OAAO,GAAG,OAAO;IAItF,eAAe,CAAC,cAAc,EAAE,cAAc,GAAG,MAAM,GAAG,IAAI;IAIxD,UAAU,CAAC,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,cAAc,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAmC7F,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;CAuB3F"}
@@ -0,0 +1,82 @@
1
+ import { exec as execCb } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { readFile, writeFile, access } from 'fs/promises';
4
+ import { join } from 'path';
5
+ const exec = promisify(execCb);
6
+ const LOCK_FILES = {
7
+ pnpm: 'pnpm-lock.yaml',
8
+ npm: 'package-lock.json',
9
+ yarn: 'yarn.lock',
10
+ bun: 'bun.lockb',
11
+ };
12
+ const INSTALL_COMMANDS = {
13
+ pnpm: 'pnpm install --no-frozen-lockfile',
14
+ npm: 'npm install',
15
+ yarn: 'yarn install',
16
+ bun: 'bun install',
17
+ };
18
+ const GITATTRIBUTES_ENTRIES = {
19
+ pnpm: 'pnpm-lock.yaml merge=ours',
20
+ npm: 'package-lock.json merge=ours',
21
+ yarn: 'yarn.lock merge=ours',
22
+ bun: 'bun.lockb merge=ours',
23
+ };
24
+ export class LockFileRegeneration {
25
+ shouldRegenerate(packageManager, lockFileRegenerate) {
26
+ return lockFileRegenerate && packageManager !== 'none';
27
+ }
28
+ getLockFileName(packageManager) {
29
+ return LOCK_FILES[packageManager] ?? null;
30
+ }
31
+ async regenerate(worktreePath, packageManager) {
32
+ const lockFile = this.getLockFileName(packageManager);
33
+ if (!lockFile) {
34
+ return { success: false, lockFile: '', packageManager, error: `Unsupported package manager: ${packageManager}` };
35
+ }
36
+ const installCommand = INSTALL_COMMANDS[packageManager];
37
+ try {
38
+ // 1. Delete the conflicted lock file (if it exists)
39
+ const lockFilePath = join(worktreePath, lockFile);
40
+ try {
41
+ await access(lockFilePath);
42
+ await exec(`rm "${lockFile}"`, { cwd: worktreePath });
43
+ }
44
+ catch {
45
+ // Lock file doesn't exist, that's fine
46
+ }
47
+ // 2. Run package manager install to regenerate
48
+ await exec(installCommand, {
49
+ cwd: worktreePath,
50
+ timeout: 120_000, // 2 minute timeout
51
+ maxBuffer: 10 * 1024 * 1024, // 10MB buffer for install output
52
+ });
53
+ // 3. Stage the regenerated lock file
54
+ await exec(`git add "${lockFile}"`, { cwd: worktreePath });
55
+ return { success: true, lockFile, packageManager };
56
+ }
57
+ catch (error) {
58
+ const message = error instanceof Error ? error.message : String(error);
59
+ return { success: false, lockFile, packageManager, error: message };
60
+ }
61
+ }
62
+ async ensureGitAttributes(repoPath, packageManager) {
63
+ const entry = GITATTRIBUTES_ENTRIES[packageManager];
64
+ if (!entry)
65
+ return;
66
+ const gitattributesPath = join(repoPath, '.gitattributes');
67
+ let content = '';
68
+ try {
69
+ content = await readFile(gitattributesPath, 'utf-8');
70
+ }
71
+ catch {
72
+ // File doesn't exist yet
73
+ }
74
+ if (content.includes(entry)) {
75
+ return; // Already configured
76
+ }
77
+ const newContent = content.endsWith('\n') || content === ''
78
+ ? content + entry + '\n'
79
+ : content + '\n' + entry + '\n';
80
+ await writeFile(gitattributesPath, newContent, 'utf-8');
81
+ }
82
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=lock-file-regeneration.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lock-file-regeneration.test.d.ts","sourceRoot":"","sources":["../../../src/merge-queue/lock-file-regeneration.test.ts"],"names":[],"mappings":""}