shilo-cli 0.2.0 → 0.3.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/README.md +93 -2
- package/bin/shilo.js +41 -2
- package/package.json +6 -2
- package/src/ShiloClient.js +22 -0
- package/src/commands/diff.js +158 -0
- package/src/commands/init.js +6 -1
- package/src/commands/log.js +155 -0
- package/src/commands/pull.js +239 -0
- package/src/commands/push.js +82 -8
- package/src/commands/status.js +187 -0
- package/src/context.js +82 -0
- package/src/remote.js +29 -3
- package/src/sync.js +344 -0
- package/src/textdiff.js +196 -0
package/src/remote.js
CHANGED
|
@@ -10,9 +10,14 @@
|
|
|
10
10
|
* Estructura en disco:
|
|
11
11
|
* {
|
|
12
12
|
* "projectId": "<uuid>",
|
|
13
|
-
* "baseURL": "https://shilo-six.vercel.app"
|
|
13
|
+
* "baseURL": "https://shilo-six.vercel.app",
|
|
14
|
+
* "lastSync": "2026-07-27T12:00:00.000Z"
|
|
14
15
|
* }
|
|
15
16
|
*
|
|
17
|
+
* `lastSync` es la marca de la última vez que esta carpeta quedó alineada con
|
|
18
|
+
* el remoto (fin de un push o pull exitoso). `status` la usa para distinguir
|
|
19
|
+
* "el remoto cambió desde que sincronicé" de "esto lo subí yo".
|
|
20
|
+
*
|
|
16
21
|
* NO contiene credenciales (esas están en ~/.shilo/config.json, cifradas), así
|
|
17
22
|
* que es seguro commitearlo al repo si el usuario quiere.
|
|
18
23
|
*/
|
|
@@ -42,18 +47,39 @@ function read(dir) {
|
|
|
42
47
|
return {
|
|
43
48
|
projectId: raw.projectId || null,
|
|
44
49
|
baseURL: raw.baseURL || null,
|
|
50
|
+
lastSync: raw.lastSync || null,
|
|
45
51
|
};
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
/**
|
|
49
55
|
* Escribe `.shilo.json` en `dir`. Devuelve la ruta escrita.
|
|
50
56
|
*/
|
|
51
|
-
function write(dir, { projectId, baseURL }) {
|
|
57
|
+
function write(dir, { projectId, baseURL, lastSync }) {
|
|
52
58
|
const p = remotePath(dir);
|
|
53
59
|
const onDisk = { projectId };
|
|
54
60
|
if (baseURL) onDisk.baseURL = baseURL;
|
|
61
|
+
if (lastSync) onDisk.lastSync = lastSync;
|
|
55
62
|
fs.writeFileSync(p, JSON.stringify(onDisk, null, 2) + '\n', 'utf8');
|
|
56
63
|
return p;
|
|
57
64
|
}
|
|
58
65
|
|
|
59
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Marca la carpeta como sincronizada ahora mismo, conservando el resto del
|
|
68
|
+
* remote. No es fatal si falla (la carpeta puede ser de sólo lectura): devuelve
|
|
69
|
+
* `false` y el caller decide si avisar.
|
|
70
|
+
*/
|
|
71
|
+
function touchSync(dir, { projectId, baseURL } = {}) {
|
|
72
|
+
try {
|
|
73
|
+
const current = read(dir) || {};
|
|
74
|
+
write(dir, {
|
|
75
|
+
projectId: projectId || current.projectId,
|
|
76
|
+
baseURL: baseURL || current.baseURL,
|
|
77
|
+
lastSync: new Date().toISOString(),
|
|
78
|
+
});
|
|
79
|
+
return true;
|
|
80
|
+
} catch (_) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { REMOTE_FILENAME, remotePath, read, write, touchSync };
|
package/src/sync.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Piezas compartidas entre `pull`, `status`, `diff`, `log` y `push`:
|
|
5
|
+
* recorrido del árbol remoto, comparación local↔remoto y atribución de
|
|
6
|
+
* cambios a partir del historial de commits.
|
|
7
|
+
*
|
|
8
|
+
* Todo lo de acá es de sólo lectura: ninguna función escribe en disco ni en el
|
|
9
|
+
* servidor. La idea es que un `shilo status` pueda contestar "¿estoy
|
|
10
|
+
* desactualizado?" sin bajar contenido.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const config = require('./config');
|
|
16
|
+
const { walk } = require('./walk');
|
|
17
|
+
|
|
18
|
+
const CONCURRENCY = 8; // pool para el N+1 de listar carpetas (el backend no tiene árbol recursivo)
|
|
19
|
+
|
|
20
|
+
// Mime types que sabemos que vuelven como texto legible. El backend siempre
|
|
21
|
+
// devuelve el contenido vía `data.text()`, así que un binario (p.ej. image/png
|
|
22
|
+
// subido desde la web) volvería corrupto: esos se saltean.
|
|
23
|
+
const TEXT_MIME_EXTRA = new Set([
|
|
24
|
+
'application/json', 'application/xml', 'application/javascript',
|
|
25
|
+
'application/typescript', 'application/yaml', 'application/x-yaml',
|
|
26
|
+
'image/svg+xml',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function isTextMime(mime) {
|
|
30
|
+
if (!mime) return true; // el backend default para archivos es text/plain
|
|
31
|
+
const m = String(mime).toLowerCase().split(';')[0].trim();
|
|
32
|
+
return m.startsWith('text/') || TEXT_MIME_EXTRA.has(m);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Gestor de credenciales con refresh serializado.
|
|
37
|
+
*
|
|
38
|
+
* El recorrido remoto dispara muchos requests en paralelo (pool). Si dos toparan
|
|
39
|
+
* con el JWT casi vencido a la vez, cada uno intentaría refrescar con el mismo
|
|
40
|
+
* refresh_token —que Supabase invalida tras el primer uso— y la sesión se
|
|
41
|
+
* rompería. Este gestor centraliza el refresh en una sola promesa compartida y
|
|
42
|
+
* entrega credenciales ya válidas, de modo que el `ensureValidToken` interno de
|
|
43
|
+
* cada request sea no-op.
|
|
44
|
+
*/
|
|
45
|
+
function makeCredsManager(client, cfg) {
|
|
46
|
+
let creds = cfg.credentials;
|
|
47
|
+
let refreshing = null;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
get current() {
|
|
51
|
+
return creds;
|
|
52
|
+
},
|
|
53
|
+
async valid() {
|
|
54
|
+
if (creds && creds.kind === 'pat') return creds;
|
|
55
|
+
const msLeft = new Date(creds.expiresAt).getTime() - Date.now();
|
|
56
|
+
if (msLeft >= 5 * 60 * 1000) return creds;
|
|
57
|
+
|
|
58
|
+
if (!refreshing) {
|
|
59
|
+
refreshing = (async () => {
|
|
60
|
+
const fresh = await client.ensureValidToken(creds);
|
|
61
|
+
if (fresh !== creds) {
|
|
62
|
+
creds = fresh;
|
|
63
|
+
config.persistCredsIfChanged(cfg, creds);
|
|
64
|
+
}
|
|
65
|
+
return creds;
|
|
66
|
+
})().finally(() => {
|
|
67
|
+
refreshing = null;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return refreshing;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Corre `worker(item)` sobre `items` con concurrencia limitada. Preserva el
|
|
77
|
+
* orden de `items` en el array de resultados. Un worker que lanza deja el hueco
|
|
78
|
+
* como `{ __error: <msg>, item }` para que el caller decida.
|
|
79
|
+
*/
|
|
80
|
+
async function runPool(items, concurrency, worker) {
|
|
81
|
+
const results = new Array(items.length);
|
|
82
|
+
let next = 0;
|
|
83
|
+
|
|
84
|
+
async function drain() {
|
|
85
|
+
while (next < items.length) {
|
|
86
|
+
const i = next++;
|
|
87
|
+
try {
|
|
88
|
+
results[i] = await worker(items[i], i);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
results[i] = { __error: e.message, item: items[i] };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const runners = [];
|
|
96
|
+
for (let k = 0; k < Math.min(concurrency, items.length); k++) {
|
|
97
|
+
runners.push(drain());
|
|
98
|
+
}
|
|
99
|
+
await Promise.all(runners);
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Recorre el árbol remoto de un proyecto listando carpeta por carpeta (BFS con
|
|
105
|
+
* pool). El backend no expone un endpoint recursivo, así que es N+1: un request
|
|
106
|
+
* por carpeta, pero ninguno baja contenido. Devuelve `{ files, errors }`:
|
|
107
|
+
* files: [{ repoPath, name, id, size, mime_type, updated_at }]
|
|
108
|
+
* errors: [{ folderPath, reason }]
|
|
109
|
+
*/
|
|
110
|
+
async function walkRemote(client, projectId, mgr) {
|
|
111
|
+
const files = [];
|
|
112
|
+
const errors = [];
|
|
113
|
+
let frontier = ['']; // rutas de carpeta a listar; '' = raíz
|
|
114
|
+
|
|
115
|
+
while (frontier.length > 0) {
|
|
116
|
+
const listed = await runPool(frontier, CONCURRENCY, async (folderPath) => {
|
|
117
|
+
const creds = await mgr.valid();
|
|
118
|
+
const { files: entries } = await client.listFiles(projectId, creds, folderPath);
|
|
119
|
+
return { folderPath, entries };
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const nextFrontier = [];
|
|
123
|
+
for (const res of listed) {
|
|
124
|
+
if (res && res.__error) {
|
|
125
|
+
errors.push({ folderPath: res.item, reason: res.__error });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const { folderPath, entries } = res;
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
const repoPath = folderPath ? `${folderPath}/${entry.name}` : entry.name;
|
|
131
|
+
if (entry.type === 'folder') {
|
|
132
|
+
nextFrontier.push(repoPath);
|
|
133
|
+
} else {
|
|
134
|
+
files.push({
|
|
135
|
+
repoPath,
|
|
136
|
+
name: entry.name,
|
|
137
|
+
id: entry.id,
|
|
138
|
+
size: entry.size,
|
|
139
|
+
mime_type: entry.mime_type,
|
|
140
|
+
updated_at: entry.updated_at || entry.created_at || null,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
frontier = nextFrontier;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
files.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
|
|
149
|
+
return { files, errors };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Árbol local en el mismo formato que el remoto, aplicando los mismos ignores
|
|
154
|
+
* que usa `push` (para que la comparación sea manzanas con manzanas).
|
|
155
|
+
* Devuelve `{ files: [{ repoPath, absPath, size }], skipped, ignored }`.
|
|
156
|
+
*/
|
|
157
|
+
function walkLocal(dir) {
|
|
158
|
+
const { files, skipped, ignored } = walk(dir);
|
|
159
|
+
return {
|
|
160
|
+
files: files
|
|
161
|
+
.map((f) => ({ repoPath: f.relPath, absPath: f.absPath, size: f.size }))
|
|
162
|
+
.sort((a, b) => a.repoPath.localeCompare(b.repoPath)),
|
|
163
|
+
skipped,
|
|
164
|
+
ignored,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Compara los dos árboles por ruta y tamaño. NO baja contenido: por eso un
|
|
170
|
+
* archivo con el mismo tamaño en ambos lados cae en `same` aunque su contenido
|
|
171
|
+
* pudiera diferir (mismo largo, distinto texto). Esa es la concesión que hace
|
|
172
|
+
* que `status` sea barato; `diff` y `push` sí confirman byte a byte.
|
|
173
|
+
*
|
|
174
|
+
* Devuelve:
|
|
175
|
+
* {
|
|
176
|
+
* onlyLocal: [{ repoPath, local }], // sin subir
|
|
177
|
+
* onlyRemote: [{ repoPath, remote }], // sin bajar
|
|
178
|
+
* differing: [{ repoPath, local, remote }], // tamaños distintos
|
|
179
|
+
* same: [{ repoPath, local, remote }], // mismo tamaño
|
|
180
|
+
* }
|
|
181
|
+
*/
|
|
182
|
+
function compareTrees(localFiles, remoteFiles) {
|
|
183
|
+
const localByPath = new Map(localFiles.map((f) => [f.repoPath, f]));
|
|
184
|
+
const remoteByPath = new Map(remoteFiles.map((f) => [f.repoPath, f]));
|
|
185
|
+
|
|
186
|
+
const onlyLocal = [];
|
|
187
|
+
const onlyRemote = [];
|
|
188
|
+
const differing = [];
|
|
189
|
+
const same = [];
|
|
190
|
+
|
|
191
|
+
for (const [repoPath, local] of localByPath) {
|
|
192
|
+
const remote = remoteByPath.get(repoPath);
|
|
193
|
+
if (!remote) {
|
|
194
|
+
onlyLocal.push({ repoPath, local });
|
|
195
|
+
} else if (typeof remote.size === 'number' && remote.size !== local.size) {
|
|
196
|
+
differing.push({ repoPath, local, remote });
|
|
197
|
+
} else {
|
|
198
|
+
same.push({ repoPath, local, remote });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const [repoPath, remote] of remoteByPath) {
|
|
203
|
+
if (!localByPath.has(repoPath)) onlyRemote.push({ repoPath, remote });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const byPath = (a, b) => a.repoPath.localeCompare(b.repoPath);
|
|
207
|
+
onlyLocal.sort(byPath);
|
|
208
|
+
onlyRemote.sort(byPath);
|
|
209
|
+
differing.sort(byPath);
|
|
210
|
+
same.sort(byPath);
|
|
211
|
+
|
|
212
|
+
return { onlyLocal, onlyRemote, differing, same };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Baja las últimas páginas del historial con sus archivos y arma un índice
|
|
217
|
+
* `file_id -> { message, author, date, hash, action }` con el commit MÁS
|
|
218
|
+
* RECIENTE que tocó cada archivo. Sirve para responder "¿esto lo subí yo o me
|
|
219
|
+
* lo va a pisar otro?".
|
|
220
|
+
*
|
|
221
|
+
* Es best-effort: si el historial falla (permisos, red), devuelve un índice
|
|
222
|
+
* vacío en vez de romper el comando que lo pidió.
|
|
223
|
+
*/
|
|
224
|
+
async function buildAuthorIndex(client, projectId, mgr, { commits = 50 } = {}) {
|
|
225
|
+
const index = new Map();
|
|
226
|
+
let history = [];
|
|
227
|
+
try {
|
|
228
|
+
const creds = await mgr.valid();
|
|
229
|
+
const result = await client.listCommits(projectId, creds, {
|
|
230
|
+
limit: Math.min(commits, 100),
|
|
231
|
+
includeFiles: true,
|
|
232
|
+
});
|
|
233
|
+
history = result.commits;
|
|
234
|
+
} catch (_) {
|
|
235
|
+
return { index, commits: [], ok: false };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// El historial viene del más nuevo al más viejo: el primero que menciona un
|
|
239
|
+
// archivo es el último que lo tocó.
|
|
240
|
+
for (const commit of history) {
|
|
241
|
+
const author = describeAuthor(commit.users);
|
|
242
|
+
for (const cf of commit.files || []) {
|
|
243
|
+
const file = cf.files || {};
|
|
244
|
+
if (!file.id || index.has(file.id)) continue;
|
|
245
|
+
index.set(file.id, {
|
|
246
|
+
message: commit.message,
|
|
247
|
+
author,
|
|
248
|
+
authorId: commit.users ? commit.users.id : null,
|
|
249
|
+
date: commit.created_at,
|
|
250
|
+
hash: commit.hash,
|
|
251
|
+
action: cf.action,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { index, commits: history, ok: true };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function describeAuthor(user) {
|
|
260
|
+
if (!user) return 'desconocido';
|
|
261
|
+
return user.username || user.display_name || 'desconocido';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Fecha absoluta corta + relativa ("2026-07-27 14:03, hace 2 h").
|
|
266
|
+
*/
|
|
267
|
+
function formatDate(iso) {
|
|
268
|
+
if (!iso) return 'sin fecha';
|
|
269
|
+
const d = new Date(iso);
|
|
270
|
+
if (isNaN(d.getTime())) return String(iso);
|
|
271
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
272
|
+
const abs =
|
|
273
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
|
274
|
+
`${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
275
|
+
return `${abs} (${relativeDate(d)})`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function relativeDate(d) {
|
|
279
|
+
const secs = Math.round((Date.now() - d.getTime()) / 1000);
|
|
280
|
+
if (secs < 0) return 'en el futuro';
|
|
281
|
+
if (secs < 60) return 'recién';
|
|
282
|
+
const mins = Math.round(secs / 60);
|
|
283
|
+
if (mins < 60) return `hace ${mins} min`;
|
|
284
|
+
const hours = Math.round(mins / 60);
|
|
285
|
+
if (hours < 48) return `hace ${hours} h`;
|
|
286
|
+
const days = Math.round(hours / 24);
|
|
287
|
+
if (days < 60) return `hace ${days} d`;
|
|
288
|
+
return `hace ${Math.round(days / 30)} meses`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* ¿Un archivo remoto cambió después de la última sincronización local?
|
|
293
|
+
* Sin `lastSync` (carpeta atada a mano, o CLI viejo) devolvemos `null`:
|
|
294
|
+
* "no se sabe", que es distinto de "no cambió".
|
|
295
|
+
*/
|
|
296
|
+
function changedSinceSync(remoteFile, lastSync) {
|
|
297
|
+
if (!lastSync || !remoteFile || !remoteFile.updated_at) return null;
|
|
298
|
+
const t = new Date(remoteFile.updated_at).getTime();
|
|
299
|
+
const s = new Date(lastSync).getTime();
|
|
300
|
+
if (isNaN(t) || isNaN(s)) return null;
|
|
301
|
+
return t > s;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Lee un archivo local como texto. Devuelve `null` si no existe o no se puede
|
|
306
|
+
* leer como utf8.
|
|
307
|
+
*/
|
|
308
|
+
function readLocalText(absPath) {
|
|
309
|
+
try {
|
|
310
|
+
if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) return null;
|
|
311
|
+
return fs.readFileSync(absPath, 'utf8');
|
|
312
|
+
} catch (_) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Normaliza una ruta que el usuario escribe en la terminal (puede venir con
|
|
319
|
+
* `\` de Windows, con `./` adelante, o absoluta) a una ruta de repo POSIX
|
|
320
|
+
* relativa a `dir`. Devuelve `null` si queda fuera de la carpeta.
|
|
321
|
+
*/
|
|
322
|
+
function toRepoPath(dir, userPath) {
|
|
323
|
+
const abs = path.resolve(dir, userPath);
|
|
324
|
+
const rel = path.relative(dir, abs);
|
|
325
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
326
|
+
return rel.split(path.sep).join('/');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = {
|
|
330
|
+
CONCURRENCY,
|
|
331
|
+
isTextMime,
|
|
332
|
+
makeCredsManager,
|
|
333
|
+
runPool,
|
|
334
|
+
walkRemote,
|
|
335
|
+
walkLocal,
|
|
336
|
+
compareTrees,
|
|
337
|
+
buildAuthorIndex,
|
|
338
|
+
describeAuthor,
|
|
339
|
+
formatDate,
|
|
340
|
+
relativeDate,
|
|
341
|
+
changedSinceSync,
|
|
342
|
+
readLocalText,
|
|
343
|
+
toRepoPath,
|
|
344
|
+
};
|
package/src/textdiff.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Diff de texto por líneas, estilo unified, sin dependencias.
|
|
5
|
+
*
|
|
6
|
+
* Usado por `shilo diff` y por el `--dry-run` de push/pull para mostrar qué
|
|
7
|
+
* cambia realmente entre la versión local y la remota de un archivo.
|
|
8
|
+
*
|
|
9
|
+
* El algoritmo es LCS con programación dinámica: simple y exacto, pero O(n·m)
|
|
10
|
+
* en memoria. Para archivos grandes eso explota, así que por encima de
|
|
11
|
+
* `MAX_CELLS` se degrada a un resumen ("N líneas locales vs M remotas") en vez
|
|
12
|
+
* de intentar el diff completo.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const MAX_CELLS = 4_000_000; // ~4M celdas: unos 2000x2000 líneas
|
|
16
|
+
const DEFAULT_CONTEXT = 3;
|
|
17
|
+
|
|
18
|
+
function splitLines(text) {
|
|
19
|
+
if (text === '' || text == null) return [];
|
|
20
|
+
const normalized = String(text).replace(/\r\n/g, '\n');
|
|
21
|
+
const lines = normalized.split('\n');
|
|
22
|
+
// Un texto terminado en \n produce un último elemento vacío que no es línea.
|
|
23
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
24
|
+
return lines;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Matriz de longitudes LCS entre dos arrays de líneas.
|
|
29
|
+
*/
|
|
30
|
+
function lcsTable(a, b) {
|
|
31
|
+
const rows = a.length + 1;
|
|
32
|
+
const cols = b.length + 1;
|
|
33
|
+
const table = new Array(rows);
|
|
34
|
+
for (let i = 0; i < rows; i++) table[i] = new Uint32Array(cols);
|
|
35
|
+
|
|
36
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
37
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
38
|
+
table[i][j] =
|
|
39
|
+
a[i] === b[j]
|
|
40
|
+
? table[i + 1][j + 1] + 1
|
|
41
|
+
: Math.max(table[i + 1][j], table[i][j + 1]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return table;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Secuencia de operaciones para transformar `a` en `b`.
|
|
49
|
+
* Devuelve [{ type: 'equal'|'del'|'add', line }] o `null` si es demasiado grande.
|
|
50
|
+
*/
|
|
51
|
+
function diffLines(a, b) {
|
|
52
|
+
if ((a.length + 1) * (b.length + 1) > MAX_CELLS) return null;
|
|
53
|
+
|
|
54
|
+
const table = lcsTable(a, b);
|
|
55
|
+
const ops = [];
|
|
56
|
+
let i = 0;
|
|
57
|
+
let j = 0;
|
|
58
|
+
|
|
59
|
+
while (i < a.length && j < b.length) {
|
|
60
|
+
if (a[i] === b[j]) {
|
|
61
|
+
ops.push({ type: 'equal', line: a[i] });
|
|
62
|
+
i++;
|
|
63
|
+
j++;
|
|
64
|
+
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
65
|
+
ops.push({ type: 'del', line: a[i] });
|
|
66
|
+
i++;
|
|
67
|
+
} else {
|
|
68
|
+
ops.push({ type: 'add', line: b[j] });
|
|
69
|
+
j++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
while (i < a.length) ops.push({ type: 'del', line: a[i++] });
|
|
73
|
+
while (j < b.length) ops.push({ type: 'add', line: b[j++] });
|
|
74
|
+
|
|
75
|
+
return ops;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Agrupa las operaciones en hunks con `context` líneas de contexto, al estilo
|
|
80
|
+
* unified. Devuelve [{ aStart, aCount, bStart, bCount, ops }] (1-indexado).
|
|
81
|
+
*/
|
|
82
|
+
function toHunks(ops, context = DEFAULT_CONTEXT) {
|
|
83
|
+
// Índice de línea (1-based) de cada op en cada lado.
|
|
84
|
+
const annotated = [];
|
|
85
|
+
let aLine = 1;
|
|
86
|
+
let bLine = 1;
|
|
87
|
+
for (const op of ops) {
|
|
88
|
+
annotated.push({ ...op, aLine, bLine });
|
|
89
|
+
if (op.type === 'equal') {
|
|
90
|
+
aLine++;
|
|
91
|
+
bLine++;
|
|
92
|
+
} else if (op.type === 'del') {
|
|
93
|
+
aLine++;
|
|
94
|
+
} else {
|
|
95
|
+
bLine++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const changed = annotated
|
|
100
|
+
.map((op, idx) => (op.type === 'equal' ? -1 : idx))
|
|
101
|
+
.filter((idx) => idx >= 0);
|
|
102
|
+
if (changed.length === 0) return [];
|
|
103
|
+
|
|
104
|
+
// Agrupar índices cambiados que estén a <= 2*context de distancia.
|
|
105
|
+
const groups = [];
|
|
106
|
+
let start = changed[0];
|
|
107
|
+
let end = changed[0];
|
|
108
|
+
for (const idx of changed.slice(1)) {
|
|
109
|
+
if (idx - end <= context * 2) {
|
|
110
|
+
end = idx;
|
|
111
|
+
} else {
|
|
112
|
+
groups.push([start, end]);
|
|
113
|
+
start = idx;
|
|
114
|
+
end = idx;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
groups.push([start, end]);
|
|
118
|
+
|
|
119
|
+
return groups.map(([gStart, gEnd]) => {
|
|
120
|
+
const from = Math.max(0, gStart - context);
|
|
121
|
+
const to = Math.min(annotated.length - 1, gEnd + context);
|
|
122
|
+
const slice = annotated.slice(from, to + 1);
|
|
123
|
+
|
|
124
|
+
let aCount = 0;
|
|
125
|
+
let bCount = 0;
|
|
126
|
+
for (const op of slice) {
|
|
127
|
+
if (op.type !== 'add') aCount++;
|
|
128
|
+
if (op.type !== 'del') bCount++;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
aStart: aCount ? slice[0].aLine : 0,
|
|
133
|
+
aCount,
|
|
134
|
+
bStart: bCount ? slice[0].bLine : 0,
|
|
135
|
+
bCount,
|
|
136
|
+
ops: slice,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Diff completo entre dos textos.
|
|
143
|
+
* Devuelve:
|
|
144
|
+
* { identical: true }
|
|
145
|
+
* { tooBig: true, aLines, bLines }
|
|
146
|
+
* { hunks, added, removed, aLines, bLines }
|
|
147
|
+
*/
|
|
148
|
+
function diffText(aText, bText, { context = DEFAULT_CONTEXT } = {}) {
|
|
149
|
+
const a = splitLines(aText);
|
|
150
|
+
const b = splitLines(bText);
|
|
151
|
+
|
|
152
|
+
if (aText === bText) return { identical: true, aLines: a.length, bLines: b.length };
|
|
153
|
+
|
|
154
|
+
const ops = diffLines(a, b);
|
|
155
|
+
if (!ops) return { tooBig: true, aLines: a.length, bLines: b.length };
|
|
156
|
+
|
|
157
|
+
const added = ops.filter((o) => o.type === 'add').length;
|
|
158
|
+
const removed = ops.filter((o) => o.type === 'del').length;
|
|
159
|
+
if (added === 0 && removed === 0) {
|
|
160
|
+
return { identical: true, aLines: a.length, bLines: b.length };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { hunks: toHunks(ops, context), added, removed, aLines: a.length, bLines: b.length };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Render de un resultado de `diffText` a líneas de texto listas para imprimir.
|
|
168
|
+
* `paint` permite inyectar colores (`ui.c`); por defecto sale en plano.
|
|
169
|
+
*/
|
|
170
|
+
function renderDiff(result, { paint = null } = {}) {
|
|
171
|
+
const color = paint || { green: (s) => s, red: (s) => s, cyan: (s) => s, dim: (s) => s };
|
|
172
|
+
if (result.identical) return [];
|
|
173
|
+
if (result.tooBig) {
|
|
174
|
+
return [
|
|
175
|
+
color.dim(
|
|
176
|
+
`(archivo demasiado grande para diff línea a línea: ` +
|
|
177
|
+
`${result.aLines} líneas de un lado, ${result.bLines} del otro)`
|
|
178
|
+
),
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const hunk of result.hunks) {
|
|
184
|
+
out.push(
|
|
185
|
+
color.cyan(`@@ -${hunk.aStart},${hunk.aCount} +${hunk.bStart},${hunk.bCount} @@`)
|
|
186
|
+
);
|
|
187
|
+
for (const op of hunk.ops) {
|
|
188
|
+
if (op.type === 'equal') out.push(color.dim(` ${op.line}`));
|
|
189
|
+
else if (op.type === 'del') out.push(color.red(`-${op.line}`));
|
|
190
|
+
else out.push(color.green(`+${op.line}`));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = { splitLines, diffLines, diffText, renderDiff, toHunks, MAX_CELLS };
|