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.
@@ -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;
@@ -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);
@@ -1,6 +1,25 @@
1
1
  /**
2
- * Log Command - Show commit logs
3
- * Displays commit history with details
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
- const commitsToShow = commits.slice(-limit).reverse();
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(commitsToShow, repository.branches[currentBranch]);
64
+ displayOnelineLog(ordered, headHash);
36
65
  } else {
37
- displayDetailedLog(commitsToShow, repository.branches[currentBranch], currentBranch);
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} branch):\n`));
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.sha === currentCommitHash;
87
+ const isHead = commit.hash === currentCommitHash;
59
88
  const headLabel = isHead ? chalk.yellow.bold(' (HEAD)') : '';
60
89
 
61
- console.log(chalk.yellow(`commit ${commit.sha}`) + headLabel);
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
- console.log(chalk.gray(` ${commit.files.length} file(s) changed`));
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.sha === currentCommitHash;
126
+ const isHead = commit.hash === currentCommitHash;
83
127
  const headLabel = isHead ? chalk.yellow(' (HEAD)') : '';
84
- const shortHash = chalk.yellow(commit.sha.substring(0, 7));
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;