Skip to content

Add a diff subcommand: what differs between a page and its local markdown - #164

Merged
willkg merged 8 commits into
mainfrom
diff-subcommand
Sep 15, 2026
Merged

willkg merged 8 commits into
mainfrom
diff-subcommand

Conversation

@willkg

@willkg willkg commented Sep 15, 2026

Copy link
Copy Markdown
Member

Closes #154. Design record in _plans/042_diff-subcommand.md.

markfluence diff FILE shows what differs between a Confluence page and the local markdown file that publishes to it. #149 taught update to refuse a page that moved past this copy's base, and left the author with no way to see how it moved — read prints the page, export writes a tree, and neither compares either against the file you are holding. Nothing is written, to disk or to Confluence.

The output is two things, on two streams

The issue's shape was one unified diff over the whole document. That cannot be made appliable, and being appliable is worth more than the uniformity — so the two halves are reported in the two registers they belong in:

$ markfluence diff docs/runbook.md
frontmatter differs (docs/runbook.md -> page 1234567890):
  title       confluence  "markfluence test page"
              local       "markfluence test page (edited)"  (frontmatter)
  page_width  confluence  "max"
              local       "narrow"  (frontmatter)
  labels      confluence  []
              local       [oncall, runbook]  (frontmatter)

--- confluence/docs/runbook.md
+++ local/docs/runbook.md
@@ -18,7 +18,7 @@
 Restart the worker pool:

-    kubectl rollout restart deploy/worker
+    kubectl rollout restart deploy/worker -n prod

stdout carries the body diff and nothing else, so diff FILE > my.diff is a real patch: patch -R -p1 pulls the page's body edits into the file (the file on disk is the +++ side), or diff --reverse | patch -p1 does it without the -R. It also feeds meld, delta, or anything else that eats a unified diff. stderr carries the frontmatter half as a per-field report, because a value difference is a one-line fact rather than a hunk — and because only a report can carry the provenance that makes "the title differs" say which file to edit. Keeping stdout to one clean document is children --space's hint rule with a stronger case; 2>/dev/null and >/dev/null keep one half each.

For patch to work at all, the +++ side has to be the bytes on disk. A composed frontmatter side never is: a pristine manifest-managed file (#139) has no frontmatter block on disk while its rendered counterpart has a full one, so patch finds no context and bails, and a file carrying a key markfluence preserves but does not model (reviewers:) would lose it on apply.

Three things that are load-bearing

The frontmatter block is shared between the two diffed documents byte for byte rather than diffed. That is what makes hunk line numbers the ones you would count to in an editor (diffing the bodies alone makes patch apply them with a "succeeded at 23 (offset 18 lines)" fudge), keeps the local side the file's exact bytes so a patch can apply to it, and means no patch can ever rewrite a page_id. A file with no frontmatter needs no special case: the prefix is empty and the numbers are already right.

Only fields the file declares are compared, because those are the ones publishing asserts: an absent labels or page_width leaves the page's own alone (L9), an absent title keeps the live one. That single rule removes most of what #154 accepted as unavoidable frontmatter noise. A page-side read that fails is reported comparable: false and does not count as a difference — read/export omit such a field, which here would claim publishing adds a label that may already be there. parent is compared as a resolved id rather than a spelling, since a file from an export tree names its parent by relative .md path.

Exit codes are diff(1)'s: 0 identical, 1 differs, 2 any trouble. A deliberate departure from this project's 1/2, and the reason the command is worth having in CI — an exit code is all a shell script has, so if markfluence diff FILE >/dev/null 2>&1; then echo "in sync"; fi works without jq. The cost is that diff's operational failures exit 2 where read's exit 1; that is stated in Long, docs/json-output.md and the README's exit table.

Outside cmd/diff/

  • internal/pagemeta gains Resolved.Origin, per-field provenance. Source answers "which location decided this page"; the report needs "which file do I edit to change this title", and unlike MetadataSource() it does not collapse the both case — correcting a field two locations supply means editing two files. Recorded in Resolve as it grades them rather than recomputed by the caller, for the reason that package exists: a second copy of the precedence rules is a second copy whatever it is used for.
  • internal/ui gains DiffAdded/DiffRemoved/DiffHunk, returning strings rather than printing (Match's pattern), since the patch goes to stdout unindented and every printing helper indents.
  • One new direct dependency, github.com/aymanbagabas/go-udiff — unified output, zero transitive dependencies of its own, BSD-3, one package derived from Go's own internal/diff. Six direct dependencies now. gotextdiff is a 2023 fork with no commits since, go-difflib is archived, go-diff is character-oriented and larger than the job.

Verified live

Against the standing fixture page in the personal space: a page read with read and diffed unedited reports nothing on either stream and exits 0 — so round-trip noise on a real markfluence-authored page with tables, macros, callouts and a TOC is zero in practice, not merely bounded by L5/L6. With a title, width, label and two body lines edited, the report and patch are as shown above, and patch -R -p1 applied to the real file: body reverted, frontmatter edits untouched, re-diff empty. Re-verified in a marker-less project, where the label keeps the typed directory and the patch applies from the invocation directory.

The command still carries a round-trip note in Long and a one-line stderr hint when the patch is non-empty, since L5/L6 are both Partial and the alternative is a stream of "diff shows a change I did not make" reports.

Review pass

/code-review found seven things, all real; the last commit fixes them. Two were wrong answers rather than polish. A diff label was recognised by its prefix, but a removed body line is - plus its content — so --json is a flag arrives as ---json is a flag and was read as a file label, dropped from the counts and coloured as one; markdown thematic breaks and flag-heavy prose hit that constantly, and the test asserting the old behaviour is replaced rather than adjusted since it is what let the collision through. And labelDifference's warnings were appended only when it returned a row, which a refused labels: value does not — so a file that cannot be published, whose labels also disagreed, reported nothing and exited 0.

Not in scope

Multiple files (status, #148, is the tree-wide view), three-way merge (the action log has the base #149 recorded, but patch/meld on the two-way diff is the 80% answer and a --merge writing conflict markers is a writing verb), --body-only/--frontmatter-only (the stream split already separates them), structured hunks in --json, and attachment binary comparison.

Closes #154's design questions: the output is split across two streams so
stdout is an appliable patch, exit codes follow diff(1), and the frontmatter
half is a per-field report carrying provenance.
Resolved records an aggregate Source, which answers "which location decided
this page" and not "which file do I edit to change this title". `diff` reports
the second beside every frontmatter difference, so Resolve now records it per
field as it grades them.

FromBoth is a real answer here rather than a collapsed one: when both locations
supply the same value, correcting it means editing two files. MetadataSource()
is untouched, so nothing else sees a change.

Computed here rather than in the caller for the reason this package exists: a
second copy of the precedence rules is a second copy whatever it is used for.
Green and red are the semantically right colors here for once -- an added line
is what publishing would put on the page, a removed one what it would take off
-- so unlike Match these reuse them rather than reaching for reverse video.
Cyan for a hunk header is what every other diff tool uses and the one color ui
had no claim on.

They return a string rather than printing, Match's pattern, because `diff`
writes its whole patch to stdout in one piece and unindented: every printing
helper here indents, and an indented patch is not a patch.
#149 taught update to refuse a page that moved past this copy's base, and left
the author with no way to see how it moved. read prints the page, export writes
a tree, and neither compares either against the file you are holding.

The output is two things on two streams, which is what makes it usable rather
than merely readable. stdout carries the body as a unified diff and nothing
else, so `diff FILE > my.diff` is a patch `patch -p1` applies to the real file;
stderr carries the frontmatter half as a per-field report. A value difference is
a one-line fact rather than a hunk, and only a report can say where the local
value came from -- which is the difference between something actionable and
"add title: X" with no hint about which file to edit.

Three things about it are load-bearing.

The frontmatter block is shared between the two diffed documents byte for byte
rather than being diffed. That is what makes hunk line numbers the ones a reader
would count to in an editor, keeps the local side the file's exact bytes so a
patch can apply to it, and means no patch can rewrite a page_id.

Only fields the file declares are compared, because those are the ones
publishing would assert: an absent labels or page_width leaves the page's own
alone (L9), an absent title keeps the live one. That one rule removes most of
what #154 accepted as unavoidable frontmatter noise. A page-side read that
fails is uncomparable rather than different -- read/export omit such a field,
which here would claim publishing adds a label that may already be there.

Exit codes are diff(1)'s: 0 identical, 1 differs, 2 any trouble. A deliberate
departure from this project's 1/2, and the reason the command is worth having
in CI -- an exit code is all a shell script has. The cost is that diff's
operational failures exit 2 where read's exit 1.
…ff(1)'s

The two things a reader has to know before using it and cannot guess: stdout is
a patch and the frontmatter report is on stderr, and exit 1 means "differs"
rather than "an operation failed".

Both are departures from rules stated elsewhere in these files, so they are
recorded where the rule is rather than only in the command's own help.
Two the plan got wrong. Normalizing the local side's trailing newline would
break the patch it exists to produce, so the local side is the file's exact
bytes. And re-rendering udiff's structured hunks means reimplementing the
@@ arithmetic a library was chosen to get right, so the canonical string is
what is printed and a classifier decides colour and counts.

Two the plan missed. page_width has a third declaration site -- the project
file's own setting, which update acts on -- and space and parent are worth
comparing even though no verb reconciles them, which makes "what publishing
would change" the rule for which fields to compare rather than a promise about
each row.

Plus the file layout as built, and the live check: a real page exported and
diffed unedited reports nothing at all, so the round-trip noise the command
warns about is zero on a markfluence-authored page in practice.
…ATION

The diff labels are root-relative whatever directory the command was invoked
from, which is what makes `patch -p1` land on the file -- but only when patch
runs from the documentation root. Said in Long, since the failure is a patch
that silently looks for the wrong path.

And frontmatter.ParseFile fails two ways that wore one code: a file that could
not be read is IO, a frontmatter block that could not be parsed is VALIDATION.
jsonout.CodeOr's own contract asks for the distinction, and a single-file
command is the one place it is visible.
A unified-diff label was recognised by its prefix, but a removed body line is
"-" plus its content -- so "--json is a flag" arrives as "---json is a flag"
and was read as a file label: left out of added/removed and coloured as one.
Markdown thematic breaks and flag-heavy prose hit it constantly. A label is now
recognised by position and nothing else (bodyLines), and the test that asserted
the old behaviour is replaced rather than adjusted, since it is what let the
collision through.

labelDifference's warnings were appended only when it returned a row, and a
labels: value markfluence refuses returns none -- so a file that cannot be
published, whose labels also disagreed, reported nothing at all and exited 0.
Warnings now travel whether or not there is a row, and labels.Declared's own
case-repair warnings travel with them: a repaired label is compared as the
repaired name, which otherwise looks like agreement with a file saying
something else.

Five smaller ones. With no markfluence.yaml anywhere, Discover falls back to the
starting directory, so a root-relative label was the bare base name and dropped
the docs/ a reader typed; reportPath gates on a real marker now. An empty page
body is compared rather than refused -- a folder 404s before that point, so the
reachable case is the empty page create leaves for a body-less file. The
frontmatter heading no longer says "differs" on a run that exits 0 with only
uncomparable rows. pagewidth.Read's explicit flag is reported, so an unset page
is not quoted as having said "narrow". And CLAUDE.md said patch -p1 where the
default direction needs -R.
@willkg

willkg commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

The help text isn't great — it's got that slop feel and it's a little confusing. I'm planning to do a polish-all-the-prose pass before a 1.0.0 release.

@willkg
willkg merged commit c7421bf into main Sep 15, 2026
1 check passed
@willkg
willkg deleted the diff-subcommand branch September 15, 2026 10:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a diff subcommand: show what differs between a page and its local markdown

1 participant