gent-cli 2.1.0 → 5.0.1
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/README.md +215 -65
- package/package.json +1 -1
- package/src/commands/add.js +102 -23
- package/src/commands/clone.js +137 -86
- package/src/commands/commit.js +89 -81
- package/src/commands/diff.js +257 -0
- package/src/commands/init.js +5 -36
- package/src/commands/log.js +57 -13
- package/src/commands/merge.js +245 -0
- package/src/commands/pull.js +176 -86
- package/src/commands/push.js +172 -79
- package/src/commands/remote.js +97 -113
- package/src/commands/reset.js +149 -0
- package/src/commands/rm.js +85 -0
- package/src/commands/show.js +167 -0
- package/src/commands/stash.js +255 -0
- package/src/commands/status.js +80 -46
- package/src/commands/tag.js +146 -0
- package/src/index.js +108 -57
- package/src/utils/constants.js +10 -26
- package/src/utils/diff-engine.js +236 -0
- package/src/utils/fileSystem.js +8 -60
- package/src/utils/hash-engine.js +337 -0
- package/src/utils/merge-engine.js +379 -0
- package/src/utils/object-store.js +54 -0
- package/src/commands/create.js +0 -121
- package/src/commands/list.js +0 -67
- package/src/services/repo-service.js +0 -291
- package/src/utils/cloud-sync.js +0 -323
- package/src/utils/diff.js +0 -121
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Diff Command - Show changes between commits, staging, and working tree
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* Display line-by-line differences between file versions, similar to
|
|
8
|
+
* `git diff`. Shows what changed, where, and how much.
|
|
9
|
+
*
|
|
10
|
+
* USAGE:
|
|
11
|
+
* gent diff → Working tree vs staging area
|
|
12
|
+
* gent diff --staged → Staging area vs last commit
|
|
13
|
+
* gent diff <file> → Diff specific file(s)
|
|
14
|
+
* gent diff --stat → Summary only (no patch)
|
|
15
|
+
*
|
|
16
|
+
* ALGORITHM:
|
|
17
|
+
* Uses LCS (Longest Common Subsequence) line-level diff from diff-engine.js.
|
|
18
|
+
* Outputs unified diff format with context lines (3 by default).
|
|
19
|
+
* Time complexity: O(m*n) where m,n = line counts of old/new files.
|
|
20
|
+
*
|
|
21
|
+
* BACKEND EXPECTATIONS:
|
|
22
|
+
* None (local only). Backend receives final blob hashes, not diffs.
|
|
23
|
+
*
|
|
24
|
+
* ============================================================================
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('fs').promises;
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const chalk = require('chalk');
|
|
30
|
+
const { getGentPath, readJSON, pathExists, getAllFiles, getIgnorePatterns } = require('../utils/fileSystem');
|
|
31
|
+
const { STAGING_FILE, COMMITS_FILE } = require('../utils/constants');
|
|
32
|
+
const { readBlobAsString, hashBlob } = require('../utils/hash-engine');
|
|
33
|
+
const { formatUnifiedDiff, diffText } = require('../utils/diff-engine');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Show differences
|
|
37
|
+
* @param {Array} files - Optional specific files to diff
|
|
38
|
+
* @param {Object} options - Command options
|
|
39
|
+
*/
|
|
40
|
+
async function diff(files, options) {
|
|
41
|
+
try {
|
|
42
|
+
const gentPath = await getGentPath();
|
|
43
|
+
const cwd = process.cwd();
|
|
44
|
+
|
|
45
|
+
const staging = await readJSON(path.join(gentPath, STAGING_FILE));
|
|
46
|
+
const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
47
|
+
|
|
48
|
+
const currentBranch = repository.currentBranch || 'main';
|
|
49
|
+
const headHash = repository.branches[currentBranch] || null;
|
|
50
|
+
const headCommit = headHash
|
|
51
|
+
? (repository.commits || []).find(c => c.hash === headHash)
|
|
52
|
+
: null;
|
|
53
|
+
|
|
54
|
+
// Build HEAD tree map: path → blobHash
|
|
55
|
+
const headTree = buildTreeMap(headCommit);
|
|
56
|
+
|
|
57
|
+
// Build staging map: path → blobHash
|
|
58
|
+
const stagingEntries = staging.entries || [];
|
|
59
|
+
const stagingMap = new Map(stagingEntries.map(e => [e.path, e.hash]));
|
|
60
|
+
|
|
61
|
+
if (options.staged) {
|
|
62
|
+
// Staged vs HEAD
|
|
63
|
+
await diffStagedVsHead(gentPath, cwd, stagingEntries, headTree, files, options);
|
|
64
|
+
} else {
|
|
65
|
+
// Working tree vs staged (or HEAD if not staged)
|
|
66
|
+
await diffWorkingTree(gentPath, cwd, stagingMap, headTree, files, options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error.code === 'ENOENT' && error.message.includes('.gent')) {
|
|
71
|
+
console.error(chalk.red('Error: Not a gent repository'));
|
|
72
|
+
console.log(chalk.yellow('Run "gent init" to initialize a repository'));
|
|
73
|
+
} else {
|
|
74
|
+
console.error(chalk.red('Error:'), error.message);
|
|
75
|
+
}
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Diff working tree vs staging/HEAD
|
|
82
|
+
*/
|
|
83
|
+
async function diffWorkingTree(gentPath, cwd, stagingMap, headTree, filterFiles, options) {
|
|
84
|
+
const ignorePatterns = await getIgnorePatterns(cwd);
|
|
85
|
+
const allFiles = await getAllFiles(cwd, ignorePatterns);
|
|
86
|
+
let hasDiffs = false;
|
|
87
|
+
|
|
88
|
+
let totalInsertions = 0;
|
|
89
|
+
let totalDeletions = 0;
|
|
90
|
+
const fileSummaries = [];
|
|
91
|
+
|
|
92
|
+
for (const absPath of allFiles) {
|
|
93
|
+
const relPath = path.relative(cwd, absPath);
|
|
94
|
+
|
|
95
|
+
// Filter if specific files provided
|
|
96
|
+
if (filterFiles && filterFiles.length > 0 && !filterFiles.includes(relPath)) continue;
|
|
97
|
+
|
|
98
|
+
// Determine base hash (staging → HEAD fallback)
|
|
99
|
+
const baseHash = stagingMap.get(relPath) || headTree.get(relPath);
|
|
100
|
+
if (!baseHash) continue; // untracked
|
|
101
|
+
|
|
102
|
+
const currentContent = await fs.readFile(absPath, 'utf-8');
|
|
103
|
+
const currentHash = hashBlob(currentContent);
|
|
104
|
+
|
|
105
|
+
if (currentHash === baseHash) continue; // unchanged
|
|
106
|
+
|
|
107
|
+
let oldContent = '';
|
|
108
|
+
try {
|
|
109
|
+
oldContent = await readBlobAsString(gentPath, baseHash);
|
|
110
|
+
} catch {
|
|
111
|
+
// No blob = new file
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const d = diffText(oldContent, currentContent);
|
|
115
|
+
totalInsertions += d.stats.insertions;
|
|
116
|
+
totalDeletions += d.stats.deletions;
|
|
117
|
+
fileSummaries.push({ file: relPath, stats: d.stats });
|
|
118
|
+
|
|
119
|
+
if (!options.stat) {
|
|
120
|
+
const unified = formatUnifiedDiff(relPath, oldContent, currentContent);
|
|
121
|
+
if (unified) {
|
|
122
|
+
hasDiffs = true;
|
|
123
|
+
printColorizedDiff(unified);
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
hasDiffs = true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (options.stat || hasDiffs) {
|
|
131
|
+
printDiffStat(fileSummaries, totalInsertions, totalDeletions);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!hasDiffs) {
|
|
135
|
+
console.log(chalk.gray('No changes'));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Diff staged files vs HEAD commit
|
|
141
|
+
*/
|
|
142
|
+
async function diffStagedVsHead(gentPath, cwd, stagedEntries, headTree, filterFiles, options) {
|
|
143
|
+
let hasDiffs = false;
|
|
144
|
+
let totalInsertions = 0;
|
|
145
|
+
let totalDeletions = 0;
|
|
146
|
+
const fileSummaries = [];
|
|
147
|
+
|
|
148
|
+
for (const entry of stagedEntries) {
|
|
149
|
+
if (filterFiles && filterFiles.length > 0 && !filterFiles.includes(entry.path)) continue;
|
|
150
|
+
|
|
151
|
+
const headBlobHash = headTree.get(entry.path);
|
|
152
|
+
|
|
153
|
+
if (entry.status === 'deleted') {
|
|
154
|
+
if (headBlobHash) {
|
|
155
|
+
const oldContent = await readBlobAsString(gentPath, headBlobHash);
|
|
156
|
+
const d = diffText(oldContent, '');
|
|
157
|
+
totalDeletions += d.stats.deletions;
|
|
158
|
+
fileSummaries.push({ file: entry.path, stats: d.stats });
|
|
159
|
+
if (!options.stat) {
|
|
160
|
+
printColorizedDiff(formatUnifiedDiff(entry.path, oldContent, ''));
|
|
161
|
+
}
|
|
162
|
+
hasDiffs = true;
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!entry.hash) continue;
|
|
168
|
+
|
|
169
|
+
if (entry.hash === headBlobHash) continue; // unchanged
|
|
170
|
+
|
|
171
|
+
let oldContent = '';
|
|
172
|
+
try {
|
|
173
|
+
if (headBlobHash) oldContent = await readBlobAsString(gentPath, headBlobHash);
|
|
174
|
+
} catch { /* new file */ }
|
|
175
|
+
|
|
176
|
+
const newContent = await readBlobAsString(gentPath, entry.hash);
|
|
177
|
+
const d = diffText(oldContent, newContent);
|
|
178
|
+
totalInsertions += d.stats.insertions;
|
|
179
|
+
totalDeletions += d.stats.deletions;
|
|
180
|
+
fileSummaries.push({ file: entry.path, stats: d.stats });
|
|
181
|
+
|
|
182
|
+
if (!options.stat) {
|
|
183
|
+
const unified = formatUnifiedDiff(entry.path, oldContent, newContent);
|
|
184
|
+
if (unified) {
|
|
185
|
+
hasDiffs = true;
|
|
186
|
+
printColorizedDiff(unified);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
hasDiffs = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (options.stat || hasDiffs) {
|
|
194
|
+
printDiffStat(fileSummaries, totalInsertions, totalDeletions);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!hasDiffs) {
|
|
198
|
+
console.log(chalk.gray('No staged changes'));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build path → hash map from commit object
|
|
204
|
+
*/
|
|
205
|
+
function buildTreeMap(commit) {
|
|
206
|
+
const map = new Map();
|
|
207
|
+
if (!commit) return map;
|
|
208
|
+
const tree = commit.tree || commit.files || [];
|
|
209
|
+
for (const f of tree) {
|
|
210
|
+
map.set(f.path || f.name, f.hash);
|
|
211
|
+
}
|
|
212
|
+
return map;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Print colorized unified diff
|
|
217
|
+
*/
|
|
218
|
+
function printColorizedDiff(unifiedDiff) {
|
|
219
|
+
const lines = unifiedDiff.split('\n');
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
if (line.startsWith('---') || line.startsWith('+++')) {
|
|
222
|
+
console.log(chalk.bold(line));
|
|
223
|
+
} else if (line.startsWith('@@')) {
|
|
224
|
+
console.log(chalk.cyan(line));
|
|
225
|
+
} else if (line.startsWith('+')) {
|
|
226
|
+
console.log(chalk.green(line));
|
|
227
|
+
} else if (line.startsWith('-')) {
|
|
228
|
+
console.log(chalk.red(line));
|
|
229
|
+
} else {
|
|
230
|
+
console.log(line);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
console.log('');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Print diff stat summary
|
|
238
|
+
*/
|
|
239
|
+
function printDiffStat(fileSummaries, totalIns, totalDel) {
|
|
240
|
+
if (fileSummaries.length === 0) return;
|
|
241
|
+
|
|
242
|
+
console.log('');
|
|
243
|
+
const maxLen = Math.max(...fileSummaries.map(f => f.file.length));
|
|
244
|
+
|
|
245
|
+
for (const { file, stats } of fileSummaries) {
|
|
246
|
+
const total = stats.insertions + stats.deletions;
|
|
247
|
+
const bar = chalk.green('+'.repeat(Math.min(stats.insertions, 30))) +
|
|
248
|
+
chalk.red('-'.repeat(Math.min(stats.deletions, 30)));
|
|
249
|
+
console.log(` ${file.padEnd(maxLen)} | ${String(total).padStart(4)} ${bar}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
console.log(chalk.gray(` ${fileSummaries.length} file(s) changed, `) +
|
|
253
|
+
chalk.green(`${totalIns} insertion(s)`) + ', ' +
|
|
254
|
+
chalk.red(`${totalDel} deletion(s)`));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = diff;
|
package/src/commands/init.js
CHANGED
|
@@ -28,10 +28,9 @@ async function init(options) {
|
|
|
28
28
|
// Get authenticated user profile if available
|
|
29
29
|
let defaultName = '';
|
|
30
30
|
let defaultEmail = '';
|
|
31
|
-
let user = null;
|
|
32
31
|
|
|
33
32
|
try {
|
|
34
|
-
user = await authStorage.getUser();
|
|
33
|
+
const user = await authStorage.getUser();
|
|
35
34
|
if (user) {
|
|
36
35
|
if (user.first_name || user.last_name) {
|
|
37
36
|
defaultName = [user.first_name, user.last_name].filter(Boolean).join(' ');
|
|
@@ -63,8 +62,12 @@ async function init(options) {
|
|
|
63
62
|
await ensureDir(path.join(gentPath, 'refs', 'tags'));
|
|
64
63
|
|
|
65
64
|
// Create/Update configuration
|
|
65
|
+
// Only write config if it doesn't exist OR if we have valid user info to update
|
|
66
66
|
const configPath = path.join(gentPath, CONFIG_FILE);
|
|
67
67
|
if (!(await pathExists(configPath)) || (defaultName && defaultEmail)) {
|
|
68
|
+
// If re-init, we might want to preserve existing config unless we have better info?
|
|
69
|
+
// Git re-init doesn't overwrite config usually.
|
|
70
|
+
// But for now, let's write ensuring we have a config file.
|
|
68
71
|
if (!isReinit || !(await pathExists(configPath))) {
|
|
69
72
|
await writeJSON(configPath, config);
|
|
70
73
|
}
|
|
@@ -106,40 +109,6 @@ node_modules/
|
|
|
106
109
|
console.log(chalk.gray(`Initialized empty Gent repository in ${gentPath}`));
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
// Handle --cloud option
|
|
110
|
-
if (options.cloud) {
|
|
111
|
-
if (!user) {
|
|
112
|
-
console.log(chalk.yellow('\nWarning: Skipping cloud repository creation (not logged in)'));
|
|
113
|
-
console.log(chalk.gray('Run "gent login" then "gent create <name> --init-local"'));
|
|
114
|
-
} else {
|
|
115
|
-
try {
|
|
116
|
-
const repoService = require('../services/repo-service');
|
|
117
|
-
const { addRemote } = require('../utils/cloud-sync');
|
|
118
|
-
const ora = require('ora');
|
|
119
|
-
|
|
120
|
-
const spinner = ora('Creating cloud repository...').start();
|
|
121
|
-
const repoName = path.basename(cwd);
|
|
122
|
-
|
|
123
|
-
const repository = await repoService.createRepository(
|
|
124
|
-
repoName,
|
|
125
|
-
options.description || 'A gent repository',
|
|
126
|
-
options.private || false,
|
|
127
|
-
'main'
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
const ownerId = repository.owner_id || repository.owner?.id || repository.owner;
|
|
131
|
-
const name = repository.name || repository.project_name || repoName;
|
|
132
|
-
|
|
133
|
-
await addRemote('origin', ownerId, name, cwd);
|
|
134
|
-
spinner.succeed(chalk.green(`Created cloud repository: ${ownerId}/${name}`));
|
|
135
|
-
console.log(chalk.gray(`Remote 'origin' added`));
|
|
136
|
-
} catch (error) {
|
|
137
|
-
console.log(chalk.red(`\nFailed to create cloud repository: ${error.message}`));
|
|
138
|
-
console.log(chalk.gray('You can create it later with "gent create <name>"'));
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
112
|
} catch (error) {
|
|
144
113
|
console.error(chalk.red('Failed to initialize repository'));
|
|
145
114
|
console.error(chalk.red('Error:'), error.message);
|
package/src/commands/log.js
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Log Command - Show commit history
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* Display commit history with details, stats, and merge info. Like `git log`.
|
|
8
|
+
*
|
|
9
|
+
* USAGE:
|
|
10
|
+
* gent log → Show last 10 commits (detailed)
|
|
11
|
+
* gent log -n 20 → Show last 20 commits
|
|
12
|
+
* gent log --oneline → Condensed one-line-per-commit view
|
|
13
|
+
* gent log --stat → Include diffstat per commit
|
|
14
|
+
*
|
|
15
|
+
* ALGORITHM:
|
|
16
|
+
* Reads commits.json, filters by branch HEAD → parent chain, displays
|
|
17
|
+
* in reverse chronological order.
|
|
18
|
+
*
|
|
19
|
+
* BACKEND EXPECTATIONS:
|
|
20
|
+
* GET /api/repos/:id/commits/?branch=main&limit=10
|
|
21
|
+
*
|
|
22
|
+
* ============================================================================
|
|
4
23
|
*/
|
|
5
24
|
|
|
6
25
|
const path = require('path');
|
|
@@ -27,14 +46,24 @@ async function log(options) {
|
|
|
27
46
|
return;
|
|
28
47
|
}
|
|
29
48
|
|
|
30
|
-
// Limit number of commits to show
|
|
31
49
|
const limit = parseInt(options.number) || 10;
|
|
32
|
-
|
|
50
|
+
|
|
51
|
+
// Walk branch chain for ordered display
|
|
52
|
+
const headHash = repository.branches[currentBranch];
|
|
53
|
+
const commitMap = new Map(commits.map(c => [c.hash, c]));
|
|
54
|
+
const ordered = [];
|
|
55
|
+
let cur = headHash;
|
|
56
|
+
while (cur && ordered.length < limit) {
|
|
57
|
+
const c = commitMap.get(cur);
|
|
58
|
+
if (!c) break;
|
|
59
|
+
ordered.push(c);
|
|
60
|
+
cur = c.parent;
|
|
61
|
+
}
|
|
33
62
|
|
|
34
63
|
if (options.oneline) {
|
|
35
|
-
displayOnelineLog(
|
|
64
|
+
displayOnelineLog(ordered, headHash);
|
|
36
65
|
} else {
|
|
37
|
-
displayDetailedLog(
|
|
66
|
+
displayDetailedLog(ordered, headHash, currentBranch, options);
|
|
38
67
|
}
|
|
39
68
|
|
|
40
69
|
} catch (error) {
|
|
@@ -51,21 +80,36 @@ async function log(options) {
|
|
|
51
80
|
/**
|
|
52
81
|
* Display detailed commit log
|
|
53
82
|
*/
|
|
54
|
-
function displayDetailedLog(commits, currentCommitHash, currentBranch) {
|
|
55
|
-
console.log(chalk.bold.cyan(`\nCommit History (${currentBranch}
|
|
83
|
+
function displayDetailedLog(commits, currentCommitHash, currentBranch, options) {
|
|
84
|
+
console.log(chalk.bold.cyan(`\nCommit History (${currentBranch}):\n`));
|
|
56
85
|
|
|
57
86
|
commits.forEach((commit, index) => {
|
|
58
|
-
const isHead = commit.
|
|
87
|
+
const isHead = commit.hash === currentCommitHash;
|
|
59
88
|
const headLabel = isHead ? chalk.yellow.bold(' (HEAD)') : '';
|
|
60
89
|
|
|
61
|
-
console.log(chalk.yellow(`commit ${commit.
|
|
90
|
+
console.log(chalk.yellow(`commit ${commit.hash}`) + headLabel);
|
|
91
|
+
if (commit.mergeParent) {
|
|
92
|
+
console.log(chalk.gray(`Merge: ${commit.parent?.substring(0, 7)} ${commit.mergeParent.substring(0, 7)}`));
|
|
93
|
+
}
|
|
62
94
|
console.log(chalk.white(`Author: ${commit.author.name} <${commit.author.email}>`));
|
|
63
95
|
console.log(chalk.white(`Date: ${new Date(commit.timestamp).toLocaleString()}`));
|
|
64
96
|
console.log(chalk.gray(` (${formatDistanceToNow(new Date(commit.timestamp), { addSuffix: true })})`));
|
|
97
|
+
if (commit.treeHash) {
|
|
98
|
+
console.log(chalk.gray(`Tree: ${commit.treeHash.substring(0, 7)}`));
|
|
99
|
+
}
|
|
65
100
|
console.log();
|
|
66
101
|
console.log(chalk.white(` ${commit.message}`));
|
|
67
102
|
console.log();
|
|
68
|
-
|
|
103
|
+
|
|
104
|
+
// Show stats if --stat flag or if commit has stats
|
|
105
|
+
if (options && options.stat && commit.stats) {
|
|
106
|
+
console.log(chalk.gray(` ${commit.stats.filesChanged} file(s), `) +
|
|
107
|
+
chalk.green(`+${commit.stats.insertions}`) + ' ' +
|
|
108
|
+
chalk.red(`-${commit.stats.deletions}`));
|
|
109
|
+
} else {
|
|
110
|
+
const fileCount = commit.files ? commit.files.length : (commit.tree ? commit.tree.length : 0);
|
|
111
|
+
console.log(chalk.gray(` ${fileCount} file(s) in tree`));
|
|
112
|
+
}
|
|
69
113
|
|
|
70
114
|
if (index < commits.length - 1) {
|
|
71
115
|
console.log(chalk.gray(' │'));
|
|
@@ -79,9 +123,9 @@ function displayDetailedLog(commits, currentCommitHash, currentBranch) {
|
|
|
79
123
|
*/
|
|
80
124
|
function displayOnelineLog(commits, currentCommitHash) {
|
|
81
125
|
commits.forEach(commit => {
|
|
82
|
-
const isHead = commit.
|
|
126
|
+
const isHead = commit.hash === currentCommitHash;
|
|
83
127
|
const headLabel = isHead ? chalk.yellow(' (HEAD)') : '';
|
|
84
|
-
const shortHash = chalk.yellow(commit.
|
|
128
|
+
const shortHash = chalk.yellow(commit.hash.substring(0, 7));
|
|
85
129
|
const message = chalk.white(commit.message);
|
|
86
130
|
const timeAgo = chalk.gray(`(${formatDistanceToNow(new Date(commit.timestamp), { addSuffix: true })})`);
|
|
87
131
|
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge Command - Merge a branch into the current branch
|
|
3
|
+
* Uses 3-way smart merge with automatic conflict resolution
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs').promises;
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const chalk = require('chalk');
|
|
9
|
+
const ora = require('ora');
|
|
10
|
+
const { getGentPath, readJSON, writeJSON, pathExists } = require('../utils/fileSystem');
|
|
11
|
+
const { COMMITS_FILE, STAGING_FILE, CONFIG_FILE } = require('../utils/constants');
|
|
12
|
+
const { generateCommitHash } = require('../utils/helpers');
|
|
13
|
+
const authStorage = require('../utils/auth-storage');
|
|
14
|
+
const { findMergeBase, mergeTreeEntries, autoMerge } = require('../utils/merge-engine');
|
|
15
|
+
const { storeTree, readBlobAsString, storeBlob } = require('../utils/hash-engine');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Merge a branch into the current branch
|
|
19
|
+
* @param {String} sourceBranch - Branch to merge from
|
|
20
|
+
* @param {Object} options - Command options
|
|
21
|
+
*/
|
|
22
|
+
async function merge(sourceBranch, options) {
|
|
23
|
+
const spinner = ora(`Merging '${sourceBranch}'...`).start();
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const gentPath = await getGentPath();
|
|
27
|
+
const cwd = process.cwd();
|
|
28
|
+
const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
29
|
+
const commits = repository.commits || [];
|
|
30
|
+
const branches = repository.branches || {};
|
|
31
|
+
const currentBranch = repository.currentBranch;
|
|
32
|
+
|
|
33
|
+
// Validate branches
|
|
34
|
+
if (!branches.hasOwnProperty(sourceBranch)) {
|
|
35
|
+
spinner.fail(chalk.red(`Branch '${sourceBranch}' not found`));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (sourceBranch === currentBranch) {
|
|
40
|
+
spinner.fail(chalk.red('Cannot merge a branch into itself'));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const oursHash = branches[currentBranch];
|
|
45
|
+
const theirsHash = branches[sourceBranch];
|
|
46
|
+
|
|
47
|
+
if (!oursHash) {
|
|
48
|
+
spinner.fail(chalk.red(`Current branch '${currentBranch}' has no commits`));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!theirsHash) {
|
|
53
|
+
spinner.fail(chalk.red(`Branch '${sourceBranch}' has no commits`));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Fast-forward check: if ours is ancestor of theirs
|
|
58
|
+
if (oursHash === theirsHash) {
|
|
59
|
+
spinner.succeed(chalk.green('Already up to date'));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Find merge base (common ancestor)
|
|
64
|
+
const baseHash = findMergeBase(commits, oursHash, theirsHash);
|
|
65
|
+
|
|
66
|
+
// Fast-forward: current branch is merge base → just move pointer
|
|
67
|
+
if (baseHash === oursHash) {
|
|
68
|
+
spinner.text = 'Fast-forward merge...';
|
|
69
|
+
repository.branches[currentBranch] = theirsHash;
|
|
70
|
+
await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
|
|
71
|
+
|
|
72
|
+
// Restore working tree from theirs commit
|
|
73
|
+
const theirsCommit = commits.find(c => c.hash === theirsHash);
|
|
74
|
+
if (theirsCommit) {
|
|
75
|
+
await restoreWorkingTree(gentPath, cwd, theirsCommit);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
spinner.succeed(chalk.green(`Fast-forward merge: ${currentBranch} → ${theirsHash.substring(0, 7)}`));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3-way merge
|
|
83
|
+
spinner.text = 'Computing 3-way merge...';
|
|
84
|
+
|
|
85
|
+
const oursCommit = commits.find(c => c.hash === oursHash);
|
|
86
|
+
const theirsCommit = commits.find(c => c.hash === theirsHash);
|
|
87
|
+
const baseCommit = baseHash ? commits.find(c => c.hash === baseHash) : null;
|
|
88
|
+
|
|
89
|
+
// Extract tree entries from commits
|
|
90
|
+
const getTree = (commit) => {
|
|
91
|
+
if (!commit) return [];
|
|
92
|
+
if (commit.tree && Array.isArray(commit.tree)) return commit.tree;
|
|
93
|
+
if (commit.files) return commit.files.map(f => ({
|
|
94
|
+
mode: '100644', name: f.path || f.name, hash: f.hash, type: 'blob'
|
|
95
|
+
}));
|
|
96
|
+
return [];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const baseTree = getTree(baseCommit);
|
|
100
|
+
const oursTree = getTree(oursCommit);
|
|
101
|
+
const theirsTree = getTree(theirsCommit);
|
|
102
|
+
|
|
103
|
+
// Perform tree-level merge
|
|
104
|
+
const mergeResult = await mergeTreeEntries(gentPath, baseTree, oursTree, theirsTree);
|
|
105
|
+
|
|
106
|
+
if (mergeResult.hasConflicts) {
|
|
107
|
+
spinner.warn(chalk.yellow(`Merged with ${mergeResult.conflicts.length} conflict(s)`));
|
|
108
|
+
console.log('');
|
|
109
|
+
|
|
110
|
+
for (const conflict of mergeResult.conflicts) {
|
|
111
|
+
if (conflict.type === 'content') {
|
|
112
|
+
console.log(chalk.red(` CONFLICT (content): ${conflict.file}`));
|
|
113
|
+
console.log(chalk.gray(` ${conflict.details.length} conflicting region(s) — markers inserted`));
|
|
114
|
+
} else if (conflict.type === 'modify-delete') {
|
|
115
|
+
console.log(chalk.yellow(` CONFLICT (modify/delete): ${conflict.file}`));
|
|
116
|
+
console.log(chalk.gray(` Deleted by ${conflict.deletedBy}, modified by ${conflict.modifiedBy} — kept modified version`));
|
|
117
|
+
} else if (conflict.type === 'add-add') {
|
|
118
|
+
console.log(chalk.yellow(` CONFLICT (add/add): ${conflict.file}`));
|
|
119
|
+
console.log(chalk.gray(` Both branches added differently — markers inserted`));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(chalk.yellow('\nConflict markers: <<<<<<< ours / ======= / >>>>>>> theirs'));
|
|
124
|
+
console.log(chalk.cyan('Resolve conflicts, then run "gent add" and "gent commit"'));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Store merged tree
|
|
128
|
+
const mergedTreeHash = await storeTree(gentPath, mergeResult.mergedEntries);
|
|
129
|
+
|
|
130
|
+
// Write merged files to working directory
|
|
131
|
+
await writeTreeToWorkDir(gentPath, cwd, mergeResult.mergedEntries);
|
|
132
|
+
|
|
133
|
+
// If no conflicts, create merge commit automatically
|
|
134
|
+
if (!mergeResult.hasConflicts) {
|
|
135
|
+
// Resolve author
|
|
136
|
+
const config = await readJSON(path.join(gentPath, CONFIG_FILE));
|
|
137
|
+
let authorName = config.user.name;
|
|
138
|
+
let authorEmail = config.user.email;
|
|
139
|
+
|
|
140
|
+
if (!authorName || !authorEmail) {
|
|
141
|
+
const globalUser = await authStorage.getUser();
|
|
142
|
+
if (globalUser) {
|
|
143
|
+
if (!authorName) authorName = [globalUser.first_name, globalUser.last_name].filter(Boolean).join(' ');
|
|
144
|
+
if (!authorEmail) authorEmail = globalUser.email;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const mergeCommit = {
|
|
149
|
+
hash: generateCommitHash(),
|
|
150
|
+
message: options.message || `Merge branch '${sourceBranch}' into ${currentBranch}`,
|
|
151
|
+
author: {
|
|
152
|
+
name: authorName || 'Unknown',
|
|
153
|
+
email: authorEmail || 'unknown@gent'
|
|
154
|
+
},
|
|
155
|
+
timestamp: new Date().toISOString(),
|
|
156
|
+
parent: oursHash,
|
|
157
|
+
mergeParent: theirsHash,
|
|
158
|
+
treeHash: mergedTreeHash,
|
|
159
|
+
tree: mergeResult.mergedEntries,
|
|
160
|
+
files: mergeResult.mergedEntries.map(e => ({ path: e.name, hash: e.hash })),
|
|
161
|
+
stats: {
|
|
162
|
+
filesChanged: mergeResult.mergedEntries.length,
|
|
163
|
+
insertions: 0,
|
|
164
|
+
deletions: 0
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
repository.commits.push(mergeCommit);
|
|
169
|
+
repository.branches[currentBranch] = mergeCommit.hash;
|
|
170
|
+
await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
|
|
171
|
+
|
|
172
|
+
// Clear staging
|
|
173
|
+
const staging = await readJSON(path.join(gentPath, STAGING_FILE));
|
|
174
|
+
staging.entries = [];
|
|
175
|
+
staging.files = [];
|
|
176
|
+
await writeJSON(path.join(gentPath, STAGING_FILE), staging);
|
|
177
|
+
|
|
178
|
+
spinner.succeed(chalk.green(`Merged '${sourceBranch}' into '${currentBranch}' — ${mergeCommit.hash.substring(0, 7)}`));
|
|
179
|
+
|
|
180
|
+
const autoResolved = mergeResult.mergedEntries.length;
|
|
181
|
+
console.log(chalk.gray(`\n Base: ${baseHash ? baseHash.substring(0, 7) : 'none'}`));
|
|
182
|
+
console.log(chalk.gray(` Ours: ${oursHash.substring(0, 7)} Theirs: ${theirsHash.substring(0, 7)}`));
|
|
183
|
+
console.log(chalk.green(` ${autoResolved} file(s) merged automatically`));
|
|
184
|
+
} else {
|
|
185
|
+
// Stage the merge state for manual resolution
|
|
186
|
+
const staging = await readJSON(path.join(gentPath, STAGING_FILE));
|
|
187
|
+
staging.mergeState = {
|
|
188
|
+
sourceBranch,
|
|
189
|
+
oursHash,
|
|
190
|
+
theirsHash,
|
|
191
|
+
baseHash,
|
|
192
|
+
mergedTreeHash,
|
|
193
|
+
mergedEntries: mergeResult.mergedEntries,
|
|
194
|
+
conflicts: mergeResult.conflicts
|
|
195
|
+
};
|
|
196
|
+
await writeJSON(path.join(gentPath, STAGING_FILE), staging);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
} catch (error) {
|
|
200
|
+
spinner.fail(chalk.red('Merge failed'));
|
|
201
|
+
if (error.code === 'ENOENT' && error.message.includes('.gent')) {
|
|
202
|
+
console.error(chalk.red('\nError: Not a gent repository'));
|
|
203
|
+
console.log(chalk.yellow('Run "gent init" to initialize a repository'));
|
|
204
|
+
} else {
|
|
205
|
+
console.error(chalk.red('\nError:'), error.message);
|
|
206
|
+
}
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Write tree entries to working directory.
|
|
213
|
+
* @param {String} gentPath
|
|
214
|
+
* @param {String} cwd
|
|
215
|
+
* @param {Array} entries
|
|
216
|
+
*/
|
|
217
|
+
async function writeTreeToWorkDir(gentPath, cwd, entries) {
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
const fullPath = path.join(cwd, entry.name);
|
|
220
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
221
|
+
|
|
222
|
+
const content = await readBlobAsString(gentPath, entry.hash);
|
|
223
|
+
await fs.writeFile(fullPath, content, 'utf-8');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Restore working tree from a commit's tree entries.
|
|
229
|
+
* @param {String} gentPath
|
|
230
|
+
* @param {String} cwd
|
|
231
|
+
* @param {Object} commit
|
|
232
|
+
*/
|
|
233
|
+
async function restoreWorkingTree(gentPath, cwd, commit) {
|
|
234
|
+
const tree = commit.tree || (commit.files || []).map(f => ({
|
|
235
|
+
mode: '100644', name: f.path || f.name, hash: f.hash, type: 'blob'
|
|
236
|
+
}));
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
await writeTreeToWorkDir(gentPath, cwd, tree);
|
|
240
|
+
} catch {
|
|
241
|
+
// Best-effort restore — blobs may not exist for legacy commits
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = merge;
|