Skip to content

Moved/renamed pages leave orphaned old-path files unless another page is also deleted in the same run #278

Description

@baptiste-pasquier

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

  1. Export a space with skip_unchanged enabled (default) so the lock file is used.
  2. Rename or move a page in Confluence (no other page deleted).
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions