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 +1 -1
- package/package.json +7 -4
- package/src/commands/canonical.js +242 -0
- package/src/commands/clone.js +2 -13
- package/src/commands/pet.js +122 -98
- package/src/commands/pull.js +3 -4
- package/src/commands/push.js +4 -5
- package/src/commands/share.js +40 -15
- package/src/commands/web.js +30 -15
- package/src/index.js +70 -40
- package/src/utils/api-client.js +6 -9
- package/src/utils/attributes.js +280 -0
- package/src/utils/canonical-journal.js +122 -0
- package/src/utils/feature-support.js +92 -0
- package/src/utils/feature-support.json +210 -0
- package/src/utils/gent-ops.js +836 -0
- package/src/utils/git-config.js +606 -0
- package/src/utils/git-index.js +581 -0
- package/src/utils/git-objects.js +537 -0
- package/src/utils/ignore.js +365 -0
- package/src/utils/lockfile.js +219 -0
- package/src/utils/merge-ops.js +282 -0
- package/src/utils/migrate.js +257 -0
- package/src/utils/object-store.js +332 -36
- package/src/utils/packfile.js +812 -0
- package/src/utils/refs.js +595 -0
- package/src/utils/repository.js +533 -0
- package/src/utils/smart-http.js +195 -0
- package/src/utils/stash-ops.js +355 -0
- package/src/utils/user-config.js +10 -0
- package/src/utils/web-urls.js +125 -0
- package/src/utils/worktree.js +676 -0
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
|
|
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": "
|
|
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 };
|
package/src/commands/clone.js
CHANGED
|
@@ -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('
|
|
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 {
|
package/src/commands/pet.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pet Command — "Genti", the gent mascot.
|
|
3
3
|
*
|
|
4
|
-
* A
|
|
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 →
|
|
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('#
|
|
29
|
-
shade: chalk.hex('#
|
|
30
|
-
eye: chalk.hex('#
|
|
31
|
-
mouth: chalk.hex('#
|
|
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
|
-
//
|
|
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
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
//
|
|
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
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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;
|
package/src/commands/pull.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|