Description
When a page is moved or renamed in Confluence, confluence-markdown-exporter correctly detects the change and re-exports it to its new path, and it correctly updates that page's export_path in the lock file. However, the old file at the previous path is only deleted if the same run also has at least one page removed from Confluence. If no page was deleted in that run (the common case — most hourly syncs just have renames/moves, or nothing at all), the stale file at the old path is left behind forever, orphaned from the lock file.
Over time (repeated renames/moves across many syncs, with no coinciding deletions) this silently accumulates duplicate/orphaned .md files and attachments in the export tree that no longer correspond to anything in confluence-lock.json.
Root cause
In confluence_markdown_exporter/confluence.py:
def sync_removed_pages(base_url: str) -> None:
"""Orchestrate stale-file cleanup: check API for deleted pages, then clean up."""
if not settings.export.cleanup_stale:
logger.debug("Stale page cleanup disabled — skipping.")
return
unseen = LockfileManager.unseen_ids()
if not unseen:
logger.debug("No unseen pages in lockfile — nothing to clean up.")
return # <-- returns before ever calling remove_pages()
with console.status(f"[dim]Checking {len(unseen)} unseen page(s) for removal…[/dim]"):
deleted = fetch_deleted_page_ids(sorted(unseen), base_url)
if deleted:
logger.info("Removing %d stale page(s) from local export.", len(deleted))
LockfileManager.remove_pages(deleted)
LockfileManager.remove_pages() is the only place that implements the "delete old file when export_path changed" cleanup for moved pages:
# Handle moved pages: delete old file when export_path changed
for page_id in cls._seen_page_ids:
if page_id in cls._all_entries_snapshot:
old_entry = cls._all_entries_snapshot[page_id]
new_entry = cls._lock.get_page(page_id)
if new_entry and old_entry.export_path != new_entry.export_path:
(cls._output_path / old_entry.export_path).unlink(missing_ok=True)
logger.info("Deleted old path for moved page: %s", old_entry.export_path)
A page that was moved/renamed and re-exported in the current run is by definition in _seen_page_ids, so it can never appear in unseen_ids() (which is all_pages().keys() - _seen_page_ids). That means:
- If zero pages happen to be genuinely deleted from Confluence in that same run,
unseen_ids() is empty, sync_removed_pages() returns on line 2976 (per current main), and LockfileManager.remove_pages() is never called — so the moved-page cleanup loop above never executes, even though it has nothing to do with deletions.
- Only if some other, unrelated page was also deleted in the same run does
remove_pages() get invoked, incidentally triggering the moved-page cleanup as a side effect.
So the old-path cleanup for moves/renames is effectively dead code in the overwhelmingly common case of "some pages moved, nothing was deleted."
Minimal repro
- Export a space with
skip_unchanged enabled (default) so the lock file is used.
- Rename or move a page in Confluence (no other page deleted).
- Re-run the export.
Expected: the file at the page's old export path is deleted, only the new path remains.
Actual: both the old and new files exist. The old file is no longer referenced anywhere in confluence-lock.json. Re-running the export again does not fix it, since the lock file's export_path for that page is already up to date and the page keeps being "seen" every run.
We hit this repeatedly on a large real-world space (thousands of pages, hourly CI sync): after enough renames/reorganizations over time, ~57 orphaned pages and ~18 orphaned attachments had accumulated with no error ever surfaced in logs.
Suggested fix
Decouple the "clean up moved pages" pass from the "check for genuinely deleted pages" pass — e.g. always run the _seen_page_ids vs _all_entries_snapshot export-path-diff loop currently inside remove_pages(), regardless of whether unseen_ids() is empty, and only gate the "check API for deleted pages" part behind unseen being non-empty. Something like splitting remove_pages() into two independently-callable steps (cleanup_moved_pages() always called, remove_deleted_pages(deleted_ids) called only when unseen is non-empty).
Diagnostic info
Not applicable to this report — this is a static-analysis finding based on reading confluence_markdown_exporter/utils/lockfile.py and confluence_markdown_exporter/confluence.py on main (confirmed still present as of the date of this issue), plus reproduced behavior against a real Confluence Cloud space via the spenhouet/confluence-markdown-exporter Docker image (version 5.3.0) run on a schedule.
Version
confluence-markdown-exporter 5.3.0 (Docker image spenhouet/confluence-markdown-exporter); confirmed the same logic is still present on the current main branch.
Confluence Version
Confluence Cloud
Jira Version
Jira Cloud
Description
When a page is moved or renamed in Confluence,
confluence-markdown-exportercorrectly detects the change and re-exports it to its new path, and it correctly updates that page'sexport_pathin the lock file. However, the old file at the previous path is only deleted if the same run also has at least one page removed from Confluence. If no page was deleted in that run (the common case — most hourly syncs just have renames/moves, or nothing at all), the stale file at the old path is left behind forever, orphaned from the lock file.Over time (repeated renames/moves across many syncs, with no coinciding deletions) this silently accumulates duplicate/orphaned
.mdfiles and attachments in the export tree that no longer correspond to anything inconfluence-lock.json.Root cause
In
confluence_markdown_exporter/confluence.py:LockfileManager.remove_pages()is the only place that implements the "delete old file whenexport_pathchanged" cleanup for moved pages:A page that was moved/renamed and re-exported in the current run is by definition in
_seen_page_ids, so it can never appear inunseen_ids()(which isall_pages().keys() - _seen_page_ids). That means:unseen_ids()is empty,sync_removed_pages()returns on line 2976 (per currentmain), andLockfileManager.remove_pages()is never called — so the moved-page cleanup loop above never executes, even though it has nothing to do with deletions.remove_pages()get invoked, incidentally triggering the moved-page cleanup as a side effect.So the old-path cleanup for moves/renames is effectively dead code in the overwhelmingly common case of "some pages moved, nothing was deleted."
Minimal repro
skip_unchangedenabled (default) so the lock file is used.Expected: the file at the page's old export path is deleted, only the new path remains.
Actual: both the old and new files exist. The old file is no longer referenced anywhere in
confluence-lock.json. Re-running the export again does not fix it, since the lock file'sexport_pathfor that page is already up to date and the page keeps being "seen" every run.We hit this repeatedly on a large real-world space (thousands of pages, hourly CI sync): after enough renames/reorganizations over time, ~57 orphaned pages and ~18 orphaned attachments had accumulated with no error ever surfaced in logs.
Suggested fix
Decouple the "clean up moved pages" pass from the "check for genuinely deleted pages" pass — e.g. always run the
_seen_page_idsvs_all_entries_snapshotexport-path-diff loop currently insideremove_pages(), regardless of whetherunseen_ids()is empty, and only gate the "check API for deleted pages" part behindunseenbeing non-empty. Something like splittingremove_pages()into two independently-callable steps (cleanup_moved_pages()always called,remove_deleted_pages(deleted_ids)called only whenunseenis non-empty).Diagnostic info
Not applicable to this report — this is a static-analysis finding based on reading
confluence_markdown_exporter/utils/lockfile.pyandconfluence_markdown_exporter/confluence.pyonmain(confirmed still present as of the date of this issue), plus reproduced behavior against a real Confluence Cloud space via thespenhouet/confluence-markdown-exporterDocker image (version 5.3.0) run on a schedule.Version
confluence-markdown-exporter 5.3.0 (Docker image
spenhouet/confluence-markdown-exporter); confirmed the same logic is still present on the currentmainbranch.Confluence Version
Confluence Cloud
Jira Version
Jira Cloud