gitroots 0.5.0 → 0.6.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/src/cli.js CHANGED
@@ -1,15 +1,11 @@
1
1
  import { setColor, c } from './ui/ansi.js';
2
- import { line } from './ui/term.js';
3
- import { brand, dot } from './ui/components.js';
4
- import { Store } from './data/store.js';
2
+ import { line, interactive } from './ui/term.js';
5
3
  import { VERSION } from './runtime.js';
6
- import { connect } from './connected.js';
7
4
 
8
- export { VERSION } from './runtime.js';
5
+ export { VERSION };
9
6
 
10
7
  // Flags that never take a value.
11
- const BOOL = new Set(['password-stdin', 'purge', 'draft', 'json', 'csv', 'all', 'pick', 'read-only', 'shell', 'request-changes', 'approve', 'comment', 'yes', 'y',
12
- 'help', 'h', 'version', 'v', 'no-color', 'fork', 'follow', 'squash', 'rebase', 'merge-commit', 'keep-branch', 'no-hooks', 'force', 'dry-run']);
8
+ const BOOL = new Set(['json', 'help', 'h', 'version', 'v', 'no-color', 'yes', 'y', 'force', 'blocked', 'purge', 'no-browser']);
13
9
 
14
10
  export function parseArgs(argv) {
15
11
  const args = { _: [], flags: {} };
@@ -20,7 +16,6 @@ export function parseArgs(argv) {
20
16
  const eq = a.indexOf('=');
21
17
  if (eq > -1) { args.flags[a.slice(2, eq)] = a.slice(eq + 1); continue; }
22
18
  const key = a.slice(2);
23
- if (key.startsWith('no-') && !BOOL.has(key)) { args.flags[key.slice(3)] = false; continue; }
24
19
  const nxt = argv[i + 1];
25
20
  if (!BOOL.has(key) && nxt !== undefined && !nxt.startsWith('-')) { args.flags[key] = nxt; i++; } else args.flags[key] = true;
26
21
  continue;
@@ -36,115 +31,46 @@ export function parseArgs(argv) {
36
31
  return args;
37
32
  }
38
33
 
39
- const COMMANDS = {
40
- status: () => import('./commands/status.js'),
41
- repo: () => import('./commands/repos.js'),
42
- repos: () => import('./commands/repos.js'),
43
- branch: () => import('./commands/repos.js'),
44
- clone: () => import('./commands/repos.js'),
45
- hooks: () => import('./commands/push.js'),
46
- hook: () => import('./commands/push.js'),
47
- check: () => import('./commands/push.js'),
48
- pr: () => import('./commands/pr.js'),
49
- audit: () => import('./commands/audit.js'),
50
- watch: () => import('./commands/watch.js'),
51
- attach: () => import('./commands/attach.js'),
52
- transfer: () => import('./commands/transfer.js'),
53
- backup: () => import('./commands/backup.js'),
54
- backups: () => import('./commands/backup.js'),
55
- restore: () => import('./commands/restore.js'),
56
- tunnel: () => import('./commands/tunnel.js'),
57
- share: () => import('./commands/tunnel.js'),
58
- sessions: () => import('./commands/watch.js'),
59
- demo: () => import('./commands/demo.js'),
60
- login: () => import('./commands/auth.js'),
61
- logout: () => import('./commands/auth.js'),
62
- whoami: () => import('./commands/auth.js'),
63
- doctor: () => import('./commands/doctor.js'),
64
- completion: () => import('./commands/completion.js'),
65
- __complete: () => import('./commands/completion.js'),
66
- update: () => import('./commands/update.js'),
67
- upgrade: () => import('./commands/update.js'),
68
- uninstall: () => import('./commands/update.js'),
69
- };
70
-
71
34
  export async function main(argv) {
72
35
  const args = parseArgs(argv);
73
- if (args.flags.color === false || args.flags['no-color']) setColor(false);
74
- if (args.flags.json || args.flags.csv) setColor(false);
36
+ if (args.flags['no-color'] || args.flags.json) setColor(false);
75
37
  if (args.flags.version || args.flags.v) { line(`gitroots ${VERSION}`); return 0; }
38
+ const [name, ...rest] = args._;
76
39
 
77
- const [cmd, ...rest] = args._;
78
- if (!cmd) {
79
- if (args.flags.help || args.flags.h) { help(); return 0; }
80
- const { default: splash } = await import('./commands/splash.js');
81
- return splash(connect(Store.open()), args);
40
+ // Hidden plumbing
41
+ if (name === 'hook') {
42
+ const fs = await import('node:fs');
43
+ const { prePush } = await import('./commands/push.js');
44
+ if (rest[0] !== 'pre-push') throw new Error(`unknown hook ${rest[0]}`);
45
+ let input = '';
46
+ try { input = fs.readFileSync(0, 'utf8'); } catch { /* no stdin */ }
47
+ return prePush({ remote: rest[1], url: rest[2], input });
82
48
  }
83
- if (cmd === 'help' || args.flags.help || args.flags.h) { help(cmd === 'help' ? rest[0] : cmd); return 0; }
84
- if (cmd === 'version') { line(`gitroots ${VERSION}`); return 0; }
49
+ if (name === 'completion') return (await import('./commands/completion.js')).run(rest, args.flags);
50
+ if (name === 'uninstall') return (await import('./commands/update.js')).run('uninstall', rest, args.flags);
51
+ if (name === 'version') { line(`gitroots ${VERSION}`); return 0; }
85
52
 
86
- const load = COMMANDS[cmd];
87
- if (!load) {
88
- line(`${c.r('✕')} unknown command ${c.b(cmd)}`);
89
- line(` ${c.g('→ gitroots help')}`);
90
- return 2;
91
- }
92
- const mod = await load();
93
- const store = connect(Store.open());
94
- const code = await mod.run(cmd, rest, args.flags, store);
95
- return code ?? 0;
96
- }
53
+ const { find, suggest } = await import('./session/registry.js');
54
+ const { makeCtx, printHelp, startSession } = await import('./session/repl.js');
97
55
 
98
- const HELP = [
99
- ['repos', 'gitroots repo list · branch list <repo> · branch rules <repo> <branch> · clone <repo>', 'repositories and branch rules'],
100
- ['push', 'git push (after gitroots hooks install) · gitroots check', 'gitroots checks every push before it leaves'],
101
- ['review', 'gitroots pr list · pr view <n> · pr diff <n> · pr approve <n> · pr merge <n>', 'reviews and merges'],
102
- ['audit', 'gitroots audit · audit <ev-id> · audit follow', 'who did what, when, and what was blocked'],
103
- ['agents', 'gitroots watch · attach · transfer · backup · backups · restore · tunnel · share', 'every agent session, one place'],
104
- ['account', 'gitroots login · whoami · logout', 'sign in to your gitroots server'],
105
- ['setup', 'gitroots doctor · update · completion <shell> · uninstall', 'keep the CLI healthy and current'],
106
- ];
107
-
108
- const DETAIL = {
109
- status: ['gitroots status', 'Everything at a glance: what needs you, repositories, agent sessions, the last hour.'],
110
- repo: ['gitroots repo list', 'List repositories you can see, with protected branches and your role.'],
111
- branch: ['gitroots branch list <repo> [--all]\ngitroots branch rules <repo> <branch>', 'Branches and the rules gitroots enforces on them.'],
112
- clone: ['gitroots clone <repo|git-url> [dir] [--url <git-url>]', 'Clone a repository and install the gitroots push checks.'],
113
- hooks: ['gitroots hooks install [--force]\ngitroots hooks uninstall', 'Install the pre-push hook so git push runs gitroots checks first.'],
114
- check: ['gitroots check [<base>..<head>] [--branch <name>]', 'Run the push checks on local commits without pushing.'],
115
- pr: ['gitroots pr list [repo] [--state open|merged|all]\ngitroots pr view <n>\ngitroots pr diff <n>\ngitroots pr create [--base staging] [--head branch] [--title "…"] [--draft]\ngitroots pr approve <n> [-m "note"]\ngitroots pr review <n> --request-changes -m "why"\ngitroots pr merge <n> [--yes] [--squash|--rebase|--merge-commit] [--keep-branch]', 'Pull requests: review, approve and merge under the branch rules.'],
116
- audit: ['gitroots audit [--repo r] [--since 2h] [--actor a] [--action a] [--decision blocked] [--pr n] [--csv]\ngitroots audit <ev-id>\ngitroots audit follow', 'History of every push, approval, merge and rule change, plus a live stream.'],
117
- watch: ['gitroots watch [--machine m]', 'Live dashboard of every agent session. Keys: ↑↓ move · tab machines/sessions · a attach · t transfer · b backup · r restore · f fork · u tunnel · p pause · s stop · / filter · ? help · q quit'],
118
- attach: ['gitroots attach <session>', 'Attach to an agent session. Prefix ctrl-g: d detach · b backup · t transfer · f fork · s share read-only. Everything else goes to the agent.'],
119
- transfer: ['gitroots transfer <session> --to <machine>', 'Move a running agent session to another machine with its conversation and workspace.'],
120
- backup: ['gitroots backup <session> [--label "…"]\ngitroots backups [--repo r] [--session s] [--all]\ngitroots backup policy', 'Snapshot an agent session: conversation, workspace and environment.'],
121
- restore: ['gitroots restore <session|backup> [--machine m] [--pick] [--point turn|backup] [--fork]', 'Bring a session back to an earlier point, on any machine.'],
122
- tunnel: ['gitroots tunnel <session> --port <n> [--shell]\ngitroots tunnel ls\ngitroots tunnel close <t-id> | --all\ngitroots share <session> --read-only [--expires 1h]', 'Reach a port on the agent\'s machine, or share a read-only live view.'],
123
- demo: ['gitroots demo reset', 'Reset the demo organisation to its starting state.'],
124
- login: ['gitroots login [--server https://gitroots.com] [--username u] [--org slug] [--password-stdin]', 'Sign in to a gitroots server. Repositories, branches and audit then come from the API.'],
125
- whoami: ['gitroots whoami', 'Show who you are signed in as.'],
126
- logout: ['gitroots logout', 'Sign out and forget the stored password.'],
127
- doctor: ['gitroots doctor', 'Check the install, git, hooks in this repo, sign-in and terminal support.'],
128
- update: ['gitroots update [--version x.y.z]', 'Update to the latest release, the same way gitroots was installed.'],
129
- completion: ['gitroots completion bash|zsh|fish', 'Print a shell completion script. e.g. gitroots completion zsh > "${fpath[1]}/_gitroots"'],
130
- uninstall: ['gitroots uninstall [--purge]', 'Remove the native install (--purge also removes ~/.gitroots).'],
131
- };
132
- DETAIL.upgrade = DETAIL.update; DETAIL.repos = DETAIL.repo; DETAIL.backups = DETAIL.backup; DETAIL.share = DETAIL.tunnel; DETAIL.hook = DETAIL.hooks;
56
+ if (!name) {
57
+ if (args.flags.help || args.flags.h || !interactive()) { printHelp(false); return 0; }
58
+ return startSession();
59
+ }
60
+ if (name === 'help' || args.flags.help || args.flags.h) { printHelp(false); return 0; }
133
61
 
134
- export function help(topic) {
135
- if (topic && DETAIL[topic]) {
136
- const [usage, desc] = DETAIL[topic];
137
- line(desc);
138
- line();
139
- for (const u of usage.split('\n')) line(` ${c.g(u)}`);
140
- line();
141
- line(c.m(' --json on every command · colors and motion drop when piped or with NO_COLOR'));
142
- return;
62
+ const cmd = find(name);
63
+ if (!cmd || cmd.session || !cmd.run) {
64
+ const near = suggest(name);
65
+ line(`${c.r('✕')} unknown command ${c.b(name)}${near.length ? ` ${c.m('did you mean')} ${near.map((x) => c.g(x.name)).join(', ')}?` : ''}`);
66
+ line(` ${c.m('gitroots help lists them all · gitroots on its own opens a session')}`);
67
+ return 2;
143
68
  }
144
- line(`${brand()} ${c.s(`v${VERSION}`)} ${dot()} ${c.s('code governance and agent sessions')}`);
145
- line();
146
- line(c.m('QUICK START'));
147
- for (const [k, cmds, what] of HELP) line(` ${c.s(k.padEnd(8))} ${c.g(cmds)}`), line(` ${' '.repeat(8)} ${c.m(what)}`);
148
- line();
149
- line(c.m(' gitroots help <command> for details · --json on every command'));
69
+ const ctx = makeCtx({ session: false });
70
+ ctx.json = Boolean(args.flags.json);
71
+ const ac = new AbortController();
72
+ ctx.signal = ac.signal;
73
+ process.on('SIGINT', () => { ac.abort(); process.exit(130); });
74
+ const code = await cmd.run(ctx, rest, args.flags);
75
+ return code ?? 0;
150
76
  }
@@ -1,37 +1,21 @@
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';
1
+ // gitroots completion bash|zsh|fish
2
+ import { COMMANDS } from '../session/registry.js';
9
3
 
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
- }
4
+ const SUBS = { hooks: ['install', 'uninstall'], approvals: ['waiting', 'approved', 'declined'], audit: ['blocked', 'merges', 'access', 'other'] };
5
+ const FLAGS = '--json --help --no-color --blocked --force --server';
6
+
7
+ export async function run(rest) {
21
8
  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);
9
+ const cmds = COMMANDS.filter((c) => !c.session).map((c) => c.name).join(' ');
10
+ const subs = Object.entries(SUBS);
24
11
  if (shell === 'bash') {
25
12
  process.stdout.write(`# gitroots bash completion · eval "$(gitroots completion bash)"
26
13
  _gitroots() {
27
- local cur prev words cword
28
- cur="\${COMP_WORDS[COMP_CWORD]}"; prev="\${COMP_WORDS[COMP_CWORD-1]}"
14
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
29
15
  if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "${cmds}" -- "$cur") ); return; fi
30
16
  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
17
  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") );;
18
+ ${subs.map(([k, v]) => ` ${k}) COMPREPLY=( $(compgen -W "${v.join(' ')}" -- "$cur") );;`).join('\n')}
35
19
  esac
36
20
  }
37
21
  complete -F _gitroots gitroots
@@ -40,16 +24,12 @@ complete -F _gitroots gitroots
40
24
  }
41
25
  if (shell === 'zsh') {
42
26
  process.stdout.write(`#compdef gitroots
43
- # gitroots zsh completion · gitroots completion zsh > "\${fpath[1]}/_gitroots" (or: source <(gitroots completion zsh))
27
+ # gitroots zsh completion · gitroots completion zsh > "\${fpath[1]}/_gitroots"
44
28
  _gitroots() {
45
- local -a cmds
46
- cmds=(${cmds})
47
- if (( CURRENT == 2 )); then compadd -a cmds; return; fi
29
+ if (( CURRENT == 2 )); then compadd -- ${cmds}; return; fi
48
30
  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
31
  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)"};;
32
+ ${subs.map(([k, v]) => ` ${k}) compadd -- ${v.join(' ')};;`).join('\n')}
53
33
  esac
54
34
  }
55
35
  compdef _gitroots gitroots
@@ -57,11 +37,8 @@ compdef _gitroots gitroots
57
37
  return 0;
58
38
  }
59
39
  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}"`);
40
+ const lines = ['# gitroots fish completion · gitroots completion fish > ~/.config/fish/completions/gitroots.fish', 'complete -c gitroots -f', `complete -c gitroots -n __fish_use_subcommand -a "${cmds}"`];
62
41
  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
42
  process.stdout.write(`${lines.join('\n')}\n`);
66
43
  return 0;
67
44
  }
@@ -1,46 +1,19 @@
1
1
  // git push · gitroots checks
2
2
  // gitroots hooks install|uninstall writes .git/hooks/pre-push
3
3
  // gitroots hook pre-push <remote> <url> (called by git, refs on stdin)
4
- // gitroots check [base..head] same checks without pushing
4
+ // gitroots check [base..head] the same checks without pushing
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { selfCommand } from '../runtime.js';
8
8
  import { c, pad } from '../ui/ansi.js';
9
9
  import { line, runSteps } from '../ui/term.js';
10
- import { brand, dot, box, json } from '../ui/components.js';
10
+ import { brand, dot, box } from '../ui/components.js';
11
11
  import { git, tryGit, ZERO, commitsToPush, changedFiles, addedLines, scanSecrets, authors, MIGRATION_RE, lastSubjectSlug } from '../git/checks.js';
12
+ import { loadRules, decide, globMatch } from '../rules.js';
13
+ import { Api } from '../api.js';
12
14
 
13
15
  const MARK = '# gitroots push checks';
14
16
 
15
- export async function run(cmd, rest, flags, store) {
16
- if (cmd === 'hooks') {
17
- const [sub = 'install', dir = '.'] = rest;
18
- if (sub === 'install') {
19
- const r = installHook(dir, { force: flags.force });
20
- if (flags.json) { json(r); return 0; }
21
- line(` ${c.g('✓')} Push checks installed ${c.m(r.path.replace(process.env.HOME || '\u0000', '~'))}${r.chained ? c.m(` · your previous hook runs first (${path.basename(r.chained)})`) : ''}`);
22
- line(` ${c.m('git push now runs gitroots checks first')} ${c.g('→ gitroots check')}`);
23
- return 0;
24
- }
25
- if (sub === 'uninstall') {
26
- const r = uninstallHook(dir);
27
- if (flags.json) { json(r); return 0; }
28
- line(r.removed ? ` ${c.g('✓')} Push checks removed` : ` ${c.m('○ no gitroots hook installed')}`);
29
- return 0;
30
- }
31
- throw new Error('usage: gitroots hooks install|uninstall [dir]');
32
- }
33
- if (cmd === 'hook') {
34
- const [kind, remote, url] = rest;
35
- if (kind !== 'pre-push') throw new Error(`unknown hook ${kind}`);
36
- let input = '';
37
- try { input = fs.readFileSync(0, 'utf8'); } catch { /* no stdin */ }
38
- return prePush({ remote, url, input, store, flags });
39
- }
40
- // check
41
- return checkLocal(rest, flags, store);
42
- }
43
-
44
17
  function hooksDir(dir) {
45
18
  const top = tryGit(['rev-parse', '--show-toplevel'], dir);
46
19
  if (!top) throw new Error(`${path.resolve(dir)} is not a git repository`);
@@ -85,7 +58,7 @@ exit 1
85
58
  return { path: file, chained };
86
59
  }
87
60
 
88
- function uninstallHook(dir = '.') {
61
+ export function uninstallHook(dir = '.') {
89
62
  const hd = hooksDir(dir);
90
63
  const file = path.join(hd, 'pre-push');
91
64
  if (!fs.existsSync(file) || !fs.readFileSync(file, 'utf8').includes(MARK)) return { removed: false };
@@ -95,36 +68,39 @@ function uninstallHook(dir = '.') {
95
68
  return { removed: true, restored: fs.existsSync(file) };
96
69
  }
97
70
 
98
- /**
99
- * Run every check for one ref update. Returns { blocked, reason, results }.
100
- */
101
- async function checkRef({ store, repoName, branch, localSha, remoteSha, cwd, quiet }) {
102
- const commits = commitsToPush(localSha, remoteSha, cwd);
103
- const cfg = repoName ? null : repoConfig(cwd);
104
- const rule = repoName ? store.ruleFor(repoName, branch) : configRule(store, cfg, branch);
105
- const paths = repoName ? store.protectedPaths(repoName) : (cfg?.paths || []);
106
71
 
107
- if (rule.protected && rule.directPush === false) {
108
- return { blocked: true, kind: 'rule', rule, commits };
109
- }
110
- if (!quiet) line(`${brand()} ${c.s(`checking ${commits.length} commit${commits.length === 1 ? '' : 's'} before ${commits.length === 1 ? 'it leaves' : 'they leave'}`)}`);
72
+ export function hookStatus(dir = '.') {
73
+ const top = tryGit(['rev-parse', '--show-toplevel'], dir);
74
+ if (!top) return { repo: null, installed: false };
75
+ const file = path.join(hooksDir(dir), 'pre-push');
76
+ return { repo: top, installed: fs.existsSync(file) && fs.readFileSync(file, 'utf8').includes(MARK), path: file };
77
+ }
78
+
79
+ const SOURCE = { gitroots: 'rules from gitroots', cache: 'rules from gitroots (cached)', '.gitroots.json': 'rules from .gitroots.json', none: 'no branch rules for this repo' };
80
+
81
+ /** Run every check for one ref update. */
82
+ async function checkRef({ set, branch, localSha, remoteSha, cwd }) {
83
+ const commits = commitsToPush(localSha, remoteSha, cwd);
84
+ const verdict = decide(set, branch);
85
+ if (verdict.blocked) return { blocked: true, kind: 'rule', verdict, commits };
86
+ line(`${brand()} ${c.s(`checking ${commits.length} commit${commits.length === 1 ? '' : 's'} before ${commits.length === 1 ? 'it leaves' : 'they leave'}`)} ${c.m(`${dot()} ${SOURCE[set.source]}`)}`);
111
87
  let files = [];
112
88
  let secretHits = [];
113
89
  const steps = [
114
- { name: 'Branch rule', live: `reading rules for ${branch}…`, ms: 400, run: async () => ({ detail: `${rule.name === '*' ? branch : rule.name} · ${rule.label.replace(/^[^·]*· /, '')}` }) },
90
+ { name: 'Branch rule', live: `reading rules for ${branch}…`, ms: 250, run: async () => ({ detail: verdict.label }) },
115
91
  {
116
- name: 'Protected paths', live: 'checking changed files…', ms: 400,
92
+ name: 'Protected paths', live: 'checking changed files…', ms: 250,
117
93
  run: async () => {
118
94
  files = changedFiles(commits, cwd);
119
- if (!paths.length) return { detail: `${files.length} files changed · no protected paths on ${branch}` };
120
- const hits = files.filter((f) => paths.some((p) => store.matchPath(p.pattern, f)));
121
- if (!hits.length) return { detail: `no files under ${paths.map((p) => p.pattern).join(', ')}` };
122
- const p = paths.find((x) => hits.some((f) => store.matchPath(x.pattern, f)));
123
- return { status: 'warn', detail: `${hits.length} file${hits.length > 1 ? 's' : ''} under ${p.pattern} · needs ${p.needs}`, hits };
95
+ if (!set.paths.length) return { detail: `${files.length} file${files.length === 1 ? '' : 's'} changed` };
96
+ const hits = files.filter((f) => set.paths.some((p) => globMatch(p.pattern, f)));
97
+ if (!hits.length) return { detail: `no files under ${set.paths.map((p) => p.pattern).join(', ')}` };
98
+ const p = set.paths.find((x) => hits.some((f) => globMatch(x.pattern, f)));
99
+ return { status: 'warn', detail: `${hits.length} file${hits.length > 1 ? 's' : ''} under ${p.pattern} · needs ${p.needs}` };
124
100
  },
125
101
  },
126
102
  {
127
- name: 'Secrets scan', live: `scanning ${commits.length} commits…`, ms: 600,
103
+ name: 'Secrets scan', live: `scanning ${commits.length} commits…`, ms: 350,
128
104
  run: async () => {
129
105
  secretHits = scanSecrets(addedLines(commits, cwd));
130
106
  if (!secretHits.length) return { detail: '0 keys or passwords found' };
@@ -133,113 +109,73 @@ async function checkRef({ store, repoName, branch, localSha, remoteSha, cwd, qui
133
109
  },
134
110
  },
135
111
  {
136
- name: 'Commit authors', live: 'checking authors…', ms: 300,
112
+ name: 'Commit authors', live: 'checking authors…', ms: 200,
137
113
  run: async () => {
138
114
  const a = authors(commits, cwd);
139
115
  const names = [...new Set(a.map((x) => x.name))];
140
116
  const signed = a.filter((x) => x.signed).length;
141
- const sig = !a.length ? '' : signed === a.length ? ' · verified' : signed ? ` · ${signed} of ${a.length} signed` : ' · unsigned';
117
+ const sig = !a.length ? '' : signed === a.length ? ' · signed' : signed ? ` · ${signed} of ${a.length} signed` : ' · unsigned';
142
118
  return { detail: `${a.length} commit${a.length === 1 ? '' : 's'} · ${names.slice(0, 3).join(', ')}${names.length > 3 ? ` +${names.length - 3}` : ''}${sig}` };
143
119
  },
144
120
  },
145
121
  {
146
- name: 'Migration files', live: 'looking for database changes…', ms: 400,
122
+ name: 'Migration files', live: 'looking for database changes…', ms: 250,
147
123
  run: async () => {
148
124
  const mig = files.filter((f) => MIGRATION_RE.test(f));
149
125
  if (!mig.length) return { detail: 'no database changes' };
150
- return { status: 'warn', detail: `${path.basename(mig[0])}${mig.length > 1 ? ` +${mig.length - 1}` : ''} changes data, flagged for review` };
126
+ return { status: 'warn', detail: `${path.basename(mig[0])}${mig.length > 1 ? ` +${mig.length - 1}` : ''} changes data, flag it for review` };
151
127
  },
152
128
  },
153
129
  ];
154
130
  const results = await runSteps(steps, { nameWidth: 20, showQueued: true });
155
- if (secretHits.length) return { blocked: true, kind: 'secret', hits: secretHits, rule, commits, results };
156
- return { blocked: false, rule, commits, results, files };
157
- }
158
-
159
- // Rules for repositories outside the gitroots org: .gitroots.json at the repo root.
160
- // { "protected": ["main", "release/*"], "paths": [{ "pattern": "infra/**", "needs": "an ops review" }] }
161
- export function repoConfig(cwd) {
162
- const top = tryGit(['rev-parse', '--show-toplevel'], cwd);
163
- if (!top) return null;
164
- try {
165
- const cfg = JSON.parse(fs.readFileSync(path.join(top, '.gitroots.json'), 'utf8'));
166
- return { repo: cfg.repo || path.basename(top), protected: cfg.protected || [], paths: (cfg.paths || []).map((p) => (typeof p === 'string' ? { pattern: p, needs: 'a review' } : p)), suggest: cfg.suggest };
167
- } catch { return null; }
131
+ if (secretHits.length) return { blocked: true, kind: 'secret', hits: secretHits, commits, results };
132
+ return { blocked: false, commits, results };
168
133
  }
169
134
 
170
- function configRule(store, cfg, branch) {
171
- if (!cfg) return { name: '*', protected: false, directPush: true, label: 'repository not managed by gitroots' };
172
- const hit = cfg.protected.find((p) => store.matchPath(p, branch));
173
- if (hit) return { name: hit, protected: true, directPush: false, label: `${hit}: no direct push, PR only`, suggest: cfg.suggest || 'push to a feature branch, then open a PR' };
174
- return { name: '*', protected: false, directPush: true, label: 'developers can push · .gitroots.json' };
135
+ function ruleBox({ repo, branch, verdict, localSha, cwd, remote }) {
136
+ const slug = lastSubjectSlug(localSha, cwd);
137
+ box([
138
+ `${brand()} ${c.r(c.b('✕ Push blocked'))} ${c.m('nothing left your machine')}`,
139
+ ` ${c.s('repo')} ${repo} ${dot()} ${c.s('branch')} ${branch} ${dot()} ${c.s('rule')} ${verdict.label}`,
140
+ ` ${c.s('you can')} ${verdict.suggest}`,
141
+ ` ${c.g(`→ git switch -c feature/${slug} && git push -u ${remote || 'origin'} feature/${slug}`)}`,
142
+ ], 'r');
175
143
  }
176
144
 
177
- function blockedBox(store, { repoName, branch, rule, localSha, cwd, ev, remote }) {
178
- const slug = lastSubjectSlug(localSha, cwd);
145
+ function secretBox(hits) {
179
146
  box([
180
147
  `${brand()} ${c.r(c.b('✕ Push blocked'))} ${c.m('nothing left your machine')}`,
181
- ` ${c.s('repo')} ${repoName} ${dot()} ${c.s('branch')} ${branch} ${dot()} ${c.s('rule')} ${rule.label}`,
182
- ` ${c.s('you can')} ${rule.suggest || 'push to a feature branch, then open a PR'}`,
183
- ` ${c.g(`→ git switch -c feature/${slug} && git push -u ${remote || 'origin'} feature/${slug}`)} ${c.m(`this attempt is logged · ${ev.id}`)}`,
148
+ ...hits.slice(0, 4).map((x) => ` ${c.r('✕')} ${pad(x.kind, 18)} ${x.file}:${x.line} ${c.m(`commit ${x.sha.slice(0, 7)}`)}`),
149
+ ` ${c.s('you can')} remove the secret, rotate it, then rewrite the commit (git commit --amend or git rebase -i)`,
184
150
  ], 'r');
185
151
  }
186
152
 
187
- export async function prePush({ remote, url, input, store, flags }) {
153
+ export async function prePush({ remote, input }) {
188
154
  const cwd = process.cwd();
189
- const repo = store.repoByRemote(url);
190
- const repoName = repo ? repo.name : null;
191
- const shownName = repoName || repoConfig(cwd)?.repo || path.basename(cwd);
155
+ const set = await loadRules(cwd, { api: Api.fromConfig(), timeout: 2500 });
192
156
  const updates = input.split('\n').map((l) => l.trim().split(/\s+/)).filter((p) => p.length === 4);
193
157
  let code = 0;
194
- for (const [localRef, localSha, remoteRef, remoteSha] of updates) {
158
+ for (const [, localSha, remoteRef, remoteSha] of updates) {
195
159
  if (!remoteRef.startsWith('refs/heads/')) continue; // tags are not checked
196
160
  const branch = remoteRef.slice('refs/heads/'.length);
197
161
  if (ZERO.test(localSha)) {
198
- const rule = repoName ? store.ruleFor(repoName, branch) : configRule(store, repoConfig(cwd), branch);
199
- if (rule.protected) {
200
- const ev = store.record({ actor: store.org.user, action: 'delete branch', target: `${branch}`, decision: 'blocked', note: rule.label, repo: shownName, rule: rule.label });
201
- store.save();
202
- line(`${brand()} ${c.r('✕ Deleting a protected branch is blocked')} ${c.m(`${shownName} · ${branch} · ${ev.id}`)}`);
203
- code = 1;
204
- }
205
- continue;
206
- }
207
- const res = await checkRef({ store, repoName, branch, localSha, remoteSha, cwd });
208
- if (res.blocked && res.kind === 'rule') {
209
- const ev = store.record({ actor: store.org.user, action: 'push', target: `${branch} · ${res.commits.length} commit${res.commits.length === 1 ? '' : 's'}`, decision: 'blocked', note: res.rule.label, repo: shownName, rule: res.rule.label, ack: true });
210
- store.save();
211
- blockedBox(store, { repoName: shownName, branch, rule: res.rule, localSha, cwd, ev, remote });
212
- code = 1;
213
- continue;
214
- }
215
- if (res.blocked) {
216
- const h0 = res.hits[0];
217
- const ev = store.record({ actor: store.org.user, action: 'push', target: `${branch} · secrets scan`, decision: 'blocked', note: `${h0.kind} in ${path.basename(h0.file)}`, repo: shownName });
218
- store.save();
219
- line();
220
- box([
221
- `${brand()} ${c.r(c.b('✕ Push blocked'))} ${c.m('nothing left your machine')}`,
222
- ...res.hits.slice(0, 4).map((x) => ` ${c.r('✕')} ${pad(x.kind, 18)} ${x.file}:${x.line} ${c.m(`commit ${x.sha.slice(0, 7)}`)}`),
223
- ` ${c.s('you can')} remove the secret, rotate it, then rewrite the commit (git commit --amend or git rebase -i)`,
224
- ` ${c.m(`this attempt is logged · ${ev.id}`)}`,
225
- ], 'r');
226
- code = 1;
162
+ const v = decide(set, branch);
163
+ if (v.blocked) { line(`${brand()} ${c.r('✕ Deleting a protected branch is blocked')} ${c.m(`${set.repo} · ${v.label}`)}`); code = 1; }
227
164
  continue;
228
165
  }
166
+ const res = await checkRef({ set, branch, localSha, remoteSha, cwd });
167
+ if (res.blocked && res.kind === 'rule') { ruleBox({ repo: set.repo, branch, verdict: res.verdict, localSha, cwd, remote }); code = 1; continue; }
168
+ if (res.blocked) { line(); secretBox(res.hits); code = 1; continue; }
229
169
  const warns = res.results.filter((r) => r.status === 'warn').length;
230
- store.record({ actor: store.org.user, action: 'push', target: `${branch} · ${res.commits.length} commit${res.commits.length === 1 ? '' : 's'}`, decision: 'allowed', note: warns ? `${warns} note${warns > 1 ? 's' : ''} for review` : 'checks passed', repo: shownName });
231
- store.save();
232
- const pr = repoName && store.prs(repoName).find((p) => p.from === branch && p.state === 'open');
233
- const tail = pr ? `PR #${pr.number} will update ${dot()} reviewers notified` : `pushing to ${remote || 'origin'}`;
234
- line(`${brand()} ${c.g('✓ Checks passed')} ${dot()} ${tail}${warns ? ` ${dot()} ${warns} note${warns > 1 ? 's' : ''} for review` : ''}${pr ? ` ${c.g(`→ gitroots pr view ${pr.number}`)}` : ''}`);
170
+ line(`${brand()} ${c.g('✓ Checks passed')} ${dot()} pushing to ${remote || 'origin'}${warns ? ` ${dot()} ${c.w(`${warns} note${warns > 1 ? 's' : ''} for review`)}` : ''}`);
235
171
  }
236
- if (flags?.json) json({ ok: code === 0 });
237
172
  return code;
238
173
  }
239
174
 
240
- async function checkLocal(rest, flags, store) {
175
+ /** gitroots check / the /check command: the push checks on local commits, without pushing. */
176
+ export async function checkLocal(rest = [], { api, branch: branchFlag } = {}) {
241
177
  const cwd = process.cwd();
242
- if (!tryGit(['rev-parse', '--show-toplevel'], cwd)) throw new Error('not inside a git repository');
178
+ if (!tryGit(['rev-parse', '--show-toplevel'], cwd)) throw new Error('not inside a git repository · cd into one, or run /hooks there');
243
179
  let localSha;
244
180
  let remoteSha = '';
245
181
  const range = rest[0];
@@ -249,18 +185,16 @@ async function checkLocal(rest, flags, store) {
249
185
  localSha = git(['rev-parse', b || 'HEAD'], cwd).trim();
250
186
  } else {
251
187
  localSha = git(['rev-parse', range || 'HEAD'], cwd).trim();
252
- const up = tryGit(['rev-parse', '@{upstream}'], cwd);
253
- remoteSha = up || '';
188
+ remoteSha = tryGit(['rev-parse', '@{upstream}'], cwd) || '';
254
189
  }
255
190
  const current = tryGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
256
- const branch = flags.branch || current;
257
- const url = tryGit(['remote', 'get-url', tryGit(['config', `branch.${current}.remote`], cwd) || 'origin'], cwd);
258
- const repo = store.repoByRemote(url) || (flags.repo ? store.repo(flags.repo) : null);
259
- line(`${c.m(cwd.replace(process.env.HOME || '\u0000', '~'))} ${c.i(branch)}${repo ? c.m(` ${dot()} gitroots repo ${repo.name}`) : (repoConfig(cwd) ? c.m(` ${dot()} rules from .gitroots.json`) : c.m(` ${dot()} not a gitroots repo, default rules`))}`);
260
- const res = await checkRef({ store, repoName: repo?.name, branch, localSha, remoteSha, cwd });
191
+ const branch = branchFlag || current;
192
+ const set = await loadRules(cwd, { api });
193
+ line(`${c.m(cwd.replace(process.env.HOME || '\u0000', '~'))} ${c.i(branch)} ${c.m(`${dot()} ${set.repo || ''} ${dot()} ${SOURCE[set.source]}`)}`);
194
+ const res = await checkRef({ set, branch, localSha, remoteSha, cwd });
261
195
  if (res.blocked && res.kind === 'rule') {
262
- line(`${brand()} ${c.r('✕ This push would be blocked')} ${c.m(`rule ${res.rule.label}`)}`);
263
- line(` ${c.s('you can')} ${res.rule.suggest || 'push to a feature branch, then open a PR'}`);
196
+ line(`${brand()} ${c.r('✕ A push to this branch would be blocked')} ${c.m(res.verdict.label)}`);
197
+ line(` ${c.s('you can')} ${res.verdict.suggest}`);
264
198
  return 1;
265
199
  }
266
200
  if (res.blocked) {
@@ -268,6 +202,6 @@ async function checkLocal(rest, flags, store) {
268
202
  line(`${brand()} ${c.r('✕ This push would be blocked')} ${c.m('remove the secret and rewrite the commit')}`);
269
203
  return 1;
270
204
  }
271
- line(`${brand()} ${c.g('✓ Ready to push')} ${c.g('→ git push')}`);
205
+ line(`${brand()} ${c.g('✓ Ready to push')}`);
272
206
  return 0;
273
207
  }
@@ -7,7 +7,7 @@ import { line, spinner, bar, confirm } from '../ui/term.js';
7
7
  import { json } from '../ui/components.js';
8
8
  import { VERSION, NPM_PACKAGE, DOWNLOAD_BASE, installMethod, nativePaths, platformKey } from '../runtime.js';
9
9
  import { latestVersion, installNative, newer, onPath } from '../installer.js';
10
- import { home } from '../data/store.js';
10
+ import { home } from '../config.js';
11
11
 
12
12
  export async function run(cmd, rest, flags) {
13
13
  if (cmd === 'uninstall') return uninstall(flags);