Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,4 @@ The design target is **semantic** (not byte-for-byte) equivalence to valid Confl

### Frontmatter-driven publishing

Each markdown file is one page. Frontmatter carries `title`, `page_id`, `space` (a key), `parent` (`null` / a `.md` path / a page id), and `page_width`. `update` requires a `page_id` (from frontmatter or `--page-id`) and errors without one; `--title`/`--page-id` override the frontmatter (single FILE only), `--page-width` overrides too (batch allowed), and `update` never writes back to files. It asserts `page_width` only when set via flag or frontmatter (otherwise the live width is left alone), and skips a file whose mtime predates the page's last version unless `--force`. `create` takes `--title`/`--page-width` overrides (`--title` single-FILE only; `--page-width` batch-ok, default `max`) and, unless `--no-persist` is given, writes `title`/`space`/`parent`/`page_id`/`page_width` back after creating; `fix` reconciles all of these (plus `page_width`) from the live page. Commands process multiple files (except `info`, single-arg) and exit non-zero if any fail.
Each markdown file is one page. Frontmatter carries `title`, `page_id`, `space` (a key), `parent` (`null` / a `.md` path / a page id), and `page_width`. `update` requires a `page_id` (from frontmatter or `--page-id`) and errors without one; `--title`/`--page-id` override the frontmatter (single FILE only), `--page-width` overrides too (batch allowed), and `update` never writes back to files. It asserts `page_width` only when set via flag or frontmatter (otherwise the live width is left alone), and skips a file whose mtime predates the page's last version unless `--force`. `create` takes `--title`/`--page-width` overrides (`--title` single-FILE only; `--page-width` batch-ok, default `max`) and, unless `--no-persist` is given, writes `title`/`space`/`parent`/`page_id`/`page_width` back after creating; `fix` reconciles all of these (plus `page_width`) from the live page. `update`, `create`, and `fix` all take `--dry-run`, which previews the actions (pages, attachment uploads, width changes, and — for `create` — frontmatter write-backs) without writing to Confluence or files; per-file human output is identical to a real run, distinguished only by a leading `DRY RUN` banner and a `dry_run: true` field in `--json`. In a `create` dry-run a previewed page has no id/URL yet, and an in-set child's `parent` is null; the parent's source `.md` is always reported in the `parent_file` output field. Commands process multiple files (except `info`, single-arg) and exit non-zero if any fail.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,20 @@ A whole tree can be created in one pass: give each child a `parent:` that points
its parent's `.md` file, and `create` orders creation parents-first and fills in the
real ids (see the `parent` field below).

`--dry-run` validates every file (the same checks a real run makes, so it exits
non-zero on the same failures) and previews what would be created — pages,
attachment uploads, page widths, and frontmatter write-backs — without writing to
Confluence or to any file. Because nothing is created, a previewed page has no id
or URL yet; an in-set child's `parent` is unresolved, but its source file is
reported in the `parent_file` output field (present in every run, in `--json`).

```sh
markfluence create docs/new_page.md --space ENG
markfluence create docs/child.md --space ENG --parent 123456
markfluence create docs/*.md --space ENG # hierarchy via parent: paths
markfluence create note.md --space ENG --title "Ad-hoc note" --page-width wide
markfluence create note.md --space ENG --no-persist # create without touching the file
markfluence create docs/*.md --space ENG --dry-run # preview; write nothing
```

### `update`
Expand All @@ -120,13 +128,19 @@ Updates are skipped when a file hasn't changed since the page's last version
(compared by mtime) unless `--force` is given. Each file is processed
independently; the command exits non-zero if any file fails.

`--dry-run` previews what would be published — the version bump, attachment
uploads, and any page-width change — without writing to Confluence. It honors the
mtime skip and `--force` just like a real run, so its forecast matches what a real
run would do.

```sh
markfluence update docs/managing_an_incident.md
markfluence update docs/*.md --message "Bulk update"
markfluence update docs/foo.md --force # ignore the mtime check
markfluence update page.md --page-id 123456 # override the target page
markfluence update page.md --title "New Title" # override / rename
markfluence update docs/*.md --page-width wide # set width across a batch
markfluence update docs/*.md --dry-run # preview; write nothing
```

### `fix`
Expand Down
133 changes: 133 additions & 0 deletions _plans/016_dry-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Plan: `--dry-run` for `update` and `create`

Add `--dry-run` to `update` and `create` so they preview what a real run would do
— pages created/updated (and version bumps), attachments that would upload,
page-width changes, and (for `create`) frontmatter write-backs — **without writing
to Confluence or to files**. `fix` already has `--dry-run`; this brings the two
publishing commands to parity (issue #11).

Exit-non-zero semantics must match a real run's validation failures.

## Decisions locked (from the interview)

### Status model — reuse the verb, add `dry_run`

- **No new status verbs.** A dry-run keeps the existing per-file status
(`published`/`skipped`/`created`/`failed`); the machine-readable signal is a
required, always-emitted `dry_run` boolean on `updateResult`/`createResult`
(mirroring the `dry_run` already on `fixResult`). Consumers key off `dry_run` to
know nothing was written. `summarize()` is untouched.

### Human output — identical per-file lines + one banner

- **Per-file lines are byte-identical between dry-run and a real run.** We do *not*
reword them (no "would publish" per line). The only human cue is a single leading
`ui.Warn` banner emitted once per invocation:

```
DRY RUN — no changes will be written.
```

The banner is **suppressed under `--json`** (it would corrupt the JSON document;
`dry_run: true` is the signal there).
- **`fix` is converted to this same rule**: drop its `renderHuman` "would set" verb
swap (verb is always "set") and add the banner. Update `fix`'s affected tests.
- The one **unavoidable exception** is `create`'s final success line. A real run
prints `Created page <id>: <url>`, but a dry-run has created nothing, so no id and
no URL exist. `create` dry-run prints a distinct line instead:

```
Would create page 'Title' in SPACE
```

(`update` has no such problem: its URL comes from the read-only `GetPage` and its
new version is `prev+1`, so `Published v4: <url>` prints identically and truly.)

### `update --dry-run`

All of `update`'s pre-write work is already read-only, so the flow is unchanged
through conversion; only the writes are swapped for previews.

1. Parse, resolve title/page_id/width, `GetPage` — unchanged (reads).
2. mtime skip (unless `--force`) — **honored**. A file that would be skipped reports
`status: "skipped"` with the identical `Skipping -- no changes` line and short-
circuits before any attachment/width preview, exactly as a real run.
3. `convert` — unchanged; surfaces `broken`/`warnings` and the attachment list.
4. Attachments: previewed via a **new read-only `client.PlanAttachments`** that
shares the per-file decision logic with `SyncAttachments` (`ListAttachments` +
checksum compare) but performs **no uploads** → accurate
`created`/`updated`/`skipped`. Real run still calls `SyncAttachments`.
5. Version: reported as `{previous, new: previous+1}` (the same value a real run
would send); no `UpdatePage` call.
6. Width: previewed via `pagewidth.Read`; reported **only when it differs** from the
live width. No `pagewidth.Apply` call. (`fix` already reads live width in its
dry-run — precedent.)

### `create --dry-run`

`create`'s **phase 1 (validation) runs unchanged** — it performs no writes, so all
validation errors, their codes, and the abort→exit-1 behavior (including
`topoSort` cycle detection) match a real run for free. Only **phase 2** is swapped
from create to preview:

- `convert` surfaces `broken`/`warnings` and the attachment list.
- Attachments: a not-yet-created page has nothing to read, so every attachment is
synthesized as `created` (no `PlanAttachments` call, no server read).
- `page_id`/`url` stay `null`; `status: "created"`, `dry_run: true`.
- Width shown from the record (no `Apply`).
- `persisted` reflects **intent**: `true` when `--persist` is in effect; `dry_run:
true` conveys nothing was actually written. No extra human line — a real run
prints nothing about persistence either, and the identical-messages rule keeps it
that way; the write-back intent lives in the JSON (`persisted` + `dry_run`).

### New `parent_file` field (create only, always present)

To give an automated consumer the parent→child relationship **without corrupting**
`parent` (whose contract stays "a page id or null"), add a separate
`parent_file` (`stringOrNull`) to `createResult`, **emitted in every run** (not
just dry-run):

| parent kind | `parent` (id) | `parent_file` |
|---|---|---|
| top-level | `null` | `null` |
| external (raw id) | `<id>` | `null` |
| published (`.md` w/ page_id) | `<id>` | `foo.md` |
| in-set (sibling being created) | real: `<new id>` · **dry-run: `null`** | `foo.md` |

Value = `parentInfo.display` (already populated for in-set and published parents).
In a dry-run an in-set child shows `parent: null` **and** `parent_file: "foo.md"`,
so the relationship is explicit; results are still emitted in topo order too.

**Rationale:** a synthesized/fake parent id is a footgun for an agent (it could feed
the fake id into a later real call); `null` is unambiguous and safe, and a
separately-named field beats overloading `parent` with a `.md` path.

### Flag

Each command gets its own `--dry-run` bool flag (as `fix` has), default false.

## Schema (`schema/json-output/v1.json`) — amend in place

- `updateResult`: add `dry_run` to properties + `required`.
- `createResult`: add `dry_run` **and** `parent_file` to properties + `required`.
- **`abortedResult()` must emit both new fields** (it builds `createResult`s; a
dry-run that aborts in phase 1 would otherwise fail conformance): `dry_run` =
the run's flag, `parent_file` = null.
- Amending `v1` (not minting `v2`) matches the precedent `fix` set when it added
`dry_run` to `v1`.

## Testing

- `client.PlanAttachments`: unit test the created/updated/skipped decision (shared
helper with `SyncAttachments`, no uploads).
- `update`/`create` dry-run: human + `--json` result-shape tests, including the
stale-file skip (update) and an in-set-parent dry-run (create → `parent: null`,
`parent_file` set).
- `fix`: fix up tests that asserted the "would set" wording.
- `TestSchemaConformance`: extend to cover the new required fields.

## Docs

- CLAUDE.md: note `--dry-run` on `update`/`create` (parity with `fix`) and the new
`parent_file` output field.
- README: document `--dry-run` for both commands.
28 changes: 27 additions & 1 deletion cmd/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ var (
pageWidthOpt string
persistOpt bool
noPersistOpt bool
dryRunOpt bool
)

// Cmd is the create command.
Expand Down Expand Up @@ -54,6 +55,8 @@ func init() {
"Write title/space/parent/page_id/page_width back into the frontmatter.")
Cmd.Flags().BoolVar(&noPersistOpt, "no-persist", false,
"Do not write anything back into the frontmatter.")
Cmd.Flags().BoolVar(&dryRunOpt, "dry-run", false,
"Preview what would be created without writing to Confluence or files.")
}

// parentInfo describes a resolved parent. kind is top|inset|published|external.
Expand Down Expand Up @@ -93,6 +96,10 @@ func run(cmd *cobra.Command, args []string) error {
return fatalFail(err.Error(), jsonout.CodeConfig)
}

if dryRunOpt {
ui.Warn("DRY RUN — no changes will be written.")
}

inSetAbs := map[string]bool{}
for _, f := range args {
if abs, err := filepath.Abs(f); err == nil {
Expand Down Expand Up @@ -184,7 +191,10 @@ func createInOrder(
parentID := r.parent.id
if r.parent.kind == "inset" {
parentID = created[r.parent.abs]
if parentID == "" {
// In a dry-run nothing is created, so an in-set parent has no id yet;
// that is not a failure (the parent would have been created first). The
// relationship is still reported via parent_file.
if parentID == "" && !dryRunOpt {
res := newResult(r)
return res.fail(errors.New("parent page was not created; skipping"), jsonout.CodeValidation)
}
Expand Down Expand Up @@ -380,6 +390,22 @@ func createOne(r record, parentID string, c *client.ConfluenceClient, persist bo
res.broken = append(res.broken, pageContent.Broken...)
res.warnings = append(res.warnings, pageContent.Warnings...)

// --dry-run: preview without creating. The page has no id/URL yet (they stay
// null); every attachment would be a fresh upload, and a new page always has
// its width set. persisted reflects intent — dry_run signals nothing was
// actually written.
if dryRunOpt {
for _, a := range pageContent.Attachments {
res.attachments = append(res.attachments, jsonout.Attachment{Action: "created", Filename: a.Filename})
}
res.width = &jsonout.PageWidth{Value: string(r.width), Default: false}
res.widthSet = true
res.persisted = persist
res.ok = true
res.status = statusCreated
return res
}

result, err := c.CreatePage(r.spaceID, r.title, pageContent.HTML, parentID)
if err != nil {
return res.fail(err, jsonout.CodeFor(err))
Expand Down
18 changes: 17 additions & 1 deletion cmd/create/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ type createResult struct {
file string
ok bool
status string
dryRun bool
pageID string
title string
space string
parent *string
parentFile *string
url string
width *jsonout.PageWidth
widthSet bool
Expand All @@ -38,7 +40,10 @@ type createResult struct {

// newResult seeds a result with the fields known before creation is attempted.
func newResult(r record) *createResult {
return &createResult{file: r.filename, title: r.title, space: r.spaceKey}
return &createResult{
file: r.filename, title: r.title, space: r.spaceKey,
dryRun: dryRunOpt, parentFile: nullableStr(r.parent.display),
}
}

func (r *createResult) fail(err error, code jsonout.Code) *createResult {
Expand Down Expand Up @@ -68,18 +73,26 @@ func (r *createResult) renderHuman() {
if r.widthSet && r.width != nil {
ui.Info(prefix + " page width: " + r.width.Value)
}
// A dry-run has created no page, so there is no id or URL to print; name the
// title and space instead. Every other line above is identical to a real run.
if r.dryRun {
ui.Success(fmt.Sprintf("%s Would create page '%s' in %s", prefix, r.title, r.space))
return
}
ui.Success(fmt.Sprintf("%s Created page %s: %s", prefix, r.pageID, r.url))
}

// jsonCreateResult is create's --json result shape.
type jsonCreateResult struct {
OK bool `json:"ok"`
Status string `json:"status"`
DryRun bool `json:"dry_run"`
File string `json:"file"`
PageID *string `json:"page_id"`
Title *string `json:"title"`
Space *string `json:"space"`
Parent *string `json:"parent"`
ParentFile *string `json:"parent_file"`
URL *string `json:"url"`
PageWidth *jsonout.PageWidth `json:"page_width"`
Persisted bool `json:"persisted"`
Expand All @@ -94,11 +107,13 @@ func (r *createResult) jsonResult() jsonCreateResult {
res := jsonCreateResult{
OK: r.ok,
Status: r.status,
DryRun: r.dryRun,
File: r.file,
PageID: nullableStr(r.pageID),
Title: nullableStr(r.title),
Space: nullableStr(r.space),
Parent: r.parent,
ParentFile: r.parentFile,
URL: nullableStr(r.url),
PageWidth: r.width,
Persisted: r.persisted,
Expand Down Expand Up @@ -191,6 +206,7 @@ func abortedResult(file, status, errMsg string, code jsonout.Code) jsonCreateRes
res := jsonCreateResult{
OK: false,
Status: status,
DryRun: dryRunOpt,
File: file,
Attachments: []jsonout.Attachment{},
Warnings: []string{},
Expand Down
40 changes: 40 additions & 0 deletions cmd/create/json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,22 @@ import (
func TestSchemaConformance(t *testing.T) {
// A normal (non-aborted) create batch.
parent := "123"
parentFile := "index.md"
results := []*createResult{
{
file: "child.md", ok: true, status: statusCreated,
pageID: "456", title: "Child", space: "ENG", parent: &parent, url: "https://x/456",
width: &jsonout.PageWidth{Value: "max", Default: false},
persisted: true,
},
{
// A dry-run of an in-set child: no page id/url yet, parent unresolved
// (null), but the relationship is reported via parent_file.
file: "grandchild.md", ok: true, status: statusCreated, dryRun: true,
title: "Grandchild", space: "ENG", parentFile: &parentFile,
width: &jsonout.PageWidth{Value: "max", Default: false},
persisted: true,
},
(&createResult{file: "bad.md"}).fail(errString("boom"), jsonout.CodeConvert),
}
items := make([]any, len(results))
Expand Down Expand Up @@ -66,11 +75,13 @@ func TestJSONResultCreated(t *testing.T) {
want := `{
"ok": true,
"status": "created",
"dry_run": false,
"file": "child.md",
"page_id": "456",
"title": "Child",
"space": "ENG",
"parent": "123",
"parent_file": null,
"url": "https://wiki.example.net/wiki/spaces/ENG/pages/456/Child",
"page_width": {
"value": "max",
Expand All @@ -88,6 +99,35 @@ func TestJSONResultCreated(t *testing.T) {
}
}

func TestJSONResultDryRunInSetParent(t *testing.T) {
parentFile := "index.md"
r := &createResult{
file: "child.md", ok: true, status: statusCreated, dryRun: true,
title: "Child", space: "ENG", parentFile: &parentFile,
width: &jsonout.PageWidth{Value: "max", Default: false},
persisted: true,
}
j := r.jsonResult()
if !j.DryRun {
t.Errorf("dry_run = false, want true")
}
// No page was created, so id/url are null; parent is unresolved but the
// source file is reported so the relationship is not lost.
if j.PageID != nil || j.URL != nil {
t.Errorf("page_id=%v url=%v, want both nil in a dry-run", j.PageID, j.URL)
}
if j.Parent != nil {
t.Errorf("parent = %v, want nil (in-set parent has no id yet)", j.Parent)
}
if j.ParentFile == nil || *j.ParentFile != "index.md" {
t.Errorf("parent_file = %v, want index.md", j.ParentFile)
}
// persisted reflects intent even though nothing was written.
if !j.Persisted {
t.Errorf("persisted = false, want true (intent)")
}
}

func TestAbortedResultShapes(t *testing.T) {
// A validation-failed file.
failed := abortedResult("bad.md", statusFailed, "no title given", jsonout.CodeValidation)
Expand Down
Loading