Fixing Nextcloud Desktop “desync”: a practical checklist
Note: This report was autogenerated from a agentic session. I apologize for the verbose slop.
- 1. Symptoms you might see
- 2. The mental model (what we learned)
- 3. Rules before you touch anything
- 4. End-to-end workflow
- 5. Folder mapping document
- 6. Script A — compare two trees and emit CSV (generic baseline)
- 7. How to read the CSV
- 8. Optional: deletion history on the server (advanced)
- 9. Curate a manifest you trust
- 10. Script B — copy from backup by manifest (generic baseline)
- 11. What we actually did (short case study)
- 12. If you are still stuck
- License for the scripts
When the Nextcloud desktop client shows conflicts, infinite sync, or folders that exist on your laptop but never appear on the server (or the other way around), it is rarely a single bug. Usually it is a mix of:
- files that never finished uploading,
- intentional deletes on another device or by another account,
- shared folders that live under someone else’s user directory on the server,
- and noise (lock files, temp files, duplicate “conflicted copy” names).
This article is a worked pattern you can reuse: measure ? understand ? curate ? restore safely ? sync again.
It is written for self-hosted Nextcloud where you can SSH to the server and optionally read the database. If you only have the desktop client, you can still do the folder diff + restore from backup parts.
1. Symptoms you might see
- “Not synced” badges, yellow or red overlay icons.
- Files in ~/Nextcloud/... that do not show up in the web UI for the path you expect.
- Duplicates named like something (conflicted copy 2025-...).ext.
- The sync log (~/.local/share/Nextcloud/ on Linux) mentions forbidden, 413, network errors, or stalled transfers (always read the last lines when debugging).
2. The mental model (what we learned)
1. “Local only” is not one problem.
A file that appears only on your laptop may be:
| Situation | Meaning |
|---|---|
| Never uploaded | Created offline, client error, or quota/network; server has no trace. |
| Deleted on the server | Someone (or another device) removed it; your copy is a stray local file. |
| Wrong place | You edited FolderA/file locally, but the share maps to another user’s tree on the server. |
2. Shared folders often sit under another account.
Example: your laptop shows ~/Nextcloud/Mullarx-Dokumente, but on the server the real path is /srv/nextcloud/<other_user>/files/Mullarx-Dokumente. The desktop app still syncs, but backups and diffs must use the actual server path.
3. Prefer activity / history over guessing.
If you have SQL access to the server, the oc_activity table can show who deleted what and when. That turns “why is this local_only?” into an answer instead of a fight with the sync client.
4. Restore from backup is a deliberate act.
Use a manifest (CSV) you have reviewed, not a blind rsync of the whole disk.
3. Rules before you touch anything
- Pause or quit the Nextcloud desktop client before bulk file copies into the sync folder (reduces races and duplicate conflict files). Turn it back on when you are done.
- Do not mass-delete “to fix” sync without a list—you may delete the only copy of something.
- Coordinate if the folder is shared: restoring old files might undo someone else’s intentional cleanup.
4. End-to-end workflow
- Write down local path ?? server path for each top-level folder (see §5).
- Generate a diff between local sync tree and a server view of the same logical folder (SSH mount, clone, or on-server scan) ? CSV (§6, script A).
- Classify rows: local_only, server_only, local_newer, server_newer (§7).
- Optional but powerful: correlate local_only with server deletions (§8).
- Curate a short CSV of paths you actually want to restore (relpath column only) (§9).
- Copy from a trusted backup into the live sync folder using script B (dry-run first).
- Resume the client and verify in the web UI.
5. Folder mapping document
Maintain a small JSON file you trust. Example shape:
{
"local_root": "/home/you/Nextcloud",
"server_datadir": "/srv/nextcloud",
"mappings": [
{
"local_folder": "Shared-Docs",
"server_owner": "alice",
"server_path": "/srv/nextcloud/alice/files/Shared-Docs",
"note": "Received via group share"
}
]
}
Whenever something looks “wrong,” check whether you are comparing the correct server subtree.
6. Script A — compare two trees and emit CSV (generic baseline)
Requirements: Python 3.8+. This walks two directories and prints only differing regular files (no symlinks). Output columns match a simple audit workflow.
Save as tree_diff.py:
#!/usr/bin/env python3
"""
Compare two directory trees (regular files only). Print CSV differences to stdout.
Usage:
python3 tree_diff.py /path/to/local /path/to/server > diff.csv
Status values:
identical -> omitted from output
local_newer -> same path, local mtime newer
server_newer -> same path, server mtime newer
conflict -> same mtime, different size (suspicious)
local_only -> file exists only locally
server_only -> file exists only on server mirror
"""
from __future__ import annotations
import csv
import os
import sys
from typing import Dict, Tuple
FileMeta = Tuple[int, int] # size, mtime
def scan_tree(root: str) -> Dict[str, FileMeta]:
out: Dict[str, FileMeta] = {}
root = os.path.abspath(root)
if not os.path.isdir(root):
raise FileNotFoundError(root)
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
path = os.path.join(dirpath, name)
if os.path.islink(path):
continue
if not os.path.isfile(path):
continue
rel = os.path.relpath(path, root).replace(os.sep, "/")
try:
st = os.stat(path)
except OSError:
continue
out[rel] = (int(st.st_size), int(st.st_mtime))
return dict(sorted(out.items()))
def iso(ts: int) -> str:
from datetime import datetime, timezone
return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone().strftime(
"%Y-%m-%d %H:%M:%S"
)
def main() -> int:
if len(sys.argv) != 3:
print("Usage: tree_diff.py <local_dir> <server_dir>", file=sys.stderr)
return 2
local_root, server_root = sys.argv[1], sys.argv[2]
local_map = scan_tree(local_root)
server_map = scan_tree(server_root)
w = csv.writer(sys.stdout, lineterminator="\n")
w.writerow(
[
"relpath",
"status",
"local_size",
"local_mtime",
"local_mtime_iso",
"server_size",
"server_mtime",
"server_mtime_iso",
]
)
keys = sorted(set(local_map) | set(server_map))
for rel in keys:
L = local_map.get(rel)
R = server_map.get(rel)
if L and R:
if L == R:
continue
ls, lm = L
rs, rm = R
if lm > rm:
status = "local_newer"
elif rm > lm:
status = "server_newer"
elif ls != rs:
status = "conflict"
else:
continue
w.writerow(
[
rel,
status,
ls,
lm,
iso(lm),
rs,
rm,
iso(rm),
]
)
elif L:
ls, lm = L
w.writerow([rel, "local_only", ls, lm, iso(lm), "", "", ""])
elif R:
rs, rm = R
w.writerow([rel, "server_only", "", "", "", rs, rm, iso(rm)])
return 0
if __name__ == "__main__":
raise SystemExit(main())
How to get a “server” directory on your laptop
- SSHFS read-only mount of the Nextcloud datadir (if your admin allows it), or
- rsync/scp a snapshot to a scratch folder, or
- Run the same script on the server against the real .../username/files/... path and transfer the CSV.
Compare apples to apples: the server path must be the same share as the local folder.
7. How to read the CSV
- local_only — Candidate for “restore or upload”; also candidate for “was deleted on server” (check history if you can).
- server_only — Pull down (or inspect) if you need those files on the laptop.
- local_newer — Usually: your edit should win; upload after fixing client issues.
- server_newer — Usually: do not overwrite from an old backup without thinking; the server has a newer version.
Trim noise before acting: ignore things like .DS_Store, Thumbs.db, ~$... Office temps, .~lock...# LibreOffice locks.
8. Optional: deletion history on the server (advanced)
If you administer the instance, oc_activity often contains file_deleted with a full path and timestamp. That helps you relabel some local_only rows as intentionally removed.
If you cannot use SQL, use the web Deleted files app and server audit logs—less complete, but still useful.
(Exact queries depend on your schema version; ask your admin or see Nextcloud documentation for your major version.)
9. Curate a manifest you trust
Open the large diff.csv in a spreadsheet and delete rows you do not want to touch. Export a minimal CSV that still has a header and a relpath column. Name it clearly, e.g. restore-curated.csv.
Only include rows that point to files you want under the sync root again.
10. Script B — copy from backup by manifest (generic baseline)
Save as copy_from_manifest.py. It defaults to dry-run; use --execute to copy.
#!/usr/bin/env python3
"""
Copy files listed in a CSV (column: relpath) from BACKUP_ROOT to DEST_ROOT.
python3 copy_from_manifest.py --csv manifest.csv \\
--backup /path/to/backup/Folder \\
--dest /path/to/Nextcloud/Folder
Add --execute to perform copies. Without it, only prints planned actions.
Skips common junk: LibreOffice locks, Office ~$ temps, .tmp, .DS_Store, etc.
"""
from __future__ import annotations
import argparse
import csv
import os
import shutil
import sys
def skip_reason(rel: str) -> str | None:
base = os.path.basename(rel)
if base.startswith("~$"):
return "office_temp"
if base.startswith(".~lock.") and base.endswith("#"):
return "libreoffice_lock"
if base.lower().endswith(".tmp"):
return "tmp"
if base.startswith("._"):
return "apple_double"
if base == ".DS_Store":
return "ds_store"
if base.lower() == "thumbs.db":
return "thumbs_db"
return None
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--csv", required=True)
p.add_argument("--backup", required=True, help="Root of backed-up tree")
p.add_argument("--dest", required=True, help="Live sync folder root")
p.add_argument("--execute", action="store_true")
args = p.parse_args()
backup = os.path.abspath(args.backup)
dest = os.path.abspath(args.dest)
if not os.path.isdir(backup):
print(f"ERROR: backup root missing: {backup}", file=sys.stderr)
return 1
rows: list[str] = []
with open(args.csv, newline="", encoding="utf-8", errors="replace") as f:
reader = csv.DictReader(f)
if not reader.fieldnames or "relpath" not in reader.fieldnames:
print("ERROR: CSV needs a header row with relpath column.", file=sys.stderr)
return 1
for row in reader:
rel = (row.get("relpath") or "").strip()
if rel:
rows.append(rel)
todo: list[str] = []
skipped = 0
missing = 0
for rel in rows:
if skip_reason(rel):
skipped += 1
continue
src = os.path.join(backup, rel)
if not os.path.isfile(src):
missing += 1
print(f"MISSING in backup: {rel}", file=sys.stderr)
continue
todo.append(rel)
print(f"Planned copies: {len(todo)} (skipped junk: {skipped}, missing: {missing})")
mode = "COPY" if args.execute else "DRY-RUN"
for i, rel in enumerate(todo, 1):
src = os.path.join(backup, rel)
dst = os.path.join(dest, rel)
print(f"[{mode} {i}/{len(todo)}] {src} -> {dst}")
if args.execute and todo:
os.makedirs(dest, exist_ok=True)
for rel in todo:
src = os.path.join(backup, rel)
dst = os.path.join(dest, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
print("Done.")
elif not args.execute and todo:
print("\nDry-run only. Re-run with --execute after verifying paths.")
return 1 if missing else 0
if __name__ == "__main__":
raise SystemExit(main())
Example command (adjust paths):
python3 copy_from_manifest.py \ --csv restore-curated.csv \ --backup /media/backup/2026-04-01/Nextcloud/Shared-Docs \ --dest "$HOME/Nextcloud/Shared-Docs"
When output looks correct:
python3 copy_from_manifest.py \ --csv restore-curated.csv \ --backup /media/backup/2026-04-01/Nextcloud/Shared-Docs \ --dest "$HOME/Nextcloud/Shared-Docs" \ --execute
Then start Nextcloud again and confirm in the browser that files appear under the expected account or share.
11. What we actually did (short case study)
- Mapped each top-level Nextcloud folder to the real server path (some under a different Linux user).
- Built tree diffs between the laptop and a mounted/visible server tree; exported CSV.
- Learned that many “local only” items were consistent with server-side deletes by another user; treated the rest as restore candidates.
- Manually curated a small CSV of files that really needed to be back in sync.
- Copied those files from a trusted local backup into ~/Nextcloud/... and let the client upload.
Your numbers will differ; the sequence is what transfers.

