gent-cli 5.0.3 → 6.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": "5.0.3",
3
+ "version": "6.0.0",
4
4
  "description": "A modern, Git-like version control CLI with built-in cloud authentication and global user identity management.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node src/index.js",
11
- "test": "echo \"Error: no test specified\" && exit 1",
11
+ "test": "node --check src/index.js && node --check tests/remote-e2e.js",
12
+ "test:remote:e2e": "node tests/remote-e2e.js",
12
13
  "demo": "bash demo.sh",
13
14
  "link": "npm link",
14
15
  "unlink": "npm unlink",
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * Branch Command - List, create, or delete branches
3
- * Manages repository branches
3
+ * Manages repository branches locally and syncs to remote API
4
4
  */
5
5
 
6
6
  const path = require('path');
7
7
  const chalk = require('chalk');
8
+ const ora = require('ora');
8
9
  const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
9
- const { COMMITS_FILE } = require('../utils/constants');
10
+ const { COMMITS_FILE, CONFIG_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
11
+ const apiClient = require('../utils/api-client');
12
+ const authStorage = require('../utils/auth-storage');
10
13
 
11
14
  /**
12
15
  * Manage branches
@@ -85,6 +88,9 @@ async function createBranch(name, repository, gentPath) {
85
88
 
86
89
  console.log(chalk.green(`✓ Created branch '${name}'`));
87
90
  console.log(chalk.gray(`Based on: ${repository.currentBranch}`));
91
+
92
+ // Sync to remote if authenticated and remote configured
93
+ await syncBranchCreate(name, currentCommit, gentPath);
88
94
  }
89
95
 
90
96
  /**
@@ -110,6 +116,64 @@ async function deleteBranch(name, repository, gentPath) {
110
116
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
111
117
 
112
118
  console.log(chalk.green(`✓ Deleted branch '${name}'`));
119
+
120
+ // Sync deletion to remote
121
+ await syncBranchDelete(name, gentPath);
122
+ }
123
+
124
+ /**
125
+ * Sync branch creation to remote API
126
+ */
127
+ async function syncBranchCreate(name, commitSha, gentPath) {
128
+ try {
129
+ const isAuth = await authStorage.isAuthenticated();
130
+ if (!isAuth) return;
131
+
132
+ const config = await readJSON(path.join(gentPath, CONFIG_FILE));
133
+ const remoteConfig = config.remotes && config.remotes.origin;
134
+ if (!remoteConfig) return;
135
+
136
+ const repoInfo = parseRemoteUrl(remoteConfig.url);
137
+ if (!repoInfo) return;
138
+
139
+ if (!commitSha) return; // No commit to point to
140
+
141
+ const url = buildRepoUrl(API_ENDPOINTS.REPO_BRANCHES_CREATE, repoInfo);
142
+ await apiClient.post(url, { name, commit_sha: commitSha });
143
+ console.log(chalk.gray(` ↑ Synced to remote`));
144
+ } catch (error) {
145
+ // Non-fatal: branch created locally even if remote sync fails
146
+ if (error.response?.status === 400) {
147
+ console.log(chalk.gray(` ⚠ Remote sync skipped (branch may already exist remotely)`));
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Sync branch deletion to remote API
154
+ */
155
+ async function syncBranchDelete(name, gentPath) {
156
+ try {
157
+ const isAuth = await authStorage.isAuthenticated();
158
+ if (!isAuth) return;
159
+
160
+ const config = await readJSON(path.join(gentPath, CONFIG_FILE));
161
+ const remoteConfig = config.remotes && config.remotes.origin;
162
+ if (!remoteConfig) return;
163
+
164
+ const repoInfo = parseRemoteUrl(remoteConfig.url);
165
+ if (!repoInfo) return;
166
+
167
+ const url = buildRepoUrl(API_ENDPOINTS.REPO_BRANCH_DETAIL, { ...repoInfo, branch_name: name });
168
+ await apiClient.delete(url);
169
+ console.log(chalk.gray(` ↑ Deleted from remote`));
170
+ } catch (error) {
171
+ if (error.response?.status === 400) {
172
+ console.log(chalk.gray(` ⚠ Cannot delete default branch on remote`));
173
+ } else if (error.response?.status === 404) {
174
+ // Branch didn't exist remotely, that's fine
175
+ }
176
+ }
113
177
  }
114
178
 
115
179
  module.exports = branch;
@@ -11,25 +11,15 @@
11
11
  * gent clone <url> → Clone into folder named after repo
12
12
  * gent clone <url> <directory> → Clone into specific directory
13
13
  *
14
- * ALGORITHM:
15
- * 1. GET <url>/clone/ receives full repo (commits, objects, config)
16
- * 2. Create .gent/ directory structure
17
- * 3. Store all blob objects in local object store
18
- * 4. Write commits.json with full history
19
- * 5. Checkout HEAD (restore working tree from latest commit)
20
- * 6. Configure remote "origin" pointing to <url>
21
- *
22
- * BACKEND EXPECTATIONS:
23
- * GET /api/repos/:id/clone/
24
- * Returns:
25
- * {
26
- * name: "repo-name",
27
- * commits: [...],
28
- * objects: [ { hash, type, data: "<base64>" } ],
29
- * branches: { "main": "<hash>", ... },
30
- * currentBranch: "main",
31
- * tags: { ... }
32
- * }
14
+ * ALGORITHM (client-side, no /clone/ endpoint):
15
+ * 1. Parse URL to get owner_id + repo_name
16
+ * 2. GET repo details → name, description, default_branch
17
+ * 3. GET branches list all branch names + SHAs
18
+ * 4. GET commits list all commits
19
+ * 5. For each commit, fetch tree + blobs
20
+ * 6. Create .gent/ directory structure
21
+ * 7. Store all objects locally
22
+ * 8. Checkout HEAD (restore working tree from latest commit)
33
23
  *
34
24
  * ============================================================================
35
25
  */
@@ -39,13 +29,14 @@ const path = require('path');
39
29
  const chalk = require('chalk');
40
30
  const ora = require('ora');
41
31
  const { ensureDir, writeJSON, pathExists } = require('../utils/fileSystem');
42
- const { GENT_DIR, CONFIG_FILE, STAGING_FILE, COMMITS_FILE } = require('../utils/constants');
32
+ const { GENT_DIR, CONFIG_FILE, STAGING_FILE, COMMITS_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
43
33
  const apiClient = require('../utils/api-client');
44
- const { storeBlob, readBlobAsString } = require('../utils/hash-engine');
34
+ const authStorage = require('../utils/auth-storage');
35
+ const { storeBlob, readBlobAsString, decodeRemoteBlobContent } = require('../utils/hash-engine');
45
36
 
46
37
  /**
47
38
  * Clone remote repository
48
- * @param {String} url - Remote repository URL
39
+ * @param {String} url - Remote repository URL (e.g. /api/repos/1/my-repo)
49
40
  * @param {String} directory - Optional target directory
50
41
  * @param {Object} options
51
42
  */
@@ -58,11 +49,30 @@ async function clone(url, directory, options) {
58
49
  const spinner = ora(`Cloning from ${url}...`).start();
59
50
 
60
51
  try {
61
- // Fetch full repo from remote
62
- spinner.text = 'Downloading repository data...';
63
- const response = await apiClient.get(`${url}/clone/`);
52
+ // Check auth
53
+ const isAuth = await authStorage.isAuthenticated();
54
+ if (!isAuth) {
55
+ spinner.fail(chalk.red('Not authenticated'));
56
+ console.log(chalk.yellow('Run "gent login" first'));
57
+ return;
58
+ }
59
+
60
+ // Parse URL
61
+ const repoInfo = parseRemoteUrl(url);
62
+ if (!repoInfo) {
63
+ spinner.fail(chalk.red('Invalid repository URL'));
64
+ console.log(chalk.yellow('Expected format: /api/repos/{owner_id}/{repo_name}'));
65
+ return;
66
+ }
64
67
 
65
- const repoName = response.name || 'gent-repo';
68
+ // 1. Get repo details
69
+ spinner.text = 'Fetching repository info...';
70
+ const repoDetail = await apiClient.get(
71
+ buildRepoUrl(API_ENDPOINTS.REPO_DETAIL, repoInfo)
72
+ );
73
+
74
+ const repoName = repoDetail.name || repoInfo.repo_name;
75
+ const defaultBranch = repoDetail.default_branch || 'main';
66
76
  const targetDir = directory || repoName;
67
77
  const targetPath = path.resolve(process.cwd(), targetDir);
68
78
 
@@ -74,6 +84,39 @@ async function clone(url, directory, options) {
74
84
  }
75
85
  }
76
86
 
87
+ // 2. Get branches
88
+ spinner.text = 'Fetching branches...';
89
+ let remoteBranches = [];
90
+ try {
91
+ remoteBranches = await apiClient.get(
92
+ buildRepoUrl(API_ENDPOINTS.REPO_BRANCHES, repoInfo)
93
+ );
94
+ } catch {
95
+ // No branches yet
96
+ }
97
+
98
+ // 3. Get all commits
99
+ spinner.text = 'Fetching commits...';
100
+ let remoteCommits = [];
101
+ try {
102
+ remoteCommits = await apiClient.get(
103
+ buildRepoUrl(API_ENDPOINTS.REPO_COMMITS, repoInfo)
104
+ );
105
+ } catch {
106
+ // No commits yet
107
+ }
108
+
109
+ // 4. Get tags
110
+ spinner.text = 'Fetching tags...';
111
+ let remoteTags = [];
112
+ try {
113
+ remoteTags = await apiClient.get(
114
+ buildRepoUrl(API_ENDPOINTS.REPO_TAGS, repoInfo)
115
+ );
116
+ } catch {
117
+ // No tags
118
+ }
119
+
77
120
  // Create directory structure
78
121
  spinner.text = 'Setting up repository...';
79
122
  const gentPath = path.join(targetPath, GENT_DIR);
@@ -82,23 +125,91 @@ async function clone(url, directory, options) {
82
125
  await ensureDir(path.join(gentPath, 'refs', 'heads'));
83
126
  await ensureDir(path.join(gentPath, 'refs', 'tags'));
84
127
 
85
- // Store blob objects
86
- const objects = response.objects || [];
87
- spinner.text = `Storing ${objects.length} object(s)...`;
128
+ // 5. For each commit, fetch tree and blobs
129
+ const localCommits = [];
130
+ let objectCount = 0;
131
+
132
+ for (let i = 0; i < remoteCommits.length; i++) {
133
+ const commit = remoteCommits[i];
134
+ spinner.text = `Fetching objects (${i + 1}/${remoteCommits.length})...`;
88
135
 
89
- for (const obj of objects) {
90
- if (obj.type === 'blob' && obj.data) {
91
- const buf = Buffer.from(obj.data, 'base64');
92
- await storeBlob(gentPath, buf);
136
+ let treeEntries = [];
137
+ if (commit.tree_sha) {
138
+ try {
139
+ const tree = await apiClient.get(
140
+ buildRepoUrl(API_ENDPOINTS.REPO_TREE_DETAIL, { ...repoInfo, sha: commit.tree_sha })
141
+ );
142
+ treeEntries = tree.entries || [];
143
+ } catch {
144
+ // Tree not available
145
+ }
146
+ }
147
+
148
+ // Fetch and store blobs
149
+ for (const entry of treeEntries) {
150
+ if (entry.type === 'blob' && entry.sha) {
151
+ try {
152
+ const blob = await apiClient.get(
153
+ buildRepoUrl(API_ENDPOINTS.REPO_BLOB_DETAIL, { ...repoInfo, sha: entry.sha })
154
+ );
155
+ if (blob.content) {
156
+ const buf = decodeRemoteBlobContent(blob.content, entry.sha);
157
+ await storeBlob(gentPath, buf);
158
+ objectCount++;
159
+ }
160
+ } catch {
161
+ // Blob fetch failed
162
+ }
163
+ }
93
164
  }
165
+
166
+ // Convert to local commit format
167
+ localCommits.push({
168
+ hash: commit.sha,
169
+ message: commit.message,
170
+ author: { name: commit.author_name, email: commit.author_email },
171
+ timestamp: commit.committed_at,
172
+ parent: commit.parent_shas && commit.parent_shas[0] || null,
173
+ mergeParent: commit.parent_shas && commit.parent_shas[1] || null,
174
+ treeHash: commit.tree_sha,
175
+ tree: treeEntries.map(e => ({
176
+ mode: e.mode || '100644',
177
+ name: e.name,
178
+ hash: e.sha,
179
+ type: e.type || 'blob'
180
+ })),
181
+ files: treeEntries.map(e => ({ path: e.name, hash: e.sha })),
182
+ stats: {}
183
+ });
184
+ }
185
+
186
+ // Build branches map
187
+ const branches = {};
188
+ for (const b of remoteBranches) {
189
+ branches[b.name] = b.commit_sha;
190
+ }
191
+ if (!branches[defaultBranch]) {
192
+ branches[defaultBranch] = null;
193
+ }
194
+
195
+ // Build tags map
196
+ const tagsMap = {};
197
+ for (const t of remoteTags) {
198
+ tagsMap[t.name] = {
199
+ hash: t.commit_sha,
200
+ message: t.message || '',
201
+ annotated: t.annotated || false,
202
+ tagger: { name: t.tagger_name || '', email: t.tagger_email || '' },
203
+ timestamp: t.created_at
204
+ };
94
205
  }
95
206
 
96
207
  // Write commits.json
97
208
  const repoData = {
98
- commits: response.commits || [],
99
- branches: response.branches || { main: null },
100
- currentBranch: response.currentBranch || 'main',
101
- tags: response.tags || {}
209
+ commits: localCommits,
210
+ branches,
211
+ currentBranch: defaultBranch,
212
+ tags: tagsMap
102
213
  };
103
214
  await writeJSON(path.join(gentPath, COMMITS_FILE), repoData);
104
215
 
@@ -107,7 +218,7 @@ async function clone(url, directory, options) {
107
218
  user: { name: '', email: '' },
108
219
  repository: {
109
220
  name: repoName,
110
- description: response.description || '',
221
+ description: repoDetail.description || '',
111
222
  created: new Date().toISOString()
112
223
  },
113
224
  remotes: {
@@ -116,10 +227,9 @@ async function clone(url, directory, options) {
116
227
  remoteRefs: {}
117
228
  };
118
229
 
119
- // Set remote ref to head
120
- const headHash = repoData.branches[repoData.currentBranch];
230
+ const headHash = branches[defaultBranch];
121
231
  if (headHash) {
122
- config.remoteRefs[`origin/${repoData.currentBranch}`] = headHash;
232
+ config.remoteRefs[`origin/${defaultBranch}`] = headHash;
123
233
  }
124
234
 
125
235
  await writeJSON(path.join(gentPath, CONFIG_FILE), config);
@@ -130,7 +240,7 @@ async function clone(url, directory, options) {
130
240
  // Write HEAD file
131
241
  await fs.writeFile(
132
242
  path.join(gentPath, 'HEAD'),
133
- `ref: refs/heads/${repoData.currentBranch}\n`
243
+ `ref: refs/heads/${defaultBranch}\n`
134
244
  );
135
245
 
136
246
  // Create .gentignore
@@ -139,11 +249,9 @@ async function clone(url, directory, options) {
139
249
 
140
250
  // Checkout working tree from HEAD commit
141
251
  if (headHash) {
142
- const headCommit = repoData.commits.find(c => c.hash === headHash);
252
+ const headCommit = localCommits.find(c => c.hash === headHash);
143
253
  if (headCommit) {
144
- const tree = headCommit.tree || (headCommit.files || []).map(f => ({
145
- name: f.path || f.name, hash: f.hash
146
- }));
254
+ const tree = headCommit.tree || [];
147
255
 
148
256
  spinner.text = 'Checking out files...';
149
257
  let fileCount = 0;
@@ -160,7 +268,7 @@ async function clone(url, directory, options) {
160
268
  }
161
269
 
162
270
  spinner.succeed(chalk.green(`Cloned into '${targetDir}'`));
163
- console.log(chalk.gray(` ${repoData.commits.length} commit(s), ${objects.length} object(s), ${fileCount} file(s)`));
271
+ console.log(chalk.gray(` ${localCommits.length} commit(s), ${objectCount} object(s), ${fileCount} file(s)`));
164
272
  } else {
165
273
  spinner.succeed(chalk.green(`Cloned into '${targetDir}' (empty)`));
166
274
  }
@@ -172,8 +280,10 @@ async function clone(url, directory, options) {
172
280
  spinner.fail(chalk.red('Clone failed'));
173
281
  if (error.response?.status === 404) {
174
282
  console.error(chalk.red('Repository not found'));
175
- } else if (error.response?.data?.message) {
176
- console.error(chalk.red(error.response.data.message));
283
+ } else if (error.response?.status === 401) {
284
+ console.error(chalk.red('Authentication failed — run "gent login"'));
285
+ } else if (error.response?.data) {
286
+ console.error(chalk.red(JSON.stringify(error.response.data)));
177
287
  } else {
178
288
  console.error(chalk.red('Error:'), error.message);
179
289
  }
@@ -8,7 +8,8 @@ const path = require('path');
8
8
  const chalk = require('chalk');
9
9
  const { ensureDir, writeJSON, pathExists } = require('../utils/fileSystem');
10
10
  const authStorage = require('../utils/auth-storage');
11
- const { GENT_DIR, CONFIG_FILE, STAGING_FILE, COMMITS_FILE } = require('../utils/constants');
11
+ const apiClient = require('../utils/api-client');
12
+ const { GENT_DIR, CONFIG_FILE, STAGING_FILE, COMMITS_FILE, API_ENDPOINTS } = require('../utils/constants');
12
13
 
13
14
  /**
14
15
  * Initialize a new gent repository
@@ -109,6 +110,11 @@ node_modules/
109
110
  console.log(chalk.gray(`Initialized empty Gent repository in ${gentPath}`));
110
111
  }
111
112
 
113
+ // Create remote repository if --remote flag is set
114
+ if (options.remote) {
115
+ await createRemoteRepo(cwd, gentPath, config, options);
116
+ }
117
+
112
118
  } catch (error) {
113
119
  console.error(chalk.red('Failed to initialize repository'));
114
120
  console.error(chalk.red('Error:'), error.message);
@@ -116,4 +122,47 @@ node_modules/
116
122
  }
117
123
  }
118
124
 
125
+ /**
126
+ * Create a remote repository on the backend and link it
127
+ */
128
+ async function createRemoteRepo(cwd, gentPath, config, options) {
129
+ try {
130
+ const isAuth = await authStorage.isAuthenticated();
131
+ if (!isAuth) {
132
+ console.log(chalk.yellow('Not authenticated — skipping remote creation'));
133
+ console.log(chalk.yellow('Run "gent login" then "gent repos --create <name>"'));
134
+ return;
135
+ }
136
+
137
+ const repoName = typeof options.remote === 'string' ? options.remote : path.basename(cwd);
138
+
139
+ console.log(chalk.gray(`Creating remote repository '${repoName}'...`));
140
+
141
+ const payload = {
142
+ name: repoName,
143
+ description: config.repository.description || '',
144
+ };
145
+
146
+ const data = await apiClient.post(API_ENDPOINTS.REPOS_CREATE, payload);
147
+ const repo = data.repository || data;
148
+
149
+ // Update local config with remote
150
+ const configPath = path.join(gentPath, CONFIG_FILE);
151
+ const localConfig = await require('../utils/fileSystem').readJSON(configPath);
152
+ localConfig.remotes = localConfig.remotes || {};
153
+ localConfig.remotes.origin = { url: `/api/repos/${repo.owner_id}/${repo.name}` };
154
+ await writeJSON(configPath, localConfig);
155
+
156
+ console.log(chalk.green(`✓ Remote repository created: /api/repos/${repo.owner_id}/${repo.name}`));
157
+ console.log(chalk.gray(` Remote 'origin' configured automatically`));
158
+
159
+ } catch (error) {
160
+ if (error.response?.status === 400) {
161
+ console.log(chalk.yellow('Remote creation failed — repository name may already exist'));
162
+ } else {
163
+ console.log(chalk.yellow(`Remote creation failed: ${error.message}`));
164
+ }
165
+ }
166
+ }
167
+
119
168
  module.exports = init;