gitroots 0.0.0-stage → 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.
@@ -0,0 +1,69 @@
1
+ // gitroots completion bash|zsh|fish · and the hidden `gitroots __complete <kind>` it calls for live ids
2
+ const TREE = {
3
+ status: [], repo: ['list', 'view'], branch: ['list', 'rules', 'restore'], clone: [], hooks: ['install', 'uninstall'], check: [],
4
+ pr: ['list', 'view', 'diff', 'create', 'approve', 'review', 'merge'], audit: ['follow'], watch: [], sessions: [],
5
+ attach: '@sessions', transfer: '@sessions', backup: '@sessions', backups: [], restore: '@sessions', tunnel: ['ls', 'close'], share: '@sessions',
6
+ login: [], logout: [], whoami: [], doctor: [], update: [], uninstall: [], completion: ['bash', 'zsh', 'fish'], demo: ['reset'], help: [],
7
+ };
8
+ const FLAGS = '--json --help --no-color --repo --since --actor --action --decision --to --machine --label --pick --fork --point --port --read-only --expires --yes --all --base --head --title --draft';
9
+
10
+ export async function run(cmd, rest, flags, store) {
11
+ if (cmd === '__complete') {
12
+ const kind = rest[0];
13
+ const ids = kind === 'sessions' ? store.sessions().map((s) => s.id)
14
+ : kind === 'prs' ? store.prs().map((p) => String(p.number))
15
+ : kind === 'machines' ? store.machines().map((m) => m.name)
16
+ : kind === 'repos' ? store.repos().map((r) => r.name)
17
+ : kind === 'backups' ? store.backups().map((b) => b.id) : [];
18
+ process.stdout.write(`${ids.join('\n')}\n`);
19
+ return 0;
20
+ }
21
+ const shell = rest[0] || (process.env.SHELL || '').split('/').pop();
22
+ const cmds = Object.keys(TREE).join(' ');
23
+ const subs = Object.entries(TREE).filter(([, v]) => Array.isArray(v) && v.length);
24
+ if (shell === 'bash') {
25
+ process.stdout.write(`# gitroots bash completion · eval "$(gitroots completion bash)"
26
+ _gitroots() {
27
+ local cur prev words cword
28
+ cur="\${COMP_WORDS[COMP_CWORD]}"; prev="\${COMP_WORDS[COMP_CWORD-1]}"
29
+ if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "${cmds}" -- "$cur") ); return; fi
30
+ case "$cur" in -*) COMPREPLY=( $(compgen -W "${FLAGS}" -- "$cur") ); return;; esac
31
+ case "$prev" in --to|--machine) COMPREPLY=( $(compgen -W "$(gitroots __complete machines 2>/dev/null)" -- "$cur") ); return;; esac
32
+ case "\${COMP_WORDS[1]}" in
33
+ ${subs.map(([k, v]) => ` ${k}) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=( $(compgen -W "${v.join(' ')}" -- "$cur") ); fi;;`).join('\n')}
34
+ attach|transfer|backup|restore|share) COMPREPLY=( $(compgen -W "$(gitroots __complete sessions 2>/dev/null)" -- "$cur") );;
35
+ esac
36
+ }
37
+ complete -F _gitroots gitroots
38
+ `);
39
+ return 0;
40
+ }
41
+ if (shell === 'zsh') {
42
+ process.stdout.write(`#compdef gitroots
43
+ # gitroots zsh completion · gitroots completion zsh > "\${fpath[1]}/_gitroots" (or: source <(gitroots completion zsh))
44
+ _gitroots() {
45
+ local -a cmds
46
+ cmds=(${cmds})
47
+ if (( CURRENT == 2 )); then compadd -a cmds; return; fi
48
+ if [[ $words[CURRENT] == -* ]]; then compadd -- ${FLAGS}; return; fi
49
+ if [[ $words[CURRENT-1] == (--to|--machine) ]]; then compadd -- \${(f)"$(gitroots __complete machines 2>/dev/null)"}; return; fi
50
+ case $words[2] in
51
+ ${subs.map(([k, v]) => ` ${k}) (( CURRENT == 3 )) && compadd -- ${v.join(' ')};;`).join('\n')}
52
+ attach|transfer|backup|restore|share) compadd -- \${(f)"$(gitroots __complete sessions 2>/dev/null)"};;
53
+ esac
54
+ }
55
+ compdef _gitroots gitroots
56
+ `);
57
+ return 0;
58
+ }
59
+ if (shell === 'fish') {
60
+ const lines = ['# gitroots fish completion · gitroots completion fish > ~/.config/fish/completions/gitroots.fish', 'complete -c gitroots -f'];
61
+ lines.push(`complete -c gitroots -n __fish_use_subcommand -a "${cmds}"`);
62
+ for (const [k, v] of subs) lines.push(`complete -c gitroots -n "__fish_seen_subcommand_from ${k}" -a "${v.join(' ')}"`);
63
+ lines.push('complete -c gitroots -n "__fish_seen_subcommand_from attach transfer backup restore share" -a "(gitroots __complete sessions 2>/dev/null)"');
64
+ lines.push('complete -c gitroots -l to -l machine -a "(gitroots __complete machines 2>/dev/null)"');
65
+ process.stdout.write(`${lines.join('\n')}\n`);
66
+ return 0;
67
+ }
68
+ throw new Error('usage: gitroots completion bash|zsh|fish');
69
+ }
@@ -0,0 +1,12 @@
1
+ // gitroots demo reset
2
+ import { c } from '../ui/ansi.js';
3
+ import { line } from '../ui/term.js';
4
+ import { Store, home } from '../data/store.js';
5
+
6
+ export async function run(_cmd, rest) {
7
+ const [sub] = rest;
8
+ if (sub !== 'reset') throw new Error('usage: gitroots demo reset');
9
+ Store.reset();
10
+ line(` ${c.g('✓')} Demo org reset ${c.m(`· ${home()}/state.json removed`)}`);
11
+ return 0;
12
+ }
@@ -0,0 +1,81 @@
1
+ // gitroots doctor
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { c, pad, state as colorState } from '../ui/ansi.js';
6
+ import { line, motion, imageProtocol } from '../ui/term.js';
7
+ import { json } from '../ui/components.js';
8
+ import { VERSION, DOWNLOAD_BASE, installMethod, nativePaths, platformKey, isBinary } from '../runtime.js';
9
+ import { latestVersion, newer, onPath } from '../installer.js';
10
+ import { home } from '../data/store.js';
11
+ import { readConfig } from '../api.js';
12
+ import { tryGit } from '../git/checks.js';
13
+
14
+ export async function run(_cmd, _rest, flags, store) {
15
+ const checks = [];
16
+ const add = (group, name, status, detail, fix) => checks.push({ group, name, status, detail, fix });
17
+
18
+ const method = installMethod();
19
+ add('install', 'gitroots', 'ok', `${VERSION} · ${method}${isBinary ? ` · ${process.execPath}` : ''}`);
20
+ add('install', 'platform', 'ok', platformKey());
21
+ if (!isBinary) {
22
+ const major = Number(process.versions.node.split('.')[0]);
23
+ add('install', 'node', major >= 18 ? 'ok' : 'fail', process.version, major >= 18 ? null : 'install Node 18 or newer, or the standalone build: curl -fsSL ' + DOWNLOAD_BASE + '/install.sh | bash');
24
+ }
25
+ if (method === 'native') {
26
+ const { bin } = nativePaths();
27
+ add('install', 'PATH', onPath(bin) ? 'ok' : 'warn', onPath(bin) ? `${bin} is on PATH` : `${bin} is not on PATH`, onPath(bin) ? null : `add export PATH="${bin}:$PATH" to your shell profile`);
28
+ }
29
+ const which = spawnSync(process.platform === 'win32' ? 'where' : 'sh', process.platform === 'win32' ? ['gitroots'] : ['-c', 'command -v gitroots'], { encoding: 'utf8' });
30
+ const found = (which.stdout || '').split('\n')[0].trim();
31
+ add('install', 'on PATH', found ? 'ok' : 'warn', found || 'gitroots is not on PATH in this shell', found ? null : method === 'npm' ? 'check npm prefix -g is on PATH' : null);
32
+
33
+ try {
34
+ const latest = await latestVersion(DOWNLOAD_BASE, 4000);
35
+ add('install', 'updates', newer(latest, VERSION) ? 'warn' : 'ok', newer(latest, VERSION) ? `${latest} is available` : 'up to date', newer(latest, VERSION) ? 'gitroots update' : null);
36
+ } catch (e) {
37
+ add('install', 'updates', 'warn', `couldn't check (${e.message})`);
38
+ }
39
+
40
+ const git = spawnSync('git', ['--version'], { encoding: 'utf8' });
41
+ add('git', 'git', git.status === 0 ? 'ok' : 'fail', git.status === 0 ? git.stdout.trim() : 'not found', git.status === 0 ? null : 'install git');
42
+ const top = tryGit(['rev-parse', '--show-toplevel']);
43
+ if (top) {
44
+ const hooks = path.resolve(tryGit(['rev-parse', '--git-path', 'hooks']) || '.git/hooks');
45
+ const hook = path.join(hooks, 'pre-push');
46
+ const ours = fs.existsSync(hook) && fs.readFileSync(hook, 'utf8').includes('# gitroots push checks');
47
+ add('git', 'push checks', ours ? 'ok' : 'warn', ours ? `installed in ${path.basename(top)}` : `not installed in ${path.basename(top)}`, ours ? null : 'gitroots hooks install');
48
+ const url = tryGit(['remote', 'get-url', 'origin']);
49
+ const repo = store.repoByRemote(url);
50
+ const cfg = fs.existsSync(path.join(top, '.gitroots.json'));
51
+ add('git', 'rules', repo || cfg ? 'ok' : 'warn', repo ? `gitroots repo ${repo.name}` : cfg ? '.gitroots.json' : 'no rules for this repo · only the secrets scan applies', repo || cfg ? null : 'add a .gitroots.json with "protected": ["main"]');
52
+ } else add('git', 'repository', 'ok', 'not inside a git repo (skipped hook checks)');
53
+
54
+ const cfg = readConfig();
55
+ if (store.api) {
56
+ try {
57
+ const me = await store.api.me();
58
+ add('account', 'signed in', me ? 'ok' : 'fail', me ? `${me.username} · ${cfg.server} · org ${cfg.org ? cfg.org.slug : 'none'}` : 'user not found', me ? null : 'gitroots login');
59
+ } catch (e) { add('account', 'signed in', 'fail', e.message, 'gitroots login'); }
60
+ } else add('account', 'signed in', 'warn', 'no · using the sample org', 'gitroots login');
61
+
62
+ try { fs.mkdirSync(home(), { recursive: true }); fs.accessSync(home(), fs.constants.W_OK); add('account', 'state', 'ok', home()); } catch { add('account', 'state', 'fail', `${home()} is not writable`, 'set GITROOTS_HOME to a writable folder'); }
63
+
64
+ const img = imageProtocol();
65
+ add('terminal', 'colors', colorState.color ? 'ok' : 'warn', colorState.color ? (colorState.truecolor ? 'truecolor' : '256 colors') : 'off (piped or NO_COLOR)');
66
+ add('terminal', 'motion', 'ok', motion() ? 'on' : 'off (piped, NO_COLOR, CI or reduced motion)');
67
+ add('terminal', 'logo image', 'ok', img ? `${img} graphics` : 'plain text logo (Kitty, Ghostty, WezTerm or iTerm2 show the image)');
68
+
69
+ if (flags.json) { json(checks); return checks.some((x) => x.status === 'fail') ? 1 : 0; }
70
+ let group = '';
71
+ for (const x of checks) {
72
+ if (x.group !== group) { if (group) line(); line(c.m(x.group.toUpperCase())); group = x.group; }
73
+ const mark = x.status === 'ok' ? c.g('✓') : x.status === 'warn' ? c.w('◐') : c.r('✕');
74
+ line(` ${mark} ${pad(x.name, 13)}${x.status === 'fail' ? c.r(x.detail) : x.status === 'warn' ? c.w(x.detail) : c.s(x.detail)}${x.fix ? ` ${c.g(`→ ${x.fix}`)}` : ''}`);
75
+ }
76
+ const fails = checks.filter((x) => x.status === 'fail').length;
77
+ const warns = checks.filter((x) => x.status === 'warn').length;
78
+ line();
79
+ line(fails ? c.r(`✕ ${fails} problem${fails > 1 ? 's' : ''}`) + (warns ? c.w(` · ◐ ${warns} to look at`) : '') : warns ? c.w(`◐ ${warns} thing${warns > 1 ? 's' : ''} to look at`) : c.g('✓ all good'));
80
+ return fails ? 1 : 0;
81
+ }
@@ -0,0 +1,322 @@
1
+ // gitroots pr list · view · diff · approve · review · merge
2
+ import { c, h, pad } from '../ui/ansi.js';
3
+ import { line, runSteps, confirm } from '../ui/term.js';
4
+ import { dot, json, next } from '../ui/components.js';
5
+ import { tryGit } from '../git/checks.js';
6
+ import { sampleNote } from '../connected.js';
7
+
8
+ export async function run(_cmd, rest, flags, store) {
9
+ const [sub = 'list', ...args] = rest;
10
+ switch (sub) {
11
+ case 'list': case 'ls': return list(args, flags, store);
12
+ case 'view': case 'show': return view(args, flags, store);
13
+ case 'diff': return diff(args, flags, store);
14
+ case 'approve': return approve(args, flags, store);
15
+ case 'review': return review(args, flags, store);
16
+ case 'merge': return merge(args, flags, store);
17
+ case 'create': case 'new': case 'open': return create(args, flags, store);
18
+ default:
19
+ if (/^#?\d+$/.test(sub)) return view([sub], flags, store);
20
+ throw new Error(`unknown: gitroots pr ${sub} · list, view, diff, create, approve, review, merge`);
21
+ }
22
+ }
23
+
24
+ function stateCell(p, me) {
25
+ if (p.state === 'merged') return c.g(`✓ merged ${p.mergedAt || ''}`.trim());
26
+ if (p.state === 'closed') return c.m('○ closed');
27
+ if (p.state === 'draft') return c.m('○ draft');
28
+ if (p.checks.state === 'fail') return c.r('✕ checks failed');
29
+ if (p.checks.state === 'running') return c.i('○ checks running');
30
+ if (p.changesRequested) return c.r('✕ changes requested');
31
+ if (p.needs && p.needs.who === me && !p.approvals.some((a) => a.by === me)) return c.w('◐ needs you');
32
+ if (p.approvals.length >= p.required) return c.g('✓ ready to merge');
33
+ return c.m(`○ waiting for ${p.required - p.approvals.length} more`);
34
+ }
35
+
36
+ function checksCell(p) {
37
+ return p.checks.state === 'pass' ? c.g('✓') : p.checks.state === 'fail' ? c.r('✕') : c.i('⠼');
38
+ }
39
+
40
+ function list([repo], flags, store) {
41
+ const want = flags.state || 'all';
42
+ let prs = store.prs(repo).filter((p) => want === 'all' || p.state === want);
43
+ prs = [...prs].sort((a, b) => b.number - a.number);
44
+ if (flags.json) { json(prs); return 0; }
45
+ const me = store.org.user;
46
+ line(` ${h(`${pad('#', 7)}${pad('TITLE', 40)}${pad('FROM → INTO', 35)}${pad('AUTHOR', 10)}${pad('APPROVALS', 12)}${pad('CHECKS', 9)}STATE`)}`);
47
+ for (const p of prs) {
48
+ const num = stateCell(p, me).includes('needs you') ? c.b(pad(String(p.number), 7)) : pad(String(p.number), 7);
49
+ const agent = p.agent ? ` ${c.m(p.agent)}` : '';
50
+ line(` ${num}${pad(p.title, 40)}${pad(`${p.from} → ${p.into}`, 35)}${pad(p.author, 10)}${pad(`${p.approvals.length} / ${p.required}`, 12)}${pad(checksCell(p), 9)}${stateCell(p, me)}${agent}`);
51
+ }
52
+ if (!prs.length) line(c.m(' no pull requests'));
53
+ const mine = prs.find((p) => stateCell(p, me).includes('needs you'));
54
+ sampleNote(store, 'pull requests are');
55
+ line();
56
+ line(next(`gitroots pr view ${(mine || prs[0] || { number: '<n>' }).number}`));
57
+ return 0;
58
+ }
59
+
60
+ function needPr(args, store) {
61
+ if (!args[0]) throw new Error('which pull request? e.g. gitroots pr view 412');
62
+ return store.pr(args[0]);
63
+ }
64
+
65
+ function view(args, flags, store) {
66
+ const p = needPr(args, store);
67
+ if (flags.json) { json(p); return 0; }
68
+ const me = store.org.user;
69
+ const add = p.files.reduce((n, f) => n + f.add, 0);
70
+ const del = p.files.reduce((n, f) => n + f.del, 0);
71
+ line(` ${c.b(`#${p.number} ${p.title}`)} ${c.m(`${p.repo} · ${p.from} → ${p.into} · opened by ${p.author} · ${p.opened}`)}`);
72
+ line();
73
+ if (p.files.length) {
74
+ line(` ${c.s(pad('files', 12))}${p.files.length} changed ${c.g(`+${add}`)} ${c.r(`−${del}`)}`);
75
+ for (const f of p.files.slice(0, 3)) {
76
+ line(` ${' '.repeat(12)}${pad(f.path, 51)}${c.g(pad(`+${f.add}`, 5))}${c.r(`−${f.del}`)}${f.flag ? ` ${c.w(`◐ ${f.flag}`)}` : ''}`);
77
+ }
78
+ if (p.files.length > 3) line(` ${' '.repeat(12)}${c.m(`+ ${p.files.length - 3} more · gitroots pr diff ${p.number}`)}`);
79
+ } else {
80
+ line(` ${c.s(pad('commits', 12))}${p.commits}`);
81
+ }
82
+ const ch = p.checks;
83
+ const checks = ch.state === 'pass'
84
+ ? `${c.g('✓')} tests ${ch.tests || 'pass'} ${c.g('✓')} lint ${c.g('✓')} secrets scan ${c.g('✓')} branch rules`
85
+ : ch.state === 'fail' ? `${c.r('✕')} ${ch.failed}` : `${c.i('⠼')} running`;
86
+ line(` ${c.s(pad('checks', 12))}${checks}`);
87
+ const appr = p.approvals.map((a) => `${c.g('✓')} ${a.by} ${c.m(`· ${a.ago}`)}`);
88
+ const missing = p.required - p.approvals.length;
89
+ if (missing > 0) {
90
+ if (p.needs && !p.approvals.some((a) => a.by === p.needs.who)) appr.push(`${c.w('○')} ${p.needs.who === me ? 'you' : p.needs.who} ${c.m(`(needed · ${p.needs.why})`)}`);
91
+ const others = missing - (p.needs && !p.approvals.some((a) => a.by === p.needs.who) ? 1 : 0);
92
+ if (others > 0) appr.push(c.m(`○ ${others} more`));
93
+ }
94
+ line(` ${c.s(pad('approvals', 12))}${appr.join(' ')}`);
95
+ if (p.changesRequested) line(` ${c.s(pad('review', 12))}${c.r('✕')} changes requested by ${p.changesRequested.by} ${c.m(`· ${p.changesRequested.note}`)}`);
96
+ for (const n of p.notes) line(` ${c.s(pad('notes', 12))}${c.w('◐')} ${n}`);
97
+ line();
98
+ if (p.state === 'merged') line(next(`gitroots audit --pr ${p.number}`));
99
+ else if (missing > 0 && !p.approvals.some((a) => a.by === me)) line(` ${c.g(`→ gitroots pr approve ${p.number}`)} ${c.m('or')} ${c.s(`gitroots pr review ${p.number} --request-changes`)} ${dot()} ${c.s(`gitroots pr diff ${p.number}`)}`);
100
+ else if (missing <= 0) line(` ${next(`gitroots pr merge ${p.number}`)}`);
101
+ else line(` ${c.m(`waiting for ${missing} more approval${missing > 1 ? 's' : ''}`)}`);
102
+ return 0;
103
+ }
104
+
105
+ function diff(args, flags, store) {
106
+ const p = needPr(args, store);
107
+ if (flags.json) { json(p.files); return 0; }
108
+ if (!p.files.length) { line(c.m(` no file list recorded for #${p.number}`)); return 0; }
109
+ const max = Math.max(...p.files.map((f) => f.add + f.del));
110
+ line(` ${c.b(`#${p.number}`)} ${c.m(`${p.from} → ${p.into}`)}`);
111
+ line();
112
+ for (const f of p.files) {
113
+ const n = Math.max(1, Math.round(((f.add + f.del) / max) * 30));
114
+ const a = Math.round((f.add / (f.add + f.del || 1)) * n);
115
+ line(` ${pad(f.path, 52)}${c.g(pad(`+${f.add}`, 6))}${c.r(pad(`−${f.del}`, 6))}${c.g('+'.repeat(a))}${c.r('−'.repeat(n - a))}${f.flag ? ` ${c.w(`◐ ${f.flag}`)}` : ''}`);
116
+ }
117
+ line();
118
+ line(next(`gitroots pr approve ${p.number}`));
119
+ return 0;
120
+ }
121
+
122
+ function approve(args, flags, store) {
123
+ const p = needPr(args, store);
124
+ const me = store.org.user;
125
+ if (p.state !== 'open') throw new Error(`#${p.number} is ${p.state}`);
126
+ if (p.author === me) throw new Error(`you opened #${p.number} · someone else has to approve it`);
127
+ if (p.approvals.some((a) => a.by === me)) {
128
+ if (flags.json) { json({ pr: p.number, approvals: p.approvals.length, already: true }); return 0; }
129
+ line(` ${c.m('○')} You already approved #${p.number} ${dot()} ${p.approvals.length} / ${p.required} approvals`);
130
+ return 0;
131
+ }
132
+ p.approvals.push({ by: me, ago: 'just now', note: flags.m || flags.message || '' });
133
+ if (p.changesRequested && p.changesRequested.by === me) delete p.changesRequested;
134
+ const extra = p.needs && p.needs.who === me ? ` ${dot()} ${p.needs.why.replace(/^tech lead for /, 'tech lead sign-off for ')} recorded` : '';
135
+ const ev = store.record({ actor: me, action: 'approve', target: `PR #${p.number}`, decision: 'recorded', note: flags.m || '', repo: p.repo, pr: p.number });
136
+ store.save();
137
+ if (flags.json) { json({ pr: p.number, approvals: p.approvals.length, required: p.required, event: ev.id }); return 0; }
138
+ line(` ${c.g('✓')} Approved #${p.number} ${dot()} ${p.approvals.length} / ${p.required} approvals${extra} ${c.m(`· ${ev.id}`)}`);
139
+ line();
140
+ line(p.approvals.length >= p.required ? next(`gitroots pr merge ${p.number}`) : c.m(` waiting for ${p.required - p.approvals.length} more`));
141
+ return 0;
142
+ }
143
+
144
+ function review(args, flags, store) {
145
+ const p = needPr(args, store);
146
+ const me = store.org.user;
147
+ const note = flags.m || flags.message || '';
148
+ if (flags.approve) return approve(args, flags, store);
149
+ if (flags['request-changes']) {
150
+ if (!note) throw new Error('say what needs to change: -m "…"');
151
+ p.changesRequested = { by: me, note };
152
+ p.approvals = p.approvals.filter((a) => a.by !== me);
153
+ const ev = store.record({ actor: me, action: 'request changes', target: `PR #${p.number}`, decision: 'recorded', note, repo: p.repo, pr: p.number });
154
+ store.save();
155
+ if (flags.json) { json({ pr: p.number, changesRequested: true, event: ev.id }); return 0; }
156
+ line(` ${c.r('✕')} Changes requested on #${p.number} ${dot()} ${p.author} notified ${c.m(`· ${ev.id}`)}`);
157
+ return 0;
158
+ }
159
+ if (!note) throw new Error('usage: gitroots pr review <n> --request-changes -m "…" | --approve | --comment -m "…"');
160
+ const ev = store.record({ actor: me, action: 'comment', target: `PR #${p.number}`, decision: 'recorded', note, repo: p.repo, pr: p.number });
161
+ store.save();
162
+ if (flags.json) { json({ pr: p.number, comment: note, event: ev.id }); return 0; }
163
+ line(` ${c.g('✓')} Comment added to #${p.number} ${c.m(`· ${ev.id}`)}`);
164
+ return 0;
165
+ }
166
+
167
+ async function merge(args, flags, store) {
168
+ const p = needPr(args, store);
169
+ const me = store.org.user;
170
+ if (p.state === 'draft') throw new Error(`#${p.number} is a draft · mark it ready first`);
171
+ if (p.state !== 'open') throw new Error(`#${p.number} is already ${p.state}`);
172
+ const rule = store.ruleFor(p.repo, p.into);
173
+ const method = flags.rebase ? 'rebase' : flags['merge-commit'] ? 'merge commit' : 'squash';
174
+ const json_ = Boolean(flags.json);
175
+
176
+ if (!json_) {
177
+ line(` ${c.b(`#${p.number} ${p.title}`)} ${c.m(`${p.from} → ${p.into} · ${p.commits} commits · ${method}`)}`);
178
+ const ok = await confirm(`Merge into ${c.b(p.into)}?`, { assumeYes: flags.yes || flags.y });
179
+ if (!ok) { line(c.m(' nothing merged')); return 1; }
180
+ line();
181
+ }
182
+
183
+ const conflictBase = store.branches(p.repo).find((b) => b.name === p.into)?.commit || 'HEAD';
184
+ const steps = [
185
+ {
186
+ name: 'Approvals', live: 'counting approvals…', ms: 600,
187
+ run: async () => (p.approvals.length >= p.required
188
+ ? { detail: `${p.approvals.length} / ${p.required} · ${p.approvals.map((a) => a.by).join(', ')}` }
189
+ : { status: 'fail', detail: `${p.approvals.length} / ${p.required} · needs ${p.required - p.approvals.length} more` }),
190
+ },
191
+ {
192
+ name: 'Checks', live: 'waiting for the latest run…', ms: 900,
193
+ run: async () => (p.checks.state === 'pass' ? { detail: `tests ${p.checks.tests || 'pass'} · lint · secrets scan` }
194
+ : p.checks.state === 'fail' ? { status: 'fail', detail: p.checks.failed } : { status: 'fail', detail: 'still running · try again when they finish' }),
195
+ },
196
+ {
197
+ name: 'Branch rules', live: `checking ${p.into} rules…`, ms: 600,
198
+ run: async () => (p.changesRequested ? { status: 'fail', detail: `changes requested by ${p.changesRequested.by}` }
199
+ : { detail: `${p.into}: ${rule.protected ? `PR only, ${rule.approvals} approvals, checks green` : 'open branch'}` }),
200
+ },
201
+ { name: 'Conflicts', live: `test-merging into ${p.into}…`, ms: 800, ok: `none with ${p.into} at ${conflictBase}` },
202
+ { name: 'Merge', live: `pushing to ${store.repo(p.repo).remote}…`, ms: 1000, ok: method === 'squash' ? `squashed ${p.commits} commits` : method === 'rebase' ? `rebased ${p.commits} commits` : 'merge commit created' },
203
+ ];
204
+
205
+ const results = json_ ? await silentSteps(steps) : await runSteps(steps, { nameWidth: 16, stopOnFail: true, showQueued: true });
206
+ const failed = results.find((r) => r.status === 'fail');
207
+ if (failed) {
208
+ const ev = store.record({ actor: me, action: 'merge', target: `PR #${p.number} → ${p.into}`, decision: 'blocked', note: failed.detail, repo: p.repo, pr: p.number });
209
+ store.save();
210
+ if (json_) { json({ merged: false, pr: p.number, reason: failed.detail, event: ev.id }); return 1; }
211
+ line();
212
+ line(` ${c.r(c.b(`✕ Not merged`))} ${failed.name.trim()}: ${failed.detail} ${c.m(`· ${ev.id}`)}`);
213
+ line();
214
+ line(next(`gitroots pr view ${p.number}`));
215
+ return 1;
216
+ }
217
+
218
+ const sha = Math.floor(Math.random() * 0xfffffff).toString(16).padStart(7, '0');
219
+ const now = new Date();
220
+ const hhmm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
221
+ p.state = 'merged';
222
+ p.mergedAt = hhmm;
223
+ p.mergeSha = sha;
224
+ const branches = store.d.branches[p.repo] || [];
225
+ const target = branches.find((b) => b.name === p.into);
226
+ if (target) { target.commit = sha; target.by = me; target.age = 'now'; }
227
+ if (!flags['keep-branch']) {
228
+ const idx = branches.findIndex((b) => b.name === p.from);
229
+ if (idx > -1) {
230
+ store.d.deletedBranches = store.d.deletedBranches || {};
231
+ (store.d.deletedBranches[p.repo] = store.d.deletedBranches[p.repo] || []).push(branches[idx]);
232
+ branches.splice(idx, 1);
233
+ }
234
+ }
235
+ const repo = store.repo(p.repo);
236
+ repo.openPrs = Math.max(0, repo.openPrs - 1);
237
+ repo.lastPush = 'just now';
238
+ const ev = store.record({ actor: me, action: 'merge', target: `PR #${p.number} → ${p.into}`, decision: 'allowed', note: `${p.approvals.length} approvals · checks green`, repo: p.repo, pr: p.number });
239
+ store.save();
240
+
241
+ if (json_) { json({ merged: true, pr: p.number, into: p.into, sha, event: ev.id }); return 0; }
242
+ const ahead = store.prs(p.repo).find((x) => x.state === 'open' && x.from === p.into);
243
+ line();
244
+ line(` ${c.g(c.b(`✓ Merged #${p.number} into ${p.into}`))} ${c.m('as')} ${sha} ${c.m(`· by ${me} · ${hhmm}`)}`);
245
+ if (!flags['keep-branch']) line(` ${c.s(pad('branch', 10))}${p.from} deleted ${c.m(`(restore with gitroots branch restore ${p.repo} ${p.from})`)}`);
246
+ line(` ${c.s(pad('audit', 10))}${ev.id} ${dot()} approvals, checks and the merge are recorded together`);
247
+ if (ahead) line(` ${c.s(pad('next', 10))}release PR #${ahead.number} waits for ${ahead.required - ahead.approvals.length} approval${ahead.required - ahead.approvals.length === 1 ? '' : 's'}`);
248
+ line();
249
+ line(` ${next(ahead ? `gitroots pr view ${ahead.number}` : `gitroots pr list ${p.repo}`, `gitroots audit --pr ${p.number}`)}`);
250
+ return 0;
251
+ }
252
+
253
+ async function silentSteps(steps) {
254
+ const out = [];
255
+ for (const s of steps) {
256
+ const r = s.run ? await s.run({ progress() {} }) : {};
257
+ const res = { ...s, ...r, status: r.status || 'ok', detail: r.detail ?? s.ok };
258
+ out.push(res);
259
+ if (res.status === 'fail') break;
260
+ }
261
+ return out;
262
+ }
263
+
264
+ // gitroots pr create [--base staging] [--head branch] [--title "…"] [--repo erp] [--draft]
265
+ function create(args, flags, store) {
266
+ const cwd = process.cwd();
267
+ const inGit = Boolean(tryGit(['rev-parse', '--show-toplevel'], cwd));
268
+ const head = flags.head || (inGit ? tryGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd) : '');
269
+ if (!head) throw new Error('which branch? run inside a git repo or pass --head <branch>');
270
+ if (inGit && flags.head && !tryGit(['rev-parse', '--verify', '--quiet', flags.head], cwd)) throw new Error(`no branch ${flags.head} in this repo`);
271
+ const url = inGit ? tryGit(['remote', 'get-url', 'origin'], cwd) : '';
272
+ const repo = flags.repo ? store.repo(flags.repo) : store.repoByRemote(url);
273
+ if (!repo) throw new Error(`this repository is not in ${store.org.name} · pass --repo <name>`);
274
+ const base = flags.base || flags.into || (store.ruleFor(repo.name, 'staging').protected ? 'staging' : repo.default);
275
+ if (head === base) throw new Error(`head and base are both ${base} · switch to your feature branch`);
276
+ const rule = store.ruleFor(repo.name, base);
277
+ if (store.prs(repo.name).some((p) => p.state === 'open' && p.from === head && p.into === base)) throw new Error(`a PR from ${head} into ${base} is already open`);
278
+
279
+ // Real numbers when the commits are here: files and lines changed against the base.
280
+ let files = [];
281
+ let commits = 0;
282
+ if (inGit) {
283
+ const baseRef = ['origin/' + base, base].find((r) => tryGit(['rev-parse', '--verify', '--quiet', r], cwd));
284
+ if (baseRef) {
285
+ commits = Number(tryGit(['rev-list', '--count', `${baseRef}..${head}`], cwd)) || 0;
286
+ files = tryGit(['diff', '--numstat', `${baseRef}...${head}`], cwd).split('\n').filter(Boolean).map((l) => {
287
+ const [a, d, p] = l.split('\t');
288
+ return { path: p, add: Number(a) || 0, del: Number(d) || 0 };
289
+ });
290
+ }
291
+ }
292
+ const paths = store.protectedPaths(repo.name);
293
+ const notes = [];
294
+ let needs;
295
+ for (const f of files) {
296
+ if (/(^|\/)migrations?\//.test(f.path)) { f.flag = 'changes data'; needs = { who: store.org.user, why: 'tech lead for migrations' }; }
297
+ const hit = paths.find((p) => store.matchPath(p.pattern, f.path));
298
+ if (hit) { f.flag = `needs ${hit.needs}`; notes.push(`${f.path} needs ${hit.needs}`); }
299
+ }
300
+ const title = flags.title || flags.t || (inGit ? tryGit(['log', '-1', '--format=%s', head], cwd) : '') || head;
301
+ const number = Math.max(...store.d.prs.map((p) => p.number)) + 1;
302
+ const pr = {
303
+ number, repo: repo.name, title, from: head, into: base, author: store.org.user, opened: 'just now', at: new Date().toISOString(),
304
+ required: rule.approvals || 1, approvals: [], checks: { state: 'running' }, state: flags.draft ? 'draft' : 'open', commits: commits || 1, files, notes,
305
+ };
306
+ if (needs) pr.needs = needs;
307
+ store.d.prs.push(pr);
308
+ repo.openPrs += 1;
309
+ const ev = store.record({ actor: store.org.user, action: 'open pr', target: `PR #${number} ${head} → ${base}`, decision: 'allowed', repo: repo.name, pr: number });
310
+ store.save();
311
+ if (flags.json) { json({ ...pr, event: ev.id }); return 0; }
312
+ const add = files.reduce((n, f) => n + f.add, 0);
313
+ const del = files.reduce((n, f) => n + f.del, 0);
314
+ line(` ${c.g('✓')} ${c.b(`Opened #${number} ${title}`)}${flags.draft ? c.m(' (draft)') : ''}`);
315
+ line(` ${c.s(pad('branch', 11))}${head} → ${base} ${dot()} ${repo.name}`);
316
+ if (files.length) line(` ${c.s(pad('changes', 11))}${files.length} files ${c.g(`+${add}`)} ${c.r(`−${del}`)} ${dot()} ${commits} commit${commits === 1 ? '' : 's'}`);
317
+ line(` ${c.s(pad('rules', 11))}${rule.protected ? `${rule.approvals} approval${rule.approvals === 1 ? '' : 's'} · checks green` : '1 approval'}${notes.length ? ` ${dot()} ${c.w(`◐ ${notes.length} protected path${notes.length > 1 ? 's' : ''}`)}` : ''}`);
318
+ line(` ${c.s(pad('checks', 11))}${c.i('○ running')}`);
319
+ line();
320
+ line(next(`gitroots pr view ${number}`));
321
+ return 0;
322
+ }