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.
- package/QUICKSTART.md +116 -85
- package/README.md +60 -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/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,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;
|
package/src/commands/log.js
CHANGED
|
@@ -60,7 +60,9 @@ async function log(options) {
|
|
|
60
60
|
cur = c.parent;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
if (options.
|
|
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
|
*/
|
package/src/commands/merge.js
CHANGED
|
@@ -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);
|
package/src/commands/reset.js
CHANGED
|
@@ -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;
|