gent-cli 6.0.1 → 8.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/QUICKSTART.md +116 -85
- package/README.md +60 -3
- package/package.json +6 -4
- package/src/commands/ai.js +82 -0
- package/src/commands/ask.js +121 -0
- package/src/commands/branch.js +3 -0
- package/src/commands/changelog.js +121 -0
- package/src/commands/checkout.js +5 -0
- package/src/commands/clone.js +6 -3
- package/src/commands/commit.js +65 -2
- package/src/commands/config.js +147 -0
- package/src/commands/docs.js +141 -0
- package/src/commands/doctor.js +169 -0
- package/src/commands/explain.js +145 -0
- package/src/commands/log.js +51 -1
- package/src/commands/merge.js +4 -0
- package/src/commands/pull.js +4 -3
- package/src/commands/reset.js +10 -0
- package/src/commands/resolve.js +280 -0
- package/src/commands/review.js +157 -0
- package/src/commands/search.js +77 -0
- package/src/commands/setup.js +201 -0
- package/src/commands/share.js +63 -0
- package/src/commands/summary.js +176 -0
- package/src/commands/template.js +135 -0
- package/src/commands/undo.js +115 -0
- package/src/commands/web.js +72 -0
- package/src/index.js +189 -12
- package/src/utils/ai-service.js +219 -0
- package/src/utils/api-client.js +28 -6
- package/src/utils/constants.js +3 -1
- package/src/utils/diff-engine.js +68 -2
- package/src/utils/env-loader.js +61 -0
- package/src/utils/journal.js +215 -0
- package/src/utils/merge-engine.js +340 -140
- package/src/utils/user-config.js +225 -0
package/src/utils/diff-engine.js
CHANGED
|
@@ -22,6 +22,14 @@
|
|
|
22
22
|
* - Move up (i-1) → DELETE (line only in old version)
|
|
23
23
|
* - Move left (j-1) → INSERT (line only in new version)
|
|
24
24
|
*
|
|
25
|
+
* COMMON PREFIX/SUFFIX TRIMMING (optimization):
|
|
26
|
+
* Before building the O(M×N) matrix, identical leading and trailing lines are
|
|
27
|
+
* stripped. Only the differing "middle" is run through LCS; the trimmed lines
|
|
28
|
+
* are re-attached as `equal` ops with their original 1-based positions. For
|
|
29
|
+
* the common case of a localized edit in a large file this turns an O(M×N)
|
|
30
|
+
* matrix into something proportional to the size of the change — a large
|
|
31
|
+
* memory and time win — while producing byte-identical output.
|
|
32
|
+
*
|
|
25
33
|
* HUNK GENERATION:
|
|
26
34
|
* Groups adjacent changes with N context lines (default 3) into hunks.
|
|
27
35
|
* Changes within 2*N+1 lines of each other merge into one hunk.
|
|
@@ -63,12 +71,14 @@ function buildLcsMatrix(a, b) {
|
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
/**
|
|
66
|
-
* Backtrack LCS matrix → line operations
|
|
74
|
+
* Backtrack the LCS matrix of two line arrays → line operations with
|
|
75
|
+
* 1-based positions local to the given arrays. Pure O(M×N) core; callers
|
|
76
|
+
* normally use buildLineOperations, which trims common prefix/suffix first.
|
|
67
77
|
* @param {String[]} oldLines
|
|
68
78
|
* @param {String[]} newLines
|
|
69
79
|
* @returns {Array<{type: 'equal'|'insert'|'delete', oldLine: number, newLine: number, content: String}>}
|
|
70
80
|
*/
|
|
71
|
-
function
|
|
81
|
+
function lcsOperations(oldLines, newLines) {
|
|
72
82
|
const matrix = buildLcsMatrix(oldLines, newLines);
|
|
73
83
|
const ops = [];
|
|
74
84
|
let i = oldLines.length;
|
|
@@ -90,6 +100,61 @@ function buildLineOperations(oldLines, newLines) {
|
|
|
90
100
|
return ops.reverse();
|
|
91
101
|
}
|
|
92
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Diff two line arrays into operations, trimming common prefix/suffix before
|
|
105
|
+
* running LCS on the differing middle. Output is identical to running LCS on
|
|
106
|
+
* the full arrays, with `oldLine`/`newLine` as absolute 1-based positions.
|
|
107
|
+
* @param {String[]} oldLines
|
|
108
|
+
* @param {String[]} newLines
|
|
109
|
+
* @returns {Array<{type: 'equal'|'insert'|'delete', oldLine: number, newLine: number, content: String}>}
|
|
110
|
+
*/
|
|
111
|
+
function buildLineOperations(oldLines, newLines) {
|
|
112
|
+
const oldLen = oldLines.length;
|
|
113
|
+
const newLen = newLines.length;
|
|
114
|
+
const minLen = Math.min(oldLen, newLen);
|
|
115
|
+
|
|
116
|
+
// Common leading lines.
|
|
117
|
+
let prefix = 0;
|
|
118
|
+
while (prefix < minLen && oldLines[prefix] === newLines[prefix]) prefix++;
|
|
119
|
+
|
|
120
|
+
// Common trailing lines (not overlapping the prefix).
|
|
121
|
+
let suffix = 0;
|
|
122
|
+
while (
|
|
123
|
+
suffix < minLen - prefix &&
|
|
124
|
+
oldLines[oldLen - 1 - suffix] === newLines[newLen - 1 - suffix]
|
|
125
|
+
) suffix++;
|
|
126
|
+
|
|
127
|
+
const ops = [];
|
|
128
|
+
|
|
129
|
+
// Prefix → equal ops at their original positions.
|
|
130
|
+
for (let k = 0; k < prefix; k++) {
|
|
131
|
+
ops.push({ type: 'equal', oldLine: k + 1, newLine: k + 1, content: oldLines[k] });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Middle → LCS, shifted back into absolute coordinates by `prefix`.
|
|
135
|
+
const oldMid = oldLines.slice(prefix, oldLen - suffix);
|
|
136
|
+
const newMid = newLines.slice(prefix, newLen - suffix);
|
|
137
|
+
if (oldMid.length || newMid.length) {
|
|
138
|
+
for (const op of lcsOperations(oldMid, newMid)) {
|
|
139
|
+
ops.push({
|
|
140
|
+
type: op.type,
|
|
141
|
+
oldLine: op.oldLine + prefix,
|
|
142
|
+
newLine: op.newLine + prefix,
|
|
143
|
+
content: op.content
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Suffix → equal ops at their original positions.
|
|
149
|
+
for (let k = 0; k < suffix; k++) {
|
|
150
|
+
const oldIdx = oldLen - suffix + k;
|
|
151
|
+
const newIdx = newLen - suffix + k;
|
|
152
|
+
ops.push({ type: 'equal', oldLine: oldIdx + 1, newLine: newIdx + 1, content: oldLines[oldIdx] });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return ops;
|
|
156
|
+
}
|
|
157
|
+
|
|
93
158
|
/**
|
|
94
159
|
* Diff two texts → operations + stats.
|
|
95
160
|
* @param {String} oldText
|
|
@@ -229,6 +294,7 @@ module.exports = {
|
|
|
229
294
|
diffText,
|
|
230
295
|
summarizeOperations,
|
|
231
296
|
buildLcsMatrix,
|
|
297
|
+
lcsOperations,
|
|
232
298
|
buildLineOperations,
|
|
233
299
|
generateHunks,
|
|
234
300
|
formatUnifiedDiff,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env Loader - Load .env files into process.env before commands run.
|
|
3
|
+
*
|
|
4
|
+
* Precedence (lower wins — does NOT clobber existing env):
|
|
5
|
+
* 1. process.env (real shell vars) ← highest, untouched
|
|
6
|
+
* 2. <cwd>/.env ← project-local
|
|
7
|
+
* 3. ~/.gent/.env ← user-global
|
|
8
|
+
*
|
|
9
|
+
* No new dependency: a tiny KEY=VALUE parser (supports quoted values + comments).
|
|
10
|
+
* Silently no-ops if files are missing or malformed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const { GENT_DIR } = require('./constants');
|
|
17
|
+
|
|
18
|
+
function parse(content) {
|
|
19
|
+
const out = {};
|
|
20
|
+
const lines = content.split(/\r?\n/);
|
|
21
|
+
for (const raw of lines) {
|
|
22
|
+
const line = raw.trim();
|
|
23
|
+
if (!line || line.startsWith('#')) continue;
|
|
24
|
+
const eq = line.indexOf('=');
|
|
25
|
+
if (eq < 1) continue;
|
|
26
|
+
const key = line.slice(0, eq).trim();
|
|
27
|
+
let value = line.slice(eq + 1).trim();
|
|
28
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
29
|
+
|
|
30
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
31
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
32
|
+
value = value.slice(1, -1);
|
|
33
|
+
} else {
|
|
34
|
+
const hash = value.indexOf(' #');
|
|
35
|
+
if (hash !== -1) value = value.slice(0, hash).trim();
|
|
36
|
+
}
|
|
37
|
+
out[key] = value;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function loadOne(filePath) {
|
|
43
|
+
try {
|
|
44
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
45
|
+
const parsed = parse(content);
|
|
46
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
47
|
+
if (process.env[k] === undefined) {
|
|
48
|
+
process.env[k] = v;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Missing or unreadable — fine.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function load() {
|
|
57
|
+
loadOne(path.join(process.cwd(), '.env'));
|
|
58
|
+
loadOne(path.join(os.homedir(), GENT_DIR, '.env'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { load, parse };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Operation Journal - Safety net for history-changing commands
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* Record every history-changing operation (commit, merge, reset, checkout,
|
|
8
|
+
* branch delete, pull) so it can be reversed with a single `gent undo` and
|
|
9
|
+
* re-applied with `gent redo`. Friendlier and more discoverable than
|
|
10
|
+
* `git reflog`: a human-readable list and one-command undo.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IS RECORDED:
|
|
13
|
+
* Before an operation mutates `commits.json`, `recordOp()` snapshots the
|
|
14
|
+
* parts that change — the `branches` map and `currentBranch`. That snapshot
|
|
15
|
+
* plus a label/description/timestamp becomes one journal entry.
|
|
16
|
+
*
|
|
17
|
+
* UNDO SEMANTICS (intentionally non-destructive):
|
|
18
|
+
* - `gent undo` restores branch pointers + current branch to their state
|
|
19
|
+
* before the last operation.
|
|
20
|
+
* - Working files are NEVER deleted. For operations that discard file
|
|
21
|
+
* content (reset --hard, fast-forward merge, pull) the entry is flagged
|
|
22
|
+
* `restoreTree`, and undo also rewrites those files from the object store.
|
|
23
|
+
* For commit / checkout / branch-delete, undo leaves the working tree
|
|
24
|
+
* as-is — a just-committed change simply becomes uncommitted again.
|
|
25
|
+
* - `gent redo` re-applies the last undone operation. Any new history-
|
|
26
|
+
* changing operation clears the redo stack.
|
|
27
|
+
*
|
|
28
|
+
* STORAGE:
|
|
29
|
+
* .gent/journal.json → { entries: [...], redo: [...] }
|
|
30
|
+
* History is capped to the most recent MAX_ENTRIES operations.
|
|
31
|
+
*
|
|
32
|
+
* ============================================================================
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const fs = require('fs').promises;
|
|
36
|
+
const path = require('path');
|
|
37
|
+
const { readJSON, writeJSON, pathExists } = require('./fileSystem');
|
|
38
|
+
const { COMMITS_FILE, STAGING_FILE } = require('./constants');
|
|
39
|
+
const { readBlobAsString } = require('./hash-engine');
|
|
40
|
+
|
|
41
|
+
const JOURNAL_FILE = 'journal.json';
|
|
42
|
+
const MAX_ENTRIES = 100;
|
|
43
|
+
|
|
44
|
+
// ─── Persistence ────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
async function readJournal(gentPath) {
|
|
47
|
+
const p = path.join(gentPath, JOURNAL_FILE);
|
|
48
|
+
if (!await pathExists(p)) return { entries: [], redo: [] };
|
|
49
|
+
try {
|
|
50
|
+
const j = await readJSON(p);
|
|
51
|
+
return { entries: j.entries || [], redo: j.redo || [] };
|
|
52
|
+
} catch {
|
|
53
|
+
return { entries: [], redo: [] };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function writeJournal(gentPath, journal) {
|
|
58
|
+
await writeJSON(path.join(gentPath, JOURNAL_FILE), journal);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Helpers ────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function snapshotState(repository) {
|
|
64
|
+
return {
|
|
65
|
+
branches: { ...(repository.branches || {}) },
|
|
66
|
+
currentBranch: repository.currentBranch || 'main'
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function makeId() {
|
|
71
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Resolve a commit's tree entries (supports legacy `files` shape). */
|
|
75
|
+
function treeOf(commits, hash) {
|
|
76
|
+
if (!hash) return [];
|
|
77
|
+
const c = (commits || []).find(x => x.hash === hash);
|
|
78
|
+
if (!c) return [];
|
|
79
|
+
if (Array.isArray(c.tree)) return c.tree;
|
|
80
|
+
return (c.files || []).map(f => ({ mode: '100644', name: f.path || f.name, hash: f.hash, type: 'blob' }));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Overwrite/create working files from a tree (never deletes). */
|
|
84
|
+
async function restoreFiles(gentPath, cwd, tree) {
|
|
85
|
+
for (const e of tree) {
|
|
86
|
+
try {
|
|
87
|
+
const content = await readBlobAsString(gentPath, e.hash);
|
|
88
|
+
const full = path.join(cwd, e.name);
|
|
89
|
+
await fs.mkdir(path.dirname(full), { recursive: true });
|
|
90
|
+
await fs.writeFile(full, content, 'utf-8');
|
|
91
|
+
} catch {
|
|
92
|
+
// Blob may be missing for legacy commits — best effort.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function clearStaging(gentPath) {
|
|
98
|
+
await writeJSON(path.join(gentPath, STAGING_FILE), { entries: [], files: [], mergeState: null });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Public API ─────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Record the pre-operation state. Call this BEFORE the command writes
|
|
105
|
+
* commits.json. Journaling must never break the underlying command, so all
|
|
106
|
+
* failures are swallowed.
|
|
107
|
+
* @param {String} gentPath
|
|
108
|
+
* @param {String} op - short op label (commit|merge|reset|checkout|branch-delete|pull)
|
|
109
|
+
* @param {String} description - human-readable detail
|
|
110
|
+
* @param {Object} [meta] - e.g. { restoreTree: true }
|
|
111
|
+
*/
|
|
112
|
+
async function recordOp(gentPath, op, description, meta = {}) {
|
|
113
|
+
try {
|
|
114
|
+
const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
115
|
+
const journal = await readJournal(gentPath);
|
|
116
|
+
journal.entries.push({
|
|
117
|
+
id: makeId(),
|
|
118
|
+
op,
|
|
119
|
+
description: description || '',
|
|
120
|
+
timestamp: new Date().toISOString(),
|
|
121
|
+
meta,
|
|
122
|
+
state: snapshotState(repository)
|
|
123
|
+
});
|
|
124
|
+
if (journal.entries.length > MAX_ENTRIES) {
|
|
125
|
+
journal.entries = journal.entries.slice(-MAX_ENTRIES);
|
|
126
|
+
}
|
|
127
|
+
journal.redo = []; // a fresh action invalidates the redo stack
|
|
128
|
+
await writeJournal(gentPath, journal);
|
|
129
|
+
} catch {
|
|
130
|
+
// Never let journaling failures surface to the user.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Return journal entries, most-recent first. */
|
|
135
|
+
async function listEntries(gentPath) {
|
|
136
|
+
const journal = await readJournal(gentPath);
|
|
137
|
+
return [...journal.entries].reverse();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Restore commits.json pointers + (optionally) working tree to `targetState`.
|
|
142
|
+
* Shared by undo and redo.
|
|
143
|
+
*/
|
|
144
|
+
async function applyState(gentPath, cwd, repository, commits, targetState, restoreTree) {
|
|
145
|
+
repository.branches = { ...targetState.branches };
|
|
146
|
+
repository.currentBranch = targetState.currentBranch;
|
|
147
|
+
await writeJSON(path.join(gentPath, COMMITS_FILE), repository);
|
|
148
|
+
|
|
149
|
+
if (restoreTree) {
|
|
150
|
+
const head = targetState.branches[targetState.currentBranch];
|
|
151
|
+
await restoreFiles(gentPath, cwd, treeOf(commits, head));
|
|
152
|
+
}
|
|
153
|
+
await clearStaging(gentPath);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reverse the last recorded operation.
|
|
158
|
+
* @returns {Promise<{ok: Boolean, reason?: String, entry?: Object, head?: String, branch?: String}>}
|
|
159
|
+
*/
|
|
160
|
+
async function applyUndo(gentPath, cwd) {
|
|
161
|
+
const journal = await readJournal(gentPath);
|
|
162
|
+
if (journal.entries.length === 0) return { ok: false, reason: 'nothing-to-undo' };
|
|
163
|
+
|
|
164
|
+
const entry = journal.entries.pop();
|
|
165
|
+
const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
166
|
+
const commits = repository.commits || [];
|
|
167
|
+
|
|
168
|
+
// Remember the post-operation state so `redo` can re-apply it.
|
|
169
|
+
const postState = snapshotState(repository);
|
|
170
|
+
journal.redo.push({ ...entry, state: postState });
|
|
171
|
+
|
|
172
|
+
await applyState(gentPath, cwd, repository, commits, entry.state, !!(entry.meta && entry.meta.restoreTree));
|
|
173
|
+
await writeJournal(gentPath, journal);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
entry,
|
|
178
|
+
branch: entry.state.currentBranch,
|
|
179
|
+
head: entry.state.branches[entry.state.currentBranch] || null
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Re-apply the last undone operation.
|
|
185
|
+
* @returns {Promise<{ok: Boolean, reason?: String, entry?: Object, head?: String, branch?: String}>}
|
|
186
|
+
*/
|
|
187
|
+
async function applyRedo(gentPath, cwd) {
|
|
188
|
+
const journal = await readJournal(gentPath);
|
|
189
|
+
if (journal.redo.length === 0) return { ok: false, reason: 'nothing-to-redo' };
|
|
190
|
+
|
|
191
|
+
const entry = journal.redo.pop();
|
|
192
|
+
const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
|
|
193
|
+
const commits = repository.commits || [];
|
|
194
|
+
|
|
195
|
+
const preState = snapshotState(repository);
|
|
196
|
+
journal.entries.push({ ...entry, state: preState });
|
|
197
|
+
|
|
198
|
+
await applyState(gentPath, cwd, repository, commits, entry.state, !!(entry.meta && entry.meta.restoreTree));
|
|
199
|
+
await writeJournal(gentPath, journal);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
entry,
|
|
204
|
+
branch: entry.state.currentBranch,
|
|
205
|
+
head: entry.state.branches[entry.state.currentBranch] || null
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
recordOp,
|
|
211
|
+
listEntries,
|
|
212
|
+
applyUndo,
|
|
213
|
+
applyRedo,
|
|
214
|
+
readJournal
|
|
215
|
+
};
|