gent-cli 23.0.0 → 24.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": "23.0.0",
3
+ "version": "24.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 tests/file-system.test.js tests/web-urls.test.js && node tests/offline-e2e.js && node --test tests/git-compat/engine.test.js tests/git-compat/cli.test.js tests/git-compat/transport.test.js tests/git-compat/migrate.test.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 tests/web-urls.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 tests/web-urls.test.js tests/repos.test.js && node tests/offline-e2e.js && node --test tests/git-compat/engine.test.js tests/git-compat/cli.test.js tests/git-compat/transport.test.js tests/git-compat/migrate.test.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 tests/web-urls.test.js tests/repos.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",
@@ -3,6 +3,7 @@
3
3
  */
4
4
  const fs = require('fs').promises;
5
5
  const path = require('path');
6
+ const inquirer = require('inquirer');
6
7
  const repository = require('../utils/repository');
7
8
  const ops = require('../utils/gent-ops');
8
9
  const merge = require('../utils/merge-ops');
@@ -13,6 +14,7 @@ const { GitIndex } = require('../utils/git-index');
13
14
  const { Lock } = require('../utils/lockfile');
14
15
  const { AttributesMatcher, looksBinary } = require('../utils/attributes');
15
16
  const { formatUnifiedDiff } = require('../utils/diff-engine');
17
+ const ai = require('../utils/ai-service');
16
18
 
17
19
  async function locatedCanonical() {
18
20
  let found;
@@ -165,10 +167,58 @@ const handlers = {
165
167
  process.exitCode = 1;
166
168
  } else console.log(result.status);
167
169
  },
168
- async resolve(repo) {
170
+ async resolve(repo, options = {}) {
169
171
  const index = await GitIndex.read(repo.indexPath);
170
- for (const name of index.conflicts().keys()) console.log(name);
171
- console.log('Edit conflicted files, then gent add <path> and gent merge --continue.');
172
+ const names = [...index.conflicts().keys()];
173
+ if (!names.length) {
174
+ console.log('No merge conflicts to resolve.');
175
+ return;
176
+ }
177
+ if (!options.ai) {
178
+ for (const name of names) console.log(name);
179
+ console.log('Edit conflicted files, then gent add <path> and gent merge --continue.');
180
+ return;
181
+ }
182
+
183
+ await ai.prime();
184
+ if (!ai.isEnabled()) throw new Error(ai.disabledHint());
185
+
186
+ let resolved = 0;
187
+ for (const name of names) {
188
+ const sides = await merge.conflictSides(repo, name);
189
+ if ([sides.base, sides.ours, sides.theirs].some(value => value && looksBinary(value))) {
190
+ console.log(`Skipping binary conflict: ${name}`);
191
+ continue;
192
+ }
193
+ try {
194
+ const suggestion = await ai.resolveConflictHunk({
195
+ base: sides.base?.toString('utf8') || '',
196
+ ours: sides.ours?.toString('utf8') || '',
197
+ theirs: sides.theirs?.toString('utf8') || '',
198
+ fileName: name,
199
+ });
200
+ console.log(`\nAI suggestion for ${name} (review before accepting):\n${suggestion}`);
201
+ const { accept } = await inquirer.prompt([{
202
+ type: 'confirm',
203
+ name: 'accept',
204
+ message: `Apply and stage this resolution for ${name}?`,
205
+ default: false,
206
+ }]);
207
+ if (!accept) continue;
208
+ worktree.assertSafeCheckoutPath(repo, name);
209
+ await worktree.assertNoSymlinkParent(repo, name);
210
+ const absolute = path.join(repo.worktree, name);
211
+ await fs.mkdir(path.dirname(absolute), { recursive: true });
212
+ await fs.writeFile(absolute, suggestion, 'utf8');
213
+ await merge.markResolved(repo, name);
214
+ resolved++;
215
+ console.log(`Resolved and staged ${name}`);
216
+ } catch (error) {
217
+ console.log(`AI did not resolve ${name}: ${error.message}`);
218
+ }
219
+ }
220
+ console.log(`${resolved} of ${names.length} conflict(s) resolved with reviewed AI suggestions.`);
221
+ console.log('Review and test the files, then run gent merge --continue.');
172
222
  },
173
223
  async stash(repo, sub = 'push', options = {}) {
174
224
  const position = Number(options.index || 0);
@@ -5,25 +5,50 @@
5
5
  const chalk = require('chalk');
6
6
  const ora = require('ora');
7
7
  const path = require('path');
8
- const { API_ENDPOINTS } = require('../utils/constants');
8
+ const inquirer = require('inquirer');
9
+ const { API_ENDPOINTS, GENT_DIR, CONFIG_FILE } = require('../utils/constants');
9
10
  const apiClient = require('../utils/api-client');
10
11
  const authStorage = require('../utils/auth-storage');
12
+ const { pathExists, readJSON, writeJSON } = require('../utils/fileSystem');
13
+ const interactive = require('../utils/interactive');
14
+ const initCommand = require('./init');
15
+ const repository = require('../utils/repository');
11
16
 
12
17
  /**
13
18
  * List or create remote repositories
14
19
  * @param {Object} options
15
20
  */
16
- async function repos(options) {
21
+ async function repos(extraNames, options) {
17
22
  try {
23
+ options = options || {};
24
+ extraNames = extraNames || [];
25
+ let local = null;
26
+
27
+ if (options.create) {
28
+ const validation = validateRepositoryName(options.create, extraNames);
29
+ if (!validation.valid) {
30
+ console.error(chalk.red(validation.message));
31
+ console.log(chalk.yellow(`Use "gent repos --create ${validation.suggestion}" instead.`));
32
+ console.log(chalk.gray('No repository was created.'));
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+ local = await prepareLocalRepository(options);
37
+ if (!local) return;
38
+ } else if (extraNames.length) {
39
+ throw new Error('unexpected repository name; use --create <name>');
40
+ }
41
+
18
42
  const isAuth = await authStorage.isAuthenticated();
19
43
  if (!isAuth) {
20
44
  console.error(chalk.red('Not authenticated'));
21
45
  console.log(chalk.yellow('Run "gent login" first'));
46
+ process.exitCode = 1;
22
47
  return;
23
48
  }
24
49
 
25
50
  if (options.create) {
26
- await createRepo(options);
51
+ await createRepo(options, local);
27
52
  return;
28
53
  }
29
54
 
@@ -73,7 +98,7 @@ async function listRepos() {
73
98
  /**
74
99
  * Create a new remote repository
75
100
  */
76
- async function createRepo(options) {
101
+ async function createRepo(options, local) {
77
102
  const name = options.create;
78
103
  if (typeof name !== 'string' || !name) {
79
104
  console.error(chalk.red('Usage: gent repos --create <name>'));
@@ -91,13 +116,93 @@ async function createRepo(options) {
91
116
  if (options.defaultBranch) {
92
117
  payload.default_branch = options.defaultBranch;
93
118
  }
119
+ if (local.kind === 'canonical') payload.object_format = 'sha256';
94
120
 
95
- const data = await apiClient.post(API_ENDPOINTS.REPOS_CREATE, payload);
96
- const repo = data.repository || data;
121
+ let data;
122
+ try {
123
+ data = await apiClient.post(API_ENDPOINTS.REPOS_CREATE, payload);
124
+ } catch (error) {
125
+ spinner.stop();
126
+ throw error;
127
+ }
128
+ const remoteRepo = data.repository || data;
129
+ const remoteUrl = await linkOrigin(local, remoteRepo);
130
+
131
+ spinner.succeed(chalk.green(`Created repository '${remoteRepo.name}'`));
132
+ console.log(chalk.gray(` URL: /api/repos/${remoteRepo.owner_id}/${remoteRepo.name}`));
133
+ console.log(chalk.green(` Linked local repository to '${remoteUrl}' as origin`));
134
+ }
135
+
136
+ function validateRepositoryName(name, extraNames = []) {
137
+ const words = [name, ...extraNames].filter(value => typeof value === 'string' && value.trim());
138
+ const combined = words.join(' ').trim();
139
+ const suggestion = combined
140
+ .toLowerCase()
141
+ .replace(/[^a-z0-9._-]+/g, '-')
142
+ .replace(/-+/g, '-')
143
+ .replace(/^[-.]+|[-.]+$/g, '') || 'repository-name';
144
+ const valid = words.length === 1 && /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(name);
145
+ return {
146
+ valid,
147
+ suggestion,
148
+ message: `Invalid repository name '${combined || String(name || '')}'. Repository names cannot contain spaces or unsupported characters.`,
149
+ };
150
+ }
151
+
152
+ async function prepareLocalRepository(options) {
153
+ const gentPath = path.join(process.cwd(), GENT_DIR);
154
+ if (!await pathExists(gentPath)) {
155
+ if (!options.yes) {
156
+ if (!interactive.isInteractive()) {
157
+ console.error(chalk.red('No local Gent repository exists in this folder.'));
158
+ console.log(chalk.yellow('Run "gent init" first, or retry with --yes to initialize and link it automatically.'));
159
+ process.exitCode = 1;
160
+ return null;
161
+ }
162
+ const { initialize } = await inquirer.prompt([{
163
+ type: 'confirm',
164
+ name: 'initialize',
165
+ message: 'No local Gent repository exists in this folder. Create it before creating and linking the remote?',
166
+ default: true,
167
+ }]);
168
+ if (!initialize) {
169
+ console.log(chalk.yellow('Cancelled. No local or remote repository was created.'));
170
+ return null;
171
+ }
172
+ }
173
+ await initCommand({ yes: true });
174
+ }
175
+
176
+ const legacyConfig = path.join(gentPath, CONFIG_FILE);
177
+ if (await pathExists(legacyConfig)) {
178
+ const config = await readJSON(legacyConfig);
179
+ if (config.remotes?.origin) throw new Error("remote 'origin' already exists; no repository was created");
180
+ return { kind: 'legacy', config, configPath: legacyConfig };
181
+ }
182
+
183
+ const canonical = await repository.open(process.cwd());
184
+ if (canonical.config.get('remote.origin.url')) throw new Error("remote 'origin' already exists; no repository was created");
185
+ return { kind: 'canonical', repo: canonical };
186
+ }
187
+
188
+ async function linkOrigin(local, remoteRepo) {
189
+ if (local.kind === 'legacy') {
190
+ const url = `/api/repos/${remoteRepo.owner_id}/${remoteRepo.name}`;
191
+ local.config.remotes = local.config.remotes || {};
192
+ local.config.remotes.origin = { url };
193
+ await writeJSON(local.configPath, local.config);
194
+ return url;
195
+ }
97
196
 
98
- spinner.succeed(chalk.green(`Created repository '${repo.name}'`));
99
- console.log(chalk.gray(` URL: /api/repos/${repo.owner_id}/${repo.name}`));
100
- console.log(chalk.gray(` Use "gent remote add origin /api/repos/${repo.owner_id}/${repo.name}" to link`));
197
+ const base = (await apiClient.resolveBaseUrl()).replace(/\/$/, '');
198
+ const owner = remoteRepo.owner_username || remoteRepo.owner_id;
199
+ const url = `${base}/${encodeURIComponent(String(owner))}/${encodeURIComponent(remoteRepo.name)}.git`;
200
+ local.repo.localConfig.set('remote.origin.url', url);
201
+ local.repo.localConfig.set('remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*');
202
+ await local.repo.localConfig.save();
203
+ return url;
101
204
  }
102
205
 
103
206
  module.exports = repos;
207
+ module.exports.validateRepositoryName = validateRepositoryName;
208
+ module.exports.prepareLocalRepository = prepareLocalRepository;
@@ -32,8 +32,15 @@ const authStorage = require('../utils/auth-storage');
32
32
  const journal = require('../utils/journal');
33
33
  const ai = require('../utils/ai-service');
34
34
 
35
- async function resolve() {
35
+ async function resolve(options = {}) {
36
36
  try {
37
+ await ai.prime();
38
+ if (options.ai && !ai.isEnabled()) {
39
+ console.error(chalk.red(ai.disabledHint()));
40
+ process.exitCode = 1;
41
+ return;
42
+ }
43
+
37
44
  const gentPath = await getGentPath();
38
45
  const cwd = process.cwd();
39
46
 
@@ -92,7 +99,7 @@ async function resolve() {
92
99
  continue;
93
100
  }
94
101
  idx++;
95
- const resolvedLines = await resolveHunk(seg, file, idx, conflictCount);
102
+ const resolvedLines = await resolveHunk(seg, file, idx, conflictCount, options);
96
103
  if (resolvedLines === null) { aborted = true; break; }
97
104
  out.push(...resolvedLines);
98
105
  }
@@ -151,7 +158,7 @@ async function resolve() {
151
158
  * Prompt for one conflict hunk. Returns the chosen lines, or null to abort
152
159
  * (leave the rest of the file as-is with markers).
153
160
  */
154
- async function resolveHunk(seg, file, idx, total) {
161
+ async function resolveHunk(seg, file, idx, total, options = {}) {
155
162
  console.log(chalk.gray(` Conflict ${idx}/${total}:`));
156
163
  console.log(chalk.green(' <<< ours'));
157
164
  seg.ours.forEach(l => console.log(chalk.green(` ${l}`)));
@@ -169,6 +176,12 @@ async function resolveHunk(seg, file, idx, total) {
169
176
  }
170
177
  choices.push({ name: 'Skip the rest of this file', value: 'skip' });
171
178
 
179
+ if (options.ai) {
180
+ const suggestion = await askAiForHunk(seg, file);
181
+ if (suggestion !== null) return suggestion;
182
+ console.log(chalk.yellow(' Choose a manual resolution instead.'));
183
+ }
184
+
172
185
  const { choice } = await inquirer.prompt([{
173
186
  type: 'list',
174
187
  name: 'choice',
@@ -191,28 +204,36 @@ async function resolveHunk(seg, file, idx, total) {
191
204
  return text.replace(/\n$/, '').split('\n');
192
205
  }
193
206
  case 'ai': {
194
- try {
195
- const suggestion = await ai.resolveConflictHunk({
196
- ours: seg.ours.join('\n'),
197
- theirs: seg.theirs.join('\n'),
198
- fileName: file
199
- });
200
- console.log(chalk.cyan(' AI suggestion:'));
201
- suggestion.split('\n').forEach(l => console.log(chalk.cyan(` ${l}`)));
202
- const { accept } = await inquirer.prompt([{
203
- type: 'confirm', name: 'accept', message: 'Use this suggestion?', default: true
204
- }]);
205
- if (accept) return suggestion.split('\n');
206
- return resolveHunk(seg, file, idx, total); // re-ask
207
- } catch (err) {
208
- console.log(chalk.yellow(` AI failed (${err.message}); choose another option.`));
209
- return resolveHunk(seg, file, idx, total);
210
- }
207
+ const suggestion = await askAiForHunk(seg, file);
208
+ if (suggestion !== null) return suggestion;
209
+ return resolveHunk(seg, file, idx, total, { ai: false });
211
210
  }
212
211
  default: return seg.ours;
213
212
  }
214
213
  }
215
214
 
215
+ async function askAiForHunk(seg, file) {
216
+ try {
217
+ const suggestion = await ai.resolveConflictHunk({
218
+ ours: seg.ours.join('\n'),
219
+ theirs: seg.theirs.join('\n'),
220
+ fileName: file
221
+ });
222
+ console.log(chalk.cyan(' AI suggestion (review before accepting):'));
223
+ suggestion.split('\n').forEach(line => console.log(chalk.cyan(` ${line}`)));
224
+ const { accept } = await inquirer.prompt([{
225
+ type: 'confirm',
226
+ name: 'accept',
227
+ message: 'Use this AI suggestion?',
228
+ default: false,
229
+ }]);
230
+ return accept ? suggestion.split('\n') : null;
231
+ } catch (error) {
232
+ console.log(chalk.yellow(` AI failed (${error.message}); no file was changed.`));
233
+ return null;
234
+ }
235
+ }
236
+
216
237
  /** Store the resolved file as a blob, patch the tree entry, and stage it. */
217
238
  async function stageResolved(gentPath, staging, entriesByName, file, content) {
218
239
  const hash = await storeBlob(gentPath, content);
package/src/index.js CHANGED
@@ -284,6 +284,7 @@ program
284
284
  program
285
285
  .command('resolve')
286
286
  .description('Interactively resolve merge conflicts left by "gent merge"')
287
+ .option('--ai', 'Ask AI for each conflict resolution and review it before applying')
287
288
  .action(resolveCommand);
288
289
 
289
290
  program
@@ -316,12 +317,13 @@ program
316
317
  .action(remoteCommand);
317
318
 
318
319
  program
319
- .command('repos')
320
+ .command('repos [names...]')
320
321
  .description('List or create remote repositories')
321
322
  .option('--create <name>', 'Create a new remote repository')
322
323
  .option('--description <text>', 'Repository description (with --create)')
323
324
  .option('--private', 'Make repository private (with --create)')
324
325
  .option('--default-branch <name>', 'Default branch name (with --create)')
326
+ .option('-y, --yes', 'Initialize the current folder without prompting')
325
327
  .action(reposCommand);
326
328
 
327
329
  program