| .forgejo/workflows | ||
| src | ||
| tests | ||
| .envrc | ||
| .gitignore | ||
| build.rs | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| PLAN.md | ||
| README.md | ||
| spec.md | ||
deescalate
A TUI tool for resolving file sync conflicts, with a focus on Syncthing.
Note: This project is under active development. See
PLAN.mdfor current progress. The documentation below describes the planned feature set.
What It Does
When file synchronization tools like Syncthing detect that a file was modified on multiple devices simultaneously, they create conflict copies rather than silently overwriting changes. This leaves you with files like:
notes.md
notes.sync-conflict-20240115-143052-5CYLJKE.md
deescalate helps you resolve these conflicts by:
- Scanning directories recursively for conflict files
- Analyzing both versions using file-type-specific metadata
- Suggesting automatic resolution when one file is clearly newer or identical
- Showing diffs when manual decision is needed
- Confirming before making any disk changes
Installation
From crates.io
cargo install deescalate
With Nix
nix run github:cyplo/deescalate
Or add to your flake inputs:
{
inputs.deescalate.url = "github:cyplo/deescalate";
}
Usage
deescalate <DIRECTORY>
Scan a directory recursively for conflicts and open the TUI:
deescalate ~/Sync
TUI Controls
| Key | Action |
|---|---|
j/k or ↑/↓ |
Navigate list |
Enter |
Open conflict details |
Space |
Toggle selection |
a |
Auto-resolve selected |
d |
Show diff |
c |
Commit changes |
q |
Quit |
? |
Help |
Supported File Types
| Type | Extensions | Metadata Used | Diff Support |
|---|---|---|---|
| Text | .txt, .md, .json, .yaml, .xml, etc. | Line count, content hash | Full text diff |
| Images | .jpg, .jpeg, .png | EXIF dates, dimensions | Metadata comparison |
| Word | .docx, .doc | Author, modified date, revision | diffoscope |
| Excel | .xlsx, .xls | Author, modified date | diffoscope |
| LibreOffice | .odt, .ods | Author, modified date, edit cycles | diffoscope |
| Author, creation/mod dates | diffoscope | ||
| Unknown | Any other extension | mtime, size, content hash | None (metadata only) |
Unknown file types are handled gracefully using filesystem metadata. You can still resolve conflicts based on modification times, file sizes, and whether the files are identical (via hash comparison).
How Resolution Works
Automatic Resolution
deescalate suggests automatic resolution when:
- One file is clearly newer (modification time differs by >1 hour)
- Files are identical (same content hash)
- One file is empty (keep the non-empty one)
You always confirm before any disk changes are made.
Manual Resolution
When automatic resolution isn't confident, you see:
- Side-by-side metadata comparison
- Full diff view (text files) or structural diff (binary files via diffoscope)
- Options: keep original, keep conflict, keep both, skip
Optional Dependencies
For enhanced diff support:
- difftastic - Syntax-aware diffs for source code
- diffoscope - Deep comparison of binary files (Office docs, PDFs, images)
Without these, deescalate falls back to basic metadata comparison.
Development
Prerequisites
- Rust 1.75+ (uses edition 2024)
- Nix (optional, for reproducible dev environment)
Setup
# With Nix
nix develop
# Or manually
cargo build
Running Tests
This project always uses cargo-nextest for testing. Do not use cargo test.
cargo nextest run
Test Architecture
Tests are data-driven and auto-generated from the tests/test_data/ directory structure:
tests/test_data/
├── text/ # Text file conflict scenarios
│ ├── simple/ # Basic conflict detection
│ ├── multiple/ # Multiple conflicts for same file
│ ├── newer_original/ # Auto-resolution by mtime
│ └── identical/ # Identical content detection
├── ui/ # UI/display tests
│ └── help_text/ # Help instructions visible
└── nested/ # Nested directory handling
Each test case directory contains:
- Conflict files - The actual files to test (original + conflict versions)
expected.toml- Declares what the test should verify
Example expected.toml:
name = "Text file with newer original"
description = "Original is 2 hours newer - should suggest keeping it"
[scan]
conflict_groups = 1
[setup]
# Set file modification times (hours ago)
mtime_hours_ago = { "notes.md" = 0, "notes.sync-conflict-....md" = 2 }
[display]
must_contain = ["notes.md", "1 conflict"]
must_contain_any = [["keep original", "Keep original"]]
To add a new test case: Create a directory under tests/test_data/ with test files and an expected.toml. The test is automatically discovered and run.
Project Structure (Target Architecture)
src/
├── main.rs # Entry point, CLI
├── lib.rs # Library root
├── scanner.rs # Find conflicts by pattern
├── types.rs # Core types
├── filetypes/ # File-type handlers (colocated logic)
│ ├── mod.rs # FileType trait, registry
│ ├── text.rs # Text: metadata, diff, resolution
│ ├── image.rs # Images: EXIF, metadata display
│ ├── office.rs # Office docs: OOXML/ODF metadata
│ ├── pdf.rs # PDF metadata
│ └── unknown.rs # Fallback: mtime, size, hash only
├── resolver.rs # Resolution logic
├── tui/ # Terminal UI
│ ├── app.rs # App state
│ ├── scan_view.rs # Conflict list
│ ├── detail_view.rs # Diff/metadata view
│ └── confirm_view.rs # Confirmation
└── operations.rs # File operations
See PLAN.md for implementation progress.
Adding a New File Type
- Create
src/filetypes/yourtype.rs - Implement the
FileTypetrait:matches()- detect files by extensionextract_metadata()- get comparison datasuggest_resolution()- auto-resolve if confidentgenerate_diff()- produce diff outputrender_details()- TUI display
- Register in
src/filetypes/mod.rs
All file-type logic is colocated in one file for easy understanding and modification.
Syncthing Conflict Format
Syncthing creates conflict files with this naming pattern:
<filename>.sync-conflict-<YYYYMMDD>-<HHMMSS>-<DEVICEID>.<ext>
- Date and time indicate when the conflict was detected
- Device ID (7 chars) identifies which device created the conflicting version
License
AGPL-3.0
Contributing
Contributions welcome! Please read the spec.md for detailed requirements before implementing features.