gent-cli 21.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": "21.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": {
@@ -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);
@@ -445,7 +445,7 @@ function autoMerge(baseText, oursText, theirsText) {
445
445
  * @param {Array} theirsEntries
446
446
  * @returns {Promise<{mergedEntries: Array, conflicts: Array, hasConflicts: Boolean}>}
447
447
  */
448
- async function mergeTreeEntries(gentPath, baseEntries, oursEntries, theirsEntries) {
448
+ async function mergeTreeEntries(gentPath, baseEntries, oursEntries, theirsEntries, labels) {
449
449
  const baseMap = treeToMap(baseEntries);
450
450
  const oursMap = treeToMap(oursEntries);
451
451
  const theirsMap = treeToMap(theirsEntries);
@@ -482,7 +482,7 @@ async function mergeTreeEntries(gentPath, baseEntries, oursEntries, theirsEntrie
482
482
  readBlobAsString(gentPath, oH),
483
483
  readBlobAsString(gentPath, tH)
484
484
  ]);
485
- const result = mergeFileContent(baseC, oursC, theirsC, filePath);
485
+ const result = mergeFileContent(baseC, oursC, theirsC, filePath, labels);
486
486
  const mergedHash = await storeBlob(gentPath, result.content);
487
487
  mergedEntries.push({ mode: '100644', name: filePath, hash: mergedHash, type: 'blob' });
488
488
  if (result.hasConflicts) conflicts.push({ file: filePath, type: 'content', details: result.conflicts });
@@ -507,7 +507,7 @@ async function mergeTreeEntries(gentPath, baseEntries, oursEntries, theirsEntrie
507
507
  readBlobAsString(gentPath, oH),
508
508
  readBlobAsString(gentPath, tH)
509
509
  ]);
510
- const result = mergeFileContent('', oursC, theirsC, filePath);
510
+ const result = mergeFileContent('', oursC, theirsC, filePath, labels);
511
511
  const mergedHash = await storeBlob(gentPath, result.content);
512
512
  mergedEntries.push({ mode: '100644', name: filePath, hash: mergedHash, type: 'blob' });
513
513
  if (result.hasConflicts) conflicts.push({ file: filePath, type: 'add-add', details: result.conflicts });