gent-cli 7.0.0 → 9.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/package.json +2 -2
- package/src/commands/ai.js +82 -0
- package/src/commands/ask.js +121 -0
- package/src/commands/branch.js +4 -0
- package/src/commands/changelog.js +121 -0
- package/src/commands/clone.js +36 -131
- package/src/commands/config.js +147 -0
- package/src/commands/docs.js +141 -0
- package/src/commands/doctor.js +169 -0
- package/src/commands/log.js +1 -2
- package/src/commands/members.js +134 -0
- package/src/commands/password.js +134 -0
- package/src/commands/pull.js +37 -95
- package/src/commands/push.js +27 -5
- package/src/commands/review.js +157 -0
- package/src/commands/search.js +77 -0
- package/src/commands/setup.js +201 -0
- package/src/commands/share.js +63 -0
- package/src/commands/show.js +3 -2
- package/src/commands/tag.js +13 -4
- package/src/commands/template.js +135 -0
- package/src/commands/web.js +72 -0
- package/src/index.js +166 -11
- package/src/services/auth-service.js +3 -4
- package/src/utils/ai-service.js +100 -37
- package/src/utils/api-client.js +33 -10
- package/src/utils/auth-storage.js +15 -3
- package/src/utils/constants.js +11 -2
- package/src/utils/env-loader.js +61 -0
- package/src/utils/user-config.js +225 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gent-cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "A modern, Git-like version control CLI with
|
|
3
|
+
"version": "9.0.0",
|
|
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": {
|
|
7
7
|
"gent": "src/index.js"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Command - Manage and verify AI integration.
|
|
3
|
+
*
|
|
4
|
+
* gent ai status → show key source, model, where it came from
|
|
5
|
+
* gent ai test → make a tiny live request to confirm it works
|
|
6
|
+
* gent ai models → list the model ids gent suggests
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const chalk = require('chalk');
|
|
10
|
+
const ora = require('ora');
|
|
11
|
+
const ai = require('../utils/ai-service');
|
|
12
|
+
const userConfig = require('../utils/user-config');
|
|
13
|
+
|
|
14
|
+
const SUGGESTED_MODELS = [
|
|
15
|
+
{ id: 'claude-opus-4-7', tag: 'flagship', note: 'Highest quality' },
|
|
16
|
+
{ id: 'claude-sonnet-4-6', tag: 'balanced', note: 'Strong, faster, cheaper' },
|
|
17
|
+
{ id: 'claude-haiku-4-5', tag: 'fastest', note: 'Fastest & cheapest' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
async function aiCommand(subcommand) {
|
|
21
|
+
const sub = (subcommand || 'status').toLowerCase();
|
|
22
|
+
switch (sub) {
|
|
23
|
+
case 'status': return status();
|
|
24
|
+
case 'test': return test();
|
|
25
|
+
case 'models': return models();
|
|
26
|
+
default:
|
|
27
|
+
console.error(chalk.red(`Unknown subcommand '${sub}'`));
|
|
28
|
+
console.log(chalk.gray('Usage: gent ai <status|test|models>'));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function status() {
|
|
34
|
+
const { value: key, source: keySource } = await ai.resolveKey();
|
|
35
|
+
const model = await ai.resolveModel();
|
|
36
|
+
const { source: modelSource } = await userConfig.getResolved('ai.model');
|
|
37
|
+
|
|
38
|
+
console.log(chalk.bold.cyan('\nGent AI status\n'));
|
|
39
|
+
if (key) {
|
|
40
|
+
console.log(` ${chalk.green('●')} API key: ${userConfig.maskSecret(key)} ${chalk.gray(`[${keySource}]`)}`);
|
|
41
|
+
} else {
|
|
42
|
+
console.log(` ${chalk.gray('○')} API key: ${chalk.gray('not set')}`);
|
|
43
|
+
console.log(chalk.gray(' ↳ ' + ai.disabledHint()));
|
|
44
|
+
}
|
|
45
|
+
console.log(` ${chalk.green('●')} Model: ${model} ${chalk.gray(`[${modelSource}]`)}`);
|
|
46
|
+
console.log(chalk.gray('\n Run `gent ai test` to verify the key actually works.'));
|
|
47
|
+
console.log();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function test() {
|
|
51
|
+
const { value: key } = await ai.resolveKey();
|
|
52
|
+
if (!key) {
|
|
53
|
+
console.error(chalk.red('No AI key configured.'));
|
|
54
|
+
console.log(chalk.yellow('Set one with `gent config set ai.api_key <key>`.'));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const model = await ai.resolveModel();
|
|
59
|
+
const spinner = ora(`Pinging Anthropic (${model})...`).start();
|
|
60
|
+
try {
|
|
61
|
+
const reply = await ai.complete({
|
|
62
|
+
prompt: 'Reply with the single word: pong',
|
|
63
|
+
maxTokens: 8,
|
|
64
|
+
});
|
|
65
|
+
spinner.succeed(chalk.green(`✓ Reachable. Reply: "${reply}"`));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
spinner.fail(chalk.red(err.message));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function models() {
|
|
73
|
+
const current = await ai.resolveModel();
|
|
74
|
+
console.log(chalk.bold.cyan('\nSuggested Claude models\n'));
|
|
75
|
+
for (const m of SUGGESTED_MODELS) {
|
|
76
|
+
const active = m.id === current ? chalk.green(' (current)') : '';
|
|
77
|
+
console.log(` ${chalk.cyan(m.id.padEnd(22))} ${chalk.gray(m.tag.padEnd(10))} ${m.note}${active}`);
|
|
78
|
+
}
|
|
79
|
+
console.log(chalk.gray('\n Switch with: gent config set ai.model <id>\n'));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = aiCommand;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask Command - Plain-English Q&A about the current repo.
|
|
3
|
+
*
|
|
4
|
+
* gent ask "what does this project do?"
|
|
5
|
+
* gent ask "who has touched src/server.js recently?"
|
|
6
|
+
* gent ask "what's pending on the current branch?"
|
|
7
|
+
*
|
|
8
|
+
* Builds a compact repo summary (README + last N commits + tree listing) and
|
|
9
|
+
* sends it as context. Falls back to a useful text dump if no AI key is set.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const chalk = require('chalk');
|
|
14
|
+
const ora = require('ora');
|
|
15
|
+
const { getGentPath, readJSON, pathExists } = require('../utils/fileSystem');
|
|
16
|
+
const fs = require('fs').promises;
|
|
17
|
+
const { COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
|
|
18
|
+
const ai = require('../utils/ai-service');
|
|
19
|
+
|
|
20
|
+
const MAX_CONTEXT_CHARS = 14000;
|
|
21
|
+
const MAX_COMMITS = 25;
|
|
22
|
+
|
|
23
|
+
async function ask(question, options = {}) {
|
|
24
|
+
try {
|
|
25
|
+
if (!question || !question.trim()) {
|
|
26
|
+
console.error(chalk.red('Usage: gent ask "<your question>"'));
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const gentPath = await getGentPath();
|
|
31
|
+
const context = await buildRepoContext(gentPath);
|
|
32
|
+
|
|
33
|
+
if (!ai.isEnabled()) {
|
|
34
|
+
console.log(chalk.yellow(ai.disabledHint()));
|
|
35
|
+
console.log(chalk.gray('\nHere is the raw repo context you can pipe into another tool:\n'));
|
|
36
|
+
console.log(context);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const spinner = ora(`Asking ${ai.getModel()}...`).start();
|
|
41
|
+
try {
|
|
42
|
+
const answer = await ai.complete({
|
|
43
|
+
system:
|
|
44
|
+
'You are a senior engineer answering questions about a software repository. ' +
|
|
45
|
+
'Be concrete and concise. If the answer is not in the context, say so. ' +
|
|
46
|
+
'Reference filenames and short commit hashes when helpful.',
|
|
47
|
+
prompt: `Repository context:\n\n${context}\n\nQuestion: ${question}`,
|
|
48
|
+
maxTokens: 1024,
|
|
49
|
+
});
|
|
50
|
+
spinner.stop();
|
|
51
|
+
console.log('\n' + answer + '\n');
|
|
52
|
+
} catch (err) {
|
|
53
|
+
spinner.fail(chalk.red(err.message));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error.code === 'ENOENT' && error.message.includes('.gent')) {
|
|
58
|
+
console.error(chalk.red('Error: Not a gent repository'));
|
|
59
|
+
console.log(chalk.yellow('Run "gent init" to initialize a repository'));
|
|
60
|
+
} else {
|
|
61
|
+
console.error(chalk.red('Error:'), error.message);
|
|
62
|
+
}
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function buildRepoContext(gentPath) {
|
|
68
|
+
const cwd = process.cwd();
|
|
69
|
+
const parts = [];
|
|
70
|
+
|
|
71
|
+
// Project name + description from config
|
|
72
|
+
try {
|
|
73
|
+
const config = await readJSON(path.join(gentPath, CONFIG_FILE));
|
|
74
|
+
const repo = config.repository || {};
|
|
75
|
+
parts.push(`# Project\nname: ${repo.name || '(unnamed)'}\ndescription: ${repo.description || ''}`);
|
|
76
|
+
} catch { /* missing config — fine */ }
|
|
77
|
+
|
|
78
|
+
// README if present (any case, common extensions)
|
|
79
|
+
const readme = await findReadme(cwd);
|
|
80
|
+
if (readme) {
|
|
81
|
+
const text = await fs.readFile(readme.path, 'utf-8').catch(() => '');
|
|
82
|
+
if (text) parts.push(`# README (${readme.rel})\n${text.slice(0, 4000)}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Recent commits
|
|
86
|
+
try {
|
|
87
|
+
const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
88
|
+
const commits = (repository.commits || []).slice(-MAX_COMMITS).reverse();
|
|
89
|
+
const branch = repository.currentBranch || 'main';
|
|
90
|
+
const lines = commits.map(c =>
|
|
91
|
+
`- ${(c.hash || '').slice(0, 7)} (${(c.author?.name || 'unknown')}): ${(c.message || '').split('\n')[0]}`
|
|
92
|
+
);
|
|
93
|
+
parts.push(`# Recent commits on '${branch}'\n${lines.join('\n')}`);
|
|
94
|
+
} catch { /* no commits yet — fine */ }
|
|
95
|
+
|
|
96
|
+
// Top-level layout
|
|
97
|
+
try {
|
|
98
|
+
const entries = await fs.readdir(cwd, { withFileTypes: true });
|
|
99
|
+
const layout = entries
|
|
100
|
+
.filter(e => !e.name.startsWith('.') && e.name !== 'node_modules')
|
|
101
|
+
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
|
|
102
|
+
.slice(0, 60);
|
|
103
|
+
parts.push(`# Top-level layout\n${layout.join('\n')}`);
|
|
104
|
+
} catch { /* unreadable — fine */ }
|
|
105
|
+
|
|
106
|
+
const joined = parts.join('\n\n');
|
|
107
|
+
return joined.length > MAX_CONTEXT_CHARS
|
|
108
|
+
? joined.slice(0, MAX_CONTEXT_CHARS) + '\n... (context truncated)'
|
|
109
|
+
: joined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function findReadme(cwd) {
|
|
113
|
+
const candidates = ['README.md', 'readme.md', 'README.txt', 'README', 'README.rst'];
|
|
114
|
+
for (const c of candidates) {
|
|
115
|
+
const p = path.join(cwd, c);
|
|
116
|
+
if (await pathExists(p)) return { path: p, rel: c };
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = ask;
|
package/src/commands/branch.js
CHANGED
|
@@ -148,6 +148,8 @@ async function syncBranchCreate(name, commitSha, gentPath) {
|
|
|
148
148
|
// Non-fatal: branch created locally even if remote sync fails
|
|
149
149
|
if (error.response?.status === 400) {
|
|
150
150
|
console.log(chalk.gray(` ⚠ Remote sync skipped (branch may already exist remotely)`));
|
|
151
|
+
} else if (error.response?.status === 403) {
|
|
152
|
+
console.log(chalk.yellow(` ⚠ Remote sync skipped — no write access to this repository`));
|
|
151
153
|
}
|
|
152
154
|
}
|
|
153
155
|
}
|
|
@@ -173,6 +175,8 @@ async function syncBranchDelete(name, gentPath) {
|
|
|
173
175
|
} catch (error) {
|
|
174
176
|
if (error.response?.status === 400) {
|
|
175
177
|
console.log(chalk.gray(` ⚠ Cannot delete default branch on remote`));
|
|
178
|
+
} else if (error.response?.status === 403) {
|
|
179
|
+
console.log(chalk.yellow(` ⚠ Remote delete skipped — no write access to this repository`));
|
|
176
180
|
} else if (error.response?.status === 404) {
|
|
177
181
|
// Branch didn't exist remotely, that's fine
|
|
178
182
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changelog Command - AI-grouped changelog between two refs.
|
|
3
|
+
*
|
|
4
|
+
* gent changelog → since the most recent tag (or last 50 commits)
|
|
5
|
+
* gent changelog <from>..<to> → commits between two refs
|
|
6
|
+
* gent changelog <from> → from <from> to HEAD
|
|
7
|
+
* gent changelog --plain → flat list, no AI grouping
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const chalk = require('chalk');
|
|
12
|
+
const ora = require('ora');
|
|
13
|
+
const { getGentPath, readJSON } = require('../utils/fileSystem');
|
|
14
|
+
const { COMMITS_FILE } = require('../utils/constants');
|
|
15
|
+
const ai = require('../utils/ai-service');
|
|
16
|
+
|
|
17
|
+
const MAX_COMMITS = 200;
|
|
18
|
+
|
|
19
|
+
async function changelog(range, options = {}) {
|
|
20
|
+
try {
|
|
21
|
+
const gentPath = await getGentPath();
|
|
22
|
+
const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
23
|
+
const commits = repository.commits || [];
|
|
24
|
+
if (commits.length === 0) {
|
|
25
|
+
console.log(chalk.yellow('No commits yet.'));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { from, to } = resolveRange(range, repository);
|
|
30
|
+
const selected = commitsBetween(commits, from, to);
|
|
31
|
+
|
|
32
|
+
if (selected.length === 0) {
|
|
33
|
+
console.log(chalk.yellow('No commits in the requested range.'));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const header = `${chalk.bold.cyan('Changelog')} ${chalk.gray(
|
|
38
|
+
`${(from || 'root').slice(0, 7)}..${(to || 'HEAD').slice(0, 7)} ` +
|
|
39
|
+
`(${selected.length} commits)`
|
|
40
|
+
)}`;
|
|
41
|
+
console.log('\n' + header + '\n');
|
|
42
|
+
|
|
43
|
+
if (options.plain || !ai.isEnabled()) {
|
|
44
|
+
for (const c of selected) {
|
|
45
|
+
const short = (c.hash || '').slice(0, 7);
|
|
46
|
+
const subject = (c.message || '').split('\n')[0];
|
|
47
|
+
console.log(` ${chalk.gray(short)} ${subject}`);
|
|
48
|
+
}
|
|
49
|
+
if (!ai.isEnabled()) {
|
|
50
|
+
console.log(chalk.gray(`\n${ai.disabledHint()}`));
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const summary = selected.map(c =>
|
|
56
|
+
`- ${(c.hash || '').slice(0, 7)}: ${(c.message || '').split('\n')[0]}`
|
|
57
|
+
).join('\n');
|
|
58
|
+
|
|
59
|
+
const spinner = ora(`Grouping with ${ai.getModel()}...`).start();
|
|
60
|
+
try {
|
|
61
|
+
const out = await ai.complete({
|
|
62
|
+
system:
|
|
63
|
+
'You write release-note-style changelogs. Group commits into ' +
|
|
64
|
+
'Features / Fixes / Improvements / Other. Keep each bullet to one line ' +
|
|
65
|
+
'and start with a verb. Drop merge commits and noise. Reply with Markdown.',
|
|
66
|
+
prompt: `Commits (newest first):\n\n${summary}`,
|
|
67
|
+
maxTokens: 1500,
|
|
68
|
+
});
|
|
69
|
+
spinner.stop();
|
|
70
|
+
console.log(out + '\n');
|
|
71
|
+
} catch (err) {
|
|
72
|
+
spinner.fail(chalk.yellow('AI grouping failed — falling back to plain list'));
|
|
73
|
+
console.log(chalk.gray(`(${err.message})\n`));
|
|
74
|
+
for (const c of selected) {
|
|
75
|
+
console.log(` ${chalk.gray((c.hash || '').slice(0, 7))} ${(c.message || '').split('\n')[0]}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.code === 'ENOENT' && error.message.includes('.gent')) {
|
|
80
|
+
console.error(chalk.red('Error: Not a gent repository'));
|
|
81
|
+
} else {
|
|
82
|
+
console.error(chalk.red('Error:'), error.message);
|
|
83
|
+
}
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveRange(range, repository) {
|
|
89
|
+
const head = repository.branches[repository.currentBranch] || null;
|
|
90
|
+
if (!range) {
|
|
91
|
+
// Use most recent tag if any, else null (means "all up to MAX_COMMITS")
|
|
92
|
+
const tags = repository.tags || {};
|
|
93
|
+
const tagHashes = Object.values(tags).map(t => t.hash || t.commit_sha).filter(Boolean);
|
|
94
|
+
return { from: tagHashes[tagHashes.length - 1] || null, to: head };
|
|
95
|
+
}
|
|
96
|
+
if (range.includes('..')) {
|
|
97
|
+
const [from, to] = range.split('..');
|
|
98
|
+
return { from: from || null, to: to || head };
|
|
99
|
+
}
|
|
100
|
+
return { from: range, to: head };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function commitsBetween(commits, from, to) {
|
|
104
|
+
const byHash = new Map(commits.map(c => [c.hash, c]));
|
|
105
|
+
const startHash = to || (commits[commits.length - 1] || {}).hash;
|
|
106
|
+
if (!startHash) return [];
|
|
107
|
+
|
|
108
|
+
const result = [];
|
|
109
|
+
let cur = startHash;
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
while (cur && cur !== from && !seen.has(cur) && result.length < MAX_COMMITS) {
|
|
112
|
+
const c = byHash.get(cur);
|
|
113
|
+
if (!c) break;
|
|
114
|
+
seen.add(cur);
|
|
115
|
+
result.push(c);
|
|
116
|
+
cur = c.parent;
|
|
117
|
+
}
|
|
118
|
+
return result; // newest first
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = changelog;
|
package/src/commands/clone.js
CHANGED
|
@@ -11,15 +11,12 @@
|
|
|
11
11
|
* gent clone <url> → Clone into folder named after repo
|
|
12
12
|
* gent clone <url> <directory> → Clone into specific directory
|
|
13
13
|
*
|
|
14
|
-
* ALGORITHM
|
|
14
|
+
* ALGORITHM:
|
|
15
15
|
* 1. Parse URL to get owner_id + repo_name
|
|
16
|
-
* 2. GET
|
|
17
|
-
* 3.
|
|
18
|
-
* 4.
|
|
19
|
-
* 5.
|
|
20
|
-
* 6. Create .gent/ directory structure
|
|
21
|
-
* 7. Store all objects locally
|
|
22
|
-
* 8. Checkout HEAD (restore working tree from latest commit)
|
|
16
|
+
* 2. GET /clone/ → full snapshot (commits, base64 objects, branches, tags)
|
|
17
|
+
* 3. Create .gent/ structure + store objects locally
|
|
18
|
+
* 4. Write commits.json / config / HEAD / staging
|
|
19
|
+
* 5. Checkout the default branch's tree
|
|
23
20
|
*
|
|
24
21
|
* ============================================================================
|
|
25
22
|
*/
|
|
@@ -32,7 +29,7 @@ const { ensureDir, writeJSON, pathExists } = require('../utils/fileSystem');
|
|
|
32
29
|
const { GENT_DIR, CONFIG_FILE, STAGING_FILE, COMMITS_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
|
|
33
30
|
const apiClient = require('../utils/api-client');
|
|
34
31
|
const authStorage = require('../utils/auth-storage');
|
|
35
|
-
const { storeBlob,
|
|
32
|
+
const { storeBlob, readBlob } = require('../utils/hash-engine');
|
|
36
33
|
|
|
37
34
|
/**
|
|
38
35
|
* Clone remote repository
|
|
@@ -65,14 +62,14 @@ async function clone(url, directory, options) {
|
|
|
65
62
|
return;
|
|
66
63
|
}
|
|
67
64
|
|
|
68
|
-
//
|
|
69
|
-
spinner.text = 'Fetching repository
|
|
70
|
-
const
|
|
71
|
-
buildRepoUrl(API_ENDPOINTS.
|
|
65
|
+
// Fetch the full repository snapshot in one call.
|
|
66
|
+
spinner.text = 'Fetching repository...';
|
|
67
|
+
const payload = await apiClient.get(
|
|
68
|
+
buildRepoUrl(API_ENDPOINTS.REPO_CLONE, repoInfo)
|
|
72
69
|
);
|
|
73
70
|
|
|
74
|
-
const repoName =
|
|
75
|
-
const defaultBranch =
|
|
71
|
+
const repoName = payload.name || repoInfo.repo_name;
|
|
72
|
+
const defaultBranch = payload.currentBranch || 'main';
|
|
76
73
|
const targetDir = directory || repoName;
|
|
77
74
|
const targetPath = path.resolve(process.cwd(), targetDir);
|
|
78
75
|
|
|
@@ -84,39 +81,6 @@ async function clone(url, directory, options) {
|
|
|
84
81
|
}
|
|
85
82
|
}
|
|
86
83
|
|
|
87
|
-
// 2. Get branches
|
|
88
|
-
spinner.text = 'Fetching branches...';
|
|
89
|
-
let remoteBranches = [];
|
|
90
|
-
try {
|
|
91
|
-
remoteBranches = await apiClient.get(
|
|
92
|
-
buildRepoUrl(API_ENDPOINTS.REPO_BRANCHES, repoInfo)
|
|
93
|
-
);
|
|
94
|
-
} catch {
|
|
95
|
-
// No branches yet
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// 3. Get all commits
|
|
99
|
-
spinner.text = 'Fetching commits...';
|
|
100
|
-
let remoteCommits = [];
|
|
101
|
-
try {
|
|
102
|
-
remoteCommits = await apiClient.get(
|
|
103
|
-
buildRepoUrl(API_ENDPOINTS.REPO_COMMITS, repoInfo)
|
|
104
|
-
);
|
|
105
|
-
} catch {
|
|
106
|
-
// No commits yet
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// 4. Get tags
|
|
110
|
-
spinner.text = 'Fetching tags...';
|
|
111
|
-
let remoteTags = [];
|
|
112
|
-
try {
|
|
113
|
-
remoteTags = await apiClient.get(
|
|
114
|
-
buildRepoUrl(API_ENDPOINTS.REPO_TAGS, repoInfo)
|
|
115
|
-
);
|
|
116
|
-
} catch {
|
|
117
|
-
// No tags
|
|
118
|
-
}
|
|
119
|
-
|
|
120
84
|
// Create directory structure
|
|
121
85
|
spinner.text = 'Setting up repository...';
|
|
122
86
|
const gentPath = path.join(targetPath, GENT_DIR);
|
|
@@ -125,100 +89,33 @@ async function clone(url, directory, options) {
|
|
|
125
89
|
await ensureDir(path.join(gentPath, 'refs', 'heads'));
|
|
126
90
|
await ensureDir(path.join(gentPath, 'refs', 'tags'));
|
|
127
91
|
|
|
128
|
-
//
|
|
129
|
-
|
|
92
|
+
// Store blob objects (base64) into the local object store.
|
|
93
|
+
spinner.text = 'Storing objects...';
|
|
130
94
|
let objectCount = 0;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
let treeEntries = [];
|
|
137
|
-
if (commit.tree_sha) {
|
|
138
|
-
try {
|
|
139
|
-
const tree = await apiClient.get(
|
|
140
|
-
buildRepoUrl(API_ENDPOINTS.REPO_TREE_DETAIL, { ...repoInfo, sha: commit.tree_sha })
|
|
141
|
-
);
|
|
142
|
-
treeEntries = tree.entries || [];
|
|
143
|
-
} catch {
|
|
144
|
-
// Tree not available
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Fetch and store blobs
|
|
149
|
-
for (const entry of treeEntries) {
|
|
150
|
-
if (entry.type === 'blob' && entry.sha) {
|
|
151
|
-
try {
|
|
152
|
-
const blob = await apiClient.get(
|
|
153
|
-
buildRepoUrl(API_ENDPOINTS.REPO_BLOB_DETAIL, { ...repoInfo, sha: entry.sha })
|
|
154
|
-
);
|
|
155
|
-
if (blob.content) {
|
|
156
|
-
const buf = decodeRemoteBlobContent(blob.content, entry.sha);
|
|
157
|
-
await storeBlob(gentPath, buf);
|
|
158
|
-
objectCount++;
|
|
159
|
-
}
|
|
160
|
-
} catch {
|
|
161
|
-
// Blob fetch failed
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Convert to local commit format
|
|
167
|
-
localCommits.push({
|
|
168
|
-
hash: commit.sha,
|
|
169
|
-
message: commit.message,
|
|
170
|
-
author: { name: commit.author_name, email: commit.author_email },
|
|
171
|
-
timestamp: commit.committed_at,
|
|
172
|
-
parent: commit.parent_shas && commit.parent_shas[0] || null,
|
|
173
|
-
mergeParent: commit.parent_shas && commit.parent_shas[1] || null,
|
|
174
|
-
treeHash: commit.tree_sha,
|
|
175
|
-
tree: treeEntries.map(e => ({
|
|
176
|
-
mode: e.mode || '100644',
|
|
177
|
-
name: e.name,
|
|
178
|
-
hash: e.sha,
|
|
179
|
-
type: e.type || 'blob'
|
|
180
|
-
})),
|
|
181
|
-
files: treeEntries.map(e => ({ path: e.name, hash: e.sha })),
|
|
182
|
-
stats: {}
|
|
183
|
-
});
|
|
95
|
+
for (const obj of payload.objects || []) {
|
|
96
|
+
if (obj.type !== 'blob' || typeof obj.data !== 'string') continue;
|
|
97
|
+
await storeBlob(gentPath, Buffer.from(obj.data, 'base64'));
|
|
98
|
+
objectCount++;
|
|
184
99
|
}
|
|
185
100
|
|
|
186
|
-
|
|
187
|
-
const branches = {};
|
|
188
|
-
|
|
189
|
-
branches[b.name] = b.commit_sha;
|
|
190
|
-
}
|
|
191
|
-
if (!branches[defaultBranch]) {
|
|
192
|
-
branches[defaultBranch] = null;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Build tags map
|
|
196
|
-
const tagsMap = {};
|
|
197
|
-
for (const t of remoteTags) {
|
|
198
|
-
tagsMap[t.name] = {
|
|
199
|
-
hash: t.commit_sha,
|
|
200
|
-
message: t.message || '',
|
|
201
|
-
annotated: t.annotated || false,
|
|
202
|
-
tagger: { name: t.tagger_name || '', email: t.tagger_email || '' },
|
|
203
|
-
timestamp: t.created_at
|
|
204
|
-
};
|
|
205
|
-
}
|
|
101
|
+
const localCommits = payload.commits || [];
|
|
102
|
+
const branches = payload.branches || {};
|
|
103
|
+
if (!(defaultBranch in branches)) branches[defaultBranch] = null;
|
|
206
104
|
|
|
207
105
|
// Write commits.json
|
|
208
|
-
|
|
106
|
+
await writeJSON(path.join(gentPath, COMMITS_FILE), {
|
|
209
107
|
commits: localCommits,
|
|
210
108
|
branches,
|
|
211
109
|
currentBranch: defaultBranch,
|
|
212
|
-
tags:
|
|
213
|
-
};
|
|
214
|
-
await writeJSON(path.join(gentPath, COMMITS_FILE), repoData);
|
|
110
|
+
tags: payload.tags || {}
|
|
111
|
+
});
|
|
215
112
|
|
|
216
113
|
// Write config with remote
|
|
217
114
|
const config = {
|
|
218
115
|
user: { name: '', email: '' },
|
|
219
116
|
repository: {
|
|
220
117
|
name: repoName,
|
|
221
|
-
description:
|
|
118
|
+
description: payload.description || '',
|
|
222
119
|
created: new Date().toISOString()
|
|
223
120
|
},
|
|
224
121
|
remotes: {
|
|
@@ -256,11 +153,17 @@ async function clone(url, directory, options) {
|
|
|
256
153
|
spinner.text = 'Checking out files...';
|
|
257
154
|
let fileCount = 0;
|
|
258
155
|
for (const entry of tree) {
|
|
156
|
+
if (entry.type && entry.type !== 'blob') continue;
|
|
157
|
+
const relPath = entry.name || entry.path;
|
|
158
|
+
if (!relPath || !entry.hash) continue;
|
|
259
159
|
try {
|
|
260
|
-
|
|
261
|
-
|
|
160
|
+
// Write the raw Buffer — not a UTF-8 string. Decoding a
|
|
161
|
+
// binary blob (PNG, PDF, etc.) as UTF-8 would replace
|
|
162
|
+
// non-utf-8 bytes with U+FFFD, silently corrupting it.
|
|
163
|
+
const buf = await readBlob(gentPath, entry.hash);
|
|
164
|
+
const fullPath = path.join(targetPath, relPath);
|
|
262
165
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
263
|
-
await fs.writeFile(fullPath,
|
|
166
|
+
await fs.writeFile(fullPath, buf);
|
|
264
167
|
fileCount++;
|
|
265
168
|
} catch {
|
|
266
169
|
// Blob missing
|
|
@@ -282,6 +185,8 @@ async function clone(url, directory, options) {
|
|
|
282
185
|
console.error(chalk.red('Repository not found'));
|
|
283
186
|
} else if (error.response?.status === 401) {
|
|
284
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'));
|
|
285
190
|
} else if (error.response?.data) {
|
|
286
191
|
console.error(chalk.red(JSON.stringify(error.response.data)));
|
|
287
192
|
} else {
|