gent-cli 2.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,14 +2,13 @@
2
2
 
3
3
  > A modern, Git-like version control CLI with built-in cloud authentication and global user identity management.
4
4
 
5
- Gent is a lightweight version control system that feels exactly like Git but handles user identity automatically through the cloud. No more configuring `user.name` and `user.email` for every repository!
5
+ Gent is a lightweight version control system that feels exactly like Git but handles user identity automatically through the cloud. No more configuring user.name and user.email for every repository!
6
6
 
7
7
  ## Features
8
8
 
9
9
  - **Cloud Authentication**: Login once, work everywhere. Your identity follows you across projects.
10
10
  - **Git-like Experience**: Familiar commands (init, add, commit, status, log, branch, checkout).
11
- - **Cloud Synchronization**: Push and pull repositories to/from the cloud.
12
- - **Zero Configuration**: `gent init` is silent and auto-detects your authenticated user profile.
11
+ - **Zero Configuration**: gent init is silent and auto-detects your authenticated user profile.
13
12
  - **Global Identity**: Commits are automatically authored with your cloud profile.
14
13
  - **Secure**: Tokens stored securely in your home directory.
15
14
 
@@ -49,10 +48,12 @@ gent whoami
49
48
  gent logout
50
49
  ```
51
50
 
52
- ## Local Usage
51
+ ## Usage
53
52
 
54
53
  ### 1. Initialize a Repository
55
54
 
55
+ Just like Git, gent init is silent and sets up a new repository in your current directory. It automatically uses your logged-in identity for configuration.
56
+
56
57
  ```bash
57
58
  gent init
58
59
  # Output: Initialized empty Gent repository in /path/to/project
@@ -60,12 +61,16 @@ gent init
60
61
 
61
62
  ### 2. Check Status
62
63
 
64
+ See which files are modified or untracked.
65
+
63
66
  ```bash
64
67
  gent status
65
68
  ```
66
69
 
67
70
  ### 3. Stage Files
68
71
 
72
+ Add files to the staging area.
73
+
69
74
  ```bash
70
75
  gent add filename.js
71
76
  # or add all files
@@ -74,74 +79,55 @@ gent add .
74
79
 
75
80
  ### 4. Commit Changes
76
81
 
82
+ Create a commit. Gent automatically fetches your name and email from your global login session.
83
+
77
84
  ```bash
78
85
  gent commit -m "Initial commit"
79
86
  # Output: [main a1b2c3d] Initial commit
80
87
  # Author: Your Name <your.email@example.com>
81
88
  ```
82
89
 
83
- ## Cloud Features
84
-
85
- ### 1. Create a Cloud Repository
86
-
87
- ```bash
88
- # Create and link a local repo
89
- gent create my-repo --init-local
90
-
91
- # Or initialize with cloud directly
92
- gent init --cloud
93
- ```
90
+ ### 5. View History
94
91
 
95
- ### 2. List Your Repositories
92
+ See your commit history.
96
93
 
97
94
  ```bash
98
- gent list
99
- # or
100
- gent ls
95
+ gent log
96
+ # or compact view
97
+ gent log --oneline
101
98
  ```
102
99
 
103
- ### 3. Clone a Repository
100
+ ## Branching
104
101
 
105
- ```bash
106
- gent clone <owner_id>/<repo_name>
107
- # Example: gent clone 1/my-repo
108
- ```
109
-
110
- ### 4. Push Changes
102
+ Manage branches just like you're used to.
111
103
 
112
104
  ```bash
113
- gent push
114
- # or commit and push in one go
115
- gent commit -m "Update README" --push
116
- ```
105
+ # Create and switch to a new branch
106
+ gent checkout -b feature-login
117
107
 
118
- ### 5. Pull Changes
108
+ # List branches
109
+ gent branch
119
110
 
120
- ```bash
121
- gent pull
122
- ```
111
+ # Switch back to main
112
+ gent checkout main
123
113
 
124
- ### 6. Manage Remotes
125
-
126
- ```bash
127
- gent remote add origin <owner_id>/<repo_name>
128
- gent remote -v
114
+ # Delete a branch
115
+ gent branch -d feature-login
129
116
  ```
130
117
 
131
118
  ## Repository Structure
132
119
 
133
- Gent creates a `.gent` directory in your project root:
120
+ Gent creates a .gent directory in your project root:
134
121
 
135
122
  ```
136
123
  .gent/
137
124
  ├── config.json # Project configuration
138
125
  ├── objects/ # Stored file contents
139
126
  ├── refs/ # Branch pointers
140
- ├── remote.json # Remote configuration
141
127
  └── HEAD # Current branch reference
142
128
  ```
143
129
 
144
- Your authentication tokens are stored globally in `~/.gent/auth.json`.
130
+ Your authentication tokens are stored globally in ~/.gent/auth.json.
145
131
 
146
132
  ## Contributing
147
133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gent-cli",
3
- "version": "2.0.0",
3
+ "version": "5.0.0",
4
4
  "description": "A modern, Git-like version control CLI with built-in cloud authentication and global user identity management.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * Add Command - Add file contents to the staging area
3
- * Stages files for the next commit
3
+ * Stages files with content snapshots (blobs) and diff stats
4
4
  */
5
5
 
6
+ const fs = require('fs').promises;
6
7
  const path = require('path');
7
8
  const chalk = require('chalk');
8
9
  const ora = require('ora');
9
10
  const { getGentPath, readJSON, writeJSON, pathExists, getAllFiles, getIgnorePatterns } = require('../utils/fileSystem');
10
- const { STAGING_FILE } = require('../utils/constants');
11
+ const { STAGING_FILE, COMMITS_FILE } = require('../utils/constants');
12
+ const { storeBlob, hashBlob, isBinaryBuffer, snapshotFile } = require('../utils/hash-engine');
13
+ const { diffText, formatUnifiedDiff } = require('../utils/diff-engine');
11
14
 
12
15
  /**
13
16
  * Add files to staging area
@@ -24,7 +27,19 @@ async function add(files, options) {
24
27
 
25
28
  // Read current staging area
26
29
  const staging = await readJSON(stagingPath);
27
- const stagedFiles = new Set(staging.files || []);
30
+ const stagedEntries = staging.entries || [];
31
+ const stagedMap = new Map(stagedEntries.map(e => [e.path, e]));
32
+
33
+ // Get last commit tree for diff comparison
34
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
35
+ const lastCommitHash = repository.branches[repository.currentBranch] || null;
36
+ const lastCommit = lastCommitHash
37
+ ? (repository.commits || []).find(c => c.hash === lastCommitHash)
38
+ : null;
39
+ const lastTreeMap = new Map(
40
+ (lastCommit && lastCommit.tree ? lastCommit.tree : (lastCommit ? lastCommit.files : []))
41
+ .map(f => [f.path || f.name, f.hash])
42
+ );
28
43
 
29
44
  let filesToAdd = [];
30
45
 
@@ -35,49 +50,113 @@ async function add(files, options) {
35
50
  const allFiles = await getAllFiles(cwd, ignorePatterns);
36
51
  filesToAdd = allFiles.map(f => path.relative(cwd, f));
37
52
  } else {
38
- // Add specified files
39
53
  for (const file of files) {
40
54
  const filePath = path.resolve(cwd, file);
41
-
42
55
  if (!await pathExists(filePath)) {
43
56
  spinner.warn(chalk.yellow(`Warning: File not found: ${file}`));
44
57
  continue;
45
58
  }
46
-
47
- const relativePath = path.relative(cwd, filePath);
48
- filesToAdd.push(relativePath);
59
+ filesToAdd.push(path.relative(cwd, filePath));
49
60
  }
50
61
  }
51
62
 
52
- // Add files to staging
63
+ // Snapshot each file → store blob, compute diff
53
64
  let addedCount = 0;
54
- for (const file of filesToAdd) {
55
- if (!stagedFiles.has(file)) {
56
- stagedFiles.add(file);
65
+ let totalInsertions = 0;
66
+ let totalDeletions = 0;
67
+ const diffSummaries = [];
68
+
69
+ for (const relPath of filesToAdd) {
70
+ const fullPath = path.join(cwd, relPath);
71
+ const content = await fs.readFile(fullPath);
72
+
73
+ // Skip binary files for diff (still store blob)
74
+ const binary = isBinaryBuffer(content);
75
+ const blobHash = await storeBlob(gentPath, content);
76
+
77
+ // Check if changed vs last commit
78
+ const prevHash = lastTreeMap.get(relPath);
79
+ if (prevHash === blobHash && stagedMap.has(relPath)) {
80
+ continue; // unchanged, already staged
81
+ }
82
+
83
+ // Determine change status
84
+ let status = 'added';
85
+ let stats = { insertions: 0, deletions: 0 };
86
+
87
+ if (prevHash && prevHash !== blobHash && !binary) {
88
+ status = 'modified';
89
+ try {
90
+ const { readBlobAsString } = require('../utils/hash-engine');
91
+ const oldContent = await readBlobAsString(gentPath, prevHash);
92
+ const diff = diffText(oldContent, content.toString('utf-8'));
93
+ stats = { insertions: diff.stats.insertions, deletions: diff.stats.deletions };
94
+ } catch {
95
+ // Old blob may not exist yet (first time adding objects)
96
+ }
97
+ } else if (!prevHash) {
98
+ status = 'added';
99
+ stats.insertions = content.toString('utf-8').split('\n').length;
100
+ }
101
+
102
+ totalInsertions += stats.insertions;
103
+ totalDeletions += stats.deletions;
104
+
105
+ // Update staging entry
106
+ stagedMap.set(relPath, {
107
+ path: relPath,
108
+ hash: blobHash,
109
+ status,
110
+ binary,
111
+ stats
112
+ });
113
+
114
+ diffSummaries.push({ path: relPath, status, stats, binary });
115
+ addedCount++;
116
+ }
117
+
118
+ // Detect deleted files (tracked in last commit but gone from disk)
119
+ for (const [trackedPath, trackedHash] of lastTreeMap) {
120
+ const fullPath = path.join(cwd, trackedPath);
121
+ if (!await pathExists(fullPath) && !stagedMap.has(trackedPath)) {
122
+ stagedMap.set(trackedPath, {
123
+ path: trackedPath,
124
+ hash: null,
125
+ status: 'deleted',
126
+ binary: false,
127
+ stats: { insertions: 0, deletions: 0 }
128
+ });
129
+ diffSummaries.push({ path: trackedPath, status: 'deleted', stats: { insertions: 0, deletions: 0 }, binary: false });
57
130
  addedCount++;
58
131
  }
59
132
  }
60
133
 
61
- // Save staging area
62
- staging.files = Array.from(stagedFiles);
134
+ // Save staging area (new format with entries)
135
+ staging.entries = Array.from(stagedMap.values());
136
+ staging.files = staging.entries.map(e => e.path); // backward compat
63
137
  await writeJSON(stagingPath, staging);
64
138
 
65
- spinner.succeed(chalk.green(`✓ Added ${addedCount} file(s) to staging area`));
139
+ spinner.succeed(chalk.green(`Added ${addedCount} file(s) to staging area`));
66
140
 
67
- if (addedCount > 0) {
68
- console.log(chalk.gray('\nStaged files:'));
69
- staging.files.forEach(file => {
70
- console.log(chalk.green(` ${file}`));
71
- });
72
- console.log(chalk.cyan('\nℹ Use "gent commit" to record your changes'));
141
+ if (diffSummaries.length > 0) {
142
+ console.log('');
143
+ for (const d of diffSummaries) {
144
+ const statusIcon = d.status === 'added' ? chalk.green('+ new')
145
+ : d.status === 'deleted' ? chalk.red('- del')
146
+ : chalk.yellow('~ mod');
147
+ const statsStr = d.binary ? chalk.gray('(binary)')
148
+ : chalk.green(`+${d.stats.insertions}`) + ' ' + chalk.red(`-${d.stats.deletions}`);
149
+ console.log(` ${statusIcon} ${d.path} ${statsStr}`);
150
+ }
151
+ console.log(chalk.gray(`\n Total: `) + chalk.green(`+${totalInsertions}`) + ' ' + chalk.red(`-${totalDeletions}`));
152
+ console.log(chalk.cyan('\nUse "gent commit" to record your changes'));
73
153
  }
74
154
 
75
155
  } catch (error) {
76
156
  spinner.fail(chalk.red('Failed to add files'));
77
-
78
157
  if (error.code === 'ENOENT' && error.message.includes('.gent')) {
79
158
  console.error(chalk.red('\nError: Not a gent repository'));
80
- console.log(chalk.yellow('Run "gent init" to initialize a repository'));
159
+ console.log(chalk.yellow('Run "gent init" to initialize a repository'));
81
160
  } else {
82
161
  console.error(chalk.red('\nError:'), error.message);
83
162
  }
@@ -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
  }