gent-cli 6.0.1 → 7.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,145 @@
1
+ /**
2
+ * ============================================================================
3
+ * Explain Command - Plain-language summary of a commit or pending changes
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Turn a diff into a human explanation. With an API key set this uses Claude;
8
+ * without one it still prints the unified diff plus a hint, so the command is
9
+ * useful either way.
10
+ *
11
+ * USAGE:
12
+ * gent explain → explain the latest commit (HEAD)
13
+ * gent explain <ref> → explain a specific commit (full or short hash)
14
+ * gent explain --staged → explain the currently staged changes
15
+ *
16
+ * ============================================================================
17
+ */
18
+
19
+ const path = require('path');
20
+ const chalk = require('chalk');
21
+ const ora = require('ora');
22
+ const { getGentPath, readJSON } = require('../utils/fileSystem');
23
+ const { COMMITS_FILE, STAGING_FILE } = require('../utils/constants');
24
+ const { readBlobAsString, treeToMap } = require('../utils/hash-engine');
25
+ const { formatUnifiedDiff } = require('../utils/diff-engine');
26
+ const ai = require('../utils/ai-service');
27
+
28
+ const MAX_DIFF_CHARS = 12000;
29
+
30
+ function treeEntriesOf(commit) {
31
+ if (!commit) return [];
32
+ if (Array.isArray(commit.tree)) return commit.tree;
33
+ return (commit.files || []).map(f => ({ name: f.path || f.name, hash: f.hash }));
34
+ }
35
+
36
+ /**
37
+ * Build a unified-diff text between two trees (old → new).
38
+ */
39
+ async function diffTrees(gentPath, oldEntries, newEntries) {
40
+ const oldMap = treeToMap(oldEntries);
41
+ const newMap = treeToMap(newEntries);
42
+ const files = new Set([...oldMap.keys(), ...newMap.keys()]);
43
+
44
+ const parts = [];
45
+ for (const file of files) {
46
+ const oh = oldMap.get(file);
47
+ const nh = newMap.get(file);
48
+ if (oh === nh) continue;
49
+ let oldText = '', newText = '';
50
+ try { if (oh) oldText = await readBlobAsString(gentPath, oh); } catch { /* binary/legacy */ }
51
+ try { if (nh) newText = await readBlobAsString(gentPath, nh); } catch { /* binary/legacy */ }
52
+ const d = formatUnifiedDiff(file, oldText, newText);
53
+ if (d) parts.push(d);
54
+ }
55
+ return parts.join('\n\n');
56
+ }
57
+
58
+ async function explain(ref, options = {}) {
59
+ try {
60
+ const gentPath = await getGentPath();
61
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
62
+ const commits = repository.commits || [];
63
+ const commitMap = new Map(commits.map(c => [c.hash, c]));
64
+
65
+ let title;
66
+ let diffText;
67
+
68
+ if (options.staged) {
69
+ const staging = await readJSON(path.join(gentPath, STAGING_FILE));
70
+ const entries = staging.entries || [];
71
+ if (entries.length === 0) {
72
+ console.log(chalk.yellow('No staged changes to explain'));
73
+ return;
74
+ }
75
+ const headHash = repository.branches[repository.currentBranch];
76
+ const headTree = treeEntriesOf(commitMap.get(headHash));
77
+ const stagedTree = entries
78
+ .filter(e => e.status !== 'deleted')
79
+ .map(e => ({ name: e.path, hash: e.hash }));
80
+ // Overlay staged entries on the HEAD tree
81
+ const overlay = new Map(headTree.map(e => [e.name, e.hash]));
82
+ for (const e of entries) {
83
+ if (e.status === 'deleted') overlay.delete(e.path);
84
+ else overlay.set(e.path, e.hash);
85
+ }
86
+ title = 'Staged changes';
87
+ diffText = await diffTrees(
88
+ gentPath,
89
+ headTree,
90
+ [...overlay].map(([name, hash]) => ({ name, hash }))
91
+ );
92
+ void stagedTree;
93
+ } else {
94
+ const targetHash = ref
95
+ ? (commits.find(c => c.hash === ref || c.hash.startsWith(ref)) || {}).hash
96
+ : repository.branches[repository.currentBranch];
97
+ const commit = targetHash ? commitMap.get(targetHash) : null;
98
+ if (!commit) {
99
+ console.log(chalk.yellow(ref ? `Commit '${ref}' not found` : 'No commits yet'));
100
+ return;
101
+ }
102
+ const parent = commit.parent ? commitMap.get(commit.parent) : null;
103
+ title = `Commit ${commit.hash.substring(0, 7)} — ${commit.message}`;
104
+ diffText = await diffTrees(gentPath, treeEntriesOf(parent), treeEntriesOf(commit));
105
+ }
106
+
107
+ if (!diffText) {
108
+ console.log(chalk.gray('No textual changes to explain.'));
109
+ return;
110
+ }
111
+
112
+ const trimmed = diffText.length > MAX_DIFF_CHARS
113
+ ? diffText.slice(0, MAX_DIFF_CHARS) + '\n... (diff truncated)'
114
+ : diffText;
115
+
116
+ console.log(chalk.bold.cyan(`\n${title}\n`));
117
+
118
+ if (!ai.isEnabled()) {
119
+ console.log(trimmed);
120
+ console.log(chalk.gray(`\n${ai.disabledHint()}`));
121
+ return;
122
+ }
123
+
124
+ const spinner = ora(`Asking ${ai.getModel()} to explain...`).start();
125
+ try {
126
+ const explanation = await ai.explainChanges(trimmed);
127
+ spinner.stop();
128
+ console.log(explanation);
129
+ } catch (err) {
130
+ spinner.fail(chalk.yellow('AI request failed — showing the raw diff instead'));
131
+ console.log(chalk.gray(`(${err.message})\n`));
132
+ console.log(trimmed);
133
+ }
134
+ } catch (error) {
135
+ if (error.code === 'ENOENT' && error.message.includes('.gent')) {
136
+ console.error(chalk.red('Error: Not a gent repository'));
137
+ console.log(chalk.yellow('\nRun "gent init" to initialize a repository'));
138
+ } else {
139
+ console.error(chalk.red('Error:'), error.message);
140
+ }
141
+ process.exit(1);
142
+ }
143
+ }
144
+
145
+ module.exports = explain;
@@ -60,7 +60,9 @@ async function log(options) {
60
60
  cur = c.parent;
61
61
  }
62
62
 
63
- if (options.oneline) {
63
+ if (options.graph) {
64
+ displayGraphLog(commits, headHash, repository.branches || {}, limit);
65
+ } else if (options.oneline) {
64
66
  displayOnelineLog(ordered, headHash);
65
67
  } else {
66
68
  displayDetailedLog(ordered, headHash, currentBranch, options);
@@ -118,6 +120,54 @@ function displayDetailedLog(commits, currentCommitHash, currentBranch, options)
118
120
  });
119
121
  }
120
122
 
123
+ /**
124
+ * Display a commit graph reachable from HEAD (parent + mergeParent edges),
125
+ * ordered newest-first, with branch/HEAD decorations and merge annotations.
126
+ * A simplified single-rail graph: merges are annotated rather than drawn as
127
+ * separate lanes, which keeps the output readable in a terminal.
128
+ */
129
+ function displayGraphLog(allCommits, headHash, branches, limit) {
130
+ const map = new Map(allCommits.map(c => [c.hash, c]));
131
+
132
+ // Reachable set from HEAD via both edges.
133
+ const reachable = [];
134
+ const seen = new Set();
135
+ const stack = [headHash];
136
+ while (stack.length) {
137
+ const h = stack.pop();
138
+ if (!h || seen.has(h)) continue;
139
+ const c = map.get(h);
140
+ if (!c) continue;
141
+ seen.add(h);
142
+ reachable.push(c);
143
+ if (c.parent) stack.push(c.parent);
144
+ if (c.mergeParent) stack.push(c.mergeParent);
145
+ }
146
+ reachable.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
147
+ const limited = reachable.slice(0, limit);
148
+
149
+ // Branch labels by commit hash.
150
+ const refs = new Map();
151
+ for (const [name, hash] of Object.entries(branches)) {
152
+ if (!hash) continue;
153
+ if (!refs.has(hash)) refs.set(hash, []);
154
+ refs.get(hash).push(name);
155
+ }
156
+
157
+ console.log(chalk.bold.cyan('\nCommit graph:\n'));
158
+ limited.forEach((c, i) => {
159
+ const headLabel = c.hash === headHash ? chalk.yellow.bold(' (HEAD)') : '';
160
+ const refLabel = refs.has(c.hash) ? chalk.green(` (${refs.get(c.hash).join(', ')})`) : '';
161
+ const time = chalk.gray(`(${formatDistanceToNow(new Date(c.timestamp), { addSuffix: true })})`);
162
+ console.log(`${chalk.yellow('*')} ${chalk.yellow(c.hash.substring(0, 7))}${headLabel}${refLabel} ${c.message} ${time}`);
163
+ if (c.mergeParent) {
164
+ console.log(chalk.gray(`|\\ merge: ${(c.parent || '').substring(0, 7)} + ${c.mergeParent.substring(0, 7)}`));
165
+ }
166
+ if (i < limited.length - 1) console.log(chalk.gray('|'));
167
+ });
168
+ console.log();
169
+ }
170
+
121
171
  /**
122
172
  * Display oneline commit log
123
173
  */
@@ -13,6 +13,7 @@ const { generateCommitHash } = require('../utils/helpers');
13
13
  const authStorage = require('../utils/auth-storage');
14
14
  const { findMergeBase, mergeTreeEntries, autoMerge } = require('../utils/merge-engine');
15
15
  const { storeTree, readBlobAsString, storeBlob } = require('../utils/hash-engine');
16
+ const journal = require('../utils/journal');
16
17
 
17
18
  /**
18
19
  * Merge a branch into the current branch
@@ -66,6 +67,7 @@ async function merge(sourceBranch, options) {
66
67
  // Fast-forward: current branch is merge base → just move pointer
67
68
  if (baseHash === oursHash) {
68
69
  spinner.text = 'Fast-forward merge...';
70
+ await journal.recordOp(gentPath, 'merge', `fast-forward '${sourceBranch}' into ${currentBranch}`, { restoreTree: true });
69
71
  repository.branches[currentBranch] = theirsHash;
70
72
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
71
73
 
@@ -165,6 +167,8 @@ async function merge(sourceBranch, options) {
165
167
  }
166
168
  };
167
169
 
170
+ await journal.recordOp(gentPath, 'merge', `merge '${sourceBranch}' into ${currentBranch}`);
171
+
168
172
  repository.commits.push(mergeCommit);
169
173
  repository.branches[currentBranch] = mergeCommit.hash;
170
174
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
@@ -30,6 +30,7 @@ const ora = require('ora');
30
30
  const { getGentPath, readJSON, writeJSON, pathExists } = require('../utils/fileSystem');
31
31
  const { STAGING_FILE, COMMITS_FILE } = require('../utils/constants');
32
32
  const { readBlobAsString } = require('../utils/hash-engine');
33
+ const journal = require('../utils/journal');
33
34
 
34
35
  /**
35
36
  * Reset staging or HEAD
@@ -112,6 +113,15 @@ async function resetHead(gentPath, cwd, args, options) {
112
113
 
113
114
  const spinner = ora(`Resetting to ${target.hash.substring(0, 7)}...`).start();
114
115
 
116
+ // Journal pre-state. A hard reset discards working-tree content, so flag it
117
+ // for working-tree restore on undo.
118
+ await journal.recordOp(
119
+ gentPath,
120
+ 'reset',
121
+ `${options.hard ? 'hard' : 'soft'} reset to ${target.hash.substring(0, 7)}`,
122
+ { restoreTree: !!options.hard }
123
+ );
124
+
115
125
  // Move branch pointer
116
126
  repository.branches[currentBranch] = target.hash;
117
127
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
@@ -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;