gent-cli 6.0.0 → 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.
- package/QUICKSTART.md +116 -85
- package/README.md +62 -3
- package/package.json +5 -3
- package/src/commands/branch.js +3 -0
- package/src/commands/checkout.js +5 -0
- package/src/commands/commit.js +65 -2
- package/src/commands/explain.js +145 -0
- package/src/commands/log.js +51 -1
- package/src/commands/merge.js +4 -0
- package/src/commands/remote.js +15 -1
- package/src/commands/reset.js +10 -0
- package/src/commands/resolve.js +280 -0
- package/src/commands/summary.js +176 -0
- package/src/commands/undo.js +115 -0
- package/src/index.js +39 -3
- package/src/utils/ai-service.js +156 -0
- package/src/utils/diff-engine.js +68 -2
- package/src/utils/journal.js +215 -0
- package/src/utils/merge-engine.js +340 -140
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Summary Command - Repository health & statistics dashboard
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* A one-glance overview of the repository — commits, branches, tags,
|
|
8
|
+
* contributors, tracked files, object-store size, most-changed files, last
|
|
9
|
+
* activity, and how far ahead of the remote the current branch is. Something
|
|
10
|
+
* plain git doesn't offer in a single command.
|
|
11
|
+
*
|
|
12
|
+
* USAGE:
|
|
13
|
+
* gent summary → print the dashboard
|
|
14
|
+
* gent summary --ai → also include a short AI-written health narrative
|
|
15
|
+
*
|
|
16
|
+
* ============================================================================
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs').promises;
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const chalk = require('chalk');
|
|
22
|
+
const boxen = require('boxen');
|
|
23
|
+
const { formatDistanceToNow } = require('date-fns');
|
|
24
|
+
const { getGentPath, readJSON, pathExists } = require('../utils/fileSystem');
|
|
25
|
+
const { COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
|
|
26
|
+
const { readBlobAsString } = require('../utils/hash-engine');
|
|
27
|
+
const { formatBytes } = require('../utils/helpers');
|
|
28
|
+
const ai = require('../utils/ai-service');
|
|
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
|
+
/** Recursively sum the byte size of the object store. */
|
|
37
|
+
async function objectStoreSize(gentPath) {
|
|
38
|
+
const root = path.join(gentPath, 'objects');
|
|
39
|
+
let total = 0;
|
|
40
|
+
async function walk(dir) {
|
|
41
|
+
let entries;
|
|
42
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
43
|
+
for (const e of entries) {
|
|
44
|
+
const full = path.join(dir, e.name);
|
|
45
|
+
if (e.isDirectory()) await walk(full);
|
|
46
|
+
else { try { total += (await fs.stat(full)).size; } catch { /* ignore */ } }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
await walk(root);
|
|
50
|
+
return total;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Count change frequency per file across history (hash-only tree diff). */
|
|
54
|
+
function mostChangedFiles(commits, commitMap, limit = 5) {
|
|
55
|
+
const counts = new Map();
|
|
56
|
+
for (const c of commits) {
|
|
57
|
+
const cur = new Map(treeEntriesOf(c).map(e => [e.name, e.hash]));
|
|
58
|
+
const parent = c.parent ? commitMap.get(c.parent) : null;
|
|
59
|
+
const prev = new Map(treeEntriesOf(parent).map(e => [e.name, e.hash]));
|
|
60
|
+
const names = new Set([...cur.keys(), ...prev.keys()]);
|
|
61
|
+
for (const name of names) {
|
|
62
|
+
if (cur.get(name) !== prev.get(name)) counts.set(name, (counts.get(name) || 0) + 1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Count commits reachable from `head` not yet known to the remote ref. */
|
|
69
|
+
function aheadCount(commitMap, head, remoteRef) {
|
|
70
|
+
if (!head) return 0;
|
|
71
|
+
let cur = head, n = 0;
|
|
72
|
+
const guard = new Set();
|
|
73
|
+
while (cur && cur !== remoteRef && !guard.has(cur)) {
|
|
74
|
+
guard.add(cur);
|
|
75
|
+
const c = commitMap.get(cur);
|
|
76
|
+
if (!c) break;
|
|
77
|
+
n++;
|
|
78
|
+
cur = c.parent;
|
|
79
|
+
}
|
|
80
|
+
return n;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function summary(options = {}) {
|
|
84
|
+
try {
|
|
85
|
+
const gentPath = await getGentPath();
|
|
86
|
+
const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
87
|
+
const configPath = path.join(gentPath, CONFIG_FILE);
|
|
88
|
+
const config = (await pathExists(configPath)) ? await readJSON(configPath) : {};
|
|
89
|
+
|
|
90
|
+
const commits = repository.commits || [];
|
|
91
|
+
const commitMap = new Map(commits.map(c => [c.hash, c]));
|
|
92
|
+
const branches = repository.branches || {};
|
|
93
|
+
const currentBranch = repository.currentBranch || 'main';
|
|
94
|
+
const tags = repository.tags || {};
|
|
95
|
+
|
|
96
|
+
// Contributors
|
|
97
|
+
const authors = new Map();
|
|
98
|
+
for (const c of commits) {
|
|
99
|
+
const key = `${c.author?.name || 'Unknown'} <${c.author?.email || 'unknown'}>`;
|
|
100
|
+
authors.set(key, (authors.get(key) || 0) + 1);
|
|
101
|
+
}
|
|
102
|
+
const topAuthors = [...authors.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
103
|
+
|
|
104
|
+
// Tracked files + lines of code (text blobs only)
|
|
105
|
+
const headHash = branches[currentBranch];
|
|
106
|
+
const headTree = treeEntriesOf(commitMap.get(headHash));
|
|
107
|
+
let loc = 0;
|
|
108
|
+
for (const e of headTree) {
|
|
109
|
+
try { loc += (await readBlobAsString(gentPath, e.hash)).split('\n').length; } catch { /* binary */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const storeSize = await objectStoreSize(gentPath);
|
|
113
|
+
const topChanged = mostChangedFiles(commits, commitMap);
|
|
114
|
+
|
|
115
|
+
// Last activity
|
|
116
|
+
const last = commits.reduce((acc, c) =>
|
|
117
|
+
(!acc || new Date(c.timestamp) > new Date(acc.timestamp)) ? c : acc, null);
|
|
118
|
+
|
|
119
|
+
// Ahead of remote (if known)
|
|
120
|
+
const remoteRef = (config.remoteRefs || {})[`origin/${currentBranch}`];
|
|
121
|
+
const ahead = remoteRef ? aheadCount(commitMap, headHash, remoteRef) : null;
|
|
122
|
+
|
|
123
|
+
// ── Render ──
|
|
124
|
+
const lines = [];
|
|
125
|
+
lines.push(chalk.bold.cyan(config.repository?.name || path.basename(process.cwd())));
|
|
126
|
+
if (config.repository?.description) lines.push(chalk.gray(config.repository.description));
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push(`${chalk.bold('Branch:')} ${chalk.green(currentBranch)} ${chalk.gray(`(${Object.keys(branches).length} total)`)}`);
|
|
129
|
+
lines.push(`${chalk.bold('Commits:')} ${commits.length}`);
|
|
130
|
+
lines.push(`${chalk.bold('Tags:')} ${Object.keys(tags).length}`);
|
|
131
|
+
lines.push(`${chalk.bold('Tracked:')} ${headTree.length} file(s), ~${loc} lines`);
|
|
132
|
+
lines.push(`${chalk.bold('Objects:')} ${formatBytes(storeSize)}`);
|
|
133
|
+
if (ahead !== null) lines.push(`${chalk.bold('Remote:')} ${ahead === 0 ? chalk.green('up to date') : chalk.yellow(`${ahead} commit(s) ahead of origin/${currentBranch}`)}`);
|
|
134
|
+
if (last) lines.push(`${chalk.bold('Last commit:')} ${formatDistanceToNow(new Date(last.timestamp), { addSuffix: true })}`);
|
|
135
|
+
|
|
136
|
+
if (topAuthors.length) {
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push(chalk.bold('Top contributors:'));
|
|
139
|
+
for (const [name, n] of topAuthors) lines.push(` ${chalk.green(String(n).padStart(4))} ${name}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (topChanged.length) {
|
|
143
|
+
lines.push('');
|
|
144
|
+
lines.push(chalk.bold('Most-changed files:'));
|
|
145
|
+
for (const [name, n] of topChanged) lines.push(` ${chalk.yellow(String(n).padStart(4))} ${name}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.log(boxen(lines.join('\n'), {
|
|
149
|
+
padding: 1, margin: 1, borderStyle: 'round', borderColor: 'cyan', title: 'gent summary', titleAlignment: 'center'
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
if (options.ai) {
|
|
153
|
+
if (!ai.isEnabled()) {
|
|
154
|
+
console.log(chalk.gray(ai.disabledHint()));
|
|
155
|
+
} else {
|
|
156
|
+
try {
|
|
157
|
+
const facts = lines.join('\n').replace(/\[[0-9;]*m/g, ''); // strip colors
|
|
158
|
+
const narrative = await ai.explainChanges(`Repository stats:\n${facts}\n\nGive a 2-3 sentence health assessment.`);
|
|
159
|
+
console.log(chalk.cyan(narrative));
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.log(chalk.yellow(`AI summary failed: ${err.message}`));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error.code === 'ENOENT' && error.message.includes('.gent')) {
|
|
167
|
+
console.error(chalk.red('Error: Not a gent repository'));
|
|
168
|
+
console.log(chalk.yellow('\nRun "gent init" to initialize a repository'));
|
|
169
|
+
} else {
|
|
170
|
+
console.error(chalk.red('Error:'), error.message);
|
|
171
|
+
}
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = summary;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Undo / Redo Commands - One-command safety net over the operation journal
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* Reverse (or re-apply) the last history-changing operation without having to
|
|
8
|
+
* reason about reflogs and commit hashes.
|
|
9
|
+
*
|
|
10
|
+
* USAGE:
|
|
11
|
+
* gent undo → Reverse the last operation
|
|
12
|
+
* gent undo --list → Show the operation history (most recent first)
|
|
13
|
+
* gent redo → Re-apply the last undone operation
|
|
14
|
+
*
|
|
15
|
+
* See src/utils/journal.js for the recorded state and exact undo semantics
|
|
16
|
+
* (working files are never deleted).
|
|
17
|
+
*
|
|
18
|
+
* ============================================================================
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const chalk = require('chalk');
|
|
22
|
+
const { formatDistanceToNow } = require('date-fns');
|
|
23
|
+
const { getGentPath } = require('../utils/fileSystem');
|
|
24
|
+
const journal = require('../utils/journal');
|
|
25
|
+
|
|
26
|
+
function shortHash(h) {
|
|
27
|
+
return h ? h.substring(0, 7) : '(none)';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function notARepo(error) {
|
|
31
|
+
return error.code === 'ENOENT' && error.message.includes('.gent');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `gent undo` — reverse the last operation, or list history with --list.
|
|
36
|
+
* @param {Object} options
|
|
37
|
+
*/
|
|
38
|
+
async function undo(options = {}) {
|
|
39
|
+
try {
|
|
40
|
+
const gentPath = await getGentPath();
|
|
41
|
+
const cwd = process.cwd();
|
|
42
|
+
|
|
43
|
+
if (options.list) {
|
|
44
|
+
await printHistory(gentPath);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result = await journal.applyUndo(gentPath, cwd);
|
|
49
|
+
if (!result.ok) {
|
|
50
|
+
console.log(chalk.yellow('Nothing to undo'));
|
|
51
|
+
console.log(chalk.gray('History-changing operations (commit, merge, reset, checkout) can be undone.'));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { entry } = result;
|
|
56
|
+
console.log(chalk.green(`✓ Undid ${chalk.bold(entry.op)}: ${entry.description}`));
|
|
57
|
+
console.log(chalk.gray(` Now on '${result.branch}' at ${shortHash(result.head)}`));
|
|
58
|
+
console.log(chalk.cyan(' Run "gent redo" to re-apply.'));
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (notARepo(error)) {
|
|
61
|
+
console.error(chalk.red('Error: Not a gent repository'));
|
|
62
|
+
console.log(chalk.yellow('\nRun "gent init" to initialize a repository'));
|
|
63
|
+
} else {
|
|
64
|
+
console.error(chalk.red('Error:'), error.message);
|
|
65
|
+
}
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `gent redo` — re-apply the last undone operation.
|
|
72
|
+
*/
|
|
73
|
+
async function redo() {
|
|
74
|
+
try {
|
|
75
|
+
const gentPath = await getGentPath();
|
|
76
|
+
const cwd = process.cwd();
|
|
77
|
+
|
|
78
|
+
const result = await journal.applyRedo(gentPath, cwd);
|
|
79
|
+
if (!result.ok) {
|
|
80
|
+
console.log(chalk.yellow('Nothing to redo'));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { entry } = result;
|
|
85
|
+
console.log(chalk.green(`✓ Redid ${chalk.bold(entry.op)}: ${entry.description}`));
|
|
86
|
+
console.log(chalk.gray(` Now on '${result.branch}' at ${shortHash(result.head)}`));
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (notARepo(error)) {
|
|
89
|
+
console.error(chalk.red('Error: Not a gent repository'));
|
|
90
|
+
console.log(chalk.yellow('\nRun "gent init" to initialize a repository'));
|
|
91
|
+
} else {
|
|
92
|
+
console.error(chalk.red('Error:'), error.message);
|
|
93
|
+
}
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function printHistory(gentPath) {
|
|
99
|
+
const entries = await journal.listEntries(gentPath);
|
|
100
|
+
if (entries.length === 0) {
|
|
101
|
+
console.log(chalk.yellow('No operations recorded yet'));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log(chalk.bold.cyan('\nOperation history (most recent first):\n'));
|
|
106
|
+
entries.forEach((e, i) => {
|
|
107
|
+
const when = chalk.gray(`(${formatDistanceToNow(new Date(e.timestamp), { addSuffix: true })})`);
|
|
108
|
+
const marker = i === 0 ? chalk.yellow('● ') : chalk.gray('○ ');
|
|
109
|
+
console.log(`${marker}${chalk.bold(e.op.padEnd(14))} ${e.description} ${when}`);
|
|
110
|
+
});
|
|
111
|
+
console.log(chalk.gray('\n"gent undo" reverses the most recent (●).'));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = undo;
|
|
115
|
+
module.exports.redo = redo;
|
package/src/index.js
CHANGED
|
@@ -9,13 +9,15 @@
|
|
|
9
9
|
* COMMANDS:
|
|
10
10
|
* Repository: init, clone
|
|
11
11
|
* Staging: add, rm, reset, status, diff
|
|
12
|
-
* History: commit, log, show, tag
|
|
13
|
-
* Branching: branch, checkout, merge, stash
|
|
12
|
+
* History: commit, log, show, tag, explain
|
|
13
|
+
* Branching: branch, checkout, merge, resolve, stash
|
|
14
|
+
* Safety: undo, redo
|
|
15
|
+
* Insight: summary
|
|
14
16
|
* Remote: remote, push, pull
|
|
15
17
|
* Auth: register, login, logout, whoami
|
|
16
18
|
*
|
|
17
19
|
* @author Abdalrahman Kanawati
|
|
18
|
-
* @version
|
|
20
|
+
* @version 7.0.0
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
const { program } = require('commander');
|
|
@@ -42,6 +44,10 @@ const remoteCommand = require('./commands/remote');
|
|
|
42
44
|
const pushCommand = require('./commands/push');
|
|
43
45
|
const pullCommand = require('./commands/pull');
|
|
44
46
|
const reposCommand = require('./commands/repos');
|
|
47
|
+
const undoCommand = require('./commands/undo');
|
|
48
|
+
const resolveCommand = require('./commands/resolve');
|
|
49
|
+
const summaryCommand = require('./commands/summary');
|
|
50
|
+
const explainCommand = require('./commands/explain');
|
|
45
51
|
|
|
46
52
|
// Import auth commands
|
|
47
53
|
const registerCommand = require('./commands/register');
|
|
@@ -110,6 +116,7 @@ program
|
|
|
110
116
|
.description('Record changes to the repository')
|
|
111
117
|
.option('-m, --message <message>', 'Commit message')
|
|
112
118
|
.option('-a, --all', 'Automatically stage all modified files')
|
|
119
|
+
.option('--ai', 'Suggest a commit message with AI (needs ANTHROPIC_API_KEY)')
|
|
113
120
|
.action(commitCommand);
|
|
114
121
|
|
|
115
122
|
program
|
|
@@ -117,6 +124,7 @@ program
|
|
|
117
124
|
.description('Show commit logs')
|
|
118
125
|
.option('-n, --number <count>', 'Limit the number of commits to show', '10')
|
|
119
126
|
.option('--oneline', 'Show each commit on a single line')
|
|
127
|
+
.option('--graph', 'Show an ASCII commit graph with branches and merges')
|
|
120
128
|
.option('--stat', 'Show file change statistics')
|
|
121
129
|
.action(logCommand);
|
|
122
130
|
|
|
@@ -133,6 +141,18 @@ program
|
|
|
133
141
|
.option('-d, --delete <name>', 'Delete a tag')
|
|
134
142
|
.action(tagCommand);
|
|
135
143
|
|
|
144
|
+
program
|
|
145
|
+
.command('explain [ref]')
|
|
146
|
+
.description('Explain a commit or staged changes in plain language')
|
|
147
|
+
.option('--staged', 'Explain staged changes instead of a commit')
|
|
148
|
+
.action(explainCommand);
|
|
149
|
+
|
|
150
|
+
program
|
|
151
|
+
.command('summary')
|
|
152
|
+
.description('Show a repository health & statistics dashboard')
|
|
153
|
+
.option('--ai', 'Add an AI-written health narrative (needs ANTHROPIC_API_KEY)')
|
|
154
|
+
.action(summaryCommand);
|
|
155
|
+
|
|
136
156
|
// ─── Branching & Merging ────────────────────────────────
|
|
137
157
|
|
|
138
158
|
program
|
|
@@ -155,6 +175,11 @@ program
|
|
|
155
175
|
.option('-m, --message <message>', 'Merge commit message')
|
|
156
176
|
.action(mergeCommand);
|
|
157
177
|
|
|
178
|
+
program
|
|
179
|
+
.command('resolve')
|
|
180
|
+
.description('Interactively resolve merge conflicts left by "gent merge"')
|
|
181
|
+
.action(resolveCommand);
|
|
182
|
+
|
|
158
183
|
program
|
|
159
184
|
.command('stash [subcommand]')
|
|
160
185
|
.description('Stash working tree changes (pop|list|drop|apply)')
|
|
@@ -162,6 +187,17 @@ program
|
|
|
162
187
|
.option('-i, --index <index>', 'Stash index for pop/apply/drop')
|
|
163
188
|
.action(stashCommand);
|
|
164
189
|
|
|
190
|
+
program
|
|
191
|
+
.command('undo')
|
|
192
|
+
.description('Reverse the last history-changing operation (safety net)')
|
|
193
|
+
.option('-l, --list', 'Show the operation history')
|
|
194
|
+
.action(undoCommand);
|
|
195
|
+
|
|
196
|
+
program
|
|
197
|
+
.command('redo')
|
|
198
|
+
.description('Re-apply the last undone operation')
|
|
199
|
+
.action(undoCommand.redo);
|
|
200
|
+
|
|
165
201
|
// ─── Remote & Sync ──────────────────────────────────────
|
|
166
202
|
|
|
167
203
|
program
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* AI Service - Optional, key-gated Claude integration (hybrid layer)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* Power Gent's *optional* "smart" features (commit-message suggestions, diff
|
|
8
|
+
* explanations, AI-assisted conflict resolution). Every feature has a
|
|
9
|
+
* reliable algorithmic path; this layer only activates when the user has set
|
|
10
|
+
* an API key, and degrades gracefully (never throws into a command) when it
|
|
11
|
+
* is absent or the request fails.
|
|
12
|
+
*
|
|
13
|
+
* ENABLEMENT:
|
|
14
|
+
* Set ANTHROPIC_API_KEY in the environment to enable. Optionally set
|
|
15
|
+
* GENT_AI_MODEL to pick a model (default: claude-opus-4-8). For a cheaper /
|
|
16
|
+
* faster option set GENT_AI_MODEL=claude-haiku-4-5.
|
|
17
|
+
*
|
|
18
|
+
* IMPLEMENTATION NOTE:
|
|
19
|
+
* Calls the Anthropic Messages API (POST /v1/messages) directly over the
|
|
20
|
+
* project's existing `axios` dependency, to honour Gent's "no new runtime
|
|
21
|
+
* dependencies" constraint. A production app would normally use the official
|
|
22
|
+
* `@anthropic-ai/sdk`; raw HTTP is a deliberate trade-off here because the AI
|
|
23
|
+
* layer is optional and self-contained.
|
|
24
|
+
*
|
|
25
|
+
* ============================================================================
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const axios = require('axios');
|
|
29
|
+
|
|
30
|
+
const API_URL = 'https://api.anthropic.com/v1/messages';
|
|
31
|
+
const API_VERSION = '2023-06-01';
|
|
32
|
+
const DEFAULT_MODEL = 'claude-opus-4-8';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the API key (env only — keeps secrets out of the repo).
|
|
36
|
+
* @returns {String|null}
|
|
37
|
+
*/
|
|
38
|
+
function getApiKey() {
|
|
39
|
+
return process.env.ANTHROPIC_API_KEY || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @returns {Boolean} whether AI features are enabled.
|
|
44
|
+
*/
|
|
45
|
+
function isEnabled() {
|
|
46
|
+
return !!getApiKey();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @returns {String} the model id to use.
|
|
51
|
+
*/
|
|
52
|
+
function getModel() {
|
|
53
|
+
return process.env.GENT_AI_MODEL || DEFAULT_MODEL;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One-line hint shown by commands when AI is requested but no key is set.
|
|
58
|
+
* @returns {String}
|
|
59
|
+
*/
|
|
60
|
+
function disabledHint() {
|
|
61
|
+
return 'AI features are off — set ANTHROPIC_API_KEY to enable (optional: GENT_AI_MODEL).';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Low-level single-shot completion. Returns the assistant's text.
|
|
66
|
+
* @param {Object} opts
|
|
67
|
+
* @param {String} opts.prompt - user content
|
|
68
|
+
* @param {String} [opts.system] - system prompt
|
|
69
|
+
* @param {Number} [opts.maxTokens]
|
|
70
|
+
* @returns {Promise<String>}
|
|
71
|
+
*/
|
|
72
|
+
async function complete({ prompt, system, maxTokens = 1024 }) {
|
|
73
|
+
const apiKey = getApiKey();
|
|
74
|
+
if (!apiKey) throw new Error('AI not enabled');
|
|
75
|
+
|
|
76
|
+
const body = {
|
|
77
|
+
model: getModel(),
|
|
78
|
+
max_tokens: maxTokens,
|
|
79
|
+
messages: [{ role: 'user', content: prompt }]
|
|
80
|
+
};
|
|
81
|
+
if (system) body.system = system;
|
|
82
|
+
|
|
83
|
+
const res = await axios.post(API_URL, body, {
|
|
84
|
+
headers: {
|
|
85
|
+
'x-api-key': apiKey,
|
|
86
|
+
'anthropic-version': API_VERSION,
|
|
87
|
+
'content-type': 'application/json'
|
|
88
|
+
},
|
|
89
|
+
timeout: 60000
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const blocks = (res.data && res.data.content) || [];
|
|
93
|
+
return blocks
|
|
94
|
+
.filter(b => b.type === 'text')
|
|
95
|
+
.map(b => b.text)
|
|
96
|
+
.join('')
|
|
97
|
+
.trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── High-level helpers ─────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Suggest a concise commit message from a staged diff / summary.
|
|
104
|
+
* @param {String} diffSummary
|
|
105
|
+
* @returns {Promise<String>}
|
|
106
|
+
*/
|
|
107
|
+
async function suggestCommitMessage(diffSummary) {
|
|
108
|
+
const system =
|
|
109
|
+
'You write clear, conventional git commit messages. Reply with ONLY the commit ' +
|
|
110
|
+
'message: a concise imperative subject line (<=72 chars), optionally followed by ' +
|
|
111
|
+
'a blank line and short body. No quotes, no preamble, no markdown fences.';
|
|
112
|
+
const prompt = `Write a commit message for these staged changes:\n\n${diffSummary}`;
|
|
113
|
+
return complete({ system, prompt, maxTokens: 512 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Explain a commit or diff in plain language.
|
|
118
|
+
* @param {String} content - diff or commit details
|
|
119
|
+
* @returns {Promise<String>}
|
|
120
|
+
*/
|
|
121
|
+
async function explainChanges(content) {
|
|
122
|
+
const system =
|
|
123
|
+
'You are a senior engineer explaining a code change to a teammate. Summarize what ' +
|
|
124
|
+
'changed and why it matters in a few short bullet points. Be specific and concise.';
|
|
125
|
+
const prompt = `Explain these changes:\n\n${content}`;
|
|
126
|
+
return complete({ system, prompt, maxTokens: 1024 });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Propose a resolution for a single merge-conflict hunk.
|
|
131
|
+
* @param {Object} hunk - { base?, ours, theirs, fileName? }
|
|
132
|
+
* @returns {Promise<String>} the suggested merged text (no conflict markers)
|
|
133
|
+
*/
|
|
134
|
+
async function resolveConflictHunk({ base, ours, theirs, fileName }) {
|
|
135
|
+
const system =
|
|
136
|
+
'You resolve git merge conflicts. Combine the intent of BOTH sides into a single ' +
|
|
137
|
+
'correct version. Reply with ONLY the resolved file section — no conflict markers, ' +
|
|
138
|
+
'no explanation, no markdown fences.';
|
|
139
|
+
const prompt =
|
|
140
|
+
`File: ${fileName || 'unknown'}\n\n` +
|
|
141
|
+
`<<<<<<< BASE (common ancestor)\n${base || '(none)'}\n` +
|
|
142
|
+
`======= OURS\n${ours}\n` +
|
|
143
|
+
`======= THEIRS\n${theirs}\n>>>>>>>\n\n` +
|
|
144
|
+
'Return the merged result for this section.';
|
|
145
|
+
return complete({ system, prompt, maxTokens: 2048 });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
isEnabled,
|
|
150
|
+
getModel,
|
|
151
|
+
disabledHint,
|
|
152
|
+
complete,
|
|
153
|
+
suggestCommitMessage,
|
|
154
|
+
explainChanges,
|
|
155
|
+
resolveConflictHunk
|
|
156
|
+
};
|
package/src/utils/diff-engine.js
CHANGED
|
@@ -22,6 +22,14 @@
|
|
|
22
22
|
* - Move up (i-1) → DELETE (line only in old version)
|
|
23
23
|
* - Move left (j-1) → INSERT (line only in new version)
|
|
24
24
|
*
|
|
25
|
+
* COMMON PREFIX/SUFFIX TRIMMING (optimization):
|
|
26
|
+
* Before building the O(M×N) matrix, identical leading and trailing lines are
|
|
27
|
+
* stripped. Only the differing "middle" is run through LCS; the trimmed lines
|
|
28
|
+
* are re-attached as `equal` ops with their original 1-based positions. For
|
|
29
|
+
* the common case of a localized edit in a large file this turns an O(M×N)
|
|
30
|
+
* matrix into something proportional to the size of the change — a large
|
|
31
|
+
* memory and time win — while producing byte-identical output.
|
|
32
|
+
*
|
|
25
33
|
* HUNK GENERATION:
|
|
26
34
|
* Groups adjacent changes with N context lines (default 3) into hunks.
|
|
27
35
|
* Changes within 2*N+1 lines of each other merge into one hunk.
|
|
@@ -63,12 +71,14 @@ function buildLcsMatrix(a, b) {
|
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
/**
|
|
66
|
-
* Backtrack LCS matrix → line operations
|
|
74
|
+
* Backtrack the LCS matrix of two line arrays → line operations with
|
|
75
|
+
* 1-based positions local to the given arrays. Pure O(M×N) core; callers
|
|
76
|
+
* normally use buildLineOperations, which trims common prefix/suffix first.
|
|
67
77
|
* @param {String[]} oldLines
|
|
68
78
|
* @param {String[]} newLines
|
|
69
79
|
* @returns {Array<{type: 'equal'|'insert'|'delete', oldLine: number, newLine: number, content: String}>}
|
|
70
80
|
*/
|
|
71
|
-
function
|
|
81
|
+
function lcsOperations(oldLines, newLines) {
|
|
72
82
|
const matrix = buildLcsMatrix(oldLines, newLines);
|
|
73
83
|
const ops = [];
|
|
74
84
|
let i = oldLines.length;
|
|
@@ -90,6 +100,61 @@ function buildLineOperations(oldLines, newLines) {
|
|
|
90
100
|
return ops.reverse();
|
|
91
101
|
}
|
|
92
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Diff two line arrays into operations, trimming common prefix/suffix before
|
|
105
|
+
* running LCS on the differing middle. Output is identical to running LCS on
|
|
106
|
+
* the full arrays, with `oldLine`/`newLine` as absolute 1-based positions.
|
|
107
|
+
* @param {String[]} oldLines
|
|
108
|
+
* @param {String[]} newLines
|
|
109
|
+
* @returns {Array<{type: 'equal'|'insert'|'delete', oldLine: number, newLine: number, content: String}>}
|
|
110
|
+
*/
|
|
111
|
+
function buildLineOperations(oldLines, newLines) {
|
|
112
|
+
const oldLen = oldLines.length;
|
|
113
|
+
const newLen = newLines.length;
|
|
114
|
+
const minLen = Math.min(oldLen, newLen);
|
|
115
|
+
|
|
116
|
+
// Common leading lines.
|
|
117
|
+
let prefix = 0;
|
|
118
|
+
while (prefix < minLen && oldLines[prefix] === newLines[prefix]) prefix++;
|
|
119
|
+
|
|
120
|
+
// Common trailing lines (not overlapping the prefix).
|
|
121
|
+
let suffix = 0;
|
|
122
|
+
while (
|
|
123
|
+
suffix < minLen - prefix &&
|
|
124
|
+
oldLines[oldLen - 1 - suffix] === newLines[newLen - 1 - suffix]
|
|
125
|
+
) suffix++;
|
|
126
|
+
|
|
127
|
+
const ops = [];
|
|
128
|
+
|
|
129
|
+
// Prefix → equal ops at their original positions.
|
|
130
|
+
for (let k = 0; k < prefix; k++) {
|
|
131
|
+
ops.push({ type: 'equal', oldLine: k + 1, newLine: k + 1, content: oldLines[k] });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Middle → LCS, shifted back into absolute coordinates by `prefix`.
|
|
135
|
+
const oldMid = oldLines.slice(prefix, oldLen - suffix);
|
|
136
|
+
const newMid = newLines.slice(prefix, newLen - suffix);
|
|
137
|
+
if (oldMid.length || newMid.length) {
|
|
138
|
+
for (const op of lcsOperations(oldMid, newMid)) {
|
|
139
|
+
ops.push({
|
|
140
|
+
type: op.type,
|
|
141
|
+
oldLine: op.oldLine + prefix,
|
|
142
|
+
newLine: op.newLine + prefix,
|
|
143
|
+
content: op.content
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Suffix → equal ops at their original positions.
|
|
149
|
+
for (let k = 0; k < suffix; k++) {
|
|
150
|
+
const oldIdx = oldLen - suffix + k;
|
|
151
|
+
const newIdx = newLen - suffix + k;
|
|
152
|
+
ops.push({ type: 'equal', oldLine: oldIdx + 1, newLine: newIdx + 1, content: oldLines[oldIdx] });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return ops;
|
|
156
|
+
}
|
|
157
|
+
|
|
93
158
|
/**
|
|
94
159
|
* Diff two texts → operations + stats.
|
|
95
160
|
* @param {String} oldText
|
|
@@ -229,6 +294,7 @@ module.exports = {
|
|
|
229
294
|
diffText,
|
|
230
295
|
summarizeOperations,
|
|
231
296
|
buildLcsMatrix,
|
|
297
|
+
lcsOperations,
|
|
232
298
|
buildLineOperations,
|
|
233
299
|
generateHunks,
|
|
234
300
|
formatUnifiedDiff,
|