getownit 0.0.0-stage → 0.1.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.
@@ -0,0 +1,116 @@
1
+ import { ApiError } from './api.js';
2
+ import { paint, 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
+ /** Runs one debrief: asks each question, grades answers, and offers follow-ups until every question passes. */
10
+ export async function runDebrief(api, prompter, target) {
11
+ const out = prompter.out;
12
+ const c = paint(out);
13
+ const commitWord = target.commits.length === 1 ? 'commit' : 'commits';
14
+ out.write(`\n${c.bold('Ownit debrief')} ${c.dim(`· ${target.repo} · ${target.commits.length} ${commitWord} · ${target.diff.changedLines} changed ${target.diff.changedLines === 1 ? 'line' : 'lines'}`)}\n`);
15
+ const payload = {
16
+ commits: target.commits,
17
+ diff: target.diff.diff,
18
+ diff_truncated: target.diff.truncated,
19
+ };
20
+ let start;
21
+ const spin = spinner(out, 'Nemotron is reading your push…');
22
+ try {
23
+ start = await api.request('POST', '/api/interview/start', {
24
+ repo: target.repo,
25
+ base_sha: target.baseSha,
26
+ head_sha: target.headSha,
27
+ changed_lines: target.diff.changedLines,
28
+ push_id: target.pushId,
29
+ ...payload,
30
+ });
31
+ }
32
+ catch (error) {
33
+ spin.stop();
34
+ return failure(out, error);
35
+ }
36
+ spin.stop();
37
+ let session = start.session;
38
+ out.write(c.dim(`Answer in your own words, up to 5 sentences. Rough is fine. Type "s" to skip.\n`));
39
+ for (const first of start.questions) {
40
+ let question = first;
41
+ let followUp = false;
42
+ for (;;) {
43
+ const label = `Question ${question.idx + 1}/${start.total}`;
44
+ out.write(`\n${c.bold(c.cyan(label))} ${c.dim(`(${KIND_LABEL[question.kind]}${followUp ? ', follow-up' : ''})`)}\n`);
45
+ out.write(`${wrap(question.text)}\n`);
46
+ let answer;
47
+ do
48
+ answer = await prompter.ask(c.bold('> '));
49
+ while (answer !== null && !answer.trim());
50
+ if (answer === null) {
51
+ out.write(`\n${c.yellow('Debrief paused.')} ${RESUME_HINT}\n`);
52
+ return { status: 'pending', reason: 'debrief paused' };
53
+ }
54
+ if (isSkip(answer))
55
+ return skip(api, out, session);
56
+ const grading = spinner(out, 'Checking your answer…');
57
+ let result;
58
+ try {
59
+ result = await api.request('POST', '/api/interview/answer', {
60
+ session,
61
+ idx: question.idx,
62
+ answer: answer.slice(0, MAX_ANSWER_CHARS),
63
+ ...payload,
64
+ });
65
+ }
66
+ catch (error) {
67
+ grading.stop();
68
+ return failure(out, error);
69
+ }
70
+ grading.stop();
71
+ session = result.session;
72
+ if (result.pass) {
73
+ out.write(`${c.green('✓ Passed.')}\n${c.dim(wrap(result.feedback))}\n`);
74
+ break;
75
+ }
76
+ out.write(`${c.yellow('Not quite yet.')}\n${wrap(result.feedback)}\n`);
77
+ if (!result.followup)
78
+ return { status: 'pending', reason: 'no follow-up question' };
79
+ out.write(c.dim("Here's another angle on the same topic:\n"));
80
+ question = result.followup;
81
+ followUp = true;
82
+ }
83
+ }
84
+ out.write(`\n${c.green(c.bold('✓ Push verified.'))} ${c.dim(`${target.commits.length} ${commitWord} owned. Nice work.`)}\n`);
85
+ return { status: 'verified' };
86
+ }
87
+ async function skip(api, out, session) {
88
+ try {
89
+ await api.request('POST', '/api/interview/skip', { session });
90
+ }
91
+ catch (error) {
92
+ return failure(out, error);
93
+ }
94
+ out.write(`${paint(out).dim('Skipped. This push is recorded as skipped.')}\n`);
95
+ return { status: 'skipped' };
96
+ }
97
+ /** Prints a one-line reason. Provider problems stay out of the stats until retried. */
98
+ function failure(out, error) {
99
+ const c = paint(out);
100
+ if (error instanceof ApiError) {
101
+ if (error.isProviderError) {
102
+ out.write(`${c.yellow('Ownit:')} ${error.message} This push won't count until you retry. ${RESUME_HINT}\n`);
103
+ return { status: 'provider_error', reason: error.message };
104
+ }
105
+ if (error.code === 'already_resolved') {
106
+ const status = error.body.status ?? 'verified';
107
+ out.write(`${c.dim(`Ownit: this push is already ${status}.`)}\n`);
108
+ return { status };
109
+ }
110
+ out.write(`${c.yellow('Ownit:')} ${sentence(error.message)} ${RESUME_HINT}\n`);
111
+ return { status: 'pending', reason: error.message };
112
+ }
113
+ const message = error instanceof Error ? error.message : String(error);
114
+ out.write(`${c.yellow('Ownit:')} ${sentence(message)} ${RESUME_HINT}\n`);
115
+ return { status: 'pending', reason: message };
116
+ }
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,72 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { hookCommand } from './commands/hook.js';
4
+ import { initCommand, uninstallCommand } from './commands/init.js';
5
+ import { interviewCommand } from './commands/interview.js';
6
+ import { badgeCommand } from './commands/badge.js';
7
+ import { keyCommand } from './commands/key.js';
8
+ import { loginCommand, logoutCommand, whoamiCommand } from './commands/login.js';
9
+ const HELP = `ownit: debrief the code you just pushed.
10
+
11
+ Usage:
12
+ ownit login [--server URL] Sign in with GitHub (device flow)
13
+ ownit logout Sign out
14
+ ownit whoami Show server, login, and key status
15
+ ownit init [--repo owner/name] Install the pre-push hook in this repo
16
+ ownit interview Debrief pushes that are still pending
17
+ ownit interview --range A..B Debrief a commit range
18
+ ownit key set [nebius|nvidia] Save your model API key (stored locally)
19
+ ownit key clear|status Remove or show it
20
+ ownit badge Print this repo's badge Markdown for your README
21
+ ownit uninstall Remove the pre-push hook
22
+ ownit help Show this help
23
+
24
+ Set OWNIT_SKIP=1 to push once without a debrief prompt (the push is pending).
25
+ `;
26
+ function version() {
27
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
28
+ return pkg.version;
29
+ }
30
+ async function main(argv) {
31
+ const [command, ...rest] = argv;
32
+ switch (command) {
33
+ case 'login':
34
+ return loginCommand(rest);
35
+ case 'logout':
36
+ return logoutCommand();
37
+ case 'whoami':
38
+ case 'status':
39
+ return whoamiCommand();
40
+ case 'init':
41
+ return initCommand(rest);
42
+ case 'uninstall':
43
+ return uninstallCommand();
44
+ case 'hook':
45
+ return hookCommand(rest);
46
+ case 'interview':
47
+ return interviewCommand(rest);
48
+ case 'key':
49
+ return keyCommand(rest);
50
+ case 'badge':
51
+ return badgeCommand();
52
+ case '--version':
53
+ case '-v':
54
+ process.stdout.write(`${version()}\n`);
55
+ return 0;
56
+ case undefined:
57
+ case 'help':
58
+ case '--help':
59
+ case '-h':
60
+ process.stdout.write(HELP);
61
+ return 0;
62
+ default:
63
+ process.stderr.write(`Unknown command "${command}".\n\n${HELP}`);
64
+ return 1;
65
+ }
66
+ }
67
+ main(process.argv.slice(2)).then((code) => {
68
+ process.exitCode = code;
69
+ }, (error) => {
70
+ process.stderr.write(`Ownit: ${error instanceof Error ? error.message : String(error)}\n`);
71
+ process.exitCode = 1;
72
+ });
@@ -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
+ }
package/dist/secret.js ADDED
@@ -0,0 +1,47 @@
1
+ import { createInterface } from 'node:readline';
2
+ /** Reads one line without echoing it on a TTY. Piped input is read as-is. */
3
+ export function readSecret(prompt) {
4
+ const stdin = process.stdin;
5
+ const out = process.stderr;
6
+ if (!stdin.isTTY) {
7
+ return new Promise((resolve) => {
8
+ const rl = createInterface({ input: stdin });
9
+ let done = false;
10
+ rl.once('line', (line) => {
11
+ done = true;
12
+ rl.close();
13
+ resolve(line);
14
+ });
15
+ rl.once('close', () => done || resolve(''));
16
+ });
17
+ }
18
+ out.write(prompt);
19
+ return new Promise((resolve) => {
20
+ let value = '';
21
+ stdin.setRawMode(true);
22
+ stdin.resume();
23
+ stdin.setEncoding('utf8');
24
+ const onData = (chunk) => {
25
+ for (const ch of chunk) {
26
+ if (ch === '\r' || ch === '\n' || ch === '\u0004') {
27
+ stdin.setRawMode(false);
28
+ stdin.pause();
29
+ stdin.off('data', onData);
30
+ out.write('\n');
31
+ resolve(value);
32
+ return;
33
+ }
34
+ if (ch === '\u0003') {
35
+ stdin.setRawMode(false);
36
+ out.write('\n');
37
+ process.exit(130);
38
+ }
39
+ if (ch === '\u007f' || ch === '\b')
40
+ value = value.slice(0, -1);
41
+ else
42
+ value += ch;
43
+ }
44
+ };
45
+ stdin.on('data', onData);
46
+ });
47
+ }