gent-cli 6.0.1 → 8.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,280 @@
1
+ /**
2
+ * ============================================================================
3
+ * Resolve Command - Interactive merge-conflict resolver
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Walk each conflict hunk left by `gent merge` and let the user choose how to
8
+ * resolve it — ours / theirs / both / edit / (optionally) ask AI — instead of
9
+ * hand-editing conflict markers. When every conflict is resolved it offers to
10
+ * finalize the merge commit.
11
+ *
12
+ * USAGE:
13
+ * gent resolve → interactively resolve the in-progress merge
14
+ *
15
+ * STATE:
16
+ * Reads staging.mergeState (written by merge.js on conflict): sourceBranch,
17
+ * oursHash, theirsHash, baseHash, mergedEntries, conflicts.
18
+ *
19
+ * ============================================================================
20
+ */
21
+
22
+ const fs = require('fs').promises;
23
+ const path = require('path');
24
+ const chalk = require('chalk');
25
+ const inquirer = require('inquirer');
26
+ const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
27
+ const { COMMITS_FILE, STAGING_FILE, CONFIG_FILE } = require('../utils/constants');
28
+ const { storeBlob, storeTree, readBlobAsString } = require('../utils/hash-engine');
29
+ const { parseConflictMarkers, hasConflictMarkers } = require('../utils/merge-engine');
30
+ const { generateCommitHash } = require('../utils/helpers');
31
+ const authStorage = require('../utils/auth-storage');
32
+ const journal = require('../utils/journal');
33
+ const ai = require('../utils/ai-service');
34
+
35
+ async function resolve() {
36
+ try {
37
+ const gentPath = await getGentPath();
38
+ const cwd = process.cwd();
39
+
40
+ const staging = await readJSON(path.join(gentPath, STAGING_FILE));
41
+ const mergeState = staging.mergeState;
42
+
43
+ if (!mergeState) {
44
+ console.log(chalk.yellow('No merge in progress'));
45
+ console.log(chalk.gray('Run "gent merge <branch>" first; if it conflicts, resolve it here.'));
46
+ return;
47
+ }
48
+
49
+ // Files that carry conflict markers on disk.
50
+ const markerFiles = (mergeState.conflicts || [])
51
+ .filter(c => c.type === 'content' || c.type === 'add-add')
52
+ .map(c => c.file);
53
+
54
+ if (markerFiles.length === 0) {
55
+ console.log(chalk.green('No conflict markers to resolve.'));
56
+ console.log(chalk.cyan('Run "gent commit" to finalize the merge.'));
57
+ return;
58
+ }
59
+
60
+ console.log(chalk.bold.cyan(`\nResolving merge of '${mergeState.sourceBranch}' — ${markerFiles.length} file(s)\n`));
61
+
62
+ // Working copy of merged tree entries (we patch hashes as files resolve).
63
+ const entriesByName = new Map((mergeState.mergedEntries || []).map(e => [e.name, { ...e }]));
64
+ let unresolvedFiles = 0;
65
+
66
+ for (const file of markerFiles) {
67
+ const full = path.join(cwd, file);
68
+ let content;
69
+ try {
70
+ content = await fs.readFile(full, 'utf-8');
71
+ } catch {
72
+ console.log(chalk.gray(` (skipping ${file} — not on disk)`));
73
+ continue;
74
+ }
75
+
76
+ if (!hasConflictMarkers(content)) {
77
+ console.log(chalk.green(` ✓ ${file} already resolved`));
78
+ await stageResolved(gentPath, staging, entriesByName, file, content);
79
+ continue;
80
+ }
81
+
82
+ console.log(chalk.bold(`\n${file}`));
83
+ const segments = parseConflictMarkers(content);
84
+ const conflictCount = segments.filter(s => s.type === 'conflict').length;
85
+ let idx = 0;
86
+ let aborted = false;
87
+ const out = [];
88
+
89
+ for (const seg of segments) {
90
+ if (seg.type === 'text') {
91
+ out.push(...seg.lines);
92
+ continue;
93
+ }
94
+ idx++;
95
+ const resolvedLines = await resolveHunk(seg, file, idx, conflictCount);
96
+ if (resolvedLines === null) { aborted = true; break; }
97
+ out.push(...resolvedLines);
98
+ }
99
+
100
+ if (aborted) {
101
+ console.log(chalk.yellow(` Left ${file} with remaining markers — re-run "gent resolve" later.`));
102
+ unresolvedFiles++;
103
+ continue;
104
+ }
105
+
106
+ const resolvedContent = out.join('\n');
107
+ await fs.writeFile(full, resolvedContent, 'utf-8');
108
+
109
+ if (hasConflictMarkers(resolvedContent)) {
110
+ unresolvedFiles++;
111
+ console.log(chalk.yellow(` ${file} still has markers`));
112
+ } else {
113
+ await stageResolved(gentPath, staging, entriesByName, file, resolvedContent);
114
+ console.log(chalk.green(` ✓ resolved ${file}`));
115
+ }
116
+ }
117
+
118
+ await writeJSON(path.join(gentPath, STAGING_FILE), staging);
119
+
120
+ if (unresolvedFiles > 0) {
121
+ console.log(chalk.yellow(`\n${unresolvedFiles} file(s) still have conflicts. Re-run "gent resolve" when ready.`));
122
+ return;
123
+ }
124
+
125
+ // All conflicts resolved — offer to finalize the merge commit.
126
+ const { finalize } = await inquirer.prompt([{
127
+ type: 'confirm',
128
+ name: 'finalize',
129
+ message: 'All conflicts resolved. Create the merge commit now?',
130
+ default: true
131
+ }]);
132
+
133
+ if (!finalize) {
134
+ console.log(chalk.cyan('Resolved files staged. Run "gent commit" when ready.'));
135
+ return;
136
+ }
137
+
138
+ await finalizeMerge(gentPath, staging, mergeState, entriesByName);
139
+ } catch (error) {
140
+ if (error.code === 'ENOENT' && error.message.includes('.gent')) {
141
+ console.error(chalk.red('Error: Not a gent repository'));
142
+ console.log(chalk.yellow('\nRun "gent init" to initialize a repository'));
143
+ } else {
144
+ console.error(chalk.red('Error:'), error.message);
145
+ }
146
+ process.exit(1);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Prompt for one conflict hunk. Returns the chosen lines, or null to abort
152
+ * (leave the rest of the file as-is with markers).
153
+ */
154
+ async function resolveHunk(seg, file, idx, total) {
155
+ console.log(chalk.gray(` Conflict ${idx}/${total}:`));
156
+ console.log(chalk.green(' <<< ours'));
157
+ seg.ours.forEach(l => console.log(chalk.green(` ${l}`)));
158
+ console.log(chalk.red(' >>> theirs'));
159
+ seg.theirs.forEach(l => console.log(chalk.red(` ${l}`)));
160
+
161
+ const choices = [
162
+ { name: 'Keep ours', value: 'ours' },
163
+ { name: 'Keep theirs', value: 'theirs' },
164
+ { name: 'Keep both (ours then theirs)', value: 'both' },
165
+ { name: 'Edit manually', value: 'edit' }
166
+ ];
167
+ if (ai.isEnabled()) {
168
+ choices.splice(3, 0, { name: `Ask AI (${ai.getModel()})`, value: 'ai' });
169
+ }
170
+ choices.push({ name: 'Skip the rest of this file', value: 'skip' });
171
+
172
+ const { choice } = await inquirer.prompt([{
173
+ type: 'list',
174
+ name: 'choice',
175
+ message: `Resolve conflict ${idx}`,
176
+ choices
177
+ }]);
178
+
179
+ switch (choice) {
180
+ case 'ours': return seg.ours;
181
+ case 'theirs': return seg.theirs;
182
+ case 'both': return [...seg.ours, ...seg.theirs];
183
+ case 'skip': return null;
184
+ case 'edit': {
185
+ const { text } = await inquirer.prompt([{
186
+ type: 'editor',
187
+ name: 'text',
188
+ message: 'Edit the resolved section',
189
+ default: [...seg.ours, ...seg.theirs].join('\n')
190
+ }]);
191
+ return text.replace(/\n$/, '').split('\n');
192
+ }
193
+ case 'ai': {
194
+ try {
195
+ const suggestion = await ai.resolveConflictHunk({
196
+ ours: seg.ours.join('\n'),
197
+ theirs: seg.theirs.join('\n'),
198
+ fileName: file
199
+ });
200
+ console.log(chalk.cyan(' AI suggestion:'));
201
+ suggestion.split('\n').forEach(l => console.log(chalk.cyan(` ${l}`)));
202
+ const { accept } = await inquirer.prompt([{
203
+ type: 'confirm', name: 'accept', message: 'Use this suggestion?', default: true
204
+ }]);
205
+ if (accept) return suggestion.split('\n');
206
+ return resolveHunk(seg, file, idx, total); // re-ask
207
+ } catch (err) {
208
+ console.log(chalk.yellow(` AI failed (${err.message}); choose another option.`));
209
+ return resolveHunk(seg, file, idx, total);
210
+ }
211
+ }
212
+ default: return seg.ours;
213
+ }
214
+ }
215
+
216
+ /** Store the resolved file as a blob, patch the tree entry, and stage it. */
217
+ async function stageResolved(gentPath, staging, entriesByName, file, content) {
218
+ const hash = await storeBlob(gentPath, content);
219
+ const entry = entriesByName.get(file) || { mode: '100644', name: file, type: 'blob' };
220
+ entry.hash = hash;
221
+ entriesByName.set(file, entry);
222
+
223
+ staging.entries = staging.entries || [];
224
+ const existing = staging.entries.find(e => e.path === file);
225
+ if (existing) {
226
+ existing.hash = hash;
227
+ existing.status = 'modified';
228
+ } else {
229
+ staging.entries.push({ path: file, hash, status: 'modified', binary: false, stats: { insertions: 0, deletions: 0 } });
230
+ }
231
+ staging.files = staging.entries.map(e => e.path);
232
+ }
233
+
234
+ /** Create the merge commit from the resolved tree and clear merge state. */
235
+ async function finalizeMerge(gentPath, staging, mergeState, entriesByName) {
236
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
237
+ const config = await readJSON(path.join(gentPath, CONFIG_FILE));
238
+
239
+ let authorName = config.user && config.user.name;
240
+ let authorEmail = config.user && config.user.email;
241
+ if (!authorName || !authorEmail) {
242
+ const globalUser = await authStorage.getUser();
243
+ if (globalUser) {
244
+ if (!authorName) authorName = [globalUser.first_name, globalUser.last_name].filter(Boolean).join(' ');
245
+ if (!authorEmail) authorEmail = globalUser.email;
246
+ }
247
+ }
248
+
249
+ const mergedEntries = [...entriesByName.values()];
250
+ const treeHash = await storeTree(gentPath, mergedEntries);
251
+
252
+ const mergeCommit = {
253
+ hash: generateCommitHash(),
254
+ message: `Merge branch '${mergeState.sourceBranch}' into ${repository.currentBranch}`,
255
+ author: { name: authorName || 'Unknown', email: authorEmail || 'unknown@gent' },
256
+ timestamp: new Date().toISOString(),
257
+ parent: mergeState.oursHash,
258
+ mergeParent: mergeState.theirsHash,
259
+ treeHash,
260
+ tree: mergedEntries,
261
+ files: mergedEntries.map(e => ({ path: e.name, hash: e.hash })),
262
+ stats: { filesChanged: mergedEntries.length, insertions: 0, deletions: 0 }
263
+ };
264
+
265
+ await journal.recordOp(gentPath, 'merge', `resolve+merge '${mergeState.sourceBranch}' into ${repository.currentBranch}`);
266
+
267
+ repository.commits = repository.commits || [];
268
+ repository.commits.push(mergeCommit);
269
+ repository.branches[repository.currentBranch] = mergeCommit.hash;
270
+ await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
271
+
272
+ staging.entries = [];
273
+ staging.files = [];
274
+ staging.mergeState = null;
275
+ await writeJSON(path.join(gentPath, STAGING_FILE), staging);
276
+
277
+ console.log(chalk.green(`\n✓ Merge committed — ${mergeCommit.hash.substring(0, 7)}`));
278
+ }
279
+
280
+ module.exports = resolve;
@@ -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_name, 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;