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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password Command - Change or reset your account password.
|
|
3
|
+
*
|
|
4
|
+
* USAGE:
|
|
5
|
+
* gent password change → change password (prompts current + new)
|
|
6
|
+
* gent password reset [email] → email yourself a reset link
|
|
7
|
+
* gent password reset-confirm → finish a reset with uid + token from the email
|
|
8
|
+
*
|
|
9
|
+
* BACKEND:
|
|
10
|
+
* POST /api/auth/password/change/ { current_password, new_password, new_password_confirm }
|
|
11
|
+
* POST /api/auth/password/reset/ { email }
|
|
12
|
+
* POST /api/auth/password/reset/confirm/ { uid, token, new_password, new_password_confirm }
|
|
13
|
+
*
|
|
14
|
+
* Note: changing/resetting the password blacklists all refresh tokens, so
|
|
15
|
+
* `change` re-logs you in with the new password to keep the session alive.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const chalk = require('chalk');
|
|
19
|
+
const inquirer = require('inquirer');
|
|
20
|
+
const ora = require('ora');
|
|
21
|
+
const { API_ENDPOINTS } = require('../utils/constants');
|
|
22
|
+
const apiClient = require('../utils/api-client');
|
|
23
|
+
const authStorage = require('../utils/auth-storage');
|
|
24
|
+
const authService = require('../services/auth-service');
|
|
25
|
+
|
|
26
|
+
async function password(action, options = {}) {
|
|
27
|
+
try {
|
|
28
|
+
const act = action || 'change';
|
|
29
|
+
if (act === 'change') {
|
|
30
|
+
await changePassword();
|
|
31
|
+
} else if (act === 'reset') {
|
|
32
|
+
await requestReset(options);
|
|
33
|
+
} else if (act === 'reset-confirm') {
|
|
34
|
+
await confirmReset();
|
|
35
|
+
} else {
|
|
36
|
+
console.error(chalk.red(`Unknown action '${action}'`));
|
|
37
|
+
console.log(chalk.yellow('Usage: gent password [change | reset [email] | reset-confirm]'));
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
handleError(error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function changePassword() {
|
|
45
|
+
if (!(await authStorage.isAuthenticated())) {
|
|
46
|
+
console.error(chalk.red('Not authenticated'));
|
|
47
|
+
console.log(chalk.yellow('Run "gent login" first'));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const user = await authStorage.getUser();
|
|
51
|
+
|
|
52
|
+
const answers = await inquirer.prompt([
|
|
53
|
+
{ type: 'password', name: 'current', message: 'Current password:', mask: '*' },
|
|
54
|
+
{
|
|
55
|
+
type: 'password', name: 'next', message: 'New password:', mask: '*',
|
|
56
|
+
validate: (v) => v.length >= 8 || 'Password must be at least 8 characters long'
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: 'password', name: 'confirm', message: 'Confirm new password:', mask: '*',
|
|
60
|
+
validate: (v, a) => v === a.next || 'Passwords do not match'
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const spinner = ora('Changing password...').start();
|
|
65
|
+
await apiClient.post(API_ENDPOINTS.PASSWORD_CHANGE, {
|
|
66
|
+
current_password: answers.current,
|
|
67
|
+
new_password: answers.next,
|
|
68
|
+
new_password_confirm: answers.confirm,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// The backend blacklisted our refresh token; re-login to refresh the session.
|
|
72
|
+
try {
|
|
73
|
+
if (user?.email) await authService.login(user.email, answers.next);
|
|
74
|
+
spinner.succeed(chalk.green('Password changed'));
|
|
75
|
+
} catch {
|
|
76
|
+
await authStorage.clearAuth();
|
|
77
|
+
spinner.succeed(chalk.green('Password changed'));
|
|
78
|
+
console.log(chalk.yellow('Please run "gent login" again with your new password.'));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function requestReset(options) {
|
|
83
|
+
let email = options.email;
|
|
84
|
+
if (!email) {
|
|
85
|
+
({ email } = await inquirer.prompt([{ type: 'input', name: 'email', message: 'Account email:' }]));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const spinner = ora('Requesting password reset...').start();
|
|
89
|
+
const res = await apiClient.post(API_ENDPOINTS.PASSWORD_RESET, { email });
|
|
90
|
+
spinner.succeed(chalk.green(res.message || 'If that account exists, a reset link has been sent.'));
|
|
91
|
+
console.log(chalk.gray('Open the link in your email, then run "gent password reset-confirm".'));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function confirmReset() {
|
|
95
|
+
const a = await inquirer.prompt([
|
|
96
|
+
{ type: 'input', name: 'uid', message: 'uid (from reset link):' },
|
|
97
|
+
{ type: 'input', name: 'token', message: 'token (from reset link):' },
|
|
98
|
+
{
|
|
99
|
+
type: 'password', name: 'next', message: 'New password:', mask: '*',
|
|
100
|
+
validate: (v) => v.length >= 8 || 'Password must be at least 8 characters long'
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: 'password', name: 'confirm', message: 'Confirm new password:', mask: '*',
|
|
104
|
+
validate: (v, ans) => v === ans.next || 'Passwords do not match'
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
const spinner = ora('Resetting password...').start();
|
|
109
|
+
const res = await apiClient.post(API_ENDPOINTS.PASSWORD_RESET_CONFIRM, {
|
|
110
|
+
uid: a.uid,
|
|
111
|
+
token: a.token,
|
|
112
|
+
new_password: a.next,
|
|
113
|
+
new_password_confirm: a.confirm,
|
|
114
|
+
});
|
|
115
|
+
spinner.succeed(chalk.green(res.message || 'Password reset successfully'));
|
|
116
|
+
console.log(chalk.gray('Run "gent login" with your new password.'));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function handleError(error) {
|
|
120
|
+
const data = error.response?.data;
|
|
121
|
+
if (error.response?.status === 401 && data?.current_password) {
|
|
122
|
+
console.error(chalk.red('Current password is incorrect'));
|
|
123
|
+
} else if (data) {
|
|
124
|
+
// DRF returns { field: [messages] } or { error/detail: message }.
|
|
125
|
+
const msg = data.error || data.detail
|
|
126
|
+
|| (typeof data === 'object' ? Object.values(data).flat().join(', ') : data);
|
|
127
|
+
console.error(chalk.red(msg || 'Request failed'));
|
|
128
|
+
} else {
|
|
129
|
+
console.error(chalk.red('Error:'), error.message);
|
|
130
|
+
}
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = password;
|
package/src/commands/pull.js
CHANGED
|
@@ -11,13 +11,10 @@
|
|
|
11
11
|
* gent pull → Pull from origin/current-branch
|
|
12
12
|
* gent pull <remote> <branch> → Pull specific remote/branch
|
|
13
13
|
*
|
|
14
|
-
* ALGORITHM
|
|
15
|
-
* 1. GET .../
|
|
16
|
-
* 2.
|
|
17
|
-
* 3.
|
|
18
|
-
* 4. For each new commit, fetch tree + blobs via individual endpoints
|
|
19
|
-
* 5. Store objects locally
|
|
20
|
-
* 6. If diverged: run 3-way merge. If fast-forward: advance pointer
|
|
14
|
+
* ALGORITHM:
|
|
15
|
+
* 1. GET .../pull/?branch=&since= → { commits, objects (base64), head } in one call
|
|
16
|
+
* 2. Store objects locally, add new commits to the local store
|
|
17
|
+
* 3. If diverged: run 3-way merge. If fast-forward: advance the pointer
|
|
21
18
|
*
|
|
22
19
|
* ============================================================================
|
|
23
20
|
*/
|
|
@@ -30,7 +27,7 @@ const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
|
|
|
30
27
|
const { COMMITS_FILE, CONFIG_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
|
|
31
28
|
const apiClient = require('../utils/api-client');
|
|
32
29
|
const authStorage = require('../utils/auth-storage');
|
|
33
|
-
const { storeBlob,
|
|
30
|
+
const { storeBlob, readBlob } = require('../utils/hash-engine');
|
|
34
31
|
const { findMergeBase, mergeTreeEntries } = require('../utils/merge-engine');
|
|
35
32
|
const { generateCommitHash } = require('../utils/helpers');
|
|
36
33
|
|
|
@@ -73,104 +70,48 @@ async function pull(remoteName, branchName, options) {
|
|
|
73
70
|
const branch = branchName || repository.currentBranch;
|
|
74
71
|
const localHead = repository.branches[branch] || null;
|
|
75
72
|
|
|
76
|
-
// 1.
|
|
77
|
-
|
|
78
|
-
|
|
73
|
+
// 1. Fetch commits + objects for this branch in a single call. `since`
|
|
74
|
+
// lets the server send only what we don't have on a fast-forward.
|
|
75
|
+
spinner.text = `Fetching updates for ${branch}...`;
|
|
76
|
+
let pullData;
|
|
79
77
|
try {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
const pullUrl = buildRepoUrl(API_ENDPOINTS.REPO_PULL, repoInfo);
|
|
79
|
+
const query = localHead
|
|
80
|
+
? `?branch=${encodeURIComponent(branch)}&since=${encodeURIComponent(localHead)}`
|
|
81
|
+
: `?branch=${encodeURIComponent(branch)}`;
|
|
82
|
+
pullData = await apiClient.get(pullUrl + query);
|
|
84
83
|
} catch (error) {
|
|
85
84
|
if (error.response?.status === 404) {
|
|
86
85
|
spinner.succeed(chalk.green('Remote branch not found — nothing to pull'));
|
|
87
86
|
return;
|
|
88
87
|
}
|
|
88
|
+
if (error.response?.status === 403) {
|
|
89
|
+
spinner.fail(chalk.red('Access denied — you are not a member of this private repository'));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
89
92
|
throw error;
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// 2. Fetch all remote commits
|
|
98
|
-
spinner.text = `Fetching commits...`;
|
|
99
|
-
const remoteCommits = await apiClient.get(
|
|
100
|
-
buildRepoUrl(API_ENDPOINTS.REPO_COMMITS, repoInfo)
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
// 3. Find commits we don't have locally
|
|
104
|
-
const localCommitSet = new Set((repository.commits || []).map(c => c.hash || c.sha));
|
|
105
|
-
const newRemoteCommits = remoteCommits.filter(c => !localCommitSet.has(c.sha));
|
|
95
|
+
const remoteHead = pullData.head;
|
|
96
|
+
config.remoteRefs = config.remoteRefs || {};
|
|
106
97
|
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
config.remoteRefs = config.remoteRefs || {};
|
|
110
|
-
config.remoteRefs[`${remote}/${branch}`] = remoteHead;
|
|
98
|
+
if (!remoteHead || remoteHead === localHead) {
|
|
99
|
+
if (remoteHead) config.remoteRefs[`${remote}/${branch}`] = remoteHead;
|
|
111
100
|
await writeJSON(path.join(gentPath, CONFIG_FILE), config);
|
|
112
101
|
spinner.succeed(chalk.green('Already up-to-date'));
|
|
113
102
|
return;
|
|
114
103
|
}
|
|
115
104
|
|
|
116
|
-
//
|
|
117
|
-
spinner.text =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
// Fetch tree for this commit
|
|
122
|
-
let treeEntries = [];
|
|
123
|
-
if (commit.tree_sha) {
|
|
124
|
-
try {
|
|
125
|
-
const tree = await apiClient.get(
|
|
126
|
-
buildRepoUrl(API_ENDPOINTS.REPO_TREE_DETAIL, { ...repoInfo, sha: commit.tree_sha })
|
|
127
|
-
);
|
|
128
|
-
treeEntries = tree.entries || [];
|
|
129
|
-
} catch {
|
|
130
|
-
// Tree may not be available
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Fetch and store blobs
|
|
135
|
-
for (const entry of treeEntries) {
|
|
136
|
-
if (entry.type === 'blob' && entry.sha) {
|
|
137
|
-
if (!(await objectExists(gentPath, entry.sha))) {
|
|
138
|
-
try {
|
|
139
|
-
const blob = await apiClient.get(
|
|
140
|
-
buildRepoUrl(API_ENDPOINTS.REPO_BLOB_DETAIL, { ...repoInfo, sha: entry.sha })
|
|
141
|
-
);
|
|
142
|
-
if (blob.content) {
|
|
143
|
-
const buf = decodeRemoteBlobContent(blob.content, entry.sha);
|
|
144
|
-
await storeBlob(gentPath, buf);
|
|
145
|
-
}
|
|
146
|
-
} catch {
|
|
147
|
-
// Blob fetch failed, continue
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Convert remote commit format to local format
|
|
154
|
-
fetchedCommits.push({
|
|
155
|
-
hash: commit.sha,
|
|
156
|
-
message: commit.message,
|
|
157
|
-
author: { name: commit.author_name, email: commit.author_email },
|
|
158
|
-
timestamp: commit.committed_at,
|
|
159
|
-
parent: commit.parent_shas && commit.parent_shas[0] || null,
|
|
160
|
-
mergeParent: commit.parent_shas && commit.parent_shas[1] || null,
|
|
161
|
-
treeHash: commit.tree_sha,
|
|
162
|
-
tree: treeEntries.map(e => ({
|
|
163
|
-
mode: e.mode || '100644',
|
|
164
|
-
name: e.name,
|
|
165
|
-
hash: e.sha,
|
|
166
|
-
type: e.type || 'blob'
|
|
167
|
-
})),
|
|
168
|
-
files: treeEntries.map(e => ({ path: e.name, hash: e.sha })),
|
|
169
|
-
stats: {}
|
|
170
|
-
});
|
|
105
|
+
// 2. Store blob objects (base64) into the local object store.
|
|
106
|
+
spinner.text = 'Storing objects...';
|
|
107
|
+
for (const obj of pullData.objects || []) {
|
|
108
|
+
if (obj.type !== 'blob' || typeof obj.data !== 'string') continue;
|
|
109
|
+
await storeBlob(gentPath, Buffer.from(obj.data, 'base64'));
|
|
171
110
|
}
|
|
172
111
|
|
|
173
|
-
//
|
|
112
|
+
// 3. Add new remote commits to the local store (dedup by hash). The
|
|
113
|
+
// server returns them in the CLI's native commit shape already.
|
|
114
|
+
const fetchedCommits = pullData.commits || [];
|
|
174
115
|
let newCount = 0;
|
|
175
116
|
const commitSet = new Set((repository.commits || []).map(c => c.hash));
|
|
176
117
|
for (const commit of fetchedCommits) {
|
|
@@ -182,9 +123,7 @@ async function pull(remoteName, branchName, options) {
|
|
|
182
123
|
}
|
|
183
124
|
}
|
|
184
125
|
|
|
185
|
-
//
|
|
186
|
-
config.remoteRefs = config.remoteRefs || {};
|
|
187
|
-
|
|
126
|
+
// 4. Merge strategy
|
|
188
127
|
if (!localHead || isAncestor(repository.commits, localHead, remoteHead)) {
|
|
189
128
|
// Fast-forward
|
|
190
129
|
const previousTree = localHead ? getCommitTree(repository.commits, localHead) : [];
|
|
@@ -224,7 +163,7 @@ async function pull(remoteName, branchName, options) {
|
|
|
224
163
|
const mergeCommit = {
|
|
225
164
|
hash: generateCommitHash(),
|
|
226
165
|
message: `Merge remote-tracking branch '${remote}/${branch}'`,
|
|
227
|
-
author: (repository.commits.find(c => c.hash === localHead) || {}).author || { name: 'Unknown', email: '' },
|
|
166
|
+
author: (repository.commits.find(c => c.hash === localHead) || {}).author || { name: 'Unknown', email: (await authStorage.getUser())?.email || '' },
|
|
228
167
|
timestamp: new Date().toISOString(),
|
|
229
168
|
parent: localHead,
|
|
230
169
|
mergeParent: remoteHead,
|
|
@@ -259,6 +198,8 @@ async function pull(remoteName, branchName, options) {
|
|
|
259
198
|
spinner.fail(chalk.red('Pull failed'));
|
|
260
199
|
if (error.response?.status === 401) {
|
|
261
200
|
console.error(chalk.red('Authentication failed — run "gent login"'));
|
|
201
|
+
} else if (error.response?.status === 403) {
|
|
202
|
+
console.error(chalk.red('Access denied — you are not a member of this private repository'));
|
|
262
203
|
} else if (error.response?.data) {
|
|
263
204
|
console.error(chalk.red(JSON.stringify(error.response.data)));
|
|
264
205
|
} else {
|
|
@@ -311,10 +252,11 @@ async function checkoutTree(gentPath, cwd, previousTree, nextTree) {
|
|
|
311
252
|
const relPath = entry.name || entry.path;
|
|
312
253
|
if (!relPath || !entry.hash) continue;
|
|
313
254
|
|
|
314
|
-
|
|
255
|
+
// Write the raw Buffer so binary blobs round-trip byte-exact.
|
|
256
|
+
const buf = await readBlob(gentPath, entry.hash);
|
|
315
257
|
const fullPath = path.join(cwd, relPath);
|
|
316
258
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
317
|
-
await fs.writeFile(fullPath,
|
|
259
|
+
await fs.writeFile(fullPath, buf);
|
|
318
260
|
}
|
|
319
261
|
}
|
|
320
262
|
|
package/src/commands/push.js
CHANGED
|
@@ -178,17 +178,39 @@ async function push(remoteName, branchName, options) {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
// Build commits for the pack
|
|
181
|
+
// Build commits for the pack. author_email must satisfy the backend's
|
|
182
|
+
// EmailField(required=True); fall back to the logged-in user's email so
|
|
183
|
+
// merge/legacy commits with a blank email don't 400 the whole push.
|
|
184
|
+
const fallbackEmail = (await authStorage.getUser())?.email || '';
|
|
182
185
|
const packCommits = commitsToPush.map(c => ({
|
|
183
186
|
sha: c.hash,
|
|
184
187
|
message: c.message,
|
|
185
188
|
tree_sha: c.treeHash || '',
|
|
186
189
|
parent_shas: [c.parent, c.mergeParent].filter(Boolean),
|
|
187
|
-
author_name: typeof c.author === 'object' ?
|
|
188
|
-
author_email: typeof c.author === 'object' ?
|
|
190
|
+
author_name: (typeof c.author === 'object' ? c.author.name : c.author) || 'Unknown',
|
|
191
|
+
author_email: (typeof c.author === 'object' ? c.author.email : '') || fallbackEmail,
|
|
189
192
|
committed_at: c.timestamp || new Date().toISOString()
|
|
190
193
|
}));
|
|
191
194
|
|
|
195
|
+
// Only send tags whose target commit will exist on the remote after this
|
|
196
|
+
// push (in this pack, or reachable from an already-pushed remote ref).
|
|
197
|
+
// The backend validates every tag's commit and atomically 400s the whole
|
|
198
|
+
// push for any tag pointing at a commit it doesn't have.
|
|
199
|
+
const pushableShas = new Set(commitsToPush.map(c => c.hash));
|
|
200
|
+
const commitByHash = new Map(commits.map(c => [c.hash, c]));
|
|
201
|
+
for (const [refName, refHead] of Object.entries(config.remoteRefs || {})) {
|
|
202
|
+
if (!refName.startsWith(`${remote}/`)) continue;
|
|
203
|
+
let cur = refHead;
|
|
204
|
+
while (cur && !pushableShas.has(cur)) {
|
|
205
|
+
pushableShas.add(cur);
|
|
206
|
+
cur = commitByHash.get(cur)?.parent || null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const tagsToPush = {};
|
|
210
|
+
for (const [name, tag] of Object.entries(repository.tags || {})) {
|
|
211
|
+
if (tag && tag.hash && pushableShas.has(tag.hash)) tagsToPush[name] = tag;
|
|
212
|
+
}
|
|
213
|
+
|
|
192
214
|
// Build push payload matching PushPackRequest schema
|
|
193
215
|
const payload = {
|
|
194
216
|
pack: {
|
|
@@ -200,7 +222,7 @@ async function push(remoteName, branchName, options) {
|
|
|
200
222
|
name: branch,
|
|
201
223
|
commit_sha: localHead
|
|
202
224
|
}],
|
|
203
|
-
tags:
|
|
225
|
+
tags: tagsToPush
|
|
204
226
|
};
|
|
205
227
|
|
|
206
228
|
// Send to backend
|
|
@@ -224,7 +246,7 @@ async function push(remoteName, branchName, options) {
|
|
|
224
246
|
} else if (error.response?.status === 401) {
|
|
225
247
|
console.error(chalk.red('Authentication failed — run "gent login"'));
|
|
226
248
|
} else if (error.response?.status === 403) {
|
|
227
|
-
console.error(chalk.red('Permission denied —
|
|
249
|
+
console.error(chalk.red(error.response.data?.error || error.response.data?.detail || 'Permission denied — you need write access to this repository'));
|
|
228
250
|
} else if (error.response?.data) {
|
|
229
251
|
console.error(chalk.red(JSON.stringify(error.response.data, null, 2)));
|
|
230
252
|
} else {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review Command - AI code review on staged or HEAD changes.
|
|
3
|
+
*
|
|
4
|
+
* gent review → review staged changes (or HEAD if no staging)
|
|
5
|
+
* gent review --staged → force staged
|
|
6
|
+
* gent review --head → force HEAD commit diff
|
|
7
|
+
* gent review <ref> → review diff for that commit
|
|
8
|
+
*
|
|
9
|
+
* Output: prioritized bug/risk list followed by smaller polish suggestions.
|
|
10
|
+
* Without an AI key, prints the raw diff so the command still has value.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const chalk = require('chalk');
|
|
15
|
+
const ora = require('ora');
|
|
16
|
+
const { getGentPath, readJSON } = require('../utils/fileSystem');
|
|
17
|
+
const { COMMITS_FILE, STAGING_FILE } = require('../utils/constants');
|
|
18
|
+
const { readBlobAsString, treeToMap } = require('../utils/hash-engine');
|
|
19
|
+
const { formatUnifiedDiff } = require('../utils/diff-engine');
|
|
20
|
+
const ai = require('../utils/ai-service');
|
|
21
|
+
|
|
22
|
+
const MAX_DIFF_CHARS = 16000;
|
|
23
|
+
|
|
24
|
+
async function review(refArg, options = {}) {
|
|
25
|
+
try {
|
|
26
|
+
const gentPath = await getGentPath();
|
|
27
|
+
const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
28
|
+
const commits = repository.commits || [];
|
|
29
|
+
const commitMap = new Map(commits.map(c => [c.hash, c]));
|
|
30
|
+
|
|
31
|
+
let title;
|
|
32
|
+
let diffText;
|
|
33
|
+
|
|
34
|
+
const explicitStaged = options.staged === true;
|
|
35
|
+
const explicitHead = options.head === true;
|
|
36
|
+
let useStaged = explicitStaged;
|
|
37
|
+
|
|
38
|
+
if (!explicitStaged && !explicitHead && !refArg) {
|
|
39
|
+
// Default: staged if anything is staged, else HEAD
|
|
40
|
+
const staging = await readJSON(path.join(gentPath, STAGING_FILE)).catch(() => ({}));
|
|
41
|
+
const entries = staging.entries || [];
|
|
42
|
+
useStaged = entries.length > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (useStaged) {
|
|
46
|
+
const result = await stagedDiff(gentPath, repository, commitMap);
|
|
47
|
+
if (!result) {
|
|
48
|
+
console.log(chalk.yellow('Nothing staged to review.'));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
title = 'Staged changes';
|
|
52
|
+
diffText = result;
|
|
53
|
+
} else {
|
|
54
|
+
const ref = refArg || repository.branches[repository.currentBranch];
|
|
55
|
+
const commit = ref ? (commitMap.get(ref) || commits.find(c => c.hash.startsWith(ref))) : null;
|
|
56
|
+
if (!commit) {
|
|
57
|
+
console.log(chalk.yellow(ref ? `Commit '${ref}' not found` : 'No commits yet'));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const parent = commit.parent ? commitMap.get(commit.parent) : null;
|
|
61
|
+
title = `Commit ${commit.hash.slice(0, 7)} — ${commit.message.split('\n')[0]}`;
|
|
62
|
+
diffText = await diffTrees(gentPath, treeEntriesOf(parent), treeEntriesOf(commit));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!diffText) {
|
|
66
|
+
console.log(chalk.gray('No textual changes to review.'));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const trimmed = diffText.length > MAX_DIFF_CHARS
|
|
71
|
+
? diffText.slice(0, MAX_DIFF_CHARS) + '\n... (diff truncated)'
|
|
72
|
+
: diffText;
|
|
73
|
+
|
|
74
|
+
console.log(chalk.bold.cyan(`\n${title}\n`));
|
|
75
|
+
|
|
76
|
+
if (!ai.isEnabled()) {
|
|
77
|
+
console.log(trimmed);
|
|
78
|
+
console.log(chalk.gray(`\n${ai.disabledHint()}`));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const spinner = ora(`Reviewing with ${ai.getModel()}...`).start();
|
|
83
|
+
try {
|
|
84
|
+
const out = await ai.complete({
|
|
85
|
+
system:
|
|
86
|
+
'You are a senior code reviewer. Given a unified diff, list concrete ' +
|
|
87
|
+
'issues you would block on, then smaller suggestions. Format:\n' +
|
|
88
|
+
'🔴 Bugs / risks\n - file:line — short description\n' +
|
|
89
|
+
'🟡 Suggestions\n - file — short description\n' +
|
|
90
|
+
'🟢 Looks good\n - one-line positive note\n' +
|
|
91
|
+
'Be specific. If nothing is wrong, say so plainly.',
|
|
92
|
+
prompt: `Review this diff:\n\n${trimmed}`,
|
|
93
|
+
maxTokens: 1500,
|
|
94
|
+
thinking: true,
|
|
95
|
+
});
|
|
96
|
+
spinner.stop();
|
|
97
|
+
console.log(out + '\n');
|
|
98
|
+
} catch (err) {
|
|
99
|
+
spinner.fail(chalk.yellow('AI review failed — showing the raw diff instead'));
|
|
100
|
+
console.log(chalk.gray(`(${err.message})\n`));
|
|
101
|
+
console.log(trimmed);
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error.code === 'ENOENT' && error.message.includes('.gent')) {
|
|
105
|
+
console.error(chalk.red('Error: Not a gent repository'));
|
|
106
|
+
} else {
|
|
107
|
+
console.error(chalk.red('Error:'), error.message);
|
|
108
|
+
}
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function treeEntriesOf(commit) {
|
|
114
|
+
if (!commit) return [];
|
|
115
|
+
if (Array.isArray(commit.tree)) return commit.tree;
|
|
116
|
+
return (commit.files || []).map(f => ({ name: f.path || f.name, hash: f.hash }));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function diffTrees(gentPath, oldEntries, newEntries) {
|
|
120
|
+
const oldMap = treeToMap(oldEntries);
|
|
121
|
+
const newMap = treeToMap(newEntries);
|
|
122
|
+
const files = new Set([...oldMap.keys(), ...newMap.keys()]);
|
|
123
|
+
const parts = [];
|
|
124
|
+
for (const file of files) {
|
|
125
|
+
const oh = oldMap.get(file);
|
|
126
|
+
const nh = newMap.get(file);
|
|
127
|
+
if (oh === nh) continue;
|
|
128
|
+
let oldText = '', newText = '';
|
|
129
|
+
try { if (oh) oldText = await readBlobAsString(gentPath, oh); } catch { /* binary */ }
|
|
130
|
+
try { if (nh) newText = await readBlobAsString(gentPath, nh); } catch { /* binary */ }
|
|
131
|
+
const d = formatUnifiedDiff(file, oldText, newText);
|
|
132
|
+
if (d) parts.push(d);
|
|
133
|
+
}
|
|
134
|
+
return parts.join('\n\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function stagedDiff(gentPath, repository, commitMap) {
|
|
138
|
+
const staging = await readJSON(path.join(gentPath, STAGING_FILE)).catch(() => ({}));
|
|
139
|
+
const entries = staging.entries || [];
|
|
140
|
+
if (entries.length === 0) return null;
|
|
141
|
+
|
|
142
|
+
const headHash = repository.branches[repository.currentBranch];
|
|
143
|
+
const head = headHash ? commitMap.get(headHash) : null;
|
|
144
|
+
const headTree = treeEntriesOf(head);
|
|
145
|
+
const overlay = new Map(headTree.map(e => [e.name, e.hash]));
|
|
146
|
+
for (const e of entries) {
|
|
147
|
+
if (e.status === 'deleted') overlay.delete(e.path);
|
|
148
|
+
else overlay.set(e.path, e.hash);
|
|
149
|
+
}
|
|
150
|
+
return diffTrees(
|
|
151
|
+
gentPath,
|
|
152
|
+
headTree,
|
|
153
|
+
[...overlay].map(([name, hash]) => ({ name, hash }))
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = review;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Command - Fuzzy-search your repositories on the gent backend.
|
|
3
|
+
*
|
|
4
|
+
* gent search <query>
|
|
5
|
+
* gent search --mine → only repos you own
|
|
6
|
+
* gent search --json → machine-readable output
|
|
7
|
+
*
|
|
8
|
+
* The current backend's /api/repos/ endpoint returns the user's repos; we
|
|
9
|
+
* filter client-side. If the backend grows a search endpoint, switch the URL.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const chalk = require('chalk');
|
|
13
|
+
const ora = require('ora');
|
|
14
|
+
const { API_ENDPOINTS } = require('../utils/constants');
|
|
15
|
+
const apiClient = require('../utils/api-client');
|
|
16
|
+
const authStorage = require('../utils/auth-storage');
|
|
17
|
+
|
|
18
|
+
async function search(query, options = {}) {
|
|
19
|
+
try {
|
|
20
|
+
if (!query && !options.mine) {
|
|
21
|
+
console.error(chalk.red('Usage: gent search <query>'));
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isAuth = await authStorage.isAuthenticated();
|
|
26
|
+
if (!isAuth) {
|
|
27
|
+
console.error(chalk.red('Not authenticated.'));
|
|
28
|
+
console.log(chalk.yellow('Run `gent login` first.'));
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const spinner = ora('Searching...').start();
|
|
33
|
+
const data = await apiClient.get(API_ENDPOINTS.REPOS);
|
|
34
|
+
const repos = Array.isArray(data) ? data : (data.results || []);
|
|
35
|
+
spinner.stop();
|
|
36
|
+
|
|
37
|
+
const me = await authStorage.getUser();
|
|
38
|
+
const myId = me?.id;
|
|
39
|
+
|
|
40
|
+
const q = (query || '').toLowerCase();
|
|
41
|
+
const filtered = repos.filter(r => {
|
|
42
|
+
if (options.mine && myId && r.owner_id !== myId) return false;
|
|
43
|
+
if (!q) return true;
|
|
44
|
+
const haystack = [r.name, r.description, r.owner_email]
|
|
45
|
+
.filter(Boolean).join(' ').toLowerCase();
|
|
46
|
+
return haystack.includes(q);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (options.json) {
|
|
50
|
+
console.log(JSON.stringify(filtered, null, 2));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (filtered.length === 0) {
|
|
55
|
+
console.log(chalk.gray('No matches.'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(chalk.bold.cyan(`\nFound ${filtered.length} repo(s):\n`));
|
|
60
|
+
for (const r of filtered) {
|
|
61
|
+
const visibility = r.is_private ? chalk.red('private') : chalk.green('public');
|
|
62
|
+
const desc = r.description ? chalk.gray(` — ${r.description}`) : '';
|
|
63
|
+
console.log(` ${chalk.white.bold(r.name)} [${visibility}]${desc}`);
|
|
64
|
+
console.log(` ${chalk.gray(`/api/repos/${r.owner_id}/${r.name}`)}`);
|
|
65
|
+
}
|
|
66
|
+
console.log();
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.response?.status === 401) {
|
|
69
|
+
console.error(chalk.red('Authentication failed — run `gent login`.'));
|
|
70
|
+
} else {
|
|
71
|
+
console.error(chalk.red('Error:'), error.message);
|
|
72
|
+
}
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = search;
|