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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * ============================================================================
3
+ * Rm Command - Remove files from working tree and staging
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Remove files from tracking and optionally from disk. Like `git rm`.
8
+ *
9
+ * USAGE:
10
+ * gent rm <file...> → Remove file(s) from tracking + disk
11
+ * gent rm --cached <file...> → Remove from staging only (keep on disk)
12
+ *
13
+ * ALGORITHM:
14
+ * Adds a "deleted" entry to staging so commit records the removal.
15
+ * Without --cached, also deletes the file from the filesystem.
16
+ *
17
+ * BACKEND EXPECTATIONS:
18
+ * Deletion is recorded as absence in the commit tree. Backend removes
19
+ * file entry from tree when processing the push.
20
+ *
21
+ * ============================================================================
22
+ */
23
+
24
+ const fs = require('fs').promises;
25
+ const path = require('path');
26
+ const chalk = require('chalk');
27
+ const { getGentPath, readJSON, writeJSON, pathExists } = require('../utils/fileSystem');
28
+ const { STAGING_FILE } = require('../utils/constants');
29
+
30
+ /**
31
+ * Remove files
32
+ * @param {Array} files
33
+ * @param {Object} options
34
+ */
35
+ async function rm(files, options) {
36
+ try {
37
+ const gentPath = await getGentPath();
38
+ const cwd = process.cwd();
39
+ const stagingPath = path.join(gentPath, STAGING_FILE);
40
+ const staging = await readJSON(stagingPath);
41
+
42
+ const entries = staging.entries || [];
43
+ const entryMap = new Map(entries.map(e => [e.path, e]));
44
+ let removed = 0;
45
+
46
+ for (const file of files) {
47
+ const relPath = path.relative(cwd, path.resolve(cwd, file));
48
+
49
+ // Stage deletion
50
+ entryMap.set(relPath, {
51
+ path: relPath,
52
+ hash: null,
53
+ status: 'deleted',
54
+ binary: false,
55
+ stats: { insertions: 0, deletions: 0 }
56
+ });
57
+
58
+ // Delete from disk unless --cached
59
+ if (!options.cached) {
60
+ const fullPath = path.join(cwd, relPath);
61
+ if (await pathExists(fullPath)) {
62
+ await fs.unlink(fullPath);
63
+ }
64
+ }
65
+
66
+ removed++;
67
+ console.log(chalk.red(` rm ${relPath}`));
68
+ }
69
+
70
+ staging.entries = Array.from(entryMap.values());
71
+ staging.files = staging.entries.filter(e => e.status !== 'deleted').map(e => e.path);
72
+ await writeJSON(stagingPath, staging);
73
+
74
+ console.log(chalk.green(`\nRemoved ${removed} file(s)`));
75
+ } catch (error) {
76
+ if (error.code === 'ENOENT' && error.message.includes('.gent')) {
77
+ console.error(chalk.red('Error: Not a gent repository'));
78
+ } else {
79
+ console.error(chalk.red('Error:'), error.message);
80
+ }
81
+ process.exit(1);
82
+ }
83
+ }
84
+
85
+ module.exports = rm;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * ============================================================================
3
+ * Show Command - Show details of a commit, tag, or tree
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Display detailed information about a specific commit including author,
8
+ * message, tree hash, parent, and full diff. Like `git show`.
9
+ *
10
+ * USAGE:
11
+ * gent show → Show HEAD commit details with diff
12
+ * gent show <hash> → Show specific commit
13
+ * gent show <tag> → Show commit referenced by tag
14
+ *
15
+ * ALGORITHM:
16
+ * Retrieves commit from commits.json, reads blob content from object store,
17
+ * computes diff against parent commit's tree, and displays unified diff.
18
+ *
19
+ * BACKEND EXPECTATIONS:
20
+ * GET /api/repos/:id/commits/:hash/ — returns commit object with tree
21
+ *
22
+ * ============================================================================
23
+ */
24
+
25
+ const path = require('path');
26
+ const chalk = require('chalk');
27
+ const { getGentPath, readJSON } = require('../utils/fileSystem');
28
+ const { COMMITS_FILE } = require('../utils/constants');
29
+ const { readBlobAsString } = require('../utils/hash-engine');
30
+ const { formatUnifiedDiff } = require('../utils/diff-engine');
31
+
32
+ /**
33
+ * Show commit details
34
+ * @param {String} ref - Commit hash, tag name, or empty for HEAD
35
+ * @param {Object} options
36
+ */
37
+ async function show(ref, options) {
38
+ try {
39
+ const gentPath = await getGentPath();
40
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
41
+ const commits = repository.commits || [];
42
+ const tags = repository.tags || {};
43
+
44
+ let targetHash = ref;
45
+
46
+ // Default to HEAD
47
+ if (!targetHash) {
48
+ targetHash = repository.branches[repository.currentBranch];
49
+ if (!targetHash) {
50
+ console.log(chalk.yellow('No commits yet'));
51
+ return;
52
+ }
53
+ }
54
+
55
+ // Resolve tag name → hash
56
+ if (tags[targetHash]) {
57
+ targetHash = tags[targetHash].hash;
58
+ }
59
+
60
+ // Find commit (support short hashes)
61
+ const commit = commits.find(c =>
62
+ c.hash === targetHash || c.hash.startsWith(targetHash)
63
+ );
64
+
65
+ if (!commit) {
66
+ console.error(chalk.red(`Commit '${ref || 'HEAD'}' not found`));
67
+ return;
68
+ }
69
+
70
+ // Display commit header
71
+ console.log(chalk.yellow(`commit ${commit.hash}`));
72
+ if (commit.mergeParent) {
73
+ console.log(chalk.gray(`Merge: ${commit.parent?.substring(0, 7)} ${commit.mergeParent.substring(0, 7)}`));
74
+ }
75
+ console.log(chalk.white(`Author: ${commit.author.name} <${commit.author.email}>`));
76
+ console.log(chalk.white(`Date: ${new Date(commit.timestamp).toLocaleString()}`));
77
+ if (commit.treeHash) {
78
+ console.log(chalk.gray(`Tree: ${commit.treeHash.substring(0, 7)}`));
79
+ }
80
+ console.log('');
81
+ console.log(chalk.white(` ${commit.message}`));
82
+ console.log('');
83
+
84
+ if (commit.stats) {
85
+ console.log(chalk.gray(` ${commit.stats.filesChanged} file(s), `) +
86
+ chalk.green(`+${commit.stats.insertions}`) + ' ' +
87
+ chalk.red(`-${commit.stats.deletions}`));
88
+ console.log('');
89
+ }
90
+
91
+ // Show diff against parent
92
+ if (!options.noPatch) {
93
+ await showCommitDiff(gentPath, commits, commit);
94
+ }
95
+
96
+ } catch (error) {
97
+ if (error.code === 'ENOENT' && error.message.includes('.gent')) {
98
+ console.error(chalk.red('Error: Not a gent repository'));
99
+ } else {
100
+ console.error(chalk.red('Error:'), error.message);
101
+ }
102
+ process.exit(1);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Display diff between commit and its parent
108
+ */
109
+ async function showCommitDiff(gentPath, commits, commit) {
110
+ const parentCommit = commit.parent
111
+ ? commits.find(c => c.hash === commit.parent)
112
+ : null;
113
+
114
+ const currentTree = commit.tree || (commit.files || []).map(f => ({
115
+ name: f.path || f.name, hash: f.hash
116
+ }));
117
+ const parentTree = parentCommit
118
+ ? (parentCommit.tree || (parentCommit.files || []).map(f => ({
119
+ name: f.path || f.name, hash: f.hash
120
+ })))
121
+ : [];
122
+
123
+ const parentMap = new Map(parentTree.map(f => [f.name || f.path, f.hash]));
124
+ const currentMap = new Map(currentTree.map(f => [f.name || f.path, f.hash]));
125
+
126
+ // Files changed
127
+ const allPaths = new Set([...parentMap.keys(), ...currentMap.keys()]);
128
+
129
+ for (const filePath of allPaths) {
130
+ const oldHash = parentMap.get(filePath);
131
+ const newHash = currentMap.get(filePath);
132
+
133
+ if (oldHash === newHash) continue;
134
+
135
+ try {
136
+ const oldContent = oldHash ? await readBlobAsString(gentPath, oldHash) : '';
137
+ const newContent = newHash ? await readBlobAsString(gentPath, newHash) : '';
138
+
139
+ const unified = formatUnifiedDiff(filePath, oldContent, newContent);
140
+ if (unified) {
141
+ printColorizedDiff(unified);
142
+ }
143
+ } catch {
144
+ // Blob missing for legacy commits
145
+ if (!oldHash && newHash) {
146
+ console.log(chalk.green(`+ new file: ${filePath}`));
147
+ } else if (oldHash && !newHash) {
148
+ console.log(chalk.red(`- deleted: ${filePath}`));
149
+ } else {
150
+ console.log(chalk.yellow(`~ modified: ${filePath} (blob not available)`));
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ function printColorizedDiff(unifiedDiff) {
157
+ for (const line of unifiedDiff.split('\n')) {
158
+ if (line.startsWith('---') || line.startsWith('+++')) console.log(chalk.bold(line));
159
+ else if (line.startsWith('@@')) console.log(chalk.cyan(line));
160
+ else if (line.startsWith('+')) console.log(chalk.green(line));
161
+ else if (line.startsWith('-')) console.log(chalk.red(line));
162
+ else console.log(line);
163
+ }
164
+ console.log('');
165
+ }
166
+
167
+ module.exports = show;
@@ -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;