gent-cli 2.0.0 → 5.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/README.md +28 -42
- package/package.json +1 -1
- package/src/commands/add.js +102 -23
- package/src/commands/clone.js +137 -86
- package/src/commands/commit.js +89 -81
- package/src/commands/diff.js +257 -0
- package/src/commands/init.js +5 -36
- package/src/commands/log.js +57 -13
- package/src/commands/merge.js +245 -0
- package/src/commands/pull.js +176 -86
- package/src/commands/push.js +172 -79
- package/src/commands/remote.js +97 -113
- package/src/commands/reset.js +149 -0
- package/src/commands/rm.js +85 -0
- package/src/commands/show.js +167 -0
- package/src/commands/stash.js +255 -0
- package/src/commands/status.js +80 -46
- package/src/commands/tag.js +146 -0
- package/src/index.js +108 -57
- package/src/utils/constants.js +10 -26
- package/src/utils/diff-engine.js +236 -0
- package/src/utils/fileSystem.js +8 -60
- package/src/utils/hash-engine.js +337 -0
- package/src/utils/merge-engine.js +379 -0
- package/src/utils/object-store.js +54 -0
- package/src/commands/create.js +0 -121
- package/src/commands/list.js +0 -67
- package/src/services/repo-service.js +0 -284
- package/src/utils/cloud-sync.js +0 -323
- package/src/utils/diff.js +0 -121
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge Command - Merge a branch into the current branch
|
|
3
|
+
* Uses 3-way smart merge with automatic conflict resolution
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs').promises;
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const chalk = require('chalk');
|
|
9
|
+
const ora = require('ora');
|
|
10
|
+
const { getGentPath, readJSON, writeJSON, pathExists } = require('../utils/fileSystem');
|
|
11
|
+
const { COMMITS_FILE, STAGING_FILE, CONFIG_FILE } = require('../utils/constants');
|
|
12
|
+
const { generateCommitHash } = require('../utils/helpers');
|
|
13
|
+
const authStorage = require('../utils/auth-storage');
|
|
14
|
+
const { findMergeBase, mergeTreeEntries, autoMerge } = require('../utils/merge-engine');
|
|
15
|
+
const { storeTree, readBlobAsString, storeBlob } = require('../utils/hash-engine');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Merge a branch into the current branch
|
|
19
|
+
* @param {String} sourceBranch - Branch to merge from
|
|
20
|
+
* @param {Object} options - Command options
|
|
21
|
+
*/
|
|
22
|
+
async function merge(sourceBranch, options) {
|
|
23
|
+
const spinner = ora(`Merging '${sourceBranch}'...`).start();
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const gentPath = await getGentPath();
|
|
27
|
+
const cwd = process.cwd();
|
|
28
|
+
const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
29
|
+
const commits = repository.commits || [];
|
|
30
|
+
const branches = repository.branches || {};
|
|
31
|
+
const currentBranch = repository.currentBranch;
|
|
32
|
+
|
|
33
|
+
// Validate branches
|
|
34
|
+
if (!branches.hasOwnProperty(sourceBranch)) {
|
|
35
|
+
spinner.fail(chalk.red(`Branch '${sourceBranch}' not found`));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (sourceBranch === currentBranch) {
|
|
40
|
+
spinner.fail(chalk.red('Cannot merge a branch into itself'));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const oursHash = branches[currentBranch];
|
|
45
|
+
const theirsHash = branches[sourceBranch];
|
|
46
|
+
|
|
47
|
+
if (!oursHash) {
|
|
48
|
+
spinner.fail(chalk.red(`Current branch '${currentBranch}' has no commits`));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!theirsHash) {
|
|
53
|
+
spinner.fail(chalk.red(`Branch '${sourceBranch}' has no commits`));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Fast-forward check: if ours is ancestor of theirs
|
|
58
|
+
if (oursHash === theirsHash) {
|
|
59
|
+
spinner.succeed(chalk.green('Already up to date'));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Find merge base (common ancestor)
|
|
64
|
+
const baseHash = findMergeBase(commits, oursHash, theirsHash);
|
|
65
|
+
|
|
66
|
+
// Fast-forward: current branch is merge base → just move pointer
|
|
67
|
+
if (baseHash === oursHash) {
|
|
68
|
+
spinner.text = 'Fast-forward merge...';
|
|
69
|
+
repository.branches[currentBranch] = theirsHash;
|
|
70
|
+
await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
|
|
71
|
+
|
|
72
|
+
// Restore working tree from theirs commit
|
|
73
|
+
const theirsCommit = commits.find(c => c.hash === theirsHash);
|
|
74
|
+
if (theirsCommit) {
|
|
75
|
+
await restoreWorkingTree(gentPath, cwd, theirsCommit);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
spinner.succeed(chalk.green(`Fast-forward merge: ${currentBranch} → ${theirsHash.substring(0, 7)}`));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3-way merge
|
|
83
|
+
spinner.text = 'Computing 3-way merge...';
|
|
84
|
+
|
|
85
|
+
const oursCommit = commits.find(c => c.hash === oursHash);
|
|
86
|
+
const theirsCommit = commits.find(c => c.hash === theirsHash);
|
|
87
|
+
const baseCommit = baseHash ? commits.find(c => c.hash === baseHash) : null;
|
|
88
|
+
|
|
89
|
+
// Extract tree entries from commits
|
|
90
|
+
const getTree = (commit) => {
|
|
91
|
+
if (!commit) return [];
|
|
92
|
+
if (commit.tree && Array.isArray(commit.tree)) return commit.tree;
|
|
93
|
+
if (commit.files) return commit.files.map(f => ({
|
|
94
|
+
mode: '100644', name: f.path || f.name, hash: f.hash, type: 'blob'
|
|
95
|
+
}));
|
|
96
|
+
return [];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const baseTree = getTree(baseCommit);
|
|
100
|
+
const oursTree = getTree(oursCommit);
|
|
101
|
+
const theirsTree = getTree(theirsCommit);
|
|
102
|
+
|
|
103
|
+
// Perform tree-level merge
|
|
104
|
+
const mergeResult = await mergeTreeEntries(gentPath, baseTree, oursTree, theirsTree);
|
|
105
|
+
|
|
106
|
+
if (mergeResult.hasConflicts) {
|
|
107
|
+
spinner.warn(chalk.yellow(`Merged with ${mergeResult.conflicts.length} conflict(s)`));
|
|
108
|
+
console.log('');
|
|
109
|
+
|
|
110
|
+
for (const conflict of mergeResult.conflicts) {
|
|
111
|
+
if (conflict.type === 'content') {
|
|
112
|
+
console.log(chalk.red(` CONFLICT (content): ${conflict.file}`));
|
|
113
|
+
console.log(chalk.gray(` ${conflict.details.length} conflicting region(s) — markers inserted`));
|
|
114
|
+
} else if (conflict.type === 'modify-delete') {
|
|
115
|
+
console.log(chalk.yellow(` CONFLICT (modify/delete): ${conflict.file}`));
|
|
116
|
+
console.log(chalk.gray(` Deleted by ${conflict.deletedBy}, modified by ${conflict.modifiedBy} — kept modified version`));
|
|
117
|
+
} else if (conflict.type === 'add-add') {
|
|
118
|
+
console.log(chalk.yellow(` CONFLICT (add/add): ${conflict.file}`));
|
|
119
|
+
console.log(chalk.gray(` Both branches added differently — markers inserted`));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(chalk.yellow('\nConflict markers: <<<<<<< ours / ======= / >>>>>>> theirs'));
|
|
124
|
+
console.log(chalk.cyan('Resolve conflicts, then run "gent add" and "gent commit"'));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Store merged tree
|
|
128
|
+
const mergedTreeHash = await storeTree(gentPath, mergeResult.mergedEntries);
|
|
129
|
+
|
|
130
|
+
// Write merged files to working directory
|
|
131
|
+
await writeTreeToWorkDir(gentPath, cwd, mergeResult.mergedEntries);
|
|
132
|
+
|
|
133
|
+
// If no conflicts, create merge commit automatically
|
|
134
|
+
if (!mergeResult.hasConflicts) {
|
|
135
|
+
// Resolve author
|
|
136
|
+
const config = await readJSON(path.join(gentPath, CONFIG_FILE));
|
|
137
|
+
let authorName = config.user.name;
|
|
138
|
+
let authorEmail = config.user.email;
|
|
139
|
+
|
|
140
|
+
if (!authorName || !authorEmail) {
|
|
141
|
+
const globalUser = await authStorage.getUser();
|
|
142
|
+
if (globalUser) {
|
|
143
|
+
if (!authorName) authorName = [globalUser.first_name, globalUser.last_name].filter(Boolean).join(' ');
|
|
144
|
+
if (!authorEmail) authorEmail = globalUser.email;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const mergeCommit = {
|
|
149
|
+
hash: generateCommitHash(),
|
|
150
|
+
message: options.message || `Merge branch '${sourceBranch}' into ${currentBranch}`,
|
|
151
|
+
author: {
|
|
152
|
+
name: authorName || 'Unknown',
|
|
153
|
+
email: authorEmail || 'unknown@gent'
|
|
154
|
+
},
|
|
155
|
+
timestamp: new Date().toISOString(),
|
|
156
|
+
parent: oursHash,
|
|
157
|
+
mergeParent: theirsHash,
|
|
158
|
+
treeHash: mergedTreeHash,
|
|
159
|
+
tree: mergeResult.mergedEntries,
|
|
160
|
+
files: mergeResult.mergedEntries.map(e => ({ path: e.name, hash: e.hash })),
|
|
161
|
+
stats: {
|
|
162
|
+
filesChanged: mergeResult.mergedEntries.length,
|
|
163
|
+
insertions: 0,
|
|
164
|
+
deletions: 0
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
repository.commits.push(mergeCommit);
|
|
169
|
+
repository.branches[currentBranch] = mergeCommit.hash;
|
|
170
|
+
await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
|
|
171
|
+
|
|
172
|
+
// Clear staging
|
|
173
|
+
const staging = await readJSON(path.join(gentPath, STAGING_FILE));
|
|
174
|
+
staging.entries = [];
|
|
175
|
+
staging.files = [];
|
|
176
|
+
await writeJSON(path.join(gentPath, STAGING_FILE), staging);
|
|
177
|
+
|
|
178
|
+
spinner.succeed(chalk.green(`Merged '${sourceBranch}' into '${currentBranch}' — ${mergeCommit.hash.substring(0, 7)}`));
|
|
179
|
+
|
|
180
|
+
const autoResolved = mergeResult.mergedEntries.length;
|
|
181
|
+
console.log(chalk.gray(`\n Base: ${baseHash ? baseHash.substring(0, 7) : 'none'}`));
|
|
182
|
+
console.log(chalk.gray(` Ours: ${oursHash.substring(0, 7)} Theirs: ${theirsHash.substring(0, 7)}`));
|
|
183
|
+
console.log(chalk.green(` ${autoResolved} file(s) merged automatically`));
|
|
184
|
+
} else {
|
|
185
|
+
// Stage the merge state for manual resolution
|
|
186
|
+
const staging = await readJSON(path.join(gentPath, STAGING_FILE));
|
|
187
|
+
staging.mergeState = {
|
|
188
|
+
sourceBranch,
|
|
189
|
+
oursHash,
|
|
190
|
+
theirsHash,
|
|
191
|
+
baseHash,
|
|
192
|
+
mergedTreeHash,
|
|
193
|
+
mergedEntries: mergeResult.mergedEntries,
|
|
194
|
+
conflicts: mergeResult.conflicts
|
|
195
|
+
};
|
|
196
|
+
await writeJSON(path.join(gentPath, STAGING_FILE), staging);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
} catch (error) {
|
|
200
|
+
spinner.fail(chalk.red('Merge failed'));
|
|
201
|
+
if (error.code === 'ENOENT' && error.message.includes('.gent')) {
|
|
202
|
+
console.error(chalk.red('\nError: Not a gent repository'));
|
|
203
|
+
console.log(chalk.yellow('Run "gent init" to initialize a repository'));
|
|
204
|
+
} else {
|
|
205
|
+
console.error(chalk.red('\nError:'), error.message);
|
|
206
|
+
}
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Write tree entries to working directory.
|
|
213
|
+
* @param {String} gentPath
|
|
214
|
+
* @param {String} cwd
|
|
215
|
+
* @param {Array} entries
|
|
216
|
+
*/
|
|
217
|
+
async function writeTreeToWorkDir(gentPath, cwd, entries) {
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
const fullPath = path.join(cwd, entry.name);
|
|
220
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
221
|
+
|
|
222
|
+
const content = await readBlobAsString(gentPath, entry.hash);
|
|
223
|
+
await fs.writeFile(fullPath, content, 'utf-8');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Restore working tree from a commit's tree entries.
|
|
229
|
+
* @param {String} gentPath
|
|
230
|
+
* @param {String} cwd
|
|
231
|
+
* @param {Object} commit
|
|
232
|
+
*/
|
|
233
|
+
async function restoreWorkingTree(gentPath, cwd, commit) {
|
|
234
|
+
const tree = commit.tree || (commit.files || []).map(f => ({
|
|
235
|
+
mode: '100644', name: f.path || f.name, hash: f.hash, type: 'blob'
|
|
236
|
+
}));
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
await writeTreeToWorkDir(gentPath, cwd, tree);
|
|
240
|
+
} catch {
|
|
241
|
+
// Best-effort restore — blobs may not exist for legacy commits
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = merge;
|
package/src/commands/pull.js
CHANGED
|
@@ -1,123 +1,213 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Pull Command - Fetch and merge remote commits into local branch
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* Download new commits from remote and merge into current branch.
|
|
8
|
+
* Like `git pull` (fetch + merge in one step).
|
|
9
|
+
*
|
|
10
|
+
* USAGE:
|
|
11
|
+
* gent pull → Pull from origin/current-branch
|
|
12
|
+
* gent pull <remote> <branch> → Pull specific remote/branch
|
|
13
|
+
*
|
|
14
|
+
* ALGORITHM:
|
|
15
|
+
* 1. GET /api/repos/:id/pull/?branch=<branch>&since=<lastKnownHash>
|
|
16
|
+
* 2. Receive commits + blob objects
|
|
17
|
+
* 3. Store blobs in local object store
|
|
18
|
+
* 4. Append commits to local history
|
|
19
|
+
* 5. If diverged: run 3-way merge (same as gent merge)
|
|
20
|
+
* 6. If fast-forward: just advance pointer
|
|
21
|
+
*
|
|
22
|
+
* BACKEND EXPECTATIONS:
|
|
23
|
+
* GET /api/repos/:id/pull/?branch=main&since=abc1234
|
|
24
|
+
* Returns:
|
|
25
|
+
* {
|
|
26
|
+
* branch: "main",
|
|
27
|
+
* commits: [...],
|
|
28
|
+
* objects: [ { hash, type, data: "<base64>" } ],
|
|
29
|
+
* head: "<remoteHeadHash>"
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* ============================================================================
|
|
4
33
|
*/
|
|
5
34
|
|
|
6
|
-
const
|
|
35
|
+
const fs = require('fs').promises;
|
|
7
36
|
const path = require('path');
|
|
37
|
+
const chalk = require('chalk');
|
|
8
38
|
const ora = require('ora');
|
|
9
|
-
const {
|
|
10
|
-
const {
|
|
11
|
-
const
|
|
39
|
+
const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
|
|
40
|
+
const { COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
|
|
41
|
+
const apiClient = require('../utils/api-client');
|
|
12
42
|
const authStorage = require('../utils/auth-storage');
|
|
13
|
-
const
|
|
43
|
+
const { storeBlob, objectExists } = require('../utils/hash-engine');
|
|
44
|
+
const { findMergeBase, mergeTreeEntries } = require('../utils/merge-engine');
|
|
45
|
+
const { generateCommitHash } = require('../utils/helpers');
|
|
14
46
|
|
|
15
47
|
/**
|
|
16
|
-
* Pull commits
|
|
17
|
-
* @param {
|
|
18
|
-
* @param {
|
|
19
|
-
* @param {Object} options
|
|
48
|
+
* Pull remote commits
|
|
49
|
+
* @param {String} remoteName
|
|
50
|
+
* @param {String} branchName
|
|
51
|
+
* @param {Object} options
|
|
20
52
|
*/
|
|
21
53
|
async function pull(remoteName, branchName, options) {
|
|
54
|
+
const spinner = ora('Pulling from remote...').start();
|
|
55
|
+
|
|
22
56
|
try {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
console.error(chalk.red('Error: Not a gent repository'));
|
|
29
|
-
console.log(chalk.yellow('Run'), chalk.cyan('gent init'), chalk.yellow('to initialize a repository'));
|
|
30
|
-
process.exit(1);
|
|
57
|
+
const isAuth = await authStorage.isAuthenticated();
|
|
58
|
+
if (!isAuth) {
|
|
59
|
+
spinner.fail(chalk.red('Not authenticated'));
|
|
60
|
+
console.log(chalk.yellow('Run "gent login" first'));
|
|
61
|
+
return;
|
|
31
62
|
}
|
|
32
63
|
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
64
|
+
const gentPath = await getGentPath();
|
|
65
|
+
const config = await readJSON(path.join(gentPath, CONFIG_FILE));
|
|
66
|
+
config.remotes = config.remotes || {};
|
|
67
|
+
|
|
68
|
+
const remote = remoteName || 'origin';
|
|
69
|
+
const remoteConfig = config.remotes[remote];
|
|
70
|
+
if (!remoteConfig) {
|
|
71
|
+
spinner.fail(chalk.red(`Remote '${remote}' not found`));
|
|
72
|
+
return;
|
|
39
73
|
}
|
|
40
74
|
|
|
41
|
-
|
|
42
|
-
|
|
75
|
+
const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
76
|
+
const branch = branchName || repository.currentBranch;
|
|
77
|
+
const localHead = repository.branches[branch] || null;
|
|
43
78
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
console.error(chalk.red(`Error: Remote '${remoteName}' not found`));
|
|
48
|
-
console.log(chalk.yellow('Add a remote with:'), chalk.cyan('gent remote add origin <owner_id>/<repo_name>'));
|
|
49
|
-
console.log(chalk.yellow('Or list remotes with:'), chalk.cyan('gent remote'));
|
|
50
|
-
process.exit(1);
|
|
51
|
-
}
|
|
79
|
+
// Fetch from remote
|
|
80
|
+
config.remoteRefs = config.remoteRefs || {};
|
|
81
|
+
const since = config.remoteRefs[`${remote}/${branch}`] || localHead || '';
|
|
52
82
|
|
|
53
|
-
|
|
54
|
-
const commitsData = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
55
|
-
branchName = branchName || commitsData.currentBranch;
|
|
83
|
+
spinner.text = `Fetching from ${remote}/${branch}...`;
|
|
56
84
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
85
|
+
const response = await apiClient.get(
|
|
86
|
+
`${remoteConfig.url}/pull/`,
|
|
87
|
+
{ params: { branch, since } }
|
|
88
|
+
);
|
|
61
89
|
|
|
62
|
-
|
|
90
|
+
const remoteCommits = response.commits || [];
|
|
91
|
+
const remoteObjects = response.objects || [];
|
|
92
|
+
const remoteHead = response.head;
|
|
63
93
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
await repoService.getRepository(remote.owner_id, remote.repo_name);
|
|
68
|
-
spinner.succeed('Remote repository verified');
|
|
69
|
-
} catch (error) {
|
|
70
|
-
spinner.fail('Remote repository not found or access denied');
|
|
71
|
-
throw error;
|
|
94
|
+
if (remoteCommits.length === 0) {
|
|
95
|
+
spinner.succeed(chalk.green('Already up-to-date'));
|
|
96
|
+
return;
|
|
72
97
|
}
|
|
73
98
|
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// Add cloud commits that don't exist locally
|
|
82
|
-
for (const cloudCommit of cloudCommits) {
|
|
83
|
-
const exists = localCommits.find(c => c.sha === cloudCommit.sha);
|
|
84
|
-
if (!exists) {
|
|
85
|
-
// Convert cloud commit format to local format
|
|
86
|
-
mergedCommits.push({
|
|
87
|
-
sha: cloudCommit.sha,
|
|
88
|
-
message: cloudCommit.message,
|
|
89
|
-
author: {
|
|
90
|
-
name: cloudCommit.author_name,
|
|
91
|
-
email: cloudCommit.author_email
|
|
92
|
-
},
|
|
93
|
-
timestamp: cloudCommit.committed_at,
|
|
94
|
-
parent: cloudCommit.parent_shas || [],
|
|
95
|
-
files: cloudCommit.files || []
|
|
96
|
-
});
|
|
99
|
+
// Store received blob objects
|
|
100
|
+
spinner.text = `Storing ${remoteObjects.length} object(s)...`;
|
|
101
|
+
for (const obj of remoteObjects) {
|
|
102
|
+
if (obj.type === 'blob' && obj.data) {
|
|
103
|
+
const buf = Buffer.from(obj.data, 'base64');
|
|
104
|
+
await storeBlob(gentPath, buf);
|
|
97
105
|
}
|
|
98
106
|
}
|
|
99
107
|
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
// Check if fast-forward is possible
|
|
109
|
+
const allCommits = [...(repository.commits || []), ...remoteCommits];
|
|
110
|
+
const commitSet = new Set((repository.commits || []).map(c => c.hash));
|
|
111
|
+
|
|
112
|
+
// Add new commits (dedup)
|
|
113
|
+
let newCount = 0;
|
|
114
|
+
for (const commit of remoteCommits) {
|
|
115
|
+
if (!commitSet.has(commit.hash)) {
|
|
116
|
+
repository.commits.push(commit);
|
|
117
|
+
commitSet.add(commit.hash);
|
|
118
|
+
newCount++;
|
|
119
|
+
}
|
|
104
120
|
}
|
|
105
121
|
|
|
106
|
-
|
|
122
|
+
if (!localHead || isAncestor(repository.commits, localHead, remoteHead)) {
|
|
123
|
+
// Fast-forward
|
|
124
|
+
repository.branches[branch] = remoteHead;
|
|
125
|
+
await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
|
|
107
126
|
|
|
108
|
-
|
|
127
|
+
config.remoteRefs[`${remote}/${branch}`] = remoteHead;
|
|
128
|
+
await writeJSON(path.join(gentPath, CONFIG_FILE), config);
|
|
109
129
|
|
|
110
|
-
|
|
111
|
-
console.log(chalk.
|
|
130
|
+
spinner.succeed(chalk.green(`Fast-forward: ${newCount} new commit(s)`));
|
|
131
|
+
console.log(chalk.gray(` ${remote}/${branch} → ${remoteHead.substring(0, 7)}`));
|
|
112
132
|
} else {
|
|
113
|
-
|
|
133
|
+
// Diverged — need 3-way merge
|
|
134
|
+
spinner.text = 'Branches diverged, merging...';
|
|
135
|
+
|
|
136
|
+
const baseHash = findMergeBase(repository.commits, localHead, remoteHead);
|
|
137
|
+
const getTree = (hash) => {
|
|
138
|
+
const c = repository.commits.find(x => x.hash === hash);
|
|
139
|
+
if (!c) return [];
|
|
140
|
+
return c.tree || (c.files || []).map(f => ({
|
|
141
|
+
mode: '100644', name: f.path || f.name, hash: f.hash, type: 'blob'
|
|
142
|
+
}));
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const baseTree = baseHash ? getTree(baseHash) : [];
|
|
146
|
+
const oursTree = getTree(localHead);
|
|
147
|
+
const theirsTree = getTree(remoteHead);
|
|
148
|
+
|
|
149
|
+
const mergeResult = await mergeTreeEntries(gentPath, baseTree, oursTree, theirsTree);
|
|
150
|
+
|
|
151
|
+
// Build merge commit
|
|
152
|
+
const { storeTree } = require('../utils/hash-engine');
|
|
153
|
+
const mergedTreeHash = await storeTree(gentPath, mergeResult.mergedEntries);
|
|
154
|
+
|
|
155
|
+
const mergeCommit = {
|
|
156
|
+
hash: generateCommitHash(),
|
|
157
|
+
message: `Merge remote-tracking branch '${remote}/${branch}'`,
|
|
158
|
+
author: (repository.commits.find(c => c.hash === localHead) || {}).author || { name: 'Unknown', email: '' },
|
|
159
|
+
timestamp: new Date().toISOString(),
|
|
160
|
+
parent: localHead,
|
|
161
|
+
mergeParent: remoteHead,
|
|
162
|
+
treeHash: mergedTreeHash,
|
|
163
|
+
tree: mergeResult.mergedEntries,
|
|
164
|
+
files: mergeResult.mergedEntries.map(e => ({ path: e.name, hash: e.hash })),
|
|
165
|
+
stats: { filesChanged: mergeResult.mergedEntries.length, insertions: 0, deletions: 0 }
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
repository.commits.push(mergeCommit);
|
|
169
|
+
repository.branches[branch] = mergeCommit.hash;
|
|
170
|
+
await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
|
|
171
|
+
|
|
172
|
+
config.remoteRefs[`${remote}/${branch}`] = remoteHead;
|
|
173
|
+
await writeJSON(path.join(gentPath, CONFIG_FILE), config);
|
|
174
|
+
|
|
175
|
+
if (mergeResult.hasConflicts) {
|
|
176
|
+
spinner.warn(chalk.yellow(`Pulled with ${mergeResult.conflicts.length} conflict(s)`));
|
|
177
|
+
for (const c of mergeResult.conflicts) {
|
|
178
|
+
console.log(chalk.red(` CONFLICT: ${c.file} (${c.type})`));
|
|
179
|
+
}
|
|
180
|
+
console.log(chalk.yellow('\nResolve conflicts, then "gent add" + "gent commit"'));
|
|
181
|
+
} else {
|
|
182
|
+
spinner.succeed(chalk.green(`Merged ${newCount} remote commit(s)`));
|
|
183
|
+
console.log(chalk.gray(` Merge commit: ${mergeCommit.hash.substring(0, 7)}`));
|
|
184
|
+
}
|
|
114
185
|
}
|
|
115
|
-
|
|
116
186
|
} catch (error) {
|
|
117
|
-
|
|
118
|
-
|
|
187
|
+
spinner.fail(chalk.red('Pull failed'));
|
|
188
|
+
if (error.response?.status === 401) {
|
|
189
|
+
console.error(chalk.red('Authentication failed — run "gent login"'));
|
|
190
|
+
} else if (error.response?.data?.message) {
|
|
191
|
+
console.error(chalk.red(error.response.data.message));
|
|
192
|
+
} else {
|
|
193
|
+
console.error(chalk.red('Error:'), error.message);
|
|
194
|
+
}
|
|
119
195
|
process.exit(1);
|
|
120
196
|
}
|
|
121
197
|
}
|
|
122
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Check if hashA is ancestor of hashB
|
|
201
|
+
*/
|
|
202
|
+
function isAncestor(commits, hashA, hashB) {
|
|
203
|
+
const commitMap = new Map(commits.map(c => [c.hash, c]));
|
|
204
|
+
let cur = hashB;
|
|
205
|
+
while (cur) {
|
|
206
|
+
if (cur === hashA) return true;
|
|
207
|
+
const c = commitMap.get(cur);
|
|
208
|
+
cur = c ? c.parent : null;
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
123
213
|
module.exports = pull;
|