gent-cli 2.1.0 → 5.0.1

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.
@@ -1,131 +1,182 @@
1
1
  /**
2
- * Clone Command - Clone a cloud repository to local
3
- * Downloads repository data and sets up local working copy
2
+ * ============================================================================
3
+ * Clone Command - Clone a remote repository to local filesystem
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Download an entire repository (commits + objects) from a remote server
8
+ * and set up a local working copy. Like `git clone`.
9
+ *
10
+ * USAGE:
11
+ * gent clone <url> → Clone into folder named after repo
12
+ * gent clone <url> <directory> → Clone into specific directory
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
+ * }
33
+ *
34
+ * ============================================================================
4
35
  */
5
36
 
6
37
  const fs = require('fs').promises;
38
+ const path = require('path');
7
39
  const chalk = require('chalk');
8
40
  const ora = require('ora');
9
- const path = require('path');
10
- const repoService = require('../services/repo-service');
11
- const authStorage = require('../utils/auth-storage');
12
41
  const { ensureDir, writeJSON, pathExists } = require('../utils/fileSystem');
13
42
  const { GENT_DIR, CONFIG_FILE, STAGING_FILE, COMMITS_FILE } = require('../utils/constants');
14
- const { addRemote, syncCommitsFromCloud } = require('../utils/cloud-sync');
43
+ const apiClient = require('../utils/api-client');
44
+ const { storeBlob, readBlobAsString } = require('../utils/hash-engine');
15
45
 
16
46
  /**
17
- * Clone a cloud repository
18
- * @param {string} repoUrl - Repository URL (owner_id/repo_name)
19
- * @param {string} directory - Target directory (optional)
20
- * @param {Object} options - Command options
47
+ * Clone remote repository
48
+ * @param {String} url - Remote repository URL
49
+ * @param {String} directory - Optional target directory
50
+ * @param {Object} options
21
51
  */
22
- async function clone(repoUrl, directory, options) {
23
- try {
24
- // Check authentication
25
- const user = await authStorage.getUser();
26
- if (!user) {
27
- console.error(chalk.red('Error: You must be logged in to clone a repository'));
28
- console.log(chalk.yellow('Run'), chalk.cyan('gent login'), chalk.yellow('to authenticate'));
29
- process.exit(1);
30
- }
31
-
32
- // Parse repository URL
33
- if (!repoUrl) {
34
- console.error(chalk.red('Error: Repository URL is required'));
35
- console.log(chalk.yellow('Usage:'), chalk.cyan('gent clone <owner_id>/<repo_name> [directory]'));
36
- process.exit(1);
37
- }
38
-
39
- const parts = repoUrl.split('/');
40
- if (parts.length !== 2) {
41
- console.error(chalk.red('Error: Invalid repository URL format'));
42
- console.log(chalk.yellow('Expected:'), chalk.cyan('<owner_id>/<repo_name>'));
43
- process.exit(1);
44
- }
52
+ async function clone(url, directory, options) {
53
+ if (!url) {
54
+ console.error(chalk.red('Usage: gent clone <url> [directory]'));
55
+ return;
56
+ }
45
57
 
46
- const ownerId = parseInt(parts[0]);
47
- const repoName = parts[1];
58
+ const spinner = ora(`Cloning from ${url}...`).start();
48
59
 
49
- if (isNaN(ownerId)) {
50
- console.error(chalk.red('Error: Owner ID must be a number'));
51
- process.exit(1);
52
- }
60
+ try {
61
+ // Fetch full repo from remote
62
+ spinner.text = 'Downloading repository data...';
63
+ const response = await apiClient.get(`${url}/clone/`);
53
64
 
54
- // Determine target directory
65
+ const repoName = response.name || 'gent-repo';
55
66
  const targetDir = directory || repoName;
56
67
  const targetPath = path.resolve(process.cwd(), targetDir);
57
68
 
58
- // Check if directory already exists
59
69
  if (await pathExists(targetPath)) {
60
- console.error(chalk.red(`Error: Directory '${targetDir}' already exists`));
61
- process.exit(1);
70
+ const items = await fs.readdir(targetPath);
71
+ if (items.length > 0) {
72
+ spinner.fail(chalk.red(`Directory '${targetDir}' is not empty`));
73
+ return;
74
+ }
62
75
  }
63
76
 
64
- console.log(chalk.cyan(`Cloning ${ownerId}/${repoName} into '${targetDir}'...\n`));
65
-
66
- // Fetch repository metadata
67
- const spinner = ora('Fetching repository...').start();
68
- const repository = await repoService.getRepository(ownerId, repoName);
69
- spinner.succeed('Repository fetched');
70
-
71
77
  // Create directory structure
72
- await ensureDir(targetPath);
78
+ spinner.text = 'Setting up repository...';
73
79
  const gentPath = path.join(targetPath, GENT_DIR);
74
80
  await ensureDir(gentPath);
75
81
  await ensureDir(path.join(gentPath, 'objects'));
76
82
  await ensureDir(path.join(gentPath, 'refs', 'heads'));
77
83
  await ensureDir(path.join(gentPath, 'refs', 'tags'));
78
84
 
79
- // Create configuration
85
+ // Store blob objects
86
+ const objects = response.objects || [];
87
+ spinner.text = `Storing ${objects.length} object(s)...`;
88
+
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);
93
+ }
94
+ }
95
+
96
+ // Write commits.json
97
+ const repoData = {
98
+ commits: response.commits || [],
99
+ branches: response.branches || { main: null },
100
+ currentBranch: response.currentBranch || 'main',
101
+ tags: response.tags || {}
102
+ };
103
+ await writeJSON(path.join(gentPath, COMMITS_FILE), repoData);
104
+
105
+ // Write config with remote
80
106
  const config = {
81
- user: {
82
- name: user.first_name && user.last_name ? `${user.first_name} ${user.last_name}` : '',
83
- email: user.email
84
- },
107
+ user: { name: '', email: '' },
85
108
  repository: {
86
- name: repository.name,
87
- description: repository.description,
109
+ name: repoName,
110
+ description: response.description || '',
88
111
  created: new Date().toISOString()
89
- }
112
+ },
113
+ remotes: {
114
+ origin: { url }
115
+ },
116
+ remoteRefs: {}
90
117
  };
118
+
119
+ // Set remote ref to head
120
+ const headHash = repoData.branches[repoData.currentBranch];
121
+ if (headHash) {
122
+ config.remoteRefs[`origin/${repoData.currentBranch}`] = headHash;
123
+ }
124
+
91
125
  await writeJSON(path.join(gentPath, CONFIG_FILE), config);
92
126
 
93
- // Create initial files
94
- await writeJSON(path.join(gentPath, STAGING_FILE), { files: [] });
95
- await writeJSON(path.join(gentPath, COMMITS_FILE), {
96
- commits: [],
97
- branches: { [repository.default_branch]: null },
98
- currentBranch: repository.default_branch
99
- });
127
+ // Write staging.json
128
+ await writeJSON(path.join(gentPath, STAGING_FILE), { entries: [], files: [] });
100
129
 
101
- // Create HEAD file
130
+ // Write HEAD file
102
131
  await fs.writeFile(
103
132
  path.join(gentPath, 'HEAD'),
104
- `ref: refs/heads/${repository.default_branch}\n`
133
+ `ref: refs/heads/${repoData.currentBranch}\n`
105
134
  );
106
135
 
107
- // Add remote
108
- await addRemote('origin', ownerId, repoName, targetPath);
109
-
110
- // Fetch commits, branches, and files
111
- try {
112
- await syncCommitsFromCloud(ownerId, repoName, targetPath);
113
- } catch (error) {
114
- // Ignore if repository has no commits yet
115
- if (!error.message.includes('404')) {
116
- throw error;
136
+ // Create .gentignore
137
+ const ignorePath = path.join(targetPath, '.gentignore');
138
+ await fs.writeFile(ignorePath, `# Gent ignore\nnode_modules/\n.DS_Store\n*.log\n.env\n.gent/\n`);
139
+
140
+ // Checkout working tree from HEAD commit
141
+ if (headHash) {
142
+ const headCommit = repoData.commits.find(c => c.hash === headHash);
143
+ if (headCommit) {
144
+ const tree = headCommit.tree || (headCommit.files || []).map(f => ({
145
+ name: f.path || f.name, hash: f.hash
146
+ }));
147
+
148
+ spinner.text = 'Checking out files...';
149
+ let fileCount = 0;
150
+ for (const entry of tree) {
151
+ try {
152
+ const content = await readBlobAsString(gentPath, entry.hash);
153
+ const fullPath = path.join(targetPath, entry.name || entry.path);
154
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
155
+ await fs.writeFile(fullPath, content, 'utf-8');
156
+ fileCount++;
157
+ } catch {
158
+ // Blob missing
159
+ }
160
+ }
161
+
162
+ spinner.succeed(chalk.green(`Cloned into '${targetDir}'`));
163
+ console.log(chalk.gray(` ${repoData.commits.length} commit(s), ${objects.length} object(s), ${fileCount} file(s)`));
164
+ } else {
165
+ spinner.succeed(chalk.green(`Cloned into '${targetDir}' (empty)`));
117
166
  }
118
- console.log(chalk.yellow('Note: Repository has no commits yet'));
167
+ } else {
168
+ spinner.succeed(chalk.green(`Cloned into '${targetDir}' (no commits)`));
119
169
  }
120
170
 
121
- console.log(chalk.green(`\n✓ Successfully cloned into '${targetDir}'`));
122
- console.log(chalk.yellow('\nNext steps:'));
123
- console.log(chalk.cyan(` cd ${targetDir}`));
124
- console.log(chalk.cyan(' gent status'), chalk.gray('- Check repository status'));
125
-
126
171
  } catch (error) {
127
- console.error(chalk.red('Failed to clone repository'));
128
- console.error(chalk.red('Error:'), error.message);
172
+ spinner.fail(chalk.red('Clone failed'));
173
+ if (error.response?.status === 404) {
174
+ console.error(chalk.red('Repository not found'));
175
+ } else if (error.response?.data?.message) {
176
+ console.error(chalk.red(error.response.data.message));
177
+ } else {
178
+ console.error(chalk.red('Error:'), error.message);
179
+ }
129
180
  process.exit(1);
130
181
  }
131
182
  }
@@ -1,18 +1,17 @@
1
1
  /**
2
2
  * Commit Command - Record changes to the repository
3
- * Creates a new commit with staged files
3
+ * Creates a new commit with tree object referencing staged blobs
4
4
  */
5
5
 
6
6
  const path = require('path');
7
- const fs = require('fs').promises;
8
7
  const chalk = require('chalk');
9
8
  const inquirer = require('inquirer');
10
9
  const ora = require('ora');
11
- const { getGentPath, readJSON, writeJSON, saveBlob, getBlob } = require('../utils/fileSystem');
10
+ const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
12
11
  const authStorage = require('../utils/auth-storage');
13
12
  const { STAGING_FILE, COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
14
- const { generateCommitHash, getFileHash } = require('../utils/helpers');
15
- const { computeDiff } = require('../utils/diff');
13
+ const { generateCommitHash } = require('../utils/helpers');
14
+ const { storeTree, snapshotFile } = require('../utils/hash-engine');
16
15
 
17
16
  /**
18
17
  * Create a new commit
@@ -25,9 +24,10 @@ async function commit(options) {
25
24
 
26
25
  // Read staging area
27
26
  const staging = await readJSON(path.join(gentPath, STAGING_FILE));
27
+ const stagedEntries = staging.entries || [];
28
28
  const stagedFiles = staging.files || [];
29
29
 
30
- if (stagedFiles.length === 0) {
30
+ if (stagedEntries.length === 0 && stagedFiles.length === 0) {
31
31
  console.log(chalk.yellow('No changes added to commit'));
32
32
  console.log(chalk.gray('Use "gent add <file>..." to stage files'));
33
33
  return;
@@ -59,20 +59,14 @@ async function commit(options) {
59
59
  let authorName = config.user.name;
60
60
  let authorEmail = config.user.email;
61
61
 
62
- // Fallback to global auth if local config is empty
63
62
  if (!authorName || !authorEmail) {
64
63
  const globalUser = await authStorage.getUser();
65
64
  if (globalUser) {
66
- if (!authorName) {
67
- authorName = [globalUser.first_name, globalUser.last_name].filter(Boolean).join(' ');
68
- }
69
- if (!authorEmail) {
70
- authorEmail = globalUser.email;
71
- }
65
+ if (!authorName) authorName = [globalUser.first_name, globalUser.last_name].filter(Boolean).join(' ');
66
+ if (!authorEmail) authorEmail = globalUser.email;
72
67
  }
73
68
  }
74
69
 
75
- // Fail if still no identity
76
70
  if (!authorName || !authorEmail) {
77
71
  spinner.stop();
78
72
  console.error(chalk.red('Author identity unknown'));
@@ -80,92 +74,106 @@ async function commit(options) {
80
74
  return;
81
75
  }
82
76
 
83
- // Create commit object
84
- const commit = {
85
- sha: generateCommitHash(),
86
- message: message,
87
- author: {
88
- name: authorName,
89
- email: authorEmail
90
- },
91
- timestamp: new Date().toISOString(),
92
- parent: repository.branches[repository.currentBranch] ? [repository.branches[repository.currentBranch]] : [],
93
- files: []
94
- };
77
+ // Build tree entries from staged data
78
+ let treeEntries = [];
79
+
80
+ if (stagedEntries.length > 0) {
81
+ // New format: entries already have blob hashes from gent add
82
+ // Carry forward unchanged files from parent commit
83
+ const parentHash = repository.branches[repository.currentBranch] || null;
84
+ const parentCommit = parentHash
85
+ ? (repository.commits || []).find(c => c.hash === parentHash)
86
+ : null;
87
+ const parentTree = parentCommit && parentCommit.tree
88
+ ? parentCommit.tree
89
+ : (parentCommit ? parentCommit.files.map(f => ({ mode: '100644', name: f.path, hash: f.hash, type: 'blob' })) : []);
90
+
91
+ // Start from parent tree, overlay staged changes
92
+ const treeMap = new Map(parentTree.map(e => [e.name, e]));
93
+
94
+ for (const entry of stagedEntries) {
95
+ if (entry.status === 'deleted') {
96
+ treeMap.delete(entry.path);
97
+ } else {
98
+ treeMap.set(entry.path, {
99
+ mode: '100644',
100
+ name: entry.path,
101
+ hash: entry.hash,
102
+ type: 'blob'
103
+ });
104
+ }
105
+ }
95
106
 
96
- // Create a map of parent files for quick lookup
97
- const parentFilesMap = new Map();
98
- if (commit.parent && commit.parent.length > 0) {
99
- const parentSha = commit.parent[0];
100
- const parentCommit = repository.commits.find(c => c.sha === parentSha);
101
- if (parentCommit) {
102
- parentCommit.files.forEach(f => parentFilesMap.set(f.path, f.hash));
107
+ treeEntries = Array.from(treeMap.values());
108
+ } else {
109
+ // Legacy fallback: files array without blob hashes
110
+ for (const file of stagedFiles) {
111
+ const entry = await snapshotFile(gentPath, cwd, file);
112
+ treeEntries.push(entry);
103
113
  }
104
114
  }
105
115
 
106
- // Hash staged files and compute diffs
107
- for (const file of stagedFiles) {
108
- const filePath = path.join(cwd, file);
109
- let content;
110
-
111
- try {
112
- content = await fs.readFile(filePath, 'utf-8');
113
- } catch (err) {
114
- // If file is deleted or unreadable, we handle it as empty content or handle deletion if we supported it
115
- // For now assuming staged files exist
116
- content = '';
116
+ // Store tree object
117
+ const treeHash = await storeTree(gentPath, treeEntries);
118
+
119
+ // Compute diff stats
120
+ let totalInsertions = 0;
121
+ let totalDeletions = 0;
122
+ if (stagedEntries.length > 0) {
123
+ for (const e of stagedEntries) {
124
+ if (e.stats) {
125
+ totalInsertions += e.stats.insertions || 0;
126
+ totalDeletions += e.stats.deletions || 0;
127
+ }
117
128
  }
129
+ }
118
130
 
119
- const hash = await getFileHash(filePath);
120
-
121
- // Save the blob for future diffs
122
- await saveBlob(content, hash);
123
-
124
- // Get previous content
125
- let oldContent = '';
126
- if (parentFilesMap.has(file)) {
127
- const oldHash = parentFilesMap.get(file);
128
- oldContent = await getBlob(oldHash);
131
+ // Create commit object
132
+ const commitObj = {
133
+ hash: generateCommitHash(),
134
+ message,
135
+ author: {
136
+ name: authorName,
137
+ email: authorEmail
138
+ },
139
+ timestamp: new Date().toISOString(),
140
+ parent: repository.branches[repository.currentBranch] || null,
141
+ treeHash,
142
+ tree: treeEntries,
143
+ files: treeEntries.map(e => ({ path: e.name, hash: e.hash })), // backward compat
144
+ stats: {
145
+ filesChanged: stagedEntries.length || stagedFiles.length,
146
+ insertions: totalInsertions,
147
+ deletions: totalDeletions
129
148
  }
149
+ };
130
150
 
131
- // Compute Myers diff
132
- const changes = computeDiff(oldContent, content);
133
-
134
- commit.files.push({
135
- path: file,
136
- hash: hash,
137
- diff: changes
138
- });
139
- }
140
-
141
- // Add commit to repository
151
+ // Save commit
142
152
  repository.commits = repository.commits || [];
143
- repository.commits.push(commit);
144
- repository.branches[repository.currentBranch] = commit.sha;
153
+ repository.commits.push(commitObj);
154
+ repository.branches[repository.currentBranch] = commitObj.hash;
145
155
 
146
- // Save repository and clear staging
147
156
  await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
157
+
158
+ // Clear staging
159
+ staging.entries = [];
148
160
  staging.files = [];
149
161
  await writeJSON(path.join(gentPath, STAGING_FILE), staging);
150
162
 
151
- spinner.succeed(chalk.green('Changes committed successfully!'));
152
-
153
- // Display commit info
154
- console.log(chalk.cyan(`\n[${repository.currentBranch} ${commit.sha.substring(0, 7)}] ${message}`));
155
- console.log(chalk.gray(`Author: ${commit.author.name} <${commit.author.email}>`));
156
- console.log(chalk.gray(`Date: ${new Date(commit.timestamp).toLocaleString()}`));
157
- console.log(chalk.gray(`\n${commit.files.length} file(s) changed`));
163
+ spinner.succeed(chalk.green('Changes committed successfully!'));
158
164
 
159
- // Handle --push option
160
- if (options.push) {
161
- const push = require('./push');
162
- await push('origin', repository.currentBranch, {});
163
- }
165
+ console.log(chalk.cyan(`\n[${repository.currentBranch} ${commitObj.hash.substring(0, 7)}] ${message}`));
166
+ console.log(chalk.gray(`Author: ${commitObj.author.name} <${commitObj.author.email}>`));
167
+ console.log(chalk.gray(`Date: ${new Date(commitObj.timestamp).toLocaleString()}`));
168
+ console.log(chalk.gray(`Tree: ${treeHash.substring(0, 7)}`));
169
+ console.log(chalk.gray(`\n${commitObj.stats.filesChanged} file(s) changed, `) +
170
+ chalk.green(`+${totalInsertions} insertions`) + ', ' +
171
+ chalk.red(`-${totalDeletions} deletions`));
164
172
 
165
173
  } catch (error) {
166
174
  if (error.code === 'ENOENT' && error.message.includes('.gent')) {
167
175
  console.error(chalk.red('Error: Not a gent repository'));
168
- console.log(chalk.yellow('\nℹ Run "gent init" to initialize a repository'));
176
+ console.log(chalk.yellow('\nRun "gent init" to initialize a repository'));
169
177
  } else {
170
178
  console.error(chalk.red('Error:'), error.message);
171
179
  }