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 +1 -1
- package/src/commands/branch.js +66 -2
- package/src/commands/clone.js +158 -48
- package/src/commands/init.js +49 -1
- package/src/commands/pull.js +109 -43
- package/src/commands/push.js +106 -53
- package/src/commands/remote.js +14 -1
- package/src/commands/repos.js +102 -0
- package/src/commands/tag.js +72 -3
- package/src/index.js +11 -0
- package/src/utils/api-client.js +13 -0
- package/src/utils/constants.js +54 -9
package/package.json
CHANGED
package/src/commands/branch.js
CHANGED
|
@@ -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;
|
package/src/commands/clone.js
CHANGED
|
@@ -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.
|
|
16
|
-
* 2.
|
|
17
|
-
* 3.
|
|
18
|
-
* 4.
|
|
19
|
-
* 5.
|
|
20
|
-
* 6.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
86
|
-
const
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
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:
|
|
99
|
-
branches
|
|
100
|
-
currentBranch:
|
|
101
|
-
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:
|
|
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
|
-
|
|
120
|
-
const headHash = repoData.branches[repoData.currentBranch];
|
|
230
|
+
const headHash = branches[defaultBranch];
|
|
121
231
|
if (headHash) {
|
|
122
|
-
config.remoteRefs[`origin/${
|
|
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/${
|
|
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 =
|
|
252
|
+
const headCommit = localCommits.find(c => c.hash === headHash);
|
|
143
253
|
if (headCommit) {
|
|
144
|
-
const tree = headCommit.tree ||
|
|
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(` ${
|
|
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?.
|
|
176
|
-
console.error(chalk.red(
|
|
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
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
|
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;
|
package/src/commands/pull.js
CHANGED
|
@@ -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 /
|
|
16
|
-
* 2.
|
|
17
|
-
* 3.
|
|
18
|
-
* 4.
|
|
19
|
-
* 5.
|
|
20
|
-
* 6. If fast-forward:
|
|
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
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
92
|
+
if (!remoteHead || remoteHead === localHead) {
|
|
93
|
+
spinner.succeed(chalk.green('Already up-to-date'));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
84
96
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
const
|
|
92
|
-
const
|
|
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 (
|
|
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
|
-
//
|
|
100
|
-
spinner.text = `
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
191
|
-
console.error(chalk.red(error.response.data
|
|
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
|
}
|
package/src/commands/push.js
CHANGED
|
@@ -14,28 +14,22 @@
|
|
|
14
14
|
*
|
|
15
15
|
* ALGORITHM:
|
|
16
16
|
* 1. Read local commits since last known remote HEAD
|
|
17
|
-
* 2.
|
|
18
|
-
* 3. POST packfile
|
|
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
|
|
21
|
+
* DATA FORMAT SENT TO BACKEND (PushPackRequest):
|
|
22
|
+
* POST /api/repos/{owner_id}/{repo_name}/push/
|
|
23
23
|
* {
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
107
|
-
const
|
|
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
|
-
|
|
112
|
-
if (h) blobHashes.add(h);
|
|
121
|
+
if (entry.hash) blobShas.add(entry.hash);
|
|
113
122
|
}
|
|
114
123
|
}
|
|
115
124
|
|
|
116
|
-
//
|
|
117
|
-
const
|
|
118
|
-
for (const
|
|
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
|
-
|
|
123
|
-
hash,
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
155
|
-
|
|
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(` ${
|
|
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?.
|
|
176
|
-
console.error(chalk.red(
|
|
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
|
}
|
package/src/commands/remote.js
CHANGED
|
@@ -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;
|
package/src/commands/tag.js
CHANGED
|
@@ -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
|
-
|
|
110
|
-
|
|
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')
|
package/src/utils/api-client.js
CHANGED
|
@@ -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
|
};
|
package/src/utils/constants.js
CHANGED
|
@@ -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
|
|
25
|
-
// Base: /api/repos/:id/
|
|
25
|
+
// Repository management
|
|
26
26
|
REPOS: '/api/repos/',
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|