Watch at EaseStories. Answers. Perspective.

Guides · PRACTICAL GUIDE

Obsidian Sync conflict copies: make a byte ledger before deleting duplicates

Inventory a quiet local vault copy, match only documented conflict filenames and compare exact Markdown bytes. The read-only tool flags missing originals and unsupported names; matching hashes never approve deletion.

Original filename illustration pairing a synthetic conflict copy in Notes with Notes/Meeting.md as the exact candidate.
Original editorial illustration: a synthetic same-directory filename pair. It does not show a real vault or Obsidian interface. Watch at Ease

A conflicted copy is a second version to review, not a disposable duplicate. Before removing anything, preserve a local vault copy and compare its exact bytes with the candidate original. Two notes can share a filename or most of their text while containing different edits.

This guide supplies an offline Markdown inventory with three separate outcomes: same bytes, different bytes and missing candidate original. It also lists unsupported conflict names and other identical-byte groups. It never connects to Sync, rewrites links, merges notes or chooses which version is correct.

1. Freeze the evidence before cleaning up

Close the vault and pause editing on the involved devices while you preserve each available local copy through your normal backup method. Keep copies outside an active sync directory. If devices already differ, preserve each separately rather than letting one version replace another during investigation.

The current official help documents two conflict-resolution choices from Obsidian 1.9.7: automatic merging or creating a conflict file. The choice is device-specific. A 2024 forum answer saying Sync does not create conflict files predates that option; it is not the current rule. Ordinary files with a numbered suffix still do not prove a particular sync cause.

2. Understand the exact relationship the scanner recognizes

For a documented conflict filename, the tool derives the candidate original in the same directory by removing the conflict suffix and retaining .md. A device label can contain spaces. The twelve-digit filename timestamp must be a valid calendar date and time; it is not evidence of which version is newest or best.

Names with a different digit count, malformed dates or nested conflict suffixes enter unsupportedNames. The scanner does not guess a replacement path elsewhere in the vault. Generic same-byte files form a separate list, without attributing their creation to Sync. Attachments, links, remote versions and duplicated sections inside an otherwise unique note are outside this inventory.

3. Save and run the full read-only script

Save the complete code below as obsidian_conflict_audit.py in a folder outside the vault. This version is 6292 bytes with SHA-256 79d0b961fe288a39e93f10af21200db69b6d163eb261137b80596c113571c2a2. Use a trusted Python installation; the checked fixtures used Python 3.12.14 on Linux. Copy the code as plain UTF-8 text, keeping its indentation.

```python #!/usr/bin/env python3 """Inventory an idle local vault copy; stdout only, never merge or delete.""" import argparse import hashlib import json import os import re import stat import sys import unicodedata from datetime import datetime from pathlib import Path MAX_FILES = 5000 MAX_ENTRIES = 20000 MAX_FILE = 2 * 1024 * 1024 MAX_TOTAL = 64 * 1024 * 1024 SKIP = {'.obsidian', '.git', '.trash'} MARKER = '(Conflicted copy' PATTERN = re.compile(r'^(.+) \(Conflicted copy (.+) ([0-9]{12})\)\.md$') def signature(s): return (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns) def read_record(path, root, remaining): before = path.lstat() if not stat.S_ISREG(before.st_mode): raise ValueError('Markdown input is not a regular file: ' + str(path)) if before.st_size > MAX_FILE or before.st_size > remaining: raise ValueError('file or total byte limit exceeded') flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) descriptor = os.open(path, flags) with os.fdopen(descriptor, 'rb') as stream: opened = os.fstat(stream.fileno()) if signature(before) != signature(opened): raise ValueError('input changed before reading') data = stream.read(min(MAX_FILE, remaining) + 1) after = os.fstat(stream.fileno()) current = path.lstat() if len(data) > MAX_FILE or len(data) > remaining: raise ValueError('file or total byte limit exceeded') if len(data) != before.st_size or not ( signature(before) == signature(after) == signature(current) ): raise ValueError('input changed while reading') return {'path': path.relative_to(root).as_posix(), 'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest()} def audit(root): root = Path(os.path.abspath(root)) if root.is_symlink() or not root.is_dir(): raise ValueError('vault root must be an existing directory, not a symlink') paths = [] entries_seen = 0 def walk_error(error): raise error for folder, dirs, files in os.walk(root, followlinks=False, onerror=walk_error): dirs[:] = sorted(d for d in dirs if d not in SKIP) entries_seen += len(dirs) + len(files) if entries_seen > MAX_ENTRIES: raise ValueError('scanned directory-entry limit exceeded') for name in dirs + files: child = Path(folder) / name if child.is_symlink(): raise ValueError('symlink in scanned tree: ' + str(child)) for name in sorted(files): if name.lower().endswith('.md'): paths.append(Path(folder) / name) if len(paths) > MAX_FILES: raise ValueError('Markdown file count limit exceeded') paths.sort(key=lambda p: p.relative_to(root).as_posix()) folded = {} for path in paths: relative = path.relative_to(root).as_posix() key = unicodedata.normalize('NFC', relative).casefold() if key in folded: raise ValueError('case/Unicode path collision: ' + relative) folded[key] = relative records = [] total = 0 for path in paths: item = read_record(path, root, MAX_TOTAL - total) records.append(item) total += item['bytes'] indexed = {item['path']: item for item in records} groups = [] unsupported = [] for item in records: path = Path(item['path']) if MARKER.casefold() not in path.name.casefold(): continue match = PATTERN.fullmatch(path.name) if not match: unsupported.append({'path': item['path'], 'reason': 'unsupported name'}) continue original_name, device, stamp = match.groups() try: datetime.strptime(stamp, '%Y%m%d%H%M') except ValueError: unsupported.append({'path': item['path'], 'reason': 'invalid timestamp'}) continue if MARKER.casefold() in original_name.casefold(): unsupported.append({'path': item['path'], 'reason': 'nested conflict name'}) continue original_path = (path.parent / (original_name + '.md')).as_posix() original = indexed.get(original_path) relation = ('ORIGINAL_MISSING' if original is None else 'SAME_BYTES' if original['sha256'] == item['sha256'] else 'DIFFERENT_BYTES') groups.append({'copy': item, 'candidateOriginalPath': original_path, 'candidateOriginal': original, 'deviceLabel': device, 'filenameTimestamp': stamp, 'relation': relation, 'deleteApproved': False}) hashes = {} for item in records: hashes.setdefault(item['sha256'], []).append(item['path']) identical = [{'sha256': digest, 'paths': names} for digest, names in sorted(hashes.items()) if len(names) > 1] return {'status': 'INVENTORY_ONLY', 'writesPerformed': False, 'deleteApproved': False, 'mergeApproved': False, 'scope': 'Markdown files in an idle local vault copy', 'skippedDirectoryNames': sorted(SKIP), 'fileCount': len(records), 'bytesHashed': total, 'files': records, 'conflictCandidates': groups, 'unsupportedNames': unsupported, 'identicalByteGroups': identical, 'limits': {'maxFiles': MAX_FILES, 'maxEntries': MAX_ENTRIES, 'maxFileBytes': MAX_FILE, 'maxTotalBytes': MAX_TOTAL}, 'notVerified': ['remote Sync state', 'note meaning or completeness', 'attachments and links', 'concurrent changes', 'parent-path races or hostile filesystems']} def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('vault', type=Path, help='idle local vault copy') args = parser.parse_args() try: result = audit(args.vault) except (OSError, ValueError) as error: print(json.dumps({'status': 'INCONCLUSIVE', 'writesPerformed': False, 'deleteApproved': False, 'reason': str(error)})) return 2 print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) return 0 if __name__ == '__main__': raise SystemExit(main()) ```

Run the command below with your own absolute path to the idle vault copy. Write the report to a new filename outside that copy. Shell redirection creates the report; the Python program itself only reads files and writes JSON to stdout. Retain any error and the exit status.

```sh python3 obsidian_conflict_audit.py '/path/to/idle-vault-copy' > '/path/outside-vault/audit-01.json' printf '%s\n' "$?" ```

Exit 0 means the inventory finished, including when differences or missing originals exist. Exit 2 reports INCONCLUSIVE after a refused input. Neither exit code is permission to delete. Report paths and device labels can be private; redact them before sharing.

4. Read the three outcomes as review tasks

SAME_BYTES means the candidate and conflict copy have the same SHA-256 in this run. It does not prove their links, remote histories or semantic role are interchangeable. Check backlinks and the intended final filename before any manual cleanup.

DIFFERENT_BYTES means the saved bytes differ. Compare both copies in a text diff and record which edits need to survive. A line-ending change also changes the hash; a shorter file or later timestamp cannot decide the correct content.

ORIGINAL_MISSING means the exact same-directory candidate was absent from the scanned Markdown set. Keep the conflict copy. A moved or renamed original, uppercase extension or another naming convention can produce this result; inspect the file history and directory context before calling the content lost.

For each candidate, keep its path, size, hash, relationship, human decision and the location of a preserved copy. Every record has deleteApproved:false. The ledger supplies evidence for your review and deliberately contains no deletion or merge command.

5. Treat refusals and incomplete scope as stopping points

The scanner refuses a symlink root, symlinks in the scanned tree, non-regular Markdown input and NFC-plus-casefold path collisions. It skips directories named .obsidian, .git and .trash and hashes only Markdown files. These exclusions are scope boundaries, not a clean bill of health for everything in the vault.

It enforces the stated file, entry and byte limits and compares file identity, size and modification time around each read. Observed changes cause refusal. The scan is not an atomic filesystem snapshot and is not a sandbox against malicious parent-path changes or hostile filesystems. Do not run it on a changing live vault.

If a limit, permission error or unsupported name interrupts useful coverage, retain the refusal. Use a smaller separately preserved copy or a reviewed specialist tool; do not remove checks or treat a partial listing as complete. Generic duplicate-folder investigation still requires directory and application review.

6. Recover content through the documented human review path

Use the Sync log's conflict filter and per-file version history to investigate a note. Preview the content before restoring: the official restore action replaces the current note. Deleted or renamed files have a separate history route in Sync settings. History retention varies by plan and file type, so check the current policy rather than assuming indefinite recovery.

Merge needed text into a new review copy, verify that important links and attachments still resolve in Obsidian, and back up the chosen result before cleaning up any originals. Restoring, renaming or deleting a note is a separate human action; this script performs none of them.

7. Know what the tests establish

Twenty synthetic fixtures verified same/different bytes, missing originals without cross-folder guessing, unsupported seconds suffixes, invalid dates, nested names, generic duplicate groups and exclusions. They also checked root/file/directory symlink refusals, case and Unicode collisions, file/count/entry/total limits, changes between lstat and open, CLI exits 0/2 and unchanged source bytes.

Those results test this inventory implementation, not Obsidian's sync engine. Windows and macOS behavior, remote state, mobile devices, GUI rendering and real conflict recovery were not exercised. Close the incident only after your own content review and preserved-copy checks, recording any remaining untested files or devices.

Frequently asked questions

Can I delete every SAME_BYTES copy? No. The hash establishes byte equality during this scan, not the role of the filename, backlinks or remote history.

Will this find duplicated paragraphs inside one note? No. It inventories complete Markdown files; duplicated internal sections require a content diff or version-history review.

Why did a fourteen-digit conflict name remain unsupported? This version recognizes only the current official twelve-digit pattern. It retains other marker-bearing names for manual review instead of guessing.

Does ORIGINAL_MISSING prove the original was deleted? No. Moving, renaming, extension case and excluded directories can also prevent an exact match.

Does exit 0 mean Sync is repaired? No. It means a local inventory completed. The report never authorizes a merge, restore or deletion.

Original illustration of SAME_BYTES, DIFFERENT_BYTES and ORIGINAL_MISSING, plus the unsupported-name review queue.
Original editorial illustration: three review outcomes and unsupported names. Matching bytes never approve deleting a note. Watch at Ease