gent-cli 6.0.0 → 7.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,176 @@
1
+ /**
2
+ * ============================================================================
3
+ * Summary Command - Repository health & statistics dashboard
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * A one-glance overview of the repository — commits, branches, tags,
8
+ * contributors, tracked files, object-store size, most-changed files, last
9
+ * activity, and how far ahead of the remote the current branch is. Something
10
+ * plain git doesn't offer in a single command.
11
+ *
12
+ * USAGE:
13
+ * gent summary → print the dashboard
14
+ * gent summary --ai → also include a short AI-written health narrative
15
+ *
16
+ * ============================================================================
17
+ */
18
+
19
+ const fs = require('fs').promises;
20
+ const path = require('path');
21
+ const chalk = require('chalk');
22
+ const boxen = require('boxen');
23
+ const { formatDistanceToNow } = require('date-fns');
24
+ const { getGentPath, readJSON, pathExists } = require('../utils/fileSystem');
25
+ const { COMMITS_FILE, CONFIG_FILE } = require('../utils/constants');
26
+ const { readBlobAsString } = require('../utils/hash-engine');
27
+ const { formatBytes } = require('../utils/helpers');
28
+ const ai = require('../utils/ai-service');
29
+
30
+ function treeEntriesOf(commit) {
31
+ if (!commit) return [];
32
+ if (Array.isArray(commit.tree)) return commit.tree;
33
+ return (commit.files || []).map(f => ({ name: f.path || f.name, hash: f.hash }));
34
+ }
35
+
36
+ /** Recursively sum the byte size of the object store. */
37
+ async function objectStoreSize(gentPath) {
38
+ const root = path.join(gentPath, 'objects');
39
+ let total = 0;
40
+ async function walk(dir) {
41
+ let entries;
42
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
43
+ for (const e of entries) {
44
+ const full = path.join(dir, e.name);
45
+ if (e.isDirectory()) await walk(full);
46
+ else { try { total += (await fs.stat(full)).size; } catch { /* ignore */ } }
47
+ }
48
+ }
49
+ await walk(root);
50
+ return total;
51
+ }
52
+
53
+ /** Count change frequency per file across history (hash-only tree diff). */
54
+ function mostChangedFiles(commits, commitMap, limit = 5) {
55
+ const counts = new Map();
56
+ for (const c of commits) {
57
+ const cur = new Map(treeEntriesOf(c).map(e => [e.name, e.hash]));
58
+ const parent = c.parent ? commitMap.get(c.parent) : null;
59
+ const prev = new Map(treeEntriesOf(parent).map(e => [e.name, e.hash]));
60
+ const names = new Set([...cur.keys(), ...prev.keys()]);
61
+ for (const name of names) {
62
+ if (cur.get(name) !== prev.get(name)) counts.set(name, (counts.get(name) || 0) + 1);
63
+ }
64
+ }
65
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
66
+ }
67
+
68
+ /** Count commits reachable from `head` not yet known to the remote ref. */
69
+ function aheadCount(commitMap, head, remoteRef) {
70
+ if (!head) return 0;
71
+ let cur = head, n = 0;
72
+ const guard = new Set();
73
+ while (cur && cur !== remoteRef && !guard.has(cur)) {
74
+ guard.add(cur);
75
+ const c = commitMap.get(cur);
76
+ if (!c) break;
77
+ n++;
78
+ cur = c.parent;
79
+ }
80
+ return n;
81
+ }
82
+
83
+ async function summary(options = {}) {
84
+ try {
85
+ const gentPath = await getGentPath();
86
+ const repository = await readJSON(path.join(gentPath, COMMITS_FILE));
87
+ const configPath = path.join(gentPath, CONFIG_FILE);
88
+ const config = (await pathExists(configPath)) ? await readJSON(configPath) : {};
89
+
90
+ const commits = repository.commits || [];
91
+ const commitMap = new Map(commits.map(c => [c.hash, c]));
92
+ const branches = repository.branches || {};
93
+ const currentBranch = repository.currentBranch || 'main';
94
+ const tags = repository.tags || {};
95
+
96
+ // Contributors
97
+ const authors = new Map();
98
+ for (const c of commits) {
99
+ const key = `${c.author?.name || 'Unknown'} <${c.author?.email || 'unknown'}>`;
100
+ authors.set(key, (authors.get(key) || 0) + 1);
101
+ }
102
+ const topAuthors = [...authors.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
103
+
104
+ // Tracked files + lines of code (text blobs only)
105
+ const headHash = branches[currentBranch];
106
+ const headTree = treeEntriesOf(commitMap.get(headHash));
107
+ let loc = 0;
108
+ for (const e of headTree) {
109
+ try { loc += (await readBlobAsString(gentPath, e.hash)).split('\n').length; } catch { /* binary */ }
110
+ }
111
+
112
+ const storeSize = await objectStoreSize(gentPath);
113
+ const topChanged = mostChangedFiles(commits, commitMap);
114
+
115
+ // Last activity
116
+ const last = commits.reduce((acc, c) =>
117
+ (!acc || new Date(c.timestamp) > new Date(acc.timestamp)) ? c : acc, null);
118
+
119
+ // Ahead of remote (if known)
120
+ const remoteRef = (config.remoteRefs || {})[`origin/${currentBranch}`];
121
+ const ahead = remoteRef ? aheadCount(commitMap, headHash, remoteRef) : null;
122
+
123
+ // ── Render ──
124
+ const lines = [];
125
+ lines.push(chalk.bold.cyan(config.repository?.name || path.basename(process.cwd())));
126
+ if (config.repository?.description) lines.push(chalk.gray(config.repository.description));
127
+ lines.push('');
128
+ lines.push(`${chalk.bold('Branch:')} ${chalk.green(currentBranch)} ${chalk.gray(`(${Object.keys(branches).length} total)`)}`);
129
+ lines.push(`${chalk.bold('Commits:')} ${commits.length}`);
130
+ lines.push(`${chalk.bold('Tags:')} ${Object.keys(tags).length}`);
131
+ lines.push(`${chalk.bold('Tracked:')} ${headTree.length} file(s), ~${loc} lines`);
132
+ lines.push(`${chalk.bold('Objects:')} ${formatBytes(storeSize)}`);
133
+ if (ahead !== null) lines.push(`${chalk.bold('Remote:')} ${ahead === 0 ? chalk.green('up to date') : chalk.yellow(`${ahead} commit(s) ahead of origin/${currentBranch}`)}`);
134
+ if (last) lines.push(`${chalk.bold('Last commit:')} ${formatDistanceToNow(new Date(last.timestamp), { addSuffix: true })}`);
135
+
136
+ if (topAuthors.length) {
137
+ lines.push('');
138
+ lines.push(chalk.bold('Top contributors:'));
139
+ for (const [name, n] of topAuthors) lines.push(` ${chalk.green(String(n).padStart(4))} ${name}`);
140
+ }
141
+
142
+ if (topChanged.length) {
143
+ lines.push('');
144
+ lines.push(chalk.bold('Most-changed files:'));
145
+ for (const [name, n] of topChanged) lines.push(` ${chalk.yellow(String(n).padStart(4))} ${name}`);
146
+ }
147
+
148
+ console.log(boxen(lines.join('\n'), {
149
+ padding: 1, margin: 1, borderStyle: 'round', borderColor: 'cyan', title: 'gent summary', titleAlignment: 'center'
150
+ }));
151
+
152
+ if (options.ai) {
153
+ if (!ai.isEnabled()) {
154
+ console.log(chalk.gray(ai.disabledHint()));
155
+ } else {
156
+ try {
157
+ const facts = lines.join('\n').replace(/\[[0-9;]*m/g, ''); // strip colors
158
+ const narrative = await ai.explainChanges(`Repository stats:\n${facts}\n\nGive a 2-3 sentence health assessment.`);
159
+ console.log(chalk.cyan(narrative));
160
+ } catch (err) {
161
+ console.log(chalk.yellow(`AI summary failed: ${err.message}`));
162
+ }
163
+ }
164
+ }
165
+ } catch (error) {
166
+ if (error.code === 'ENOENT' && error.message.includes('.gent')) {
167
+ console.error(chalk.red('Error: Not a gent repository'));
168
+ console.log(chalk.yellow('\nRun "gent init" to initialize a repository'));
169
+ } else {
170
+ console.error(chalk.red('Error:'), error.message);
171
+ }
172
+ process.exit(1);
173
+ }
174
+ }
175
+
176
+ module.exports = summary;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * ============================================================================
3
+ * Undo / Redo Commands - One-command safety net over the operation journal
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Reverse (or re-apply) the last history-changing operation without having to
8
+ * reason about reflogs and commit hashes.
9
+ *
10
+ * USAGE:
11
+ * gent undo → Reverse the last operation
12
+ * gent undo --list → Show the operation history (most recent first)
13
+ * gent redo → Re-apply the last undone operation
14
+ *
15
+ * See src/utils/journal.js for the recorded state and exact undo semantics
16
+ * (working files are never deleted).
17
+ *
18
+ * ============================================================================
19
+ */
20
+
21
+ const chalk = require('chalk');
22
+ const { formatDistanceToNow } = require('date-fns');
23
+ const { getGentPath } = require('../utils/fileSystem');
24
+ const journal = require('../utils/journal');
25
+
26
+ function shortHash(h) {
27
+ return h ? h.substring(0, 7) : '(none)';
28
+ }
29
+
30
+ function notARepo(error) {
31
+ return error.code === 'ENOENT' && error.message.includes('.gent');
32
+ }
33
+
34
+ /**
35
+ * `gent undo` — reverse the last operation, or list history with --list.
36
+ * @param {Object} options
37
+ */
38
+ async function undo(options = {}) {
39
+ try {
40
+ const gentPath = await getGentPath();
41
+ const cwd = process.cwd();
42
+
43
+ if (options.list) {
44
+ await printHistory(gentPath);
45
+ return;
46
+ }
47
+
48
+ const result = await journal.applyUndo(gentPath, cwd);
49
+ if (!result.ok) {
50
+ console.log(chalk.yellow('Nothing to undo'));
51
+ console.log(chalk.gray('History-changing operations (commit, merge, reset, checkout) can be undone.'));
52
+ return;
53
+ }
54
+
55
+ const { entry } = result;
56
+ console.log(chalk.green(`✓ Undid ${chalk.bold(entry.op)}: ${entry.description}`));
57
+ console.log(chalk.gray(` Now on '${result.branch}' at ${shortHash(result.head)}`));
58
+ console.log(chalk.cyan(' Run "gent redo" to re-apply.'));
59
+ } catch (error) {
60
+ if (notARepo(error)) {
61
+ console.error(chalk.red('Error: Not a gent repository'));
62
+ console.log(chalk.yellow('\nRun "gent init" to initialize a repository'));
63
+ } else {
64
+ console.error(chalk.red('Error:'), error.message);
65
+ }
66
+ process.exit(1);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * `gent redo` — re-apply the last undone operation.
72
+ */
73
+ async function redo() {
74
+ try {
75
+ const gentPath = await getGentPath();
76
+ const cwd = process.cwd();
77
+
78
+ const result = await journal.applyRedo(gentPath, cwd);
79
+ if (!result.ok) {
80
+ console.log(chalk.yellow('Nothing to redo'));
81
+ return;
82
+ }
83
+
84
+ const { entry } = result;
85
+ console.log(chalk.green(`✓ Redid ${chalk.bold(entry.op)}: ${entry.description}`));
86
+ console.log(chalk.gray(` Now on '${result.branch}' at ${shortHash(result.head)}`));
87
+ } catch (error) {
88
+ if (notARepo(error)) {
89
+ console.error(chalk.red('Error: Not a gent repository'));
90
+ console.log(chalk.yellow('\nRun "gent init" to initialize a repository'));
91
+ } else {
92
+ console.error(chalk.red('Error:'), error.message);
93
+ }
94
+ process.exit(1);
95
+ }
96
+ }
97
+
98
+ async function printHistory(gentPath) {
99
+ const entries = await journal.listEntries(gentPath);
100
+ if (entries.length === 0) {
101
+ console.log(chalk.yellow('No operations recorded yet'));
102
+ return;
103
+ }
104
+
105
+ console.log(chalk.bold.cyan('\nOperation history (most recent first):\n'));
106
+ entries.forEach((e, i) => {
107
+ const when = chalk.gray(`(${formatDistanceToNow(new Date(e.timestamp), { addSuffix: true })})`);
108
+ const marker = i === 0 ? chalk.yellow('● ') : chalk.gray('○ ');
109
+ console.log(`${marker}${chalk.bold(e.op.padEnd(14))} ${e.description} ${when}`);
110
+ });
111
+ console.log(chalk.gray('\n"gent undo" reverses the most recent (●).'));
112
+ }
113
+
114
+ module.exports = undo;
115
+ module.exports.redo = redo;
package/src/index.js CHANGED
@@ -9,13 +9,15 @@
9
9
  * COMMANDS:
10
10
  * Repository: init, clone
11
11
  * Staging: add, rm, reset, status, diff
12
- * History: commit, log, show, tag
13
- * Branching: branch, checkout, merge, stash
12
+ * History: commit, log, show, tag, explain
13
+ * Branching: branch, checkout, merge, resolve, stash
14
+ * Safety: undo, redo
15
+ * Insight: summary
14
16
  * Remote: remote, push, pull
15
17
  * Auth: register, login, logout, whoami
16
18
  *
17
19
  * @author Abdalrahman Kanawati
18
- * @version 2.0.0
20
+ * @version 7.0.0
19
21
  */
20
22
 
21
23
  const { program } = require('commander');
@@ -42,6 +44,10 @@ const remoteCommand = require('./commands/remote');
42
44
  const pushCommand = require('./commands/push');
43
45
  const pullCommand = require('./commands/pull');
44
46
  const reposCommand = require('./commands/repos');
47
+ const undoCommand = require('./commands/undo');
48
+ const resolveCommand = require('./commands/resolve');
49
+ const summaryCommand = require('./commands/summary');
50
+ const explainCommand = require('./commands/explain');
45
51
 
46
52
  // Import auth commands
47
53
  const registerCommand = require('./commands/register');
@@ -110,6 +116,7 @@ program
110
116
  .description('Record changes to the repository')
111
117
  .option('-m, --message <message>', 'Commit message')
112
118
  .option('-a, --all', 'Automatically stage all modified files')
119
+ .option('--ai', 'Suggest a commit message with AI (needs ANTHROPIC_API_KEY)')
113
120
  .action(commitCommand);
114
121
 
115
122
  program
@@ -117,6 +124,7 @@ program
117
124
  .description('Show commit logs')
118
125
  .option('-n, --number <count>', 'Limit the number of commits to show', '10')
119
126
  .option('--oneline', 'Show each commit on a single line')
127
+ .option('--graph', 'Show an ASCII commit graph with branches and merges')
120
128
  .option('--stat', 'Show file change statistics')
121
129
  .action(logCommand);
122
130
 
@@ -133,6 +141,18 @@ program
133
141
  .option('-d, --delete <name>', 'Delete a tag')
134
142
  .action(tagCommand);
135
143
 
144
+ program
145
+ .command('explain [ref]')
146
+ .description('Explain a commit or staged changes in plain language')
147
+ .option('--staged', 'Explain staged changes instead of a commit')
148
+ .action(explainCommand);
149
+
150
+ program
151
+ .command('summary')
152
+ .description('Show a repository health & statistics dashboard')
153
+ .option('--ai', 'Add an AI-written health narrative (needs ANTHROPIC_API_KEY)')
154
+ .action(summaryCommand);
155
+
136
156
  // ─── Branching & Merging ────────────────────────────────
137
157
 
138
158
  program
@@ -155,6 +175,11 @@ program
155
175
  .option('-m, --message <message>', 'Merge commit message')
156
176
  .action(mergeCommand);
157
177
 
178
+ program
179
+ .command('resolve')
180
+ .description('Interactively resolve merge conflicts left by "gent merge"')
181
+ .action(resolveCommand);
182
+
158
183
  program
159
184
  .command('stash [subcommand]')
160
185
  .description('Stash working tree changes (pop|list|drop|apply)')
@@ -162,6 +187,17 @@ program
162
187
  .option('-i, --index <index>', 'Stash index for pop/apply/drop')
163
188
  .action(stashCommand);
164
189
 
190
+ program
191
+ .command('undo')
192
+ .description('Reverse the last history-changing operation (safety net)')
193
+ .option('-l, --list', 'Show the operation history')
194
+ .action(undoCommand);
195
+
196
+ program
197
+ .command('redo')
198
+ .description('Re-apply the last undone operation')
199
+ .action(undoCommand.redo);
200
+
165
201
  // ─── Remote & Sync ──────────────────────────────────────
166
202
 
167
203
  program
@@ -0,0 +1,156 @@
1
+ /**
2
+ * ============================================================================
3
+ * AI Service - Optional, key-gated Claude integration (hybrid layer)
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Power Gent's *optional* "smart" features (commit-message suggestions, diff
8
+ * explanations, AI-assisted conflict resolution). Every feature has a
9
+ * reliable algorithmic path; this layer only activates when the user has set
10
+ * an API key, and degrades gracefully (never throws into a command) when it
11
+ * is absent or the request fails.
12
+ *
13
+ * ENABLEMENT:
14
+ * Set ANTHROPIC_API_KEY in the environment to enable. Optionally set
15
+ * GENT_AI_MODEL to pick a model (default: claude-opus-4-8). For a cheaper /
16
+ * faster option set GENT_AI_MODEL=claude-haiku-4-5.
17
+ *
18
+ * IMPLEMENTATION NOTE:
19
+ * Calls the Anthropic Messages API (POST /v1/messages) directly over the
20
+ * project's existing `axios` dependency, to honour Gent's "no new runtime
21
+ * dependencies" constraint. A production app would normally use the official
22
+ * `@anthropic-ai/sdk`; raw HTTP is a deliberate trade-off here because the AI
23
+ * layer is optional and self-contained.
24
+ *
25
+ * ============================================================================
26
+ */
27
+
28
+ const axios = require('axios');
29
+
30
+ const API_URL = 'https://api.anthropic.com/v1/messages';
31
+ const API_VERSION = '2023-06-01';
32
+ const DEFAULT_MODEL = 'claude-opus-4-8';
33
+
34
+ /**
35
+ * Resolve the API key (env only — keeps secrets out of the repo).
36
+ * @returns {String|null}
37
+ */
38
+ function getApiKey() {
39
+ return process.env.ANTHROPIC_API_KEY || null;
40
+ }
41
+
42
+ /**
43
+ * @returns {Boolean} whether AI features are enabled.
44
+ */
45
+ function isEnabled() {
46
+ return !!getApiKey();
47
+ }
48
+
49
+ /**
50
+ * @returns {String} the model id to use.
51
+ */
52
+ function getModel() {
53
+ return process.env.GENT_AI_MODEL || DEFAULT_MODEL;
54
+ }
55
+
56
+ /**
57
+ * One-line hint shown by commands when AI is requested but no key is set.
58
+ * @returns {String}
59
+ */
60
+ function disabledHint() {
61
+ return 'AI features are off — set ANTHROPIC_API_KEY to enable (optional: GENT_AI_MODEL).';
62
+ }
63
+
64
+ /**
65
+ * Low-level single-shot completion. Returns the assistant's text.
66
+ * @param {Object} opts
67
+ * @param {String} opts.prompt - user content
68
+ * @param {String} [opts.system] - system prompt
69
+ * @param {Number} [opts.maxTokens]
70
+ * @returns {Promise<String>}
71
+ */
72
+ async function complete({ prompt, system, maxTokens = 1024 }) {
73
+ const apiKey = getApiKey();
74
+ if (!apiKey) throw new Error('AI not enabled');
75
+
76
+ const body = {
77
+ model: getModel(),
78
+ max_tokens: maxTokens,
79
+ messages: [{ role: 'user', content: prompt }]
80
+ };
81
+ if (system) body.system = system;
82
+
83
+ const res = await axios.post(API_URL, body, {
84
+ headers: {
85
+ 'x-api-key': apiKey,
86
+ 'anthropic-version': API_VERSION,
87
+ 'content-type': 'application/json'
88
+ },
89
+ timeout: 60000
90
+ });
91
+
92
+ const blocks = (res.data && res.data.content) || [];
93
+ return blocks
94
+ .filter(b => b.type === 'text')
95
+ .map(b => b.text)
96
+ .join('')
97
+ .trim();
98
+ }
99
+
100
+ // ─── High-level helpers ─────────────────────────────────
101
+
102
+ /**
103
+ * Suggest a concise commit message from a staged diff / summary.
104
+ * @param {String} diffSummary
105
+ * @returns {Promise<String>}
106
+ */
107
+ async function suggestCommitMessage(diffSummary) {
108
+ const system =
109
+ 'You write clear, conventional git commit messages. Reply with ONLY the commit ' +
110
+ 'message: a concise imperative subject line (<=72 chars), optionally followed by ' +
111
+ 'a blank line and short body. No quotes, no preamble, no markdown fences.';
112
+ const prompt = `Write a commit message for these staged changes:\n\n${diffSummary}`;
113
+ return complete({ system, prompt, maxTokens: 512 });
114
+ }
115
+
116
+ /**
117
+ * Explain a commit or diff in plain language.
118
+ * @param {String} content - diff or commit details
119
+ * @returns {Promise<String>}
120
+ */
121
+ async function explainChanges(content) {
122
+ const system =
123
+ 'You are a senior engineer explaining a code change to a teammate. Summarize what ' +
124
+ 'changed and why it matters in a few short bullet points. Be specific and concise.';
125
+ const prompt = `Explain these changes:\n\n${content}`;
126
+ return complete({ system, prompt, maxTokens: 1024 });
127
+ }
128
+
129
+ /**
130
+ * Propose a resolution for a single merge-conflict hunk.
131
+ * @param {Object} hunk - { base?, ours, theirs, fileName? }
132
+ * @returns {Promise<String>} the suggested merged text (no conflict markers)
133
+ */
134
+ async function resolveConflictHunk({ base, ours, theirs, fileName }) {
135
+ const system =
136
+ 'You resolve git merge conflicts. Combine the intent of BOTH sides into a single ' +
137
+ 'correct version. Reply with ONLY the resolved file section — no conflict markers, ' +
138
+ 'no explanation, no markdown fences.';
139
+ const prompt =
140
+ `File: ${fileName || 'unknown'}\n\n` +
141
+ `<<<<<<< BASE (common ancestor)\n${base || '(none)'}\n` +
142
+ `======= OURS\n${ours}\n` +
143
+ `======= THEIRS\n${theirs}\n>>>>>>>\n\n` +
144
+ 'Return the merged result for this section.';
145
+ return complete({ system, prompt, maxTokens: 2048 });
146
+ }
147
+
148
+ module.exports = {
149
+ isEnabled,
150
+ getModel,
151
+ disabledHint,
152
+ complete,
153
+ suggestCommitMessage,
154
+ explainChanges,
155
+ resolveConflictHunk
156
+ };
@@ -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 buildLineOperations(oldLines, newLines) {
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,