Skip to content

Commit 86b078b

Browse files
committed
feat(diff): --exit-code so olcli diff can gate CI
Second follow-up left open by #48, alongside --latexdiff. Reports diff(1)'s statuses: 0 when nothing differs, 1 when something does, 2 when the run itself failed. The 1-vs-2 split is the whole feature. A pipeline that cannot separate "the project differs" from "the run failed" reads an expired session cookie as a content change, and a job that goes red for the wrong reason sends whoever reads the log looking for a diff that was never computed. All ten failure paths in the command move to 2 together, so no failure can be mistaken for a difference. Without the flag they stay 1, as in every other command, so existing scripts are unaffected. The status is set rather than exited on. process.exit discards whatever is still buffered on a non-TTY stdout, and `olcli diff --exit-code > patch.txt` is exactly a large patch going into a pipe: exiting outright truncated a 1.5 MB patch to the 128 KB pipe buffer here, losing 91% of it mid-hunk. The gate covers whatever was compared, so --file narrows it the way a git diff --exit-code pathspec does, and under --latexdiff it reports on the project rather than on the markup - a changed figure is a real difference even though a marked-up root document cannot show one. Refs #48
1 parent aec7af0 commit 86b078b

7 files changed

Lines changed: 218 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ All notable changes to this project will be documented in this file.
1717
- Placed next to the root document rather than at the project root, so relative `\includegraphics` and `\bibliography` paths resolve exactly as they do for the document it was built from
1818
- A compile failure writes the CLSI log next to the marked-up source instead of reporting only a status, and deletes a PDF left by an earlier run rather than leaving one that describes a different revision. The compile runs against the project, so a `.sty` or `.cls` that exists only locally is the usual cause and the message says so. A missing *figure* is not: Overleaf draws a placeholder box naming the file and still reports success
1919
- **`src/latexdiff.ts`** - root document detection, argument construction, output naming and failure interpretation are functions over data, unit-tested with no Overleaf account and no `latexdiff` binary. 21 tests, in the suite CI already runs
20+
- **`olcli diff --exit-code` makes the command a CI gate** ([#48](https://github.com/aloth/olcli/pull/48)) - the second follow-up left open when the core `diff` command shipped in 0.10.0. It reports `diff(1)`'s statuses: `0` when nothing differs, `1` when something does, `2` when the run itself failed
21+
- The `1` vs `2` split is the whole feature. A pipeline that cannot separate "the project differs" from "the run failed" reads an expired session cookie as a content change, and a job that goes red for the wrong reason sends whoever reads the log looking for a diff that was never computed. All ten failure paths in the command move to `2` together - bad flag combinations, a missing directory, an unresolvable project, a `latexdiff` that is not installed, a failed remote compile - so no failure can be mistaken for a difference
22+
- Failures stay `1` when the flag is absent, which is what every other command exits, so scripts that check `olcli diff` for success are unaffected
23+
- The status is *set* rather than exited on. `process.exit` discards whatever is still buffered on a non-TTY stdout, and `olcli diff --exit-code > patch.txt` is exactly a large patch going into a pipe: measured here, exiting outright truncated a 1.5 MB patch to the 128 KB pipe buffer and lost 91% of it mid-hunk. Returning lets node flush first
24+
- The gate covers whatever was compared, so `--file` narrows it to one file the way a `git diff --exit-code` pathspec does, and a `--file` matching nothing is `0` rather than an error. Under `--latexdiff` it reports on the project rather than on the markup - a changed figure is a real difference even though a marked-up root document cannot show one
25+
- Exit statuses and the "unchanged files are not differences" rule are pinned by unit tests in `test/diff.test.ts`; `test/e2e.sh` gains eight cases covering the clean tree, a difference, `--file` narrowing, the redirected-output case and both failure statuses
2026

2127
## [0.12.0] - 2026-09-06
2228

‎README.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ olcli diff # every changed file, as patches
218218
olcli diff --name-only # just the changed paths
219219
olcli diff --file main.tex # one file
220220
olcli diff -U 8 # wider context
221+
olcli diff --exit-code # exit 1 if anything differs, for CI
221222
```
222223

223224
**The remote side is fetched fresh on every run.** The diff describes the
@@ -281,6 +282,40 @@ written next to the marked-up source when that happens. A *figure* you added
281282
locally does not fail it; Overleaf draws a placeholder box naming the missing
282283
file and the rest of the PDF is fine.
283284

285+
#### Using `diff` as a CI gate
286+
287+
`--exit-code` turns the command into a check, with the statuses `diff(1)` uses:
288+
289+
| Status | Meaning |
290+
|--------|---------|
291+
| `0` | Nothing differs |
292+
| `1` | Something differs |
293+
| `2` | The run failed — bad flags, no session, project unreachable |
294+
295+
```yaml
296+
- name: Fail if the paper on Overleaf has drifted from the repo
297+
run: olcli diff --exit-code --name-only
298+
env:
299+
OVERLEAF_SESSION: ${{ secrets.OVERLEAF_SESSION }}
300+
```
301+
302+
**The `1` versus `2` split is the point of the flag.** Without it a pipeline
303+
cannot tell a changed file from an expired session cookie, and a job that goes
304+
red for the second reason sends whoever reads the log hunting for a diff that
305+
was never computed. Every other olcli command reports failure as `1`, and
306+
`olcli diff` still does when `--exit-code` is absent, so adding the flag does
307+
not change what existing scripts see.
308+
309+
The gate covers whatever was compared: `--file main.tex` narrows it to one
310+
file, the way a `git diff --exit-code` pathspec does, and a `--file` that
311+
matches nothing is `0` rather than an error. Under `--latexdiff` it still
312+
reports on the project as a whole — a changed figure is a real difference even
313+
though a marked-up root document cannot show it.
314+
315+
Note that this compares against Overleaf **now**, not against your last pull,
316+
so the check is "has anyone drifted from what is committed here", which is what
317+
makes it worth running on a schedule as well as on a push.
318+
284319
#### How deletion propagation works
285320

286321
`olcli` records a manifest of remote files in `.olcli.json`. On next sync:

‎SKILL.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,19 @@ olcli sync --no-delete # Sync without propagating local deletions to remote
157157
olcli diff # unified diff of every changed file
158158
olcli diff --name-only # changed paths only
159159
olcli diff --file main.tex # a single file
160+
olcli diff --exit-code # CI gate: 0 same, 1 differs, 2 failed
160161
```
161162

162163
The remote side is fetched fresh each run, so this shows what a subsequent
163164
`push` would overwrite — not a comparison against the last `pull`. `a/` is the
164165
remote, `b/` is local. Binary files are reported as differing without a patch.
165166

167+
`--exit-code` uses `diff(1)`'s statuses so the command can gate a pipeline:
168+
`0` nothing differs, `1` something does, `2` the run itself failed. The last
169+
one matters — without it a job cannot tell a changed file from an expired
170+
session. Failures stay `1` when the flag is absent, so existing scripts are
171+
unaffected.
172+
166173
### Delete or rename remote files
167174

168175
```bash
@@ -304,6 +311,7 @@ zip arxiv.zip *.tex main.bbl figures/*.pdf
304311
- **Auto-detect project**: Run commands from a synced directory (contains `.olcli.json`) to skip the project argument
305312
- **Dry run**: Use `olcli push --dry-run` or `olcli sync --dry-run` to preview before applying
306313
- **Preview content**: `push --dry-run` lists files by modification time; `olcli diff` compares actual contents, so the two lists can differ
314+
- **CI gate**: `olcli diff --exit-code` exits 1 when anything differs and 2 when the run failed, so a pipeline can distinguish drift from breakage
307315
- **Force overwrite**: Use `olcli pull --force` to overwrite local changes
308316
- **Two-way deletes**: `olcli sync` propagates *local* deletions to the remote; use `--no-delete` to opt out per run
309317
- **Build artifacts**: `.aux`, `.bbl`, `.log`, `.synctex.gz` etc. are filtered by default. Add custom patterns to a `.olignore` file (gitignore-style)

‎src/cli.ts‎

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,15 @@ import { OverleafClient } from './client.js';
1717
import { resolveRemotePath, resolveWithin, normalizeRemotePath } from './paths.js';
1818
import { planProjectRenames } from './rename-plan.js';
1919
import { scanLocalFiles } from './scan.js';
20-
import { compareTrees, filterRemoteTree, renderFileDiff, statusLetter, type FileDiff } from './diff.js';
20+
import {
21+
compareTrees,
22+
DIFF_EXIT_FAILURE,
23+
differencesExitCode,
24+
filterRemoteTree,
25+
renderFileDiff,
26+
statusLetter,
27+
type FileDiff,
28+
} from './diff.js';
2129
import {
2230
DIFF_OUTPUT_DIR,
2331
LatexdiffError,
@@ -1800,6 +1808,7 @@ program
18001808
.option('--name-only', 'List changed paths instead of printing patches')
18011809
.option('--file <path>', 'Diff a single file')
18021810
.option('-U, --unified <n>', 'Lines of context around each hunk (default: 3)', parseInt)
1811+
.option('--exit-code', 'Exit 1 if anything differs, 0 if nothing does, 2 on failure (for CI)')
18031812
.option('--latexdiff', 'Mark the revision up inside the document with latexdiff (requires latexdiff on PATH)')
18041813
.option('--pdf', 'Compile the marked-up document on Overleaf and download the PDF (implies --latexdiff)')
18051814
.option('--main <path>', 'Root .tex document to mark up (default: the only file declaring \\documentclass)')
@@ -1821,13 +1830,26 @@ reason.
18211830
inside the document instead: struck through is what a push would overwrite,
18221831
underlined is what it would upload. --pdf additionally uploads the marked-up
18231832
document to the project for one compile, downloads the PDF, and removes it
1824-
again - so a reviewable PDF needs no local TeX installation.`)
1833+
again - so a reviewable PDF needs no local TeX installation.
1834+
1835+
--exit-code makes the command a CI gate, with diff(1)'s statuses: 0 when
1836+
nothing differs, 1 when something does, and 2 when the run itself failed. The
1837+
last one matters - without it a pipeline cannot tell a changed file from an
1838+
expired session cookie. It applies to whatever was compared, so --file narrows
1839+
the gate to one file, and it reports on the project even under --latexdiff,
1840+
where the markup only covers the root document.`)
18251841
.action(async (project, dir, options) => {
18261842
const targetDir = dir || '.';
18271843

1844+
// Every way this command can fail, as opposed to finding differences.
1845+
// Under --exit-code that has to be distinguishable from status 1, or a
1846+
// pipeline reads "could not reach Overleaf" as "the paper changed"; with
1847+
// the flag absent it stays 1, which is what every other command exits.
1848+
const failureCode = options.exitCode ? DIFF_EXIT_FAILURE : 1;
1849+
18281850
if (!existsSync(targetDir)) {
18291851
console.error(chalk.red(`Directory not found: ${targetDir}`));
1830-
process.exit(1);
1852+
process.exit(failureCode);
18311853
}
18321854

18331855
// Checked before connecting: an unusable combination of flags should not
@@ -1842,7 +1864,7 @@ again - so a reviewable PDF needs no local TeX installation.`)
18421864
if (latexdiffMode && conflicting.length > 0) {
18431865
console.error(chalk.red(`--latexdiff cannot be combined with ${conflicting.join(', ')}`));
18441866
console.error('It marks up one root document; those options select and shape unified patch output.');
1845-
process.exit(1);
1867+
process.exit(failureCode);
18461868
}
18471869

18481870
if (!latexdiffMode) {
@@ -1855,7 +1877,7 @@ again - so a reviewable PDF needs no local TeX installation.`)
18551877
].filter(Boolean);
18561878
if (latexdiffOnly.length > 0) {
18571879
console.error(chalk.red(`${latexdiffOnly.join(', ')} only applies with --latexdiff`));
1858-
process.exit(1);
1880+
process.exit(failureCode);
18591881
}
18601882
}
18611883

@@ -1869,7 +1891,7 @@ again - so a reviewable PDF needs no local TeX installation.`)
18691891
} catch (error: any) {
18701892
spinner.fail(error.message);
18711893
console.error('Either run from a directory with .olcli.json or pass a project name/ID');
1872-
process.exit(1);
1894+
process.exit(failureCode);
18731895
}
18741896
const { id: projectId, name: projectName } = resolved;
18751897

@@ -1916,10 +1938,15 @@ again - so a reviewable PDF needs no local TeX installation.`)
19161938
remoteFiles,
19171939
entries,
19181940
fetchedAt,
1941+
failureCode,
19191942
options,
19201943
spinner,
19211944
});
19221945
setLastProject(projectId);
1946+
// Reports on the comparison, not on the markup: a changed figure is a
1947+
// difference in the project even though a marked-up root document
1948+
// cannot show it. The `No .tex file differs` line above says as much.
1949+
if (options.exitCode) process.exitCode = differencesExitCode(entries);
19231950
return;
19241951
}
19251952

@@ -1984,9 +2011,14 @@ again - so a reviewable PDF needs no local TeX installation.`)
19842011
console.log(chalk.dim(` a/ = remote as of ${fetchedAt.toISOString()}, b/ = local`));
19852012

19862013
setLastProject(projectId);
2014+
// Set rather than exited: `process.exit` drops whatever is still
2015+
// buffered on a non-TTY stdout, and `olcli diff --exit-code > patch.txt`
2016+
// is precisely a large patch going into a pipe. Returning lets node
2017+
// flush and then exit with this status on its own.
2018+
if (options.exitCode) process.exitCode = differencesExitCode(entries);
19872019
} catch (error: any) {
19882020
spinner.fail(`Failed: ${error.message}`);
1989-
process.exit(1);
2021+
process.exit(failureCode);
19902022
}
19912023
});
19922024

@@ -2010,6 +2042,8 @@ async function runLatexdiffMode(params: {
20102042
remoteFiles: Map<string, Buffer>;
20112043
entries: FileDiff[];
20122044
fetchedAt: Date;
2045+
/** What to exit with when this mode fails; 2 under --exit-code, else 1. */
2046+
failureCode: number;
20132047
/** Only the flags this mode reads; the rest of `diff`'s options are rejected. */
20142048
options: {
20152049
main?: string;
@@ -2022,7 +2056,7 @@ async function runLatexdiffMode(params: {
20222056
}): Promise<void> {
20232057
const {
20242058
client, projectId, projectName, targetDir,
2025-
localFiles, remoteFiles, entries, fetchedAt, options, spinner,
2059+
localFiles, remoteFiles, entries, fetchedAt, failureCode, options, spinner,
20262060
} = params;
20272061

20282062
let root = '';
@@ -2035,7 +2069,7 @@ async function runLatexdiffMode(params: {
20352069
for (const candidate of error.candidates) {
20362070
console.error(chalk.dim(` ${candidate}`));
20372071
}
2038-
process.exit(1);
2072+
process.exit(failureCode);
20392073
}
20402074

20412075
// latexdiff needs two versions of the same document. A root document that
@@ -2045,7 +2079,7 @@ async function runLatexdiffMode(params: {
20452079
spinner.stop();
20462080
console.error(chalk.red(`${root} is not in "${projectName}" yet, so there is no earlier version to mark up.`));
20472081
console.error(chalk.dim(' Push it first, or use --main to name a document that exists on both sides.'));
2048-
process.exit(1);
2082+
process.exit(failureCode);
20492083
}
20502084

20512085
if (!entries.some((e) => isTexPath(e.path))) {
@@ -2080,7 +2114,7 @@ async function runLatexdiffMode(params: {
20802114
console.error(error.stderr);
20812115
}
20822116
cleanupTmp();
2083-
process.exit(1);
2117+
process.exit(failureCode);
20842118
}
20852119

20862120
// An explicit --output is a path the user chose, so it is taken relative to
@@ -2100,7 +2134,7 @@ async function runLatexdiffMode(params: {
21002134
}
21012135

21022136
if (options.pdf) {
2103-
await compileMarkupOnOverleaf({ client, projectId, projectName, root, markup, texPath, pdfPath, spinner });
2137+
await compileMarkupOnOverleaf({ client, projectId, projectName, root, markup, texPath, pdfPath, failureCode, spinner });
21042138
}
21052139

21062140
console.log();
@@ -2146,9 +2180,11 @@ async function compileMarkupOnOverleaf(params: {
21462180
markup: string;
21472181
texPath: string;
21482182
pdfPath: string;
2183+
/** What to exit with when the compile fails; 2 under --exit-code, else 1. */
2184+
failureCode: number;
21492185
spinner: ReturnType<typeof ora>;
21502186
}): Promise<void> {
2151-
const { client, projectId, projectName, root, markup, texPath, pdfPath, spinner } = params;
2187+
const { client, projectId, projectName, root, markup, texPath, pdfPath, failureCode, spinner } = params;
21522188
const scratch = remoteScratchPath(root);
21532189

21542190
spinner.start(`Checking ${scratch} is free...`);
@@ -2157,7 +2193,7 @@ async function compileMarkupOnOverleaf(params: {
21572193
console.error(chalk.dim(' --pdf uploads the marked-up document under that name for one compile and'));
21582194
console.error(chalk.dim(' removes it again; it will not overwrite a file that is already there.'));
21592195
console.error(chalk.dim(` The marked-up source was still written: ${texPath}`));
2160-
process.exit(1);
2196+
process.exit(failureCode);
21612197
}
21622198

21632199
spinner.stop();
@@ -2226,7 +2262,7 @@ async function compileMarkupOnOverleaf(params: {
22262262
if (failure) {
22272263
spinner.fail(failure[0]);
22282264
for (const line of failure.slice(1)) console.error(chalk.dim(line));
2229-
process.exit(1);
2265+
process.exit(failureCode);
22302266
}
22312267
}
22322268

‎src/diff.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,39 @@ export function statusLetter(status: FileStatus): string {
217217
case 'unchanged': return ' ';
218218
}
219219
}
220+
221+
/**
222+
* Exit statuses for `diff --exit-code`, following diff(1) rather than the
223+
* plain success/failure every other olcli command reports.
224+
*
225+
* The 1-vs-2 split is the entire point of the flag. A gate that cannot
226+
* separate "the project differs" from "the run failed" reads an expired
227+
* cookie or a typo'd flag as a content change, and a pipeline that fails for
228+
* the wrong reason is worse than one that does not fail at all - it sends
229+
* whoever reads the log looking for a diff that was never computed.
230+
*
231+
* Only `--exit-code` moves failures to 2. Without it every failure stays 1,
232+
* so existing scripts that check `olcli diff` for success keep working.
233+
*/
234+
export const DIFF_EXIT_CLEAN = 0;
235+
export const DIFF_EXIT_DIFFERENCES = 1;
236+
export const DIFF_EXIT_FAILURE = 2;
237+
238+
/**
239+
* The status `diff --exit-code` should finish with, given the comparison it
240+
* just rendered.
241+
*
242+
* Takes the entries rather than a count so an unfiltered `compareTrees`
243+
* result answers the same as the filtered list the command prints from: a
244+
* gate that fired because every *unchanged* file was counted would report a
245+
* difference on every run and be switched off within a day.
246+
*
247+
* The entries passed in are whatever was reported, so `--file main.tex`
248+
* narrows the question to that file, the same way `git diff --exit-code`
249+
* narrows to its pathspec.
250+
*/
251+
export function differencesExitCode(entries: FileDiff[]): number {
252+
return entries.some((e) => e.status !== 'unchanged')
253+
? DIFF_EXIT_DIFFERENCES
254+
: DIFF_EXIT_CLEAN;
255+
}

‎test/diff.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { test } from 'node:test';
22
import assert from 'node:assert/strict';
33
import {
44
compareTrees,
5+
DIFF_EXIT_CLEAN,
6+
DIFF_EXIT_DIFFERENCES,
7+
DIFF_EXIT_FAILURE,
8+
differencesExitCode,
59
filterRemoteTree,
610
renderFileDiff,
711
isBinary,
@@ -217,3 +221,46 @@ test('renderFileDiff: a non-numeric context falls back to the default', () => {
217221
assert.equal(bad, good);
218222
assert.match(bad, /^\+line TEN$/m);
219223
});
224+
225+
// ─── --exit-code ──────────────────────────────────────────────────────────────
226+
227+
const entry = (status: 'added' | 'deleted' | 'modified' | 'unchanged') =>
228+
({ path: `${status}.tex`, status, binary: false });
229+
230+
test('differencesExitCode: nothing to report is a clean gate', () => {
231+
assert.equal(differencesExitCode([]), DIFF_EXIT_CLEAN);
232+
});
233+
234+
test('differencesExitCode: unchanged files are not differences', () => {
235+
// The command filters these out before printing, but the gate is fed
236+
// whatever it is given: counting entries instead of inspecting their status
237+
// would fire on every run of a project that matches its remote exactly.
238+
const local = new Map([['a.tex', buf('same\n')], ['b.tex', buf('same\n')]]);
239+
const unfiltered = compareTrees(local, new Map(local));
240+
241+
assert.equal(unfiltered.length, 2);
242+
assert.equal(differencesExitCode(unfiltered), DIFF_EXIT_CLEAN);
243+
});
244+
245+
test('differencesExitCode: every non-unchanged status is a difference', () => {
246+
// A remote-only file included on purpose: plain `push` leaves it alone, but
247+
// the two sides still do not match, and a gate that passed would be saying
248+
// they do.
249+
for (const status of ['added', 'deleted', 'modified'] as const) {
250+
assert.equal(differencesExitCode([entry(status)]), DIFF_EXIT_DIFFERENCES, status);
251+
}
252+
});
253+
254+
test('differencesExitCode: one difference among unchanged files still fires', () => {
255+
const entries = [entry('unchanged'), entry('modified'), entry('unchanged')];
256+
assert.equal(differencesExitCode(entries), DIFF_EXIT_DIFFERENCES);
257+
});
258+
259+
test('exit statuses follow diff(1), and failure is distinguishable', () => {
260+
// The whole point of the flag: a CI job has to be able to tell a changed
261+
// file from a run that never got as far as comparing anything.
262+
assert.equal(DIFF_EXIT_CLEAN, 0);
263+
assert.equal(DIFF_EXIT_DIFFERENCES, 1);
264+
assert.equal(DIFF_EXIT_FAILURE, 2);
265+
assert.notEqual(DIFF_EXIT_FAILURE, DIFF_EXIT_DIFFERENCES);
266+
});

0 commit comments

Comments
 (0)