科技 · 实用指南
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 的版本控制按文件夹、按设备配置。官方文档说明,它保存的是从其他设备收到的变更,不会归档同一设备上的本地修改。因此,开启版本控制能增加恢复层,却不是完整备份,也不能替代应用内历史或独立备份。
完成核对并修正触发原因后,先恢复一台设备,确认文件集合符合预期,再逐步恢复其他设备。把审计清单与备份放在一起,作为当时实际核过的路径和哈希记录。
