gent-cli 14.0.0 → 20.0.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 CHANGED
@@ -25,7 +25,7 @@ Beyond a faithful git-like workflow, Gent adds:
25
25
  - **`gent resolve`** — an interactive conflict resolver (ours / theirs / both / edit / AI).
26
26
  - **`gent summary`** — a repository health dashboard, plus **`gent log --graph`**.
27
27
  - **Optional AI** (`gent commit --ai`, `gent explain`, `gent summary --ai`, AI option in `gent resolve`) — off by default, enabled with `ANTHROPIC_API_KEY`.
28
- - **Genti, your terminal mascot** — a chunky pixel bot that *acts out* your workflow: it carries a file crate to the cloud on `gent push`, walks one home on `gent pull`, and reconciles two branches on `gent merge`. It plays once (in place, no scrollback spam) after a successful command. Meet it directly with `gent pet` (add `--loop` to keep it running; try `gent pet push|pull|merge|auth`). Set `GENT_NO_PET=1` (or run in CI / a non-interactive shell) to turn the celebrations off.
28
+ - **Genti, your terminal mascot** — a mint one-eyed sky-jelly that *acts out* your workflow: it floats a file crate to the cloud on `gent push`, carries one home on `gent pull`, and reconciles two branches on `gent merge`. It plays once (in place, no scrollback spam) after a successful command. Meet it directly with `gent pet` (add `--loop` to keep it running; try `gent pet push|pull|merge|auth`). Set `GENT_NO_PET=1` (or run in CI / a non-interactive shell) to turn the celebrations off.
29
29
 
30
30
  See [docs/COMMANDS.md](docs/COMMANDS.md) for the full reference and
31
31
  [docs/ALGORITHMS.md](docs/ALGORITHMS.md) for how the engines work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gent-cli",
3
- "version": "14.0.0",
3
+ "version": "20.0.0",
4
4
  "description": "A modern, Git-like version control CLI with cloud sync, AI-powered superpowers (ask/review/docs/changelog), and zero-friction setup (gent setup/doctor/config).",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -8,14 +8,17 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node src/index.js",
11
- "test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js && node tests/offline-e2e.js",
12
- "test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js",
11
+ "test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js && node tests/offline-e2e.js && node --test tests/git-compat/engine.test.js tests/git-compat/cli.test.js tests/git-compat/transport.test.js tests/git-compat/migrate.test.js",
12
+ "test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js tests/web-urls.test.js",
13
13
  "test:e2e": "node tests/offline-e2e.js",
14
14
  "test:remote:e2e": "node tests/remote-e2e.js",
15
15
  "demo": "bash demo.sh",
16
16
  "link": "npm link",
17
17
  "unlink": "npm unlink",
18
- "prepublishOnly": "echo 'Ready to publish gent-cli!'"
18
+ "prepublishOnly": "echo 'Ready to publish gent-cli!'",
19
+ "test:canonical": "node --test tests/git-compat/engine.test.js tests/git-compat/cli.test.js tests/git-compat/transport.test.js tests/git-compat/migrate.test.js",
20
+ "test:independence": "node tests/git-compat/independence.js",
21
+ "test:interop": "node --test tests/interop/canonical.test.js"
19
22
  },
20
23
  "keywords": [
21
24
  "cli",
@@ -0,0 +1,242 @@
1
+ /** Canonical command adapters. Legacy repositories retain their v12 handlers
2
+ * until migration ships; no canonical command falls back to a legacy writer.
3
+ */
4
+ const fs = require('fs').promises;
5
+ const path = require('path');
6
+ const repository = require('../utils/repository');
7
+ const ops = require('../utils/gent-ops');
8
+ const merge = require('../utils/merge-ops');
9
+ const stash = require('../utils/stash-ops');
10
+ const journal = require('../utils/canonical-journal');
11
+ const worktree = require('../utils/worktree');
12
+ const { GitIndex } = require('../utils/git-index');
13
+ const { Lock } = require('../utils/lockfile');
14
+ const { AttributesMatcher, looksBinary } = require('../utils/attributes');
15
+ const { formatUnifiedDiff } = require('../utils/diff-engine');
16
+
17
+ async function locatedCanonical() {
18
+ let found;
19
+ try { found = await repository.findGitdir(); } catch (error) {
20
+ if (error.code === 'GENT_NOT_A_REPOSITORY') return null;
21
+ throw error;
22
+ }
23
+ if (await repository.isLegacyRepository(found.commondir)) return null;
24
+ return repository.open();
25
+ }
26
+
27
+ function route(name, legacy) {
28
+ const handler = async (...args) => {
29
+ try {
30
+ for (let dir = process.cwd(); ; dir = path.dirname(dir)) {
31
+ if (await fs.access(path.join(dir, '.gent-migration.json')).then(() => true, () => false)) throw new Error('interrupted migration; use gent migrate --continue or --abort');
32
+ if (path.dirname(dir) === dir) break;
33
+ }
34
+ if (name === 'init') {
35
+ const options = args[0] || {};
36
+ if (!options.objectFormat) {
37
+ const existing = await locatedCanonical();
38
+ if (!existing) return legacy(...args);
39
+ console.log(`Repository already initialized: ${existing.gitdir}`);
40
+ return;
41
+ }
42
+ if (options.objectFormat !== 'sha256') throw new Error('only --object-format=sha256 is supported');
43
+ if (options.remote) throw new Error('canonical remote creation is not implemented yet');
44
+ const result = await repository.init(process.cwd());
45
+ console.log(`Initialized SHA-256 repository: ${result.gitdir}`);
46
+ return;
47
+ }
48
+ // CLI-global settings remain available inside canonical repositories.
49
+ if (name === 'config' && args[1]?.[0] && !/^(user\.|core\.|remote\.|branch\.)/i.test(args[1][0])) {
50
+ return legacy(...args);
51
+ }
52
+ const repo = await locatedCanonical();
53
+ if (!repo) return legacy(...args);
54
+ if (!handlers[name]) throw new Error(`gent ${name} is not implemented for canonical repositories yet`);
55
+ const readOnly = ['status', 'log', 'show', 'diff', 'summary'].includes(name);
56
+ let lock;
57
+ try {
58
+ if (!readOnly) {
59
+ lock = await Lock.acquire(path.join(repo.gentWorktreeMetaDir, 'operation'));
60
+ await repo.assertNoExternalOperation(`gent ${name}`);
61
+ if (!(name === 'checkout' && args[1]?.abort)) await worktree.assertNoPendingCheckout(repo, `gent ${name}`);
62
+ }
63
+ const checkpointed = ['commit', 'checkout', 'reset', 'merge'].includes(name) && !args[1]?.abort && !args[0]?.abort;
64
+ const checkpoint = checkpointed ? await journal.begin(repo, name) : null;
65
+ const result = await handlers[name](repo, ...args);
66
+ if (checkpoint) await journal.finish(repo, checkpoint);
67
+ return result;
68
+ } finally { if (lock) await lock.release(); }
69
+ } catch (error) {
70
+ console.error(`Error: ${error.message}`);
71
+ process.exitCode = 1;
72
+ }
73
+ };
74
+ if (legacy.redo) handler.redo = route('redo', legacy.redo);
75
+ return handler;
76
+ }
77
+
78
+ const transport = require('../utils/smart-http');
79
+ const handlers = {
80
+ async remote(repo, sub, args = []) {
81
+ const [name = 'origin', url] = args;
82
+ transport.nameCheck(name);
83
+ if (sub === 'add' || sub === 'set-url') {
84
+ if (!url) throw new Error('provide a remote URL');
85
+ if (sub === 'add' && repo.config.get(`remote.${name}.url`)) throw new Error('remote already exists');
86
+ repo.localConfig.set(`remote.${name}.url`, transport.remoteUrl(url));
87
+ repo.localConfig.set(`remote.${name}.fetch`, `+refs/heads/*:refs/remotes/${name}/*`);
88
+ await repo.localConfig.save();
89
+ } else if (sub === 'remove') {
90
+ repo.localConfig.unset(`remote.${name}.url`);
91
+ repo.localConfig.unset(`remote.${name}.fetch`);
92
+ await repo.localConfig.save();
93
+ } else if (!sub) console.log(transport.configured(repo, name));
94
+ else throw new Error('use remote add|set-url|remove');
95
+ },
96
+ async fetch(repo, remote = 'origin') { await transport.fetch(repo, remote); console.log(`Fetched ${remote}`); },
97
+ async push(repo, remote = 'origin', branch, options = {}) {
98
+ await transport.push(repo, remote, branch, options); console.log('Push complete');
99
+ },
100
+ async pull(repo, remote = 'origin', branch) {
101
+ branch ||= (await repo.refs.head()).branch;
102
+ if (!branch) throw new Error('specify a branch from detached HEAD');
103
+ await transport.fetch(repo, remote);
104
+ const result = await merge.merge(repo, `refs/remotes/${remote}/${branch}`);
105
+ console.log(result.status);
106
+ if (result.status === 'conflicts') process.exitCode = 1;
107
+ },
108
+ async undo(repo, options = {}) {
109
+ if (options.list) { for (const item of (await journal.read(repo)).undo.slice().reverse()) console.log(item.name); }
110
+ else console.log(`Undid ${await journal.restore(repo)}`);
111
+ },
112
+ async redo(repo) { console.log(`Redid ${await journal.restore(repo, true)}`); },
113
+ async status(repo) {
114
+ const state = await ops.status(repo);
115
+ console.log(`On ${state.head.branch || 'detached HEAD'}`);
116
+ for (const item of state.staged) console.log(`staged ${item.status}: ${item.path}`);
117
+ for (const item of state.unstaged) console.log(`unstaged ${item.status}: ${item.path}`);
118
+ for (const name of state.conflicted) console.log(`conflict: ${name}`);
119
+ for (const name of state.untracked) console.log(`untracked: ${name}`);
120
+ if (!state.staged.length && !state.unstaged.length && !state.conflicted.length && !state.untracked.length) console.log('Working tree clean');
121
+ },
122
+ async add(repo, files, options) {
123
+ const result = await ops.addPaths(repo, files, options);
124
+ console.log(`Staged ${result.staged.length} change(s), ${result.removed.length} deletion(s)`);
125
+ },
126
+ async rm(repo, files, options) { await ops.removePaths(repo, files, options); },
127
+ async commit(repo, options) {
128
+ if (options.ai) throw new Error('AI commit messages are not connected to canonical repositories yet');
129
+ if (!options.message) throw new Error('provide a commit message with -m');
130
+ if (options.all) {
131
+ const index = await GitIndex.read(repo.indexPath);
132
+ const files = [...new Set(index.entries.map(e => path.join(repo.worktree, e.path)))];
133
+ if (files.length) await ops.addPaths(repo, files);
134
+ }
135
+ const result = await ops.createCommit(repo, options);
136
+ console.log(`[${result.branch || 'detached'} ${result.oid.slice(0, 12)}] ${options.message}`);
137
+ },
138
+ async branch(repo, name, options) {
139
+ if (options.delete) await ops.deleteBranch(repo, options.delete);
140
+ else if (name) await ops.createBranch(repo, name);
141
+ else for (const branch of await ops.listBranches(repo)) console.log(`${branch.current ? '*' : ' '} ${branch.name} ${branch.oid.slice(0, 12)}`);
142
+ },
143
+ async checkout(repo, target, options) {
144
+ if (options.abort) { await worktree.abortCheckout(repo); console.log('Checkout restored'); return; }
145
+ const result = await ops.checkout(repo, target, options);
146
+ console.log(`Switched to ${result.branch || result.oid}`);
147
+ },
148
+ async reset(repo, files, options) {
149
+ if (options.hard) await ops.reset(repo, 'hard', options.hard === true ? 'HEAD' : options.hard);
150
+ else if (options.soft) await ops.reset(repo, 'soft', options.soft === true ? 'HEAD' : options.soft);
151
+ else if (files.length) await ops.unstagePaths(repo, files);
152
+ else await ops.reset(repo, 'mixed', 'HEAD');
153
+ },
154
+ async merge(repo, branch, options) {
155
+ if (options.abort) return merge.abortMerge(repo);
156
+ if (options.continue) return merge.concludeMerge(repo, options.message);
157
+ const result = await merge.merge(repo, branch, options);
158
+ console.log(result.status);
159
+ if (result.status === 'conflicts') {
160
+ console.log('Resolve files, stage with gent add, then gent merge --continue or gent commit -m <message>.');
161
+ process.exitCode = 1;
162
+ }
163
+ },
164
+ async resolve(repo) {
165
+ const index = await GitIndex.read(repo.indexPath);
166
+ for (const name of index.conflicts().keys()) console.log(name);
167
+ console.log('Edit conflicted files, then gent add <path> and gent merge --continue.');
168
+ },
169
+ async stash(repo, sub = 'push', options = {}) {
170
+ const position = Number(options.index || 0);
171
+ if (!Number.isInteger(position) || position < 0) throw new Error('stash index must be a nonnegative integer');
172
+ if (sub === 'list') { for (const item of await stash.list(repo)) console.log(`stash@{${item.index}}: ${item.message}`); }
173
+ else if (sub === 'push' || sub === 'save') console.log((await stash.push(repo, options)).message);
174
+ else if (['apply', 'pop', 'drop'].includes(sub)) await stash[sub](repo, position, options);
175
+ else throw new Error(`unknown stash operation '${sub}'`);
176
+ },
177
+ async tag(repo, name, options) {
178
+ if (options.delete) await ops.deleteTag(repo, options.delete);
179
+ else if (name) await ops.createTag(repo, name, options);
180
+ else for (const item of await ops.listTags(repo)) console.log(item.name);
181
+ },
182
+ async log(repo, options) {
183
+ if (options.graph || options.stat) throw new Error('canonical log --graph/--stat formatting is not implemented yet; use log --oneline');
184
+ for (const commit of await ops.walkHistory(repo, { max: Number(options.number) })) {
185
+ console.log(`${commit.oid.slice(0, 12)} ${commit.message.toString().split('\n')[0]}`);
186
+ }
187
+ },
188
+ async show(repo, ref = 'HEAD', options = {}) {
189
+ const oid = await ops.peelToCommit(repo, ref);
190
+ const commit = await repo.objects.readCommit(oid);
191
+ console.log(`commit ${oid}\nParents: ${commit.parents.join(' ')}\n${commit.message.toString()}`);
192
+ if (options.patch !== false) {
193
+ const before = commit.parents.length ? await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(commit.parents[0])).tree) : new Map();
194
+ await printTreeDiff(repo, before, await worktree.readTreeRecursive(repo, commit.tree));
195
+ }
196
+ },
197
+ async diff(repo, files, options) {
198
+ const index = await GitIndex.read(repo.indexPath);
199
+ if (index.hasConflicts()) throw new Error('resolve conflicts before requesting a canonical diff');
200
+ const staged = new Map(index.staged().map(e => [e.path, e]));
201
+ const head = await repo.refs.head();
202
+ const before = options.staged ? await worktree.readTreeRecursive(repo, head.oid ? (await repo.objects.readCommit(head.oid)).tree : null) : staged;
203
+ const after = options.staged ? staged : new Map();
204
+ if (!options.staged) {
205
+ const attrs = new AttributesMatcher(repo);
206
+ for (const [name, entry] of staged) {
207
+ const snapshot = await worktree.snapshotPath(repo, name);
208
+ if (snapshot) after.set(name, { ...entry, mode: snapshot.mode, bytes: snapshot.mode === 0o120000 ? Buffer.from(snapshot.content, 'base64') : await attrs.toIndex(name, Buffer.from(snapshot.content, 'base64')) });
209
+ }
210
+ }
211
+ await printTreeDiff(repo, before, after, files, options.stat);
212
+ },
213
+ async summary(repo, options = {}) {
214
+ if (options.ai) throw new Error('AI summary is not connected to canonical repositories yet');
215
+ console.log(`${(await ops.walkHistory(repo)).length} commits, ${(await ops.listBranches(repo)).length} branches`);
216
+ await handlers.status(repo);
217
+ },
218
+ async config(repo, sub = 'list', args = []) {
219
+ const [key, value] = args;
220
+ if (sub === 'get') console.log(repo.config.get(key, ''));
221
+ else if (sub === 'set') {
222
+ if (!key || value === undefined) throw new Error('usage: gent config set <key> <value>');
223
+ if (!/^user\.(name|email)$/i.test(key)) throw new Error('canonical config writes currently support user.name and user.email only');
224
+ repo.localConfig.set(key, value); await repo.localConfig.save();
225
+ } else throw new Error('use gent config get <key> or gent config set user.name/user.email <value>');
226
+ }
227
+ };
228
+
229
+ async function printTreeDiff(repo, before, after, files = [], stat = false) {
230
+ const wanted = files.map(name => repo.relativePath(path.resolve(name)));
231
+ for (const name of new Set([...before.keys(), ...after.keys()])) {
232
+ if (wanted.length && !wanted.some(p => name === p || name.startsWith(p + '/'))) continue;
233
+ const a = before.get(name), b = after.get(name);
234
+ const read = item => item ? (item.bytes || repo.objects.readBlob(item.oid)) : Buffer.alloc(0);
235
+ const oldBytes = await read(a), newBytes = await read(b);
236
+ if (oldBytes.equals(newBytes) && a?.mode === b?.mode) continue;
237
+ if (stat || looksBinary(oldBytes) || looksBinary(newBytes)) console.log(`${name}: ${oldBytes.length} -> ${newBytes.length} bytes`);
238
+ else console.log(formatUnifiedDiff(name, oldBytes.toString(), newBytes.toString()));
239
+ }
240
+ }
241
+
242
+ module.exports = { route };
@@ -28,7 +28,6 @@ const ora = require('ora');
28
28
  const { ensureDir, writeJSON, pathExists } = require('../utils/fileSystem');
29
29
  const { GENT_DIR, CONFIG_FILE, STAGING_FILE, COMMITS_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
30
30
  const apiClient = require('../utils/api-client');
31
- const authStorage = require('../utils/auth-storage');
32
31
  const { storeBlob, readBlob } = require('../utils/hash-engine');
33
32
 
34
33
  /**
@@ -46,14 +45,6 @@ async function clone(url, directory, options) {
46
45
  const spinner = ora(`Cloning from ${url}...`).start();
47
46
 
48
47
  try {
49
- // Check auth
50
- const isAuth = await authStorage.isAuthenticated();
51
- if (!isAuth) {
52
- spinner.fail(chalk.red('Not authenticated'));
53
- console.log(chalk.yellow('Run "gent login" first'));
54
- return;
55
- }
56
-
57
48
  // Parse URL
58
49
  const repoInfo = parseRemoteUrl(url);
59
50
  if (!repoInfo) {
@@ -183,10 +174,8 @@ async function clone(url, directory, options) {
183
174
  spinner.fail(chalk.red('Clone failed'));
184
175
  if (error.response?.status === 404) {
185
176
  console.error(chalk.red('Repository not found'));
186
- } else if (error.response?.status === 401) {
187
- console.error(chalk.red('Authentication failed run "gent login"'));
188
- } else if (error.response?.status === 403) {
189
- console.error(chalk.red('Access denied — you do not have permission to clone this repository'));
177
+ } else if (error.response?.status === 401 || error.response?.status === 403) {
178
+ console.error(chalk.red('This repository is private or you do not have access to it. Run "gent login" to clone private repositories you can access.'));
190
179
  } else if (error.response?.data) {
191
180
  console.error(chalk.red(JSON.stringify(error.response.data)));
192
181
  } else {
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Pet Command — "Genti", the gent mascot.
3
3
  *
4
- * A chunky pixel-block creature that lives in your terminal and *acts out*
4
+ * A mint one-eyed sky-jelly that lives in your terminal and *acts out*
5
5
  * gent workflows:
6
6
  *
7
7
  * gent pet → idle: Genti breathes, blinks, waves, drops a tip
8
- * gent pet push → walks a file crate to the cloud, over and over
8
+ * gent pet push → floats a file crate to the cloud, over and over
9
9
  * gent pet pull → carries a crate back from the cloud
10
10
  * gent pet merge → stands between two branches and "thinks" them together
11
11
  * gent pet auth → little sign-in scene
@@ -25,10 +25,10 @@ const FRAME_MS = 90;
25
25
 
26
26
  // ── Palette ──────────────────────────────────────────────────────────────
27
27
  const C = {
28
- body: chalk.hex('#c97b5a'), // Genti's orange skin
29
- shade: chalk.hex('#9c5a3f'), // bottom shading
30
- eye: chalk.hex('#15110f'), // dark eye holes
31
- mouth: chalk.hex('#5a2f22'),
28
+ body: chalk.hex('#55e6c1'), // Genti's mint glow
29
+ shade: chalk.hex('#168f86'), // lower-body depth
30
+ eye: chalk.hex('#7c3aed'), // single violet eye
31
+ mouth: chalk.hex('#34205f'),
32
32
  crate: chalk.hex('#e0b64d'), // file crate edges
33
33
  crateIn:chalk.hex('#b98a1f'), // crate fill
34
34
  cloud: chalk.hex('#d6def0'), // remote / cloud
@@ -115,49 +115,27 @@ class Canvas {
115
115
  }
116
116
 
117
117
  // ── Mascot sprite ────────────────────────────────────────────────────────
118
- // Body is a 12-wide block with a 1-col margin (transparent) each side.
118
+ // Wide fins, one eye, and ribbon tentacles give Genti a sky-jelly silhouette.
119
119
  function mascot({ blink = false, mouth = '_', armUp = false } = {}) {
120
- const W = 12;
121
- const rows = [];
122
- rows[0] = ' ' + '#'.repeat(W) + ' ';
123
- rows[1] = ' ' + '#'.repeat(W) + ' ';
124
- rows[2] = ' ' + '#'.repeat(W) + ' '; // eyes row
125
- rows[3] = ' ' + '#'.repeat(W) + ' ';
126
- rows[4] = ' ' + '#'.repeat(W) + ' '; // mouth row
127
- rows[5] = ' ' + '@'.repeat(W) + ' '; // shaded chin
128
-
129
- const grid = rows.map(r => r.split(''));
130
- const eye = blink ? '#' : 'O';
131
- // eyes at body cols 3-4 and 8-9 → +1 for margin
132
- [3, 4].forEach(c => grid[2][c + 1] = eye);
133
- [8, 9].forEach(c => grid[2][c + 1] = eye);
134
- // mouth at cols 5-8 (row4)
135
- for (let c = 5; c <= 8; c++) grid[4][c + 1] = mouth;
136
- // ears (stick out at row2)
137
- grid[2][0] = '#';
138
- grid[2][W + 1] = '#';
139
-
140
- let body = grid.map(r => r.join(''));
141
-
142
- // arm (raised wave) sits to the right of the head on row1
143
- if (armUp) {
144
- const r1 = body[1].split('');
145
- r1[W + 1] = '#';
146
- body[1] = r1.join('');
147
- body.unshift(' #'); // tiny raised hand
148
- } else {
149
- body.unshift(' ');
150
- }
151
- return body; // 7 rows (incl. leading arm/space row), 14 wide
120
+ const eye = blink ? '##' : 'OO';
121
+ const smile = mouth.repeat(4);
122
+ return [
123
+ armUp ? '# #### ' : ' #### ',
124
+ armUp ? ' ## ######## ' : ' ########## ',
125
+ ' ############ ',
126
+ '##############',
127
+ ` ####${eye}#### `,
128
+ ` ##${smile}## `,
129
+ ' @@@@@@ ',
130
+ ];
152
131
  }
153
132
 
154
- // Legs are separate so they can shuffle while the body glides.
133
+ // Ribbon tentacles trail independently so the sky-jelly appears to float.
155
134
  function legs(step) {
156
- // step: 'stand' | 'a' | 'b'
157
135
  const map = {
158
- stand: ' ## ## ',
159
- a: ' ## # ',
160
- b: ' # ## ',
136
+ stand: ' @ @ @ ',
137
+ a: ' @ @ @ ',
138
+ b: ' @ @ @ ',
161
139
  };
162
140
  return [map[step] || map.stand];
163
141
  }
@@ -402,63 +380,83 @@ const up = (n) => `\x1b[${n}A`;
402
380
  * @param {boolean} opts.altScreen use the alternate screen buffer
403
381
  * @returns {Promise<void>}
404
382
  */
405
- function play(sceneName, { loop = false, footer = true, goodbye = false, altScreen = false, maxTicks = Infinity } = {}) {
406
- return new Promise((resolve) => {
407
- const scene = SCENES[sceneName] || SCENES.idle;
408
- const cv = new Canvas(CV_W, CV_H);
409
- const state = { count: 0, tip: TIPS[Math.floor(Math.random() * TIPS.length)] };
410
- const totalTicks = loop ? Infinity : Math.min(scene.cycle + 1, maxTicks); // one clean cycle, capped
411
- let t = 0;
412
- let printed = false;
413
- let done = false;
414
-
415
- const footerLine = () => footer
416
- ? chalk.gray(' ') +
417
- (loop ? chalk.gray('Ctrl+C to leave') : C.body('Genti')) +
418
- chalk.gray(' · more scenes: ') + C.say('gent pet push|pull|merge')
419
- : '';
420
-
421
- const paint = () => {
422
- cv.clear();
423
- scene.fn(cv, t, state);
424
- if (sceneName === 'idle' && loop && t > 0 && t % scene.cycle === 0) {
425
- state.tip = TIPS[Math.floor(Math.random() * TIPS.length)];
426
- }
427
- const lines = cv.render().split('\n');
428
- lines.push(footerLine());
429
- const block = lines.map(l => CLEAR_LINE + l).join('\n');
430
- if (altScreen) {
431
- process.stdout.write(HOME + block);
432
- } else {
433
- if (printed) process.stdout.write(up(lines.length));
434
- process.stdout.write(block + '\n');
435
- }
436
- printed = true;
437
- };
438
-
439
- const finish = () => {
440
- if (done) return;
441
- done = true;
442
- clearInterval(timer);
443
- process.removeListener('SIGINT', onSig);
444
- process.stdout.write(SHOW + (altScreen ? LEAVE_ALT : ''));
445
- if (goodbye) {
446
- console.log(C.body(' Genti waves.') + chalk.gray(' Come back with ') + C.say('gent pet') + chalk.gray('.'));
447
- }
448
- resolve();
449
- };
383
+ function runLoop(sceneName, {
384
+ loop = false, footer = true, goodbye = false, altScreen = false,
385
+ maxTicks = Infinity, clearOnStop = false, exitOnSig = false,
386
+ } = {}) {
387
+ const scene = SCENES[sceneName] || SCENES.idle;
388
+ const cv = new Canvas(CV_W, CV_H);
389
+ const state = { count: 0, tip: TIPS[Math.floor(Math.random() * TIPS.length)] };
390
+ const totalTicks = loop ? Infinity : Math.min(scene.cycle + 1, maxTicks);
391
+ const blockH = CV_H + (footer ? 1 : 0);
392
+ let t = 0;
393
+ let printed = false;
394
+ let done = false;
395
+ let resolveFn;
396
+ const promise = new Promise((r) => { resolveFn = r; });
397
+
398
+ const footerLine = () => footer
399
+ ? chalk.gray(' ') +
400
+ (loop ? chalk.gray('Ctrl+C to cancel') : C.body('Genti the sky-jelly')) +
401
+ chalk.gray(' · more scenes: ') + C.say('gent pet push|pull|merge')
402
+ : '';
403
+
404
+ const paint = () => {
405
+ cv.clear();
406
+ scene.fn(cv, t, state);
407
+ if (sceneName === 'idle' && loop && t > 0 && t % scene.cycle === 0) {
408
+ state.tip = TIPS[Math.floor(Math.random() * TIPS.length)];
409
+ }
410
+ const lines = cv.render().split('\n');
411
+ lines.push(footerLine());
412
+ const block = lines.map(l => CLEAR_LINE + l).join('\n');
413
+ if (altScreen) {
414
+ process.stdout.write(HOME + block);
415
+ } else {
416
+ if (printed) process.stdout.write(up(lines.length));
417
+ process.stdout.write(block + '\n');
418
+ }
419
+ printed = true;
420
+ };
450
421
 
451
- const onSig = () => finish();
422
+ // Erase the inline block so the caller can print clean output in its place.
423
+ const eraseBlock = () => {
424
+ if (!printed) return;
425
+ process.stdout.write(up(blockH));
426
+ for (let i = 0; i < blockH; i++) process.stdout.write(CLEAR_LINE + (i < blockH - 1 ? '\n' : ''));
427
+ process.stdout.write(up(blockH - 1));
428
+ };
429
+
430
+ const stop = () => {
431
+ if (done) return;
432
+ done = true;
433
+ clearInterval(timer);
434
+ process.removeListener('SIGINT', onSig);
435
+ if (clearOnStop && !altScreen) eraseBlock();
436
+ process.stdout.write(SHOW + (altScreen ? LEAVE_ALT : ''));
437
+ if (goodbye) {
438
+ console.log(C.body(' Genti waves.') + chalk.gray(' Come back with ') + C.say('gent pet') + chalk.gray('.'));
439
+ }
440
+ resolveFn();
441
+ };
452
442
 
453
- process.stdout.write((altScreen ? ENTER_ALT + HOME : '') + HIDE);
443
+ const onSig = () => { stop(); if (exitOnSig) process.exit(130); };
444
+
445
+ process.stdout.write((altScreen ? ENTER_ALT + HOME : '') + HIDE);
446
+ paint();
447
+ const timer = setInterval(() => {
448
+ t++;
449
+ if (t >= totalTicks) { paint(); return stop(); }
454
450
  paint();
455
- const timer = setInterval(() => {
456
- t++;
457
- if (t >= totalTicks) { paint(); return finish(); }
458
- paint();
459
- }, FRAME_MS);
460
- process.on('SIGINT', onSig);
461
- });
451
+ }, FRAME_MS);
452
+ process.on('SIGINT', onSig);
453
+
454
+ return { promise, stop };
455
+ }
456
+
457
+ // Play a scene to completion (or until Ctrl+C when looping). Resolves when done.
458
+ function play(sceneName, opts = {}) {
459
+ return runLoop(sceneName, opts).promise;
462
460
  }
463
461
 
464
462
  /**
@@ -528,6 +526,32 @@ async function celebrate(scene) {
528
526
  } catch (_) { /* ignore — decoration only */ }
529
527
  }
530
528
 
529
+ /**
530
+ * Public: run `task` while Genti animates as the live loader — carrying the
531
+ * crate to the cloud on push, home on pull, etc. The animation loops until the
532
+ * task settles, then erases itself so the command can print its own result.
533
+ *
534
+ * Safe + transparent: with no TTY / GENT_NO_PET / CI it simply awaits the task
535
+ * with no animation. Always returns (or throws) exactly what `task` does.
536
+ *
537
+ * @param {string} scene 'push' | 'pull' | 'merge' | …
538
+ * @param {() => Promise<any>} task the real async work (e.g. the network call)
539
+ * @returns {Promise<any>}
540
+ */
541
+ async function during(scene, task) {
542
+ if (!process.stdout.isTTY || process.env.GENT_NO_PET || process.env.CI || !SCENES[scene]) {
543
+ return task();
544
+ }
545
+ const anim = runLoop(scene, { loop: true, footer: true, altScreen: false, clearOnStop: true, exitOnSig: true });
546
+ try {
547
+ return await task();
548
+ } finally {
549
+ anim.stop();
550
+ await anim.promise;
551
+ }
552
+ }
553
+
531
554
  module.exports = petCommand;
532
555
  module.exports.celebrate = celebrate;
533
556
  module.exports.banner = banner;
557
+ module.exports.during = during;
@@ -73,14 +73,15 @@ async function pull(remoteName, branchName, options) {
73
73
 
74
74
  // 1. Fetch commits + objects for this branch in a single call. `since`
75
75
  // lets the server send only what we don't have on a fast-forward.
76
- spinner.text = `Fetching updates for ${branch}...`;
76
+ // Genti walks a crate home from the cloud while we fetch.
77
+ spinner.stop();
77
78
  let pullData;
78
79
  try {
79
80
  const pullUrl = buildRepoUrl(API_ENDPOINTS.REPO_PULL, repoInfo);
80
81
  const query = localHead
81
82
  ? `?branch=${encodeURIComponent(branch)}&since=${encodeURIComponent(localHead)}`
82
83
  : `?branch=${encodeURIComponent(branch)}`;
83
- pullData = await apiClient.get(pullUrl + query);
84
+ pullData = await pet.during('pull', () => apiClient.get(pullUrl + query));
84
85
  } catch (error) {
85
86
  if (error.response?.status === 404) {
86
87
  spinner.succeed(chalk.green('Remote branch not found — nothing to pull'));
@@ -138,7 +139,6 @@ async function pull(remoteName, branchName, options) {
138
139
 
139
140
  spinner.succeed(chalk.green(`Fast-forward: ${newCount} new commit(s)`));
140
141
  console.log(chalk.gray(` ${remote}/${branch} → ${remoteHead.substring(0, 7)}`));
141
- await pet.celebrate('pull');
142
142
  } else {
143
143
  // Diverged — need 3-way merge
144
144
  spinner.text = 'Branches diverged, merging...';
@@ -194,7 +194,6 @@ async function pull(remoteName, branchName, options) {
194
194
  } else {
195
195
  spinner.succeed(chalk.green(`Merged ${newCount} remote commit(s)`));
196
196
  console.log(chalk.gray(` Merge commit: ${mergeCommit.hash.substring(0, 7)}`));
197
- await pet.celebrate('pull');
198
197
  }
199
198
  }
200
199
  } catch (error) {
@@ -232,20 +232,19 @@ async function push(remoteName, branchName, options) {
232
232
  tags: tagsToPush
233
233
  };
234
234
 
235
- // Send to backend
235
+ // Send to backend — Genti carries the crate to the cloud while we wait.
236
236
  const pushUrl = buildRepoUrl(API_ENDPOINTS.REPO_PUSH, repoInfo);
237
- const response = await apiClient.post(pushUrl, payload);
237
+ spinner.stop();
238
+ const response = await pet.during('push', () => apiClient.post(pushUrl, payload, { timeout: 120000 }));
238
239
 
239
240
  // Update remote ref
240
241
  config.remoteRefs[`${remote}/${branch}`] = localHead;
241
242
  await writeJSON(configPath, config);
242
243
 
243
- spinner.succeed(chalk.green(`Pushed ${commitsToPush.length} commit(s) to ${remote}/${branch}`));
244
+ console.log(chalk.green(`✔ Pushed ${commitsToPush.length} commit(s) to ${remote}/${branch}`));
244
245
  console.log(chalk.gray(` ${localHead.substring(0, 7)} → ${remote}/${branch}`));
245
246
  console.log(chalk.gray(` ${packBlobs.length} blob(s), ${packTrees.length} tree(s) transferred`));
246
247
 
247
- await pet.celebrate('push');
248
-
249
248
  } catch (error) {
250
249
  spinner.fail(chalk.red('Push failed'));
251
250