gitroots 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -2
- package/assets/logo.png +0 -0
- package/bin/gitroots.js +11 -0
- package/package.json +35 -4
- package/src/api.js +121 -0
- package/src/cli.js +150 -0
- package/src/commands/attach.js +262 -0
- package/src/commands/audit.js +150 -0
- package/src/commands/auth.js +99 -0
- package/src/commands/backup.js +106 -0
- package/src/commands/completion.js +69 -0
- package/src/commands/demo.js +12 -0
- package/src/commands/doctor.js +81 -0
- package/src/commands/pr.js +322 -0
- package/src/commands/push.js +273 -0
- package/src/commands/repos.js +122 -0
- package/src/commands/restore.js +125 -0
- package/src/commands/splash.js +95 -0
- package/src/commands/status.js +84 -0
- package/src/commands/transfer.js +79 -0
- package/src/commands/tunnel.js +116 -0
- package/src/commands/update.js +69 -0
- package/src/commands/watch.js +347 -0
- package/src/connected.js +82 -0
- package/src/data/live.js +49 -0
- package/src/data/sample.js +163 -0
- package/src/data/store.js +145 -0
- package/src/git/checks.js +96 -0
- package/src/installer.js +96 -0
- package/src/runtime.js +63 -0
- package/src/ui/ansi.js +138 -0
- package/src/ui/components.js +85 -0
- package/src/ui/term.js +296 -0
|
@@ -0,0 +1,273 @@
|
|
|
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] 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, json } from '../ui/components.js';
|
|
11
|
+
import { git, tryGit, ZERO, commitsToPush, changedFiles, addedLines, scanSecrets, authors, MIGRATION_RE, lastSubjectSlug } from '../git/checks.js';
|
|
12
|
+
|
|
13
|
+
const MARK = '# gitroots push checks';
|
|
14
|
+
|
|
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
|
+
function hooksDir(dir) {
|
|
45
|
+
const top = tryGit(['rev-parse', '--show-toplevel'], dir);
|
|
46
|
+
if (!top) throw new Error(`${path.resolve(dir)} is not a git repository`);
|
|
47
|
+
return path.resolve(dir, git(['rev-parse', '--git-path', 'hooks'], dir).trim());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function installHook(dir = '.', { force = false } = {}) {
|
|
51
|
+
const hd = hooksDir(dir);
|
|
52
|
+
fs.mkdirSync(hd, { recursive: true });
|
|
53
|
+
const file = path.join(hd, 'pre-push');
|
|
54
|
+
let chained = null;
|
|
55
|
+
if (fs.existsSync(file)) {
|
|
56
|
+
const existing = fs.readFileSync(file, 'utf8');
|
|
57
|
+
if (!existing.includes(MARK)) {
|
|
58
|
+
if (!force) throw new Error(`a pre-push hook already exists at ${file} · rerun with --force to keep it and run it before gitroots`);
|
|
59
|
+
chained = path.join(hd, 'pre-push.local');
|
|
60
|
+
fs.renameSync(file, chained);
|
|
61
|
+
} else if (fs.existsSync(path.join(hd, 'pre-push.local'))) chained = path.join(hd, 'pre-push.local');
|
|
62
|
+
}
|
|
63
|
+
const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
64
|
+
// The gitroots that installed the hook; if it moved (update, node version switch), the launcher or PATH.
|
|
65
|
+
const cmd = selfCommand();
|
|
66
|
+
const self = cmd.map(q).join(' ');
|
|
67
|
+
const script = `#!/bin/sh
|
|
68
|
+
${MARK} · installed by gitroots hooks install
|
|
69
|
+
input=$(cat)
|
|
70
|
+
here=$(dirname "$0")
|
|
71
|
+
if [ -x "$here/pre-push.local" ]; then
|
|
72
|
+
printf '%s\\n' "$input" | "$here/pre-push.local" "$@" || exit $?
|
|
73
|
+
fi
|
|
74
|
+
if [ -x ${q(cmd[0])} ] && { [ ${cmd.length} -eq 1 ] || [ -f ${q(cmd[cmd.length - 1])} ]; }; then
|
|
75
|
+
printf '%s\\n' "$input" | ${self} hook pre-push "$@"; exit $?
|
|
76
|
+
elif [ -x "$HOME/.local/bin/gitroots" ]; then
|
|
77
|
+
printf '%s\\n' "$input" | "$HOME/.local/bin/gitroots" hook pre-push "$@"; exit $?
|
|
78
|
+
elif command -v gitroots >/dev/null 2>&1; then
|
|
79
|
+
printf '%s\\n' "$input" | gitroots hook pre-push "$@"; exit $?
|
|
80
|
+
fi
|
|
81
|
+
echo "gitroots: push checks could not run (gitroots not found) · reinstall with gitroots hooks install" >&2
|
|
82
|
+
exit 1
|
|
83
|
+
`;
|
|
84
|
+
fs.writeFileSync(file, script, { mode: 0o755 });
|
|
85
|
+
return { path: file, chained };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function uninstallHook(dir = '.') {
|
|
89
|
+
const hd = hooksDir(dir);
|
|
90
|
+
const file = path.join(hd, 'pre-push');
|
|
91
|
+
if (!fs.existsSync(file) || !fs.readFileSync(file, 'utf8').includes(MARK)) return { removed: false };
|
|
92
|
+
fs.rmSync(file);
|
|
93
|
+
const local = path.join(hd, 'pre-push.local');
|
|
94
|
+
if (fs.existsSync(local)) fs.renameSync(local, file);
|
|
95
|
+
return { removed: true, restored: fs.existsSync(file) };
|
|
96
|
+
}
|
|
97
|
+
|
|
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
|
+
|
|
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'}`)}`);
|
|
111
|
+
let files = [];
|
|
112
|
+
let secretHits = [];
|
|
113
|
+
const steps = [
|
|
114
|
+
{ name: 'Branch rule', live: `reading rules for ${branch}…`, ms: 400, run: async () => ({ detail: `${rule.name === '*' ? branch : rule.name} · ${rule.label.replace(/^[^·]*· /, '')}` }) },
|
|
115
|
+
{
|
|
116
|
+
name: 'Protected paths', live: 'checking changed files…', ms: 400,
|
|
117
|
+
run: async () => {
|
|
118
|
+
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 };
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'Secrets scan', live: `scanning ${commits.length} commits…`, ms: 600,
|
|
128
|
+
run: async () => {
|
|
129
|
+
secretHits = scanSecrets(addedLines(commits, cwd));
|
|
130
|
+
if (!secretHits.length) return { detail: '0 keys or passwords found' };
|
|
131
|
+
const f = secretHits[0];
|
|
132
|
+
return { status: 'fail', detail: `${f.kind} in ${f.file}:${f.line}${secretHits.length > 1 ? ` · ${secretHits.length - 1} more` : ''}` };
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'Commit authors', live: 'checking authors…', ms: 300,
|
|
137
|
+
run: async () => {
|
|
138
|
+
const a = authors(commits, cwd);
|
|
139
|
+
const names = [...new Set(a.map((x) => x.name))];
|
|
140
|
+
const signed = a.filter((x) => x.signed).length;
|
|
141
|
+
const sig = !a.length ? '' : signed === a.length ? ' · verified' : signed ? ` · ${signed} of ${a.length} signed` : ' · unsigned';
|
|
142
|
+
return { detail: `${a.length} commit${a.length === 1 ? '' : 's'} · ${names.slice(0, 3).join(', ')}${names.length > 3 ? ` +${names.length - 3}` : ''}${sig}` };
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'Migration files', live: 'looking for database changes…', ms: 400,
|
|
147
|
+
run: async () => {
|
|
148
|
+
const mig = files.filter((f) => MIGRATION_RE.test(f));
|
|
149
|
+
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` };
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
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; }
|
|
168
|
+
}
|
|
169
|
+
|
|
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' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function blockedBox(store, { repoName, branch, rule, localSha, cwd, ev, remote }) {
|
|
178
|
+
const slug = lastSubjectSlug(localSha, cwd);
|
|
179
|
+
box([
|
|
180
|
+
`${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}`)}`,
|
|
184
|
+
], 'r');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function prePush({ remote, url, input, store, flags }) {
|
|
188
|
+
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);
|
|
192
|
+
const updates = input.split('\n').map((l) => l.trim().split(/\s+/)).filter((p) => p.length === 4);
|
|
193
|
+
let code = 0;
|
|
194
|
+
for (const [localRef, localSha, remoteRef, remoteSha] of updates) {
|
|
195
|
+
if (!remoteRef.startsWith('refs/heads/')) continue; // tags are not checked
|
|
196
|
+
const branch = remoteRef.slice('refs/heads/'.length);
|
|
197
|
+
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;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
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}`)}` : ''}`);
|
|
235
|
+
}
|
|
236
|
+
if (flags?.json) json({ ok: code === 0 });
|
|
237
|
+
return code;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function checkLocal(rest, flags, store) {
|
|
241
|
+
const cwd = process.cwd();
|
|
242
|
+
if (!tryGit(['rev-parse', '--show-toplevel'], cwd)) throw new Error('not inside a git repository');
|
|
243
|
+
let localSha;
|
|
244
|
+
let remoteSha = '';
|
|
245
|
+
const range = rest[0];
|
|
246
|
+
if (range && range.includes('..')) {
|
|
247
|
+
const [a, b] = range.split('..');
|
|
248
|
+
remoteSha = git(['rev-parse', a || 'HEAD'], cwd).trim();
|
|
249
|
+
localSha = git(['rev-parse', b || 'HEAD'], cwd).trim();
|
|
250
|
+
} else {
|
|
251
|
+
localSha = git(['rev-parse', range || 'HEAD'], cwd).trim();
|
|
252
|
+
const up = tryGit(['rev-parse', '@{upstream}'], cwd);
|
|
253
|
+
remoteSha = up || '';
|
|
254
|
+
}
|
|
255
|
+
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 });
|
|
261
|
+
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'}`);
|
|
264
|
+
return 1;
|
|
265
|
+
}
|
|
266
|
+
if (res.blocked) {
|
|
267
|
+
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)}`)}`);
|
|
268
|
+
line(`${brand()} ${c.r('✕ This push would be blocked')} ${c.m('remove the secret and rewrite the commit')}`);
|
|
269
|
+
return 1;
|
|
270
|
+
}
|
|
271
|
+
line(`${brand()} ${c.g('✓ Ready to push')} ${c.g('→ git push')}`);
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// gitroots repo list · branch list · branch rules · clone
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { c, h, pad } from '../ui/ansi.js';
|
|
5
|
+
import { line } from '../ui/term.js';
|
|
6
|
+
import { dot, json, next } from '../ui/components.js';
|
|
7
|
+
import { installHook } from './push.js';
|
|
8
|
+
import { apiRepos, apiBranches } from '../connected.js';
|
|
9
|
+
|
|
10
|
+
export async function run(cmd, rest, flags, store) {
|
|
11
|
+
if (cmd === 'clone') return clone(rest, flags, store);
|
|
12
|
+
const [sub = 'list', ...args] = rest;
|
|
13
|
+
if (cmd === 'repo' || cmd === 'repos') {
|
|
14
|
+
if (sub === 'list' || sub === 'ls') return repoList(flags, store);
|
|
15
|
+
if (sub === 'view') return branchRules([args[0], store.repo(args[0]).default], flags, store);
|
|
16
|
+
throw new Error(`unknown: gitroots repo ${sub} · try gitroots repo list`);
|
|
17
|
+
}
|
|
18
|
+
if (sub === 'list' || sub === 'ls') return branchList(args, flags, store);
|
|
19
|
+
if (sub === 'rules') return branchRules(args, flags, store);
|
|
20
|
+
if (sub === 'restore') return branchRestore(args, flags, store);
|
|
21
|
+
throw new Error(`unknown: gitroots branch ${sub} · try gitroots branch list <repo>`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function repoList(flags, store) {
|
|
25
|
+
const repos = store.api ? await apiRepos(store) : store.repos();
|
|
26
|
+
if (flags.json) { json(repos); return 0; }
|
|
27
|
+
line(` ${h(`${pad('NAME', 14)}${pad('PROVIDER', 11)}${pad('DEFAULT', 10)}${pad('PROTECTED', 12)}${pad('BRANCHES', 11)}${pad('OPEN PRS', 11)}${pad('YOUR ROLE', 14)}LAST PUSH`)}`);
|
|
28
|
+
for (const r of repos) {
|
|
29
|
+
line(` ${c.b(pad(r.name, 14))}${pad(r.provider, 11)}${pad(r.default, 10)}${pad(r.protected.length ? String(r.protected.length) : c.m('—'), 12)}${pad(String(r.branches), 11)}${pad(r.openPrs === null ? c.m('—') : String(r.openPrs), 11)}${pad(r.role, 14)}${r.lastPush || c.m('—')}`);
|
|
30
|
+
}
|
|
31
|
+
if (!repos.length) line(c.m(store.api ? ' no repositories synced yet · connect GitHub in the gitroots dashboard' : ' no repositories'));
|
|
32
|
+
line();
|
|
33
|
+
line(next(`gitroots branch list ${repos[0]?.name || '<repo>'}`));
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ruleCell(b) {
|
|
38
|
+
return b.rule === 'protected' ? `${c.g(pad('● protected', 12))} ${pad(b.ruleText, 28)}` : `${c.i(pad('○ open', 12))} ${pad(b.ruleText, 28)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function branchList([repoName], flags, store) {
|
|
42
|
+
if (!repoName) throw new Error('which repository? gitroots branch list <repo>');
|
|
43
|
+
const all = store.api ? await apiBranches(store, repoName) : store.branches(repoName);
|
|
44
|
+
const shown = flags.all ? all : all.slice(0, 6);
|
|
45
|
+
if (flags.json) { json(all); return 0; }
|
|
46
|
+
line(` ${h(`${pad('BRANCH', 24)}${pad('RULE', 41)}${pad('APPROVALS', 12)}${pad('LAST COMMIT', 25)}AHEAD / BEHIND`)}`);
|
|
47
|
+
for (const b of shown) {
|
|
48
|
+
const name = b.rule === 'protected' ? c.b(pad(b.name, 24)) : pad(b.name, 24);
|
|
49
|
+
const appr = b.approvals ? pad(String(b.approvals), 12) : c.m(pad('—', 12));
|
|
50
|
+
line(` ${name}${ruleCell(b)}${appr}${pad(b.commit ? `${b.commit} ${b.by} · ${b.age}` : c.m('—'), 25)}${c.m(b.aheadBehind || '—')}`);
|
|
51
|
+
}
|
|
52
|
+
if (all.length > shown.length) line(` ${c.m(`+ ${all.length - shown.length} more · gitroots branch list ${repoName} --all`)}`);
|
|
53
|
+
line();
|
|
54
|
+
const prot = all.find((b) => b.rule === 'protected' && b.name !== 'main') || all[0];
|
|
55
|
+
if (prot) line(next(`gitroots branch rules ${repoName} ${prot.name}`));
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function branchRules([repoName, branch], flags, store) {
|
|
60
|
+
if (!repoName || !branch) throw new Error('usage: gitroots branch rules <repo> <branch>');
|
|
61
|
+
if (!store.api) store.repo(repoName);
|
|
62
|
+
const rule = store.ruleFor(repoName, branch);
|
|
63
|
+
const paths = store.protectedPaths(repoName);
|
|
64
|
+
const agents = store.rules(repoName).agents;
|
|
65
|
+
if (flags.json) { json({ repo: repoName, branch, rule, paths, agents }); return 0; }
|
|
66
|
+
line(` ${c.b(branch)} ${rule.protected ? c.g('● protected') : c.i('○ open')}`);
|
|
67
|
+
if (rule.protected) {
|
|
68
|
+
line(` ${pad('push', 13)}${c.r('✕')} ${pad(rule.push, 44)} ${c.m('changes arrive through PRs')}`);
|
|
69
|
+
line(` ${pad('merge', 13)}${c.g('✓')} ${rule.mergeText}`);
|
|
70
|
+
} else {
|
|
71
|
+
line(` ${pad('push', 13)}${c.g('✓')} ${rule.label}`);
|
|
72
|
+
line(` ${pad('merge', 13)}${c.g('✓')} through a PR into a protected branch`);
|
|
73
|
+
}
|
|
74
|
+
for (const p of paths) line(` ${pad('paths', 13)}${c.w('◐')} ${p.pattern} needs ${p.needs}`);
|
|
75
|
+
if (agents) line(` ${pad('agents', 13)}${c.g('✓')} ${agents}`);
|
|
76
|
+
line();
|
|
77
|
+
line(next(`gitroots clone ${repoName}`));
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function branchRestore([repoName, branch], flags, store) {
|
|
82
|
+
if (!repoName || !branch) throw new Error('usage: gitroots branch restore <repo> <branch>');
|
|
83
|
+
store.repo(repoName);
|
|
84
|
+
const deleted = (store.d.deletedBranches || {})[repoName] || [];
|
|
85
|
+
const b = deleted.find((x) => x.name === branch);
|
|
86
|
+
if (!b) throw new Error(`no deleted branch ${branch} in ${repoName}`);
|
|
87
|
+
store.d.deletedBranches[repoName] = deleted.filter((x) => x !== b);
|
|
88
|
+
store.d.branches[repoName].push(b);
|
|
89
|
+
const ev = store.record({ actor: store.org.user, action: 'branch restore', target: `${repoName} ${branch}`, decision: 'allowed', repo: repoName });
|
|
90
|
+
store.save();
|
|
91
|
+
if (flags.json) { json({ restored: b, event: ev.id }); return 0; }
|
|
92
|
+
line(` ${c.g('✓')} Restored ${c.b(branch)} at ${b.commit} ${c.m(`audit ${ev.id}`)}`);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function clone([target, dirArg], flags, store) {
|
|
97
|
+
if (!target) throw new Error('usage: gitroots clone <repo|git-url> [dir]');
|
|
98
|
+
const isUrl = /[:/]/.test(target);
|
|
99
|
+
const repo = isUrl ? store.repoByRemote(target) : store.repo(target);
|
|
100
|
+
const name = repo ? repo.name : path.basename(target).replace(/\.git$/, '');
|
|
101
|
+
const url = flags.url || (isUrl ? target : null);
|
|
102
|
+
const dir = path.resolve(dirArg || name);
|
|
103
|
+
const shown = dir.replace(process.env.HOME || '\u0000', '~');
|
|
104
|
+
|
|
105
|
+
if (!url) {
|
|
106
|
+
// Demo org repositories have no reachable remote: show what clone does.
|
|
107
|
+
if (flags.json) { json({ repo: name, dir, cloned: false, reason: 'demo repository has no remote · pass --url' }); return 0; }
|
|
108
|
+
line(` ${c.g('✓')} Cloned ${name} into ${shown} ${c.m(`${repo?.branches ?? '?'} branches · default ${repo?.default ?? 'main'}`)}`);
|
|
109
|
+
line(` ${c.g('✓')} Push checks installed ${c.m('git push now runs gitroots checks first')} ${c.g(`→ cd ${name} && git switch -c feature/…`)}`);
|
|
110
|
+
line(c.m(` sample data · nothing was cloned · gitroots clone ${name} --url <git-url> clones for real`));
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
const r = spawnSync('git', ['clone', url, dir], { stdio: flags.json ? 'ignore' : ['ignore', 'ignore', 'inherit'] });
|
|
114
|
+
if (r.status !== 0) { line(` ${c.r('✕')} git clone failed ${c.m(`(exit ${r.status})`)}`); return r.status || 1; }
|
|
115
|
+
const branches = spawnSync('git', ['-C', dir, 'branch', '-r'], { encoding: 'utf8' }).stdout.split('\n').filter((l) => l.trim() && !l.includes('->')).length;
|
|
116
|
+
const def = (spawnSync('git', ['-C', dir, 'symbolic-ref', '--short', 'HEAD'], { encoding: 'utf8' }).stdout || 'main').trim();
|
|
117
|
+
const hook = installHook(dir, { force: true, repo: name });
|
|
118
|
+
if (flags.json) { json({ repo: name, dir, cloned: true, branches, default: def, hook: hook.path }); return 0; }
|
|
119
|
+
line(` ${c.g('✓')} Cloned ${name} into ${shown} ${c.m(`${branches} branches ${dot()} default ${def}`)}`);
|
|
120
|
+
line(` ${c.g('✓')} Push checks installed ${c.m('git push now runs gitroots checks first')} ${c.g(`→ cd ${path.basename(dir)} && git switch -c feature/…`)}`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// gitroots restore <session|backup> [--machine m] [--pick] [--point turn|b-id] [--fork]
|
|
2
|
+
import { c, h, pad, fit } from '../ui/ansi.js';
|
|
3
|
+
import { line, runSteps, select, interactive, size } from '../ui/term.js';
|
|
4
|
+
import { dot, json, next, sessionState } from '../ui/components.js';
|
|
5
|
+
|
|
6
|
+
export async function run(_cmd, rest, flags, store) {
|
|
7
|
+
const [target] = rest;
|
|
8
|
+
if (!target) throw new Error('usage: gitroots restore <session|backup> [--machine m] [--pick] [--fork]');
|
|
9
|
+
return restore(store, target, flags);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function pointsFor(store, s) {
|
|
13
|
+
return store.backups().filter((b) => b.session === s.id).sort((a, b) => b.turn - a.turn);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function panels(left, right, total) {
|
|
17
|
+
const w = Math.floor((total - 2) / 2);
|
|
18
|
+
const inner = w - 4;
|
|
19
|
+
const rows = Math.max(left.length, right.length);
|
|
20
|
+
const out = [];
|
|
21
|
+
const top = c.m(`╭${'─'.repeat(w - 2)}╮`);
|
|
22
|
+
const bot = c.m(`╰${'─'.repeat(w - 2)}╯`);
|
|
23
|
+
out.push(`${top} ${top}`);
|
|
24
|
+
for (let i = 0; i < rows; i++) {
|
|
25
|
+
out.push(`${c.m('│')} ${fit(left[i] || '', inner)} ${c.m('│')} ${c.m('│')} ${fit(right[i] || '', inner)} ${c.m('│')}`);
|
|
26
|
+
}
|
|
27
|
+
out.push(`${bot} ${bot}`);
|
|
28
|
+
return out.join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function restore(store, target, flags = {}) {
|
|
32
|
+
let s;
|
|
33
|
+
let point;
|
|
34
|
+
if (/^b-\d+$/.test(target)) { point = store.backup(target); s = store.session(point.session); } else s = store.session(target);
|
|
35
|
+
const points = pointsFor(store, s);
|
|
36
|
+
if (!points.length) throw new Error(`no backups for ${s.id} · gitroots backup ${s.id} makes one`);
|
|
37
|
+
if (!point && flags.point) {
|
|
38
|
+
point = /^b-/.test(flags.point) ? store.backup(flags.point) : points.find((p) => String(p.turn) === String(flags.point).replace(/^turn\s*/, ''));
|
|
39
|
+
if (!point) throw new Error(`no restore point ${flags.point} for ${s.id}`);
|
|
40
|
+
}
|
|
41
|
+
const machine = store.machine(flags.machine || s.machine);
|
|
42
|
+
if (!machine.online) throw new Error(`${machine.name} is offline · pick an online machine with --machine`);
|
|
43
|
+
let fork = Boolean(flags.fork);
|
|
44
|
+
|
|
45
|
+
if (!point && flags.pick && !flags.json) {
|
|
46
|
+
if (!interactive()) throw new Error('--pick needs a terminal · use --point <turn|backup>');
|
|
47
|
+
const latest = points[0];
|
|
48
|
+
line(`${c.i(c.b('?'))} Restore ${c.b(s.id)} to which point? ${c.m(`${s.agent} · ${s.repo} · ${s.branch}`)}`);
|
|
49
|
+
line();
|
|
50
|
+
const cols = Math.min(size().cols, 140);
|
|
51
|
+
const res = await select({
|
|
52
|
+
count: points.length,
|
|
53
|
+
initial: Math.min(1, points.length - 1),
|
|
54
|
+
keys: { f: 'fork', d: 'diff' },
|
|
55
|
+
render: (idx) => {
|
|
56
|
+
const rows = [` ${h(`${pad('POINT', 12)}${pad('WHEN', 14)}${pad('LAST PROMPT', 46)}${pad('FILES', 9)}BACKUP`)}`];
|
|
57
|
+
points.forEach((p, i) => {
|
|
58
|
+
const sel = i === idx;
|
|
59
|
+
const text = `${pad(`turn ${p.turn}`, 12)}${pad(p.created, 14)}${pad(p.prompt || p.label, 46)}${pad(String(p.files), 9)}${p.id}`;
|
|
60
|
+
rows.push(sel ? ` ${c.g('❯')} ${c.bg('sel')(c.b(text))}` : ` ${text}`);
|
|
61
|
+
});
|
|
62
|
+
const p = points[idx];
|
|
63
|
+
const dropped = latest.turn - p.turn;
|
|
64
|
+
const left = [
|
|
65
|
+
h(`Preview · turn ${p.turn}`),
|
|
66
|
+
`${c.s(pad('workspace', 11))}HEAD ${p.head} · ${p.files} file${p.files === 1 ? '' : 's'} changed`,
|
|
67
|
+
...(p.diff || []).map((f) => ` ${pad(f.path, 26)}${c.g(pad(`+${f.add}`, 4))} ${c.r(`−${f.del}`)}`),
|
|
68
|
+
dropped > 0 ? `${c.s(pad('dropped', 11))}${dropped} later turns` : `${c.s(pad('dropped', 11))}nothing, this is the latest point`,
|
|
69
|
+
dropped > 0 ? `${' '.repeat(11)}${c.m(`kept safe in ${latest.id}, nothing is deleted`)}` : '',
|
|
70
|
+
];
|
|
71
|
+
const right = [
|
|
72
|
+
h('Target'),
|
|
73
|
+
`${c.s(pad('machine', 11))}${machine.name} · ${machine.os}${machine.self ? ' · this machine' : ''}`,
|
|
74
|
+
`${c.s(pad('folder', 11))}~/work/${s.repo}${s.changed ? ` · ${c.w('has uncommitted changes')}` : ''}`,
|
|
75
|
+
`${c.s(pad('plan', 11))}${s.changed ? 'stash local changes as gitroots/pre-restore' : 'checkout, apply, resume'}`,
|
|
76
|
+
s.changed ? `${' '.repeat(11)}then checkout, apply, resume` : '',
|
|
77
|
+
`${c.s(pad('resume', 11))}${s.resume} · same conversation`,
|
|
78
|
+
];
|
|
79
|
+
return `${rows.join('\n')}\n\n${panels(left, right, cols)}\n\n${c.m('↑↓ move enter restore f restore as a fork (keep ' + s.id + ' as is) esc cancel')}`;
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
if (res.action === 'cancel') { line(c.m(' nothing restored')); return 1; }
|
|
83
|
+
if (res.action === 'fork') fork = true;
|
|
84
|
+
point = points[res.index];
|
|
85
|
+
line();
|
|
86
|
+
}
|
|
87
|
+
point = point || points[0];
|
|
88
|
+
|
|
89
|
+
if (flags.json) {
|
|
90
|
+
const r = apply(store, s, point, machine, fork);
|
|
91
|
+
json(r);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const steps = [];
|
|
96
|
+
if (s.changed && s.turns !== point.turn && !fork) steps.push({ name: 'Stashed local changes', live: 'git stash push -m gitroots/pre-restore…', ms: 500, ok: 'gitroots/pre-restore' });
|
|
97
|
+
steps.push({ name: `Workspace at turn ${point.turn}`, live: `checkout ${point.head} · apply backup ${point.id}…`, ms: 900, ok: `HEAD ${point.head} · ${point.files} file${point.files === 1 ? '' : 's'}` });
|
|
98
|
+
steps.push({ name: 'Conversation', live: `rewinding to turn ${point.turn}…`, ms: 500, ok: `${point.turn} turns · later turns kept in ${points[0].id}` });
|
|
99
|
+
await runSteps(steps, { nameWidth: 28 });
|
|
100
|
+
const r = apply(store, s, point, machine, fork);
|
|
101
|
+
const ss = store.session(r.session);
|
|
102
|
+
line(` ${c.g('✓')} ${c.b(`${fork ? 'Forked' : 'Restored'} ${r.session} on ${machine.name}`)} ${sessionState(ss)} ${dot()} picks up from turn ${point.turn} ${c.g(`→ gitroots attach ${r.session}`)}`);
|
|
103
|
+
if (fork) line(` ${c.m(`${s.id} is unchanged`)}`);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function apply(store, s, point, machine, fork) {
|
|
108
|
+
let target = s;
|
|
109
|
+
if (fork) {
|
|
110
|
+
target = { ...s, id: store.nextSessionId(), ask: undefined, forkedFrom: s.id };
|
|
111
|
+
store.d.sessions.push(target);
|
|
112
|
+
}
|
|
113
|
+
target.turns = point.turn;
|
|
114
|
+
target.head = point.head;
|
|
115
|
+
target.changed = point.files;
|
|
116
|
+
target.machine = machine.name;
|
|
117
|
+
target.state = 'running';
|
|
118
|
+
target.updated = 'just now';
|
|
119
|
+
if (!fork) target.ask = undefined;
|
|
120
|
+
const ev = store.record({ actor: store.org.user, action: fork ? 'fork' : 'restore', target: `${target.id} → turn ${point.turn} (${point.id})`, decision: 'allowed', note: machine.name, repo: s.repo });
|
|
121
|
+
store.save();
|
|
122
|
+
return { session: target.id, from: s.id, point: point.id, turn: point.turn, machine: machine.name, fork, event: ev.id };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export { next };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// `gitroots` with no arguments: welcome and boot.
|
|
2
|
+
import { c, pad } from '../ui/ansi.js';
|
|
3
|
+
import { line, write, spinner, sleep, motion, logoImage } from '../ui/term.js';
|
|
4
|
+
import { brand, dot, json } from '../ui/components.js';
|
|
5
|
+
import { VERSION } from '../runtime.js';
|
|
6
|
+
import { apiRepos } from '../connected.js';
|
|
7
|
+
|
|
8
|
+
export function needsSummary(store) {
|
|
9
|
+
const items = store.needsYou();
|
|
10
|
+
const parts = [];
|
|
11
|
+
const reviews = items.filter((x) => x.kind === 'review');
|
|
12
|
+
const agents = items.filter((x) => x.kind === 'agent');
|
|
13
|
+
const blocked = items.filter((x) => x.kind === 'blocked');
|
|
14
|
+
for (const r of reviews) parts.push(`PR #${r.pr.number} review`);
|
|
15
|
+
for (const a of agents) parts.push(`${a.session.id} approval`);
|
|
16
|
+
if (blocked.length) parts.push(`${blocked.length} blocked push${blocked.length > 1 ? 'es' : ''}`);
|
|
17
|
+
return { count: items.length, text: parts.join(' · ') };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default async function splash(store, args) {
|
|
21
|
+
const { org } = store;
|
|
22
|
+
const repos = store.repos();
|
|
23
|
+
const branchCount = repos.reduce((n, r) => n + r.branches, 0);
|
|
24
|
+
const providers = [...new Set(repos.map((r) => r.provider))].join(' · ');
|
|
25
|
+
const machines = store.machines().filter((m) => m.online);
|
|
26
|
+
const sessions = store.sessions();
|
|
27
|
+
const needs = needsSummary(store);
|
|
28
|
+
|
|
29
|
+
if (args.flags.json) {
|
|
30
|
+
json({ version: VERSION, org: org.name, user: org.user, role: org.role, relay: org.relay, repos: repos.length, branches: branchCount, machines: machines.length, sessions: sessions.length, needsYou: needs });
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const anim = motion();
|
|
35
|
+
line();
|
|
36
|
+
// Logo: the real image in Kitty, Ghostty, WezTerm and iTerm2; a plain gitroots line elsewhere.
|
|
37
|
+
const img = logoImage(26, 4);
|
|
38
|
+
const ver = `${c.s(`v${VERSION}`)} ${dot()} ${c.s('code governance and agent sessions')}`;
|
|
39
|
+
const tag = c.g('Secure it to the root, scale with confidence.');
|
|
40
|
+
if (img) {
|
|
41
|
+
write('\n\n\n\n\x1b[4A');
|
|
42
|
+
write(`\x1b7${img}\x1b8`);
|
|
43
|
+
const rows = ['', ver, tag, ''];
|
|
44
|
+
for (const r of rows) write(`\x1b[28C${c.m('│')} ${r}\n`);
|
|
45
|
+
} else {
|
|
46
|
+
line(`${brand()} ${c.m('│')} ${ver}`);
|
|
47
|
+
line(`${' '.repeat(8)} ${c.m('│')} ${tag}`);
|
|
48
|
+
}
|
|
49
|
+
line();
|
|
50
|
+
|
|
51
|
+
const checks = [
|
|
52
|
+
{ wait: 'Signing in…', ok: `Signed in as ${org.user}`, meta: `org ${org.name} ${dot()} ${org.role}` },
|
|
53
|
+
{ wait: 'Checking repositories…', ok: `${repos.length} repos · ${branchCount} branches`, meta: `${providers} ${dot()} rules in sync` },
|
|
54
|
+
{ wait: `Connecting agentd and relay ${org.relay.name}…`, ok: `${store.machines().length} machines · ${sessions.length} agent sessions`, meta: `relay ${org.relay.name} ${dot()} ${org.relay.latencyMs} ms` },
|
|
55
|
+
];
|
|
56
|
+
if (store.api) {
|
|
57
|
+
// Signed in: the first two checks are real calls to the gitroots server.
|
|
58
|
+
checks[0].run = async () => {
|
|
59
|
+
const me = await store.api.me();
|
|
60
|
+
if (!me) throw new Error('user not found');
|
|
61
|
+
return { ok: `Signed in as ${me.username}`, meta: `org ${org.name} ${dot()} ${org.role} ${dot()} ${store.api.server.replace(/^https?:\/\//, '')}` };
|
|
62
|
+
};
|
|
63
|
+
checks[1].run = async () => {
|
|
64
|
+
const rs = await apiRepos(store);
|
|
65
|
+
return { ok: `${rs.length} repos · ${rs.reduce((n, r) => n + (r.branches || 0), 0)} branches`, meta: 'GitHub · from the gitroots API' };
|
|
66
|
+
};
|
|
67
|
+
checks[2].meta += ` ${dot()} sample sessions`;
|
|
68
|
+
}
|
|
69
|
+
for (const ch of checks) {
|
|
70
|
+
const sp = spinner((f) => `${c.i(f)} ${c.s(ch.wait)}`);
|
|
71
|
+
const t0 = Date.now();
|
|
72
|
+
let r = {};
|
|
73
|
+
try { if (ch.run) r = await ch.run(); } catch (e) { sp.stop(`${c.r('✕')} ${e.message}`); continue; }
|
|
74
|
+
if (anim && Date.now() - t0 < 700) await sleep(700 - (Date.now() - t0));
|
|
75
|
+
sp.stop(`${c.g('✓')} ${r.ok || ch.ok} ${c.m(r.meta || ch.meta)}`);
|
|
76
|
+
}
|
|
77
|
+
line();
|
|
78
|
+
line(c.m('QUICK START'));
|
|
79
|
+
const qs = [
|
|
80
|
+
['repos', `${c.g('gitroots repo list')} ${dot()} ${c.g('branch list erp')} ${dot()} ${c.g('clone erp')}`, 'repositories and branch rules'],
|
|
81
|
+
['push', c.g('git push'), 'gitroots checks every push before it leaves'],
|
|
82
|
+
['review', `${c.g('gitroots pr list')} ${dot()} ${c.g('pr view 412')} ${dot()} ${c.g('pr approve')} ${dot()} ${c.g('pr merge')}`, 'reviews and merges'],
|
|
83
|
+
['audit', `${c.g('gitroots audit')} ${dot()} ${c.g('audit follow')}`, 'who did what, when, and what was blocked'],
|
|
84
|
+
['agents', `${c.g('gitroots watch')} ${dot()} ${c.g('attach')} ${dot()} ${c.g('transfer')} ${dot()} ${c.g('backup')} ${dot()} ${c.g('tunnel')}`, 'every agent session, one place'],
|
|
85
|
+
];
|
|
86
|
+
for (const [k, cmds, what] of qs) line(` ${c.s(pad(k, 8))} ${pad(cmds, 55)} ${c.m(what)}`);
|
|
87
|
+
line();
|
|
88
|
+
if (needs.count) {
|
|
89
|
+
line(`${c.w(`◐ ${needs.count} thing${needs.count > 1 ? 's' : ''} need${needs.count > 1 ? '' : 's'} you`)} ${c.m(needs.text)} ${c.g('→ gitroots status')}`);
|
|
90
|
+
} else {
|
|
91
|
+
line(`${c.g('✓ nothing needs you')} ${c.g('→ gitroots watch')}`);
|
|
92
|
+
}
|
|
93
|
+
if (store.mode === 'demo') line(c.m(` sample data · gitroots login connects your gitroots server · gitroots demo reset starts over`));
|
|
94
|
+
return 0;
|
|
95
|
+
}
|