Skip to content

Commit f8b960e

Browse files
authored
Merge pull request #57 from Waynting/feat/diff-exit-code
CI gate from #48: olcli diff --exit-code reports diff(1)'s statuses, 0 when nothing differs, 1 when something does, 2 when the run itself failed. Author: @Waynting. Stacked on #56, so only 86b078b is new here. Verified before merging: npm ci, lint and build clean, 84 tests passing. The 1 versus 2 split is the feature. All ten failure paths move to 2 together, so a pipeline cannot read an expired session cookie as a content change. Without the flag every failure stays 1, which is what every other command exits, so existing scripts are unaffected.
2 parents 7d9ca3c + 86b078b commit f8b960e

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)