@smoothbricks/cli 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +12 -0
- package/dist/lib/conflict-markers.d.ts +54 -0
- package/dist/lib/conflict-markers.d.ts.map +1 -0
- package/dist/lib/conflict-markers.js +118 -0
- package/dist/pr/index.d.ts +27 -0
- package/dist/pr/index.d.ts.map +1 -0
- package/dist/pr/index.js +256 -0
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +5 -0
- package/package.json +1 -1
- package/src/cli.ts +13 -0
- package/src/lib/conflict-markers.test.ts +165 -0
- package/src/lib/conflict-markers.ts +150 -0
- package/src/pr/index.test.ts +187 -0
- package/src/pr/index.ts +361 -0
- package/src/release/index.ts +5 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
assertNoConflictMarkers,
|
|
4
|
+
type ConflictMarkerHit,
|
|
5
|
+
ConflictMarkersError,
|
|
6
|
+
findMarkerLines,
|
|
7
|
+
formatMarkerHits,
|
|
8
|
+
type MarkerScanShell,
|
|
9
|
+
parseGitGrep,
|
|
10
|
+
scanRefChangedForMarkers,
|
|
11
|
+
scanTrackedForMarkers,
|
|
12
|
+
} from './conflict-markers.js';
|
|
13
|
+
|
|
14
|
+
describe('findMarkerLines', () => {
|
|
15
|
+
it('detects ours/base/theirs markers with their 1-based line numbers', () => {
|
|
16
|
+
const text = [
|
|
17
|
+
'{',
|
|
18
|
+
'<<<<<<< HEAD',
|
|
19
|
+
' "a": 1',
|
|
20
|
+
'||||||| base',
|
|
21
|
+
' "a": 0',
|
|
22
|
+
'=======',
|
|
23
|
+
' "a": 2',
|
|
24
|
+
'>>>>>>> feat',
|
|
25
|
+
'}',
|
|
26
|
+
].join('\n');
|
|
27
|
+
expect(findMarkerLines(text)).toEqual([2, 4, 8]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('does not flag a bare ======= separator (Markdown h1 / diff fill)', () => {
|
|
31
|
+
expect(findMarkerLines('Title\n=======\nbody')).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not flag quoted/indented marker strings in source', () => {
|
|
35
|
+
const source = ["const OPEN = '<<<<<<< ';", " const CLOSE = '>>>>>>> ';", 'const P = /^<<<<<<< /;'].join('\n');
|
|
36
|
+
expect(findMarkerLines(source)).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns empty for clean text', () => {
|
|
40
|
+
expect(findMarkerLines('{\n "a": 1\n}\n')).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('parseGitGrep', () => {
|
|
45
|
+
it('groups path:line:content rows by file', () => {
|
|
46
|
+
const stdout = ['pkg/a.json:12:<<<<<<< HEAD', 'pkg/a.json:16:>>>>>>> x', 'b.toml:36:<<<<<<< HEAD', ''].join('\n');
|
|
47
|
+
expect(parseGitGrep(stdout)).toEqual([
|
|
48
|
+
{ file: 'pkg/a.json', lines: [12, 16] },
|
|
49
|
+
{ file: 'b.toml', lines: [36] },
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('tolerates paths containing colons in content and skips malformed rows', () => {
|
|
54
|
+
const stdout = ['a.ts:3:foo: bar', 'garbage-without-colon', 'a.ts:9:>>>>>>> y'].join('\n');
|
|
55
|
+
expect(parseGitGrep(stdout)).toEqual([{ file: 'a.ts', lines: [3, 9] }]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns empty for empty output', () => {
|
|
59
|
+
expect(parseGitGrep('')).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('scanTrackedForMarkers', () => {
|
|
64
|
+
it('returns hits when git grep finds markers (exit 0)', async () => {
|
|
65
|
+
const shell = scriptedShell([{ exitCode: 0, stdout: 'x.json:2:<<<<<<< HEAD\n', stderr: '' }]);
|
|
66
|
+
const hits = await scanTrackedForMarkers(shell.shell, '/repo');
|
|
67
|
+
expect(hits).toEqual([{ file: 'x.json', lines: [2] }]);
|
|
68
|
+
expect(shell.calls[0].args).toEqual(['grep', '-nI', '-E', '^(<<<<<<< |\\|\\|\\|\\|\\|\\|\\| |>>>>>>> )']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('passes pathspecs through after --', async () => {
|
|
72
|
+
const shell = scriptedShell([{ exitCode: 1, stdout: '', stderr: '' }]);
|
|
73
|
+
await scanTrackedForMarkers(shell.shell, '/repo', ['packages/a', 'packages/b']);
|
|
74
|
+
expect(shell.calls[0].args.slice(-3)).toEqual(['--', 'packages/a', 'packages/b']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('treats exit 1 as no matches', async () => {
|
|
78
|
+
const shell = scriptedShell([{ exitCode: 1, stdout: '', stderr: '' }]);
|
|
79
|
+
expect(await scanTrackedForMarkers(shell.shell, '/repo')).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('throws on git grep error (exit > 1)', async () => {
|
|
83
|
+
const shell = scriptedShell([{ exitCode: 128, stdout: '', stderr: 'not a git repo' }]);
|
|
84
|
+
await expect(scanTrackedForMarkers(shell.shell, '/repo')).rejects.toThrow('not a git repo');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('scanRefChangedForMarkers', () => {
|
|
89
|
+
it('reads changed-file blobs at head and reports only markered files', async () => {
|
|
90
|
+
const shell = scriptedShell([
|
|
91
|
+
{ exitCode: 0, stdout: 'pkg/a.json\npkg/clean.ts\n', stderr: '' }, // git diff --name-only
|
|
92
|
+
{ exitCode: 0, stdout: '{\n<<<<<<< HEAD\n=======\n>>>>>>> feat\n}\n', stderr: '' }, // show a.json
|
|
93
|
+
{ exitCode: 0, stdout: 'export const ok = 1;\n', stderr: '' }, // show clean.ts
|
|
94
|
+
]);
|
|
95
|
+
const hits = await scanRefChangedForMarkers(shell.shell, '/repo', 'base', 'head');
|
|
96
|
+
expect(hits).toEqual([{ file: 'pkg/a.json', lines: [2, 4] }]);
|
|
97
|
+
expect(shell.calls[0].args).toEqual(['diff', '--name-only', 'base...head']);
|
|
98
|
+
expect(shell.calls[1].args).toEqual(['show', 'head:pkg/a.json', '--textconv']);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('skips files deleted/unreadable at head', async () => {
|
|
102
|
+
const shell = scriptedShell([
|
|
103
|
+
{ exitCode: 0, stdout: 'gone.txt\n', stderr: '' },
|
|
104
|
+
{ exitCode: 128, stdout: '', stderr: 'exists on disk, but not in head' },
|
|
105
|
+
]);
|
|
106
|
+
expect(await scanRefChangedForMarkers(shell.shell, '/repo', 'base', 'head')).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('formatMarkerHits', () => {
|
|
111
|
+
it('formats file + line list per hit', () => {
|
|
112
|
+
const hits: ConflictMarkerHit[] = [
|
|
113
|
+
{ file: 'a.json', lines: [12, 16] },
|
|
114
|
+
{ file: 'b.toml', lines: [3] },
|
|
115
|
+
];
|
|
116
|
+
expect(formatMarkerHits(hits)).toBe(' a.json (lines 12, 16)\n b.toml (lines 3)');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
interface ScriptedCall {
|
|
121
|
+
command: string;
|
|
122
|
+
args: string[];
|
|
123
|
+
cwd: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function scriptedShell(responses: { exitCode: number; stdout: string; stderr: string }[]): {
|
|
127
|
+
shell: MarkerScanShell;
|
|
128
|
+
calls: ScriptedCall[];
|
|
129
|
+
} {
|
|
130
|
+
const calls: ScriptedCall[] = [];
|
|
131
|
+
let index = 0;
|
|
132
|
+
const shell: MarkerScanShell = {
|
|
133
|
+
async runResult(command, args, cwd) {
|
|
134
|
+
calls.push({ command, args, cwd });
|
|
135
|
+
const response = responses[index++];
|
|
136
|
+
if (!response) {
|
|
137
|
+
throw new Error(`unexpected shell call: ${command} ${args.join(' ')}`);
|
|
138
|
+
}
|
|
139
|
+
return response;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
return { shell, calls };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe('assertNoConflictMarkers', () => {
|
|
146
|
+
it('throws ConflictMarkersError listing hits when markers exist', async () => {
|
|
147
|
+
const shell = scriptedShell([{ exitCode: 0, stdout: 'a.json:2:<<<<<<< HEAD\n', stderr: '' }]);
|
|
148
|
+
let caught: unknown;
|
|
149
|
+
try {
|
|
150
|
+
await assertNoConflictMarkers(shell.shell, '/repo', 'publish');
|
|
151
|
+
} catch (error) {
|
|
152
|
+
caught = error;
|
|
153
|
+
}
|
|
154
|
+
expect(caught).toBeInstanceOf(ConflictMarkersError);
|
|
155
|
+
if (caught instanceof ConflictMarkersError) {
|
|
156
|
+
expect(caught.hits).toEqual([{ file: 'a.json', lines: [2] }]);
|
|
157
|
+
expect(caught.message).toContain('Refusing to publish');
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('resolves when the tree is clean', async () => {
|
|
162
|
+
const shell = scriptedShell([{ exitCode: 1, stdout: '', stderr: '' }]);
|
|
163
|
+
await expect(assertNoConflictMarkers(shell.shell, '/repo', 'publish')).resolves.toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared detection of Git merge-conflict markers.
|
|
3
|
+
*
|
|
4
|
+
* Real conflict markers always sit at column 0 and are a 7-character run of
|
|
5
|
+
* `<`, `|`, or `>` followed by a space (the diff3 base marker `|||||||` and the
|
|
6
|
+
* ours/theirs markers `<<<<<<< ` / `>>>>>>> `). We deliberately do NOT match a
|
|
7
|
+
* bare `=======` separator: it is ambiguous with Markdown h1 underlines and adds
|
|
8
|
+
* no signal (a real conflict always carries the angle/pipe markers too). The
|
|
9
|
+
* column-0 anchor also means quoted marker strings inside source (which are
|
|
10
|
+
* indented) never false-positive.
|
|
11
|
+
*/
|
|
12
|
+
export const CONFLICT_MARKER_PATTERN = '^(<<<<<<< |\\|\\|\\|\\|\\|\\|\\| |>>>>>>> )';
|
|
13
|
+
|
|
14
|
+
const conflictMarkerRe = new RegExp(CONFLICT_MARKER_PATTERN);
|
|
15
|
+
|
|
16
|
+
export interface ConflictMarkerHit {
|
|
17
|
+
/** Repo-relative path of the offending file. */
|
|
18
|
+
readonly file: string;
|
|
19
|
+
/** 1-based line numbers carrying a conflict marker. */
|
|
20
|
+
readonly lines: number[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 1-based line numbers of every conflict-marker line in `text`. */
|
|
24
|
+
export function findMarkerLines(text: string): number[] {
|
|
25
|
+
const hits: number[] = [];
|
|
26
|
+
const lines = text.split('\n');
|
|
27
|
+
for (let i = 0; i < lines.length; i++) {
|
|
28
|
+
if (conflictMarkerRe.test(lines[i])) {
|
|
29
|
+
hits.push(i + 1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return hits;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MarkerScanShell {
|
|
36
|
+
runResult(
|
|
37
|
+
command: string,
|
|
38
|
+
args: string[],
|
|
39
|
+
cwd: string,
|
|
40
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Scan tracked files in the working tree for conflict markers via `git grep`.
|
|
45
|
+
* `pathspecs` optionally restricts the scan (e.g. release package roots).
|
|
46
|
+
*/
|
|
47
|
+
export async function scanTrackedForMarkers(
|
|
48
|
+
shell: MarkerScanShell,
|
|
49
|
+
root: string,
|
|
50
|
+
pathspecs: string[] = [],
|
|
51
|
+
): Promise<ConflictMarkerHit[]> {
|
|
52
|
+
const args = ['grep', '-nI', '-E', CONFLICT_MARKER_PATTERN];
|
|
53
|
+
if (pathspecs.length > 0) {
|
|
54
|
+
args.push('--', ...pathspecs);
|
|
55
|
+
}
|
|
56
|
+
const { exitCode, stdout, stderr } = await shell.runResult('git', args, root);
|
|
57
|
+
// git grep: 0 = matches found, 1 = no matches, >1 = error.
|
|
58
|
+
if (exitCode > 1) {
|
|
59
|
+
throw new Error(`git grep for conflict markers failed: ${stderr.trim() || `exit ${exitCode}`}`);
|
|
60
|
+
}
|
|
61
|
+
return parseGitGrep(stdout);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Thrown by {@link assertNoConflictMarkers} so callers/tests can inspect the hits. */
|
|
65
|
+
export class ConflictMarkersError extends Error {
|
|
66
|
+
constructor(
|
|
67
|
+
readonly hits: ConflictMarkerHit[],
|
|
68
|
+
context: string,
|
|
69
|
+
) {
|
|
70
|
+
super(`Refusing to ${context}: conflict markers found in ${hits.length} file(s):\n${formatMarkerHits(hits)}`);
|
|
71
|
+
this.name = 'ConflictMarkersError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reusable publish/merge guard: throw {@link ConflictMarkersError} when any
|
|
77
|
+
* tracked file carries conflict markers. `context` names the blocked operation
|
|
78
|
+
* (e.g. `'publish'`) for the message.
|
|
79
|
+
*/
|
|
80
|
+
export async function assertNoConflictMarkers(shell: MarkerScanShell, root: string, context: string): Promise<void> {
|
|
81
|
+
const hits = await scanTrackedForMarkers(shell, root);
|
|
82
|
+
if (hits.length > 0) {
|
|
83
|
+
throw new ConflictMarkersError(hits, context);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Scan the files changed between `baseRef` and `headRef` (three-dot, i.e. since
|
|
89
|
+
* their merge base) for conflict markers, reading blobs at `headRef` — no
|
|
90
|
+
* checkout or working-tree mutation required.
|
|
91
|
+
*/
|
|
92
|
+
export async function scanRefChangedForMarkers(
|
|
93
|
+
shell: MarkerScanShell,
|
|
94
|
+
root: string,
|
|
95
|
+
baseRef: string,
|
|
96
|
+
headRef: string,
|
|
97
|
+
): Promise<ConflictMarkerHit[]> {
|
|
98
|
+
const diff = await shell.runResult('git', ['diff', '--name-only', `${baseRef}...${headRef}`], root);
|
|
99
|
+
if (diff.exitCode !== 0) {
|
|
100
|
+
throw new Error(`git diff ${baseRef}...${headRef} failed: ${diff.stderr.trim() || `exit ${diff.exitCode}`}`);
|
|
101
|
+
}
|
|
102
|
+
const files = diff.stdout.split('\n').filter((line) => line.length > 0);
|
|
103
|
+
const hits: ConflictMarkerHit[] = [];
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
const show = await shell.runResult('git', ['show', `${headRef}:${file}`, '--textconv'], root);
|
|
106
|
+
if (show.exitCode !== 0) {
|
|
107
|
+
continue; // deleted at head / binary / unreadable — not a marker source
|
|
108
|
+
}
|
|
109
|
+
const lines = findMarkerLines(show.stdout);
|
|
110
|
+
if (lines.length > 0) {
|
|
111
|
+
hits.push({ file, lines });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return hits;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Parse `path:line:content` rows from `git grep -n` into per-file hits. */
|
|
118
|
+
export function parseGitGrep(stdout: string): ConflictMarkerHit[] {
|
|
119
|
+
const byFile = new Map<string, number[]>();
|
|
120
|
+
for (const row of stdout.split('\n')) {
|
|
121
|
+
if (row.length === 0) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const firstColon = row.indexOf(':');
|
|
125
|
+
if (firstColon < 0) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const secondColon = row.indexOf(':', firstColon + 1);
|
|
129
|
+
if (secondColon < 0) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const file = row.slice(0, firstColon);
|
|
133
|
+
const line = Number.parseInt(row.slice(firstColon + 1, secondColon), 10);
|
|
134
|
+
if (!Number.isFinite(line)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const existing = byFile.get(file);
|
|
138
|
+
if (existing) {
|
|
139
|
+
existing.push(line);
|
|
140
|
+
} else {
|
|
141
|
+
byFile.set(file, [line]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return [...byFile.entries()].map(([file, lines]) => ({ file, lines }));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Human-readable one-liner summary of marker hits, e.g. for CLI output. */
|
|
148
|
+
export function formatMarkerHits(hits: ConflictMarkerHit[]): string {
|
|
149
|
+
return hits.map((hit) => ` ${hit.file} (lines ${hit.lines.join(', ')})`).join('\n');
|
|
150
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { type PrResolveShell, resolvePrConflicts } from './index.js';
|
|
6
|
+
|
|
7
|
+
let gitDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
gitDir = mkdtempSync(join(tmpdir(), 'smoo-pr-'));
|
|
11
|
+
spyOn(console, 'log').mockImplementation(() => {});
|
|
12
|
+
spyOn(console, 'error').mockImplementation(() => {});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
rmSync(gitDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const PR_JSON = JSON.stringify({
|
|
20
|
+
number: 40,
|
|
21
|
+
url: 'https://github.com/conloca/private/pull/40',
|
|
22
|
+
headRefName: 'gar-sync/private-to-public',
|
|
23
|
+
baseRefName: 'public-mirror',
|
|
24
|
+
isCrossRepository: false,
|
|
25
|
+
});
|
|
26
|
+
const MARKERED = '{\n<<<<<<< HEAD\n "a": 1\n=======\n "a": 2\n>>>>>>> feat\n}\n';
|
|
27
|
+
const CLEAN = '{\n "a": 1\n}\n';
|
|
28
|
+
|
|
29
|
+
const statePath = () => join(gitDir, 'smoo', 'pr-resolve.json');
|
|
30
|
+
|
|
31
|
+
describe('smoo pr resolve — phase A (start)', () => {
|
|
32
|
+
it('reports clean and writes no state when the PR has no markers', async () => {
|
|
33
|
+
const shell = scriptedShell([
|
|
34
|
+
ok(gitDir), // rev-parse --absolute-git-dir
|
|
35
|
+
ok(''), // status --porcelain (clean tree)
|
|
36
|
+
ok(PR_JSON), // gh pr view
|
|
37
|
+
ok('a.json\n'), // diff --name-only base...head
|
|
38
|
+
ok(CLEAN), // show head:a.json
|
|
39
|
+
]);
|
|
40
|
+
const exit = await resolvePrConflicts('/repo', '40', { remote: 'origin' }, shell.shell);
|
|
41
|
+
expect(exit).toBe(0);
|
|
42
|
+
expect(existsSync(statePath())).toBe(false);
|
|
43
|
+
expect(shell.runCommands).not.toContainEqual(expect.stringContaining('checkout'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('checks out the branch, saves state, and returns 2 when markers exist', async () => {
|
|
47
|
+
const shell = scriptedShell([
|
|
48
|
+
ok(gitDir),
|
|
49
|
+
ok(''),
|
|
50
|
+
ok(PR_JSON),
|
|
51
|
+
ok('a.json\n'),
|
|
52
|
+
ok(MARKERED),
|
|
53
|
+
ok('main\n'), // symbolic-ref (current branch)
|
|
54
|
+
ok('deadbeefcafe\n'), // rev-parse HEAD
|
|
55
|
+
]);
|
|
56
|
+
const exit = await resolvePrConflicts('/repo', '40', { remote: 'origin' }, shell.shell);
|
|
57
|
+
expect(exit).toBe(2);
|
|
58
|
+
const state = JSON.parse(readFileSync(statePath(), 'utf8'));
|
|
59
|
+
expect(state.pr).toBe(40);
|
|
60
|
+
expect(state.headBranch).toBe('gar-sync/private-to-public');
|
|
61
|
+
expect(state.originalBranch).toBe('main');
|
|
62
|
+
expect(state.remote).toBe('origin');
|
|
63
|
+
expect(shell.runCommands).toContainEqual(
|
|
64
|
+
'git checkout -B gar-sync/private-to-public origin/gar-sync/private-to-public',
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('refuses to start on a dirty working tree', async () => {
|
|
69
|
+
const shell = scriptedShell([ok(gitDir), ok(' M src/x.ts\n')]);
|
|
70
|
+
const exit = await resolvePrConflicts('/repo', '40', { remote: 'origin' }, shell.shell);
|
|
71
|
+
expect(exit).toBe(1);
|
|
72
|
+
expect(existsSync(statePath())).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('smoo pr resolve — phase B (finish)', () => {
|
|
77
|
+
it('pushes and restores the original branch when markers are resolved', async () => {
|
|
78
|
+
writeState({ originalBranch: 'main' });
|
|
79
|
+
const shell = scriptedShell([
|
|
80
|
+
ok(gitDir),
|
|
81
|
+
ok('gar-sync/private-to-public\n'), // symbolic-ref (on the PR branch)
|
|
82
|
+
ok(''), // status --porcelain (committed, clean)
|
|
83
|
+
ok('a.json\n'), // diff --name-only base...HEAD
|
|
84
|
+
ok(CLEAN), // show HEAD:a.json (resolved)
|
|
85
|
+
ok(''), // git push (fast-forward)
|
|
86
|
+
]);
|
|
87
|
+
const exit = await resolvePrConflicts('/repo', undefined, {}, shell.shell);
|
|
88
|
+
expect(exit).toBe(0);
|
|
89
|
+
expect(existsSync(statePath())).toBe(false);
|
|
90
|
+
expect(shell.runResultCommands).toContainEqual('git push origin HEAD:refs/heads/gar-sync/private-to-public');
|
|
91
|
+
expect(shell.runCommands).toContainEqual('git checkout main');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns 2 and keeps state when markers still remain', async () => {
|
|
95
|
+
writeState({ originalBranch: 'main' });
|
|
96
|
+
const shell = scriptedShell([
|
|
97
|
+
ok(gitDir),
|
|
98
|
+
ok('gar-sync/private-to-public\n'),
|
|
99
|
+
ok(''),
|
|
100
|
+
ok('a.json\n'),
|
|
101
|
+
ok(MARKERED), // still markered
|
|
102
|
+
]);
|
|
103
|
+
const exit = await resolvePrConflicts('/repo', undefined, {}, shell.shell);
|
|
104
|
+
expect(exit).toBe(2);
|
|
105
|
+
expect(existsSync(statePath())).toBe(true);
|
|
106
|
+
expect(shell.runResultCommands).not.toContainEqual(expect.stringContaining('push'));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns 2 when the resolution is not committed', async () => {
|
|
110
|
+
writeState({ originalBranch: 'main' });
|
|
111
|
+
const shell = scriptedShell([ok(gitDir), ok('gar-sync/private-to-public\n'), ok(' M a.json\n')]);
|
|
112
|
+
const exit = await resolvePrConflicts('/repo', undefined, {}, shell.shell);
|
|
113
|
+
expect(exit).toBe(2);
|
|
114
|
+
expect(existsSync(statePath())).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('errors when HEAD is not on the PR branch', async () => {
|
|
118
|
+
writeState({ originalBranch: 'main' });
|
|
119
|
+
const shell = scriptedShell([ok(gitDir), ok('some-other-branch\n')]);
|
|
120
|
+
const exit = await resolvePrConflicts('/repo', undefined, {}, shell.shell);
|
|
121
|
+
expect(exit).toBe(1);
|
|
122
|
+
expect(existsSync(statePath())).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('errors when pointed at a different PR mid-resolution', async () => {
|
|
126
|
+
writeState({ originalBranch: 'main' });
|
|
127
|
+
const shell = scriptedShell([ok(gitDir)]);
|
|
128
|
+
const exit = await resolvePrConflicts('/repo', '99', {}, shell.shell);
|
|
129
|
+
expect(exit).toBe(1);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('smoo pr resolve --abort', () => {
|
|
134
|
+
it('restores the original branch and clears state', async () => {
|
|
135
|
+
writeState({ originalBranch: 'main' });
|
|
136
|
+
const shell = scriptedShell([ok(gitDir)]);
|
|
137
|
+
const exit = await resolvePrConflicts('/repo', undefined, { abort: true }, shell.shell);
|
|
138
|
+
expect(exit).toBe(0);
|
|
139
|
+
expect(existsSync(statePath())).toBe(false);
|
|
140
|
+
expect(shell.runCommands).toContainEqual('git checkout main');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
function writeState(overrides: Record<string, unknown>): void {
|
|
145
|
+
mkdirSync(join(gitDir, 'smoo'), { recursive: true });
|
|
146
|
+
const state = {
|
|
147
|
+
pr: 40,
|
|
148
|
+
url: 'https://github.com/conloca/private/pull/40',
|
|
149
|
+
headBranch: 'gar-sync/private-to-public',
|
|
150
|
+
baseBranch: 'public-mirror',
|
|
151
|
+
remote: 'origin',
|
|
152
|
+
crossRepo: false,
|
|
153
|
+
originalBranch: 'main',
|
|
154
|
+
originalSha: 'deadbeefcafe',
|
|
155
|
+
startedAt: '2026-07-01T00:00:00Z',
|
|
156
|
+
...overrides,
|
|
157
|
+
};
|
|
158
|
+
writeFileSync(statePath(), `${JSON.stringify(state, null, 2)}\n`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function ok(stdout: string): { exitCode: number; stdout: string; stderr: string } {
|
|
162
|
+
return { exitCode: 0, stdout, stderr: '' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function scriptedShell(responses: { exitCode: number; stdout: string; stderr: string }[]): {
|
|
166
|
+
shell: PrResolveShell;
|
|
167
|
+
runCommands: string[];
|
|
168
|
+
runResultCommands: string[];
|
|
169
|
+
} {
|
|
170
|
+
const runCommands: string[] = [];
|
|
171
|
+
const runResultCommands: string[] = [];
|
|
172
|
+
let index = 0;
|
|
173
|
+
const shell: PrResolveShell = {
|
|
174
|
+
async runResult(command, args, _cwd) {
|
|
175
|
+
runResultCommands.push(`${command} ${args.join(' ')}`);
|
|
176
|
+
const response = responses[index++];
|
|
177
|
+
if (!response) {
|
|
178
|
+
throw new Error(`unexpected runResult call: ${command} ${args.join(' ')}`);
|
|
179
|
+
}
|
|
180
|
+
return response;
|
|
181
|
+
},
|
|
182
|
+
async run(command, args, _cwd) {
|
|
183
|
+
runCommands.push(`${command} ${args.join(' ')}`);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
return { shell, runCommands, runResultCommands };
|
|
187
|
+
}
|