gent-cli 20.0.0 → 22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gent-cli",
3
- "version": "20.0.0",
3
+ "version": "22.0.0",
4
4
  "description": "A modern, Git-like version control CLI with cloud sync, AI-powered superpowers (ask/review/docs/changelog), and zero-friction setup (gent setup/doctor/config).",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -155,11 +155,15 @@ const handlers = {
155
155
  if (options.abort) return merge.abortMerge(repo);
156
156
  if (options.continue) return merge.concludeMerge(repo, options.message);
157
157
  const result = await merge.merge(repo, branch, options);
158
- console.log(result.status);
159
158
  if (result.status === 'conflicts') {
159
+ for (const conflict of result.conflicts) {
160
+ if (conflict.kind === 'content') console.log(`Auto-merging ${conflict.path}`);
161
+ console.log(`CONFLICT (${conflict.kind}): Merge conflict in ${conflict.path}`);
162
+ }
163
+ console.log('Automatic merge failed; fix conflicts and then commit the result.');
160
164
  console.log('Resolve files, stage with gent add, then gent merge --continue or gent commit -m <message>.');
161
165
  process.exitCode = 1;
162
- }
166
+ } else console.log(result.status);
163
167
  },
164
168
  async resolve(repo) {
165
169
  const index = await GitIndex.read(repo.indexPath);
@@ -3,12 +3,77 @@
3
3
  * Changes the current working branch
4
4
  */
5
5
 
6
+ const fs = require('fs').promises;
6
7
  const path = require('path');
7
8
  const chalk = require('chalk');
8
9
  const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
9
- const { COMMITS_FILE } = require('../utils/constants');
10
+ const { COMMITS_FILE, STAGING_FILE } = require('../utils/constants');
11
+ const { hashBlob, readBlob } = require('../utils/hash-engine');
10
12
  const journal = require('../utils/journal');
11
13
 
14
+ function treeOf(repository, commitHash) {
15
+ if (!commitHash) return [];
16
+ const commit = (repository.commits || []).find(item => item.hash === commitHash);
17
+ if (!commit) throw new Error(`Commit '${commitHash}' not found`);
18
+ return commit.tree || (commit.files || []).map(file => ({
19
+ mode: '100644', name: file.path || file.name, hash: file.hash, type: 'blob'
20
+ }));
21
+ }
22
+
23
+ function safePath(cwd, relativePath) {
24
+ if (!relativePath || path.isAbsolute(relativePath) || relativePath.split(/[\\/]/).includes('..')) {
25
+ throw new Error(`Unsafe repository path '${relativePath}'`);
26
+ }
27
+ const fullPath = path.resolve(cwd, relativePath);
28
+ if (fullPath !== cwd && !fullPath.startsWith(cwd + path.sep)) {
29
+ throw new Error(`Unsafe repository path '${relativePath}'`);
30
+ }
31
+ return fullPath;
32
+ }
33
+
34
+ async function switchWorkingTree(gentPath, cwd, repository, currentHash, targetHash) {
35
+ const staging = await readJSON(path.join(gentPath, STAGING_FILE));
36
+ if ((staging.entries || []).length || (staging.files || []).length || staging.mergeState) {
37
+ throw new Error('Commit or stash staged changes before switching branches');
38
+ }
39
+
40
+ const currentTree = new Map(treeOf(repository, currentHash).map(entry => [entry.name || entry.path, entry]));
41
+ const targetTree = new Map(treeOf(repository, targetHash).map(entry => [entry.name || entry.path, entry]));
42
+ const changedPaths = [...new Set([...currentTree.keys(), ...targetTree.keys()])]
43
+ .filter(name => currentTree.get(name)?.hash !== targetTree.get(name)?.hash);
44
+ const writes = new Map();
45
+
46
+ // Read and validate every affected path before changing any file.
47
+ for (const name of changedPaths) {
48
+ const fullPath = safePath(cwd, name);
49
+ const current = currentTree.get(name);
50
+ const target = targetTree.get(name);
51
+ const stat = await fs.lstat(fullPath).catch(() => null);
52
+
53
+ if (current) {
54
+ if (!stat || !stat.isFile()) throw new Error(`Local changes would be overwritten by checkout: ${name}`);
55
+ const bytes = await fs.readFile(fullPath);
56
+ if (hashBlob(bytes) !== current.hash) {
57
+ throw new Error(`Local changes would be overwritten by checkout: ${name}`);
58
+ }
59
+ } else if (target && stat) {
60
+ throw new Error(`Untracked file would be overwritten by checkout: ${name}`);
61
+ }
62
+
63
+ if (target) writes.set(name, await readBlob(gentPath, target.hash));
64
+ }
65
+
66
+ for (const name of changedPaths) {
67
+ if (targetTree.has(name)) continue;
68
+ await fs.unlink(safePath(cwd, name));
69
+ }
70
+ for (const [name, bytes] of writes) {
71
+ const fullPath = safePath(cwd, name);
72
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
73
+ await fs.writeFile(fullPath, bytes);
74
+ }
75
+ }
76
+
12
77
  /**
13
78
  * Switch to a different branch
14
79
  * @param {String} branch - Branch name
@@ -55,7 +120,11 @@ async function checkout(branch, options) {
55
120
  return;
56
121
  }
57
122
 
58
- await journal.recordOp(gentPath, 'checkout', `switch to branch '${branch}'`);
123
+ const cwd = path.dirname(gentPath);
124
+ const currentCommit = branches[repository.currentBranch] || null;
125
+ const targetCommit = branches[branch] || null;
126
+ await switchWorkingTree(gentPath, cwd, repository, currentCommit, targetCommit);
127
+ await journal.recordOp(gentPath, 'checkout', `switch to branch '${branch}'`, { restoreTree: true });
59
128
 
60
129
  repository.currentBranch = branch;
61
130
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
@@ -76,6 +76,10 @@ async function commit(options) {
76
76
 
77
77
  const config = await readJSON(path.join(gentPath, CONFIG_FILE));
78
78
  const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
79
+ const mergeState = staging.mergeState || null;
80
+ if (mergeState && repository.branches[repository.currentBranch] !== mergeState.oursHash) {
81
+ throw new Error('Current branch changed after the merge started; abort and retry the merge');
82
+ }
79
83
 
80
84
  // Resolve author identity
81
85
  let authorName = config.user.name;
@@ -105,13 +109,17 @@ async function commit(options) {
105
109
  if (stagedEntries.length > 0) {
106
110
  // New format: entries already have blob hashes from gent add
107
111
  // Carry forward unchanged files from parent commit
108
- const parentHash = repository.branches[repository.currentBranch] || null;
112
+ const parentHash = mergeState
113
+ ? mergeState.oursHash
114
+ : repository.branches[repository.currentBranch] || null;
109
115
  const parentCommit = parentHash
110
116
  ? (repository.commits || []).find(c => c.hash === parentHash)
111
117
  : null;
112
- const parentTree = parentCommit && parentCommit.tree
113
- ? parentCommit.tree
114
- : (parentCommit ? parentCommit.files.map(f => ({ mode: '100644', name: f.path, hash: f.hash, type: 'blob' })) : []);
118
+ const parentTree = mergeState
119
+ ? (mergeState.mergedEntries || [])
120
+ : parentCommit && parentCommit.tree
121
+ ? parentCommit.tree
122
+ : (parentCommit ? parentCommit.files.map(f => ({ mode: '100644', name: f.path, hash: f.hash, type: 'blob' })) : []);
115
123
 
116
124
  // Start from parent tree, overlay staged changes
117
125
  const treeMap = new Map(parentTree.map(e => [e.name, e]));
@@ -162,7 +170,8 @@ async function commit(options) {
162
170
  email: authorEmail
163
171
  },
164
172
  timestamp: new Date().toISOString(),
165
- parent: repository.branches[repository.currentBranch] || null,
173
+ parent: mergeState ? mergeState.oursHash : repository.branches[repository.currentBranch] || null,
174
+ ...(mergeState ? { mergeParent: mergeState.theirsHash } : {}),
166
175
  treeHash,
167
176
  tree: treeEntries,
168
177
  files: treeEntries.map(e => ({ path: e.name, hash: e.hash })), // backward compat
@@ -185,6 +194,7 @@ async function commit(options) {
185
194
  // Clear staging
186
195
  staging.entries = [];
187
196
  staging.files = [];
197
+ staging.mergeState = null;
188
198
  await writeJSON(path.join(gentPath, STAGING_FILE), staging);
189
199
 
190
200
  spinner.succeed(chalk.green('Changes committed successfully!'));
@@ -12,7 +12,7 @@ const { COMMITS_FILE, STAGING_FILE, CONFIG_FILE } = require('../utils/constants'
12
12
  const { generateCommitHash } = require('../utils/helpers');
13
13
  const authStorage = require('../utils/auth-storage');
14
14
  const { findMergeBase, mergeTreeEntries, autoMerge } = require('../utils/merge-engine');
15
- const { storeTree, readBlobAsString, storeBlob } = require('../utils/hash-engine');
15
+ const { storeTree, readBlob, hashBlob } = require('../utils/hash-engine');
16
16
  const pet = require('./pet');
17
17
  const journal = require('../utils/journal');
18
18
 
@@ -26,7 +26,7 @@ async function merge(sourceBranch, options) {
26
26
 
27
27
  try {
28
28
  const gentPath = await getGentPath();
29
- const cwd = process.cwd();
29
+ const cwd = path.dirname(gentPath);
30
30
  const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
31
31
  const commits = repository.commits || [];
32
32
  const branches = repository.branches || {};
@@ -35,11 +35,12 @@ async function merge(sourceBranch, options) {
35
35
  // Validate branches
36
36
  if (!branches.hasOwnProperty(sourceBranch)) {
37
37
  spinner.fail(chalk.red(`Branch '${sourceBranch}' not found`));
38
+ process.exitCode = 1;
38
39
  return;
39
40
  }
40
41
 
41
42
  if (sourceBranch === currentBranch) {
42
- spinner.fail(chalk.red('Cannot merge a branch into itself'));
43
+ spinner.succeed(chalk.green('Already up to date'));
43
44
  return;
44
45
  }
45
46
 
@@ -48,14 +49,19 @@ async function merge(sourceBranch, options) {
48
49
 
49
50
  if (!oursHash) {
50
51
  spinner.fail(chalk.red(`Current branch '${currentBranch}' has no commits`));
52
+ process.exitCode = 1;
51
53
  return;
52
54
  }
53
55
 
54
56
  if (!theirsHash) {
55
57
  spinner.fail(chalk.red(`Branch '${sourceBranch}' has no commits`));
58
+ process.exitCode = 1;
56
59
  return;
57
60
  }
58
61
 
62
+ const oursCommit = commits.find(c => c.hash === oursHash);
63
+ const theirsCommit = commits.find(c => c.hash === theirsHash);
64
+
59
65
  // Fast-forward check: if ours is ancestor of theirs
60
66
  if (oursHash === theirsHash) {
61
67
  spinner.succeed(chalk.green('Already up to date'));
@@ -65,19 +71,28 @@ async function merge(sourceBranch, options) {
65
71
  // Find merge base (common ancestor)
66
72
  const baseHash = findMergeBase(commits, oursHash, theirsHash);
67
73
 
74
+ // The incoming branch is already contained in the current branch.
75
+ if (baseHash === theirsHash) {
76
+ spinner.succeed(chalk.green('Already up to date'));
77
+ return;
78
+ }
79
+
80
+ if (!baseHash) {
81
+ spinner.fail(chalk.red('Refusing to merge unrelated histories'));
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+
86
+ await assertCleanWorkingTree(gentPath, cwd, oursCommit);
87
+
68
88
  // Fast-forward: current branch is merge base → just move pointer
69
89
  if (baseHash === oursHash) {
70
90
  spinner.text = 'Fast-forward merge...';
71
91
  await journal.recordOp(gentPath, 'merge', `fast-forward '${sourceBranch}' into ${currentBranch}`, { restoreTree: true });
92
+ await checkoutTree(gentPath, cwd, treeOf(oursCommit), treeOf(theirsCommit));
72
93
  repository.branches[currentBranch] = theirsHash;
73
94
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
74
95
 
75
- // Restore working tree from theirs commit
76
- const theirsCommit = commits.find(c => c.hash === theirsHash);
77
- if (theirsCommit) {
78
- await restoreWorkingTree(gentPath, cwd, theirsCommit);
79
- }
80
-
81
96
  spinner.succeed(chalk.green(`Fast-forward merge: ${currentBranch} → ${theirsHash.substring(0, 7)}`));
82
97
  await pet.celebrate('merge');
83
98
  return;
@@ -86,26 +101,21 @@ async function merge(sourceBranch, options) {
86
101
  // 3-way merge
87
102
  spinner.text = 'Computing 3-way merge...';
88
103
 
89
- const oursCommit = commits.find(c => c.hash === oursHash);
90
- const theirsCommit = commits.find(c => c.hash === theirsHash);
91
104
  const baseCommit = baseHash ? commits.find(c => c.hash === baseHash) : null;
92
105
 
93
106
  // Extract tree entries from commits
94
- const getTree = (commit) => {
95
- if (!commit) return [];
96
- if (commit.tree && Array.isArray(commit.tree)) return commit.tree;
97
- if (commit.files) return commit.files.map(f => ({
98
- mode: '100644', name: f.path || f.name, hash: f.hash, type: 'blob'
99
- }));
100
- return [];
101
- };
102
-
103
- const baseTree = getTree(baseCommit);
104
- const oursTree = getTree(oursCommit);
105
- const theirsTree = getTree(theirsCommit);
107
+ const baseTree = treeOf(baseCommit);
108
+ const oursTree = treeOf(oursCommit);
109
+ const theirsTree = treeOf(theirsCommit);
106
110
 
107
111
  // Perform tree-level merge
108
- const mergeResult = await mergeTreeEntries(gentPath, baseTree, oursTree, theirsTree);
112
+ const mergeResult = await mergeTreeEntries(
113
+ gentPath,
114
+ baseTree,
115
+ oursTree,
116
+ theirsTree,
117
+ { ours: 'HEAD', theirs: sourceBranch }
118
+ );
109
119
 
110
120
  if (mergeResult.hasConflicts) {
111
121
  spinner.warn(chalk.yellow(`Merged with ${mergeResult.conflicts.length} conflict(s)`));
@@ -124,7 +134,7 @@ async function merge(sourceBranch, options) {
124
134
  }
125
135
  }
126
136
 
127
- console.log(chalk.yellow('\nConflict markers: <<<<<<< ours / ======= / >>>>>>> theirs'));
137
+ console.log(chalk.yellow(`\nConflict markers: <<<<<<< HEAD / ======= / >>>>>>> ${sourceBranch}`));
128
138
  console.log(chalk.cyan('Resolve conflicts, then run "gent add" and "gent commit"'));
129
139
  }
130
140
 
@@ -132,7 +142,7 @@ async function merge(sourceBranch, options) {
132
142
  const mergedTreeHash = await storeTree(gentPath, mergeResult.mergedEntries);
133
143
 
134
144
  // Write merged files to working directory
135
- await writeTreeToWorkDir(gentPath, cwd, mergeResult.mergedEntries);
145
+ await checkoutTree(gentPath, cwd, oursTree, mergeResult.mergedEntries);
136
146
 
137
147
  // If no conflicts, create merge commit automatically
138
148
  if (!mergeResult.hasConflicts) {
@@ -148,6 +158,7 @@ async function merge(sourceBranch, options) {
148
158
  if (!authorEmail) authorEmail = globalUser.email;
149
159
  }
150
160
  }
161
+ if (!authorName && authorEmail) authorName = authorEmail;
151
162
 
152
163
  const mergeCommit = {
153
164
  hash: generateCommitHash(),
@@ -201,6 +212,7 @@ async function merge(sourceBranch, options) {
201
212
  conflicts: mergeResult.conflicts
202
213
  };
203
214
  await writeJSON(path.join(gentPath, STAGING_FILE), staging);
215
+ process.exitCode = 1;
204
216
  }
205
217
 
206
218
  } catch (error) {
@@ -221,31 +233,58 @@ async function merge(sourceBranch, options) {
221
233
  * @param {String} cwd
222
234
  * @param {Array} entries
223
235
  */
224
- async function writeTreeToWorkDir(gentPath, cwd, entries) {
225
- for (const entry of entries) {
226
- const fullPath = path.join(cwd, entry.name);
227
- await fs.mkdir(path.dirname(fullPath), { recursive: true });
236
+ function treeOf(commit) {
237
+ if (!commit) return [];
238
+ if (Array.isArray(commit.tree)) return commit.tree;
239
+ return (commit.files || []).map(file => ({
240
+ mode: '100644', name: file.path || file.name, hash: file.hash, type: 'blob'
241
+ }));
242
+ }
228
243
 
229
- const content = await readBlobAsString(gentPath, entry.hash);
230
- await fs.writeFile(fullPath, content, 'utf-8');
244
+ function safePath(cwd, relativePath) {
245
+ if (!relativePath || path.isAbsolute(relativePath) || relativePath.split(/[\\/]/).includes('..')) {
246
+ throw new Error(`Unsafe repository path '${relativePath}'`);
247
+ }
248
+ const fullPath = path.resolve(cwd, relativePath);
249
+ if (fullPath !== cwd && !fullPath.startsWith(cwd + path.sep)) {
250
+ throw new Error(`Unsafe repository path '${relativePath}'`);
231
251
  }
252
+ return fullPath;
232
253
  }
233
254
 
234
- /**
235
- * Restore working tree from a commit's tree entries.
236
- * @param {String} gentPath
237
- * @param {String} cwd
238
- * @param {Object} commit
239
- */
240
- async function restoreWorkingTree(gentPath, cwd, commit) {
241
- const tree = commit.tree || (commit.files || []).map(f => ({
242
- mode: '100644', name: f.path || f.name, hash: f.hash, type: 'blob'
243
- }));
255
+ async function assertCleanWorkingTree(gentPath, cwd, commit) {
256
+ const staging = await readJSON(path.join(gentPath, STAGING_FILE));
257
+ if ((staging.entries || []).length || (staging.files || []).length || staging.mergeState) {
258
+ throw new Error('Commit or stash staged changes before merging');
259
+ }
260
+ for (const entry of treeOf(commit)) {
261
+ const fullPath = safePath(cwd, entry.name || entry.path);
262
+ const stat = await fs.lstat(fullPath).catch(() => null);
263
+ if (!stat || !stat.isFile() || hashBlob(await fs.readFile(fullPath)) !== entry.hash) {
264
+ throw new Error(`Local changes would be overwritten by merge: ${entry.name || entry.path}`);
265
+ }
266
+ }
267
+ }
244
268
 
245
- try {
246
- await writeTreeToWorkDir(gentPath, cwd, tree);
247
- } catch {
248
- // Best-effort restore blobs may not exist for legacy commits
269
+ async function checkoutTree(gentPath, cwd, previousEntries, nextEntries) {
270
+ const previous = new Map(previousEntries.map(entry => [entry.name || entry.path, entry]));
271
+ const next = new Map(nextEntries.map(entry => [entry.name || entry.path, entry]));
272
+ const writes = new Map();
273
+
274
+ for (const [name, entry] of next) {
275
+ const fullPath = safePath(cwd, name);
276
+ if (!previous.has(name) && await fs.lstat(fullPath).catch(() => null)) {
277
+ throw new Error(`Untracked file would be overwritten by merge: ${name}`);
278
+ }
279
+ writes.set(name, await readBlob(gentPath, entry.hash));
280
+ }
281
+ for (const name of previous.keys()) {
282
+ if (!next.has(name)) await fs.unlink(safePath(cwd, name));
283
+ }
284
+ for (const [name, bytes] of writes) {
285
+ const fullPath = safePath(cwd, name);
286
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
287
+ await fs.writeFile(fullPath, bytes);
249
288
  }
250
289
  }
251
290
 
@@ -24,10 +24,10 @@ const path = require('path');
24
24
  const chalk = require('chalk');
25
25
  const ora = require('ora');
26
26
  const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
27
- const { COMMITS_FILE, CONFIG_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
27
+ const { COMMITS_FILE, CONFIG_FILE, STAGING_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
28
28
  const apiClient = require('../utils/api-client');
29
29
  const authStorage = require('../utils/auth-storage');
30
- const { storeBlob, readBlob } = require('../utils/hash-engine');
30
+ const { storeBlob, readBlob, hashBlob } = require('../utils/hash-engine');
31
31
  const { findMergeBase, mergeTreeEntries } = require('../utils/merge-engine');
32
32
  const pet = require('./pet');
33
33
  const { generateCommitHash } = require('../utils/helpers');
@@ -68,8 +68,12 @@ async function pull(remoteName, branchName, options) {
68
68
  }
69
69
 
70
70
  const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
71
- const branch = branchName || repository.currentBranch;
72
- const localHead = repository.branches[branch] || null;
71
+ const currentBranch = repository.currentBranch;
72
+ const branch = branchName || currentBranch;
73
+ const localHead = repository.branches[currentBranch] || null;
74
+ const cwd = path.dirname(gentPath);
75
+
76
+ await assertCleanWorkingTree(gentPath, cwd, getCommitTree(repository.commits || [], localHead));
73
77
 
74
78
  // 1. Fetch commits + objects for this branch in a single call. `since`
75
79
  // lets the server send only what we don't have on a fast-forward.
@@ -130,9 +134,9 @@ async function pull(remoteName, branchName, options) {
130
134
  // Fast-forward
131
135
  const previousTree = localHead ? getCommitTree(repository.commits, localHead) : [];
132
136
  const nextTree = getCommitTree(repository.commits, remoteHead);
133
- repository.branches[branch] = remoteHead;
137
+ await checkoutTree(gentPath, cwd, previousTree, nextTree);
138
+ repository.branches[currentBranch] = remoteHead;
134
139
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
135
- await checkoutTree(gentPath, process.cwd(), previousTree, nextTree);
136
140
 
137
141
  config.remoteRefs[`${remote}/${branch}`] = remoteHead;
138
142
  await writeJSON(path.join(gentPath, CONFIG_FILE), config);
@@ -156,12 +160,45 @@ async function pull(remoteName, branchName, options) {
156
160
  const oursTree = getTree(localHead);
157
161
  const theirsTree = getTree(remoteHead);
158
162
 
159
- const mergeResult = await mergeTreeEntries(gentPath, baseTree, oursTree, theirsTree);
163
+ const mergeResult = await mergeTreeEntries(
164
+ gentPath,
165
+ baseTree,
166
+ oursTree,
167
+ theirsTree,
168
+ { ours: 'HEAD', theirs: `${remote}/${branch}` }
169
+ );
160
170
 
161
171
  // Build merge commit
162
172
  const { storeTree } = require('../utils/hash-engine');
163
173
  const mergedTreeHash = await storeTree(gentPath, mergeResult.mergedEntries);
164
174
 
175
+ if (mergeResult.hasConflicts) {
176
+ await checkoutTree(gentPath, cwd, oursTree, mergeResult.mergedEntries);
177
+ const staging = await readJSON(path.join(gentPath, STAGING_FILE));
178
+ staging.mergeState = {
179
+ sourceBranch: `${remote}/${branch}`,
180
+ oursHash: localHead,
181
+ theirsHash: remoteHead,
182
+ baseHash,
183
+ mergedTreeHash,
184
+ mergedEntries: mergeResult.mergedEntries,
185
+ conflicts: mergeResult.conflicts
186
+ };
187
+ await writeJSON(path.join(gentPath, STAGING_FILE), staging);
188
+ await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
189
+
190
+ config.remoteRefs[`${remote}/${branch}`] = remoteHead;
191
+ await writeJSON(path.join(gentPath, CONFIG_FILE), config);
192
+
193
+ spinner.warn(chalk.yellow(`Pulled with ${mergeResult.conflicts.length} conflict(s)`));
194
+ for (const c of mergeResult.conflicts) {
195
+ console.log(chalk.red(` CONFLICT: ${c.file} (${c.type})`));
196
+ }
197
+ console.log(chalk.yellow('\nResolve conflicts, then "gent add" + "gent commit"'));
198
+ process.exitCode = 1;
199
+ return;
200
+ }
201
+
165
202
  const mergeCommit = {
166
203
  hash: generateCommitHash(),
167
204
  message: `Merge remote-tracking branch '${remote}/${branch}'`,
@@ -176,25 +213,15 @@ async function pull(remoteName, branchName, options) {
176
213
  };
177
214
 
178
215
  repository.commits.push(mergeCommit);
179
- repository.branches[branch] = mergeCommit.hash;
216
+ repository.branches[currentBranch] = mergeCommit.hash;
180
217
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
181
- if (!mergeResult.hasConflicts) {
182
- await checkoutTree(gentPath, process.cwd(), oursTree, mergeResult.mergedEntries);
183
- }
218
+ await checkoutTree(gentPath, cwd, oursTree, mergeResult.mergedEntries);
184
219
 
185
220
  config.remoteRefs[`${remote}/${branch}`] = remoteHead;
186
221
  await writeJSON(path.join(gentPath, CONFIG_FILE), config);
187
222
 
188
- if (mergeResult.hasConflicts) {
189
- spinner.warn(chalk.yellow(`Pulled with ${mergeResult.conflicts.length} conflict(s)`));
190
- for (const c of mergeResult.conflicts) {
191
- console.log(chalk.red(` CONFLICT: ${c.file} (${c.type})`));
192
- }
193
- console.log(chalk.yellow('\nResolve conflicts, then "gent add" + "gent commit"'));
194
- } else {
195
- spinner.succeed(chalk.green(`Merged ${newCount} remote commit(s)`));
196
- console.log(chalk.gray(` Merge commit: ${mergeCommit.hash.substring(0, 7)}`));
197
- }
223
+ spinner.succeed(chalk.green(`Merged ${newCount} remote commit(s)`));
224
+ console.log(chalk.gray(` Merge commit: ${mergeCommit.hash.substring(0, 7)}`));
198
225
  }
199
226
  } catch (error) {
200
227
  spinner.fail(chalk.red('Pull failed'));
@@ -216,15 +243,46 @@ async function pull(remoteName, branchName, options) {
216
243
  */
217
244
  function isAncestor(commits, hashA, hashB) {
218
245
  const commitMap = new Map(commits.map(c => [c.hash, c]));
219
- let cur = hashB;
220
- while (cur) {
246
+ const pending = [hashB];
247
+ const seen = new Set();
248
+ while (pending.length) {
249
+ const cur = pending.pop();
250
+ if (!cur || seen.has(cur)) continue;
221
251
  if (cur === hashA) return true;
252
+ seen.add(cur);
222
253
  const c = commitMap.get(cur);
223
- cur = c ? c.parent : null;
254
+ if (c?.parent) pending.push(c.parent);
255
+ if (c?.mergeParent) pending.push(c.mergeParent);
224
256
  }
225
257
  return false;
226
258
  }
227
259
 
260
+ function safePath(cwd, relativePath) {
261
+ if (!relativePath || path.isAbsolute(relativePath) || relativePath.split(/[\\/]/).includes('..')) {
262
+ throw new Error(`Unsafe repository path '${relativePath}'`);
263
+ }
264
+ const fullPath = path.resolve(cwd, relativePath);
265
+ if (fullPath !== cwd && !fullPath.startsWith(cwd + path.sep)) {
266
+ throw new Error(`Unsafe repository path '${relativePath}'`);
267
+ }
268
+ return fullPath;
269
+ }
270
+
271
+ async function assertCleanWorkingTree(gentPath, cwd, tree) {
272
+ const staging = await readJSON(path.join(gentPath, STAGING_FILE));
273
+ if ((staging.entries || []).length || (staging.files || []).length || staging.mergeState) {
274
+ throw new Error('Commit or stash staged changes before pulling');
275
+ }
276
+ for (const entry of tree) {
277
+ const relPath = entry.name || entry.path;
278
+ const fullPath = safePath(cwd, relPath);
279
+ const stat = await fs.lstat(fullPath).catch(() => null);
280
+ if (!stat || !stat.isFile() || hashBlob(await fs.readFile(fullPath)) !== entry.hash) {
281
+ throw new Error(`Local changes would be overwritten by pull: ${relPath}`);
282
+ }
283
+ }
284
+ }
285
+
228
286
  function getCommitTree(commits, hash) {
229
287
  const commit = commits.find(c => c.hash === hash);
230
288
  if (!commit) return [];
@@ -237,26 +295,35 @@ function getCommitTree(commits, hash) {
237
295
  }
238
296
 
239
297
  async function checkoutTree(gentPath, cwd, previousTree, nextTree) {
298
+ const previousPaths = new Set(previousTree.map(e => e.name || e.path));
240
299
  const nextPaths = new Set(nextTree.map(e => e.name || e.path));
300
+ const writes = new Map();
301
+
302
+ // Validate collisions and read every required blob before mutating files.
303
+ for (const entry of nextTree) {
304
+ if (entry.type && entry.type !== 'blob') continue;
305
+ const relPath = entry.name || entry.path;
306
+ if (!relPath || !entry.hash) continue;
307
+ const fullPath = safePath(cwd, relPath);
308
+ if (!previousPaths.has(relPath) && await fs.lstat(fullPath).catch(() => null)) {
309
+ throw new Error(`Untracked file would be overwritten by pull: ${relPath}`);
310
+ }
311
+ writes.set(relPath, await readBlob(gentPath, entry.hash));
312
+ }
241
313
 
242
314
  for (const entry of previousTree) {
243
315
  const relPath = entry.name || entry.path;
244
316
  if (!relPath || nextPaths.has(relPath)) continue;
245
317
  try {
246
- await fs.unlink(path.join(cwd, relPath));
318
+ await fs.unlink(safePath(cwd, relPath));
247
319
  } catch {
248
320
  // File already absent.
249
321
  }
250
322
  }
251
323
 
252
- for (const entry of nextTree) {
253
- if (entry.type && entry.type !== 'blob') continue;
254
- const relPath = entry.name || entry.path;
255
- if (!relPath || !entry.hash) continue;
256
-
324
+ for (const [relPath, buf] of writes) {
257
325
  // Write the raw Buffer so binary blobs round-trip byte-exact.
258
- const buf = await readBlob(gentPath, entry.hash);
259
- const fullPath = path.join(cwd, relPath);
326
+ const fullPath = safePath(cwd, relPath);
260
327
  await fs.mkdir(path.dirname(fullPath), { recursive: true });
261
328
  await fs.writeFile(fullPath, buf);
262
329
  }
@@ -245,6 +245,7 @@ async function finalizeMerge(gentPath, staging, mergeState, entriesByName) {
245
245
  if (!authorEmail) authorEmail = globalUser.email;
246
246
  }
247
247
  }
248
+ if (!authorName && authorEmail) authorName = authorEmail;
248
249
 
249
250
  const mergedEntries = [...entriesByName.values()];
250
251
  const treeHash = await storeTree(gentPath, mergedEntries);
@@ -130,9 +130,10 @@ function matchBaseToOther(baseLines, otherLines) {
130
130
  * @param {String[]} baseLines
131
131
  * @param {String[]} oursLines
132
132
  * @param {String[]} theirsLines
133
+ * @param {{ours?: String, theirs?: String}} [labels]
133
134
  * @returns {{merged: String[], conflicts: Array, hasConflicts: Boolean}}
134
135
  */
135
- function threeWayMerge(baseLines, oursLines, theirsLines) {
136
+ function threeWayMerge(baseLines, oursLines, theirsLines, labels = {}) {
136
137
  const oMatch = matchBaseToOther(baseLines, oursLines);
137
138
  const tMatch = matchBaseToOther(baseLines, theirsLines);
138
139
 
@@ -157,7 +158,7 @@ function threeWayMerge(baseLines, oursLines, theirsLines) {
157
158
  const oursSeg = oursLines.slice(prevO, a.o);
158
159
  const theirsSeg = theirsLines.slice(prevT, a.t);
159
160
 
160
- resolveRegion(baseSeg, oursSeg, theirsSeg, merged, conflicts);
161
+ resolveRegion(baseSeg, oursSeg, theirsSeg, merged, conflicts, labels);
161
162
 
162
163
  // Emit the synchronized anchor line (skip the end sentinel).
163
164
  if (a.b < baseLines.length) {
@@ -180,8 +181,9 @@ function threeWayMerge(baseLines, oursLines, theirsLines) {
180
181
  * @param {String[]} theirsSeg
181
182
  * @param {String[]} merged - output accumulator (mutated)
182
183
  * @param {Array} conflicts - output accumulator (mutated)
184
+ * @param {{ours?: String, theirs?: String}} [labels]
183
185
  */
184
- function resolveRegion(baseSeg, oursSeg, theirsSeg, merged, conflicts) {
186
+ function resolveRegion(baseSeg, oursSeg, theirsSeg, merged, conflicts, labels = {}) {
185
187
  if (baseSeg.length === 0 && oursSeg.length === 0 && theirsSeg.length === 0) {
186
188
  return;
187
189
  }
@@ -213,11 +215,11 @@ function resolveRegion(baseSeg, oursSeg, theirsSeg, merged, conflicts) {
213
215
  oursContent: oursSeg,
214
216
  theirsContent: theirsSeg
215
217
  });
216
- merged.push('<<<<<<< ours');
218
+ merged.push(`<<<<<<< ${labels.ours || 'ours'}`);
217
219
  merged.push(...oursSeg);
218
220
  merged.push('=======');
219
221
  merged.push(...theirsSeg);
220
- merged.push('>>>>>>> theirs');
222
+ merged.push(`>>>>>>> ${labels.theirs || 'theirs'}`);
221
223
  }
222
224
 
223
225
  // ─── Sub-Merge (fine-grained) ───────────────────────────
@@ -390,9 +392,10 @@ function parseConflictMarkers(content) {
390
392
  * @param {String} oursContent
391
393
  * @param {String} theirsContent
392
394
  * @param {String} [fileName] - used to pick a language-aware strategy
395
+ * @param {{ours?: String, theirs?: String}} [labels] - conflict marker labels
393
396
  * @returns {{content: String, hasConflicts: Boolean, conflicts: Array}}
394
397
  */
395
- function mergeFileContent(baseContent, oursContent, theirsContent, fileName) {
398
+ function mergeFileContent(baseContent, oursContent, theirsContent, fileName, labels) {
396
399
  if (fileName && /\.json$/i.test(fileName)) {
397
400
  const jsonResult = mergeJsonContent(baseContent || '', oursContent || '', theirsContent || '');
398
401
  if (jsonResult) return jsonResult;
@@ -401,7 +404,7 @@ function mergeFileContent(baseContent, oursContent, theirsContent, fileName) {
401
404
  const base = splitLines(baseContent || '');
402
405
  const ours = splitLines(oursContent || '');
403
406
  const theirs = splitLines(theirsContent || '');
404
- const result = threeWayMerge(base, ours, theirs);
407
+ const result = threeWayMerge(base, ours, theirs, labels);
405
408
  return { content: result.merged.join('\n'), hasConflicts: result.hasConflicts, conflicts: result.conflicts };
406
409
  }
407
410
 
@@ -442,7 +445,7 @@ function autoMerge(baseText, oursText, theirsText) {
442
445
  * @param {Array} theirsEntries
443
446
  * @returns {Promise<{mergedEntries: Array, conflicts: Array, hasConflicts: Boolean}>}
444
447
  */
445
- async function mergeTreeEntries(gentPath, baseEntries, oursEntries, theirsEntries) {
448
+ async function mergeTreeEntries(gentPath, baseEntries, oursEntries, theirsEntries, labels) {
446
449
  const baseMap = treeToMap(baseEntries);
447
450
  const oursMap = treeToMap(oursEntries);
448
451
  const theirsMap = treeToMap(theirsEntries);
@@ -479,7 +482,7 @@ async function mergeTreeEntries(gentPath, baseEntries, oursEntries, theirsEntrie
479
482
  readBlobAsString(gentPath, oH),
480
483
  readBlobAsString(gentPath, tH)
481
484
  ]);
482
- const result = mergeFileContent(baseC, oursC, theirsC, filePath);
485
+ const result = mergeFileContent(baseC, oursC, theirsC, filePath, labels);
483
486
  const mergedHash = await storeBlob(gentPath, result.content);
484
487
  mergedEntries.push({ mode: '100644', name: filePath, hash: mergedHash, type: 'blob' });
485
488
  if (result.hasConflicts) conflicts.push({ file: filePath, type: 'content', details: result.conflicts });
@@ -504,7 +507,7 @@ async function mergeTreeEntries(gentPath, baseEntries, oursEntries, theirsEntrie
504
507
  readBlobAsString(gentPath, oH),
505
508
  readBlobAsString(gentPath, tH)
506
509
  ]);
507
- const result = mergeFileContent('', oursC, theirsC, filePath);
510
+ const result = mergeFileContent('', oursC, theirsC, filePath, labels);
508
511
  const mergedHash = await storeBlob(gentPath, result.content);
509
512
  mergedEntries.push({ mode: '100644', name: filePath, hash: mergedHash, type: 'blob' });
510
513
  if (result.hasConflicts) conflicts.push({ file: filePath, type: 'add-add', details: result.conflicts });
@@ -158,7 +158,8 @@ async function merge(repo, theirRevision, options = {}) {
158
158
  baseBytes.toString('utf-8'),
159
159
  oursBytes.toString('utf-8'),
160
160
  theirsBytes.toString('utf-8'),
161
- filePath
161
+ filePath,
162
+ { ours: 'HEAD', theirs: label }
162
163
  );
163
164
  const content = Buffer.from(result.content, 'utf-8');
164
165
  const oid = await repo.objects.write('blob', content);
@@ -183,7 +184,7 @@ async function merge(repo, theirRevision, options = {}) {
183
184
  }
184
185
  await index.write(repo.indexPath);
185
186
 
186
- const message = options.message || `Merge ${label} into ${head.branch || 'HEAD'}`;
187
+ const message = options.message || `Merge branch '${label}'`;
187
188
 
188
189
  if (conflicts.length) {
189
190
  await ops.writeMergeState(repo, [theirs], `${message}\n\nConflicts:\n${conflicts.map(c => ' ' + c.path).join('\n')}\n`);