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 +1 -1
- package/src/commands/checkout.js +71 -2
- package/src/commands/commit.js +15 -5
- package/src/commands/merge.js +85 -46
- package/src/commands/pull.js +99 -32
- package/src/commands/resolve.js +1 -0
- package/src/utils/merge-engine.js +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gent-cli",
|
|
3
|
-
"version": "
|
|
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": {
|
package/src/commands/checkout.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/commands/commit.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
113
|
-
?
|
|
114
|
-
:
|
|
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!'));
|
package/src/commands/merge.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
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.
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
package/src/commands/pull.js
CHANGED
|
@@ -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
|
|
72
|
-
const
|
|
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
|
-
|
|
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(
|
|
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[
|
|
216
|
+
repository.branches[currentBranch] = mergeCommit.hash;
|
|
180
217
|
await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
|
|
181
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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(
|
|
318
|
+
await fs.unlink(safePath(cwd, relPath));
|
|
247
319
|
} catch {
|
|
248
320
|
// File already absent.
|
|
249
321
|
}
|
|
250
322
|
}
|
|
251
323
|
|
|
252
|
-
for (const
|
|
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
|
|
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
|
}
|
package/src/commands/resolve.js
CHANGED
|
@@ -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 });
|