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.
@@ -0,0 +1,255 @@
1
+ /**
2
+ * ============================================================================
3
+ * Stash Command - Temporarily shelve working tree changes
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Save uncommitted changes on a stack so you can switch branches cleanly,
8
+ * then restore them later. Like `git stash`.
9
+ *
10
+ * USAGE:
11
+ * gent stash → Stash all modified tracked files
12
+ * gent stash pop → Restore most recent stash and remove it
13
+ * gent stash list → List all stashed entries
14
+ * gent stash drop [index] → Drop a specific stash entry
15
+ * gent stash apply [index] → Apply stash without removing it
16
+ *
17
+ * ALGORITHM:
18
+ * Saved as JSON stack in .gent/stash.json. Each entry stores:
19
+ * - Snapshot of staged entries (staging.entries)
20
+ * - Blob hashes of modified working tree files
21
+ * After stashing, staging is cleared and files are restored to HEAD state.
22
+ *
23
+ * BACKEND EXPECTATIONS:
24
+ * Stash is local only. Backend does not need stash support.
25
+ *
26
+ * ============================================================================
27
+ */
28
+
29
+ const fs = require('fs').promises;
30
+ const path = require('path');
31
+ const chalk = require('chalk');
32
+ const ora = require('ora');
33
+ const { getGentPath, readJSON, writeJSON, pathExists, getAllFiles, getIgnorePatterns } = require('../utils/fileSystem');
34
+ const { STAGING_FILE, COMMITS_FILE } = require('../utils/constants');
35
+ const { storeBlob, readBlobAsString, hashBlob } = require('../utils/hash-engine');
36
+
37
+ const STASH_FILE = 'stash.json';
38
+
39
+ /**
40
+ * Stash management
41
+ * @param {String} subcommand - pop|list|drop|apply (null = stash push)
42
+ * @param {Object} options
43
+ */
44
+ async function stash(subcommand, options) {
45
+ try {
46
+ const gentPath = await getGentPath();
47
+ const cwd = process.cwd();
48
+
49
+ switch (subcommand) {
50
+ case 'pop':
51
+ await stashPop(gentPath, cwd, options);
52
+ break;
53
+ case 'list':
54
+ await stashList(gentPath);
55
+ break;
56
+ case 'drop':
57
+ await stashDrop(gentPath, options);
58
+ break;
59
+ case 'apply':
60
+ await stashApply(gentPath, cwd, options, false);
61
+ break;
62
+ default:
63
+ await stashPush(gentPath, cwd, options);
64
+ break;
65
+ }
66
+ } catch (error) {
67
+ if (error.code === 'ENOENT' && error.message.includes('.gent')) {
68
+ console.error(chalk.red('Error: Not a gent repository'));
69
+ } else {
70
+ console.error(chalk.red('Error:'), error.message);
71
+ }
72
+ process.exit(1);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Push current changes onto stash stack
78
+ */
79
+ async function stashPush(gentPath, cwd, options) {
80
+ const spinner = ora('Stashing changes...').start();
81
+
82
+ const staging = await readJSON(path.join(gentPath, STAGING_FILE));
83
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
84
+ const headHash = repository.branches[repository.currentBranch] || null;
85
+ const headCommit = headHash ? (repository.commits || []).find(c => c.hash === headHash) : null;
86
+
87
+ // Get HEAD tree
88
+ const headTree = new Map();
89
+ if (headCommit) {
90
+ const tree = headCommit.tree || headCommit.files || [];
91
+ for (const f of tree) headTree.set(f.path || f.name, f.hash);
92
+ }
93
+
94
+ // Collect modified working tree files
95
+ const ignorePatterns = await getIgnorePatterns(cwd);
96
+ const allFiles = await getAllFiles(cwd, ignorePatterns);
97
+ const workingChanges = [];
98
+
99
+ for (const absPath of allFiles) {
100
+ const relPath = path.relative(cwd, absPath);
101
+ const content = await fs.readFile(absPath, 'utf-8');
102
+ const currentHash = hashBlob(content);
103
+ const headBlobHash = headTree.get(relPath);
104
+
105
+ if (headBlobHash && currentHash !== headBlobHash) {
106
+ const blobHash = await storeBlob(gentPath, content);
107
+ workingChanges.push({ path: relPath, hash: blobHash });
108
+ }
109
+ }
110
+
111
+ const stagedEntries = staging.entries || [];
112
+
113
+ if (workingChanges.length === 0 && stagedEntries.length === 0) {
114
+ spinner.info(chalk.yellow('No local changes to stash'));
115
+ return;
116
+ }
117
+
118
+ // Save stash entry
119
+ const stashes = await readStashes(gentPath);
120
+ stashes.unshift({
121
+ message: options.message || `WIP on ${repository.currentBranch}`,
122
+ branch: repository.currentBranch,
123
+ timestamp: new Date().toISOString(),
124
+ stagedEntries: stagedEntries,
125
+ workingChanges: workingChanges
126
+ });
127
+ await writeStashes(gentPath, stashes);
128
+
129
+ // Clear staging
130
+ staging.entries = [];
131
+ staging.files = [];
132
+ await writeJSON(path.join(gentPath, STAGING_FILE), staging);
133
+
134
+ // Restore working tree to HEAD state
135
+ for (const change of workingChanges) {
136
+ const headBlobHash = headTree.get(change.path);
137
+ if (headBlobHash) {
138
+ try {
139
+ const content = await readBlobAsString(gentPath, headBlobHash);
140
+ await fs.writeFile(path.join(cwd, change.path), content, 'utf-8');
141
+ } catch { /* best effort */ }
142
+ }
143
+ }
144
+
145
+ spinner.succeed(chalk.green(`Stashed ${stagedEntries.length} staged + ${workingChanges.length} working tree changes`));
146
+ }
147
+
148
+ /**
149
+ * Pop: apply and remove most recent stash
150
+ */
151
+ async function stashPop(gentPath, cwd, options) {
152
+ await stashApply(gentPath, cwd, options, true);
153
+ }
154
+
155
+ /**
156
+ * Apply stash entry to working tree
157
+ */
158
+ async function stashApply(gentPath, cwd, options, removAfter) {
159
+ const index = options.index ? parseInt(options.index) : 0;
160
+ const stashes = await readStashes(gentPath);
161
+
162
+ if (stashes.length === 0) {
163
+ console.log(chalk.yellow('No stash entries'));
164
+ return;
165
+ }
166
+
167
+ if (index >= stashes.length) {
168
+ console.error(chalk.red(`stash@{${index}} does not exist`));
169
+ return;
170
+ }
171
+
172
+ const entry = stashes[index];
173
+
174
+ // Restore working tree changes
175
+ for (const change of (entry.workingChanges || [])) {
176
+ try {
177
+ const content = await readBlobAsString(gentPath, change.hash);
178
+ const fullPath = path.join(cwd, change.path);
179
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
180
+ await fs.writeFile(fullPath, content, 'utf-8');
181
+ } catch { /* blob missing */ }
182
+ }
183
+
184
+ // Restore staged entries
185
+ if (entry.stagedEntries && entry.stagedEntries.length > 0) {
186
+ const staging = await readJSON(path.join(gentPath, STAGING_FILE));
187
+ staging.entries = entry.stagedEntries;
188
+ staging.files = entry.stagedEntries.map(e => e.path);
189
+ await writeJSON(path.join(gentPath, STAGING_FILE), staging);
190
+ }
191
+
192
+ if (removAfter) {
193
+ stashes.splice(index, 1);
194
+ await writeStashes(gentPath, stashes);
195
+ }
196
+
197
+ console.log(chalk.green(`Applied stash@{${index}}: ${entry.message}`));
198
+ }
199
+
200
+ /**
201
+ * List stash entries
202
+ */
203
+ async function stashList(gentPath) {
204
+ const stashes = await readStashes(gentPath);
205
+
206
+ if (stashes.length === 0) {
207
+ console.log(chalk.gray('No stash entries'));
208
+ return;
209
+ }
210
+
211
+ stashes.forEach((entry, i) => {
212
+ const ts = new Date(entry.timestamp).toLocaleString();
213
+ console.log(
214
+ chalk.yellow(`stash@{${i}}: `) +
215
+ chalk.white(entry.message) +
216
+ chalk.gray(` (${entry.branch}, ${ts})`)
217
+ );
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Drop stash entry
223
+ */
224
+ async function stashDrop(gentPath, options) {
225
+ const index = options.index ? parseInt(options.index) : 0;
226
+ const stashes = await readStashes(gentPath);
227
+
228
+ if (index >= stashes.length) {
229
+ console.error(chalk.red(`stash@{${index}} does not exist`));
230
+ return;
231
+ }
232
+
233
+ const removed = stashes.splice(index, 1);
234
+ await writeStashes(gentPath, stashes);
235
+ console.log(chalk.green(`Dropped stash@{${index}}: ${removed[0].message}`));
236
+ }
237
+
238
+ /**
239
+ * Read stash stack from disk
240
+ */
241
+ async function readStashes(gentPath) {
242
+ const stashPath = path.join(gentPath, STASH_FILE);
243
+ if (!await pathExists(stashPath)) return [];
244
+ const data = await readJSON(stashPath);
245
+ return data.stashes || [];
246
+ }
247
+
248
+ /**
249
+ * Write stash stack to disk
250
+ */
251
+ async function writeStashes(gentPath, stashes) {
252
+ await writeJSON(path.join(gentPath, STASH_FILE), { stashes });
253
+ }
254
+
255
+ module.exports = stash;
@@ -1,14 +1,33 @@
1
1
  /**
2
- * Status Command - Show the working tree status
3
- * Displays staged, modified, and untracked files
2
+ * ============================================================================
3
+ * Status Command - Show working tree status
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Display staged, modified, untracked, and deleted files. Like `git status`.
8
+ *
9
+ * USAGE:
10
+ * gent status → Detailed status
11
+ * gent status -s → Short format
12
+ *
13
+ * ALGORITHM:
14
+ * Computes SHA-256 blob hash for each working file, compares against:
15
+ * - Staging entries (from gent add)
16
+ * - Last commit tree (from commits.json)
17
+ * Classifies files as staged/modified/untracked/deleted.
18
+ *
19
+ * BACKEND EXPECTATIONS:
20
+ * None (local only).
21
+ *
22
+ * ============================================================================
4
23
  */
5
24
 
6
25
  const fs = require('fs').promises;
7
26
  const path = require('path');
8
27
  const chalk = require('chalk');
9
- const { getGentPath, readJSON, pathExists, getTrackedFiles, getIgnorePatterns } = require('../utils/fileSystem');
28
+ const { getGentPath, readJSON, pathExists, getIgnorePatterns, getAllFiles } = require('../utils/fileSystem');
10
29
  const { STAGING_FILE, COMMITS_FILE } = require('../utils/constants');
11
- const { getFileHash, getAllFiles } = require('../utils/helpers');
30
+ const { hashBlob } = require('../utils/hash-engine');
12
31
 
13
32
  /**
14
33
  * Show repository status
@@ -19,59 +38,66 @@ async function status(options) {
19
38
  const gentPath = await getGentPath();
20
39
  const cwd = process.cwd();
21
40
 
22
- // Read staging area and commits
23
41
  const staging = await readJSON(path.join(gentPath, STAGING_FILE));
24
42
  const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
25
43
 
26
- const stagedFiles = staging.files || [];
44
+ const stagedEntries = staging.entries || [];
45
+ const stagedFiles = stagedEntries.map(e => e.path);
27
46
  const currentBranch = repository.currentBranch || 'main';
28
- const lastCommit = repository.branches[currentBranch];
47
+ const lastCommitHash = repository.branches[currentBranch];
48
+
49
+ // Build HEAD tree map: path → blobHash
50
+ const lastCommit = lastCommitHash
51
+ ? (repository.commits || []).find(c => c.hash === lastCommitHash)
52
+ : null;
53
+ const trackedMap = new Map();
54
+ if (lastCommit) {
55
+ const tree = lastCommit.tree || lastCommit.files || [];
56
+ for (const f of tree) trackedMap.set(f.path || f.name, f.hash);
57
+ }
29
58
 
30
- // Get all files in the working directory
59
+ // Get all working directory files
31
60
  const ignorePatterns = await getIgnorePatterns(cwd);
32
61
  const allFiles = await getAllFiles(cwd, ignorePatterns);
33
62
 
34
- // Get tracked files from last commit
35
- const trackedFiles = await getTrackedFiles(gentPath, lastCommit);
36
-
37
- // Categorize files
38
63
  const stagedSet = new Set(stagedFiles);
39
- const trackedSet = new Set(trackedFiles.map(f => f.path));
40
-
41
64
  const modified = [];
42
65
  const untracked = [];
43
66
  const deleted = [];
44
67
 
45
- // Check for modifications and untracked files
46
- for (const file of allFiles) {
47
- const relativePath = path.relative(cwd, file);
48
-
49
- if (trackedSet.has(relativePath)) {
50
- // Check if modified
51
- const currentHash = await getFileHash(file);
52
- const trackedFile = trackedFiles.find(f => f.path === relativePath);
53
-
54
- if (trackedFile && currentHash !== trackedFile.hash && !stagedSet.has(relativePath)) {
55
- modified.push(relativePath);
68
+ // Check working tree against HEAD
69
+ for (const absFile of allFiles) {
70
+ const relPath = path.relative(cwd, absFile);
71
+ const headHash = trackedMap.get(relPath);
72
+
73
+ if (headHash) {
74
+ // Tracked file check modification via blob hash
75
+ const content = await fs.readFile(absFile);
76
+ const currentHash = hashBlob(content);
77
+ if (currentHash !== headHash && !stagedSet.has(relPath)) {
78
+ modified.push(relPath);
56
79
  }
57
- } else if (!stagedSet.has(relativePath)) {
58
- untracked.push(relativePath);
80
+ } else if (!stagedSet.has(relPath)) {
81
+ untracked.push(relPath);
59
82
  }
60
83
  }
61
84
 
62
85
  // Check for deleted files
63
- for (const trackedFile of trackedFiles) {
64
- const fullPath = path.join(cwd, trackedFile.path);
65
- if (!await pathExists(fullPath) && !stagedSet.has(trackedFile.path)) {
66
- deleted.push(trackedFile.path);
86
+ for (const [trackedPath] of trackedMap) {
87
+ const fullPath = path.join(cwd, trackedPath);
88
+ if (!await pathExists(fullPath) && !stagedSet.has(trackedPath)) {
89
+ deleted.push(trackedPath);
67
90
  }
68
91
  }
69
92
 
70
- // Display status
93
+ // Check for merge in progress
94
+ const mergeState = staging.mergeState || null;
95
+
96
+ // Display
71
97
  if (options.short) {
72
- displayShortStatus(stagedFiles, modified, untracked, deleted);
98
+ displayShortStatus(stagedEntries, modified, untracked, deleted);
73
99
  } else {
74
- displayDetailedStatus(currentBranch, stagedFiles, modified, untracked, deleted, lastCommit);
100
+ displayDetailedStatus(currentBranch, stagedEntries, modified, untracked, deleted, lastCommitHash, mergeState);
75
101
  }
76
102
 
77
103
  } catch (error) {
@@ -88,9 +114,13 @@ async function status(options) {
88
114
  /**
89
115
  * Display detailed status output
90
116
  */
91
- function displayDetailedStatus(branch, staged, modified, untracked, deleted, lastCommit) {
117
+ function displayDetailedStatus(branch, stagedEntries, modified, untracked, deleted, lastCommit, mergeState) {
92
118
  console.log(chalk.bold(`On branch ${chalk.cyan(branch)}`));
93
119
 
120
+ if (mergeState) {
121
+ console.log(chalk.yellow(`Merging branch '${mergeState.sourceBranch}'`));
122
+ }
123
+
94
124
  if (!lastCommit) {
95
125
  console.log(chalk.gray('No commits yet\n'));
96
126
  } else {
@@ -98,12 +128,15 @@ function displayDetailedStatus(branch, staged, modified, untracked, deleted, las
98
128
  }
99
129
 
100
130
  // Staged files
101
- if (staged.length > 0) {
131
+ if (stagedEntries.length > 0) {
102
132
  console.log(chalk.green.bold('Changes to be committed:'));
103
133
  console.log(chalk.gray(' (use "gent reset <file>..." to unstage)\n'));
104
- staged.forEach(file => {
105
- console.log(chalk.green(`\t${file}`));
106
- });
134
+ for (const entry of stagedEntries) {
135
+ const icon = entry.status === 'added' ? 'new file: '
136
+ : entry.status === 'deleted' ? 'deleted: '
137
+ : 'modified: ';
138
+ console.log(chalk.green(`\t${icon}${entry.path}`));
139
+ }
107
140
  console.log();
108
141
  }
109
142
 
@@ -136,9 +169,9 @@ function displayDetailedStatus(branch, staged, modified, untracked, deleted, las
136
169
  }
137
170
 
138
171
  // Status summary
139
- if (staged.length === 0 && modified.length === 0 && untracked.length === 0 && deleted.length === 0) {
140
- console.log(chalk.green('Working tree clean'));
141
- } else if (staged.length === 0) {
172
+ if (stagedEntries.length === 0 && modified.length === 0 && untracked.length === 0 && deleted.length === 0) {
173
+ console.log(chalk.green('Working tree clean'));
174
+ } else if (stagedEntries.length === 0) {
142
175
  console.log(chalk.yellow('No changes added to commit (use "gent add" to track files)'));
143
176
  }
144
177
  }
@@ -146,10 +179,11 @@ function displayDetailedStatus(branch, staged, modified, untracked, deleted, las
146
179
  /**
147
180
  * Display short status output
148
181
  */
149
- function displayShortStatus(staged, modified, untracked, deleted) {
150
- staged.forEach(file => {
151
- console.log(chalk.green('A ') + file);
152
- });
182
+ function displayShortStatus(stagedEntries, modified, untracked, deleted) {
183
+ for (const entry of stagedEntries) {
184
+ const code = entry.status === 'added' ? 'A ' : entry.status === 'deleted' ? 'D ' : 'M ';
185
+ console.log(chalk.green(code) + entry.path);
186
+ }
153
187
 
154
188
  modified.forEach(file => {
155
189
  console.log(chalk.red(' M ') + file);
@@ -0,0 +1,146 @@
1
+ /**
2
+ * ============================================================================
3
+ * Tag Command - Create, list, and delete named references to commits
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Mark specific commits with version labels (e.g. v1.0.0). Like `git tag`.
8
+ *
9
+ * USAGE:
10
+ * gent tag → List all tags
11
+ * gent tag <name> → Create lightweight tag on HEAD
12
+ * gent tag <name> -m <msg> → Create annotated tag with message
13
+ * gent tag -d <name> → Delete a tag
14
+ *
15
+ * ALGORITHM:
16
+ * Tags stored in commits.json under "tags" map: { name → { hash, message, ... } }
17
+ * Lightweight tag = just a name pointing to commit hash.
18
+ * Annotated tag = includes tagger info, message, timestamp.
19
+ *
20
+ * BACKEND EXPECTATIONS:
21
+ * POST /api/repos/:id/tags/ { name, hash, message, annotated }
22
+ * GET /api/repos/:id/tags/
23
+ * DELETE /api/repos/:id/tags/:name/
24
+ *
25
+ * ============================================================================
26
+ */
27
+
28
+ const path = require('path');
29
+ const chalk = require('chalk');
30
+ const { getGentPath, readJSON, writeJSON } = require('../utils/fileSystem');
31
+ const { COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
32
+ const authStorage = require('../utils/auth-storage');
33
+
34
+ /**
35
+ * Manage tags
36
+ * @param {String} name - Tag name (optional)
37
+ * @param {Object} options
38
+ */
39
+ async function tag(name, options) {
40
+ try {
41
+ const gentPath = await getGentPath();
42
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
43
+ repository.tags = repository.tags || {};
44
+
45
+ if (options.delete) {
46
+ await deleteTag(options.delete, repository, gentPath);
47
+ return;
48
+ }
49
+
50
+ if (name) {
51
+ await createTag(name, repository, gentPath, options);
52
+ return;
53
+ }
54
+
55
+ // List tags
56
+ listTags(repository);
57
+
58
+ } catch (error) {
59
+ if (error.code === 'ENOENT' && error.message.includes('.gent')) {
60
+ console.error(chalk.red('Error: Not a gent repository'));
61
+ } else {
62
+ console.error(chalk.red('Error:'), error.message);
63
+ }
64
+ process.exit(1);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * List all tags
70
+ */
71
+ function listTags(repository) {
72
+ const tags = Object.keys(repository.tags).sort();
73
+
74
+ if (tags.length === 0) {
75
+ console.log(chalk.gray('No tags'));
76
+ return;
77
+ }
78
+
79
+ for (const tagName of tags) {
80
+ const t = repository.tags[tagName];
81
+ const short = t.hash ? t.hash.substring(0, 7) : '???????';
82
+ const msg = t.message ? chalk.gray(` — ${t.message}`) : '';
83
+ console.log(chalk.yellow(tagName) + chalk.gray(` → ${short}`) + msg);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Create a tag
89
+ */
90
+ async function createTag(name, repository, gentPath, options) {
91
+ if (repository.tags[name]) {
92
+ console.error(chalk.red(`Tag '${name}' already exists`));
93
+ process.exit(1);
94
+ }
95
+
96
+ const currentBranch = repository.currentBranch;
97
+ const commitHash = repository.branches[currentBranch];
98
+
99
+ if (!commitHash) {
100
+ console.error(chalk.red('No commits to tag'));
101
+ return;
102
+ }
103
+
104
+ const tagObj = { hash: commitHash };
105
+
106
+ // Annotated tag
107
+ if (options.message) {
108
+ const config = await readJSON(path.join(gentPath, CONFIG_FILE));
109
+ let taggerName = config.user.name;
110
+ let taggerEmail = config.user.email;
111
+
112
+ if (!taggerName || !taggerEmail) {
113
+ const user = await authStorage.getUser();
114
+ if (user) {
115
+ taggerName = taggerName || [user.first_name, user.last_name].filter(Boolean).join(' ');
116
+ taggerEmail = taggerEmail || user.email;
117
+ }
118
+ }
119
+
120
+ tagObj.message = options.message;
121
+ tagObj.annotated = true;
122
+ tagObj.tagger = { name: taggerName || 'Unknown', email: taggerEmail || '' };
123
+ tagObj.timestamp = new Date().toISOString();
124
+ }
125
+
126
+ repository.tags[name] = tagObj;
127
+ await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
128
+
129
+ console.log(chalk.green(`Created tag '${name}' → ${commitHash.substring(0, 7)}`));
130
+ }
131
+
132
+ /**
133
+ * Delete a tag
134
+ */
135
+ async function deleteTag(name, repository, gentPath) {
136
+ if (!repository.tags[name]) {
137
+ console.error(chalk.red(`Tag '${name}' not found`));
138
+ process.exit(1);
139
+ }
140
+
141
+ delete repository.tags[name];
142
+ await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
143
+ console.log(chalk.green(`Deleted tag '${name}'`));
144
+ }
145
+
146
+ module.exports = tag;