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/README.md +76 -76
- package/bin/gitroots.js +8 -0
- package/package.json +2 -1
- package/src/agents/local.js +219 -0
- package/src/api.js +61 -98
- package/src/cli.js +36 -110
- package/src/commands/completion.js +14 -37
- package/src/commands/push.js +65 -131
- package/src/commands/update.js +1 -1
- package/src/config.js +66 -0
- package/src/rules.js +88 -0
- package/src/runtime.js +1 -1
- package/src/session/account.js +133 -0
- package/src/session/agents.js +416 -0
- package/src/session/helpers.js +71 -0
- package/src/session/logo.js +40 -0
- package/src/session/registry.js +61 -0
- package/src/session/repl.js +332 -0
- package/src/session/setup.js +101 -0
- package/src/session/workspace.js +270 -0
- package/src/ui/term.js +2 -0
- package/src/commands/attach.js +0 -262
- package/src/commands/audit.js +0 -150
- package/src/commands/auth.js +0 -99
- package/src/commands/backup.js +0 -106
- package/src/commands/demo.js +0 -12
- package/src/commands/doctor.js +0 -81
- package/src/commands/pr.js +0 -322
- package/src/commands/repos.js +0 -122
- package/src/commands/restore.js +0 -125
- package/src/commands/splash.js +0 -95
- package/src/commands/status.js +0 -84
- package/src/commands/transfer.js +0 -79
- package/src/commands/tunnel.js +0 -116
- package/src/commands/watch.js +0 -347
- package/src/connected.js +0 -82
- package/src/data/live.js +0 -49
- package/src/data/sample.js +0 -163
- package/src/data/store.js +0 -145
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 }
|
|
5
|
+
export { VERSION };
|
|
9
6
|
|
|
10
7
|
// Flags that never take a value.
|
|
11
|
-
const BOOL = new Set(['
|
|
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
|
|
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
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
const {
|
|
81
|
-
|
|
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 (
|
|
84
|
-
if (
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
135
|
-
if (
|
|
136
|
-
const
|
|
137
|
-
line(
|
|
138
|
-
line();
|
|
139
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
2
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 =
|
|
23
|
-
const subs = Object.entries(
|
|
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
|
|
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})
|
|
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"
|
|
27
|
+
# gitroots zsh completion · gitroots completion zsh > "\${fpath[1]}/_gitroots"
|
|
44
28
|
_gitroots() {
|
|
45
|
-
|
|
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})
|
|
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
|
}
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
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:
|
|
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:
|
|
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
|
|
120
|
-
const hits = files.filter((f) => paths.some((p) =>
|
|
121
|
-
if (!hits.length) return { detail: `no files under ${paths.map((p) => p.pattern).join(', ')}` };
|
|
122
|
-
const p = paths.find((x) => hits.some((f) =>
|
|
123
|
-
return { status: 'warn', detail: `${hits.length} file${hits.length > 1 ? 's' : ''} under ${p.pattern} · needs ${p.needs}
|
|
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:
|
|
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:
|
|
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 ? ' ·
|
|
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:
|
|
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,
|
|
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,
|
|
156
|
-
return { blocked: false,
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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.
|
|
182
|
-
` ${c.s('you can')}
|
|
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,
|
|
153
|
+
export async function prePush({ remote, input }) {
|
|
188
154
|
const cwd = process.cwd();
|
|
189
|
-
const
|
|
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 [
|
|
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
|
|
199
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
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('✕
|
|
263
|
-
line(` ${c.s('you can')} ${res.
|
|
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')}
|
|
205
|
+
line(`${brand()} ${c.g('✓ Ready to push')}`);
|
|
272
206
|
return 0;
|
|
273
207
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -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 '../
|
|
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);
|