A read-only command that shows the page tree a project declares and, for each page, whether the local file and the Confluence page still agree. The thing markfluence cannot answer today at all is the reverse direction — the page is ahead of your local copy — and making that answerable needs one small piece of recorded state, which is most of this issue.
Motivation
Three separate wants meet in one command.
Seeing the tree. Nothing prints the page hierarchy a project declares. children prints the live tree, which needs credentials and answers a different question ("what is under this node in Confluence?"). Reading parent: across a tree of files, or across a markfluence.yaml pages: block (#139), is currently a manual exercise. This is also the want that made a nested pages: hierarchy tempting during #139's design, and the reason it was rejected there: storing the tree in order to display the tree is the expensive way round, and a read-only view is the cheap one.
Auditing. #139 deliberately makes several things invisible in normal use, because every diagnostic there is scoped to the files an invocation names: a pages: entry naming no file on disk, a file under the root registered in neither location, an entry whose page_id resolves to nothing. Each is legitimate in some situation (a branch that deleted a file, a draft, a sparse checkout), which is exactly why they must not be errors — and why there should be somewhere to ask.
Drift. "Would update do anything, and is anyone else editing these pages?"
What it shows
Sketch, not a specification:
$ markfluence status
root: /home/me/myrepo space: ENG
12345 docs/engineering-docs.md Engineering Docs
12346 ├─ docs/deploy-runbook.md Deploy Runbook modified
12347 └─ docs/on-call-handbook.md On-call Handbook page changed (v7, you published v5)
— docs/draft.md (unmanaged)
— docs/removed.md entry names no file
4 pages, 1 modified, 1 changed in Confluence, 1 unmanaged, 1 dangling entry.
A page whose parent: is an id rather than an in-project path is a root of the local forest, annotated with that id — the tree is a forest, not a single tree, and a page parented to a Cloud folder or to a page outside the project is normal.
Offline by default, network by opt-in
The tree, published-vs-unpublished, unmanaged files and dangling entries all come from disk. Requiring a token to look at a tree would be wrong, and the precedent is check, which is its own command precisely because it is the offline verb — the first whose run() never constructs a client.ConfluenceClient. A flag (--remote, --fetch, name TBD) adds one GET per page for the drift columns.
What "changed" can honestly mean
This is the part to get right, because two readings of it differ enormously in cost and in truthfulness.
Comparing content is a trap, and it is one docs/confluence/ already warns about. The converter targets semantic, not byte-for-byte, equivalence to storage. Worse, any save through the Confluence editor re-serializes the body through ADF — that is the coalesceSplitMarks case, where a mark markfluence nested around a link comes back split per text run. So a byte comparison of markfluence's output against the live storage would report "changed" for pages nobody touched. docs/confluence/ records "body.storage proves only what was stored, never what takes effect" as one of two traps that have each already produced a confident wrong conclusion. A semantic comparison would need a normalizer nobody has written, and it would become a second source of truth about what equivalence means.
Comparing timestamps is available today and is not sufficient on its own. version.createdAt against the file's mtime is exactly what update's mtime skip uses (cmd/update/update.go:250-258), so it faithfully reports what update would do — which is a good contract for one column. What it cannot do is tell you why the page is newer, and the failure is not marginal: publishing sets createdAt to now while the file's mtime is from whenever it was saved, so immediately after a successful update, every page is newer than its file. A "page is ahead" column built on timestamps alone would light up for the wrong reason most of the time.
Tracking state in a content property
Record the page version markfluence last published, as a content property on the page. Then "live version > recorded version" means precisely someone other than markfluence has written to this page since markfluence last did — no timestamp skew, no ambiguity with your own publishes, and a version number increments only when somebody actually saves.
This is an existing pattern applied to the body rather than a new idea. An attachment already records a SHA-256 in its comment, and that is exactly how client.SyncAttachments decides skip-vs-update; the body has had no equivalent. The machinery exists too: SetContentProperty/ListContentProperties, with SetContentProperty already carrying a retry-once on top because "a versioned PUT is not as idempotent as its method", and page_width already storing two properties per page (internal/pagewidth).
Why a version number rather than a hash of the body. A hash is more granular and is worse here: an ADF round-trip changes the stored bytes when someone opens the editor and saves without editing anything, so a hash reports "changed" where a version number reports, accurately, "somebody saved it". A hash is also strictly more state to keep correct. If a hash is ever wanted, note that it would compare Confluence's bytes now against Confluence's bytes when markfluence last wrote — both sides Confluence's own serialization — which sidesteps the semantic-equivalence problem above, unlike comparing against the converter's output.
Open implementation questions:
- The property key, and whether this is one property per fact or a single markfluence-state property holding a small object.
client.SetContentProperty takes a string value today, so an object would want a typed helper rather than callers hand-encoding JSON.
- Which verbs write it.
update and create clearly. fix is read-only against Confluence and should stay that way.
- Pages published before this lands have no recorded version, which must read as "unknown", never as "changed" — the same tri-state discipline
pagedoc.UserCache and StorageOptions.UserNames keep for an unresolved mention, and for the same reason: a fabricated answer written confidently is worse than an absent one.
- A
--dry-run publish must not write it, or a preview would silently claim a publish happened.
The more valuable consequence: update stops clobbering silently (#149)
The display is not the best thing this state buys. update currently avoids overwriting a Confluence-side edit only by accident: the mtime skip means a page newer than its file is skipped. But edit the file after someone's UI edit and the file wins on mtime, so the publish overwrites their work with no warning at all — and --force bypasses the check regardless (cmd/update/update.go:250-258).
A recorded version turns that into a real diagnostic: "this page was changed in Confluence since markfluence last published it; publishing will overwrite that." That is a safety property rather than a convenience, and it is arguably worth more than the status column that prompted it. Filed separately as #149, since it is a defect where this is a feature, and it is probably the better reason to build the recorded state — this issue can consume it once it exists.
Why not an existing command
Not a flag on children. children asks Confluence what is under a node: it takes a page or a space, needs credentials, reports folders, and reports live ids. A local view takes the root, needs none of that, has no folders to report, and has no id at all for an unpublished page — so it would share only the output shape, and children's argument rule is already "exactly one of PAGE or --space".
Not update --dry-run, though it overlaps. update --dry-run docs/**/*.md already reports skipped-vs-would-publish per file, honouring the mtime skip, so its forecast is real. What it does not give you is the hierarchy, the audit facts, or the reverse direction — and it reads as "here is what I would write", not "here is where things stand".
--json
A new command enum entry plus a statusResult definition and an if/then branch constraining both results.items and summary — a command added to the enum without a branch is completely unvalidated, which internal/schematest's document tests exist to catch. Every result field on a typed struct, no omitempty.
Out of scope
- Semantic body comparison. See above; it needs a normalizer that does not exist.
- Fixing drift.
status reports; update and fix already act in their own directions.
- Live-tree output. That is
children.
Related
A read-only command that shows the page tree a project declares and, for each page, whether the local file and the Confluence page still agree. The thing markfluence cannot answer today at all is the reverse direction — the page is ahead of your local copy — and making that answerable needs one small piece of recorded state, which is most of this issue.
Motivation
Three separate wants meet in one command.
Seeing the tree. Nothing prints the page hierarchy a project declares.
childrenprints the live tree, which needs credentials and answers a different question ("what is under this node in Confluence?"). Readingparent:across a tree of files, or across amarkfluence.yamlpages:block (#139), is currently a manual exercise. This is also the want that made a nestedpages:hierarchy tempting during #139's design, and the reason it was rejected there: storing the tree in order to display the tree is the expensive way round, and a read-only view is the cheap one.Auditing. #139 deliberately makes several things invisible in normal use, because every diagnostic there is scoped to the files an invocation names: a
pages:entry naming no file on disk, a file under the root registered in neither location, an entry whosepage_idresolves to nothing. Each is legitimate in some situation (a branch that deleted a file, a draft, a sparse checkout), which is exactly why they must not be errors — and why there should be somewhere to ask.Drift. "Would
updatedo anything, and is anyone else editing these pages?"What it shows
Sketch, not a specification:
A page whose
parent:is an id rather than an in-project path is a root of the local forest, annotated with that id — the tree is a forest, not a single tree, and a page parented to a Cloud folder or to a page outside the project is normal.Offline by default, network by opt-in
The tree, published-vs-unpublished, unmanaged files and dangling entries all come from disk. Requiring a token to look at a tree would be wrong, and the precedent is
check, which is its own command precisely because it is the offline verb — the first whoserun()never constructs aclient.ConfluenceClient. A flag (--remote,--fetch, name TBD) adds one GET per page for the drift columns.What "changed" can honestly mean
This is the part to get right, because two readings of it differ enormously in cost and in truthfulness.
Comparing content is a trap, and it is one
docs/confluence/already warns about. The converter targets semantic, not byte-for-byte, equivalence to storage. Worse, any save through the Confluence editor re-serializes the body through ADF — that is thecoalesceSplitMarkscase, where a mark markfluence nested around a link comes back split per text run. So a byte comparison of markfluence's output against the live storage would report "changed" for pages nobody touched.docs/confluence/records "body.storageproves only what was stored, never what takes effect" as one of two traps that have each already produced a confident wrong conclusion. A semantic comparison would need a normalizer nobody has written, and it would become a second source of truth about what equivalence means.Comparing timestamps is available today and is not sufficient on its own.
version.createdAtagainst the file's mtime is exactly whatupdate's mtime skip uses (cmd/update/update.go:250-258), so it faithfully reports whatupdatewould do — which is a good contract for one column. What it cannot do is tell you why the page is newer, and the failure is not marginal: publishing setscreatedAtto now while the file's mtime is from whenever it was saved, so immediately after a successfulupdate, every page is newer than its file. A "page is ahead" column built on timestamps alone would light up for the wrong reason most of the time.Tracking state in a content property
Record the page version markfluence last published, as a content property on the page. Then "live version > recorded version" means precisely someone other than markfluence has written to this page since markfluence last did — no timestamp skew, no ambiguity with your own publishes, and a version number increments only when somebody actually saves.
This is an existing pattern applied to the body rather than a new idea. An attachment already records a SHA-256 in its comment, and that is exactly how
client.SyncAttachmentsdecides skip-vs-update; the body has had no equivalent. The machinery exists too:SetContentProperty/ListContentProperties, withSetContentPropertyalready carrying a retry-once on top because "a versioned PUT is not as idempotent as its method", andpage_widthalready storing two properties per page (internal/pagewidth).Why a version number rather than a hash of the body. A hash is more granular and is worse here: an ADF round-trip changes the stored bytes when someone opens the editor and saves without editing anything, so a hash reports "changed" where a version number reports, accurately, "somebody saved it". A hash is also strictly more state to keep correct. If a hash is ever wanted, note that it would compare Confluence's bytes now against Confluence's bytes when markfluence last wrote — both sides Confluence's own serialization — which sidesteps the semantic-equivalence problem above, unlike comparing against the converter's output.
Open implementation questions:
client.SetContentPropertytakes astringvalue today, so an object would want a typed helper rather than callers hand-encoding JSON.updateandcreateclearly.fixis read-only against Confluence and should stay that way.pagedoc.UserCacheandStorageOptions.UserNameskeep for an unresolved mention, and for the same reason: a fabricated answer written confidently is worse than an absent one.--dry-runpublish must not write it, or a preview would silently claim a publish happened.The more valuable consequence:
updatestops clobbering silently (#149)The display is not the best thing this state buys.
updatecurrently avoids overwriting a Confluence-side edit only by accident: the mtime skip means a page newer than its file is skipped. But edit the file after someone's UI edit and the file wins on mtime, so the publish overwrites their work with no warning at all — and--forcebypasses the check regardless (cmd/update/update.go:250-258).A recorded version turns that into a real diagnostic: "this page was changed in Confluence since markfluence last published it; publishing will overwrite that." That is a safety property rather than a convenience, and it is arguably worth more than the status column that prompted it. Filed separately as #149, since it is a defect where this is a feature, and it is probably the better reason to build the recorded state — this issue can consume it once it exists.
Why not an existing command
Not a flag on
children.childrenasks Confluence what is under a node: it takes a page or a space, needs credentials, reports folders, and reports live ids. A local view takes the root, needs none of that, has no folders to report, and has no id at all for an unpublished page — so it would share only the output shape, andchildren's argument rule is already "exactly one of PAGE or--space".Not
update --dry-run, though it overlaps.update --dry-run docs/**/*.mdalready reports skipped-vs-would-publish per file, honouring the mtime skip, so its forecast is real. What it does not give you is the hierarchy, the audit facts, or the reverse direction — and it reads as "here is what I would write", not "here is where things stand".--jsonA new
commandenum entry plus astatusResultdefinition and anif/thenbranch constraining bothresults.itemsandsummary— a command added to the enum without a branch is completely unvalidated, whichinternal/schematest's document tests exist to catch. Every result field on a typed struct, noomitempty.Out of scope
statusreports;updateandfixalready act in their own directions.children.Related
markfluence.yamlpages:. The audit facts and the rejected nested-hierarchy alternative both come from its design discussion (_plans/039_project-file-pages.md).updatesilently overwriting a Confluence-side edit. The same recorded state fixes it, and it is a defect rather than a feature.updateenforcing space/parent and supporting moves; another case where knowing what the live page says before writing matters.