gent-cli 5.0.1 → 5.0.4

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.1",
3
+ "version": "5.0.4",
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": {
@@ -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');
34
+ const authStorage = require('../utils/auth-storage');
44
35
  const { storeBlob, readBlobAsString } = 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 = Buffer.from(blob.content, 'base64');
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,46 @@ 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
+
148
+ // Update local config with remote
149
+ const configPath = path.join(gentPath, CONFIG_FILE);
150
+ const localConfig = await require('../utils/fileSystem').readJSON(configPath);
151
+ localConfig.remotes = localConfig.remotes || {};
152
+ localConfig.remotes.origin = { url: `/api/repos/${data.owner_id}/${data.name}` };
153
+ await writeJSON(configPath, localConfig);
154
+
155
+ console.log(chalk.green(`✓ Remote repository created: /api/repos/${data.owner_id}/${data.name}`));
156
+ console.log(chalk.gray(` Remote 'origin' configured automatically`));
157
+
158
+ } catch (error) {
159
+ if (error.response?.status === 400) {
160
+ console.log(chalk.yellow('Remote creation failed — repository name may already exist'));
161
+ } else {
162
+ console.log(chalk.yellow(`Remote creation failed: ${error.message}`));
163
+ }
164
+ }
165
+ }
166
+
119
167
  module.exports = init;
@@ -11,23 +11,13 @@
11
11
  * gent pull → Pull from origin/current-branch
12
12
  * gent pull <remote> <branch> → Pull specific remote/branch
13
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
- * }
14
+ * ALGORITHM (client-side, no /pull/ endpoint):
15
+ * 1. GET .../branches/{branch}/ → get remote branch head SHA
16
+ * 2. GET .../commits/ list all remote commits
17
+ * 3. Diff local vs remote commits, find new ones
18
+ * 4. For each new commit, fetch tree + blobs via individual endpoints
19
+ * 5. Store objects locally
20
+ * 6. If diverged: run 3-way merge. If fast-forward: advance pointer
31
21
  *
32
22
  * ============================================================================
33
23
  */
@@ -37,7 +27,7 @@ const path = require('path');
37
27
  const chalk = require('chalk');
38
28
  const ora = require('ora');
39
29
  const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
40
- const { COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
30
+ const { COMMITS_FILE, CONFIG_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
41
31
  const apiClient = require('../utils/api-client');
42
32
  const authStorage = require('../utils/auth-storage');
43
33
  const { storeBlob, objectExists } = require('../utils/hash-engine');
@@ -72,53 +62,129 @@ async function pull(remoteName, branchName, options) {
72
62
  return;
73
63
  }
74
64
 
65
+ const repoInfo = parseRemoteUrl(remoteConfig.url);
66
+ if (!repoInfo) {
67
+ spinner.fail(chalk.red('Invalid remote URL format'));
68
+ console.log(chalk.yellow('Expected: /api/repos/{owner_id}/{repo_name}'));
69
+ return;
70
+ }
71
+
75
72
  const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
76
73
  const branch = branchName || repository.currentBranch;
77
74
  const localHead = repository.branches[branch] || null;
78
75
 
79
- // Fetch from remote
80
- config.remoteRefs = config.remoteRefs || {};
81
- const since = config.remoteRefs[`${remote}/${branch}`] || localHead || '';
76
+ // 1. Get remote branch info to find remote HEAD
77
+ spinner.text = `Fetching branch info for ${branch}...`;
78
+ let remoteHead;
79
+ try {
80
+ const branchInfo = await apiClient.get(
81
+ buildRepoUrl(API_ENDPOINTS.REPO_BRANCH_DETAIL, { ...repoInfo, branch_name: branch })
82
+ );
83
+ remoteHead = branchInfo.commit_sha;
84
+ } catch (error) {
85
+ if (error.response?.status === 404) {
86
+ spinner.succeed(chalk.green('Remote branch not found — nothing to pull'));
87
+ return;
88
+ }
89
+ throw error;
90
+ }
82
91
 
83
- spinner.text = `Fetching from ${remote}/${branch}...`;
92
+ if (!remoteHead || remoteHead === localHead) {
93
+ spinner.succeed(chalk.green('Already up-to-date'));
94
+ return;
95
+ }
84
96
 
85
- const response = await apiClient.get(
86
- `${remoteConfig.url}/pull/`,
87
- { params: { branch, since } }
97
+ // 2. Fetch all remote commits
98
+ spinner.text = `Fetching commits...`;
99
+ const remoteCommits = await apiClient.get(
100
+ buildRepoUrl(API_ENDPOINTS.REPO_COMMITS, repoInfo)
88
101
  );
89
102
 
90
- const remoteCommits = response.commits || [];
91
- const remoteObjects = response.objects || [];
92
- const remoteHead = response.head;
103
+ // 3. Find commits we don't have locally
104
+ const localCommitSet = new Set((repository.commits || []).map(c => c.hash || c.sha));
105
+ const newRemoteCommits = remoteCommits.filter(c => !localCommitSet.has(c.sha));
93
106
 
94
- if (remoteCommits.length === 0) {
107
+ if (newRemoteCommits.length === 0) {
108
+ // We have all commits but pointer is different — update ref
109
+ config.remoteRefs = config.remoteRefs || {};
110
+ config.remoteRefs[`${remote}/${branch}`] = remoteHead;
111
+ await writeJSON(path.join(gentPath, CONFIG_FILE), config);
95
112
  spinner.succeed(chalk.green('Already up-to-date'));
96
113
  return;
97
114
  }
98
115
 
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);
116
+ // 4. For each new commit, fetch tree and blobs
117
+ spinner.text = `Fetching ${newRemoteCommits.length} new commit(s) + objects...`;
118
+
119
+ const fetchedCommits = [];
120
+ for (const commit of newRemoteCommits) {
121
+ // Fetch tree for this commit
122
+ let treeEntries = [];
123
+ if (commit.tree_sha) {
124
+ try {
125
+ const tree = await apiClient.get(
126
+ buildRepoUrl(API_ENDPOINTS.REPO_TREE_DETAIL, { ...repoInfo, sha: commit.tree_sha })
127
+ );
128
+ treeEntries = tree.entries || [];
129
+ } catch {
130
+ // Tree may not be available
131
+ }
105
132
  }
106
- }
107
133
 
108
- // Check if fast-forward is possible
109
- const allCommits = [...(repository.commits || []), ...remoteCommits];
110
- const commitSet = new Set((repository.commits || []).map(c => c.hash));
134
+ // Fetch and store blobs
135
+ for (const entry of treeEntries) {
136
+ if (entry.type === 'blob' && entry.sha) {
137
+ if (!(await objectExists(gentPath, entry.sha))) {
138
+ try {
139
+ const blob = await apiClient.get(
140
+ buildRepoUrl(API_ENDPOINTS.REPO_BLOB_DETAIL, { ...repoInfo, sha: entry.sha })
141
+ );
142
+ if (blob.content) {
143
+ const buf = Buffer.from(blob.content, 'base64');
144
+ await storeBlob(gentPath, buf);
145
+ }
146
+ } catch {
147
+ // Blob fetch failed, continue
148
+ }
149
+ }
150
+ }
151
+ }
111
152
 
112
- // Add new commits (dedup)
153
+ // Convert remote commit format to local format
154
+ fetchedCommits.push({
155
+ hash: commit.sha,
156
+ message: commit.message,
157
+ author: { name: commit.author_name, email: commit.author_email },
158
+ timestamp: commit.committed_at,
159
+ parent: commit.parent_shas && commit.parent_shas[0] || null,
160
+ mergeParent: commit.parent_shas && commit.parent_shas[1] || null,
161
+ treeHash: commit.tree_sha,
162
+ tree: treeEntries.map(e => ({
163
+ mode: e.mode || '100644',
164
+ name: e.name,
165
+ hash: e.sha,
166
+ type: e.type || 'blob'
167
+ })),
168
+ files: treeEntries.map(e => ({ path: e.name, hash: e.sha })),
169
+ stats: {}
170
+ });
171
+ }
172
+
173
+ // 5. Add new commits to local store (dedup)
113
174
  let newCount = 0;
114
- for (const commit of remoteCommits) {
175
+ const commitSet = new Set((repository.commits || []).map(c => c.hash));
176
+ for (const commit of fetchedCommits) {
115
177
  if (!commitSet.has(commit.hash)) {
178
+ repository.commits = repository.commits || [];
116
179
  repository.commits.push(commit);
117
180
  commitSet.add(commit.hash);
118
181
  newCount++;
119
182
  }
120
183
  }
121
184
 
185
+ // 6. Merge strategy
186
+ config.remoteRefs = config.remoteRefs || {};
187
+
122
188
  if (!localHead || isAncestor(repository.commits, localHead, remoteHead)) {
123
189
  // Fast-forward
124
190
  repository.branches[branch] = remoteHead;
@@ -187,8 +253,8 @@ async function pull(remoteName, branchName, options) {
187
253
  spinner.fail(chalk.red('Pull failed'));
188
254
  if (error.response?.status === 401) {
189
255
  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));
256
+ } else if (error.response?.data) {
257
+ console.error(chalk.red(JSON.stringify(error.response.data)));
192
258
  } else {
193
259
  console.error(chalk.red('Error:'), error.message);
194
260
  }
@@ -14,28 +14,22 @@
14
14
  *
15
15
  * ALGORITHM:
16
16
  * 1. Read local commits since last known remote HEAD
17
- * 2. Collect all blob objects referenced by those commits
18
- * 3. POST packfile (commits + blobs + trees) to remote /push/ endpoint
19
- * 4. Remote updates branch pointer
17
+ * 2. Build proper pack: collect tree objects + blob objects
18
+ * 3. POST packfile to /api/repos/{owner_id}/{repo_name}/push/
19
+ * 4. Remote updates branch pointer via branch_updates
20
20
  *
21
- * DATA FORMAT SENT TO BACKEND:
22
- * POST /api/repos/:id/push/
21
+ * DATA FORMAT SENT TO BACKEND (PushPackRequest):
22
+ * POST /api/repos/{owner_id}/{repo_name}/push/
23
23
  * {
24
- * branch: "main",
25
- * force: false,
26
- * commits: [ { hash, message, author, timestamp, parent, treeHash, tree, files, stats } ],
27
- * objects: [ { hash, type: "blob", data: "<base64>" } ],
24
+ * pack: {
25
+ * commits: [{ sha, message, tree_sha, parent_shas, author_name, author_email, committed_at }],
26
+ * trees: [{ sha, entries: [{ type, mode, name, sha }] }],
27
+ * blobs: [{ sha, size, content, encoding }]
28
+ * },
29
+ * branch_updates: [{ name, commit_sha }],
28
30
  * tags: { "v1.0": { hash, message, ... } }
29
31
  * }
30
32
  *
31
- * BACKEND EXPECTATIONS:
32
- * - Validate auth (JWT Bearer token)
33
- * - Verify fast-forward (reject non-ff unless force=true)
34
- * - Store blob objects in backend object store
35
- * - Append commits to branch history
36
- * - Update branch refs
37
- * - Return { success, ref, hash }
38
- *
39
33
  * ============================================================================
40
34
  */
41
35
 
@@ -44,10 +38,10 @@ const path = require('path');
44
38
  const chalk = require('chalk');
45
39
  const ora = require('ora');
46
40
  const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
47
- const { COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
41
+ const { COMMITS_FILE, CONFIG_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
48
42
  const apiClient = require('../utils/api-client');
49
43
  const authStorage = require('../utils/auth-storage');
50
- const { readBlob, objectExists } = require('../utils/hash-engine');
44
+ const { readBlob, readTree, objectExists, readBlobAsString } = require('../utils/hash-engine');
51
45
 
52
46
  /**
53
47
  * Push commits to remote
@@ -81,6 +75,15 @@ async function push(remoteName, branchName, options) {
81
75
  return;
82
76
  }
83
77
 
78
+ // Parse remote URL to get owner_id and repo_name
79
+ const repoInfo = parseRemoteUrl(remoteConfig.url);
80
+ if (!repoInfo) {
81
+ spinner.fail(chalk.red('Invalid remote URL format'));
82
+ console.log(chalk.yellow('Expected: /api/repos/{owner_id}/{repo_name}'));
83
+ console.log(chalk.yellow('Use "gent remote set-url origin <url>" to fix'));
84
+ return;
85
+ }
86
+
84
87
  const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
85
88
  const branch = branchName || repository.currentBranch;
86
89
  const localHead = repository.branches[branch];
@@ -103,26 +106,71 @@ async function push(remoteName, branchName, options) {
103
106
 
104
107
  spinner.text = `Pushing ${commitsToPush.length} commit(s) to ${remote}/${branch}...`;
105
108
 
106
- // Collect all blob hashes from commits to push
107
- const blobHashes = new Set();
109
+ // Collect all tree and blob objects from commits
110
+ const treeShas = new Set();
111
+ const blobShas = new Set();
112
+
108
113
  for (const commit of commitsToPush) {
114
+ // Collect tree SHA
115
+ if (commit.treeHash) {
116
+ treeShas.add(commit.treeHash);
117
+ }
118
+ // Collect blob hashes from tree entries
109
119
  const tree = commit.tree || commit.files || [];
110
120
  for (const entry of tree) {
111
- const h = entry.hash;
112
- if (h) blobHashes.add(h);
121
+ if (entry.hash) blobShas.add(entry.hash);
113
122
  }
114
123
  }
115
124
 
116
- // Read blob data for transfer
117
- const objects = [];
118
- for (const hash of blobHashes) {
125
+ // Build tree objects for the pack
126
+ const packTrees = [];
127
+ for (const treeSha of treeShas) {
128
+ try {
129
+ if (await objectExists(gentPath, treeSha)) {
130
+ const entries = await readTree(gentPath, treeSha);
131
+ packTrees.push({
132
+ sha: treeSha,
133
+ entries: entries.map(e => ({
134
+ type: e.type || 'blob',
135
+ mode: e.mode || '100644',
136
+ name: e.name,
137
+ sha: e.hash
138
+ }))
139
+ });
140
+ }
141
+ } catch {
142
+ // If tree can't be read from object store, build from commit data
143
+ }
144
+ }
145
+
146
+ // If no tree objects from object store, build from commit tree data
147
+ if (packTrees.length === 0) {
148
+ for (const commit of commitsToPush) {
149
+ if (commit.treeHash && commit.tree) {
150
+ packTrees.push({
151
+ sha: commit.treeHash,
152
+ entries: commit.tree.map(e => ({
153
+ type: e.type || 'blob',
154
+ mode: e.mode || '100644',
155
+ name: e.name || e.path,
156
+ sha: e.hash
157
+ }))
158
+ });
159
+ }
160
+ }
161
+ }
162
+
163
+ // Build blob objects for the pack
164
+ const packBlobs = [];
165
+ for (const hash of blobShas) {
119
166
  try {
120
167
  if (await objectExists(gentPath, hash)) {
121
168
  const data = await readBlob(gentPath, hash);
122
- objects.push({
123
- hash,
124
- type: 'blob',
125
- data: data.toString('base64')
169
+ packBlobs.push({
170
+ sha: hash,
171
+ size: data.length,
172
+ content: data.toString('base64'),
173
+ encoding: 'base64'
126
174
  });
127
175
  }
128
176
  } catch {
@@ -130,31 +178,34 @@ async function push(remoteName, branchName, options) {
130
178
  }
131
179
  }
132
180
 
133
- // Build push payload
181
+ // Build commits for the pack
182
+ const packCommits = commitsToPush.map(c => ({
183
+ sha: c.hash,
184
+ message: c.message,
185
+ tree_sha: c.treeHash || '',
186
+ parent_shas: [c.parent, c.mergeParent].filter(Boolean),
187
+ author_name: typeof c.author === 'object' ? (c.author.name || 'Unknown') : (c.author || 'Unknown'),
188
+ author_email: typeof c.author === 'object' ? (c.author.email || '') : '',
189
+ committed_at: c.timestamp || new Date().toISOString()
190
+ }));
191
+
192
+ // Build push payload matching PushPackRequest schema
134
193
  const payload = {
135
- branch,
136
- force: !!options.force,
137
- commits: commitsToPush.map(c => ({
138
- hash: c.hash,
139
- message: c.message,
140
- author: c.author,
141
- timestamp: c.timestamp,
142
- parent: c.parent,
143
- mergeParent: c.mergeParent || null,
144
- treeHash: c.treeHash || null,
145
- tree: c.tree || null,
146
- files: c.files || [],
147
- stats: c.stats || {}
148
- })),
149
- objects,
194
+ pack: {
195
+ commits: packCommits,
196
+ trees: packTrees,
197
+ blobs: packBlobs
198
+ },
199
+ branch_updates: [{
200
+ name: branch,
201
+ commit_sha: localHead
202
+ }],
150
203
  tags: repository.tags || {}
151
204
  };
152
205
 
153
206
  // Send to backend
154
- const response = await apiClient.post(
155
- `${remoteConfig.url}/push/`,
156
- payload
157
- );
207
+ const pushUrl = buildRepoUrl(API_ENDPOINTS.REPO_PUSH, repoInfo);
208
+ const response = await apiClient.post(pushUrl, payload);
158
209
 
159
210
  // Update remote ref
160
211
  config.remoteRefs[`${remote}/${branch}`] = localHead;
@@ -162,7 +213,7 @@ async function push(remoteName, branchName, options) {
162
213
 
163
214
  spinner.succeed(chalk.green(`Pushed ${commitsToPush.length} commit(s) to ${remote}/${branch}`));
164
215
  console.log(chalk.gray(` ${localHead.substring(0, 7)} → ${remote}/${branch}`));
165
- console.log(chalk.gray(` ${objects.length} object(s) transferred`));
216
+ console.log(chalk.gray(` ${packBlobs.length} blob(s), ${packTrees.length} tree(s) transferred`));
166
217
 
167
218
  } catch (error) {
168
219
  spinner.fail(chalk.red('Push failed'));
@@ -172,8 +223,10 @@ async function push(remoteName, branchName, options) {
172
223
  console.log(chalk.yellow('Run "gent pull" first, or use "gent push --force"'));
173
224
  } else if (error.response?.status === 401) {
174
225
  console.error(chalk.red('Authentication failed — run "gent login"'));
175
- } else if (error.response?.data?.message) {
176
- console.error(chalk.red(error.response.data.message));
226
+ } else if (error.response?.status === 403) {
227
+ console.error(chalk.red('Permission denied — only repo owner can push'));
228
+ } else if (error.response?.data) {
229
+ console.error(chalk.red(JSON.stringify(error.response.data, null, 2)));
177
230
  } else {
178
231
  console.error(chalk.red('Error:'), error.message);
179
232
  }
@@ -29,7 +29,7 @@
29
29
  const path = require('path');
30
30
  const chalk = require('chalk');
31
31
  const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
32
- const { CONFIG_FILE } = require('../utils/constants');
32
+ const { CONFIG_FILE, parseRemoteUrl } = require('../utils/constants');
33
33
 
34
34
  /**
35
35
  * Manage remotes
@@ -55,6 +55,13 @@ async function remote(subcommand, args, options) {
55
55
  console.error(chalk.red(`Remote '${name}' already exists`));
56
56
  return;
57
57
  }
58
+ // Validate URL format
59
+ if (!parseRemoteUrl(url)) {
60
+ console.error(chalk.red('Invalid remote URL format'));
61
+ console.log(chalk.yellow('Expected: /api/repos/{owner_id}/{repo_name}'));
62
+ console.log(chalk.yellow('Example: /api/repos/1/my-project'));
63
+ return;
64
+ }
58
65
  config.remotes[name] = { url };
59
66
  await writeJSON(configPath, config);
60
67
  console.log(chalk.green(`Added remote '${name}' → ${url}`));
@@ -85,6 +92,12 @@ async function remote(subcommand, args, options) {
85
92
  console.error(chalk.red(`Remote '${name}' not found`));
86
93
  return;
87
94
  }
95
+ // Validate URL format
96
+ if (!parseRemoteUrl(url)) {
97
+ console.error(chalk.red('Invalid remote URL format'));
98
+ console.log(chalk.yellow('Expected: /api/repos/{owner_id}/{repo_name}'));
99
+ return;
100
+ }
88
101
  config.remotes[name].url = url;
89
102
  await writeJSON(configPath, config);
90
103
  console.log(chalk.green(`Updated '${name}' → ${url}`));
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Repos Command - List and create remote repositories
3
+ */
4
+
5
+ const chalk = require('chalk');
6
+ const ora = require('ora');
7
+ const path = require('path');
8
+ const { API_ENDPOINTS } = require('../utils/constants');
9
+ const apiClient = require('../utils/api-client');
10
+ const authStorage = require('../utils/auth-storage');
11
+
12
+ /**
13
+ * List or create remote repositories
14
+ * @param {Object} options
15
+ */
16
+ async function repos(options) {
17
+ try {
18
+ const isAuth = await authStorage.isAuthenticated();
19
+ if (!isAuth) {
20
+ console.error(chalk.red('Not authenticated'));
21
+ console.log(chalk.yellow('Run "gent login" first'));
22
+ return;
23
+ }
24
+
25
+ if (options.create) {
26
+ await createRepo(options);
27
+ return;
28
+ }
29
+
30
+ await listRepos();
31
+
32
+ } catch (error) {
33
+ if (error.response?.status === 401) {
34
+ console.error(chalk.red('Authentication failed — run "gent login"'));
35
+ } else if (error.response?.data) {
36
+ console.error(chalk.red(JSON.stringify(error.response.data)));
37
+ } else {
38
+ console.error(chalk.red('Error:'), error.message);
39
+ }
40
+ process.exit(1);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * List all user repositories
46
+ */
47
+ async function listRepos() {
48
+ const spinner = ora('Fetching repositories...').start();
49
+
50
+ const data = await apiClient.get(API_ENDPOINTS.REPOS);
51
+
52
+ spinner.stop();
53
+
54
+ if (!data || data.length === 0) {
55
+ console.log(chalk.gray('No repositories found'));
56
+ console.log(chalk.yellow('Use "gent repos --create <name>" to create one'));
57
+ return;
58
+ }
59
+
60
+ console.log(chalk.bold.cyan('\nRepositories:\n'));
61
+
62
+ for (const repo of data) {
63
+ const visibility = repo.is_private ? chalk.red('private') : chalk.green('public');
64
+ const desc = repo.description ? chalk.gray(` — ${repo.description}`) : '';
65
+ const url = chalk.gray(` /api/repos/${repo.owner_id}/${repo.name}`);
66
+ console.log(` ${chalk.white.bold(repo.name)} [${visibility}]${desc}`);
67
+ console.log(` ${url}`);
68
+ }
69
+
70
+ console.log();
71
+ }
72
+
73
+ /**
74
+ * Create a new remote repository
75
+ */
76
+ async function createRepo(options) {
77
+ const name = options.create;
78
+ if (typeof name !== 'string' || !name) {
79
+ console.error(chalk.red('Usage: gent repos --create <name>'));
80
+ return;
81
+ }
82
+
83
+ const spinner = ora(`Creating repository '${name}'...`).start();
84
+
85
+ const payload = {
86
+ name,
87
+ description: options.description || '',
88
+ is_private: !!options.private,
89
+ };
90
+
91
+ if (options.defaultBranch) {
92
+ payload.default_branch = options.defaultBranch;
93
+ }
94
+
95
+ const data = await apiClient.post(API_ENDPOINTS.REPOS_CREATE, payload);
96
+
97
+ spinner.succeed(chalk.green(`Created repository '${data.name}'`));
98
+ console.log(chalk.gray(` URL: /api/repos/${data.owner_id}/${data.name}`));
99
+ console.log(chalk.gray(` Use "gent remote add origin /api/repos/${data.owner_id}/${data.name}" to link`));
100
+ }
101
+
102
+ module.exports = repos;
@@ -28,7 +28,8 @@
28
28
  const path = require('path');
29
29
  const chalk = require('chalk');
30
30
  const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
31
- const { COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
31
+ const { COMMITS_FILE, CONFIG_FILE, API_ENDPOINTS, buildRepoUrl, parseRemoteUrl } = require('../utils/constants');
32
+ const apiClient = require('../utils/api-client');
32
33
  const authStorage = require('../utils/auth-storage');
33
34
 
34
35
  /**
@@ -104,10 +105,12 @@ async function createTag(name, repository, gentPath, options) {
104
105
  const tagObj = { hash: commitHash };
105
106
 
106
107
  // Annotated tag
108
+ let taggerName = '';
109
+ let taggerEmail = '';
107
110
  if (options.message) {
108
111
  const config = await readJSON(path.join(gentPath, CONFIG_FILE));
109
- let taggerName = config.user.name;
110
- let taggerEmail = config.user.email;
112
+ taggerName = config.user.name;
113
+ taggerEmail = config.user.email;
111
114
 
112
115
  if (!taggerName || !taggerEmail) {
113
116
  const user = await authStorage.getUser();
@@ -127,6 +130,9 @@ async function createTag(name, repository, gentPath, options) {
127
130
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
128
131
 
129
132
  console.log(chalk.green(`Created tag '${name}' → ${commitHash.substring(0, 7)}`));
133
+
134
+ // Sync to remote
135
+ await syncTagCreate(name, tagObj, taggerName, taggerEmail, gentPath);
130
136
  }
131
137
 
132
138
  /**
@@ -141,6 +147,69 @@ async function deleteTag(name, repository, gentPath) {
141
147
  delete repository.tags[name];
142
148
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
143
149
  console.log(chalk.green(`Deleted tag '${name}'`));
150
+
151
+ // Sync deletion to remote
152
+ await syncTagDelete(name, gentPath);
153
+ }
154
+
155
+ /**
156
+ * Sync tag creation to remote API
157
+ */
158
+ async function syncTagCreate(name, tagObj, taggerName, taggerEmail, gentPath) {
159
+ try {
160
+ const isAuth = await authStorage.isAuthenticated();
161
+ if (!isAuth) return;
162
+
163
+ const config = await readJSON(path.join(gentPath, CONFIG_FILE));
164
+ const remoteConfig = config.remotes && config.remotes.origin;
165
+ if (!remoteConfig) return;
166
+
167
+ const repoInfo = parseRemoteUrl(remoteConfig.url);
168
+ if (!repoInfo) return;
169
+
170
+ const payload = {
171
+ name,
172
+ commit_sha: tagObj.hash,
173
+ message: tagObj.message || '',
174
+ annotated: !!tagObj.annotated,
175
+ };
176
+
177
+ if (taggerName) payload.tagger_name = taggerName;
178
+ if (taggerEmail) payload.tagger_email = taggerEmail;
179
+
180
+ const url = buildRepoUrl(API_ENDPOINTS.REPO_TAGS_CREATE, repoInfo);
181
+ await apiClient.post(url, payload);
182
+ console.log(chalk.gray(` ↑ Synced to remote`));
183
+ } catch (error) {
184
+ if (error.response?.status === 400) {
185
+ console.log(chalk.gray(` ⚠ Remote sync skipped (tag may already exist remotely)`));
186
+ }
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Sync tag deletion to remote API
192
+ */
193
+ async function syncTagDelete(name, gentPath) {
194
+ try {
195
+ const isAuth = await authStorage.isAuthenticated();
196
+ if (!isAuth) return;
197
+
198
+ const config = await readJSON(path.join(gentPath, CONFIG_FILE));
199
+ const remoteConfig = config.remotes && config.remotes.origin;
200
+ if (!remoteConfig) return;
201
+
202
+ const repoInfo = parseRemoteUrl(remoteConfig.url);
203
+ if (!repoInfo) return;
204
+
205
+ const url = buildRepoUrl(API_ENDPOINTS.REPO_TAG_DETAIL, { ...repoInfo, tag_name: name });
206
+ await apiClient.delete(url);
207
+ console.log(chalk.gray(` ↑ Deleted from remote`));
208
+ } catch (error) {
209
+ if (error.response?.status === 404) {
210
+ // Tag didn't exist remotely
211
+ }
212
+ }
144
213
  }
145
214
 
146
215
  module.exports = tag;
package/src/index.js CHANGED
@@ -41,6 +41,7 @@ const stashCommand = require('./commands/stash');
41
41
  const remoteCommand = require('./commands/remote');
42
42
  const pushCommand = require('./commands/push');
43
43
  const pullCommand = require('./commands/pull');
44
+ const reposCommand = require('./commands/repos');
44
45
 
45
46
  // Import auth commands
46
47
  const registerCommand = require('./commands/register');
@@ -60,6 +61,7 @@ program
60
61
  .command('init')
61
62
  .description('Initialize a new gent repository')
62
63
  .option('-y, --yes', 'Skip prompts and use defaults')
64
+ .option('--remote [name]', 'Create a remote repository on the backend')
63
65
  .action(initCommand);
64
66
 
65
67
  program
@@ -168,6 +170,15 @@ program
168
170
  .option('-v, --verbose', 'Show remote URLs')
169
171
  .action(remoteCommand);
170
172
 
173
+ program
174
+ .command('repos')
175
+ .description('List or create remote repositories')
176
+ .option('--create <name>', 'Create a new remote repository')
177
+ .option('--description <text>', 'Repository description (with --create)')
178
+ .option('--private', 'Make repository private (with --create)')
179
+ .option('--default-branch <name>', 'Default branch name (with --create)')
180
+ .action(reposCommand);
181
+
171
182
  program
172
183
  .command('push [remote] [branch]')
173
184
  .description('Push local commits to remote')
@@ -170,10 +170,23 @@ async function del(url, config = {}) {
170
170
  return response.data;
171
171
  }
172
172
 
173
+ /**
174
+ * Make PATCH request
175
+ * @param {string} url - Endpoint URL
176
+ * @param {Object} data - Request payload
177
+ * @param {Object} config - Axios config
178
+ * @returns {Promise} Response data
179
+ */
180
+ async function patch(url, data = {}, config = {}) {
181
+ const response = await apiClient.patch(url, data, config);
182
+ return response.data;
183
+ }
184
+
173
185
  module.exports = {
174
186
  get,
175
187
  post,
176
188
  put,
177
189
  delete: del,
190
+ patch,
178
191
  apiClient // Export raw client if needed
179
192
  };
@@ -15,22 +15,67 @@ module.exports = {
15
15
  // API Configuration
16
16
  API_BASE_URL: 'https://gent-api.onrender.com',
17
17
  API_ENDPOINTS: {
18
+ // Auth
18
19
  LOGIN: '/api/auth/login/',
19
20
  REGISTER: '/api/auth/register/',
20
21
  LOGOUT: '/api/auth/logout/',
21
22
  REFRESH: '/api/auth/token/refresh/',
22
23
  PROFILE: '/api/auth/profile/',
23
24
 
24
- // Repository endpoints (used by push/pull/clone)
25
- // Base: /api/repos/:id/
25
+ // Repository management
26
26
  REPOS: '/api/repos/',
27
- REPO_PUSH: '/push/', // POST - upload commits + objects
28
- REPO_PULL: '/pull/', // GET - download commits + objects since hash
29
- REPO_CLONE: '/clone/', // GET - full repo download
30
- REPO_REFS: '/refs/', // GET - list remote branch refs
31
- REPO_TAGS: '/tags/', // GET/POST/DELETE - tag management
32
- REPO_MERGE: '/merge/', // POST - server-side merge request
33
- REPO_COMMITS: '/commits/', // GET - commit history
27
+ REPOS_CREATE: '/api/repos/create/',
28
+ // Template: /api/repos/{owner_id}/{repo_name}/
29
+ REPO_DETAIL: '/api/repos/{owner_id}/{repo_name}/',
30
+ REPO_DELETE: '/api/repos/{owner_id}/{repo_name}/delete/',
31
+
32
+ // Push
33
+ REPO_PUSH: '/api/repos/{owner_id}/{repo_name}/push/',
34
+
35
+ // Branches
36
+ REPO_BRANCHES: '/api/repos/{owner_id}/{repo_name}/branches/',
37
+ REPO_BRANCHES_CREATE: '/api/repos/{owner_id}/{repo_name}/branches/create/',
38
+ REPO_BRANCH_DETAIL: '/api/repos/{owner_id}/{repo_name}/branches/{branch_name}/',
39
+
40
+ // Tags
41
+ REPO_TAGS: '/api/repos/{owner_id}/{repo_name}/tags/',
42
+ REPO_TAGS_CREATE: '/api/repos/{owner_id}/{repo_name}/tags/create/',
43
+ REPO_TAG_DETAIL: '/api/repos/{owner_id}/{repo_name}/tags/{tag_name}/',
44
+
45
+ // Commits
46
+ REPO_COMMITS: '/api/repos/{owner_id}/{repo_name}/commits/',
47
+ REPO_COMMIT_DETAIL: '/api/repos/{owner_id}/{repo_name}/commits/{sha}/',
48
+
49
+ // Objects (trees & blobs)
50
+ REPO_TREE_CREATE: '/api/repos/{owner_id}/{repo_name}/tree/create/',
51
+ REPO_TREE_DETAIL: '/api/repos/{owner_id}/{repo_name}/tree/{sha}/',
52
+ REPO_BLOB_CREATE: '/api/repos/{owner_id}/{repo_name}/blob/create/',
53
+ REPO_BLOB_DETAIL: '/api/repos/{owner_id}/{repo_name}/blob/{sha}/',
54
+ },
55
+
56
+ /**
57
+ * Build a repo-scoped API path by replacing {owner_id} and {repo_name} tokens.
58
+ * @param {string} template - Endpoint template from API_ENDPOINTS
59
+ * @param {object} params - { owner_id, repo_name, branch_name?, tag_name?, sha? }
60
+ * @returns {string}
61
+ */
62
+ buildRepoUrl(template, params) {
63
+ let url = template;
64
+ for (const [key, value] of Object.entries(params)) {
65
+ url = url.replace(`{${key}}`, encodeURIComponent(value));
66
+ }
67
+ return url;
68
+ },
69
+
70
+ /**
71
+ * Parse a remote URL like /api/repos/{owner_id}/{repo_name} into { owner_id, repo_name }.
72
+ * @param {string} url - Remote URL stored in config
73
+ * @returns {{ owner_id: string, repo_name: string } | null}
74
+ */
75
+ parseRemoteUrl(url) {
76
+ const match = url.match(/\/api\/repos\/(\d+)\/([^/]+)\/?$/);
77
+ if (!match) return null;
78
+ return { owner_id: match[1], repo_name: match[2] };
34
79
  },
35
80
 
36
81
  // Default ignore patterns