科技 · 實用指南
Syncthing 衝突檔太多?刪除或合併前先逐一稽核
這套唯讀 Python 稽核會把每個符合規範的 .sync-conflict 副本與可能的原檔配對,計算兩邊 SHA-256,並標記為「完全相同」「內容不同」或「原檔缺失」。它不會刪除、改名或合併任何檔案。

Syncthing 發現無法安全協調的並行修改時,會保留衝突副本。檔名中的日期、時間與裝置 ID 只能說明副本由何時、哪台裝置觸發,不能證明哪份內容應該保留。刪除或改名前,先在所有可連線裝置暫停相關資料夾、停止編輯,並把資料備份到同步目錄之外,再做唯讀清點。
先理解衝突檔名能告訴你的事
Syncthing 現行文件給出的形式類似 `report.sync-conflict-20261011-102030-DEVICE.docx`。時間戳與裝置後綴有助識別副本,但 Syncthing 不理解試算表、資料庫、照片或筆記的內容。時間較新不代表內容較正確;衝突檔也可能傳播到其他裝置,因此在單一電腦直接刪除,不能取代整組核對。
最安全的第一步不在 Syncthing 裡完成:停止編輯,在每台可連線裝置暫停該同步資料夾,並把完整資料複製到同步樹之外。下方稽核只讀取檔案位元組,不查詢或修改 Syncthing 資料庫。
用三種狀態取代一鍵刪除
稽核將每個衝突副本標記為 `identical`、`different` 或 `base-missing`。`identical` 代表衝突副本與推定原檔的 SHA-256 完全一致;`different` 代表兩份都存在但位元組不同;`base-missing` 代表預期的不帶衝突後綴檔案不是一般檔案。這些只是證據標籤,不是自動處理指令。
即使兩份完全相同,也要先確認相對路徑並保留備份,因為同名原檔可能在錯誤資料夾。內容不同時要用建立該檔案的應用程式進行格式感知比較。原檔缺失時,衝突副本可能是僅存的一份,絕不能當成垃圾。
執行已測試的唯讀稽核
把下列內容存成 `syncthing_conflict_audit.py`。它只使用 Python 標準函式庫、略過符號連結、不跟隨連結目錄遞迴,並可將清單寫到同步樹之外。 ```python #!/usr/bin/env python3 """Inventory Syncthing conflict copies without changing any files. The tool pairs filenames of the form ``name.sync-conflict-YYYYMMDD-HHMMSS-DEVICE.ext`` with their likely base file, hashes both when possible, and emits a CSV or JSON manifest. """ from __future__ import annotations import argparse import csv import hashlib import json import os import re import sys from dataclasses import asdict, dataclass from pathlib import Path from typing import Iterable, TextIO CONFLICT_RE = re.compile( r"^(?P<stem>.+)\.sync-conflict-" r"(?P<date>\d{8})-(?P<time>\d{6})-" r"(?P<device>[^./\\]+)(?P<suffix>\.[^/\\]+)?$" ) @dataclass(frozen=True) class AuditRow: conflict_path: str base_path: str status: str conflict_sha256: str base_sha256: str conflict_bytes: int base_bytes: int | None conflict_timestamp: str modified_by: str def parse_conflict_name(name: str) -> tuple[str, str, str] | None: """Return (base filename, compact timestamp, device ID), if recognized.""" match = CONFLICT_RE.match(name) if not match: return None suffix = match.group("suffix") or "" base_name = f"{match.group('stem')}{suffix}" timestamp = f"{match.group('date')}T{match.group('time')}" return base_name, timestamp, match.group("device") def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str: digest = hashlib.sha256() with path.open("rb") as handle: while chunk := handle.read(chunk_size): digest.update(chunk) return digest.hexdigest() def find_conflicts(root: Path) -> Iterable[Path]: """Yield regular conflict files in deterministic order; do not follow links.""" found: list[Path] = [] for directory, dirnames, filenames in os.walk(root, followlinks=False): dirnames.sort() for filename in sorted(filenames): candidate = Path(directory, filename) if parse_conflict_name(filename) and candidate.is_file() and not candidate.is_symlink(): found.append(candidate) yield from sorted(found) def audit(root: Path) -> list[AuditRow]: root = root.resolve() rows: list[AuditRow] = [] for conflict in find_conflicts(root): parsed = parse_conflict_name(conflict.name) assert parsed is not None base_name, timestamp, device = parsed base = conflict.with_name(base_name) conflict_hash = sha256_file(conflict) conflict_size = conflict.stat().st_size if base.is_file() and not base.is_symlink(): base_hash = sha256_file(base) base_size: int | None = base.stat().st_size status = "identical" if conflict_hash == base_hash else "different" else: base_hash = "" base_size = None status = "base-missing" rows.append( AuditRow( conflict_path=str(conflict.relative_to(root)), base_path=str(base.relative_to(root)), status=status, conflict_sha256=conflict_hash, base_sha256=base_hash, conflict_bytes=conflict_size, base_bytes=base_size, conflict_timestamp=timestamp, modified_by=device, ) ) return rows def write_json(rows: list[AuditRow], output: TextIO) -> None: json.dump([asdict(row) for row in rows], output, ensure_ascii=False, indent=2) output.write("\n") def write_csv(rows: list[AuditRow], output: TextIO) -> None: fieldnames = list(AuditRow.__dataclass_fields__) writer = csv.DictWriter(output, fieldnames=fieldnames) writer.writeheader() writer.writerows(asdict(row) for row in rows) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Create a read-only manifest of Syncthing .sync-conflict files." ) parser.add_argument("root", type=Path, help="Folder to scan recursively") parser.add_argument("--format", choices=("csv", "json"), default="csv") parser.add_argument( "--output", type=Path, help="Manifest path; omit to write to standard output", ) return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) if not args.root.is_dir(): print(f"error: not a directory: {args.root}", file=sys.stderr) return 2 rows = audit(args.root) output: TextIO if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) output = args.output.open("w", encoding="utf-8", newline="") else: output = sys.stdout try: if args.format == "json": write_json(rows, output) else: write_csv(rows, output) finally: if args.output: output.close() print( f"Scanned {args.root}: {len(rows)} conflict file(s); no files changed.", file=sys.stderr, ) return 0 if __name__ == "__main__": raise SystemExit(main()) ```
在 PowerShell 或終端機執行 `python syncthing_conflict_audit.py "/同步資料夾路徑" --output "/安全位置/syncthing-conflicts.csv"`。需要 JSON 時加入 `--format json` 並使用 `.json` 輸出檔。不要把清單放進正在診斷的同步資料夾,否則清單本身會成為新的同步變更。
閱讀清單時不要過度推論
先依 `status` 排序,優先查看 `base-missing`。開啟前把這些衝突檔複製到獨立備份。對 `different` 行,應在原應用程式比較原檔與衝突副本;文字可用差異工具,辦公文件、相片目錄、筆記資料庫與壓縮檔則需要對應格式的檢查或匯出。驗證合併或選定結果前,兩份都要保留。
對 `identical` 而言,相同 SHA-256 只代表稽核當下兩份一般檔案的位元組一致。它縮小判斷範圍,但不是刪除許可。確認相對路徑、關閉可能改寫檔案的應用程式,並在同步仍暫停時小批量處理。
裝置後綴與時間戳只是線索
規範中的衝突名稱記錄 Syncthing 建立副本的時間,以及哪台裝置的變更被移入該名稱。裝置時鐘偏差、應用程式儲存方式與後續人工複製都可能讓時間戳誤導。內容比較必須與中繼資料線索分開;若檔案格式自帶修訂歷程,也要一併檢查。
若處理後衝突檔持續出現,請排查多裝置同時編輯、應用程式在開啟期間重寫檔案、裝置時鐘異常,以及把資料庫類檔案當成一般檔案並行同步等原因。修正觸發條件比反覆刪除症狀更重要。
日後可用版本控制,但要記住限制
Syncthing 版本控制按資料夾、按裝置設定。官方文件說明,它保存從其他裝置收到的變更,不會封存同一裝置上的本機修改。因此,開啟版本控制能增加復原層,卻不是完整備份,也不能取代應用程式內歷程或獨立備份。
完成核對並修正觸發原因後,先恢復一台裝置,確認檔案集合符合預期,再逐步恢復其他裝置。把稽核清單與備份存放在一起,留下實際核對過的路徑與雜湊紀錄。
