Add a diff subcommand: what differs between a page and its local markdown - #164
Merged
Merged
Conversation
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.
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #154. Design record in
_plans/042_diff-subcommand.md.markfluence diff FILEshows what differs between a Confluence page and the local markdown file that publishes to it. #149 taughtupdateto refuse a page that moved past this copy's base, and left the author with no way to see how it moved —readprints the page,exportwrites 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:
stdout carries the body diff and nothing else, so
diff FILE > my.diffis a real patch:patch -R -p1pulls the page's body edits into the file (the file on disk is the+++side), ordiff --reverse | patch -p1does it without the-R. It also feedsmeld,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 ischildren --space's hint rule with a stronger case;2>/dev/nulland>/dev/nullkeep one half each.For
patchto 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, sopatchfinds 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
patchapply 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 apage_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
labelsorpage_widthleaves the page's own alone (L9), an absenttitlekeeps the live one. That single rule removes most of what #154 accepted as unavoidable frontmatter noise. A page-side read that fails is reportedcomparable: falseand does not count as a difference —read/exportomit such a field, which here would claim publishing adds a label that may already be there.parentis compared as a resolved id rather than a spelling, since a file from an export tree names its parent by relative.mdpath.Exit codes are
diff(1)'s:0identical,1differs,2any trouble. A deliberate departure from this project's1/2, and the reason the command is worth having in CI — an exit code is all a shell script has, soif markfluence diff FILE >/dev/null 2>&1; then echo "in sync"; fiworks withoutjq. The cost is thatdiff's operational failures exit2whereread's exit1; that is stated inLong,docs/json-output.mdand the README's exit table.Outside
cmd/diff/internal/pagemetagainsResolved.Origin, per-field provenance.Sourceanswers "which location decided this page"; the report needs "which file do I edit to change this title", and unlikeMetadataSource()it does not collapse the both case — correcting a field two locations supply means editing two files. Recorded inResolveas 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/uigainsDiffAdded/DiffRemoved/DiffHunk, returning strings rather than printing (Match's pattern), since the patch goes to stdout unindented and every printing helper indents.github.com/aymanbagabas/go-udiff— unified output, zero transitive dependencies of its own, BSD-3, one package derived from Go's owninternal/diff. Six direct dependencies now.gotextdiffis a 2023 fork with no commits since,go-difflibis archived,go-diffis character-oriented and larger than the job.Verified live
Against the standing fixture page in the personal space: a page read with
readand 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, andpatch -R -p1applied 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
Longand 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-reviewfound 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 flagarrives as---json is a flagand 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. AndlabelDifference's warnings were appended only when it returned a row, which a refusedlabels: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, butpatch/meldon the two-way diff is the 80% answer and a--mergewriting conflict markers is a writing verb),--body-only/--frontmatter-only(the stream split already separates them), structured hunks in--json, and attachment binary comparison.