실용 가이드 · 실용 가이드
Obsidian Sync 충돌 사본: 중복 노트를 지우기 전에 바이트 기록표 만들기
변경이 멈춘 로컬 보관함 사본을 조사하고 공식 충돌 파일명만 연결해 Markdown 원본 바이트를 비교한다. 읽기 전용 도구는 원본 후보 누락과 미지원 이름을 표시한다. 해시가 같아도 삭제를 승인하지 않는다.

충돌 사본은 검토해야 할 두 번째 버전이며, 버려도 되는 중복 파일이 아니다. 무엇이든 삭제하기 전에 로컬 보관함 사본을 보존하고 원본 후보와 정확한 바이트를 비교해야 한다. 이름이 같거나 본문 대부분이 같은 노트에도 서로 다른 수정 내용이 남아 있을 수 있다.
이 가이드는 바이트가 같음, 바이트가 다름, 원본 후보 없음이라는 세 가지 결과를 분리하는 오프라인 Markdown 목록을 제공한다. 미지원 충돌 이름과 그 밖의 동일 바이트 파일 묶음도 표시한다. Sync에 접속하거나 링크를 바꾸거나 노트를 병합하지 않으며, 올바른 버전을 선택하지도 않는다.
1. 정리하기 전에 증거를 보존한다
평소 사용하는 백업 방식으로 각 기기의 로컬 사본을 보존하는 동안 보관함을 닫고 관련 기기에서 편집을 멈춘다. 사본은 활성 동기화 디렉터리 밖에 둔다. 기기 사이에 이미 차이가 있다면 조사 중 한 버전이 다른 버전을 덮어쓰게 하지 말고 각각 보존한다.
현재 공식 도움말은 Obsidian 1.9.7부터 자동 병합과 충돌 파일 생성 중 선택할 수 있다고 설명한다. 설정은 기기별로 적용된다. 2024년 포럼의 Sync는 충돌 파일을 만들지 않는다는 답변은 이 기능보다 이전의 설명이며, 현재 규칙이 아니다. 숫자 접미사가 붙은 일반 파일도 특정 동기화 원인을 입증하지 않는다.
2. 도구가 인식하는 정확한 관계를 이해한다
공식 형식에 맞는 충돌 이름에서는 충돌 접미사를 제거하고 .md를 유지해 같은 디렉터리의 원본 후보를 계산한다. 기기 이름에는 공백이 들어갈 수 있다. 열두 자리 파일명 시각은 유효한 달력 날짜와 시간이어야 하지만, 어느 버전이 더 새롭거나 더 나은지 보여 주는 증거는 아니다.
자리 수가 다르거나 날짜가 잘못됐거나 충돌 접미사가 중첩된 이름은 unsupportedNames에 들어간다. 보관함의 다른 위치에서 대체 경로를 추측하지 않는다. 일반 동일 바이트 파일은 별도 목록에 넣고 생성 원인을 Sync로 단정하지 않는다. 첨부 파일, 링크, 원격 버전, 하나의 고유 노트 내부에 중복된 문단은 이 목록의 범위 밖이다.
3. 읽기 전용 전체 스크립트를 저장해 실행한다
아래 전체 코드를 보관함 밖의 obsidian_conflict_audit.py로 저장한다. 이 버전은 6292바이트이고 SHA-256은 79d0b961fe288a39e93f10af21200db69b6d163eb261137b80596c113571c2a2이다. 신뢰할 수 있는 Python 설치를 사용한다. 테스트 환경은 Linux·Python 3.12.14였다. 들여쓰기를 유지하며 UTF-8 일반 텍스트로 복사한다.
```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()) ```
아래 명령에 변경이 멈춘 보관함 사본의 절대 경로를 넣는다. 보고서는 그 사본 밖의 새 파일명에 저장한다. 셸 리디렉션이 보고서 파일을 만들며, Python 프로그램 자체는 파일을 읽고 표준 출력에 JSON을 보낼 뿐이다. 오류와 종료 코드를 보존한다.
```sh python3 obsidian_conflict_audit.py '/path/to/idle-vault-copy' > '/path/outside-vault/audit-01.json' printf '%s\n' "$?" ```
종료 코드 0은 차이나 원본 누락이 있어도 목록 작성이 완료됐음을 뜻한다. 코드 2는 입력을 거부한 뒤 INCONCLUSIVE를 반환한다. 어느 코드도 삭제 권한이 아니다. 보고서의 경로와 기기 이름은 개인정보일 수 있으므로 공유 전에 가린다.
4. 세 결과를 검토 작업으로 읽는다
SAME_BYTES는 이번 실행에서 원본 후보와 충돌 사본의 SHA-256이 같다는 뜻이다. 링크, 원격 이력, 의미상 역할을 서로 바꿔도 된다는 증명은 아니다. 수동 정리 전에 백링크와 최종적으로 남길 파일명을 확인한다.
DIFFERENT_BYTES는 저장된 바이트가 다르다는 뜻이다. 텍스트 차이 도구로 두 사본을 비교하고 남겨야 할 수정을 기록한다. 줄바꿈 형식만 바뀌어도 해시가 달라진다. 파일이 더 짧거나 시각이 더 늦다는 이유로 내용의 정답을 결정할 수 없다.
ORIGINAL_MISSING은 스캔한 Markdown 집합에 같은 디렉터리의 정확한 원본 후보가 없었다는 뜻이다. 충돌 사본을 보존한다. 원본 이동·이름 변경, 대문자 확장자, 다른 명명 규칙도 이 결과를 만들 수 있다. 내용이 유실됐다고 판단하기 전에 파일 이력과 디렉터리 맥락을 확인한다.
후보마다 경로, 크기, 해시, 관계, 사람의 결정, 보존한 사본 위치를 남긴다. 각 기록에는 deleteApproved:false가 붙는다. 기록표는 검토를 위한 증거를 제공하며, 삭제나 병합 명령을 의도적으로 포함하지 않는다.
5. 거부나 범위 부족이 있으면 멈춘다
도구는 심볼릭 링크 루트, 스캔 트리의 심볼릭 링크, 일반 파일이 아닌 Markdown 입력, NFC 정규화와 대소문자 접기 후 충돌하는 경로를 거부한다. .obsidian, .git, .trash라는 디렉터리를 제외하고 Markdown만 해시 계산한다. 제외는 범위의 한계이며 보관함 전체가 정상이라는 증명이 아니다.
명시한 파일 수·항목 수·바이트 한도를 적용하고, 각 읽기 전후에 파일 식별 정보·크기·수정 시각을 비교한다. 관찰된 변화가 있으면 거부한다. 그러나 원자적 파일 시스템 스냅샷도 아니며, 악의적인 상위 경로 변경이나 적대적 파일 시스템을 막는 샌드박스도 아니다. 계속 바뀌는 실제 보관함에는 실행하지 않는다.
한도, 권한 오류, 미지원 이름 때문에 필요한 범위를 확인하지 못하면 거부 결과를 보존한다. 따로 보존한 작은 사본이나 검토한 전문 도구를 사용한다. 검사를 없애거나 부분 목록을 전체로 간주하지 않는다. 일반 중복 폴더에는 디렉터리와 애플리케이션 검토도 필요하다.
6. 공식 수동 검토 경로로 내용을 복구한다
Sync 로그의 충돌 필터와 파일별 버전 이력으로 노트를 조사한다. 공식 복원은 현재 노트를 교체하므로 먼저 내용을 미리 본다. 삭제되거나 이름이 바뀐 파일은 Sync 설정에 별도 이력 경로가 있다. 보존 기간은 요금제와 파일 종류에 따라 다르므로 무기한 복구를 가정하지 말고 현재 정책을 확인한다.
필요한 글을 새 검토 사본에 병합하고 중요한 링크와 첨부 파일이 Obsidian에서 열리는지 확인한다. 선택한 결과를 백업한 뒤 원본을 정리한다. 복원·이름 변경·삭제는 별도의 수동 작업이며 이 스크립트는 수행하지 않는다.
7. 테스트가 입증한 범위를 안다
모의 테스트 20개는 같은·다른 바이트, 다른 폴더를 추측하지 않는 원본 누락, 미지원 초 단위 접미사, 잘못된 날짜, 중첩 이름, 일반 중복 묶음과 제외를 검증했다. 루트·파일·디렉터리 링크 거부, 대소문자·Unicode 충돌, 파일당·파일 수·항목 수·총 바이트 한도, lstat와 open 사이의 변화, CLI 코드 0/2, 원본 바이트 유지도 확인했다.
이 결과는 목록 도구 구현을 테스트한 것이며 Obsidian 동기화 엔진을 테스트한 것은 아니다. Windows, macOS, 원격 상태, 모바일 기기, 화면 표시, 실제 충돌 복구는 실행하지 않았다. 자신의 내용 검토와 보존 사본 확인을 마친 뒤에 조사 종료를 결정하고, 미검증 파일과 기기를 기록한다.
자주 묻는 질문
SAME_BYTES 사본을 전부 지워도 되나요? 아니요. 해시는 스캔 당시의 바이트 일치를 보여 줄 뿐, 파일명의 역할·백링크·원격 이력을 입증하지 않습니다.
한 노트 안의 중복 문단을 찾나요? 아니요. 전체 Markdown 파일을 목록화합니다. 내부 중복은 내용 차이나 버전 이력을 검토해야 합니다.
열네 자리 충돌 이름은 왜 미지원인가요? 이 버전은 현재 공식 열두 자리 형식만 인식합니다. 다른 충돌 표식 이름은 추측하지 않고 수동 검토에 남깁니다.
ORIGINAL_MISSING이면 원본이 삭제된 건가요? 아니요. 이동·이름 변경·확장자 대소문자·제외 디렉터리도 정확한 일치를 막을 수 있습니다.
종료 코드 0이면 Sync가 복구됐나요? 아니요. 로컬 목록이 완료됐다는 뜻이며, 보고서는 병합·복원·삭제를 승인하지 않습니다.
