getownit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ import { ApiError } from './api.js';
2
+ import { errorMessage, paint, plural, sentence, spinner, wrap } from './ui.js';
3
+ const MAX_ANSWER_CHARS = 2000;
4
+ export const RESUME_HINT = 'Run "ownit interview" when you\'re ready.';
5
+ const KIND_LABEL = { why: 'why', how: 'how', next: "what's next" };
6
+ function isSkip(answer) {
7
+ return /^(s|skip|:skip)$/i.test(answer.trim());
8
+ }
9
+ /** Shows a spinner while `work` runs. */
10
+ async function withSpinner(out, text, work) {
11
+ const spin = spinner(out, text);
12
+ try {
13
+ return await work();
14
+ }
15
+ finally {
16
+ spin.stop();
17
+ }
18
+ }
19
+ function printQuestion(out, question, total, followUp) {
20
+ const c = paint(out);
21
+ const label = `Question ${question.idx + 1}/${total}`;
22
+ out.write(`\n${c.bold(c.cyan(label))} ${c.dim(`(${KIND_LABEL[question.kind]}${followUp ? ', follow-up' : ''})`)}\n`);
23
+ out.write(`${wrap(question.text)}\n`);
24
+ }
25
+ /** The next non-blank answer, or null when the input ends. */
26
+ async function readAnswer(prompter) {
27
+ const c = paint(prompter.out);
28
+ let answer;
29
+ do
30
+ answer = await prompter.ask(c.bold('> '));
31
+ while (answer !== null && !answer.trim());
32
+ return answer;
33
+ }
34
+ /**
35
+ * Asks a question, and follow-ups after a miss, until an answer passes (null)
36
+ * or the debrief ends early (its outcome).
37
+ */
38
+ async function askUntilPassed(debrief, first) {
39
+ const { api, prompter } = debrief;
40
+ const out = prompter.out;
41
+ const c = paint(out);
42
+ let question = first;
43
+ let followUp = false;
44
+ for (;;) {
45
+ printQuestion(out, question, debrief.total, followUp);
46
+ const answer = await readAnswer(prompter);
47
+ if (answer === null) {
48
+ out.write(`\n${c.yellow('Debrief paused.')} ${RESUME_HINT}\n`);
49
+ return { status: 'pending', reason: 'debrief paused' };
50
+ }
51
+ if (isSkip(answer))
52
+ return skip(api, out, debrief.session);
53
+ let result;
54
+ try {
55
+ result = await withSpinner(out, 'Checking your answer…', () => api.request('POST', '/api/interview/answer', {
56
+ session: debrief.session,
57
+ idx: question.idx,
58
+ answer: answer.slice(0, MAX_ANSWER_CHARS),
59
+ ...debrief.payload,
60
+ }));
61
+ }
62
+ catch (error) {
63
+ return failure(out, error);
64
+ }
65
+ debrief.session = result.session;
66
+ if (result.pass) {
67
+ out.write(`${c.green('✓ Passed.')}\n${c.dim(wrap(result.feedback))}\n`);
68
+ return null;
69
+ }
70
+ out.write(`${c.yellow('Not quite yet.')}\n${wrap(result.feedback)}\n`);
71
+ if (!result.followup)
72
+ return { status: 'pending', reason: 'no follow-up question' };
73
+ out.write(c.dim("Here's another angle on the same topic:\n"));
74
+ question = result.followup;
75
+ followUp = true;
76
+ }
77
+ }
78
+ /** Runs one debrief: asks each question, grades answers, and offers follow-ups until every question passes. */
79
+ export async function runDebrief(api, prompter, target) {
80
+ const out = prompter.out;
81
+ const c = paint(out);
82
+ const commitWord = plural(target.commits.length, 'commit');
83
+ out.write(`\n${c.bold('Ownit debrief')} ${c.dim(`· ${target.repo} · ${target.commits.length} ${commitWord} · ${target.diff.changedLines} changed ${plural(target.diff.changedLines, 'line')}`)}\n`);
84
+ const payload = {
85
+ commits: target.commits,
86
+ diff: target.diff.diff,
87
+ diff_truncated: target.diff.truncated,
88
+ };
89
+ let start;
90
+ try {
91
+ start = await withSpinner(out, 'Nemotron is reading your push…', () => api.request('POST', '/api/interview/start', {
92
+ repo: target.repo,
93
+ base_sha: target.baseSha,
94
+ head_sha: target.headSha,
95
+ changed_lines: target.diff.changedLines,
96
+ push_id: target.pushId,
97
+ ...payload,
98
+ }));
99
+ }
100
+ catch (error) {
101
+ return failure(out, error);
102
+ }
103
+ out.write(c.dim(`Answer in your own words, up to 5 sentences. Rough is fine. Type "s" to skip.\n`));
104
+ const debrief = { api, prompter, total: start.total, payload, session: start.session };
105
+ for (const question of start.questions) {
106
+ const ended = await askUntilPassed(debrief, question);
107
+ if (ended)
108
+ return ended;
109
+ }
110
+ out.write(`\n${c.green(c.bold('✓ Push verified.'))} ${c.dim(`${target.commits.length} ${commitWord} owned. Nice work.`)}\n`);
111
+ return { status: 'verified' };
112
+ }
113
+ async function skip(api, out, session) {
114
+ try {
115
+ await api.request('POST', '/api/interview/skip', { session });
116
+ }
117
+ catch (error) {
118
+ return failure(out, error);
119
+ }
120
+ out.write(`${paint(out).dim('Skipped. This push is recorded as skipped.')}\n`);
121
+ return { status: 'skipped' };
122
+ }
123
+ /** Prints a one-line reason. Provider problems stay out of the stats until retried. */
124
+ function failure(out, error) {
125
+ const c = paint(out);
126
+ if (error instanceof ApiError) {
127
+ if (error.isProviderError) {
128
+ out.write(`${c.yellow('Ownit:')} ${error.message} These commits stay unverified until you retry. ${RESUME_HINT}\n`);
129
+ return { status: 'provider_error', reason: error.message };
130
+ }
131
+ if (error.code === 'already_resolved') {
132
+ const status = error.body.status ?? 'verified';
133
+ out.write(`${c.dim(`Ownit: this push is already ${status}.`)}\n`);
134
+ return { status };
135
+ }
136
+ out.write(`${c.yellow('Ownit:')} ${sentence(error.message)} ${RESUME_HINT}\n`);
137
+ return { status: 'pending', reason: error.message };
138
+ }
139
+ const message = errorMessage(error);
140
+ out.write(`${c.yellow('Ownit:')} ${sentence(message)} ${RESUME_HINT}\n`);
141
+ return { status: 'pending', reason: message };
142
+ }
package/dist/diff.js ADDED
@@ -0,0 +1,174 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import ignore from 'ignore';
4
+ import { EMPTY_TREE, git, tryGit } from './git.js';
5
+ export const DIFF_CHAR_CAP = 60_000;
6
+ const LOCKFILES = new Set([
7
+ 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb',
8
+ 'Cargo.lock', 'Gemfile.lock', 'poetry.lock', 'Pipfile.lock', 'uv.lock', 'pdm.lock', 'composer.lock',
9
+ 'go.sum', 'flake.lock', 'mix.lock', 'pubspec.lock', 'Podfile.lock', 'packages.lock.json', 'gradle.lockfile',
10
+ 'Package.resolved', 'deno.lock',
11
+ ]);
12
+ // Built-in rules, in .gitignore syntax. A .ownitignore at the repo root adds more.
13
+ const BUILTIN_IGNORES = [
14
+ 'node_modules/', 'dist/', 'build/', 'out/', '.next/', '.nuxt/', 'coverage/', 'vendor/', '__generated__/',
15
+ '*.min.js', '*.min.css', '*.map', '*.bundle.js', '*.chunk.js',
16
+ '*.pb.go', '*_pb2.py', '*_pb2_grpc.py', '*.pb.ts', '*.generated.*', '*.g.dart', '*.freezed.dart',
17
+ '*.snap',
18
+ ];
19
+ const BINARY_EXTENSIONS = /\.(png|jpe?g|gif|webp|ico|bmp|tiff?|psd|pdf|zip|gz|tgz|bz2|xz|7z|rar|jar|war|class|so|dylib|dll|exe|bin|o|a|wasm|woff2?|ttf|otf|eot|mp[34]|mov|avi|webm|wav|ogg|flac|sqlite|db)$/i;
20
+ const GENERATED_MARKER = /@generated|DO NOT EDIT|auto-?generated|generated by /i;
21
+ function unquote(path) {
22
+ if (!path.startsWith('"'))
23
+ return path;
24
+ try {
25
+ return JSON.parse(path);
26
+ }
27
+ catch {
28
+ return path.slice(1, -1);
29
+ }
30
+ }
31
+ function pathFromHeader(header) {
32
+ const plus = header.match(/^\+\+\+ (?:b\/)?(.+)$/m)?.[1];
33
+ if (plus && plus !== '/dev/null')
34
+ return unquote(plus);
35
+ const minus = header.match(/^--- (?:a\/)?(.+)$/m)?.[1];
36
+ if (minus && minus !== '/dev/null')
37
+ return unquote(minus);
38
+ const renamed = header.match(/^rename to (.+)$/m)?.[1];
39
+ if (renamed)
40
+ return unquote(renamed);
41
+ const first = header.split('\n')[0] ?? '';
42
+ const idx = first.lastIndexOf(' b/');
43
+ return unquote(idx >= 0 ? first.slice(idx + 3) : first.replace(/^diff --git a\//, ''));
44
+ }
45
+ /** Splits `git diff` output into per-file header and body. */
46
+ export function parseDiff(text) {
47
+ const files = [];
48
+ const chunks = text.split(/^(?=diff --git )/m).filter((c) => c.startsWith('diff --git '));
49
+ for (const chunk of chunks) {
50
+ const hunkStart = chunk.search(/^@@ /m);
51
+ const header = hunkStart === -1 ? chunk : chunk.slice(0, hunkStart);
52
+ const body = hunkStart === -1 ? '' : chunk.slice(hunkStart);
53
+ files.push({
54
+ path: pathFromHeader(header),
55
+ header: header.endsWith('\n') ? header : `${header}\n`,
56
+ body,
57
+ binary: /^Binary files .* differ$/m.test(header) || /^GIT binary patch$/m.test(header),
58
+ });
59
+ }
60
+ return files;
61
+ }
62
+ /** Counts added and removed lines inside hunks only. */
63
+ export function countChangedLines(body) {
64
+ let count = 0;
65
+ for (const line of body.split('\n')) {
66
+ if ((line.startsWith('+') || line.startsWith('-')) && !line.startsWith('+++ ') && !line.startsWith('--- '))
67
+ count++;
68
+ }
69
+ return count;
70
+ }
71
+ /** Returns the reason a file is excluded from the debrief, or null to keep it. */
72
+ export function ignoreRules(ownitignore = '') {
73
+ const builtin = ignore().add(BUILTIN_IGNORES);
74
+ const custom = ignore().add(ownitignore);
75
+ return (path, file) => {
76
+ const name = path.split('/').pop() ?? path;
77
+ if (LOCKFILES.has(name))
78
+ return 'lockfile';
79
+ if (builtin.ignores(path))
80
+ return 'generated or build output';
81
+ if (custom.ignores(path))
82
+ return '.ownitignore';
83
+ if (file?.binary || BINARY_EXTENSIONS.test(path))
84
+ return 'binary';
85
+ if (file) {
86
+ const firstAdded = file.body
87
+ .split('\n')
88
+ .filter((l) => l.startsWith('+'))
89
+ .slice(0, 15)
90
+ .join('\n');
91
+ if (GENERATED_MARKER.test(firstAdded))
92
+ return 'generated';
93
+ // Minified: very long added lines.
94
+ if (file.body.split('\n').some((l) => l.length > 2000))
95
+ return 'minified';
96
+ }
97
+ return null;
98
+ };
99
+ }
100
+ /** Cuts text to at most `limit` characters at a line boundary. */
101
+ function cutLines(text, limit) {
102
+ if (text.length <= limit)
103
+ return { text, droppedLines: 0 };
104
+ let end = text.lastIndexOf('\n', limit);
105
+ if (end <= 0)
106
+ end = 0;
107
+ const kept = text.slice(0, end + (end ? 1 : 0));
108
+ const droppedLines = text.slice(kept.length).split('\n').filter(Boolean).length;
109
+ return { text: kept, droppedLines };
110
+ }
111
+ /**
112
+ * Fits the diff under `cap` characters. Every file header is kept; the body
113
+ * budget is shared fairly, so one huge file cannot crowd out the rest.
114
+ */
115
+ export function truncateFiles(files, cap = DIFF_CHAR_CAP) {
116
+ const headerChars = files.reduce((sum, f) => sum + f.header.length, 0);
117
+ let remaining = Math.max(0, cap - headerChars);
118
+ const bodies = new Map();
119
+ let truncated = false;
120
+ const bySize = [...files].sort((a, b) => a.body.length - b.body.length);
121
+ bySize.forEach((file, i) => {
122
+ const share = Math.floor(remaining / (bySize.length - i));
123
+ if (file.body.length <= share) {
124
+ bodies.set(file, file.body);
125
+ remaining -= file.body.length;
126
+ return;
127
+ }
128
+ truncated = true;
129
+ const marker = (n) => `[ownit: ${n} more lines of this file not shown]\n`;
130
+ const { text, droppedLines } = cutLines(file.body, Math.max(0, share - marker(999999).length));
131
+ const body = `${text}${marker(droppedLines)}`;
132
+ bodies.set(file, body);
133
+ remaining -= body.length;
134
+ });
135
+ return { text: files.map((f) => f.header + (bodies.get(f) ?? '')).join(''), truncated };
136
+ }
137
+ /** Filters ignored files out of a raw diff, then truncates what is left. */
138
+ export function prepareDiff(raw, check, cap = DIFF_CHAR_CAP) {
139
+ const kept = [];
140
+ const omitted = [];
141
+ for (const file of parseDiff(raw)) {
142
+ const reason = check(file.path, file);
143
+ if (reason)
144
+ omitted.push({ path: file.path, reason });
145
+ else
146
+ kept.push(file);
147
+ }
148
+ const changedLines = kept.reduce((sum, f) => sum + countChangedLines(f.body), 0);
149
+ const { text, truncated } = truncateFiles(kept, cap);
150
+ const note = omitted.length
151
+ ? `\n# Not shown (ignored by ownit): ${omitted.map((o) => `${o.path} (${o.reason})`).join(', ')}\n`
152
+ : '';
153
+ return { diff: text + note, truncated, changedLines, files: kept.length, omitted };
154
+ }
155
+ /** Reads .ownitignore from the pushed commit, falling back to the working tree. */
156
+ export function readOwnitignore(head, cwd = process.cwd()) {
157
+ const committed = tryGit(['show', `${head}:.ownitignore`], cwd);
158
+ if (committed !== null)
159
+ return committed;
160
+ const root = tryGit(['rev-parse', '--show-toplevel'], cwd)?.trim();
161
+ try {
162
+ return root ? readFileSync(join(root, '.ownitignore'), 'utf8') : '';
163
+ }
164
+ catch {
165
+ return '';
166
+ }
167
+ }
168
+ /** Builds the filtered diff for one or more ranges (a null base means the empty tree). */
169
+ export function buildDiff(ranges, cwd = process.cwd()) {
170
+ const raw = ranges
171
+ .map(({ base, head }) => git(['diff', '--no-color', '--no-ext-diff', '--find-renames', '--unified=3', base ?? EMPTY_TREE, head], cwd))
172
+ .join('');
173
+ return prepareDiff(raw, ignoreRules(readOwnitignore(ranges[0].head, cwd)));
174
+ }
package/dist/git.js ADDED
@@ -0,0 +1,76 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ export const ZERO_SHA = /^0+$/;
3
+ export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
4
+ export class GitError extends Error {
5
+ }
6
+ /** Runs git and returns stdout. Throws GitError with stderr on a non-zero exit. */
7
+ export function git(args, cwd = process.cwd()) {
8
+ const result = spawnSync('git', ['-c', 'core.quotePath=false', ...args], {
9
+ cwd,
10
+ encoding: 'utf8',
11
+ maxBuffer: 256 * 1024 * 1024,
12
+ stdio: ['ignore', 'pipe', 'pipe'],
13
+ });
14
+ if (result.error)
15
+ throw new GitError(`git ${args[0]} failed: ${result.error.message}`);
16
+ if (result.status !== 0)
17
+ throw new GitError(result.stderr.trim() || `git ${args.join(' ')} exited with ${result.status}`);
18
+ return result.stdout;
19
+ }
20
+ /** Like git(), but returns null instead of throwing. */
21
+ export function tryGit(args, cwd = process.cwd()) {
22
+ try {
23
+ return git(args, cwd);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export function objectExists(sha, cwd = process.cwd()) {
30
+ return tryGit(['cat-file', '-e', `${sha}^{commit}`], cwd) !== null;
31
+ }
32
+ export function repoRoot(cwd = process.cwd()) {
33
+ return git(['rev-parse', '--show-toplevel'], cwd).trim();
34
+ }
35
+ export function resolveCommit(rev, cwd = process.cwd()) {
36
+ return git(['rev-parse', '--verify', `${rev}^{commit}`], cwd).trim();
37
+ }
38
+ /** Commits in `revs` (rev-list syntax), oldest first. */
39
+ export function listCommits(revs, cwd = process.cwd()) {
40
+ const out = git(['log', '--reverse', '--format=%H%x1f%B%x1e', ...revs, '--'], cwd);
41
+ return out
42
+ .split('\x1e')
43
+ .map((chunk) => chunk.replace(/^\s+/, ''))
44
+ .filter(Boolean)
45
+ .map((chunk) => {
46
+ const [sha, message = ''] = chunk.split('\x1f');
47
+ return { sha: sha.trim(), message: message.trim() };
48
+ });
49
+ }
50
+ /** "owner/name" from a GitHub remote URL, or null for other hosts. */
51
+ export function parseGitHubRemote(url) {
52
+ const match = url.trim().match(/github\.com[:/]+([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i);
53
+ return match ? `${match[1]}/${match[2]}` : null;
54
+ }
55
+ /** The GitHub repo for this checkout: `ownit.repo` config, else the given remote, else origin. */
56
+ export function githubRepo(cwd = process.cwd(), remote) {
57
+ const configured = tryGit(['config', '--get', 'ownit.repo'], cwd)?.trim();
58
+ if (configured)
59
+ return configured;
60
+ for (const name of [remote, 'origin']) {
61
+ if (!name)
62
+ continue;
63
+ const url = /[:/]/.test(name) ? name : tryGit(['remote', 'get-url', name], cwd)?.trim();
64
+ const parsed = url ? parseGitHubRemote(url) : null;
65
+ if (parsed)
66
+ return parsed;
67
+ }
68
+ return null;
69
+ }
70
+ export function gitConfigGet(key, cwd = process.cwd()) {
71
+ return tryGit(['config', '--get', key], cwd)?.trim() || null;
72
+ }
73
+ /** The shared .git directory (the same for every worktree). */
74
+ export function gitCommonDir(cwd = process.cwd()) {
75
+ return git(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd).trim();
76
+ }
package/dist/hooks.js ADDED
@@ -0,0 +1,103 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
+ import { gitCommonDir, gitConfigGet, repoRoot } from './git.js';
4
+ // Hook chaining and core.hooksPath handling follow the approach of
5
+ // parthh01/sphinx (MIT): keep the user's hook and run it first, and find the
6
+ // directory git actually uses before installing.
7
+ export const HOOK_MARKER = '# ownit-managed-hook';
8
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
9
+ const shPath = (value) => (process.platform === 'win32' ? value.replaceAll('\\', '/') : value);
10
+ /** The absolute node binary and CLI entry used when `ownit` is not on PATH (e.g. GUI clients). */
11
+ export function launcher() {
12
+ const entry = process.argv[1] ? realpathSync(process.argv[1]) : '';
13
+ return { node: shPath(process.execPath), cli: shPath(entry) };
14
+ }
15
+ /**
16
+ * The pre-push script. It runs pre-push.local first (its failure blocks the
17
+ * push), then the debrief with the terminal on stdin. Ownit itself never blocks
18
+ * a push: the script always ends with exit 0.
19
+ */
20
+ export function hookScript(fallback = launcher()) {
21
+ return `#!/bin/sh
22
+ ${HOOK_MARKER}
23
+ # Installed by "ownit init". Remove with "ownit uninstall". Your previous hook, if any, is pre-push.local.
24
+ hook_dir=$(dirname "$0")
25
+ refs=$(cat)
26
+ if [ -x "$hook_dir/pre-push.local" ]; then
27
+ if [ -n "$refs" ]; then printf '%s\\n' "$refs"; fi | "$hook_dir/pre-push.local" "$@" || exit $?
28
+ fi
29
+ [ -n "$refs" ] || exit 0
30
+ [ "\${OWNIT_SKIP:-}" = "1" ] && exit 0
31
+ [ -n "\${OWNIT_HOOK_ACTIVE:-}" ] && exit 0
32
+ OWNIT_REFS=$refs
33
+ OWNIT_HOOK_ACTIVE=1
34
+ export OWNIT_REFS OWNIT_HOOK_ACTIVE
35
+ if command -v ownit >/dev/null 2>&1; then
36
+ set -- ownit hook pre-push "$@"
37
+ elif [ -f ${quote(fallback.cli)} ]; then
38
+ set -- ${quote(fallback.node)} ${quote(fallback.cli)} hook pre-push "$@"
39
+ else
40
+ echo 'Ownit: CLI not found, so no debrief this time. Push continues.' >&2
41
+ exit 0
42
+ fi
43
+ tty=\${OWNIT_TTY_DEVICE:-/dev/tty}
44
+ if [ -z "\${OWNIT_NO_TTY:-}" ] && (exec <"$tty") 2>/dev/null; then
45
+ OWNIT_TTY=1 "$@" <"$tty"
46
+ else
47
+ "$@" </dev/null
48
+ fi
49
+ exit 0
50
+ `;
51
+ }
52
+ /** Lines to append to a pre-push hook owned by a hook manager such as husky. */
53
+ export const MANAGED_SNIPPET = `# Ownit debrief (never blocks the push). Keep this at the end: it reads stdin.
54
+ export OWNIT_REFS="$(cat)"
55
+ if (exec </dev/tty) 2>/dev/null; then OWNIT_TTY=1 ownit hook pre-push "$@" </dev/tty; else ownit hook pre-push "$@" </dev/null; fi || true`;
56
+ /** Where git looks for hooks in this repo, and whether Ownit can install there. */
57
+ export function hookLocation(cwd = process.cwd()) {
58
+ const root = repoRoot(cwd);
59
+ const configured = gitConfigGet('core.hooksPath', cwd);
60
+ if (!configured)
61
+ return { kind: 'install', dir: join(gitCommonDir(cwd), 'hooks'), via: 'default' };
62
+ const dir = isAbsolute(configured) ? configured : resolve(root, configured);
63
+ if (/(^|[\\/])\.husky([\\/]|$)/.test(configured) || existsSync(join(root, '.husky'))) {
64
+ return { kind: 'manual', dir, manager: 'husky', reason: 'husky manages this repo\'s hooks' };
65
+ }
66
+ return { kind: 'install', dir, via: 'core.hooksPath' };
67
+ }
68
+ export function isOwnitHook(file) {
69
+ try {
70
+ return readFileSync(file, 'utf8').includes(HOOK_MARKER);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ /** Writes the hook, moving an existing non-Ownit hook to pre-push.local. */
77
+ export function installHook(dir, script = hookScript()) {
78
+ mkdirSync(dir, { recursive: true });
79
+ const file = join(dir, 'pre-push');
80
+ const local = join(dir, 'pre-push.local');
81
+ const updated = existsSync(file) && isOwnitHook(file);
82
+ if (existsSync(file) && !updated) {
83
+ if (existsSync(local)) {
84
+ throw new Error(`${file} exists and so does ${local}. Merge them by hand, then rerun "ownit init".`);
85
+ }
86
+ renameSync(file, local);
87
+ }
88
+ writeFileSync(file, script, { mode: 0o755 });
89
+ chmodSync(file, 0o755);
90
+ return { file, chained: existsSync(local), updated };
91
+ }
92
+ /** Removes the Ownit hook and restores pre-push.local as pre-push. */
93
+ export function uninstallHook(dir) {
94
+ const file = join(dir, 'pre-push');
95
+ const local = join(dir, 'pre-push.local');
96
+ if (!isOwnitHook(file))
97
+ return { removed: false, restored: false };
98
+ rmSync(file);
99
+ const restored = existsSync(local);
100
+ if (restored)
101
+ renameSync(local, file);
102
+ return { removed: true, restored };
103
+ }
package/dist/index.js ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { badgeCommand } from './commands/badge.js';
4
+ import { hookCommand } from './commands/hook.js';
5
+ import { initCommand, uninstallCommand } from './commands/init.js';
6
+ import { interviewCommand } from './commands/interview.js';
7
+ import { keyCommand } from './commands/key.js';
8
+ import { loginCommand, logoutCommand, whoamiCommand } from './commands/login.js';
9
+ import { errorMessage } from './ui.js';
10
+ /** Every command, in the order `ownit help` lists them. */
11
+ const COMMANDS = {
12
+ login: { help: [['ownit login [--server URL]', 'Sign in with GitHub (device flow)']], run: loginCommand },
13
+ logout: { help: [['ownit logout', 'Sign out']], run: logoutCommand },
14
+ whoami: { help: [['ownit whoami', 'Show server, login, and key status']], run: whoamiCommand },
15
+ init: { help: [['ownit init [--repo owner/name]', 'Install the pre-push hook in this repo']], run: initCommand },
16
+ interview: {
17
+ help: [
18
+ ['ownit interview', 'Debrief pushes that are still pending'],
19
+ ['ownit interview --range A..B', 'Debrief a commit range'],
20
+ ],
21
+ run: interviewCommand,
22
+ },
23
+ key: {
24
+ help: [
25
+ ['ownit key set [nebius|nvidia]', 'Save your model API key (stored locally)'],
26
+ ['ownit key clear|status', 'Remove or show it'],
27
+ ],
28
+ run: keyCommand,
29
+ },
30
+ badge: { help: [['ownit badge', "Print this repo's badge Markdown for your README"]], run: badgeCommand },
31
+ uninstall: { help: [['ownit uninstall', 'Remove the pre-push hook']], run: uninstallCommand },
32
+ help: { help: [['ownit help', 'Show this help']], run: () => print(helpText()) },
33
+ // Not listed: the installed git hook calls `ownit hook pre-push`.
34
+ hook: { run: (args) => hookCommand(args) },
35
+ version: { run: () => print(`${version()}\n`) },
36
+ };
37
+ const ALIASES = { status: 'whoami', '--help': 'help', '-h': 'help', '--version': 'version', '-v': 'version' };
38
+ function findCommand(name) {
39
+ const key = Object.hasOwn(ALIASES, name) ? ALIASES[name] : name;
40
+ return Object.hasOwn(COMMANDS, key) ? COMMANDS[key] : undefined;
41
+ }
42
+ function helpText() {
43
+ const usage = Object.values(COMMANDS)
44
+ .flatMap((command) => command.help ?? [])
45
+ .map(([line, description]) => ` ${line.padEnd(33)}${description}`)
46
+ .join('\n');
47
+ return `ownit: debrief the code you just pushed.
48
+
49
+ Usage:
50
+ ${usage}
51
+
52
+ Set OWNIT_SKIP=1 to push once without a debrief prompt (the push is pending).
53
+ `;
54
+ }
55
+ function version() {
56
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
57
+ return pkg.version;
58
+ }
59
+ function print(text) {
60
+ process.stdout.write(text);
61
+ return 0;
62
+ }
63
+ async function main(argv) {
64
+ const [name = 'help', ...args] = argv;
65
+ const command = findCommand(name);
66
+ if (!command) {
67
+ process.stderr.write(`Unknown command "${name}".\n\n${helpText()}`);
68
+ return 1;
69
+ }
70
+ return command.run(args);
71
+ }
72
+ main(process.argv.slice(2)).then((code) => {
73
+ process.exitCode = code;
74
+ }, (error) => {
75
+ process.stderr.write(`Ownit: ${errorMessage(error)}\n`);
76
+ process.exitCode = 1;
77
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Model providers the worker accepts, in the order `ownit key set` lists them.
3
+ * Add a provider here and in the worker's PROVIDERS (worker/src/model.ts).
4
+ */
5
+ export const PROVIDERS = {
6
+ nebius: { name: 'Nebius', envVar: 'NEBIUS_API_KEY', keyUrl: 'https://tokenfactory.nebius.com/', keyPrefix: undefined },
7
+ nvidia: { name: 'NVIDIA', envVar: 'NVIDIA_API_KEY', keyUrl: 'https://build.nvidia.com/', keyPrefix: 'nvapi-' },
8
+ };
9
+ export const PROVIDER_IDS = Object.keys(PROVIDERS);
10
+ export function isProvider(value) {
11
+ return Object.hasOwn(PROVIDERS, value);
12
+ }
13
+ /** The key to send with debriefs and its provider. A provider's env var wins over the saved key. */
14
+ export function modelKey(config, env = process.env) {
15
+ for (const provider of PROVIDER_IDS) {
16
+ const key = env[PROVIDERS[provider].envVar];
17
+ if (key)
18
+ return { provider, key };
19
+ }
20
+ if (config.apiKey)
21
+ return { provider: config.provider ?? 'nebius', key: config.apiKey };
22
+ if (config.nebiusKey)
23
+ return { provider: 'nebius', key: config.nebiusKey };
24
+ return undefined;
25
+ }
26
+ /** The provider whose keys have this key's prefix, if any. */
27
+ export function providerByPrefix(key) {
28
+ return PROVIDER_IDS.find((id) => {
29
+ const prefix = PROVIDERS[id].keyPrefix;
30
+ return prefix !== undefined && key.startsWith(prefix);
31
+ });
32
+ }
33
+ /** e.g. "NVIDIA, …1a2b" */
34
+ export function describeKey({ provider, key }) {
35
+ return `${PROVIDERS[provider]?.name ?? provider}, …${key.slice(-4)}`;
36
+ }
@@ -0,0 +1,57 @@
1
+ import { listCommits, objectExists, tryGit, ZERO_SHA } from './git.js';
2
+ /** Parses the pre-push stdin: "<local ref> <local sha> <remote ref> <remote sha>" per line. */
3
+ export function parseRefs(text) {
4
+ return text
5
+ .split(/\r?\n/)
6
+ .map((line) => line.trim().split(/\s+/))
7
+ .filter((f) => f.length === 4 && /^[0-9a-f]{40,64}$/.test(f[1]) && /^[0-9a-f]{40,64}$/.test(f[3]))
8
+ .map(([localRef, localSha, remoteRef, remoteSha]) => ({ localRef: localRef, localSha: localSha, remoteRef: remoteRef, remoteSha: remoteSha }));
9
+ }
10
+ /** First parent of a commit, or null for a root commit. */
11
+ function parentOf(sha, cwd) {
12
+ return tryGit(['rev-parse', '--verify', '--quiet', `${sha}^1`], cwd)?.trim() || null;
13
+ }
14
+ /**
15
+ * The commits a `git push` will publish, oldest first, across every updated
16
+ * ref. Deletions are ignored. For a new branch, commits already on any
17
+ * remote-tracking branch of that remote are excluded.
18
+ */
19
+ export function computePush(updates, remote, cwd = process.cwd()) {
20
+ const seen = new Set();
21
+ const commits = [];
22
+ const ranges = [];
23
+ const isNamedRemote = Boolean(remote) && tryGit(['remote', 'get-url', remote], cwd) !== null;
24
+ for (const update of updates) {
25
+ if (ZERO_SHA.test(update.localSha))
26
+ continue;
27
+ let revs;
28
+ let base;
29
+ if (!ZERO_SHA.test(update.remoteSha) && objectExists(update.remoteSha, cwd)) {
30
+ revs = [update.localSha, '--not', update.remoteSha];
31
+ base = tryGit(['merge-base', update.localSha, update.remoteSha], cwd)?.trim() || update.remoteSha;
32
+ }
33
+ else {
34
+ revs = [update.localSha, '--not', isNamedRemote ? `--remotes=${remote}` : '--remotes'];
35
+ base = null;
36
+ }
37
+ const list = listCommits(revs, cwd).filter((c) => !seen.has(c.sha));
38
+ if (!list.length)
39
+ continue;
40
+ if (base === null)
41
+ base = parentOf(list[0].sha, cwd);
42
+ for (const c of list)
43
+ seen.add(c.sha);
44
+ commits.push(...list);
45
+ ranges.push({ base, head: update.localSha });
46
+ }
47
+ if (!ranges.length)
48
+ return null;
49
+ return { base: ranges[0].base, head: ranges[0].head, commits, ranges };
50
+ }
51
+ /** Rebuilds the range of a recorded push from its commit list, for `ownit interview`. */
52
+ export function rangeForRecordedPush(baseSha, headSha, shas, cwd = process.cwd()) {
53
+ if (baseSha && !ZERO_SHA.test(baseSha) && objectExists(baseSha, cwd))
54
+ return { base: baseSha, head: headSha };
55
+ const oldest = shas[0] ?? headSha;
56
+ return { base: parentOf(oldest, cwd), head: headSha };
57
+ }