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.
@@ -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;
@@ -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 (client-side, no /pull/ endpoint):
15
- * 1. GET .../branches/{branch}/get remote branch head SHA
16
- * 2. GET .../commits/ list all remote commits
17
- * 3. Diff local vs remote commits, find new ones
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, objectExists, readBlobAsString, decodeRemoteBlobContent } = require('../utils/hash-engine');
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. Get remote branch info to find remote HEAD
77
- spinner.text = `Fetching branch info for ${branch}...`;
78
- let remoteHead;
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 branchInfo = await apiClient.get(
81
- buildRepoUrl(API_ENDPOINTS.REPO_BRANCH_DETAIL, { ...repoInfo, branch_name: branch })
82
- );
83
- remoteHead = branchInfo.commit_sha;
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
- if (!remoteHead || remoteHead === localHead) {
93
- spinner.succeed(chalk.green('Already up-to-date'));
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 (newRemoteCommits.length === 0) {
108
- // We have all commits but pointer is different — update ref
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
- // 4. For each new commit, fetch tree and blobs
117
- spinner.text = `Fetching ${newRemoteCommits.length} new commit(s) + objects...`;
118
-
119
- const fetchedCommits = [];
120
- for (const commit of newRemoteCommits) {
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
- // 5. Add new commits to local store (dedup)
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
- // 6. Merge strategy
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
- const content = await readBlobAsString(gentPath, entry.hash);
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, content, 'utf-8');
259
+ await fs.writeFile(fullPath, buf);
318
260
  }
319
261
  }
320
262
 
@@ -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' ? (c.author.name || 'Unknown') : (c.author || 'Unknown'),
188
- author_email: typeof c.author === 'object' ? (c.author.email || '') : '',
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: repository.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 — only repo owner can push'));
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;