Skip to content

ctx diff

ctx diff shows what changed in one page — per frontmatter field and per ## section, rather than per line.

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.

Terminal window
ctx diff <slug>
ctx diff <slug> --from <sha> --to <sha>
ctx diff <slug> --local
ctx diff <slug> --blame
ctx diff <slug> --json
FlagTypeDefaultDescription
--blameboolfalseannotate each section with the commit that introduced its heading (best-effort; adds latency on long histories)
--fromstringold sha (default: parent of --to)
--jsonboolfalseemit raw SectionDiff JSON instead of formatted text
--localboolfalsediff your local .contexo/ copy against the server's HEAD (what `ctx push` would change)
--tostringnew sha (default: HEAD-for-this-path)
--typestringpage 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.

FlagTypeDefaultDescription
--rootstringproject root directory (default: current directory)

The most recent change to a page:

Terminal window
ctx diff stripe-subscription
ctx diff stripe-subscription
diff: 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:

Terminal window
ctx diff stripe-subscription --local
ctx diff stripe-subscription --local
diff: 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:

Terminal window
ctx diff stripe-subscription --blame
ctx diff stripe-subscription --blame
diff: 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)

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 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 Questions has 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”.
  1. The slug is resolved against your local working copy — .contexo/wiki/concepts/, .contexo/wiki/entities/, .contexo/wiki/analyses/, .contexo/raw/sessions/ — exactly as ctx history does. You cannot diff a page you don’t have locally, even if it exists on the server.
  2. repo_id comes from .contexo/config.json; the token and server URL come from .contexo/credentials.json, whose URL takes precedence.
  3. 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.
  4. --local path: no server-side diffing at all. The CLI reads the file from disk, fetches the server’s bytes with GET /v1/repos/<repo_id>/pages/<path> (taking the sha from the X-Page-SHA response header), and runs the identical differ in-process.
  5. 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.
  6. 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.
  7. Nothing is written and no commit is made. ctx diff is read-only on both sides.

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.

  • ctx evolution — every commit’s diff in one round-trip
  • ctx history — where the shas for --from/--to come from
  • ctx push — the preview that --local mirrors
  • ctx_diff — the MCP tool your agent calls