Skip to content

markfluence status: show the local page tree and whether Confluence has moved on #148

Description

@willkg

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions