gent-cli 9.1.0 → 11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gent-cli",
3
- "version": "9.1.0",
3
+ "version": "11.0.0",
4
4
  "description": "A modern, Git-like version control CLI with cloud sync, AI-powered superpowers (ask/review/docs/changelog), and zero-friction setup (gent setup/doctor/config).",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -8,8 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node src/index.js",
11
- "test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js && node tests/offline-e2e.js",
12
- "test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js",
11
+ "test": "node --check src/index.js && node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js && node tests/offline-e2e.js",
12
+ "test:unit": "node --test tests/diff.test.js tests/merge.test.js tests/hash.test.js tests/merge-base.test.js tests/file-system.test.js",
13
13
  "test:e2e": "node tests/offline-e2e.js",
14
14
  "test:remote:e2e": "node tests/remote-e2e.js",
15
15
  "demo": "bash demo.sh",
@@ -76,8 +76,8 @@ async function add(files, options) {
76
76
 
77
77
  // Check if changed vs last commit
78
78
  const prevHash = lastTreeMap.get(relPath);
79
- if (prevHash === blobHash && stagedMap.has(relPath)) {
80
- continue; // unchanged, already staged
79
+ if (prevHash === blobHash) {
80
+ continue; // identical to the last commit — nothing to stage
81
81
  }
82
82
 
83
83
  // Determine change status
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Auto Command - One-shot interactive flow.
3
+ *
4
+ * gent auto
5
+ *
6
+ * Walks a fresh user from nothing to a pushed commit:
7
+ * sign in (or register) → init → link a remote → stage → commit → push.
8
+ * Each step no-ops when it's already done, so `gent auto` is safe to re-run.
9
+ */
10
+
11
+ const path = require('path');
12
+ const chalk = require('chalk');
13
+ const ora = require('ora');
14
+ const boxen = require('boxen');
15
+ const inquirer = require('inquirer');
16
+
17
+ const { pathExists, readJSON, writeJSON } = require('../utils/fileSystem');
18
+ const { GENT_DIR, CONFIG_FILE, API_ENDPOINTS, parseRemoteUrl } = require('../utils/constants');
19
+ const authStorage = require('../utils/auth-storage');
20
+ const authService = require('../services/auth-service');
21
+ const apiClient = require('../utils/api-client');
22
+ const { isInteractive } = require('../utils/interactive');
23
+
24
+ const initCommand = require('./init');
25
+ const addCommand = require('./add');
26
+ const commitCommand = require('./commit');
27
+ const pushCommand = require('./push');
28
+
29
+ async function auto() {
30
+ if (!isInteractive()) {
31
+ console.error(chalk.red('gent auto needs an interactive terminal.'));
32
+ console.log(chalk.yellow('In scripts/CI, use the individual commands: gent init / add / commit / push.'));
33
+ process.exit(1);
34
+ }
35
+
36
+ console.log(boxen(
37
+ chalk.bold.cyan('gent auto') + chalk.gray(' — guided flow\n') +
38
+ chalk.white('Sign in → init → link remote → add → commit → push.'),
39
+ { padding: 1, margin: 1, borderStyle: 'round', borderColor: 'cyan' }
40
+ ));
41
+
42
+ try {
43
+ await ensureAuth();
44
+ await ensureRepo();
45
+ const linked = await ensureRemote();
46
+ await stageChanges();
47
+ await commitChanges();
48
+
49
+ if (linked) {
50
+ await pushChanges();
51
+ } else {
52
+ console.log(chalk.gray('\nSkipped push — no remote linked. Run `gent push` once you link one.'));
53
+ }
54
+
55
+ console.log(chalk.green('\n✓ All done.'));
56
+ } catch (err) {
57
+ // Sub-steps handle their own expected errors; this catches anything
58
+ // unexpected (e.g. a corrupt .gent/config.json) with a clean message.
59
+ console.error(chalk.red('\ngent auto stopped:'), err.message);
60
+ process.exit(1);
61
+ }
62
+ }
63
+
64
+ // ─── 1. Account ─────────────────────────────────────────
65
+ async function ensureAuth() {
66
+ console.log(chalk.bold('\n1) Account'));
67
+
68
+ if (await authStorage.isAuthenticated()) {
69
+ const user = await authStorage.getUser();
70
+ console.log(chalk.green(` ✓ Signed in as ${user?.email || 'unknown'}`));
71
+ return;
72
+ }
73
+
74
+ const { action } = await inquirer.prompt([{
75
+ type: 'list',
76
+ name: 'action',
77
+ message: 'You are not signed in. What would you like to do?',
78
+ choices: [
79
+ { name: 'Log in to an existing account', value: 'login' },
80
+ { name: 'Create a new account', value: 'register' },
81
+ ],
82
+ }]);
83
+
84
+ const creds = await inquirer.prompt([
85
+ {
86
+ type: 'input',
87
+ name: 'email',
88
+ message: 'Email:',
89
+ validate: v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'Please enter a valid email',
90
+ },
91
+ {
92
+ type: 'password',
93
+ name: 'password',
94
+ message: 'Password:',
95
+ mask: '*',
96
+ validate: v => v.length > 0 || 'Password is required',
97
+ },
98
+ ]);
99
+
100
+ let extra = {};
101
+ if (action === 'register') {
102
+ extra = await inquirer.prompt([
103
+ {
104
+ type: 'password',
105
+ name: 'passwordConfirm',
106
+ message: 'Confirm password:',
107
+ mask: '*',
108
+ validate: v => v === creds.password || 'Passwords do not match',
109
+ },
110
+ { type: 'input', name: 'firstName', message: 'First name:', default: '' },
111
+ { type: 'input', name: 'lastName', message: 'Last name:', default: '' },
112
+ ]);
113
+ }
114
+
115
+ const spinner = ora(action === 'login' ? 'Logging in…' : 'Creating account…').start();
116
+ try {
117
+ if (action === 'login') {
118
+ await authService.login(creds.email, creds.password);
119
+ } else {
120
+ await authService.register(
121
+ creds.email, creds.password, extra.passwordConfirm, extra.firstName, extra.lastName
122
+ );
123
+ }
124
+ spinner.succeed(chalk.green(`Signed in as ${creds.email}`));
125
+ } catch (err) {
126
+ spinner.fail(chalk.red(err.message));
127
+ process.exit(1);
128
+ }
129
+ }
130
+
131
+ // ─── 2. Repository ──────────────────────────────────────
132
+ async function ensureRepo() {
133
+ console.log(chalk.bold('\n2) Repository'));
134
+ if (await pathExists(path.join(process.cwd(), GENT_DIR))) {
135
+ console.log(chalk.green(' ✓ Already a gent repository'));
136
+ return;
137
+ }
138
+ await initCommand({}); // prints its own "Initialized …" line
139
+ }
140
+
141
+ // ─── 3. Remote ──────────────────────────────────────────
142
+ // Returns true when origin is linked (so we know whether to push at the end).
143
+ async function ensureRemote() {
144
+ console.log(chalk.bold('\n3) Remote'));
145
+
146
+ const configPath = path.join(process.cwd(), GENT_DIR, CONFIG_FILE);
147
+ const config = await readJSON(configPath);
148
+ config.remotes = config.remotes || {};
149
+
150
+ if (config.remotes.origin) {
151
+ console.log(chalk.green(` ✓ Linked: origin → ${config.remotes.origin.url}`));
152
+ return true;
153
+ }
154
+
155
+ const { how } = await inquirer.prompt([{
156
+ type: 'list',
157
+ name: 'how',
158
+ message: 'No remote linked yet. Link one?',
159
+ choices: [
160
+ { name: 'Create a new repository on gent', value: 'create' },
161
+ { name: 'Link an existing remote URL', value: 'existing' },
162
+ { name: 'Skip for now', value: 'skip' },
163
+ ],
164
+ }]);
165
+
166
+ if (how === 'skip') {
167
+ console.log(chalk.gray(' Skipped.'));
168
+ return false;
169
+ }
170
+
171
+ if (how === 'existing') {
172
+ const { url } = await inquirer.prompt([{
173
+ type: 'input',
174
+ name: 'url',
175
+ message: 'Remote URL (/api/repos/{owner_id}/{repo}):',
176
+ validate: v => !!parseRemoteUrl(v) || 'Expected /api/repos/{owner_id}/{repo_name}',
177
+ }]);
178
+ config.remotes.origin = { url };
179
+ await writeJSON(configPath, config);
180
+ console.log(chalk.green(` ✓ Linked origin → ${url}`));
181
+ return true;
182
+ }
183
+
184
+ // create
185
+ if (!(await authStorage.isAuthenticated())) {
186
+ console.log(chalk.yellow(' Sign in first to create a remote — skipping.'));
187
+ return false;
188
+ }
189
+
190
+ const { name, isPrivate } = await inquirer.prompt([
191
+ {
192
+ type: 'input',
193
+ name: 'name',
194
+ message: 'Repository name:',
195
+ default: path.basename(process.cwd()),
196
+ validate: v => v.trim().length > 0 || 'Please enter a name',
197
+ },
198
+ { type: 'confirm', name: 'isPrivate', message: 'Private repository?', default: false },
199
+ ]);
200
+
201
+ const spinner = ora(`Creating '${name}' on gent…`).start();
202
+ try {
203
+ const data = await apiClient.post(API_ENDPOINTS.REPOS_CREATE, {
204
+ name: name.trim(),
205
+ description: '',
206
+ is_private: isPrivate,
207
+ });
208
+ const repo = data.repository || data;
209
+ const url = `/api/repos/${repo.owner_id}/${repo.name}`;
210
+ config.remotes.origin = { url };
211
+ await writeJSON(configPath, config);
212
+ spinner.succeed(chalk.green(`Linked origin → ${url}`));
213
+ return true;
214
+ } catch (err) {
215
+ if (err.response?.status === 400) {
216
+ spinner.warn(chalk.yellow('Could not create — the name may already be taken.'));
217
+ console.log(chalk.gray(' Tip: `gent repos` to list, then `gent remote add origin <url>`.'));
218
+ } else {
219
+ spinner.fail(chalk.red(err.message));
220
+ }
221
+ return false;
222
+ }
223
+ }
224
+
225
+ // ─── 4. Stage ───────────────────────────────────────────
226
+ async function stageChanges() {
227
+ console.log(chalk.bold('\n4) Stage changes'));
228
+
229
+ const { how } = await inquirer.prompt([{
230
+ type: 'list',
231
+ name: 'how',
232
+ message: 'What should we stage?',
233
+ choices: [
234
+ { name: 'All changes', value: 'all' },
235
+ { name: 'Specific paths', value: 'pick' },
236
+ { name: 'Nothing (skip)', value: 'skip' },
237
+ ],
238
+ default: 'all',
239
+ }]);
240
+
241
+ if (how === 'skip') return;
242
+
243
+ if (how === 'all') {
244
+ await addCommand([], { all: true });
245
+ return;
246
+ }
247
+
248
+ const { paths } = await inquirer.prompt([{
249
+ type: 'input',
250
+ name: 'paths',
251
+ message: 'Paths to add (space-separated):',
252
+ validate: v => v.trim().length > 0 || 'Enter at least one path',
253
+ }]);
254
+ await addCommand(paths.trim().split(/\s+/), {});
255
+ }
256
+
257
+ // ─── 5. Commit ──────────────────────────────────────────
258
+ async function commitChanges() {
259
+ console.log(chalk.bold('\n5) Commit'));
260
+ // commit() prompts for the message itself and no-ops if nothing is staged.
261
+ await commitCommand({});
262
+ }
263
+
264
+ // ─── 6. Push ────────────────────────────────────────────
265
+ async function pushChanges() {
266
+ console.log(chalk.bold('\n6) Push'));
267
+ await pushCommand('origin', undefined, {});
268
+ }
269
+
270
+ module.exports = auto;
package/src/index.js CHANGED
@@ -29,8 +29,10 @@ require('./utils/env-loader').load();
29
29
  const { program } = require('commander');
30
30
  const chalk = require('chalk');
31
31
  const packageJson = require('../package.json');
32
+ const interactive = require('./utils/interactive');
32
33
 
33
34
  // Import core commands
35
+ const autoCommand = require('./commands/auto');
34
36
  const initCommand = require('./commands/init');
35
37
  const cloneCommand = require('./commands/clone');
36
38
  const statusCommand = require('./commands/status');
@@ -85,6 +87,11 @@ program
85
87
 
86
88
  // ─── Repository Setup ───────────────────────────────────
87
89
 
90
+ program
91
+ .command('auto')
92
+ .description('Guided flow: sign in → init → link remote → add → commit → push')
93
+ .action(autoCommand);
94
+
88
95
  program
89
96
  .command('init')
90
97
  .description('Initialize a new gent repository')
@@ -93,9 +100,18 @@ program
93
100
  .action(initCommand);
94
101
 
95
102
  program
96
- .command('clone <url> [directory]')
103
+ .command('clone [url] [directory]')
97
104
  .description('Clone a remote repository')
98
- .action(cloneCommand);
105
+ .action(async (url, directory, options) => {
106
+ if (!url && interactive.isInteractive()) {
107
+ ({ url, directory } = await interactive.promptClone());
108
+ }
109
+ if (!url) {
110
+ console.error(chalk.red('error: missing url — usage: gent clone <url> [directory]'));
111
+ process.exit(1);
112
+ }
113
+ return cloneCommand(url, directory, options);
114
+ });
99
115
 
100
116
  // ─── Staging & Working Tree ─────────────────────────────
101
117
 
@@ -118,10 +134,19 @@ program
118
134
  });
119
135
 
120
136
  program
121
- .command('rm <files...>')
137
+ .command('rm [files...]')
122
138
  .description('Remove files from working tree and staging')
123
139
  .option('--cached', 'Only remove from staging, keep file on disk')
124
- .action(rmCommand);
140
+ .action(async (files, options) => {
141
+ if ((!files || files.length === 0) && interactive.isInteractive()) {
142
+ files = await interactive.promptRm();
143
+ }
144
+ if (!files || files.length === 0) {
145
+ console.error('error: specify files to remove');
146
+ process.exit(1);
147
+ }
148
+ return rmCommand(files, options);
149
+ });
125
150
 
126
151
  program
127
152
  .command('reset [files...]')
@@ -192,16 +217,46 @@ program
192
217
  .action(branchCommand);
193
218
 
194
219
  program
195
- .command('checkout <branch>')
220
+ .command('checkout [branch]')
196
221
  .description('Switch branches or restore working tree files')
197
222
  .option('-b, --create', 'Create a new branch')
198
- .action(checkoutCommand);
223
+ .action(async (branch, options) => {
224
+ if (!branch && interactive.isInteractive()) {
225
+ const picked = await interactive.promptCheckout();
226
+ branch = picked.branch;
227
+ options.create = picked.create; // picker decides: existing branch ⇒ switch, not create
228
+ }
229
+ if (!branch) {
230
+ console.error(chalk.red('error: missing branch — usage: gent checkout <branch>'));
231
+ process.exit(1);
232
+ }
233
+ return checkoutCommand(branch, options);
234
+ });
199
235
 
200
236
  program
201
- .command('merge <branch>')
237
+ .command('merge [branch]')
202
238
  .description('Merge a branch into the current branch (3-way smart merge)')
203
239
  .option('-m, --message <message>', 'Merge commit message')
204
- .action(mergeCommand);
240
+ .action(async (branch, options) => {
241
+ if (!branch && interactive.isInteractive()) {
242
+ const picked = await interactive.promptMerge();
243
+ if (!picked.branch) {
244
+ // In a repo with only one branch → nothing to merge; otherwise
245
+ // fall through so mergeCommand reports "Not a gent repository".
246
+ if (picked.current) {
247
+ console.log(chalk.yellow('No other branches to merge.'));
248
+ return;
249
+ }
250
+ } else {
251
+ branch = picked.branch;
252
+ }
253
+ }
254
+ if (!branch && !interactive.isInteractive()) {
255
+ console.error(chalk.red('error: missing branch — usage: gent merge <branch>'));
256
+ process.exit(1);
257
+ }
258
+ return mergeCommand(branch, options);
259
+ });
205
260
 
206
261
  program
207
262
  .command('resolve')
@@ -286,9 +341,14 @@ program
286
341
  // ─── Platform-special (AI-powered) ──────────────────────
287
342
 
288
343
  program
289
- .command('ask <question>')
344
+ .command('ask [question]')
290
345
  .description('Ask Claude a question about this repo (needs AI key)')
291
- .action(askCommand);
346
+ .action(async (question, options) => {
347
+ if (!question && interactive.isInteractive()) {
348
+ question = await interactive.promptAsk();
349
+ }
350
+ return askCommand(question, options);
351
+ });
292
352
 
293
353
  program
294
354
  .command('review [ref]')
@@ -384,6 +444,54 @@ program
384
444
  }
385
445
  });
386
446
 
447
+ // ─── Grouped help ───────────────────────────────────────
448
+ // Commander's default lists all ~45 commands in one flat block, which reads as
449
+ // noise. Group them by purpose instead, and render our own list after Options.
450
+ const HELP_GROUPS = [
451
+ ['Start here', ['auto', 'setup', 'init', 'clone']],
452
+ ['Work on changes', ['status', 'add', 'rm', 'reset', 'diff', 'commit']],
453
+ ['History', ['log', 'show', 'tag', 'explain', 'summary']],
454
+ ['Branches & merging', ['branch', 'checkout', 'merge', 'resolve', 'stash', 'undo', 'redo']],
455
+ ['Remote & sync', ['remote', 'repos', 'members', 'push', 'pull', 'search', 'web', 'share']],
456
+ ['Account', ['register', 'login', 'logout', 'whoami', 'password']],
457
+ ['AI', ['ask', 'review', 'docs', 'changelog', 'ai']],
458
+ ['Config & tools', ['config', 'doctor', 'template', 'help']],
459
+ ];
460
+
461
+ function configureGroupedHelp(program) {
462
+ // Hide the default flat "Commands:" block…
463
+ program.configureHelp({ visibleCommands: () => [] });
464
+
465
+ // …and print a grouped one in its place.
466
+ program.addHelpText('after', () => {
467
+ const byName = new Map(program.commands.map(c => [c.name(), c]));
468
+ const pad = 13;
469
+ const listed = new Set();
470
+ const lines = ['Commands:', ''];
471
+
472
+ const row = (name) =>
473
+ ` ${chalk.cyan(name.padEnd(pad))}${chalk.gray(byName.get(name).description())}`;
474
+
475
+ for (const [title, names] of HELP_GROUPS) {
476
+ const rows = names.filter(n => byName.has(n));
477
+ if (!rows.length) continue;
478
+ rows.forEach(n => listed.add(n));
479
+ lines.push(chalk.bold(title), ...rows.map(row), '');
480
+ }
481
+
482
+ // Anything not placed in a group still shows up, so help stays complete.
483
+ const leftovers = [...byName.keys()].filter(n => !listed.has(n));
484
+ if (leftovers.length) {
485
+ lines.push(chalk.bold('Other'), ...leftovers.map(row), '');
486
+ }
487
+
488
+ lines.push(chalk.gray('Run ') + chalk.cyan('gent help <command>') + chalk.gray(' for details.'));
489
+ return '\n' + lines.join('\n');
490
+ });
491
+ }
492
+
493
+ configureGroupedHelp(program);
494
+
387
495
  // Friendlier global error mapping. Per-command handlers still own their own
388
496
  // errors; this catches anything that bubbles up (e.g. unknown command).
389
497
  function explainError(err) {
@@ -402,7 +510,8 @@ function showQuickstart() {
402
510
  console.log(chalk.bold.cyan('Gent CLI ') + chalk.gray(`v${packageJson.version}`));
403
511
  console.log(chalk.gray('A Git-like VCS with cloud sync + AI superpowers.\n'));
404
512
  console.log(chalk.bold('First time? Try:'));
405
- console.log(` ${chalk.cyan('gent setup')} ${chalk.gray('interactive walkthrough (login + AI key + remote)')}`);
513
+ console.log(` ${chalk.cyan('gent auto')} ${chalk.gray('guided init commit push (interactive)')}`);
514
+ console.log(` ${chalk.cyan('gent setup')} ${chalk.gray('configure login + AI key + remote')}`);
406
515
  console.log(` ${chalk.cyan('gent doctor')} ${chalk.gray('check everything is wired up')}`);
407
516
  console.log(` ${chalk.cyan('gent template list')} ${chalk.gray('scaffold a starter project')}`);
408
517
  console.log();
@@ -92,6 +92,8 @@ module.exports = {
92
92
  '.gent',
93
93
  'node_modules',
94
94
  '.git',
95
+ '.gitignore',
96
+ '.gentignore',
95
97
  '.DS_Store',
96
98
  '*.log',
97
99
  '.env',
@@ -7,6 +7,8 @@ const fs = require('fs').promises;
7
7
  const path = require('path');
8
8
  const { GENT_DIR, DEFAULT_IGNORE_PATTERNS, IGNORE_FILE } = require('./constants');
9
9
 
10
+ const GIT_IGNORE_FILE = '.gitignore';
11
+
10
12
  /**
11
13
  * Check if a path exists
12
14
  * @param {String} path - Path to check
@@ -115,21 +117,35 @@ function shouldIgnore(filePath, patterns) {
115
117
  const normalizedPath = filePath.replace(/\\/g, '/');
116
118
 
117
119
  for (const pattern of patterns) {
120
+ const normalizedPattern = normalizeIgnorePattern(pattern);
121
+ if (!normalizedPattern) {
122
+ continue;
123
+ }
124
+
118
125
  // Exact match
119
- if (normalizedPath === pattern || normalizedPath.startsWith(pattern + '/')) {
126
+ if (normalizedPath === normalizedPattern || normalizedPath.startsWith(normalizedPattern + '/')) {
120
127
  return true;
121
128
  }
122
129
 
130
+ // Basename match
131
+ if (!normalizedPattern.includes('/') && !normalizedPattern.includes('*')) {
132
+ const parts = normalizedPath.split('/');
133
+ if (parts.includes(normalizedPattern)) {
134
+ return true;
135
+ }
136
+ }
137
+
123
138
  // Wildcard match
124
- if (pattern.includes('*')) {
125
- const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
126
- if (regex.test(normalizedPath)) {
139
+ if (normalizedPattern.includes('*')) {
140
+ const wildcardRegex = normalizedPattern.split('*').map(escapeRegex).join('.*');
141
+ const regex = new RegExp('^' + wildcardRegex + '$');
142
+ if (regex.test(normalizedPath) || regex.test(path.posix.basename(normalizedPath))) {
127
143
  return true;
128
144
  }
129
145
  }
130
146
 
131
147
  // Extension match
132
- if (pattern.startsWith('*.') && normalizedPath.endsWith(pattern.substring(1))) {
148
+ if (normalizedPattern.startsWith('*.') && normalizedPath.endsWith(normalizedPattern.substring(1))) {
133
149
  return true;
134
150
  }
135
151
  }
@@ -137,6 +153,56 @@ function shouldIgnore(filePath, patterns) {
137
153
  return false;
138
154
  }
139
155
 
156
+ function normalizeIgnorePattern(pattern) {
157
+ return pattern
158
+ .replace(/\\/g, '/')
159
+ .replace(/^\.\//, '')
160
+ .replace(/^\/+/, '')
161
+ .replace(/\/+$/, '');
162
+ }
163
+
164
+ function escapeRegex(pattern) {
165
+ return pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
166
+ }
167
+
168
+ function parseIgnoreContent(content, basePath = '') {
169
+ return content.split('\n')
170
+ .map(line => line.trim())
171
+ .filter(line => line && !line.startsWith('#') && !line.startsWith('!'))
172
+ .map(line => normalizeIgnorePattern(path.posix.join(basePath, line)))
173
+ .filter(Boolean);
174
+ }
175
+
176
+ async function collectGitIgnorePatterns(rootDir, patterns) {
177
+ async function scan(currentDir) {
178
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
179
+ const relativeDir = path.relative(rootDir, currentDir).replace(/\\/g, '/');
180
+ const gitIgnore = entries.find(entry => entry.isFile() && entry.name === GIT_IGNORE_FILE);
181
+
182
+ if (gitIgnore) {
183
+ const ignorePath = path.join(currentDir, GIT_IGNORE_FILE);
184
+ const content = await fs.readFile(ignorePath, 'utf-8');
185
+ patterns.push(...parseIgnoreContent(content, relativeDir));
186
+ }
187
+
188
+ for (const entry of entries) {
189
+ if (!entry.isDirectory()) {
190
+ continue;
191
+ }
192
+
193
+ const fullPath = path.join(currentDir, entry.name);
194
+ const relativePath = path.relative(rootDir, fullPath);
195
+ if (shouldIgnore(relativePath, patterns)) {
196
+ continue;
197
+ }
198
+
199
+ await scan(fullPath);
200
+ }
201
+ }
202
+
203
+ await scan(rootDir);
204
+ }
205
+
140
206
  /**
141
207
  * Get ignore patterns from .gentignore file
142
208
  * @param {String} dir - Directory to check
@@ -148,13 +214,11 @@ async function getIgnorePatterns(dir) {
148
214
 
149
215
  if (await pathExists(ignorePath)) {
150
216
  const content = await fs.readFile(ignorePath, 'utf-8');
151
- const lines = content.split('\n')
152
- .map(line => line.trim())
153
- .filter(line => line && !line.startsWith('#'));
154
-
155
- patterns.push(...lines);
217
+ patterns.push(...parseIgnoreContent(content));
156
218
  }
157
219
 
220
+ await collectGitIgnorePatterns(dir, patterns);
221
+
158
222
  return patterns;
159
223
  }
160
224
 
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Interactive helpers — shared prompt resolvers for commands that need an
3
+ * argument the user didn't pass on the command line.
4
+ *
5
+ * The rule everywhere: if we're in a TTY, ask; otherwise leave the value
6
+ * undefined so the command prints its usual "usage" error (keeps scripts/CI
7
+ * behaving exactly as before).
8
+ */
9
+
10
+ const path = require('path');
11
+ const inquirer = require('inquirer');
12
+ const { getGentPath, readJSON } = require('./fileSystem');
13
+ const { COMMITS_FILE } = require('./constants');
14
+
15
+ // Sentinel for the "create a new branch" choice — deliberately not a legal
16
+ // branch name, so it can never collide with a real one.
17
+ const CREATE_NEW = '__gent_create_new_branch__';
18
+
19
+ /** True only when both stdin and stdout are a real terminal. */
20
+ function isInteractive() {
21
+ return Boolean(process.stdout.isTTY && process.stdin.isTTY);
22
+ }
23
+
24
+ /** Local branches from commits.json. { current, names } — empty if not a repo. */
25
+ async function loadBranches() {
26
+ try {
27
+ const gentPath = await getGentPath();
28
+ const repo = await readJSON(path.join(gentPath, COMMITS_FILE));
29
+ return {
30
+ current: repo.currentBranch || 'main',
31
+ names: Object.keys(repo.branches || {}),
32
+ };
33
+ } catch {
34
+ return { current: null, names: [] };
35
+ }
36
+ }
37
+
38
+ /** File paths tracked in the current branch's HEAD commit. [] if not a repo. */
39
+ async function listTrackedFiles() {
40
+ try {
41
+ const gentPath = await getGentPath();
42
+ const repo = await readJSON(path.join(gentPath, COMMITS_FILE));
43
+ const head = repo.branches?.[repo.currentBranch];
44
+ const commit = head ? (repo.commits || []).find(c => c.hash === head) : null;
45
+ const tree = commit ? (commit.tree || commit.files || []) : [];
46
+ return tree.map(f => f.name || f.path).filter(Boolean);
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ /** gent clone — ask for the URL and (optionally) a target directory. */
53
+ async function promptClone() {
54
+ const ans = await inquirer.prompt([
55
+ {
56
+ type: 'input',
57
+ name: 'url',
58
+ message: 'Repository URL to clone (/api/repos/{owner_id}/{repo}):',
59
+ validate: v => v.trim().length > 0 || 'Please enter a URL',
60
+ },
61
+ {
62
+ type: 'input',
63
+ name: 'directory',
64
+ message: 'Target directory (blank = repo name):',
65
+ default: '',
66
+ },
67
+ ]);
68
+ return { url: ans.url.trim(), directory: ans.directory.trim() || undefined };
69
+ }
70
+
71
+ /** gent checkout — pick an existing branch or create a new one. */
72
+ async function promptCheckout() {
73
+ const { current, names } = await loadBranches();
74
+ const others = names.filter(n => n !== current);
75
+
76
+ const choices = [
77
+ ...others.map(n => ({ name: n, value: n })),
78
+ ...(others.length ? [new inquirer.Separator()] : []),
79
+ { name: '+ Create a new branch…', value: CREATE_NEW },
80
+ ];
81
+
82
+ const { pick } = await inquirer.prompt([{
83
+ type: 'list',
84
+ name: 'pick',
85
+ message: current ? `Switch to which branch? (current: ${current})` : 'Branch:',
86
+ choices,
87
+ }]);
88
+
89
+ if (pick === CREATE_NEW) {
90
+ const { name } = await inquirer.prompt([{
91
+ type: 'input',
92
+ name: 'name',
93
+ message: 'New branch name:',
94
+ validate: v => v.trim().length > 0 || 'Please enter a name',
95
+ }]);
96
+ return { branch: name.trim(), create: true };
97
+ }
98
+ return { branch: pick, create: false };
99
+ }
100
+
101
+ /** gent merge — pick a branch (other than the current one) to merge in.
102
+ * `current` is null when we're not in a repo, so the caller can tell
103
+ * "no other branches" apart from "not a repo". */
104
+ async function promptMerge() {
105
+ const { current, names } = await loadBranches();
106
+ const others = names.filter(n => n !== current);
107
+ if (!others.length) return { branch: null, current };
108
+
109
+ const { branch } = await inquirer.prompt([{
110
+ type: 'list',
111
+ name: 'branch',
112
+ message: `Merge which branch into '${current}'?`,
113
+ choices: others,
114
+ }]);
115
+ return { branch, current };
116
+ }
117
+
118
+ /** gent ask — free-text question. */
119
+ async function promptAsk() {
120
+ const { question } = await inquirer.prompt([{
121
+ type: 'input',
122
+ name: 'question',
123
+ message: 'What do you want to ask about this repo?',
124
+ validate: v => v.trim().length > 0 || 'Please enter a question',
125
+ }]);
126
+ return question.trim();
127
+ }
128
+
129
+ /** gent rm — checkbox of tracked files, or free-text if none are tracked. */
130
+ async function promptRm() {
131
+ const tracked = await listTrackedFiles();
132
+ if (tracked.length) {
133
+ const { files } = await inquirer.prompt([{
134
+ type: 'checkbox',
135
+ name: 'files',
136
+ message: 'Select files to remove:',
137
+ choices: tracked,
138
+ validate: v => v.length > 0 || 'Select at least one file (space to toggle)',
139
+ }]);
140
+ return files;
141
+ }
142
+ const { paths } = await inquirer.prompt([{
143
+ type: 'input',
144
+ name: 'paths',
145
+ message: 'Files to remove (space-separated):',
146
+ validate: v => v.trim().length > 0 || 'Enter at least one path',
147
+ }]);
148
+ return paths.trim().split(/\s+/);
149
+ }
150
+
151
+ module.exports = {
152
+ isInteractive,
153
+ loadBranches,
154
+ listTrackedFiles,
155
+ promptClone,
156
+ promptCheckout,
157
+ promptMerge,
158
+ promptAsk,
159
+ promptRm,
160
+ };