gitroots 0.0.0-stage → 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 +137 -2
- package/assets/logo.png +0 -0
- package/bin/gitroots.js +19 -0
- package/package.json +36 -4
- package/src/agents/local.js +219 -0
- package/src/api.js +84 -0
- package/src/cli.js +76 -0
- package/src/commands/completion.js +46 -0
- package/src/commands/push.js +207 -0
- package/src/commands/update.js +69 -0
- package/src/config.js +66 -0
- package/src/git/checks.js +96 -0
- package/src/installer.js +96 -0
- package/src/rules.js +88 -0
- package/src/runtime.js +63 -0
- 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/ansi.js +138 -0
- package/src/ui/components.js +85 -0
- package/src/ui/term.js +298 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// git push · gitroots checks
|
|
2
|
+
// gitroots hooks install|uninstall writes .git/hooks/pre-push
|
|
3
|
+
// gitroots hook pre-push <remote> <url> (called by git, refs on stdin)
|
|
4
|
+
// gitroots check [base..head] the same checks without pushing
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { selfCommand } from '../runtime.js';
|
|
8
|
+
import { c, pad } from '../ui/ansi.js';
|
|
9
|
+
import { line, runSteps } from '../ui/term.js';
|
|
10
|
+
import { brand, dot, box } from '../ui/components.js';
|
|
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';
|
|
14
|
+
|
|
15
|
+
const MARK = '# gitroots push checks';
|
|
16
|
+
|
|
17
|
+
function hooksDir(dir) {
|
|
18
|
+
const top = tryGit(['rev-parse', '--show-toplevel'], dir);
|
|
19
|
+
if (!top) throw new Error(`${path.resolve(dir)} is not a git repository`);
|
|
20
|
+
return path.resolve(dir, git(['rev-parse', '--git-path', 'hooks'], dir).trim());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function installHook(dir = '.', { force = false } = {}) {
|
|
24
|
+
const hd = hooksDir(dir);
|
|
25
|
+
fs.mkdirSync(hd, { recursive: true });
|
|
26
|
+
const file = path.join(hd, 'pre-push');
|
|
27
|
+
let chained = null;
|
|
28
|
+
if (fs.existsSync(file)) {
|
|
29
|
+
const existing = fs.readFileSync(file, 'utf8');
|
|
30
|
+
if (!existing.includes(MARK)) {
|
|
31
|
+
if (!force) throw new Error(`a pre-push hook already exists at ${file} · rerun with --force to keep it and run it before gitroots`);
|
|
32
|
+
chained = path.join(hd, 'pre-push.local');
|
|
33
|
+
fs.renameSync(file, chained);
|
|
34
|
+
} else if (fs.existsSync(path.join(hd, 'pre-push.local'))) chained = path.join(hd, 'pre-push.local');
|
|
35
|
+
}
|
|
36
|
+
const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
37
|
+
// The gitroots that installed the hook; if it moved (update, node version switch), the launcher or PATH.
|
|
38
|
+
const cmd = selfCommand();
|
|
39
|
+
const self = cmd.map(q).join(' ');
|
|
40
|
+
const script = `#!/bin/sh
|
|
41
|
+
${MARK} · installed by gitroots hooks install
|
|
42
|
+
input=$(cat)
|
|
43
|
+
here=$(dirname "$0")
|
|
44
|
+
if [ -x "$here/pre-push.local" ]; then
|
|
45
|
+
printf '%s\\n' "$input" | "$here/pre-push.local" "$@" || exit $?
|
|
46
|
+
fi
|
|
47
|
+
if [ -x ${q(cmd[0])} ] && { [ ${cmd.length} -eq 1 ] || [ -f ${q(cmd[cmd.length - 1])} ]; }; then
|
|
48
|
+
printf '%s\\n' "$input" | ${self} hook pre-push "$@"; exit $?
|
|
49
|
+
elif [ -x "$HOME/.local/bin/gitroots" ]; then
|
|
50
|
+
printf '%s\\n' "$input" | "$HOME/.local/bin/gitroots" hook pre-push "$@"; exit $?
|
|
51
|
+
elif command -v gitroots >/dev/null 2>&1; then
|
|
52
|
+
printf '%s\\n' "$input" | gitroots hook pre-push "$@"; exit $?
|
|
53
|
+
fi
|
|
54
|
+
echo "gitroots: push checks could not run (gitroots not found) · reinstall with gitroots hooks install" >&2
|
|
55
|
+
exit 1
|
|
56
|
+
`;
|
|
57
|
+
fs.writeFileSync(file, script, { mode: 0o755 });
|
|
58
|
+
return { path: file, chained };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function uninstallHook(dir = '.') {
|
|
62
|
+
const hd = hooksDir(dir);
|
|
63
|
+
const file = path.join(hd, 'pre-push');
|
|
64
|
+
if (!fs.existsSync(file) || !fs.readFileSync(file, 'utf8').includes(MARK)) return { removed: false };
|
|
65
|
+
fs.rmSync(file);
|
|
66
|
+
const local = path.join(hd, 'pre-push.local');
|
|
67
|
+
if (fs.existsSync(local)) fs.renameSync(local, file);
|
|
68
|
+
return { removed: true, restored: fs.existsSync(file) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
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]}`)}`);
|
|
87
|
+
let files = [];
|
|
88
|
+
let secretHits = [];
|
|
89
|
+
const steps = [
|
|
90
|
+
{ name: 'Branch rule', live: `reading rules for ${branch}…`, ms: 250, run: async () => ({ detail: verdict.label }) },
|
|
91
|
+
{
|
|
92
|
+
name: 'Protected paths', live: 'checking changed files…', ms: 250,
|
|
93
|
+
run: async () => {
|
|
94
|
+
files = changedFiles(commits, cwd);
|
|
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}` };
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'Secrets scan', live: `scanning ${commits.length} commits…`, ms: 350,
|
|
104
|
+
run: async () => {
|
|
105
|
+
secretHits = scanSecrets(addedLines(commits, cwd));
|
|
106
|
+
if (!secretHits.length) return { detail: '0 keys or passwords found' };
|
|
107
|
+
const f = secretHits[0];
|
|
108
|
+
return { status: 'fail', detail: `${f.kind} in ${f.file}:${f.line}${secretHits.length > 1 ? ` · ${secretHits.length - 1} more` : ''}` };
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'Commit authors', live: 'checking authors…', ms: 200,
|
|
113
|
+
run: async () => {
|
|
114
|
+
const a = authors(commits, cwd);
|
|
115
|
+
const names = [...new Set(a.map((x) => x.name))];
|
|
116
|
+
const signed = a.filter((x) => x.signed).length;
|
|
117
|
+
const sig = !a.length ? '' : signed === a.length ? ' · signed' : signed ? ` · ${signed} of ${a.length} signed` : ' · unsigned';
|
|
118
|
+
return { detail: `${a.length} commit${a.length === 1 ? '' : 's'} · ${names.slice(0, 3).join(', ')}${names.length > 3 ? ` +${names.length - 3}` : ''}${sig}` };
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'Migration files', live: 'looking for database changes…', ms: 250,
|
|
123
|
+
run: async () => {
|
|
124
|
+
const mig = files.filter((f) => MIGRATION_RE.test(f));
|
|
125
|
+
if (!mig.length) return { detail: 'no database changes' };
|
|
126
|
+
return { status: 'warn', detail: `${path.basename(mig[0])}${mig.length > 1 ? ` +${mig.length - 1}` : ''} changes data, flag it for review` };
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
const results = await runSteps(steps, { nameWidth: 20, showQueued: true });
|
|
131
|
+
if (secretHits.length) return { blocked: true, kind: 'secret', hits: secretHits, commits, results };
|
|
132
|
+
return { blocked: false, commits, results };
|
|
133
|
+
}
|
|
134
|
+
|
|
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');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function secretBox(hits) {
|
|
146
|
+
box([
|
|
147
|
+
`${brand()} ${c.r(c.b('✕ Push blocked'))} ${c.m('nothing left your machine')}`,
|
|
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)`,
|
|
150
|
+
], 'r');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function prePush({ remote, input }) {
|
|
154
|
+
const cwd = process.cwd();
|
|
155
|
+
const set = await loadRules(cwd, { api: Api.fromConfig(), timeout: 2500 });
|
|
156
|
+
const updates = input.split('\n').map((l) => l.trim().split(/\s+/)).filter((p) => p.length === 4);
|
|
157
|
+
let code = 0;
|
|
158
|
+
for (const [, localSha, remoteRef, remoteSha] of updates) {
|
|
159
|
+
if (!remoteRef.startsWith('refs/heads/')) continue; // tags are not checked
|
|
160
|
+
const branch = remoteRef.slice('refs/heads/'.length);
|
|
161
|
+
if (ZERO.test(localSha)) {
|
|
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; }
|
|
164
|
+
continue;
|
|
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; }
|
|
169
|
+
const warns = res.results.filter((r) => r.status === 'warn').length;
|
|
170
|
+
line(`${brand()} ${c.g('✓ Checks passed')} ${dot()} pushing to ${remote || 'origin'}${warns ? ` ${dot()} ${c.w(`${warns} note${warns > 1 ? 's' : ''} for review`)}` : ''}`);
|
|
171
|
+
}
|
|
172
|
+
return code;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** gitroots check / the /check command: the push checks on local commits, without pushing. */
|
|
176
|
+
export async function checkLocal(rest = [], { api, branch: branchFlag } = {}) {
|
|
177
|
+
const cwd = process.cwd();
|
|
178
|
+
if (!tryGit(['rev-parse', '--show-toplevel'], cwd)) throw new Error('not inside a git repository · cd into one, or run /hooks there');
|
|
179
|
+
let localSha;
|
|
180
|
+
let remoteSha = '';
|
|
181
|
+
const range = rest[0];
|
|
182
|
+
if (range && range.includes('..')) {
|
|
183
|
+
const [a, b] = range.split('..');
|
|
184
|
+
remoteSha = git(['rev-parse', a || 'HEAD'], cwd).trim();
|
|
185
|
+
localSha = git(['rev-parse', b || 'HEAD'], cwd).trim();
|
|
186
|
+
} else {
|
|
187
|
+
localSha = git(['rev-parse', range || 'HEAD'], cwd).trim();
|
|
188
|
+
remoteSha = tryGit(['rev-parse', '@{upstream}'], cwd) || '';
|
|
189
|
+
}
|
|
190
|
+
const current = tryGit(['rev-parse', '--abbrev-ref', 'HEAD'], 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 });
|
|
195
|
+
if (res.blocked && res.kind === 'rule') {
|
|
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}`);
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
200
|
+
if (res.blocked) {
|
|
201
|
+
for (const x of res.hits.slice(0, 6)) line(` ${c.r('✕')} ${pad(x.kind, 18)} ${x.file}:${x.line} ${c.m(`commit ${x.sha.slice(0, 7)}`)}`);
|
|
202
|
+
line(`${brand()} ${c.r('✕ This push would be blocked')} ${c.m('remove the secret and rewrite the commit')}`);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
line(`${brand()} ${c.g('✓ Ready to push')}`);
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// gitroots update · uninstall
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { c } from '../ui/ansi.js';
|
|
6
|
+
import { line, spinner, bar, confirm } from '../ui/term.js';
|
|
7
|
+
import { json } from '../ui/components.js';
|
|
8
|
+
import { VERSION, NPM_PACKAGE, DOWNLOAD_BASE, installMethod, nativePaths, platformKey } from '../runtime.js';
|
|
9
|
+
import { latestVersion, installNative, newer, onPath } from '../installer.js';
|
|
10
|
+
import { home } from '../config.js';
|
|
11
|
+
|
|
12
|
+
export async function run(cmd, rest, flags) {
|
|
13
|
+
if (cmd === 'uninstall') return uninstall(flags);
|
|
14
|
+
return update(flags);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function update(flags) {
|
|
18
|
+
const method = installMethod();
|
|
19
|
+
if (method === 'npm') {
|
|
20
|
+
const target = `${NPM_PACKAGE}@${flags.version || 'latest'}`;
|
|
21
|
+
line(`${c.i('○')} installed with npm · running ${c.b(`npm install -g ${target}`)}`);
|
|
22
|
+
const r = spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install', '-g', target], { stdio: 'inherit' });
|
|
23
|
+
return r.status ?? 1;
|
|
24
|
+
}
|
|
25
|
+
if (method === 'source') {
|
|
26
|
+
line(`${c.m('○')} running from a source checkout · update it with git pull, or install a release:`);
|
|
27
|
+
line(` ${c.g(`curl -fsSL ${DOWNLOAD_BASE}/install.sh | bash`)}`);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
const sp = spinner((f) => `${c.i(f)} ${c.s(`Checking ${DOWNLOAD_BASE}…`)}`);
|
|
31
|
+
let target;
|
|
32
|
+
try { target = flags.version || await latestVersion(); } catch (e) { sp.stop(`${c.r('✕')} ${e.message}`); return 1; }
|
|
33
|
+
if (!flags.version && !newer(target, VERSION) && !flags.force) {
|
|
34
|
+
sp.stop(`${c.g('✓')} gitroots ${VERSION} is the latest`);
|
|
35
|
+
if (flags.json) json({ current: VERSION, latest: target, updated: false });
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
sp.update((f) => `${c.i(f)} ${c.s(`Downloading gitroots ${target} for ${platformKey()}…`)}`);
|
|
39
|
+
let progress = 0;
|
|
40
|
+
sp.update((f) => `${c.i(f)} ${c.b('Downloading')} ${bar(progress, 30)} ${c.s(`${Math.round(progress * 100)}% · gitroots ${target}`)}`);
|
|
41
|
+
try {
|
|
42
|
+
const r = await installNative(target, { onProgress: (p) => { progress = p; } });
|
|
43
|
+
sp.stop(`${c.g('✓')} Updated ${VERSION} → ${c.b(target)} ${c.m(`checksum verified · ${r.launcher}`)}`);
|
|
44
|
+
if (flags.json) json({ current: VERSION, latest: target, updated: true });
|
|
45
|
+
return 0;
|
|
46
|
+
} catch (e) {
|
|
47
|
+
sp.stop(`${c.r('✕')} ${e.message}`);
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function uninstall(flags) {
|
|
53
|
+
const method = installMethod();
|
|
54
|
+
if (method === 'npm') { line(`installed with npm · run ${c.b(`npm uninstall -g ${NPM_PACKAGE}`)}`); return 0; }
|
|
55
|
+
const { data, launcher, bin } = nativePaths();
|
|
56
|
+
const targets = [launcher, data];
|
|
57
|
+
if (flags.purge) targets.push(home());
|
|
58
|
+
line('This removes:');
|
|
59
|
+
for (const t of targets) line(` ${c.s(t)}${fs.existsSync(t) ? '' : c.m(' (not there)')}`);
|
|
60
|
+
line(c.w('◐ push hooks block pushes once gitroots is gone · run gitroots hooks uninstall in each repo first'));
|
|
61
|
+
const ok = await confirm('Uninstall gitroots?', { def: false, assumeYes: flags.yes || flags.y });
|
|
62
|
+
if (!ok) { line(c.m(' nothing removed')); return 1; }
|
|
63
|
+
for (const t of targets) fs.rmSync(t, { recursive: true, force: true });
|
|
64
|
+
line(`${c.g('✓')} gitroots removed${flags.purge ? '' : c.m(` · settings kept in ${home()} (--purge removes them)`)}`);
|
|
65
|
+
if (onPath(bin)) line(c.m(` ${bin} stays on your PATH; remove the line the installer added to your shell profile if you no longer need it`));
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { path };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Where gitroots keeps its settings and sign-in.
|
|
2
|
+
// ~/.gitroots/config.json server, user, chosen workspace (no secrets)
|
|
3
|
+
// token macOS Keychain, or ~/.gitroots/credentials.json (mode 600) elsewhere
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_SERVER = process.env.GITROOTS_SERVER || 'https://gitroots.com';
|
|
10
|
+
|
|
11
|
+
export function home() {
|
|
12
|
+
return process.env.GITROOTS_HOME || path.join(os.homedir(), '.gitroots');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const file = (name) => path.join(home(), name);
|
|
16
|
+
|
|
17
|
+
function readJson(name, fallback) {
|
|
18
|
+
try { return JSON.parse(fs.readFileSync(file(name), 'utf8')); } catch { return fallback; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeJson(name, data) {
|
|
22
|
+
fs.mkdirSync(home(), { recursive: true });
|
|
23
|
+
fs.writeFileSync(file(name), JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
24
|
+
try { fs.chmodSync(file(name), 0o600); } catch { /* windows */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readConfig() { return readJson('config.json', {}); }
|
|
28
|
+
export function writeConfig(cfg) { writeJson('config.json', cfg); }
|
|
29
|
+
export function updateConfig(patch) { const c = { ...readConfig(), ...patch }; writeConfig(c); return c; }
|
|
30
|
+
|
|
31
|
+
const useKeychain = () => process.platform === 'darwin' && !process.env.GITROOTS_NO_KEYCHAIN;
|
|
32
|
+
const service = (server) => `gitroots-cli:${server}`;
|
|
33
|
+
|
|
34
|
+
export function saveToken(server, username, token) {
|
|
35
|
+
if (useKeychain()) {
|
|
36
|
+
const r = spawnSync('security', ['add-generic-password', '-U', '-a', username, '-s', service(server), '-w', token], { stdio: 'ignore' });
|
|
37
|
+
if (r.status === 0) return 'keychain';
|
|
38
|
+
}
|
|
39
|
+
const all = readJson('credentials.json', {});
|
|
40
|
+
all[`${username}@${server}`] = token;
|
|
41
|
+
writeJson('credentials.json', all);
|
|
42
|
+
return 'file';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function loadToken(server, username) {
|
|
46
|
+
if (process.env.GITROOTS_TOKEN) return process.env.GITROOTS_TOKEN;
|
|
47
|
+
if (!server || !username) return null;
|
|
48
|
+
if (useKeychain()) {
|
|
49
|
+
const r = spawnSync('security', ['find-generic-password', '-a', username, '-s', service(server), '-w'], { encoding: 'utf8' });
|
|
50
|
+
if (r.status === 0) return r.stdout.replace(/\n$/, '');
|
|
51
|
+
}
|
|
52
|
+
return readJson('credentials.json', {})[`${username}@${server}`] || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function forgetToken(server, username) {
|
|
56
|
+
if (process.platform === 'darwin') spawnSync('security', ['delete-generic-password', '-a', username, '-s', service(server)], { stdio: 'ignore' });
|
|
57
|
+
const all = readJson('credentials.json', {});
|
|
58
|
+
delete all[`${username}@${server}`];
|
|
59
|
+
writeJson('credentials.json', all);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Small local caches (branch rules for the push hook, command history).
|
|
63
|
+
export function readCache(name, fallback) { return readJson(`cache-${name}.json`, fallback); }
|
|
64
|
+
export function writeCache(name, data) { try { writeJson(`cache-${name}.json`, data); } catch { /* read-only home */ } }
|
|
65
|
+
|
|
66
|
+
export function historyFile() { return file('history'); }
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Real checks gitroots runs on commits before they leave the machine.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
export const ZERO = /^0+$/;
|
|
5
|
+
|
|
6
|
+
export function git(args, cwd = process.cwd()) {
|
|
7
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
8
|
+
if (r.error) throw r.error;
|
|
9
|
+
if (r.status !== 0) {
|
|
10
|
+
const err = new Error((r.stderr || '').trim() || `git ${args[0]} failed`);
|
|
11
|
+
err.status = r.status;
|
|
12
|
+
throw err;
|
|
13
|
+
}
|
|
14
|
+
return r.stdout;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function tryGit(args, cwd) { try { return git(args, cwd).trim(); } catch { return ''; } }
|
|
18
|
+
|
|
19
|
+
const MAX_COMMITS = 500;
|
|
20
|
+
|
|
21
|
+
// Commits that would be sent: local..remote, or everything not on any remote for a new branch.
|
|
22
|
+
export function commitsToPush(localSha, remoteSha, cwd) {
|
|
23
|
+
const args = remoteSha && !ZERO.test(remoteSha) && objectExists(remoteSha, cwd)
|
|
24
|
+
? ['rev-list', `--max-count=${MAX_COMMITS}`, `${remoteSha}..${localSha}`]
|
|
25
|
+
: ['rev-list', `--max-count=${MAX_COMMITS}`, localSha, '--not', '--remotes'];
|
|
26
|
+
return git(args, cwd).split('\n').filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function objectExists(sha, cwd) {
|
|
30
|
+
const r = spawnSync('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd });
|
|
31
|
+
return r.status === 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function changedFiles(commits, cwd) {
|
|
35
|
+
const files = new Set();
|
|
36
|
+
for (const sha of commits) {
|
|
37
|
+
for (const f of git(['diff-tree', '--no-commit-id', '--name-only', '-r', '-m', '--root', sha], cwd).split('\n')) if (f) files.add(f);
|
|
38
|
+
}
|
|
39
|
+
return [...files];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Added lines with file and line number, from each commit's patch.
|
|
43
|
+
export function addedLines(commits, cwd) {
|
|
44
|
+
const outLines = [];
|
|
45
|
+
for (const sha of commits) {
|
|
46
|
+
const patch = git(['show', '--format=', '--unified=0', '--no-color', '--no-ext-diff', '-m', '--first-parent', sha], cwd);
|
|
47
|
+
let file = null;
|
|
48
|
+
let ln = 0;
|
|
49
|
+
for (const l of patch.split('\n')) {
|
|
50
|
+
if (l.startsWith('+++ ')) { file = l.slice(4).replace(/^b\//, ''); continue; }
|
|
51
|
+
if (l.startsWith('@@')) { const m = /\+(\d+)/.exec(l); ln = m ? Number(m[1]) : 0; continue; }
|
|
52
|
+
if (l.startsWith('+') && file && file !== '/dev/null') { outLines.push({ sha, file, line: ln, text: l.slice(1) }); ln++; continue; }
|
|
53
|
+
if (!l.startsWith('-') && !l.startsWith('\\')) ln++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return outLines;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const SECRET_PATTERNS = [
|
|
60
|
+
{ name: 'AWS access key', re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
|
61
|
+
{ name: 'AWS secret key', re: /aws_secret_access_key\s*[:=]\s*['"]?[A-Za-z0-9/+=]{40}/i },
|
|
62
|
+
{ name: 'private key', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY( BLOCK)?-----/ },
|
|
63
|
+
{ name: 'GitHub token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{60,}\b/ },
|
|
64
|
+
{ name: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20}\b/ },
|
|
65
|
+
{ name: 'Slack token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
66
|
+
{ name: 'Stripe live key', re: /\b[sr]k_live_[A-Za-z0-9]{20,}\b/ },
|
|
67
|
+
{ name: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
68
|
+
{ name: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
|
|
69
|
+
{ name: 'OpenAI API key', re: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b/ },
|
|
70
|
+
{ name: 'password', re: /(?:password|passwd|pwd|secret|api_key|apikey|token)\w*['"]?\s*[:=]\s*['"][^'"\s]{8,}['"]/i, skip: /(example|changeme|placeholder|your[_-]|xxx|\*\*\*|<|\$\{|os\.environ|getenv|process\.env)/i },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export function scanSecrets(lines) {
|
|
74
|
+
const hits = [];
|
|
75
|
+
for (const l of lines) {
|
|
76
|
+
for (const p of SECRET_PATTERNS) {
|
|
77
|
+
if (p.re.test(l.text) && !(p.skip && p.skip.test(l.text))) { hits.push({ ...l, kind: p.name }); break; }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return hits;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function authors(commits, cwd) {
|
|
84
|
+
if (!commits.length) return [];
|
|
85
|
+
return git(['show', '-s', '--format=%H%x09%an%x09%ae%x09%G?', ...commits], cwd)
|
|
86
|
+
.split('\n').filter(Boolean)
|
|
87
|
+
.map((l) => { const [sha, name, email, sig] = l.split('\t'); return { sha, name, email, signed: sig === 'G' || sig === 'U', sig }; });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const MIGRATION_RE = /(^|\/)(migrations?|migrate|alembic\/versions|db\/migrate)\/|(^|\/)[^/]*migrat[^/]*\.(py|sql|rb|js|ts)$|\.sql$/i;
|
|
91
|
+
|
|
92
|
+
export function lastSubjectSlug(sha, cwd) {
|
|
93
|
+
const subj = tryGit(['log', '-1', '--format=%s', sha], cwd);
|
|
94
|
+
const slug = subj.toLowerCase().replace(/^[a-z]+(\([^)]*\))?:\s*/, '').replace(/[^a-z0-9]+/g, '-').split('-').filter(Boolean).slice(0, 4).join('-');
|
|
95
|
+
return slug || 'my-change';
|
|
96
|
+
}
|
package/src/installer.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Native install / update, mirroring install.sh:
|
|
2
|
+
// <base>/latest "0.5.0"
|
|
3
|
+
// <base>/<version>/manifest.json { version, platforms: { "darwin-arm64": { file, checksum, size } } }
|
|
4
|
+
// <base>/<version>/<file> gitroots-<version>-<platform>.tar.gz (.zip on Windows)
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { DOWNLOAD_BASE, nativePaths, platformKey } from './runtime.js';
|
|
11
|
+
|
|
12
|
+
async function fetchOk(url, timeout = 30000) {
|
|
13
|
+
let res;
|
|
14
|
+
try { res = await fetch(url, { signal: AbortSignal.timeout(timeout), headers: { 'User-Agent': 'gitroots-cli' } }); } catch (e) {
|
|
15
|
+
throw new Error(`can't reach ${url} (${e.cause?.code || e.name})`);
|
|
16
|
+
}
|
|
17
|
+
if (!res.ok) throw new Error(`${res.status} from ${url}`);
|
|
18
|
+
return res;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function latestVersion(base = DOWNLOAD_BASE, timeout) {
|
|
22
|
+
const v = (await (await fetchOk(`${base}/latest`, timeout)).text()).trim();
|
|
23
|
+
if (!/^\d+\.\d+\.\d+([-+][\w.]+)?$/.test(v)) throw new Error(`${base}/latest is not a version (${v.slice(0, 40)})`);
|
|
24
|
+
return v;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function manifest(version, base = DOWNLOAD_BASE) {
|
|
28
|
+
return (await fetchOk(`${base}/${version}/manifest.json`)).json();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function newer(a, b) {
|
|
32
|
+
const pa = a.split(/[.-]/).map(Number);
|
|
33
|
+
const pb = b.split(/[.-]/).map(Number);
|
|
34
|
+
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Download, verify and install one version. Returns the installed binary path.
|
|
39
|
+
export async function installNative(version, { base = DOWNLOAD_BASE, onProgress = () => {} } = {}) {
|
|
40
|
+
const key = platformKey();
|
|
41
|
+
const man = await manifest(version, base);
|
|
42
|
+
const entry = man.platforms && man.platforms[key];
|
|
43
|
+
if (!entry) throw new Error(`no gitroots ${version} build for ${key} · available: ${Object.keys(man.platforms || {}).join(', ')}`);
|
|
44
|
+
|
|
45
|
+
const res = await fetchOk(`${base}/${version}/${entry.file}`, 600000);
|
|
46
|
+
const total = Number(res.headers.get('content-length')) || entry.size || 0;
|
|
47
|
+
const chunks = [];
|
|
48
|
+
let got = 0;
|
|
49
|
+
for await (const chunk of res.body) { chunks.push(chunk); got += chunk.length; if (total) onProgress(got / total); }
|
|
50
|
+
const buf = Buffer.concat(chunks);
|
|
51
|
+
const sum = crypto.createHash('sha256').update(buf).digest('hex');
|
|
52
|
+
if (sum !== entry.checksum) throw new Error(`checksum mismatch for ${entry.file} · expected ${entry.checksum.slice(0, 12)}…, got ${sum.slice(0, 12)}… · nothing was installed`);
|
|
53
|
+
|
|
54
|
+
const { data, bin, launcher } = nativePaths();
|
|
55
|
+
const dir = path.join(data, 'versions', version);
|
|
56
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gitroots-'));
|
|
57
|
+
const archive = path.join(tmp, entry.file);
|
|
58
|
+
fs.writeFileSync(archive, buf);
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
const ok = entry.file.endsWith('.zip')
|
|
61
|
+
? spawnSync('powershell', ['-NoProfile', '-Command', `Expand-Archive -Force -Path '${archive}' -DestinationPath '${dir}'`], { stdio: 'ignore' }).status === 0
|
|
62
|
+
: spawnSync('tar', ['-xzf', archive, '-C', dir], { stdio: 'ignore' }).status === 0;
|
|
63
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
64
|
+
if (!ok) throw new Error(`could not unpack ${entry.file}`);
|
|
65
|
+
const exe = path.join(dir, process.platform === 'win32' ? 'gitroots.exe' : 'gitroots');
|
|
66
|
+
if (!fs.existsSync(exe)) throw new Error(`${entry.file} has no gitroots binary`);
|
|
67
|
+
if (process.platform !== 'win32') fs.chmodSync(exe, 0o755);
|
|
68
|
+
|
|
69
|
+
fs.mkdirSync(bin, { recursive: true });
|
|
70
|
+
if (process.platform === 'win32') {
|
|
71
|
+
// A running .exe can be renamed but not overwritten.
|
|
72
|
+
if (fs.existsSync(launcher)) { try { fs.rmSync(`${launcher}.old`, { force: true }); } catch { /* in use */ } fs.renameSync(launcher, `${launcher}.old`); }
|
|
73
|
+
fs.copyFileSync(exe, launcher);
|
|
74
|
+
} else {
|
|
75
|
+
const link = `${launcher}.new`;
|
|
76
|
+
try { fs.rmSync(link, { force: true }); } catch { /* none */ }
|
|
77
|
+
fs.symlinkSync(exe, link);
|
|
78
|
+
fs.renameSync(link, launcher);
|
|
79
|
+
}
|
|
80
|
+
pruneVersions(data, version);
|
|
81
|
+
return { exe, launcher, version, platform: key };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Keep the current and the previous version.
|
|
85
|
+
function pruneVersions(data, current) {
|
|
86
|
+
const vdir = path.join(data, 'versions');
|
|
87
|
+
let all = [];
|
|
88
|
+
try { all = fs.readdirSync(vdir); } catch { return; }
|
|
89
|
+
const sorted = all.filter((v) => /^\d/.test(v)).sort((a, b) => (newer(a, b) ? -1 : 1));
|
|
90
|
+
const keep = new Set([current, sorted.find((v) => v !== current)]);
|
|
91
|
+
for (const v of sorted) if (!keep.has(v)) fs.rmSync(path.join(vdir, v), { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function onPath(dir) {
|
|
95
|
+
return (process.env.PATH || '').split(path.delimiter).some((p) => path.resolve(p) === path.resolve(dir));
|
|
96
|
+
}
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Branch rules for the repo being pushed, from (in order):
|
|
2
|
+
// 1. gitroots: the workspace's branch rules for this GitHub repo (cached for offline pushes)
|
|
3
|
+
// 2. .gitroots.json at the repo root: { "protected": ["main", "release/*"], "paths": [{ "pattern": "infra/**", "needs": "an ops review" }] }
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { tryGit } from './git/checks.js';
|
|
7
|
+
import { readCache, writeCache, readConfig } from './config.js';
|
|
8
|
+
|
|
9
|
+
export function remoteFullName(url) {
|
|
10
|
+
if (!url) return null;
|
|
11
|
+
const m = /github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url.trim());
|
|
12
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function globMatch(pattern, value) {
|
|
16
|
+
const re = new RegExp(`^${pattern
|
|
17
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
18
|
+
.replace(/\*\*/g, '\u0000')
|
|
19
|
+
.replace(/\*/g, '[^/]*')
|
|
20
|
+
.replace(/\u0000/g, '.*')
|
|
21
|
+
.replace(/\?/g, '.')}$`);
|
|
22
|
+
return re.test(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function localConfig(top) {
|
|
26
|
+
try {
|
|
27
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(top, '.gitroots.json'), 'utf8'));
|
|
28
|
+
return {
|
|
29
|
+
rules: (cfg.protected || []).map((p) => ({ pattern: p, level: 'protected', allow_direct_push: false, required_approvals: 0, description: 'No direct pushes · .gitroots.json' })),
|
|
30
|
+
paths: (cfg.paths || []).map((p) => (typeof p === 'string' ? { pattern: p, needs: 'a review' } : p)),
|
|
31
|
+
suggest: cfg.suggest,
|
|
32
|
+
};
|
|
33
|
+
} catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Rules for the repository at cwd. api is optional; with it, rules are refreshed from gitroots. */
|
|
37
|
+
export async function loadRules(cwd, { api = null, timeout = 3000 } = {}) {
|
|
38
|
+
const top = tryGit(['rev-parse', '--show-toplevel'], cwd);
|
|
39
|
+
if (!top) return { source: 'none', repo: null, rules: [], paths: [] };
|
|
40
|
+
const url = tryGit(['remote', 'get-url', 'origin'], cwd);
|
|
41
|
+
const fullName = remoteFullName(url);
|
|
42
|
+
const local = localConfig(top);
|
|
43
|
+
const cache = readCache('rules', {});
|
|
44
|
+
let source = 'none';
|
|
45
|
+
let rules = [];
|
|
46
|
+
if (fullName && api && api.signedIn) {
|
|
47
|
+
try {
|
|
48
|
+
const d = await api.repo(fullName, { timeout });
|
|
49
|
+
rules = d.branch_rules || [];
|
|
50
|
+
cache[fullName.toLowerCase()] = { rules, at: new Date().toISOString(), org: api.orgId };
|
|
51
|
+
writeCache('rules', cache);
|
|
52
|
+
source = 'gitroots';
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (e.status === 404) { delete cache[fullName.toLowerCase()]; writeCache('rules', cache); }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (source === 'none' && fullName && cache[fullName.toLowerCase()]) {
|
|
58
|
+
rules = cache[fullName.toLowerCase()].rules;
|
|
59
|
+
source = 'cache';
|
|
60
|
+
}
|
|
61
|
+
if (local) {
|
|
62
|
+
rules = [...rules, ...local.rules];
|
|
63
|
+
if (source === 'none') source = '.gitroots.json';
|
|
64
|
+
}
|
|
65
|
+
return { source, repo: fullName || path.basename(top), top, rules, paths: local ? local.paths : [], suggest: local?.suggest };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function ruleFor(rules, branch) {
|
|
69
|
+
return rules.find((r) => r.pattern === branch) || rules.find((r) => globMatch(r.pattern, branch)) || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const LEAD_ROLES = new Set(['owner', 'lead', 'admin']);
|
|
73
|
+
|
|
74
|
+
/** Is a direct push of `branch` allowed? */
|
|
75
|
+
export function decide(set, branch) {
|
|
76
|
+
const rule = ruleFor(set.rules, branch);
|
|
77
|
+
if (!rule) return { blocked: false, rule: null, label: set.source === 'none' ? 'no rules for this repo' : `${branch} · no rule, developers can push` };
|
|
78
|
+
const role = (readConfig().org?.role || '').toLowerCase();
|
|
79
|
+
const label = `${rule.pattern}: ${rule.description || rule.level}`;
|
|
80
|
+
if (rule.allow_direct_push === false) {
|
|
81
|
+
const needs = rule.required_approvals ? `, it needs ${rule.required_approvals} approval${rule.required_approvals > 1 ? 's' : ''}` : '';
|
|
82
|
+
return { blocked: true, rule, label, suggest: set.suggest || `push to a feature branch, then open a pull request into ${branch}${needs}` };
|
|
83
|
+
}
|
|
84
|
+
if (rule.level === 'leads' && !LEAD_ROLES.has(role)) {
|
|
85
|
+
return { blocked: true, rule, label, suggest: 'only workspace leads push here · push to a feature branch and ask a lead to merge' };
|
|
86
|
+
}
|
|
87
|
+
return { blocked: false, rule, label };
|
|
88
|
+
}
|