From 052d837be4d39a85afeb73204065ae4c2e11a964 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Sun, 1 Mar 2026 22:05:49 +0100 Subject: [PATCH 1/4] feat: remove space property requirement from frontmatter --- cmd/diff_test.go | 12 ++++---- cmd/pull_context_test.go | 4 +-- cmd/pull_stash_test.go | 2 +- cmd/pull_test.go | 10 +++---- cmd/push_conflict_test.go | 4 +-- cmd/push_dryrun_test.go | 4 +-- cmd/push_snapshot_test.go | 4 +-- cmd/push_stash_test.go | 13 ++++----- cmd/push_target_test.go | 7 ++--- cmd/push_test.go | 10 +++---- cmd/relink_test.go | 8 +++--- cmd/validate.go | 13 +-------- cmd/validate_test.go | 42 ++++++++++++++-------------- internal/fs/frontmatter.go | 6 ---- internal/fs/frontmatter_test.go | 21 +++++--------- internal/sync/assets_test.go | 2 +- internal/sync/pull_test.go | 6 ++-- internal/sync/push_assets_test.go | 8 +++--- internal/sync/push_lifecycle_test.go | 5 ++-- internal/sync/push_links_test.go | 6 +--- internal/sync/push_rollback_test.go | 6 ++-- internal/sync/push_test.go | 6 ++-- 22 files changed, 82 insertions(+), 117 deletions(-) diff --git a/cmd/diff_test.go b/cmd/diff_test.go index 290b3ef..28ad51a 100644 --- a/cmd/diff_test.go +++ b/cmd/diff_test.go @@ -30,7 +30,7 @@ func TestRunDiff_FileModeShowsContentChanges(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -97,7 +97,7 @@ func TestRunDiff_SpaceModeNoDifferences(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 2, ConfluenceLastModified: "2026-02-01T11:00:00Z", }, @@ -165,7 +165,7 @@ func TestRunDiff_ReportsBestEffortWarnings(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -224,7 +224,7 @@ func TestRunDiff_FolderListFailureFallsBackToPageHierarchy(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -330,7 +330,7 @@ func TestNormalizeDiffMarkdown_StripsReadOnlyMetadata(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "My Page", ID: "42", - Space: "ENG", + Version: 3, CreatedBy: "alice@example.com", CreatedAt: "2026-01-01T00:00:00Z", @@ -395,7 +395,7 @@ func TestRunDiff_FileModeIgnoresMetadataOnlyChanges(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 2, UpdatedBy: "old-user@example.com", UpdatedAt: "2026-01-01T00:00:00Z", diff --git a/cmd/pull_context_test.go b/cmd/pull_context_test.go index 769ce26..2953a6a 100644 --- a/cmd/pull_context_test.go +++ b/cmd/pull_context_test.go @@ -212,7 +212,7 @@ func TestRunPull_ForcePullRefreshesEntireSpace(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", }, @@ -299,7 +299,7 @@ func TestRunPull_ForceFlagRejectedForFileTarget(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", }, diff --git a/cmd/pull_stash_test.go b/cmd/pull_stash_test.go index 922b6d3..bdb642c 100644 --- a/cmd/pull_stash_test.go +++ b/cmd/pull_stash_test.go @@ -100,7 +100,7 @@ func TestRunPull_DiscardLocalFailureRestoresLocalChanges(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", }, diff --git a/cmd/pull_test.go b/cmd/pull_test.go index bb5240b..36b0570 100644 --- a/cmd/pull_test.go +++ b/cmd/pull_test.go @@ -31,7 +31,7 @@ func TestRunPull_RestoresScopedStashAndCreatesTag(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", }, @@ -133,7 +133,7 @@ func TestRunPull_FailureCleanupPreservesStateFile(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", }, @@ -230,7 +230,7 @@ func TestRunPull_NoopDoesNotCreateTag(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 2, CreatedBy: "User author-1", CreatedAt: "2026-02-01T10:00:00Z", @@ -324,7 +324,7 @@ func TestRunPull_RecreatesMissingSpaceDirWithoutRestoringDeletionStash(t *testin Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", }, @@ -398,7 +398,7 @@ func TestRunPull_DraftSpaceListing(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Draft Page", ID: "10", - Space: "ENG", + Version: 1, Status: "draft", }, diff --git a/cmd/push_conflict_test.go b/cmd/push_conflict_test.go index 41dee8f..a295f7e 100644 --- a/cmd/push_conflict_test.go +++ b/cmd/push_conflict_test.go @@ -53,7 +53,7 @@ func TestRunPush_ConflictPolicies(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -121,7 +121,7 @@ func TestRunPush_PullMergeRestoresStashedWorkspaceBeforePull(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, diff --git a/cmd/push_dryrun_test.go b/cmd/push_dryrun_test.go index 906794b..0288b36 100644 --- a/cmd/push_dryrun_test.go +++ b/cmd/push_dryrun_test.go @@ -22,7 +22,6 @@ func TestRunPush_DryRunDoesNotMutateFrontmatter(t *testing.T) { writeMarkdown(t, newFile, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ Title: "New page", - Space: "ENG", }, Body: "new content\n", }) @@ -105,7 +104,6 @@ func TestRunPush_DryRunShowsMarkdownPreviewNotRawADF(t *testing.T) { writeMarkdown(t, newFile, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ Title: "Preview page", - Space: "ENG", }, Body: "hello dry-run\n", }) @@ -154,7 +152,7 @@ func TestRunPush_PreflightShowsPlanWithoutRemoteWrites(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, diff --git a/cmd/push_snapshot_test.go b/cmd/push_snapshot_test.go index bc4b80f..8819634 100644 --- a/cmd/push_snapshot_test.go +++ b/cmd/push_snapshot_test.go @@ -24,7 +24,7 @@ func TestRunPush_UsesStagedTrackedSnapshotContent(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -70,7 +70,7 @@ func TestRunPush_UsesUnstagedTrackedSnapshotContent(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, diff --git a/cmd/push_stash_test.go b/cmd/push_stash_test.go index f9ca8d9..3bf6c6c 100644 --- a/cmd/push_stash_test.go +++ b/cmd/push_stash_test.go @@ -23,7 +23,7 @@ func TestRunPush_IncludesUntrackedAssetsFromWorkspaceSnapshot(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -72,7 +72,7 @@ func TestRunPush_FailureRetainsSnapshotAndSyncBranch(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -137,7 +137,7 @@ func TestRunPush_PreservesOutOfScopeChanges(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -195,7 +195,6 @@ func TestRunPush_DoesNotWarnForSyncedUntrackedFilesInStash(t *testing.T) { writeMarkdown(t, newPagePath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ Title: "New Page", - Space: "ENG", }, Body: "New page content\n", }) @@ -253,7 +252,7 @@ func TestRunPush_FileTargetRestoresUnsyncedScopedTrackedChangesFromStash(t *test Frontmatter: fs.Frontmatter{ Title: "Secondary", ID: "2", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -277,7 +276,7 @@ func TestRunPush_FileTargetRestoresUnsyncedScopedTrackedChangesFromStash(t *test Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -288,7 +287,7 @@ func TestRunPush_FileTargetRestoresUnsyncedScopedTrackedChangesFromStash(t *test Frontmatter: fs.Frontmatter{ Title: "Secondary", ID: "2", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, diff --git a/cmd/push_target_test.go b/cmd/push_target_test.go index 5b9e733..3be362c 100644 --- a/cmd/push_target_test.go +++ b/cmd/push_target_test.go @@ -23,7 +23,7 @@ func TestRunPush_FileModeStillRequiresOnConflict(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -79,7 +79,7 @@ func TestRunPush_FileTargetDetectsWorkspaceChanges(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -120,7 +120,6 @@ func TestRunPush_FileTargetAllowsMissingIDForNewPage(t *testing.T) { writeMarkdown(t, newFile, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ Title: "New page", - Space: "ENG", }, Body: "new content\n", }) @@ -170,7 +169,7 @@ func TestRunPush_SpaceModeAssumesPullMerge(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, diff --git a/cmd/push_test.go b/cmd/push_test.go index 33d7e74..9df5681 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -30,7 +30,7 @@ func TestRunPush_UnresolvedValidationStopsBeforeRemoteWrites(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -88,7 +88,7 @@ func TestRunPush_WritesStructuredCommitTrailers(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -156,7 +156,7 @@ func TestRunPush_KeepsStateFileUntracked(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -266,7 +266,7 @@ func TestRunPush_WorksWithoutGitRemoteConfigured(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -321,7 +321,7 @@ func preparePushRepoWithBaseline(t *testing.T, repo string) string { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, diff --git a/cmd/relink_test.go b/cmd/relink_test.go index 081c016..c293075 100644 --- a/cmd/relink_test.go +++ b/cmd/relink_test.go @@ -27,7 +27,7 @@ func TestRunRelink_NonInteractiveRequiresYesForHighImpactChanges(t *testing.T) { } writeMarkdown(t, filepath.Join(targetDir, "target.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Target", ID: "42", Space: "TGT", Version: 1}, + Frontmatter: fs.Frontmatter{Title: "Target", ID: "42", Version: 1}, Body: "target body\n", }) if err := fs.SaveState(targetDir, fs.SpaceState{ @@ -46,7 +46,7 @@ func TestRunRelink_NonInteractiveRequiresYesForHighImpactChanges(t *testing.T) { for i := 1; i <= 11; i++ { name := fmt.Sprintf("doc-%02d.md", i) writeMarkdown(t, filepath.Join(sourceDir, name), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: fmt.Sprintf("Doc %d", i), ID: fmt.Sprintf("%d", 100+i), Space: "SRC", Version: 1}, + Frontmatter: fs.Frontmatter{Title: fmt.Sprintf("Doc %d", i), ID: fmt.Sprintf("%d", 100+i), Version: 1}, Body: "[Target](https://example.atlassian.net/wiki/pages/viewpage.action?pageId=42)\n", }) sourceState.PagePathIndex[name] = fmt.Sprintf("%d", 100+i) @@ -122,7 +122,7 @@ func TestRunGlobalRelink(t *testing.T) { } writeMarkdown(t, filepath.Join(targetDir, "target.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Target", ID: "42", Space: "TGT", Version: 1}, + Frontmatter: fs.Frontmatter{Title: "Target", ID: "42", Version: 1}, Body: "target body\n", }) if err := fs.SaveState(targetDir, fs.SpaceState{ @@ -135,7 +135,7 @@ func TestRunGlobalRelink(t *testing.T) { } writeMarkdown(t, filepath.Join(sourceDir, "doc.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Doc", ID: "101", Space: "SRC", Version: 1}, + Frontmatter: fs.Frontmatter{Title: "Doc", ID: "101", Version: 1}, Body: "[Target](https://example.atlassian.net/wiki/pages/viewpage.action?pageId=42)\n", }) if err := fs.SaveState(sourceDir, fs.SpaceState{ diff --git a/cmd/validate.go b/cmd/validate.go index 246d511..fc4071c 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -284,9 +284,6 @@ func (r *validateImmutableFrontmatterResolver) validate(absPath string) []fs.Val if id := strings.TrimSpace(baselineFrontmatter.ID); id != "" { previous.ID = id } - if space := strings.TrimSpace(baselineFrontmatter.Space); space != "" { - previous.Space = space - } previous.State = baselineFrontmatter.State } @@ -294,16 +291,8 @@ func (r *validateImmutableFrontmatterResolver) validate(absPath string) []fs.Val return nil } - if strings.TrimSpace(previous.Space) == "" { - if key := strings.TrimSpace(r.state.SpaceKey); key != "" { - previous.Space = key - } else { - previous.Space = r.spaceKey - } - } - if !baselineFound { - // Without reliable prior lifecycle state, enforce ID/space immutability only. + // Without reliable prior lifecycle state, enforce ID immutability only. previous.State = current.State } diff --git a/cmd/validate_test.go b/cmd/validate_test.go index ca4515c..896d986 100644 --- a/cmd/validate_test.go +++ b/cmd/validate_test.go @@ -25,7 +25,7 @@ func TestResolveValidateTargetContext_ResolvesSanitizedSpaceDirectoryByKey(t *te Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "TD", + Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", }, @@ -64,7 +64,7 @@ func TestRunValidateTarget_BlocksTamperedIDAgainstState(t *testing.T) { rootPath := filepath.Join(spaceDir, "root.md") writeMarkdown(t, rootPath, fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Space: "ENG", Version: 1}, + Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Version: 1}, Body: "content\n", }) if err := fs.SaveState(spaceDir, fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}); err != nil { @@ -75,7 +75,7 @@ func TestRunValidateTarget_BlocksTamperedIDAgainstState(t *testing.T) { runGitForTest(t, repo, "commit", "-m", "baseline") writeMarkdown(t, rootPath, fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", ID: "2", Space: "ENG", Version: 1}, + Frontmatter: fs.Frontmatter{Title: "Root", ID: "2", Version: 1}, Body: "content\n", }) @@ -103,7 +103,7 @@ func TestRunValidateTarget_IgnoresSpaceFrontmatter(t *testing.T) { rootPath := filepath.Join(spaceDir, "root.md") writeMarkdown(t, rootPath, fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Space: "ENG", Version: 1}, + Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Version: 1}, Body: "content\n", }) if err := fs.SaveState(spaceDir, fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}); err != nil { @@ -114,7 +114,7 @@ func TestRunValidateTarget_IgnoresSpaceFrontmatter(t *testing.T) { runGitForTest(t, repo, "commit", "-m", "baseline") writeMarkdown(t, rootPath, fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Space: "OPS", Version: 1}, + Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Version: 1}, Body: "content\n", }) @@ -139,7 +139,7 @@ func TestRunValidateTarget_BlocksCurrentToDraftTransition(t *testing.T) { rootPath := filepath.Join(spaceDir, "root.md") writeMarkdown(t, rootPath, fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Space: "ENG", Version: 1, State: "current"}, + Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Version: 1, State: "current"}, Body: "content\n", }) if err := fs.SaveState(spaceDir, fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}); err != nil { @@ -150,7 +150,7 @@ func TestRunValidateTarget_BlocksCurrentToDraftTransition(t *testing.T) { runGitForTest(t, repo, "commit", "-m", "baseline") writeMarkdown(t, rootPath, fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Space: "ENG", Version: 1, State: "draft"}, + Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Version: 1, State: "draft"}, Body: "content\n", }) @@ -178,7 +178,7 @@ func TestRunValidateTarget_AllowsDraftToDraftForExistingDraftPage(t *testing.T) rootPath := filepath.Join(spaceDir, "root.md") writeMarkdown(t, rootPath, fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Space: "ENG", Version: 1, State: "draft"}, + Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Version: 1, State: "draft"}, Body: "content\n", }) if err := fs.SaveState(spaceDir, fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}); err != nil { @@ -189,7 +189,7 @@ func TestRunValidateTarget_AllowsDraftToDraftForExistingDraftPage(t *testing.T) runGitForTest(t, repo, "commit", "-m", "baseline") writeMarkdown(t, rootPath, fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Space: "ENG", Version: 1, State: "draft"}, + Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Version: 1, State: "draft"}, Body: "updated content\n", }) @@ -216,7 +216,7 @@ func TestRunValidateTarget_AllowsNonAssetsMediaReferenceWithinSpace(t *testing.T } writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", Space: "ENG"}, + Frontmatter: fs.Frontmatter{Title: "Root"}, Body: "![image](images/outside.png)\n", }) if err := fs.SaveState(spaceDir, fs.SpaceState{SpaceKey: "ENG"}); err != nil { @@ -248,7 +248,7 @@ func TestRunValidateTarget_AllowsLocalFileLinkAttachment(t *testing.T) { } writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", Space: "ENG"}, + Frontmatter: fs.Frontmatter{Title: "Root"}, Body: "[Manual](assets/manual.pdf)\n", }) if err := fs.SaveState(spaceDir, fs.SpaceState{SpaceKey: "ENG"}); err != nil { @@ -277,7 +277,7 @@ func TestRunValidateTarget_FailsForMissingAssetFile(t *testing.T) { } writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", Space: "ENG"}, + Frontmatter: fs.Frontmatter{Title: "Root"}, Body: "![missing](assets/missing.png)\n", }) if err := fs.SaveState(spaceDir, fs.SpaceState{SpaceKey: "ENG"}); err != nil { @@ -310,7 +310,7 @@ func TestRunValidateTarget_OutsideAssetPathShowsActionableMessage(t *testing.T) } writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", Space: "ENG"}, + Frontmatter: fs.Frontmatter{Title: "Root"}, Body: "![outside](../outside.png)\n", }) if err := fs.SaveState(spaceDir, fs.SpaceState{SpaceKey: "ENG"}); err != nil { @@ -350,7 +350,7 @@ func TestRunValidateTarget_AllowsCrossSpaceEncodedRelativeLink(t *testing.T) { } writeMarkdown(t, filepath.Join(tdDir, "target.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Target", ID: "200", Space: "TD", Version: 1}, + Frontmatter: fs.Frontmatter{Title: "Target", ID: "200", Version: 1}, Body: "target\n", }) if err := fs.SaveState(tdDir, fs.SpaceState{SpaceKey: "TD", PagePathIndex: map[string]string{"target.md": "200"}}); err != nil { @@ -358,7 +358,7 @@ func TestRunValidateTarget_AllowsCrossSpaceEncodedRelativeLink(t *testing.T) { } writeMarkdown(t, filepath.Join(engDir, "root.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", ID: "100", Space: "ENG", Version: 1}, + Frontmatter: fs.Frontmatter{Title: "Root", ID: "100", Version: 1}, Body: "[cross](../Technical%20Docs%20(TD)/target.md)\n", }) if err := fs.SaveState(engDir, fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "100"}}); err != nil { @@ -387,11 +387,11 @@ func TestRunValidateTarget_AllowsLinkToSimultaneousNewPageInSpaceScope(t *testin } writeMarkdown(t, filepath.Join(spaceDir, "Fancy-Extensions.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Fancy Extensions", Space: "ENG"}, + Frontmatter: fs.Frontmatter{Title: "Fancy Extensions"}, Body: "[New page](New-Page.md)\n", }) writeMarkdown(t, filepath.Join(spaceDir, "New-Page.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "New Page", Space: "ENG"}, + Frontmatter: fs.Frontmatter{Title: "New Page"}, Body: "hello\n", }) if err := fs.SaveState(spaceDir, fs.SpaceState{SpaceKey: "ENG"}); err != nil { @@ -420,7 +420,7 @@ func TestRunValidateTargetWithContext_ReturnsCancellation(t *testing.T) { } writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Space: "ENG", Version: 1}, + Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Version: 1}, Body: "content\n", }) if err := fs.SaveState(spaceDir, fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}); err != nil { @@ -451,11 +451,11 @@ func TestRunValidateTarget_BlocksDuplicatePageIDs(t *testing.T) { // Two different files claiming the same Confluence page ID writeMarkdown(t, filepath.Join(spaceDir, "page-a.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Page A", ID: "42", Space: "ENG", Version: 1}, + Frontmatter: fs.Frontmatter{Title: "Page A", ID: "42", Version: 1}, Body: "content a\n", }) writeMarkdown(t, filepath.Join(spaceDir, "page-b.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Page B", ID: "42", Space: "ENG", Version: 1}, + Frontmatter: fs.Frontmatter{Title: "Page B", ID: "42", Version: 1}, Body: "content b\n", }) if err := fs.SaveState(spaceDir, fs.SpaceState{ @@ -538,7 +538,7 @@ func TestRunValidateCommand(t *testing.T) { } writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Space: "ENG", Version: 1}, + Frontmatter: fs.Frontmatter{Title: "Root", ID: "1", Version: 1}, Body: "content\n", }) if err := fs.SaveState(spaceDir, fs.SpaceState{SpaceKey: "ENG", PagePathIndex: map[string]string{"root.md": "1"}}); err != nil { diff --git a/internal/fs/frontmatter.go b/internal/fs/frontmatter.go index 9ad0bcc..3a84690 100644 --- a/internal/fs/frontmatter.go +++ b/internal/fs/frontmatter.go @@ -44,7 +44,6 @@ var ( type Frontmatter struct { Title string ID string - Space string Version int State string Status string @@ -64,7 +63,6 @@ type Frontmatter struct { type frontmatterYAML struct { Title string `yaml:"title,omitempty"` ID string `yaml:"id,omitempty"` - Space string `yaml:"space,omitempty"` Version int `yaml:"version,omitempty"` State string `yaml:"state,omitempty"` Status string `yaml:"status,omitempty"` @@ -129,7 +127,6 @@ func (fm *Frontmatter) UnmarshalYAML(value *yaml.Node) error { fm.Title = strings.TrimSpace(decoded.Title) fm.ID = strings.TrimSpace(decoded.ID) - fm.Space = strings.TrimSpace(decoded.Space) fm.Version = decoded.Version fm.State = strings.TrimSpace(decoded.State) fm.Status = strings.TrimSpace(decoded.Status) @@ -142,9 +139,6 @@ func (fm *Frontmatter) UnmarshalYAML(value *yaml.Node) error { if fm.ID == "" { fm.ID = strings.TrimSpace(decoded.LegacyPageID) } - if fm.Space == "" { - fm.Space = strings.TrimSpace(decoded.LegacySpaceKey) - } if fm.Version == 0 { fm.Version = decoded.LegacyVersion } diff --git a/internal/fs/frontmatter_test.go b/internal/fs/frontmatter_test.go index cdec759..3a65cfa 100644 --- a/internal/fs/frontmatter_test.go +++ b/internal/fs/frontmatter_test.go @@ -71,13 +71,10 @@ Body text. t.Fatalf("formatted output missing metadata keys, got:\n%s", string(out)) } - parsedAgain, err := ParseMarkdownDocument(out) + _, err = ParseMarkdownDocument(out) if err != nil { t.Fatalf("ParseMarkdownDocument(second pass) unexpected error: %v", err) } - if parsedAgain.Frontmatter.Space != "" { - t.Fatalf("Space(second pass) = %q, want empty", parsedAgain.Frontmatter.Space) - } } func TestReadWriteMarkdownDocument(t *testing.T) { @@ -86,9 +83,9 @@ func TestReadWriteMarkdownDocument(t *testing.T) { doc := MarkdownDocument{ Frontmatter: Frontmatter{ - Title: "Test", - ID: "22", - Space: "ENG", + Title: "Test", + ID: "22", + Version: 3, }, Body: "# Body\n", @@ -158,7 +155,7 @@ func TestNormalizeLabels_DedupesAndSorts(t *testing.T) { func TestValidateFrontmatterSchema_InvalidLabels(t *testing.T) { result := ValidateFrontmatterSchema(Frontmatter{ - Space: "ENG", + Labels: []string{"", " ", "ready to review", "tab\tlabel"}, }) if result.IsValid() { @@ -189,12 +186,10 @@ func TestValidateFrontmatterSchema_InvalidLabels(t *testing.T) { func TestValidateImmutableFrontmatter_State(t *testing.T) { previous := Frontmatter{ ID: "1", - Space: "ENG", State: "current", } current := Frontmatter{ ID: "1", - Space: "ENG", State: "draft", } @@ -221,12 +216,10 @@ func TestValidateImmutableFrontmatter_State(t *testing.T) { func TestValidateImmutableFrontmatter(t *testing.T) { previous := Frontmatter{ - ID: "1", - Space: "ENG", + ID: "1", } current := Frontmatter{ - ID: "2", - Space: "OPS", + ID: "2", } result := ValidateImmutableFrontmatter(previous, current) diff --git a/internal/sync/assets_test.go b/internal/sync/assets_test.go index 2bfc48a..4459aa6 100644 --- a/internal/sync/assets_test.go +++ b/internal/sync/assets_test.go @@ -13,7 +13,7 @@ func TestFindOrphanAssets_ReturnsOnlyUnreferencedAssets(t *testing.T) { spaceDir := t.TempDir() if err := fs.WriteMarkdownDocument(filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ - Frontmatter: fs.Frontmatter{Title: "Root", Space: "ENG"}, + Frontmatter: fs.Frontmatter{Title: "Root"}, Body: "![Used](assets/used.png)\n[Doc](assets/used.pdf)\n", }); err != nil { t.Fatalf("write markdown: %v", err) diff --git a/internal/sync/pull_test.go b/internal/sync/pull_test.go index 71276fd..0bc3900 100644 --- a/internal/sync/pull_test.go +++ b/internal/sync/pull_test.go @@ -25,7 +25,7 @@ func TestPull_IncrementalRewriteDeleteAndWatermark(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: strings.TrimSuffix(filepath.Base(relPath), ".md"), ID: pageID, - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", }, @@ -282,7 +282,7 @@ func TestPull_ForceFullPullsAllPagesWithoutIncrementalChanges(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", }, @@ -521,7 +521,7 @@ func TestPull_TrashedRecoveryDeletesLocalPage(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Trashed Page", ID: "10", - Space: "ENG", + Version: 3, State: "trashed", }, diff --git a/internal/sync/push_assets_test.go b/internal/sync/push_assets_test.go index c9ba2a8..a6312c9 100644 --- a/internal/sync/push_assets_test.go +++ b/internal/sync/push_assets_test.go @@ -19,7 +19,7 @@ func TestPush_KeepOrphanAssetsPreservesUnreferencedAttachment(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, }, Body: "content\n", @@ -85,7 +85,7 @@ func TestPush_MigratesLocalRelativeAssetIntoPageHierarchy(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, }, Body: "![diagram](./diagram.png)\n", @@ -154,7 +154,7 @@ func TestPush_UploadsLocalFileLinksAsAttachments(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, }, Body: "[Manual](assets/manual.pdf)\n", @@ -230,7 +230,7 @@ func TestPush_UploadsInlineLocalFileLinksWithoutEmbeddedPlaceholder(t *testing.T Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, }, Body: "Please review [Manual](assets/manual.pdf) before sign-off.\n", diff --git a/internal/sync/push_lifecycle_test.go b/internal/sync/push_lifecycle_test.go index 33aa9dd..71443c0 100644 --- a/internal/sync/push_lifecycle_test.go +++ b/internal/sync/push_lifecycle_test.go @@ -18,7 +18,7 @@ func TestPush_NewPageFailsWhenTrackedPageWithSameTitleExistsInSameDirectory(t *t Frontmatter: fs.Frontmatter{ Title: "Conflict Test Page", ID: "1", - Space: "ENG", + Version: 1, }, Body: "existing\n", @@ -30,7 +30,6 @@ func TestPush_NewPageFailsWhenTrackedPageWithSameTitleExistsInSameDirectory(t *t if err := fs.WriteMarkdownDocument(newPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ Title: "Conflict Test Page", - Space: "ENG", }, Body: "new\n", }); err != nil { @@ -117,7 +116,7 @@ func TestPush_ArchivedRemotePageReturnsActionableError(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, }, Body: "content\n", diff --git a/internal/sync/push_links_test.go b/internal/sync/push_links_test.go index 7711fea..a2d8f98 100644 --- a/internal/sync/push_links_test.go +++ b/internal/sync/push_links_test.go @@ -18,7 +18,6 @@ func TestPush_PreflightStrictFailureSkipsRemoteMutations(t *testing.T) { if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ Title: "New", - Space: "ENG", }, Body: "[Broken](missing.md)\n", }); err != nil { @@ -71,7 +70,6 @@ func TestPush_PreflightStrictResolvesCrossSpaceLinkWithGlobalIndex(t *testing.T) if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ Title: "New", - Space: "ENG", }, Body: "[Cross Space](../Technical%20Docs%20(TD)/target.md)\n", }); err != nil { @@ -83,7 +81,7 @@ func TestPush_PreflightStrictResolvesCrossSpaceLinkWithGlobalIndex(t *testing.T) Frontmatter: fs.Frontmatter{ Title: "Target", ID: "200", - Space: "TD", + Version: 1, }, Body: "target\n", @@ -117,7 +115,6 @@ func TestPush_ResolvesLinksBetweenSimultaneousNewPages(t *testing.T) { if err := fs.WriteMarkdownDocument(filepath.Join(spaceDir, "Fancy-Extensions.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ Title: "Fancy Extensions", - Space: "ENG", }, Body: "[New page](New-Page.md)\n", }); err != nil { @@ -127,7 +124,6 @@ func TestPush_ResolvesLinksBetweenSimultaneousNewPages(t *testing.T) { if err := fs.WriteMarkdownDocument(filepath.Join(spaceDir, "New-Page.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ Title: "New Page", - Space: "ENG", }, Body: "new page body\n", }); err != nil { diff --git a/internal/sync/push_rollback_test.go b/internal/sync/push_rollback_test.go index 3e89e36..1e2f335 100644 --- a/internal/sync/push_rollback_test.go +++ b/internal/sync/push_rollback_test.go @@ -26,7 +26,6 @@ func TestPush_RollbackDeletesCreatedPageAndAttachmentsOnUpdateFailure(t *testing if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ Title: "New", - Space: "ENG", }, Body: "![asset](assets/new.png)\n", }); err != nil { @@ -93,7 +92,7 @@ func TestPush_RollbackRestoresMetadataOnSyncFailure(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, Status: "Ready", Labels: []string{"team"}, @@ -161,7 +160,7 @@ func TestPush_RollbackRestoresPageContentOnPostUpdateFailure(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Updated Title", ID: "1", - Space: "ENG", + Version: 1, Labels: []string{"team"}, }, @@ -246,7 +245,6 @@ func TestPush_DryRunSkipsRollbackAttempts(t *testing.T) { if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ Title: "New", - Space: "ENG", }, Body: "![asset](assets/new.png)\n", }); err != nil { diff --git a/internal/sync/push_test.go b/internal/sync/push_test.go index 5e4dfbe..99b9369 100644 --- a/internal/sync/push_test.go +++ b/internal/sync/push_test.go @@ -19,7 +19,7 @@ func TestPush_BlocksImmutableIDTampering(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "2", - Space: "ENG", + Version: 1, }, Body: "content\n", @@ -57,7 +57,7 @@ func TestPush_IgnoresFrontmatterSpace(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "OPS", + Version: 1, }, Body: "content\n", @@ -99,7 +99,7 @@ func TestPush_BlocksCurrentToDraftTransition(t *testing.T) { Frontmatter: fs.Frontmatter{ Title: "Root", ID: "1", - Space: "ENG", + Version: 1, State: "draft", }, From a612f1e0fef5c599c2b79095e7cec6ae2d3b6fc6 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Mon, 2 Mar 2026 21:37:47 +0100 Subject: [PATCH 2/4] feat: enhance folder management and error handling in sync process --- cmd/dry_run_remote.go | 10 ++ cmd/push_test.go | 28 +++-- internal/confluence/client_errors.go | 3 + internal/confluence/client_pages.go | 3 + internal/confluence/client_pages_write.go | 56 ++++++++++ internal/confluence/types.go | 17 +++ internal/sync/push.go | 18 +++- internal/sync/push_hierarchy.go | 124 +++++++++++++++++++++- internal/sync/push_hierarchy_test.go | 4 + internal/sync/push_page.go | 38 +++++++ internal/sync/push_testhelpers_test.go | 18 +++- internal/sync/push_types.go | 2 + 12 files changed, 305 insertions(+), 16 deletions(-) diff --git a/cmd/dry_run_remote.go b/cmd/dry_run_remote.go index 116c93f..e58bc31 100644 --- a/cmd/dry_run_remote.go +++ b/cmd/dry_run_remote.go @@ -33,6 +33,9 @@ func (d *dryRunPushRemote) GetPage(ctx context.Context, pageID string) (confluen } func (d *dryRunPushRemote) GetContentStatus(ctx context.Context, pageID string) (string, error) { + if strings.HasPrefix(pageID, "dry-run-") { + return "", nil + } return d.inner.GetContentStatus(ctx, pageID) } @@ -48,6 +51,9 @@ func (d *dryRunPushRemote) DeleteContentStatus(ctx context.Context, pageID strin } func (d *dryRunPushRemote) GetLabels(ctx context.Context, pageID string) ([]string, error) { + if strings.HasPrefix(pageID, "dry-run-") { + return nil, nil + } return d.inner.GetLabels(ctx, pageID) } @@ -200,6 +206,10 @@ func (d *dryRunPushRemote) CreateFolder(ctx context.Context, input confluence.Fo }, nil } +func (d *dryRunPushRemote) ListFolders(ctx context.Context, opts confluence.FolderListOptions) (confluence.FolderListResult, error) { + return d.inner.ListFolders(ctx, opts) +} + func (d *dryRunPushRemote) MovePage(ctx context.Context, pageID string, targetID string) error { fmt.Fprintf(d.out, "[DRY-RUN] MOVE PAGE (PUT %s/wiki/rest/api/content/%s/move/append/%s)\n\n", d.domain, pageID, targetID) return nil diff --git a/cmd/push_test.go b/cmd/push_test.go index 9df5681..d44ef70 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -28,8 +28,8 @@ func TestRunPush_UnresolvedValidationStopsBeforeRemoteWrites(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -86,8 +86,8 @@ func TestRunPush_WritesStructuredCommitTrailers(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -154,8 +154,8 @@ func TestRunPush_KeepsStateFileUntracked(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -264,8 +264,8 @@ func TestRunPush_WorksWithoutGitRemoteConfigured(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -319,8 +319,8 @@ func preparePushRepoWithBaseline(t *testing.T, repo string) string { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -520,6 +520,14 @@ func (f *cmdFakePushRemote) CreateFolder(_ context.Context, input confluence.Fol }, nil } +func (f *cmdFakePushRemote) ListFolders(_ context.Context, _ confluence.FolderListOptions) (confluence.FolderListResult, error) { + return confluence.FolderListResult{}, nil +} + +func (f *cmdFakePushRemote) DeleteFolder(_ context.Context, _ string) error { + return nil +} + func (f *cmdFakePushRemote) MovePage(_ context.Context, pageID string, targetID string) error { return nil } diff --git a/internal/confluence/client_errors.go b/internal/confluence/client_errors.go index 25d776f..b0270a4 100644 --- a/internal/confluence/client_errors.go +++ b/internal/confluence/client_errors.go @@ -95,6 +95,9 @@ func isArchivedAPIError(err error) bool { if strings.Contains(combined, "cannot update archived") { return true } + if strings.Contains(combined, "unable to restore content") { + return true + } return false } diff --git a/internal/confluence/client_pages.go b/internal/confluence/client_pages.go index 3cc5f01..d62123f 100644 --- a/internal/confluence/client_pages.go +++ b/internal/confluence/client_pages.go @@ -176,6 +176,9 @@ func (c *Client) ListPages(ctx context.Context, opts PageListOptions) (PageListR if opts.SpaceKey != "" { query.Set("space-key", opts.SpaceKey) } + if opts.Title != "" { + query.Set("title", opts.Title) + } status := opts.Status if status == "" { status = "current" diff --git a/internal/confluence/client_pages_write.go b/internal/confluence/client_pages_write.go index 60b209b..80d81e7 100644 --- a/internal/confluence/client_pages_write.go +++ b/internal/confluence/client_pages_write.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/url" + "strconv" "strings" ) @@ -163,6 +164,61 @@ func (c *Client) CreateFolder(ctx context.Context, input FolderCreateInput) (Fol return payload.toModel(), nil } +func (c *Client) DeleteFolder(ctx context.Context, folderID string) error { + id := strings.TrimSpace(folderID) + if id == "" { + return errors.New("folder ID is required") + } + + req, err := c.newRequest(ctx, http.MethodDelete, "/wiki/api/v2/folders/"+url.PathEscape(id), nil, nil) + if err != nil { + return err + } + if err := c.do(req, nil); err != nil { + if isHTTPStatus(err, http.StatusNotFound) { + return ErrNotFound + } + return err + } + return nil +} + +// ListFolders returns a list of folders. +func (c *Client) ListFolders(ctx context.Context, opts FolderListOptions) (FolderListResult, error) { + query := url.Values{} + if opts.SpaceID != "" { + query.Set("space-id", opts.SpaceID) + } + if opts.Title != "" { + query.Set("title", opts.Title) + } + if opts.Limit > 0 { + query.Set("limit", strconv.Itoa(opts.Limit)) + } + if opts.Cursor != "" { + query.Set("cursor", opts.Cursor) + } + + req, err := c.newRequest(ctx, http.MethodGet, "/wiki/api/v2/folders", query, nil) + if err != nil { + return FolderListResult{}, err + } + + var payload v2ListResponse[folderDTO] + if err := c.do(req, &payload); err != nil { + return FolderListResult{}, err + } + + out := FolderListResult{ + Folders: make([]Folder, 0, len(payload.Results)), + NextCursor: extractCursor(payload.Cursor, payload.Meta.Cursor, payload.Links.Next), + } + for _, item := range payload.Results { + out.Folders = append(out.Folders, item.toModel()) + } + return out, nil +} + func (c *Client) MovePage(ctx context.Context, pageID string, targetID string) error { id := strings.TrimSpace(pageID) if id == "" { diff --git a/internal/confluence/types.go b/internal/confluence/types.go index 0ce9011..3050ffa 100644 --- a/internal/confluence/types.go +++ b/internal/confluence/types.go @@ -37,6 +37,8 @@ type Service interface { WaitForArchiveTask(ctx context.Context, taskID string, opts ArchiveTaskWaitOptions) (ArchiveTaskStatus, error) DeletePage(ctx context.Context, pageID string, hardDelete bool) error CreateFolder(ctx context.Context, input FolderCreateInput) (Folder, error) + ListFolders(ctx context.Context, opts FolderListOptions) (FolderListResult, error) + DeleteFolder(ctx context.Context, folderID string) error MovePage(ctx context.Context, pageID string, targetID string) error } @@ -84,6 +86,7 @@ type Page struct { type PageListOptions struct { SpaceID string SpaceKey string + Title string Status string Limit int Cursor string @@ -104,6 +107,20 @@ type Folder struct { ParentType string } +// FolderListOptions configures folder listing. +type FolderListOptions struct { + SpaceID string + Title string + Limit int + Cursor string +} + +// FolderListResult is a page of folder list results. +type FolderListResult struct { + Folders []Folder + NextCursor string +} + // PageUpsertInput is used for create/update operations. type PageUpsertInput struct { SpaceID string diff --git a/internal/sync/push.go b/internal/sync/push.go index 22b23fc..0515a9f 100644 --- a/internal/sync/push.go +++ b/internal/sync/push.go @@ -52,17 +52,31 @@ func Push(ctx context.Context, remote PushRemote, opts PushOptions) (PushResult, return PushResult{}, fmt.Errorf("list pages: %w", err) } + // Try to list folders, but don't fail the whole push if it's broken (Confluence bug) + remoteFolders, err := listAllPushFolders(ctx, remote, confluence.FolderListOptions{ + SpaceID: space.ID, + }) + if err != nil { + slog.Warn("list_folders_failed_falling_back_to_pages", "error", err.Error()) + remoteFolders = nil + } + pages, err = recoverMissingPages(ctx, remote, space.ID, state.PagePathIndex, pages) if err != nil { return PushResult{}, fmt.Errorf("recover missing pages: %w", err) } remotePageByID := make(map[string]confluence.Page, len(pages)) - for _, page := range pages { remotePageByID[page.ID] = page } + // Also index folders by title for hierarchy reconciliation + remoteFolderByTitle := make(map[string]confluence.Folder) + for _, f := range remoteFolders { + remoteFolderByTitle[strings.ToLower(strings.TrimSpace(f.Title))] = f + } + pageIDByPath, err := BuildPageIndex(spaceDir) if err != nil { return PushResult{}, fmt.Errorf("build page index: %w", err) @@ -511,7 +525,7 @@ func pushUpsertPage( doc.Frontmatter.Version = precreatedPage.Version } else { if dirPath != "" && dirPath != "." { - folderIDByPath, err = ensureFolderHierarchy(ctx, remote, space.ID, dirPath, relPath, pageIDByPath, folderIDByPath, diagnostics) + folderIDByPath, err = ensureFolderHierarchy(ctx, remote, space.ID, dirPath, relPath, opts, pageIDByPath, folderIDByPath, diagnostics) if err != nil { return failWithRollback(fmt.Errorf("ensure folder hierarchy for %s: %w", relPath, err)) } diff --git a/internal/sync/push_hierarchy.go b/internal/sync/push_hierarchy.go index 0933caf..8d7042b 100644 --- a/internal/sync/push_hierarchy.go +++ b/internal/sync/push_hierarchy.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "path/filepath" "sort" "strings" @@ -55,6 +56,7 @@ func ensureFolderHierarchy( remote PushRemote, spaceID, dirPath string, currentRelPath string, + opts PushOptions, pageIDByPath PageIndex, folderIDByPath map[string]string, diagnostics *[]PushDiagnostic, @@ -78,6 +80,13 @@ func ensureFolderHierarchy( currentPath = filepath.ToSlash(filepath.Join(currentPath, seg)) } + if isIndexFile(currentRelPath) { + dirOfCurrent := normalizeRelPath(filepath.ToSlash(filepath.Dir(filepath.FromSlash(currentRelPath)))) + if currentPath == dirOfCurrent { + continue + } + } + if indexParentID, hasIndexParent := indexPageParentIDForDir(currentPath, currentRelPath, pageIDByPath); hasIndexParent { parentID = indexParentID parentType = "page" @@ -90,6 +99,17 @@ func ensureFolderHierarchy( continue } + // Check if folder already exists remotely by title + if f, ok := opts.RemoteFolderByTitle[strings.ToLower(strings.TrimSpace(seg))]; ok { + createdID := strings.TrimSpace(f.ID) + if createdID != "" { + folderIDByPath[currentPath] = createdID + parentID = createdID + parentType = "folder" + continue + } + } + createInput := confluence.FolderCreateInput{ SpaceID: spaceID, Title: seg, @@ -101,7 +121,85 @@ func ensureFolderHierarchy( created, err := remote.CreateFolder(ctx, createInput) if err != nil { - return nil, fmt.Errorf("create folder %q: %w", currentPath, err) + slog.Info("folder_creation_failed", "path", currentPath, "error", err.Error()) + + foundExisting := false + // 1. Try to find it in pre-fetched folders + if f, ok := opts.RemoteFolderByTitle[strings.ToLower(strings.TrimSpace(seg))]; ok { + created = f + err = nil + foundExisting = true + } + + // 2. If not found and it's a conflict, try robust listing + if !foundExisting && strings.Contains(err.Error(), "400") && (strings.Contains(strings.ToLower(err.Error()), "folder exists with the same title") || strings.Contains(strings.ToLower(err.Error()), "already exists with the same title")) { + folders, listErr := listAllPushFolders(ctx, remote, confluence.FolderListOptions{ + SpaceID: spaceID, + Title: seg, + }) + if listErr == nil { + for _, f := range folders { + if strings.EqualFold(strings.TrimSpace(f.Title), strings.TrimSpace(seg)) { + created = f + err = nil + foundExisting = true + break + } + } + } + } + + // 3. Fallback: if it's still failing, check if it exists as a PAGE + if !foundExisting { + pages, listErr := remote.ListPages(ctx, confluence.PageListOptions{ + SpaceID: spaceID, + Title: seg, + Status: "current", + }) + if listErr == nil { + for _, p := range pages.Pages { + if strings.EqualFold(strings.TrimSpace(p.Title), strings.TrimSpace(seg)) { + created = confluence.Folder{ + ID: p.ID, + SpaceID: p.SpaceID, + Title: p.Title, + ParentID: p.ParentPageID, + ParentType: p.ParentType, + } + err = nil + foundExisting = true + break + } + } + } + } + + // 4. Radical fallback: if it's STILL failing, create it as a PAGE + if !foundExisting { + slog.Warn("folder_api_broken_falling_back_to_page", "path", currentPath) + pageCreated, pageErr := remote.CreatePage(ctx, confluence.PageUpsertInput{ + SpaceID: spaceID, + ParentPageID: parentID, + Title: seg, + Status: "current", + BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), + }) + if pageErr == nil { + created = confluence.Folder{ + ID: pageCreated.ID, + SpaceID: pageCreated.SpaceID, + Title: pageCreated.Title, + ParentID: pageCreated.ParentPageID, + ParentType: pageCreated.ParentType, + } + err = nil + foundExisting = true + } + } + + if err != nil { + return nil, fmt.Errorf("create folder %q: %w", currentPath, err) + } } createdID := strings.TrimSpace(created.ID) @@ -424,7 +522,7 @@ func precreatePendingPushPages( dirPath := normalizeRelPath(filepath.ToSlash(filepath.Dir(filepath.FromSlash(relPath)))) if dirPath != "" && dirPath != "." { - folderIDByPath, err = ensureFolderHierarchy(ctx, remote, space.ID, dirPath, relPath, pageIDByPath, folderIDByPath, diagnostics) + folderIDByPath, err = ensureFolderHierarchy(ctx, remote, space.ID, dirPath, relPath, opts, pageIDByPath, folderIDByPath, diagnostics) if err != nil { return nil, fmt.Errorf("ensure folder hierarchy for %s: %w", relPath, err) } @@ -440,7 +538,27 @@ func precreatePendingPushPages( BodyADF: []byte(`{"version":1,"type":"doc","content":[]}`), }) if err != nil { - return nil, fmt.Errorf("create placeholder page for %s: %w", relPath, err) + // If page with this title already exists, try to find it and use its ID + if strings.Contains(err.Error(), "400") && (strings.Contains(strings.ToLower(err.Error()), "title already exists") || strings.Contains(strings.ToLower(err.Error()), "already exists with the same title")) { + // Use specific title filter for efficiency and reliability + pages, listErr := remote.ListPages(ctx, confluence.PageListOptions{ + SpaceID: space.ID, + Title: title, + Status: "current", + }) + if listErr == nil { + for _, p := range pages.Pages { + if strings.EqualFold(strings.TrimSpace(p.Title), strings.TrimSpace(title)) { + created = p + err = nil + break + } + } + } + } + if err != nil { + return nil, fmt.Errorf("create placeholder page for %s: %w", relPath, err) + } } createdID := strings.TrimSpace(created.ID) diff --git a/internal/sync/push_hierarchy_test.go b/internal/sync/push_hierarchy_test.go index 91aa1b0..a9a3577 100644 --- a/internal/sync/push_hierarchy_test.go +++ b/internal/sync/push_hierarchy_test.go @@ -44,6 +44,7 @@ func TestEnsureFolderHierarchy_CreatesMissingFolders(t *testing.T) { "space-1", "Engineering/Backend", "", + PushOptions{}, nil, folderIndex, nil, @@ -74,6 +75,7 @@ func TestEnsureFolderHierarchy_SkipsExistingFolders(t *testing.T) { "space-1", "Engineering/Backend", "", + PushOptions{}, nil, folderIndex, nil, @@ -100,6 +102,7 @@ func TestEnsureFolderHierarchy_EmitsDiagnostics(t *testing.T) { "space-1", "NewFolder", "", + PushOptions{}, nil, folderIndex, &diagnostics, @@ -171,6 +174,7 @@ func TestEnsureFolderHierarchy_UsesIndexPageAsParent(t *testing.T) { "space-1", "Parent/Sub", "Parent/Sub/Child.md", + PushOptions{}, pageIndex, folderIndex, nil, diff --git a/internal/sync/push_page.go b/internal/sync/push_page.go index 2b8ed0e..d6e63ca 100644 --- a/internal/sync/push_page.go +++ b/internal/sync/push_page.go @@ -261,6 +261,16 @@ func normalizePageLifecycleState(state string) string { } func listAllPushPages(ctx context.Context, remote PushRemote, opts confluence.PageListOptions) ([]confluence.Page, error) { + // Try with title filter first if provided + if opts.Title != "" { + res, err := remote.ListPages(ctx, opts) + if err == nil { + return res.Pages, nil + } + // Fallback to full list if title filter failed + opts.Title = "" + } + result := []confluence.Page{} cursor := opts.Cursor for { @@ -277,3 +287,31 @@ func listAllPushPages(ctx context.Context, remote PushRemote, opts confluence.Pa } return result, nil } + +func listAllPushFolders(ctx context.Context, remote PushRemote, opts confluence.FolderListOptions) ([]confluence.Folder, error) { + // Try with title filter first if provided + if opts.Title != "" { + res, err := remote.ListFolders(ctx, opts) + if err == nil { + return res.Folders, nil + } + // Fallback to full list if title filter failed + opts.Title = "" + } + + result := []confluence.Folder{} + cursor := opts.Cursor + for { + opts.Cursor = cursor + folderResult, err := remote.ListFolders(ctx, opts) + if err != nil { + return nil, err + } + result = append(result, folderResult.Folders...) + if strings.TrimSpace(folderResult.NextCursor) == "" || folderResult.NextCursor == cursor { + break + } + cursor = folderResult.NextCursor + } + return result, nil +} diff --git a/internal/sync/push_testhelpers_test.go b/internal/sync/push_testhelpers_test.go index 8247180..c2cde41 100644 --- a/internal/sync/push_testhelpers_test.go +++ b/internal/sync/push_testhelpers_test.go @@ -102,11 +102,19 @@ func (f *fakeFolderPushRemote) CreateFolder(_ context.Context, input confluence. ParentID: input.ParentID, ParentType: input.ParentType, } - f.folders = append(f.folders, created) f.foldersByID[id] = created + f.folders = append(f.folders, created) return created, nil } +func (f *fakeFolderPushRemote) ListFolders(_ context.Context, _ confluence.FolderListOptions) (confluence.FolderListResult, error) { + return confluence.FolderListResult{Folders: append([]confluence.Folder(nil), f.folders...)}, nil +} + +func (f *fakeFolderPushRemote) DeleteFolder(_ context.Context, _ string) error { + return nil +} + func (f *fakeFolderPushRemote) MovePage(_ context.Context, pageID string, targetID string) error { f.moves = append(f.moves, fakePageMove{pageID: pageID, targetID: targetID}) return nil @@ -312,6 +320,14 @@ func (f *rollbackPushRemote) CreateFolder(_ context.Context, input confluence.Fo return confluence.Folder{ID: "folder-1", SpaceID: input.SpaceID, Title: input.Title, ParentID: input.ParentID}, nil } +func (f *rollbackPushRemote) ListFolders(_ context.Context, _ confluence.FolderListOptions) (confluence.FolderListResult, error) { + return confluence.FolderListResult{}, nil +} + +func (f *rollbackPushRemote) DeleteFolder(_ context.Context, _ string) error { + return nil +} + func (f *rollbackPushRemote) MovePage(_ context.Context, pageID string, targetID string) error { return nil } diff --git a/internal/sync/push_types.go b/internal/sync/push_types.go index c1d349b..c5efdd1 100644 --- a/internal/sync/push_types.go +++ b/internal/sync/push_types.go @@ -31,6 +31,7 @@ type PushRemote interface { UploadAttachment(ctx context.Context, input confluence.AttachmentUploadInput) (confluence.Attachment, error) DeleteAttachment(ctx context.Context, attachmentID string, pageID string) error CreateFolder(ctx context.Context, input confluence.FolderCreateInput) (confluence.Folder, error) + ListFolders(ctx context.Context, opts confluence.FolderListOptions) (confluence.FolderListResult, error) MovePage(ctx context.Context, pageID string, targetID string) error } @@ -67,6 +68,7 @@ type PushOptions struct { State fs.SpaceState GlobalPageIndex GlobalPageIndex Changes []PushFileChange + RemoteFolderByTitle map[string]confluence.Folder ConflictPolicy PushConflictPolicy HardDelete bool KeepOrphanAssets bool From bd6b5b0be678b5a4b95dd6a9c4225150882f72dc Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Mon, 2 Mar 2026 22:07:22 +0100 Subject: [PATCH 3/4] test: add coverage for zero-coverage functions in internal/sync Adds targeted tests for outsideSpaceAssetError, PushConflictError.Error, ResolveLinksInSpace, and findClosingBacktickRun to bring internal/sync coverage from 69.5% to 70.8%, satisfying the 70% minimum gate. Co-Authored-By: Claude Sonnet 4.6 --- internal/sync/push_assets_test.go | 32 +++++++++++ internal/sync/push_lifecycle_test.go | 15 +++++ internal/sync/relink_test.go | 83 ++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) diff --git a/internal/sync/push_assets_test.go b/internal/sync/push_assets_test.go index a6312c9..02fe519 100644 --- a/internal/sync/push_assets_test.go +++ b/internal/sync/push_assets_test.go @@ -273,3 +273,35 @@ func TestPush_UploadsInlineLocalFileLinksWithoutEmbeddedPlaceholder(t *testing.T t.Fatalf("expected inline file link conversion to avoid embedded placeholder, body=%s", body) } } + +func TestOutsideSpaceAssetError_ContainsSuggestedPath(t *testing.T) { + spaceDir := t.TempDir() + sourcePath := filepath.Join(spaceDir, "docs", "page.md") + destination := "../../../somewhere/image.png" + + err := outsideSpaceAssetError(spaceDir, sourcePath, destination) + if err == nil { + t.Fatal("expected error, got nil") + } + msg := err.Error() + if !strings.Contains(msg, "image.png") { + t.Errorf("error message missing filename: %q", msg) + } + if !strings.Contains(msg, "assets/") { + t.Errorf("error message missing assets path hint: %q", msg) + } +} + +func TestOutsideSpaceAssetError_EmptyDestination(t *testing.T) { + spaceDir := t.TempDir() + sourcePath := filepath.Join(spaceDir, "page.md") + + err := outsideSpaceAssetError(spaceDir, sourcePath, " ") + if err == nil { + t.Fatal("expected error, got nil") + } + // empty destination should fall back to "file" placeholder + if !strings.Contains(err.Error(), "file") { + t.Errorf("expected 'file' placeholder in message: %q", err.Error()) + } +} diff --git a/internal/sync/push_lifecycle_test.go b/internal/sync/push_lifecycle_test.go index 71443c0..d414c4a 100644 --- a/internal/sync/push_lifecycle_test.go +++ b/internal/sync/push_lifecycle_test.go @@ -211,3 +211,18 @@ func TestPush_DeleteBlocksLocalStateWhenArchiveTaskDoesNotComplete(t *testing.T) t.Fatalf("expected ARCHIVE_TASK_TIMEOUT diagnostic, got %+v", result.Diagnostics) } } + +func TestPushConflictError_Error(t *testing.T) { + err := &PushConflictError{ + Path: "docs/page.md", + PageID: "42", + LocalVersion: 3, + RemoteVersion: 5, + Policy: PushConflictPolicyCancel, + } + got := err.Error() + want := "remote version conflict for docs/page.md (page 42): local=3 remote=5 policy=cancel" + if got != want { + t.Errorf("PushConflictError.Error() = %q, want %q", got, want) + } +} diff --git a/internal/sync/relink_test.go b/internal/sync/relink_test.go index 88c3019..2cee7ef 100644 --- a/internal/sync/relink_test.go +++ b/internal/sync/relink_test.go @@ -278,3 +278,86 @@ func TestBuildGlobalPageIndex(t *testing.T) { t.Errorf("missing or wrong path for 201: %s", p) } } + +func TestResolveLinksInSpace(t *testing.T) { + spaceDir := t.TempDir() + + targetPath := filepath.Join(spaceDir, "target.md") + if err := os.WriteFile(targetPath, []byte("target content"), 0o600); err != nil { + t.Fatal(err) + } + + sourcePath := filepath.Join(spaceDir, "source.md") + content := "[link](https://example.atlassian.net/wiki/pages/viewpage.action?pageId=777)\n" + if err := os.WriteFile(sourcePath, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + // non-.md file should be ignored + if err := os.WriteFile(filepath.Join(spaceDir, "image.png"), []byte("data"), 0o600); err != nil { + t.Fatal(err) + } + + index := GlobalPageIndex{"777": targetPath} + result, err := ResolveLinksInSpace(spaceDir, index, nil, false) + if err != nil { + t.Fatalf("ResolveLinksInSpace: %v", err) + } + if result.FilesSeen != 2 { + t.Errorf("FilesSeen = %d, want 2", result.FilesSeen) + } + if result.FilesChanged != 1 { + t.Errorf("FilesChanged = %d, want 1", result.FilesChanged) + } + if result.LinksConverted != 1 { + t.Errorf("LinksConverted = %d, want 1", result.LinksConverted) + } +} + +func TestResolveLinksInSpace_FiltersByTargetPageIDs(t *testing.T) { + spaceDir := t.TempDir() + + targetPath := filepath.Join(spaceDir, "target.md") + if err := os.WriteFile(targetPath, []byte("target"), 0o600); err != nil { + t.Fatal(err) + } + sourcePath := filepath.Join(spaceDir, "source.md") + content := "[link](https://example.atlassian.net/wiki/pages/viewpage.action?pageId=888)\n" + if err := os.WriteFile(sourcePath, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + index := GlobalPageIndex{"888": targetPath} + + // filter to an ID that is NOT in the content — should result in no changes + result, err := ResolveLinksInSpace(spaceDir, index, map[string]struct{}{"999": {}}, false) + if err != nil { + t.Fatalf("ResolveLinksInSpace: %v", err) + } + if result.FilesChanged != 0 { + t.Errorf("expected no changes when target IDs don't match, got %d", result.FilesChanged) + } +} + +func TestFindClosingBacktickRun(t *testing.T) { + tests := []struct { + name string + content string + start int + run int + want int + }{ + {"finds single backtick", "hello `world` end", 7, 1, 12}, + {"finds triple backtick", "pre ```code``` end", 7, 3, 11}, + {"not found returns -1", "no closing here", 0, 3, -1}, + {"start past end returns -1", "abc", 10, 1, -1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findClosingBacktickRun([]byte(tt.content), tt.start, tt.run) + if got != tt.want { + t.Errorf("findClosingBacktickRun(%q, %d, %d) = %d, want %d", tt.content, tt.start, tt.run, got, tt.want) + } + }) + } +} From 6fdfa3d070b1716366507fef70c863b7ce4907e9 Mon Sep 17 00:00:00 2001 From: Robert Gonek Date: Mon, 2 Mar 2026 22:10:50 +0100 Subject: [PATCH 4/4] style: apply gofmt formatting to all Go source files Co-Authored-By: Claude Sonnet 4.6 --- cmd/diff_test.go | 24 ++++++++++++------------ cmd/pull_context_test.go | 8 ++++---- cmd/pull_stash_test.go | 4 ++-- cmd/pull_test.go | 20 ++++++++++---------- cmd/push_conflict_test.go | 8 ++++---- cmd/push_dryrun_test.go | 4 ++-- cmd/push_snapshot_test.go | 8 ++++---- cmd/push_stash_test.go | 24 ++++++++++++------------ cmd/push_target_test.go | 12 ++++++------ cmd/validate_test.go | 4 ++-- internal/sync/pull_test.go | 12 ++++++------ internal/sync/push_assets_test.go | 16 ++++++++-------- internal/sync/push_lifecycle_test.go | 8 ++++---- internal/sync/push_links_test.go | 4 ++-- internal/sync/push_rollback_test.go | 8 ++++---- internal/sync/push_test.go | 12 ++++++------ 16 files changed, 88 insertions(+), 88 deletions(-) diff --git a/cmd/diff_test.go b/cmd/diff_test.go index 28ad51a..8c38f52 100644 --- a/cmd/diff_test.go +++ b/cmd/diff_test.go @@ -28,8 +28,8 @@ func TestRunDiff_FileModeShowsContentChanges(t *testing.T) { localFile := filepath.Join(spaceDir, "root.md") writeMarkdown(t, localFile, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -95,8 +95,8 @@ func TestRunDiff_SpaceModeNoDifferences(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 2, ConfluenceLastModified: "2026-02-01T11:00:00Z", @@ -163,8 +163,8 @@ func TestRunDiff_ReportsBestEffortWarnings(t *testing.T) { localFile := filepath.Join(spaceDir, "root.md") writeMarkdown(t, localFile, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -222,8 +222,8 @@ func TestRunDiff_FolderListFailureFallsBackToPageHierarchy(t *testing.T) { localFile := filepath.Join(spaceDir, "root.md") writeMarkdown(t, localFile, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -328,8 +328,8 @@ func TestNormalizeDiffMarkdown_StripsReadOnlyMetadata(t *testing.T) { t.Parallel() doc := fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "My Page", - ID: "42", + Title: "My Page", + ID: "42", Version: 3, CreatedBy: "alice@example.com", @@ -393,8 +393,8 @@ func TestRunDiff_FileModeIgnoresMetadataOnlyChanges(t *testing.T) { localFile := filepath.Join(spaceDir, "root.md") writeMarkdown(t, localFile, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 2, UpdatedBy: "old-user@example.com", diff --git a/cmd/pull_context_test.go b/cmd/pull_context_test.go index 2953a6a..f1595ac 100644 --- a/cmd/pull_context_test.go +++ b/cmd/pull_context_test.go @@ -210,8 +210,8 @@ func TestRunPull_ForcePullRefreshesEntireSpace(t *testing.T) { } writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", @@ -297,8 +297,8 @@ func TestRunPull_ForceFlagRejectedForFileTarget(t *testing.T) { filePath := filepath.Join(spaceDir, "root.md") writeMarkdown(t, filePath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", diff --git a/cmd/pull_stash_test.go b/cmd/pull_stash_test.go index bdb642c..c598df9 100644 --- a/cmd/pull_stash_test.go +++ b/cmd/pull_stash_test.go @@ -98,8 +98,8 @@ func TestRunPull_DiscardLocalFailureRestoresLocalChanges(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", diff --git a/cmd/pull_test.go b/cmd/pull_test.go index 36b0570..612f6c9 100644 --- a/cmd/pull_test.go +++ b/cmd/pull_test.go @@ -29,8 +29,8 @@ func TestRunPull_RestoresScopedStashAndCreatesTag(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", @@ -131,8 +131,8 @@ func TestRunPull_FailureCleanupPreservesStateFile(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", @@ -228,8 +228,8 @@ func TestRunPull_NoopDoesNotCreateTag(t *testing.T) { baselineDoc := fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 2, CreatedBy: "User author-1", @@ -322,8 +322,8 @@ func TestRunPull_RecreatesMissingSpaceDirWithoutRestoringDeletionStash(t *testin } writeMarkdown(t, filepath.Join(spaceDir, "Root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", @@ -396,8 +396,8 @@ func TestRunPull_DraftSpaceListing(t *testing.T) { // Page 10 is known locally as a draft writeMarkdown(t, filepath.Join(spaceDir, "draft.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Draft Page", - ID: "10", + Title: "Draft Page", + ID: "10", Version: 1, Status: "draft", diff --git a/cmd/push_conflict_test.go b/cmd/push_conflict_test.go index a295f7e..a3bbb2c 100644 --- a/cmd/push_conflict_test.go +++ b/cmd/push_conflict_test.go @@ -51,8 +51,8 @@ func TestRunPush_ConflictPolicies(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -119,8 +119,8 @@ func TestRunPush_PullMergeRestoresStashedWorkspaceBeforePull(t *testing.T) { writeMarkdown(t, rootPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", diff --git a/cmd/push_dryrun_test.go b/cmd/push_dryrun_test.go index 0288b36..553e64b 100644 --- a/cmd/push_dryrun_test.go +++ b/cmd/push_dryrun_test.go @@ -150,8 +150,8 @@ func TestRunPush_PreflightShowsPlanWithoutRemoteWrites(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", diff --git a/cmd/push_snapshot_test.go b/cmd/push_snapshot_test.go index 8819634..e584e84 100644 --- a/cmd/push_snapshot_test.go +++ b/cmd/push_snapshot_test.go @@ -22,8 +22,8 @@ func TestRunPush_UsesStagedTrackedSnapshotContent(t *testing.T) { writeMarkdown(t, rootPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -68,8 +68,8 @@ func TestRunPush_UsesUnstagedTrackedSnapshotContent(t *testing.T) { writeMarkdown(t, rootPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", diff --git a/cmd/push_stash_test.go b/cmd/push_stash_test.go index 3bf6c6c..6de984b 100644 --- a/cmd/push_stash_test.go +++ b/cmd/push_stash_test.go @@ -21,8 +21,8 @@ func TestRunPush_IncludesUntrackedAssetsFromWorkspaceSnapshot(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -70,8 +70,8 @@ func TestRunPush_FailureRetainsSnapshotAndSyncBranch(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -135,8 +135,8 @@ func TestRunPush_PreservesOutOfScopeChanges(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -250,8 +250,8 @@ func TestRunPush_FileTargetRestoresUnsyncedScopedTrackedChangesFromStash(t *test secondaryPath := filepath.Join(spaceDir, "secondary.md") writeMarkdown(t, secondaryPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Secondary", - ID: "2", + Title: "Secondary", + ID: "2", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -274,8 +274,8 @@ func TestRunPush_FileTargetRestoresUnsyncedScopedTrackedChangesFromStash(t *test rootPath := filepath.Join(spaceDir, "root.md") writeMarkdown(t, rootPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -285,8 +285,8 @@ func TestRunPush_FileTargetRestoresUnsyncedScopedTrackedChangesFromStash(t *test writeMarkdown(t, secondaryPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Secondary", - ID: "2", + Title: "Secondary", + ID: "2", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", diff --git a/cmd/push_target_test.go b/cmd/push_target_test.go index 3be362c..db06675 100644 --- a/cmd/push_target_test.go +++ b/cmd/push_target_test.go @@ -21,8 +21,8 @@ func TestRunPush_FileModeStillRequiresOnConflict(t *testing.T) { writeMarkdown(t, rootFile, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -77,8 +77,8 @@ func TestRunPush_FileTargetDetectsWorkspaceChanges(t *testing.T) { writeMarkdown(t, rootFile, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", @@ -167,8 +167,8 @@ func TestRunPush_SpaceModeAssumesPullMerge(t *testing.T) { writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", diff --git a/cmd/validate_test.go b/cmd/validate_test.go index 896d986..2fdd6e3 100644 --- a/cmd/validate_test.go +++ b/cmd/validate_test.go @@ -23,8 +23,8 @@ func TestResolveValidateTargetContext_ResolvesSanitizedSpaceDirectoryByKey(t *te writeMarkdown(t, filepath.Join(spaceDir, "root.md"), fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T10:00:00Z", diff --git a/internal/sync/pull_test.go b/internal/sync/pull_test.go index 0bc3900..4986d71 100644 --- a/internal/sync/pull_test.go +++ b/internal/sync/pull_test.go @@ -23,8 +23,8 @@ func TestPull_IncrementalRewriteDeleteAndWatermark(t *testing.T) { writeDoc := func(relPath string, pageID string, body string) { doc := fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: strings.TrimSuffix(filepath.Base(relPath), ".md"), - ID: pageID, + Title: strings.TrimSuffix(filepath.Base(relPath), ".md"), + ID: pageID, Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", @@ -280,8 +280,8 @@ func TestPull_ForceFullPullsAllPagesWithoutIncrementalChanges(t *testing.T) { initialDoc := fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, ConfluenceLastModified: "2026-02-01T08:00:00Z", @@ -519,8 +519,8 @@ func TestPull_TrashedRecoveryDeletesLocalPage(t *testing.T) { trashedPath := filepath.Join(spaceDir, "trashed.md") if err := fs.WriteMarkdownDocument(trashedPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Trashed Page", - ID: "10", + Title: "Trashed Page", + ID: "10", Version: 3, State: "trashed", diff --git a/internal/sync/push_assets_test.go b/internal/sync/push_assets_test.go index 02fe519..ec2fe8f 100644 --- a/internal/sync/push_assets_test.go +++ b/internal/sync/push_assets_test.go @@ -17,8 +17,8 @@ func TestPush_KeepOrphanAssetsPreservesUnreferencedAttachment(t *testing.T) { if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, }, @@ -83,8 +83,8 @@ func TestPush_MigratesLocalRelativeAssetIntoPageHierarchy(t *testing.T) { if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, }, @@ -152,8 +152,8 @@ func TestPush_UploadsLocalFileLinksAsAttachments(t *testing.T) { if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, }, @@ -228,8 +228,8 @@ func TestPush_UploadsInlineLocalFileLinksWithoutEmbeddedPlaceholder(t *testing.T if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, }, diff --git a/internal/sync/push_lifecycle_test.go b/internal/sync/push_lifecycle_test.go index d414c4a..3f9e368 100644 --- a/internal/sync/push_lifecycle_test.go +++ b/internal/sync/push_lifecycle_test.go @@ -16,8 +16,8 @@ func TestPush_NewPageFailsWhenTrackedPageWithSameTitleExistsInSameDirectory(t *t existingPath := filepath.Join(spaceDir, "Conflict-Test-Page.md") if err := fs.WriteMarkdownDocument(existingPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Conflict Test Page", - ID: "1", + Title: "Conflict Test Page", + ID: "1", Version: 1, }, @@ -114,8 +114,8 @@ func TestPush_ArchivedRemotePageReturnsActionableError(t *testing.T) { if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, }, diff --git a/internal/sync/push_links_test.go b/internal/sync/push_links_test.go index a2d8f98..7af08ee 100644 --- a/internal/sync/push_links_test.go +++ b/internal/sync/push_links_test.go @@ -79,8 +79,8 @@ func TestPush_PreflightStrictResolvesCrossSpaceLinkWithGlobalIndex(t *testing.T) targetPath := filepath.Join(tdDir, "target.md") if err := fs.WriteMarkdownDocument(targetPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Target", - ID: "200", + Title: "Target", + ID: "200", Version: 1, }, diff --git a/internal/sync/push_rollback_test.go b/internal/sync/push_rollback_test.go index 1e2f335..9f7fad9 100644 --- a/internal/sync/push_rollback_test.go +++ b/internal/sync/push_rollback_test.go @@ -90,8 +90,8 @@ func TestPush_RollbackRestoresMetadataOnSyncFailure(t *testing.T) { if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, Status: "Ready", @@ -158,8 +158,8 @@ func TestPush_RollbackRestoresPageContentOnPostUpdateFailure(t *testing.T) { if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Updated Title", - ID: "1", + Title: "Updated Title", + ID: "1", Version: 1, Labels: []string{"team"}, diff --git a/internal/sync/push_test.go b/internal/sync/push_test.go index 99b9369..6544686 100644 --- a/internal/sync/push_test.go +++ b/internal/sync/push_test.go @@ -17,8 +17,8 @@ func TestPush_BlocksImmutableIDTampering(t *testing.T) { if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "2", + Title: "Root", + ID: "2", Version: 1, }, @@ -55,8 +55,8 @@ func TestPush_IgnoresFrontmatterSpace(t *testing.T) { if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, }, @@ -97,8 +97,8 @@ func TestPush_BlocksCurrentToDraftTransition(t *testing.T) { if err := fs.WriteMarkdownDocument(mdPath, fs.MarkdownDocument{ Frontmatter: fs.Frontmatter{ - Title: "Root", - ID: "1", + Title: "Root", + ID: "1", Version: 1, State: "draft",