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.
@@ -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;
@@ -1,123 +1,213 @@
1
1
  /**
2
- * Pull Command - Pull commits from cloud to local repository
3
- * Downloads commits, trees, and blobs from the cloud
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 chalk = require('chalk');
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 { pathExists, readJSON, writeJSON } = require('../utils/fileSystem');
10
- const { GENT_DIR, COMMITS_FILE } = require('../utils/constants');
11
- const { getRemote, syncCommitsFromCloud } = require('../utils/cloud-sync');
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 repoService = require('../services/repo-service');
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 from cloud to local
17
- * @param {string} remoteName - Remote name (default: origin)
18
- * @param {string} branchName - Branch name (optional)
19
- * @param {Object} options - Command 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 cwd = process.cwd();
24
- const gentPath = path.join(cwd, GENT_DIR);
25
-
26
- // Check if in a gent repository
27
- if (!(await pathExists(gentPath))) {
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
- // Check authentication
34
- const user = await authStorage.getUser();
35
- if (!user) {
36
- console.error(chalk.red('Error: You must be logged in to pull'));
37
- console.log(chalk.yellow('Run'), chalk.cyan('gent login'), chalk.yellow('to authenticate'));
38
- process.exit(1);
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
- // Default to 'origin' if no remote specified
42
- remoteName = remoteName || 'origin';
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
- // Get remote configuration
45
- const remote = await getRemote(remoteName, cwd);
46
- if (!remote) {
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
- // Get current branch if not specified
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
- if (!branchName) {
58
- console.error(chalk.red('Error: No branch specified and no current branch found'));
59
- process.exit(1);
60
- }
85
+ const response = await apiClient.get(
86
+ `${remoteConfig.url}/pull/`,
87
+ { params: { branch, since } }
88
+ );
61
89
 
62
- console.log(chalk.cyan(`Pulling from ${remoteName}/${branchName}...\n`));
90
+ const remoteCommits = response.commits || [];
91
+ const remoteObjects = response.objects || [];
92
+ const remoteHead = response.head;
63
93
 
64
- // Verify repository exists
65
- const spinner = ora('Verifying remote repository...').start();
66
- try {
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
- // Pull commits
75
- const cloudCommits = await syncCommitsFromCloud(remote.owner_id, remote.repo_name, cwd);
76
-
77
- // Update local commits file with cloud commits
78
- const localCommits = commitsData.commits || [];
79
- const mergedCommits = [...localCommits];
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
- // Update branch pointer if we have cloud commits
101
- if (cloudCommits.length > 0) {
102
- const latestCloudCommit = cloudCommits[cloudCommits.length - 1];
103
- commitsData.branches[branchName] = latestCloudCommit.sha;
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
- commitsData.commits = mergedCommits;
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
- await writeJSON(path.join(gentPath, COMMITS_FILE), commitsData);
127
+ config.remoteRefs[`${remote}/${branch}`] = remoteHead;
128
+ await writeJSON(path.join(gentPath, CONFIG_FILE), config);
109
129
 
110
- if (cloudCommits.length > 0) {
111
- console.log(chalk.green(`\n✓ Successfully pulled ${cloudCommits.length} commit(s) from ${remoteName}/${branchName}`));
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
- console.log(chalk.yellow('Already up to date'));
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
- console.error(chalk.red('Failed to pull'));
118
- console.error(chalk.red('Error:'), error.message);
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;