@aloth/olcli 0.9.0 → 0.10.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 +38 -0
- package/dist/cli.js +193 -95
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +12 -13
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/diff.d.ts +85 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +168 -0
- package/dist/diff.js.map +1 -0
- package/dist/ignore.js.map +1 -1
- package/dist/mcp.js +96 -1
- package/dist/mcp.js.map +1 -1
- package/dist/paths.d.ts +12 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +21 -0
- package/dist/paths.js.map +1 -1
- package/dist/remote-helper.js +1 -1
- package/dist/remote-helper.js.map +1 -1
- package/dist/scan.d.ts +42 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +57 -0
- package/dist/scan.js.map +1 -0
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ Work with Overleaf projects directly from your command line. Edit locally with y
|
|
|
20
20
|
- ⬇️ **Pull** project files to local directory for offline editing
|
|
21
21
|
- ⬆️ **Push** local changes back to Overleaf
|
|
22
22
|
- 🔄 **Sync** bidirectionally with smart conflict detection
|
|
23
|
+
- 🔍 **Diff** local files against the live remote before pushing
|
|
23
24
|
- 🔀 **Git remote** — use Overleaf as a native git remote ([docs](docs/GIT-REMOTE.md))
|
|
24
25
|
- ✌️ **Two-way deletions** — files removed locally are deleted on Overleaf on next sync
|
|
25
26
|
- 🗑️ **Delete** and ✏️ **rename** remote files by path
|
|
@@ -132,6 +133,7 @@ All commands auto-detect the project when run from a synced directory (contains
|
|
|
132
133
|
| `olcli pull [project] [dir]` | Download project files to local directory |
|
|
133
134
|
| `olcli push [dir]` | Upload local changes to Overleaf (`--delete` also removes files deleted locally) |
|
|
134
135
|
| `olcli sync [dir]` | Bidirectional sync (pull + push) |
|
|
136
|
+
| `olcli diff [project] [dir]` | Show content-level changes between local files and the remote |
|
|
135
137
|
| `olcli upload <file> [project]` | Upload a single file (`--to <path>` sets the remote destination) |
|
|
136
138
|
| `olcli download <file> [project]` | Download a single file |
|
|
137
139
|
| `olcli delete <file> [project]` | Delete a remote file or folder (alias: `rm`) |
|
|
@@ -150,8 +152,11 @@ All commands auto-detect the project when run from a synced directory (contains
|
|
|
150
152
|
| `olcli comments delete <id>` | Delete a comment thread |
|
|
151
153
|
| `olcli ignored [dir]` | List ignore patterns in effect |
|
|
152
154
|
| `olcli config set-url <url>` | Set self-hosted base URL |
|
|
155
|
+
| `olcli config get-url` | Show the configured base URL |
|
|
153
156
|
| `olcli config set-cookie-name <name>` | Set session cookie name |
|
|
157
|
+
| `olcli config get-cookie-name` | Show the configured session cookie name |
|
|
154
158
|
| `olcli config set-timeout <ms>` | Set default HTTP timeout |
|
|
159
|
+
| `olcli config get-timeout` | Show the configured HTTP timeout |
|
|
155
160
|
| `olcli check` | Show config paths and credential sources |
|
|
156
161
|
|
|
157
162
|
### Compile Options
|
|
@@ -192,6 +197,39 @@ Useful in multi-doc projects: each `-r` run compiles the file as if it were the
|
|
|
192
197
|
- **Propagates local deletions** — use `--no-delete` to opt out
|
|
193
198
|
- Use `--dry-run` to preview without applying
|
|
194
199
|
|
|
200
|
+
### Diff
|
|
201
|
+
|
|
202
|
+
`olcli diff` compares the bytes of your local files against the project's
|
|
203
|
+
current contents and prints a unified diff.
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
olcli diff # every changed file, as patches
|
|
207
|
+
olcli diff --name-only # just the changed paths
|
|
208
|
+
olcli diff --file main.tex # one file
|
|
209
|
+
olcli diff -U 8 # wider context
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**The remote side is fetched fresh on every run.** The diff describes the
|
|
213
|
+
project as it is at that moment — which is what a subsequent `push` would
|
|
214
|
+
overwrite — not a comparison against your last `pull`. `.olcli.json` records
|
|
215
|
+
remote *paths*, never remote *contents*, so there is no stored snapshot to
|
|
216
|
+
compare against; and the whole project arrives in a single request, the same
|
|
217
|
+
one `pull` makes, so fetching fresh costs one round trip rather than one per
|
|
218
|
+
file. A collaborator editing between `diff` and `push` can still change the
|
|
219
|
+
outcome, which is why the fetch time is printed.
|
|
220
|
+
|
|
221
|
+
In the output, `a/` is the remote and `b/` is local: a `+` line is content
|
|
222
|
+
`push` would upload, a `-` line is content it would overwrite. Files that
|
|
223
|
+
differ only in bytes that are not text (PDFs, images) are reported as
|
|
224
|
+
`Binary files ... differ`. Both sides pass through the same ignore layers, so
|
|
225
|
+
build artifacts sitting on Overleaf are not reported as locally deleted.
|
|
226
|
+
|
|
227
|
+
`diff --name-only` and `push --dry-run` answer different questions and will
|
|
228
|
+
disagree. `push --dry-run` lists files whose **modification time** is newer
|
|
229
|
+
than the last pull, because that is what `push` uploads; `diff` lists files
|
|
230
|
+
whose **contents** actually differ. A file you touched without editing appears
|
|
231
|
+
in the first and not the second.
|
|
232
|
+
|
|
195
233
|
#### How deletion propagation works
|
|
196
234
|
|
|
197
235
|
`olcli` records a manifest of remote files in `.olcli.json`. On next sync:
|
package/dist/cli.js
CHANGED
|
@@ -12,9 +12,11 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
|
12
12
|
import { join, dirname, basename } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { OverleafClient } from './client.js';
|
|
15
|
-
import { resolveRemotePath } from './paths.js';
|
|
15
|
+
import { resolveRemotePath, resolveWithin, normalizeRemotePath } from './paths.js';
|
|
16
16
|
import { planProjectRenames } from './rename-plan.js';
|
|
17
|
-
import {
|
|
17
|
+
import { scanLocalFiles } from './scan.js';
|
|
18
|
+
import { compareTrees, filterRemoteTree, renderFileDiff, statusLetter } from './diff.js';
|
|
19
|
+
import { loadIgnore } from './ignore.js';
|
|
18
20
|
// Read version from package.json
|
|
19
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
22
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
@@ -87,7 +89,7 @@ async function resolveProject(client, projectArg, dir = '.') {
|
|
|
87
89
|
return { id: projectArg, name: projectArg };
|
|
88
90
|
}
|
|
89
91
|
// Otherwise, look up by name
|
|
90
|
-
|
|
92
|
+
const proj = await client.getProject(projectArg);
|
|
91
93
|
if (!proj) {
|
|
92
94
|
throw new Error(`Project not found: ${projectArg}`);
|
|
93
95
|
}
|
|
@@ -276,21 +278,6 @@ program
|
|
|
276
278
|
process.exit(1);
|
|
277
279
|
}
|
|
278
280
|
});
|
|
279
|
-
function printFolder(folder, indent) {
|
|
280
|
-
// Print subfolders
|
|
281
|
-
for (const f of folder.folders || []) {
|
|
282
|
-
console.log(`${indent}📁 ${chalk.blue(f.name)}/`);
|
|
283
|
-
printFolder(f, indent + ' ');
|
|
284
|
-
}
|
|
285
|
-
// Print docs
|
|
286
|
-
for (const d of folder.docs || []) {
|
|
287
|
-
console.log(`${indent}📄 ${d.name}`);
|
|
288
|
-
}
|
|
289
|
-
// Print files
|
|
290
|
-
for (const f of folder.fileRefs || []) {
|
|
291
|
-
console.log(`${indent}📎 ${f.name}`);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
281
|
const commentsCmd = program
|
|
295
282
|
.command('comments')
|
|
296
283
|
.description('View and manage Overleaf comments');
|
|
@@ -934,9 +921,15 @@ program
|
|
|
934
921
|
let fileCount = 0;
|
|
935
922
|
let skippedCount = 0;
|
|
936
923
|
const skippedFiles = [];
|
|
924
|
+
const unsafeEntries = [];
|
|
937
925
|
for (const entry of entries) {
|
|
938
926
|
if (!entry.isDirectory) {
|
|
939
|
-
const filePath =
|
|
927
|
+
const filePath = resolveWithin(targetDir, entry.entryName);
|
|
928
|
+
if (!filePath) {
|
|
929
|
+
// Entry would escape the target directory (zip-slip) - never extract
|
|
930
|
+
unsafeEntries.push(entry.entryName);
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
940
933
|
const fileDir = dirname(filePath);
|
|
941
934
|
// Check if local file exists and is newer than last pull
|
|
942
935
|
if (!options.force && existsSync(filePath) && lastPull) {
|
|
@@ -949,7 +942,7 @@ program
|
|
|
949
942
|
continue;
|
|
950
943
|
}
|
|
951
944
|
}
|
|
952
|
-
catch
|
|
945
|
+
catch {
|
|
953
946
|
// File doesn't exist or can't stat, proceed with download
|
|
954
947
|
}
|
|
955
948
|
}
|
|
@@ -960,11 +953,18 @@ program
|
|
|
960
953
|
fileCount++;
|
|
961
954
|
}
|
|
962
955
|
}
|
|
956
|
+
if (unsafeEntries.length > 0) {
|
|
957
|
+
console.log(chalk.yellow(` Skipped ${unsafeEntries.length} unsafe archive entr${unsafeEntries.length === 1 ? 'y' : 'ies'} (path escapes target directory):`));
|
|
958
|
+
for (const name of unsafeEntries.slice(0, 5)) {
|
|
959
|
+
console.log(chalk.dim(` ${name}`));
|
|
960
|
+
}
|
|
961
|
+
}
|
|
963
962
|
// Save project metadata (with manifest of remote files for sync deletion tracking)
|
|
964
963
|
const remoteManifest = [];
|
|
965
964
|
for (const e of entries) {
|
|
966
|
-
if (!e.isDirectory)
|
|
965
|
+
if (!e.isDirectory && resolveWithin(targetDir, e.entryName)) {
|
|
967
966
|
remoteManifest.push(e.entryName);
|
|
967
|
+
}
|
|
968
968
|
}
|
|
969
969
|
writeFileSync(join(targetDir, '.olcli.json'), JSON.stringify({
|
|
970
970
|
projectId,
|
|
@@ -1062,51 +1062,19 @@ program
|
|
|
1062
1062
|
disableAll: options.ignore === false,
|
|
1063
1063
|
});
|
|
1064
1064
|
// Get list of files to upload
|
|
1065
|
-
const {
|
|
1065
|
+
const { files: localFileList, ignored: filesIgnored } = scanLocalFiles(targetDir, ignoreCtx);
|
|
1066
1066
|
const filesToUpload = [];
|
|
1067
|
-
const filesIgnored = [];
|
|
1068
1067
|
// Every local file that survives ignore filtering, regardless of mtime.
|
|
1069
1068
|
// filesToUpload is mtime-filtered and therefore useless as a deletion
|
|
1070
1069
|
// baseline: an unchanged file would look "absent" and get deleted.
|
|
1071
1070
|
const allLocalPaths = new Set();
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
//
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
// Skip hidden files and .olcli.json (always — predates ignore subsystem)
|
|
1078
|
-
if (entry.name.startsWith('.'))
|
|
1079
|
-
continue;
|
|
1080
|
-
const fullPath = join(currentDir, entry.name);
|
|
1081
|
-
const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name;
|
|
1082
|
-
if (entry.isDirectory()) {
|
|
1083
|
-
// Test directory ignore (gitignore semantics: trailing slash matches dir)
|
|
1084
|
-
if (shouldIgnore(`${relativePath}/`, ignoreCtx)) {
|
|
1085
|
-
filesIgnored.push(`${relativePath}/`);
|
|
1086
|
-
continue;
|
|
1087
|
-
}
|
|
1088
|
-
scanDir(fullPath, relativePath);
|
|
1089
|
-
}
|
|
1090
|
-
else {
|
|
1091
|
-
if (shouldIgnore(relativePath, ignoreCtx, texSiblings)) {
|
|
1092
|
-
filesIgnored.push(relativePath);
|
|
1093
|
-
continue;
|
|
1094
|
-
}
|
|
1095
|
-
allLocalPaths.add(relativePath);
|
|
1096
|
-
// Check if file is newer than last pull (unless --all)
|
|
1097
|
-
if (options.all || !lastPull) {
|
|
1098
|
-
filesToUpload.push({ path: fullPath, relativePath });
|
|
1099
|
-
}
|
|
1100
|
-
else {
|
|
1101
|
-
const stats = statSync(fullPath);
|
|
1102
|
-
if (stats.mtime > lastPull) {
|
|
1103
|
-
filesToUpload.push({ path: fullPath, relativePath });
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1071
|
+
for (const file of localFileList) {
|
|
1072
|
+
allLocalPaths.add(file.relativePath);
|
|
1073
|
+
// Check if file is newer than last pull (unless --all)
|
|
1074
|
+
if (options.all || !lastPull || file.mtime > lastPull) {
|
|
1075
|
+
filesToUpload.push({ path: file.path, relativePath: file.relativePath });
|
|
1107
1076
|
}
|
|
1108
1077
|
}
|
|
1109
|
-
scanDir(targetDir);
|
|
1110
1078
|
if (options.showIgnored && filesIgnored.length > 0) {
|
|
1111
1079
|
spinner.stop();
|
|
1112
1080
|
console.log(chalk.bold(chalk.dim(`Ignored ${filesIgnored.length} file(s)/dir(s):`)));
|
|
@@ -1156,6 +1124,12 @@ program
|
|
|
1156
1124
|
if (noBaseline) {
|
|
1157
1125
|
console.log(chalk.dim(' --delete skipped: no manifest yet (first push from this directory)'));
|
|
1158
1126
|
}
|
|
1127
|
+
// This list is mtime-based: a file touched but not edited is in it, and
|
|
1128
|
+
// a file whose bytes already match the remote is too. `olcli diff`
|
|
1129
|
+
// compares content instead.
|
|
1130
|
+
if (filesToUpload.length > 0) {
|
|
1131
|
+
console.log(chalk.dim(' selected by modification time — run `olcli diff` to see content changes'));
|
|
1132
|
+
}
|
|
1159
1133
|
return;
|
|
1160
1134
|
}
|
|
1161
1135
|
// If --probe-folder is set, or if we don't have a cached rootFolderId, try probing
|
|
@@ -1321,41 +1295,17 @@ program
|
|
|
1321
1295
|
});
|
|
1322
1296
|
// Track local modifications
|
|
1323
1297
|
const localFiles = new Map();
|
|
1324
|
-
|
|
1325
|
-
const { readdirSync, statSync } = await import('node:fs');
|
|
1326
|
-
function scanLocalFiles(currentDir, relativeBase = '') {
|
|
1327
|
-
if (!existsSync(currentDir))
|
|
1328
|
-
return;
|
|
1329
|
-
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
1330
|
-
const texSiblings = buildTexSiblingSet(entries.filter((e) => !e.isDirectory()).map((e) => e.name));
|
|
1331
|
-
for (const entry of entries) {
|
|
1332
|
-
if (entry.name.startsWith('.'))
|
|
1333
|
-
continue;
|
|
1334
|
-
const fullPath = join(currentDir, entry.name);
|
|
1335
|
-
const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name;
|
|
1336
|
-
if (entry.isDirectory()) {
|
|
1337
|
-
if (shouldIgnore(`${relativePath}/`, ignoreCtx)) {
|
|
1338
|
-
filesIgnored.push(`${relativePath}/`);
|
|
1339
|
-
continue;
|
|
1340
|
-
}
|
|
1341
|
-
scanLocalFiles(fullPath, relativePath);
|
|
1342
|
-
}
|
|
1343
|
-
else {
|
|
1344
|
-
if (shouldIgnore(relativePath, ignoreCtx, texSiblings)) {
|
|
1345
|
-
filesIgnored.push(relativePath);
|
|
1346
|
-
continue;
|
|
1347
|
-
}
|
|
1348
|
-
const stats = statSync(fullPath);
|
|
1349
|
-
localFiles.set(relativePath, {
|
|
1350
|
-
mtime: stats.mtime,
|
|
1351
|
-
content: readFileSync(fullPath)
|
|
1352
|
-
});
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1298
|
+
let filesIgnored = [];
|
|
1356
1299
|
// Read local files before overwriting
|
|
1357
|
-
if (existsSync(metaPath)) {
|
|
1358
|
-
scanLocalFiles(targetDir);
|
|
1300
|
+
if (existsSync(targetDir) && existsSync(metaPath)) {
|
|
1301
|
+
const scan = scanLocalFiles(targetDir, ignoreCtx);
|
|
1302
|
+
filesIgnored = scan.ignored;
|
|
1303
|
+
for (const file of scan.files) {
|
|
1304
|
+
localFiles.set(file.relativePath, {
|
|
1305
|
+
mtime: file.mtime,
|
|
1306
|
+
content: readFileSync(file.path)
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1359
1309
|
}
|
|
1360
1310
|
if (options.showIgnored && filesIgnored.length > 0) {
|
|
1361
1311
|
spinner.stop();
|
|
@@ -1365,13 +1315,23 @@ program
|
|
|
1365
1315
|
}
|
|
1366
1316
|
spinner.start();
|
|
1367
1317
|
}
|
|
1368
|
-
// Extract remote files
|
|
1318
|
+
// Extract remote files (skipping entries that would escape the target
|
|
1319
|
+
// directory - zip-slip protection)
|
|
1369
1320
|
const remoteFiles = new Map();
|
|
1321
|
+
const unsafeRemoteEntries = [];
|
|
1370
1322
|
for (const entry of zip.getEntries()) {
|
|
1371
1323
|
if (!entry.isDirectory) {
|
|
1324
|
+
if (!resolveWithin(targetDir, entry.entryName)) {
|
|
1325
|
+
unsafeRemoteEntries.push(entry.entryName);
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1372
1328
|
remoteFiles.set(entry.entryName, entry.getData());
|
|
1373
1329
|
}
|
|
1374
1330
|
}
|
|
1331
|
+
if (unsafeRemoteEntries.length > 0) {
|
|
1332
|
+
spinner.warn(`Skipped ${unsafeRemoteEntries.length} unsafe archive entr${unsafeRemoteEntries.length === 1 ? 'y' : 'ies'} (path escapes target directory)`);
|
|
1333
|
+
spinner.start();
|
|
1334
|
+
}
|
|
1375
1335
|
// Merge: local changes take precedence for files modified after last pull
|
|
1376
1336
|
let lastPull;
|
|
1377
1337
|
let previousManifest = [];
|
|
@@ -1427,7 +1387,9 @@ program
|
|
|
1427
1387
|
spinner.text = 'Comparing files...';
|
|
1428
1388
|
// Write remote files, but preserve local modifications
|
|
1429
1389
|
for (const [path, remoteContent] of remoteFiles) {
|
|
1430
|
-
const filePath =
|
|
1390
|
+
const filePath = resolveWithin(targetDir, path);
|
|
1391
|
+
if (!filePath)
|
|
1392
|
+
continue; // already filtered above; defense in depth
|
|
1431
1393
|
const fileDir = dirname(filePath);
|
|
1432
1394
|
if (!existsSync(fileDir)) {
|
|
1433
1395
|
mkdirSync(fileDir, { recursive: true });
|
|
@@ -1528,6 +1490,142 @@ program
|
|
|
1528
1490
|
process.exit(1);
|
|
1529
1491
|
}
|
|
1530
1492
|
});
|
|
1493
|
+
program
|
|
1494
|
+
.command('diff [project] [dir]')
|
|
1495
|
+
.description('Show content-level differences between local files and the remote project')
|
|
1496
|
+
.option('--name-only', 'List changed paths instead of printing patches')
|
|
1497
|
+
.option('--file <path>', 'Diff a single file')
|
|
1498
|
+
.option('-U, --unified <n>', 'Lines of context around each hunk (default: 3)', parseInt)
|
|
1499
|
+
.option('--no-default-ignore', 'Disable built-in LaTeX artifact ignore list (only .olignore applies)')
|
|
1500
|
+
.option('--no-ignore', 'Disable all ignore filtering')
|
|
1501
|
+
.option('--cookie <session>', 'Session cookie override')
|
|
1502
|
+
.addHelpText('after', `
|
|
1503
|
+
The remote side is fetched fresh on every run, so the diff describes the
|
|
1504
|
+
project as it is right now - which is what a subsequent push would overwrite.
|
|
1505
|
+
It is not a comparison against the last pull. A collaborator editing between
|
|
1506
|
+
diff and push can still change the outcome; the fetch time is printed for that
|
|
1507
|
+
reason.`)
|
|
1508
|
+
.action(async (project, dir, options) => {
|
|
1509
|
+
const targetDir = dir || '.';
|
|
1510
|
+
if (!existsSync(targetDir)) {
|
|
1511
|
+
console.error(chalk.red(`Directory not found: ${targetDir}`));
|
|
1512
|
+
process.exit(1);
|
|
1513
|
+
}
|
|
1514
|
+
const spinner = ora('Connecting...').start();
|
|
1515
|
+
try {
|
|
1516
|
+
const client = await getClient(options.cookie);
|
|
1517
|
+
let resolved;
|
|
1518
|
+
try {
|
|
1519
|
+
resolved = await resolveProject(client, project, targetDir);
|
|
1520
|
+
}
|
|
1521
|
+
catch (error) {
|
|
1522
|
+
spinner.fail(error.message);
|
|
1523
|
+
console.error('Either run from a directory with .olcli.json or pass a project name/ID');
|
|
1524
|
+
process.exit(1);
|
|
1525
|
+
}
|
|
1526
|
+
const { id: projectId, name: projectName } = resolved;
|
|
1527
|
+
// The whole project arrives as one zip in a single request - the same
|
|
1528
|
+
// call pull and sync already make. Fetching per-file would mean one
|
|
1529
|
+
// request per file and could not tell us which files differ without
|
|
1530
|
+
// downloading them anyway.
|
|
1531
|
+
spinner.text = 'Fetching remote project...';
|
|
1532
|
+
const zipBuffer = await client.downloadProject(projectId);
|
|
1533
|
+
const fetchedAt = new Date();
|
|
1534
|
+
const AdmZip = (await import('adm-zip')).default;
|
|
1535
|
+
const zip = new AdmZip(zipBuffer);
|
|
1536
|
+
const ignoreCtx = loadIgnore(targetDir, {
|
|
1537
|
+
noDefaults: options.defaultIgnore === false,
|
|
1538
|
+
disableAll: options.ignore === false,
|
|
1539
|
+
});
|
|
1540
|
+
// Both sides go through the same filters; see filterRemoteTree.
|
|
1541
|
+
const remoteFiles = filterRemoteTree(zip.getEntries()
|
|
1542
|
+
.filter((e) => !e.isDirectory)
|
|
1543
|
+
.map((e) => ({ path: e.entryName, data: e.getData() })), ignoreCtx, (path) => resolveWithin(targetDir, path) !== null);
|
|
1544
|
+
const scan = scanLocalFiles(targetDir, ignoreCtx);
|
|
1545
|
+
const localFiles = new Map();
|
|
1546
|
+
for (const file of scan.files) {
|
|
1547
|
+
localFiles.set(file.relativePath, readFileSync(file.path));
|
|
1548
|
+
}
|
|
1549
|
+
let entries = compareTrees(localFiles, remoteFiles).filter((e) => e.status !== 'unchanged');
|
|
1550
|
+
if (options.file) {
|
|
1551
|
+
const wanted = normalizeRemotePath(options.file);
|
|
1552
|
+
entries = entries.filter((e) => e.path === wanted);
|
|
1553
|
+
if (entries.length === 0) {
|
|
1554
|
+
spinner.info(`No differences in ${wanted}`);
|
|
1555
|
+
if (!localFiles.has(wanted) && !remoteFiles.has(wanted)) {
|
|
1556
|
+
console.log(chalk.dim(' (file is on neither side, or is filtered by an ignore rule)'));
|
|
1557
|
+
}
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
spinner.stop();
|
|
1562
|
+
if (entries.length === 0) {
|
|
1563
|
+
console.log(chalk.green(`No differences — local files match "${projectName}"`));
|
|
1564
|
+
console.log(chalk.dim(` remote fetched ${fetchedAt.toISOString()}`));
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
if (options.nameOnly) {
|
|
1568
|
+
for (const e of entries) {
|
|
1569
|
+
const colour = e.status === 'added' ? chalk.green
|
|
1570
|
+
: e.status === 'deleted' ? chalk.red
|
|
1571
|
+
: chalk.yellow;
|
|
1572
|
+
console.log(`${colour(statusLetter(e.status))} ${e.path}`);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
else {
|
|
1576
|
+
for (const e of entries) {
|
|
1577
|
+
const patch = renderFileDiff(e, localFiles.get(e.path), remoteFiles.get(e.path), { context: options.unified });
|
|
1578
|
+
patch.replace(/\n$/, '').split('\n').forEach((line, index) => {
|
|
1579
|
+
console.log(colourizeDiffLine(line, index, e.binary));
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
console.log();
|
|
1584
|
+
// With --file the summary would count only the one file asked for, which
|
|
1585
|
+
// reads as "this is all that differs". Report totals only for a full run.
|
|
1586
|
+
if (!options.file) {
|
|
1587
|
+
const counts = {
|
|
1588
|
+
added: entries.filter((e) => e.status === 'added').length,
|
|
1589
|
+
modified: entries.filter((e) => e.status === 'modified').length,
|
|
1590
|
+
deleted: entries.filter((e) => e.status === 'deleted').length,
|
|
1591
|
+
};
|
|
1592
|
+
console.log(chalk.bold(`${entries.length} file(s) differ from "${projectName}": ` +
|
|
1593
|
+
`${counts.added} added, ${counts.modified} modified, ${counts.deleted} remote-only`));
|
|
1594
|
+
if (counts.deleted > 0) {
|
|
1595
|
+
console.log(chalk.dim(' remote-only files are left alone by push; use push --delete to remove them'));
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
console.log(chalk.dim(` a/ = remote as of ${fetchedAt.toISOString()}, b/ = local`));
|
|
1599
|
+
setLastProject(projectId);
|
|
1600
|
+
}
|
|
1601
|
+
catch (error) {
|
|
1602
|
+
spinner.fail(`Failed: ${error.message}`);
|
|
1603
|
+
process.exit(1);
|
|
1604
|
+
}
|
|
1605
|
+
});
|
|
1606
|
+
/**
|
|
1607
|
+
* Colourize one line of a rendered patch. chalk already no-ops when stdout is
|
|
1608
|
+
* not a TTY, so this needs no flag of its own.
|
|
1609
|
+
*
|
|
1610
|
+
* The file headers are identified by position, not by prefix: a removed line
|
|
1611
|
+
* whose own content starts with `--` renders as `--- something` and would
|
|
1612
|
+
* otherwise be mistaken for the `---` header and shown as unchanged.
|
|
1613
|
+
*/
|
|
1614
|
+
function colourizeDiffLine(line, index, binary) {
|
|
1615
|
+
if (index === 0)
|
|
1616
|
+
return chalk.bold(line); // our `diff --olcli` header
|
|
1617
|
+
if (binary)
|
|
1618
|
+
return chalk.magenta(line); // the single summary line
|
|
1619
|
+
if (index <= 2)
|
|
1620
|
+
return chalk.bold(line); // `---` / `+++`
|
|
1621
|
+
if (line.startsWith('@@'))
|
|
1622
|
+
return chalk.cyan(line);
|
|
1623
|
+
if (line.startsWith('+'))
|
|
1624
|
+
return chalk.green(line);
|
|
1625
|
+
if (line.startsWith('-'))
|
|
1626
|
+
return chalk.red(line);
|
|
1627
|
+
return line;
|
|
1628
|
+
}
|
|
1531
1629
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1532
1630
|
// HELP
|
|
1533
1631
|
// ─────────────────────────────────────────────────────────────────────────────
|