ctx diff
ctx diff shows what changed in one page — per frontmatter field and per ##
section, rather than per line.
When you’d reach for it
Section titled “When you’d reach for it”Two situations. Before editing a page an agent wrote, to see what the last change
actually did. And before pushing, where --local shows exactly what your working
copy would change on the server — the same computation the pre-push preview runs,
without pushing.
The thing to unlearn is git diff. Contexo pairs ## sections by heading and
reports each one added, removed, modified, unchanged or renamed. A reformat that
only shuffles blank lines between sections reads as unchanged; a section whose
heading changed but whose body mostly survived shows up as one rename, not a
delete plus an add. The other common mistake is assuming the default range means
“since I last pulled” — it doesn’t. With no --from/--to it is the page’s most
recent commit against the commit before it.
Synopsis
Section titled “Synopsis”ctx diff <slug>ctx diff <slug> --from <sha> --to <sha>ctx diff <slug> --localctx diff <slug> --blamectx diff <slug> --json| Flag | Type | Default | Description |
|---|---|---|---|
--blame | bool | false | annotate each section with the commit that introduced its heading (best-effort; adds latency on long histories) |
--from | string | old sha (default: parent of --to) | |
--json | bool | false | emit raw SectionDiff JSON instead of formatted text |
--local | bool | false | diff your local .contexo/ copy against the server's HEAD (what `ctx push` would change) |
--to | string | new sha (default: HEAD-for-this-path) | |
--type | string | page type (concept|entity|analysis|source); only needed when the slug is ambiguous |
--from and --to take hex commit shas, abbreviated or full — the 8-character
shas ctx history prints paste in directly. They are not
general git revisions: HEAD, main, tags and sha~1 all contain non-hex
characters and are rejected with unknown sha.
The defaults are resolved server-side and are both path-scoped. --to defaults to
the most recent commit that touched this page, and --from defaults to the
previous commit that touched this page — not the git parent, so commits that only
changed other pages are skipped. Passing only --from therefore diffs that sha
against the page’s current tip.
--local is mutually exclusive with --from, --to and --blame; the
combination is rejected before any request is made. It compares your file on disk
against the server’s current copy, so the right-hand side of the range prints as
local instead of a sha.
--json emits the raw SectionDiff: from_sha, to_sha, frontmatter (with
changed/added/removed field lists), an optional preamble, and sections[]
where each entry carries heading, status, from, to, line_diff and
optionally old_heading and introduced_by. This is the same payload the
ctx_diff MCP tool returns to an agent.
| Flag | Type | Default | Description |
|---|---|---|---|
--root | string | project root directory (default: current directory) |
Examples
Section titled “Examples”The most recent change to a page:
ctx diff stripe-subscriptiondiff: stripe-subscription a4b2c1d..b7e9f20
Frontmatter
~ updated
- 2026-07-20T10:04:00Z
+ 2026-07-24T16:31:00Z
+ tags: proration
Sections
~ ## Concepts (2 lines changed)
+ ## Webhooks (2 lines added)
= ## Agent Reasoning
- ## Open Questions (1 lines removed)What a push would send, without pushing:
ctx diff stripe-subscription --localdiff: stripe-subscription a4b2c1d..local
Frontmatter
~ updated
- 2026-07-20 10:04:00 +0000 UTC
+ 2026-07-24 16:31:00 +0000 UTC
+ tags: proration
Sections
~ ## Concepts (2 lines changed)
+ ## Webhooks (2 lines added)
= ## Agent Reasoning
- ## Open Questions (1 lines removed)Who introduced each section:
ctx diff stripe-subscription --blamediff: stripe-subscription a4b2c1d..b7e9f20
Frontmatter
~ updated
- 2026-07-20T10:04:00Z
+ 2026-07-24T16:31:00Z
+ tags: proration
Sections
~ ## Concepts (2 lines changed) — introduced by dev at a4b2c1d
+ ## Webhooks (2 lines added) — introduced by dev at b7e9f20
= ## Agent Reasoning — introduced by dev at a4b2c1d
- ## Open Questions (1 lines removed)Reading the output
Section titled “Reading the output”The header is diff: <slug> <from>..<to> with both shas shortened to seven
characters. Under --local the right-hand side is the literal word local, and
an empty left-hand side (..local) means the page doesn’t exist on the server
yet.
Under Frontmatter:
| Marker | Meaning |
|---|---|
~ field |
field changed; the - old and + new values follow on indented lines |
+ field: value |
key added, or a member added to a list-valued field |
- field: value |
key removed, or a member removed from a list-valued field |
Under Sections (the heading is printed with its ## prefix intact):
| Marker | Meaning |
|---|---|
= ## Heading |
unchanged |
+ ## Heading (N lines added) |
added |
- ## Heading (N lines removed) |
removed |
~ ## Heading (N lines changed) |
modified |
~> ## Heading (renamed from "Old", N lines changed) |
renamed; the count is omitted when the body is byte-identical |
A Preamble block appears above Sections when the text before the first ##
changed, rendered as +/-/~ preamble (N lines ...).
List-valued frontmatter fields use set semantics: order is ignored and each
added or removed member gets its own +/- line. That’s why a new tag shows as
+ tags: proration rather than an old→new pair on tags.
If frontmatter fails to parse on either side, the differ gives up on structure,
sets parse_fallback in the JSON, and prints
(frontmatter unparseable on at least one side; whole-file line diff below).
Despite that wording the text renderer then prints only the one-line preamble
summary (~ preamble (N lines changed)) — the whole-file line diff itself is
carried in --json under preamble.line_diff.
--blame
Section titled “--blame”--blame answers “who wrote this section?”, not “who made this change”. The
server walks the page’s history oldest→newest, parses just the ## headings out
of each revision, records the first commit each heading appeared in, and hangs
— introduced by <author> at <sha> off the matching rows.
Consequences worth knowing before you trust it:
- It attributes the heading, not the body. A section rewritten from scratch by someone else still blames whoever first added that heading.
- Removed sections are never annotated. In the example above,
- ## Open Questionshas no blame suffix — that is by design, not a lookup failure. - Renamed sections prefer the old heading’s blame when it’s known, so a rename keeps pointing at the commit that originally introduced the section.
- The walk is capped at 200 commits and revisions it can’t read are skipped.
- It is best-effort: if blame fails outright the server returns the plain diff rather than an error, so a missing annotation is not distinguishable from “never resolved”.
How it works
Section titled “How it works”- The slug is resolved against your local working copy —
.contexo/wiki/concepts/,.contexo/wiki/entities/,.contexo/wiki/analyses/,.contexo/raw/sessions/— exactly asctx historydoes. You cannot diff a page you don’t have locally, even if it exists on the server. repo_idcomes from.contexo/config.json; the token and server URL come from.contexo/credentials.json, whose URL takes precedence.- Default path:
GET /v1/repos/<repo_id>/diff/<path>?from=&to=&blame=. The server checks membership, resolves the shas, reads the blob at each side, runs the section differ, optionally annotates blame, and returns the JSON. --localpath: no server-side diffing at all. The CLI reads the file from disk, fetches the server’s bytes withGET /v1/repos/<repo_id>/pages/<path>(taking the sha from theX-Page-SHAresponse header), and runs the identical differ in-process.- Sections are split on
##at column 0 only.###and deeper stay inside their parent section’s body, and anything before the first##is diffed separately as the preamble. Trailing blank lines are trimmed per section, which is why whitespace-only reflows don’t register. - Rename detection is a post-pass: an unmatched removed section and an unmatched added section are collapsed into one rename when their bodies share at least 70% of their word tokens.
- Nothing is written and no commit is made.
ctx diffis read-only on both sides.
Common errors
Section titled “Common errors”diff: --local is mutually exclusive with --from/--to/--blame — pick one mode.
There is no way to blame a local working copy; commit it with
ctx push first.
sync: page diff (400): {"error":"no parent commit for this path; pass ?from explicitly"}
— the page has exactly one commit, so there’s nothing to default --from to.
Either pass --from explicitly or use ctx evolution, which
renders a first commit as “everything added”.
sync: page diff (404): {"error":"path not present in repo"} — the page exists
locally but was never pushed. Use --local to see it against nothing.
sync: page diff (400): {"error":"unknown sha: HEAD"} — a non-hex revision, a
sha that doesn’t resolve, or an ambiguous abbreviation.
diff --local: read local wiki/concepts/x.md: ... — the file resolved and then
couldn’t be read; usually a permissions problem or a file removed mid-run.
slug "x" is ambiguous (2 matches: [...]); pass --type to disambiguate — the same
slug exists under two page types.
diff: not authenticated (run 'ctx auth login') — no readable
.contexo/credentials.json.
See also
Section titled “See also”ctx evolution— every commit’s diff in one round-tripctx history— where the shas for--from/--tocome fromctx push— the preview that--localmirrorsctx_diff— the MCP tool your agent calls
