cinna-cli 0.1.1__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/.github/workflows/publish.yml +4 -4
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/PKG-INFO +1 -1
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/docs/README.md +9 -2
- cinna_cli-0.1.3/docs/interface.md +160 -0
- cinna_cli-0.1.3/docs/mutagen_capabilities.md +248 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/pyproject.toml +1 -1
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/main.py +1 -1
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/sync_session.py +77 -8
- cinna_cli-0.1.3/src/cinna/sync_tui.py +858 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_main.py +1 -1
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/uv.lock +1 -1
- cinna_cli-0.1.1/src/cinna/sync_tui.py +0 -352
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/.gitignore +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/LICENSE.md +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/README.md +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/__init__.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/auth.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/bootstrap.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/client.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/config.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/console.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/context.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/errors.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/logging.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/mcp_proxy.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/mutagen_runtime.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/sync.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/sync_ssh_shim.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/templates/CLAUDE.md.template +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/src/cinna/templates/__init__.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/__init__.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/conftest.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_auth.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_bootstrap.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_client.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_config.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_context.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_mutagen_runtime.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_sync.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_sync_session.py +0 -0
- {cinna_cli-0.1.1 → cinna_cli-0.1.3}/tests/test_sync_ssh_shim.py +0 -0
|
@@ -11,10 +11,10 @@ jobs:
|
|
|
11
11
|
name: Build distribution
|
|
12
12
|
runs-on: ubuntu-latest
|
|
13
13
|
steps:
|
|
14
|
-
- uses: actions/checkout@
|
|
14
|
+
- uses: actions/checkout@v6
|
|
15
15
|
|
|
16
16
|
- name: Install uv
|
|
17
|
-
uses: astral-sh/setup-uv@
|
|
17
|
+
uses: astral-sh/setup-uv@v8.1.0
|
|
18
18
|
with:
|
|
19
19
|
python-version: "3.11"
|
|
20
20
|
|
|
@@ -25,7 +25,7 @@ jobs:
|
|
|
25
25
|
run: uv tool run --from twine twine check dist/*
|
|
26
26
|
|
|
27
27
|
- name: Upload dist
|
|
28
|
-
uses: actions/upload-artifact@
|
|
28
|
+
uses: actions/upload-artifact@v7
|
|
29
29
|
with:
|
|
30
30
|
name: dist
|
|
31
31
|
path: dist/
|
|
@@ -41,7 +41,7 @@ jobs:
|
|
|
41
41
|
id-token: write
|
|
42
42
|
steps:
|
|
43
43
|
- name: Download dist
|
|
44
|
-
uses: actions/download-artifact@
|
|
44
|
+
uses: actions/download-artifact@v8
|
|
45
45
|
with:
|
|
46
46
|
name: dist
|
|
47
47
|
path: dist/
|
|
@@ -195,6 +195,7 @@ main.py (CLI commands — Click)
|
|
|
195
195
|
├── client.py — PlatformClient: HTTP + SSE stream_exec
|
|
196
196
|
├── mutagen_runtime.py — detect/install Mutagen; gate on version match
|
|
197
197
|
├── sync_session.py — wrap the `mutagen` CLI (start/stop/status/conflicts)
|
|
198
|
+
├── sync_tui.py — live Textual TUI shown by `cinna dev` (Sync/Details/Conflicts tabs)
|
|
198
199
|
├── sync_ssh_shim.py — `cinna-sync-ssh` entry point (WebSocket transport)
|
|
199
200
|
├── sync.py — tarball/zip extraction helpers (initial clone only)
|
|
200
201
|
├── context.py — CLAUDE.md, BUILDING_AGENT.md, .mcp.json, opencode.json
|
|
@@ -304,7 +305,12 @@ Opening the sync WebSocket keeps the remote env warm. The platform heartbeats `l
|
|
|
304
305
|
|
|
305
306
|
### Conflicts
|
|
306
307
|
|
|
307
|
-
Mutagen
|
|
308
|
+
Mutagen 0.18.1 in `two-way-safe` records conflicts in its session state (the `conflicts[]` array of `mutagen sync list --template '{{json .}}'`) but, contrary to older mutagen behavior, does **not** write `<name>.conflict.<side>.<timestamp>` files to disk — both sides simply retain their divergent content. See [`mutagen_capabilities.md` §7](./mutagen_capabilities.md#7--two-way-safe-does-not-write-conflictsidets-files) for the empirical proof and re-verification steps.
|
|
309
|
+
|
|
310
|
+
Two surfaces expose conflicts:
|
|
311
|
+
|
|
312
|
+
- **`cinna sync conflicts`** — a read-only Click subcommand that walks the workspace for any `*.conflict.*` files mutagen *does* end up writing (other sync modes, or future mutagen versions, may still produce them). Implementation lives in `sync_session.list_conflicts`.
|
|
313
|
+
- **The Conflicts tab in `cinna dev`** — sourced from mutagen's JSON `conflicts[]`, always populated when a conflict exists. The user navigates the list with `↑`/`↓` and resolves with `1` (take REMOTE) or `5` (take LOCAL). Resolution mechanism: delete the file on the losing side (locally with `unlink()`, remotely via `cinna exec rm`) then `mutagen sync reset <session>`; mutagen sees one side empty, no common ancestor, and propagates the survivor. Verified against 0.18.1; see [`interface.md`](./interface.md) for the full element-to-mutagen-capability mapping.
|
|
308
314
|
|
|
309
315
|
---
|
|
310
316
|
|
|
@@ -428,6 +434,7 @@ uv run ruff format --check src/
|
|
|
428
434
|
| WebSocket client | websockets | Minimal dep for the sync shim |
|
|
429
435
|
| Sync engine | Mutagen | Battle-tested bidirectional sync; OSS; cross-platform |
|
|
430
436
|
| Terminal | Rich | Spinners, panels, tables |
|
|
437
|
+
| Live TUI | Textual | Three-tab interactive view (`cinna dev`); built on Rich |
|
|
431
438
|
| MCP | mcp SDK | Official protocol SDK |
|
|
432
439
|
| Tests | pytest + respx | Standard; respx is purpose-built for httpx |
|
|
433
440
|
| Build | Hatchling | Minimal, standards-compliant |
|
|
@@ -440,7 +447,7 @@ uv run ruff format --check src/
|
|
|
440
447
|
- **Interactive stdin for `cinna exec`** — REPLs, debuggers.
|
|
441
448
|
- **Port forwarding** — `cinna forward local:remote` (Mutagen supports it).
|
|
442
449
|
- **Post-sync hooks** — e.g. `uv sync` on `pyproject.toml` change.
|
|
443
|
-
- **Web UI conflict resolution** — TUI
|
|
450
|
+
- **Web UI conflict resolution** — the in-TUI Conflicts tab covers the common case; editor-based 3-way merge stays the fallback for ones the TUI can't handle (e.g. asymmetric directory/file conflicts).
|
|
444
451
|
- **Multi-device presence UI**.
|
|
445
452
|
- **Bundled Mutagen daemon** — stay with a user-installed daemon.
|
|
446
453
|
- **Telemetry pipe** to the backend.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# cinna sync TUI — Interface Reference
|
|
2
|
+
|
|
3
|
+
> Maps every visible element of the live sync TUI (the screen shown by `cinna dev`) to the underlying Mutagen capability it depends on. Use this together with [`mutagen_capabilities.md`](./mutagen_capabilities.md) when bumping Mutagen versions: each feature here points to the section that proves the capability still works.
|
|
4
|
+
|
|
5
|
+
The TUI is implemented in `src/cinna/sync_tui.py` and rendered by [Textual](https://textual.textualize.io/). It opens when the user runs `cinna dev` and closes on `q` / Ctrl-C; closing the TUI terminates the Mutagen sync session.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Layout
|
|
10
|
+
|
|
11
|
+
Three tabs, switched with `←` / `→`:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
15
|
+
│ cinna sync — <agent name> <clock> │
|
|
16
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
17
|
+
│ [ Sync ] [ Details ] [ Conflicts ] │
|
|
18
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
19
|
+
│ │
|
|
20
|
+
│ <tab content> │
|
|
21
|
+
│ │
|
|
22
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
23
|
+
│ q Quit ◀ Tab Tab ▶ 1 take REMOTE 5 take LOCAL │
|
|
24
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Sync tab (default)
|
|
30
|
+
|
|
31
|
+
The status-and-activity view. Layout:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
┌─ Status pill, agent identity, endpoints ────────────────────────┐
|
|
35
|
+
│ ⬤ Watching for changes │
|
|
36
|
+
│ Agent: my-agent @ https://platform.example.com │
|
|
37
|
+
│ Local: /Users/me/work/my-agent/workspace │
|
|
38
|
+
│ Remote: cinna@cinna-agent-<uuid>:/app/workspace │
|
|
39
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
40
|
+
127 files · 14 dirs · 3.4 MB Successful cycles: 12
|
|
41
|
+
· receiving 17/60 (53.4 MB so far, current file 3.0 MB)
|
|
42
|
+
┌─ Activity log (scrolling) ──────────────────────────────────────┐
|
|
43
|
+
│ 19:21:08 sync attached — both endpoints connected │
|
|
44
|
+
│ 19:21:09 status: watching → staging-beta [local→remote] │
|
|
45
|
+
│ 19:21:09 → remote [1/12] scripts/main.py (4.2 KB) │
|
|
46
|
+
│ 19:21:09 → remote [2/12] docs/notes.md (1.1 KB) │
|
|
47
|
+
│ 19:21:10 cycle #3 complete — synced 12 files (12.4 KB) │
|
|
48
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Elements and their Mutagen dependencies
|
|
52
|
+
|
|
53
|
+
| Element | Source | Mutagen feature |
|
|
54
|
+
|---------------------------------------|-------------------------------------------------------------------|----------------------------------------------------------------------------------|
|
|
55
|
+
| Status pill (`⬤ <text>`) | `_state_pill` reading `status` + `alpha.connected`, `beta.connected` | [`mutagen_capabilities.md` §1, §3](./mutagen_capabilities.md#3-side-suffixed-status-values) |
|
|
56
|
+
| Direction tag (`local→remote`) | `_side_label` parsing the `-alpha` / `-beta` suffix of `status` | [§3 side-suffixed status](./mutagen_capabilities.md#3-side-suffixed-status-values) |
|
|
57
|
+
| Local / Remote URL lines | `alpha.path`, `beta.{user,host,path}` from session JSON | [§1 JSON shape](./mutagen_capabilities.md#1-mutagen-sync-list--template-json--output-shape) |
|
|
58
|
+
| Stats line (files / dirs / total size)| `alpha.{files,directories,totalFileSize}`, `successfulCycles` | [§1 JSON shape](./mutagen_capabilities.md#1-mutagen-sync-list--template-json--output-shape) |
|
|
59
|
+
| Live progress (`receiving N/M …`) | `(alpha|beta).stagingProgress.{receivedFiles,expectedFiles,totalReceivedSize,expectedSize}` | [§4 per-side stagingProgress](./mutagen_capabilities.md#4-per-side-stagingprogress) |
|
|
60
|
+
| Per-file activity lines | `_emit_staging_events` diffing `stagingProgress.path` between consecutive state records | [§4 stagingProgress.path](./mutagen_capabilities.md#4-per-side-stagingprogress) **and** [§2 monitor streams every change](./mutagen_capabilities.md#2-mutagen-sync-monitor-streams-state-on-every-change) — polling alone misses paths |
|
|
61
|
+
| Status-transition lines | `_emit_events` diffing `status` between consecutive records | [§3 status](./mutagen_capabilities.md#3-side-suffixed-status-values) |
|
|
62
|
+
| Endpoint connect/disconnect lines | `_emit_events` diffing `alpha.connected` / `beta.connected` | [§1 JSON shape](./mutagen_capabilities.md#1-mutagen-sync-list--template-json--output-shape) |
|
|
63
|
+
| Cycle-complete line with byte delta | `_emit_cycle_complete` diffing `(alpha|beta).{files,totalFileSize}` against a pre-cycle snapshot | [§1 JSON shape](./mutagen_capabilities.md#1-mutagen-sync-list--template-json--output-shape); `successfulCycles` as edge trigger |
|
|
64
|
+
| Scan / transition problem lines | `_emit_problem_events` reading `(alpha|beta)(Scan|Transition)Problems[]` | [§1 JSON shape](./mutagen_capabilities.md#1-mutagen-sync-list--template-json--output-shape) |
|
|
65
|
+
| Conflict log lines (`conflict: …`) | `_emit_conflict_events` reading `conflicts[]` | [§6 conflict JSON shape](./mutagen_capabilities.md#6-conflict-json-shape) |
|
|
66
|
+
| `error: …` line | `lastError` field | [§1 JSON shape](./mutagen_capabilities.md#1-mutagen-sync-list--template-json--output-shape) |
|
|
67
|
+
| Activity log file (`cinna.log`) | Every TUI line is also routed to the `cinna.sync_tui` logger | Independent of Mutagen — survives TUI close |
|
|
68
|
+
|
|
69
|
+
### How updates are delivered
|
|
70
|
+
|
|
71
|
+
A single subprocess streams JSON state into the TUI: `mutagen sync monitor --template '{{json .}}{{"\n"}}' <session>`. The TUI reads `proc.stdout` line by line in `_monitor_loop`. Each parsed record is fed to `_render_sync_tab`, which renders the static pieces and then calls `_emit_events` — the per-file / per-cycle event emitter. Depends on [§2 monitor streaming](./mutagen_capabilities.md#2-mutagen-sync-monitor-streams-state-on-every-change).
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Details tab
|
|
76
|
+
|
|
77
|
+
Verbatim output of `mutagen sync list --long <session>`, refreshed every 2 seconds (`DETAILS_INTERVAL`). This is what a power user would see if they ran the command themselves — full session metadata, scan results, raw conflict listings. Pure mutagen feature, no cinna interpretation.
|
|
78
|
+
|
|
79
|
+
| Element | Source | Mutagen feature |
|
|
80
|
+
|-------------|----------------------------------|----------------------------------------|
|
|
81
|
+
| Rendered text | `mutagen sync list --long` | Native human-readable output — stable contract across 0.18.x |
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Conflicts tab
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
┌─ Conflicts (2) ──────────────────────────────────────────────────┐
|
|
89
|
+
│ ▶ app-data/storage/workflow.db (local+remote modified) │
|
|
90
|
+
│ docs/WORKFLOW_PROMPT.md (local+remote modified) │
|
|
91
|
+
│ │
|
|
92
|
+
│ ↑/↓ navigate · 1 take REMOTE (server) · 5 take LOCAL (yours) │
|
|
93
|
+
│ Resolution deletes the losing side's file and resets mutagen │
|
|
94
|
+
│ history so the survivor propagates. │
|
|
95
|
+
└──────────────────────────────────────────────────────────────────┘
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Elements
|
|
99
|
+
|
|
100
|
+
| Element | Source | Mutagen feature |
|
|
101
|
+
|--------------------------------|-------------------------------------------------------------|---------------------------------------------------------------------------|
|
|
102
|
+
| Conflicts list (one row / path)| `_extract_conflicts(session)` flattening `conflicts[].alphaChanges[].path` and `betaChanges[].path` | [§6 conflict JSON shape](./mutagen_capabilities.md#6-conflict-json-shape) |
|
|
103
|
+
| Per-side modification tag | Presence of paths in `alphaChanges` vs `betaChanges` | [§6](./mutagen_capabilities.md#6-conflict-json-shape) |
|
|
104
|
+
| `1 take REMOTE` action | `_resolve_selected("beta")`: `rm <local file>` then `mutagen sync reset <session>` | [§8 per-file delete + reset](./mutagen_capabilities.md#8-per-file-conflict-resolution-via-delete--mutagen-sync-reset) |
|
|
105
|
+
| `5 take LOCAL` action | `_resolve_selected("alpha")`: `cinna exec rm <remote file>` then `mutagen sync reset <session>` | [§8](./mutagen_capabilities.md#8-per-file-conflict-resolution-via-delete--mutagen-sync-reset); requires cinna's own remote-exec channel |
|
|
106
|
+
| Auto-refresh | `_maybe_refresh_conflicts` is called on every monitor record; re-renders the tab only when the set actually changes | [§2 monitor](./mutagen_capabilities.md#2-mutagen-sync-monitor-streams-state-on-every-change), [§6 conflicts](./mutagen_capabilities.md#6-conflict-json-shape) |
|
|
107
|
+
|
|
108
|
+
### Why not source from disk?
|
|
109
|
+
|
|
110
|
+
Because Mutagen 0.18.1 in `two-way-safe` does **not** write `.conflict.<side>.<ts>` files — both sides keep their own version and only the JSON state records the divergence. See [§7](./mutagen_capabilities.md#7--two-way-safe-does-not-write-conflictsidets-files). If a future mutagen version starts writing these copies again, `src/cinna/sync_session.py:list_conflicts` and `group_conflicts` are still in place and can be wired back in as a secondary data source.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Keybindings
|
|
115
|
+
|
|
116
|
+
| Key | Action | Where it lives |
|
|
117
|
+
|-----------|-------------------|-----------------------------------------------------------------------|
|
|
118
|
+
| `q` | Quit | App-level `action_quit`; on exit, the caller (`run_foreground`) calls `stop(config)` to terminate the Mutagen session. |
|
|
119
|
+
| `Ctrl-C` | Quit | Same as `q`. |
|
|
120
|
+
| `←` / `→` | Cycle tabs | `action_cycle_tab(±1)`; wraps at ends. |
|
|
121
|
+
| `1` | Take REMOTE | No-op outside the Conflicts tab. See [§8](./mutagen_capabilities.md#8-per-file-conflict-resolution-via-delete--mutagen-sync-reset). |
|
|
122
|
+
| `5` | Take LOCAL | No-op outside the Conflicts tab. `1` and `5` are placed far apart on the keyboard to make a misfire unlikely. |
|
|
123
|
+
| `↑` / `↓` | Navigate Conflicts list | Native to Textual's `OptionList`; only the Conflicts tab's list is focusable, so these keys do nothing on Sync / Details. |
|
|
124
|
+
|
|
125
|
+
The `←` / `→` / `1` / `5` bindings are declared with `priority=True` so they fire even when the Conflicts `OptionList` has focus.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Lifecycle
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
cinna dev
|
|
133
|
+
└─ sync_session.start(config, workspace_root) # create / refresh Mutagen session
|
|
134
|
+
└─ run_foreground(config, workspace_root)
|
|
135
|
+
└─ run_tui(config, session_name, env, workspace_root)
|
|
136
|
+
└─ SyncApp.run()
|
|
137
|
+
on_mount:
|
|
138
|
+
· spawn _monitor_loop (mutagen sync monitor --template …)
|
|
139
|
+
· spawn _details_loop (mutagen sync list --long, every 2 s)
|
|
140
|
+
user presses q / Ctrl-C
|
|
141
|
+
on_unmount:
|
|
142
|
+
· cancel both loops
|
|
143
|
+
· terminate the monitor subprocess
|
|
144
|
+
└─ stop(config) # mutagen sync terminate <session-name>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The TUI does not outlive its terminal. Once the user quits, sync stops. To observe an existing sync session from another terminal without affecting it, the user runs `cinna sync status` (a read-only Click subcommand defined in `main.py`, not part of the TUI).
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Adding a new TUI element
|
|
152
|
+
|
|
153
|
+
Procedure when you want to expose a new piece of Mutagen state:
|
|
154
|
+
|
|
155
|
+
1. Look at the raw JSON: `mutagen sync list --template '{{json .}}' <session>` while the relevant state is active. Note the exact JSON key and where it lives (top-level, under `alpha`/`beta`, etc.).
|
|
156
|
+
2. Add a row to [`mutagen_capabilities.md`](./mutagen_capabilities.md) if the field isn't already documented there. Include a reproduction command — future you, on the next mutagen bump, needs to verify it didn't move.
|
|
157
|
+
3. Wire the read into `_render_sync_tab` (for static display) or `_emit_events` (for diff-driven log lines). Use `_safe_int` from `sync_session.py` for numeric fields to keep the defensive-accessor pattern consistent.
|
|
158
|
+
4. Add a row to the appropriate table in this document linking the new element back to the capabilities reference.
|
|
159
|
+
|
|
160
|
+
If a feature would need a Mutagen capability that doesn't yet exist in our pinned version, document the gap in [`mutagen_capabilities.md`](./mutagen_capabilities.md) and link to it from here so we know what to look for on the next bump.
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Mutagen Capabilities — Verified Findings
|
|
2
|
+
|
|
3
|
+
> A reference of every Mutagen behavior cinna-cli currently depends on, with the exact commands to re-verify each one. The goal: when we bump Mutagen, run through this doc top to bottom and confirm nothing regressed.
|
|
4
|
+
|
|
5
|
+
**Currently pinned version:** Mutagen `0.18.1` (verified May 2026 against `mutagen version` on macOS arm64).
|
|
6
|
+
|
|
7
|
+
All claims below were reproduced locally against that version. The reproduction commands assume the `mutagen` daemon is running (`mutagen daemon start`). They use temp dirs `/tmp/c-a` and `/tmp/c-b` as alpha/beta — clean them up between tests.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1. `mutagen sync list --template '{{json .}}'` output shape
|
|
12
|
+
|
|
13
|
+
**What we rely on:** the JSON state is a top-level **array of session objects** (even when listing a single session by name). Each object flattens both the session config (alpha, beta endpoints, ignore rules) and the runtime state (status, conflicts, stagingProgress) at the top level — `state` is **not** a nested object.
|
|
14
|
+
|
|
15
|
+
**Reproduce:**
|
|
16
|
+
```bash
|
|
17
|
+
mkdir -p /tmp/c-a /tmp/c-b && echo hi > /tmp/c-a/x.txt
|
|
18
|
+
mutagen sync create --name=cap-test /tmp/c-a /tmp/c-b
|
|
19
|
+
sleep 2
|
|
20
|
+
mutagen sync list --template '{{json .}}' cap-test
|
|
21
|
+
mutagen sync terminate cap-test
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Expected top-level keys (per session):**
|
|
25
|
+
- `identifier`, `version`, `creationTime`, `creatingVersion`, `name`
|
|
26
|
+
- `alpha`, `beta` — each contains `protocol`, `path`, `connected`, `scanned`, `directories`, `files`, `totalFileSize`, and during a transfer `stagingProgress`
|
|
27
|
+
- `status` — string, see §3
|
|
28
|
+
- `successfulCycles` — int counter (only emitted after first non-zero cycle)
|
|
29
|
+
- `paused` — bool
|
|
30
|
+
- `conflicts` — array, see §6
|
|
31
|
+
- `lastError` — string (optional)
|
|
32
|
+
|
|
33
|
+
**Where we read it:** `src/cinna/sync_session.py:_list_sessions`, `_to_status`; `src/cinna/sync_tui.py:_parse_monitor_payload`.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 2. `mutagen sync monitor` streams state on every change
|
|
38
|
+
|
|
39
|
+
**What we rely on:** `mutagen sync monitor --template '{{json .}}{{"\n"}}' <session>` is a long-running subprocess that writes one JSON-array record to stdout every time the daemon's session state changes — including progress ticks while a transfer is in flight. This is what lets the Sync tab show per-file paths in real time; polling at any practical interval misses fast files.
|
|
40
|
+
|
|
41
|
+
**Reproduce:**
|
|
42
|
+
```bash
|
|
43
|
+
mkdir -p /tmp/c-a /tmp/c-b
|
|
44
|
+
for i in $(seq 1 60); do dd if=/dev/urandom of=/tmp/c-a/file$i.bin bs=1M count=8 2>/dev/null; done
|
|
45
|
+
mutagen sync create --name=cap-monitor /tmp/c-a /tmp/c-b
|
|
46
|
+
( mutagen sync monitor --template '{{json .}}{{"\n"}}' cap-monitor & MPID=$!
|
|
47
|
+
sleep 12; kill $MPID 2>/dev/null
|
|
48
|
+
) | head -20
|
|
49
|
+
mutagen sync terminate cap-monitor
|
|
50
|
+
rm -rf /tmp/c-a /tmp/c-b
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Expected:** multiple `[{...}]\n\n` payloads. During staging, you'll see `stagingProgress.path` change between records.
|
|
54
|
+
|
|
55
|
+
**Where we read it:** `src/cinna/sync_tui.py:_monitor_loop`.
|
|
56
|
+
|
|
57
|
+
**Known limit:** during very fast local-to-local syncs (60 files transferring in ~1 second on an SSD) the daemon coalesces updates and we observed roughly 1 distinct path emitted per 5–10 files. For cinna's actual use case (remote sync over network) each file takes long enough that monitor emits multiple progress records per file and we catch them all.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 3. Side-suffixed `status` values
|
|
62
|
+
|
|
63
|
+
**What we rely on:** `status` can take side-suffixed values `staging-alpha`, `staging-beta`, `transitioning-alpha`, `transitioning-beta`. These mean "currently writing to that side" — alpha = local, beta = remote. The base prefix (`staging`/`transitioning`/`scanning`/`watching`/`reconciling`/`saving`) is the phase.
|
|
64
|
+
|
|
65
|
+
**Reproduce:** see §2 — during the transfer, watch the `status` field flip between `scanning` → `staging-beta` → `scanning` → `watching`.
|
|
66
|
+
|
|
67
|
+
**Why we normalize:** `_to_status` and `_state_pill` both call `base_status(raw)` (in `sync_session.py`) which strips the `-<side>` suffix before matching against the set of healthy states. The side suffix drives the "local→remote" / "remote→local" direction label shown in the Sync tab.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 4. Per-side `stagingProgress`
|
|
72
|
+
|
|
73
|
+
**What we rely on:** when a side is staging, that side's object carries a `stagingProgress` block with these fields:
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"path": "file36.bin",
|
|
77
|
+
"receivedSize": 1638400,
|
|
78
|
+
"expectedSize": 3145728,
|
|
79
|
+
"receivedFiles": 17,
|
|
80
|
+
"expectedFiles": 60,
|
|
81
|
+
"totalReceivedSize": 53477376
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`path` is the file currently being received on that side. `expectedSize` is the *current file's* total, not the session's. `totalReceivedSize` is the cumulative bytes since staging started.
|
|
86
|
+
|
|
87
|
+
**Reproduce:** §2 — watch any single record where `status` is `staging-beta`. `stagingProgress` will appear under `beta`.
|
|
88
|
+
|
|
89
|
+
**Where we use it:** `src/cinna/sync_tui.py:_emit_staging_events` (logs each new `path` once, deduped against the previous record); `_render_sync_tab` appends the live progress hint to the stats line.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 5. `mutagen.yml` and ignore semantics
|
|
94
|
+
|
|
95
|
+
**What we rely on:** the per-workspace `mutagen.yml` written by `cinna sync` configures sync mode, scan mode, and ignore patterns. Mutagen reads it from the workspace root at session creation.
|
|
96
|
+
|
|
97
|
+
**Currently written** (`src/cinna/sync_session.py:MUTAGEN_YML_TEMPLATE`):
|
|
98
|
+
```yaml
|
|
99
|
+
sync:
|
|
100
|
+
defaults:
|
|
101
|
+
mode: two-way-safe
|
|
102
|
+
permissions:
|
|
103
|
+
mode: portable
|
|
104
|
+
ignore:
|
|
105
|
+
vcs: true
|
|
106
|
+
paths:
|
|
107
|
+
- __pycache__/
|
|
108
|
+
- node_modules/
|
|
109
|
+
- .venv/
|
|
110
|
+
- .cinna/
|
|
111
|
+
- .mypy_cache/
|
|
112
|
+
- .pytest_cache/
|
|
113
|
+
- .DS_Store
|
|
114
|
+
scan:
|
|
115
|
+
mode: full
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Available sync modes** (`mutagen sync create --help`):
|
|
119
|
+
- `two-way-safe` (cinna default) — both sides authoritative, conflicts halt
|
|
120
|
+
- `two-way-resolved` — alpha wins on conflict (no manual intervention)
|
|
121
|
+
- `one-way-safe` — alpha authoritative, beta cannot diverge
|
|
122
|
+
- `one-way-replica` — alpha mirrored to beta exactly, beta overwritten
|
|
123
|
+
|
|
124
|
+
**Available scan modes**: `full` (cinna default) and `accelerated`. `accelerated` uses filesystem watches and short-circuits unchanged directories; `full` is slower but more accurate for cases where watches misbehave (network filesystems, container bind mounts).
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 6. Conflict JSON shape
|
|
129
|
+
|
|
130
|
+
**What we rely on:** when both sides modify the same file under `two-way-safe`, mutagen records a conflict in the session's `conflicts[]` array.
|
|
131
|
+
|
|
132
|
+
**Shape:**
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"root": "shared.txt",
|
|
136
|
+
"alphaChanges": [
|
|
137
|
+
{"path": "shared.txt",
|
|
138
|
+
"old": {"kind": "file", "digest": "c9e870..."},
|
|
139
|
+
"new": {"kind": "file", "digest": "534ec1..."}}
|
|
140
|
+
],
|
|
141
|
+
"betaChanges": [
|
|
142
|
+
{"path": "shared.txt",
|
|
143
|
+
"old": {"kind": "file", "digest": "c9e870..."},
|
|
144
|
+
"new": {"kind": "file", "digest": "e52d59..."}}
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Some conflict kinds (directory/file disagreement, asymmetric delete) only populate `root`; the per-side change arrays may be empty.
|
|
150
|
+
|
|
151
|
+
**Reproduce:**
|
|
152
|
+
```bash
|
|
153
|
+
mkdir -p /tmp/c-a /tmp/c-b && echo "orig" > /tmp/c-a/shared.txt
|
|
154
|
+
mutagen sync create --name=cap-conflict --sync-mode=two-way-safe /tmp/c-a /tmp/c-b
|
|
155
|
+
sleep 2
|
|
156
|
+
mutagen sync pause cap-conflict
|
|
157
|
+
echo "LOCAL" > /tmp/c-a/shared.txt
|
|
158
|
+
echo "REMOTE" > /tmp/c-b/shared.txt
|
|
159
|
+
mutagen sync resume cap-conflict
|
|
160
|
+
sleep 2
|
|
161
|
+
mutagen sync list --template '{{json .}}' cap-conflict
|
|
162
|
+
mutagen sync terminate cap-conflict
|
|
163
|
+
rm -rf /tmp/c-a /tmp/c-b
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**Where we read it:** `src/cinna/sync_tui.py:_extract_conflicts`.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 7. ⚠ `two-way-safe` does NOT write `.conflict.<side>.<ts>` files
|
|
171
|
+
|
|
172
|
+
**What we rely on:** in mutagen 0.18.1's `two-way-safe` mode, when a conflict occurs, mutagen records it in `conflicts[]` JSON but **leaves both sides' canonical files untouched**. No `.conflict.alpha.<ts>` or `.conflict.beta.<ts>` files are written.
|
|
173
|
+
|
|
174
|
+
This contradicts older docs / forum posts about mutagen that describe conflict-marker files. We verified empirically that it does not happen in 0.18.1 two-way-safe.
|
|
175
|
+
|
|
176
|
+
**Implication:** populating the Conflicts tab from a disk walk (`*.conflict.*`) returns an empty list even when conflicts exist. The tab must source from JSON, not the filesystem.
|
|
177
|
+
|
|
178
|
+
**Reproduce:** §6, then check `ls -la /tmp/c-a /tmp/c-b` after the conflict appears — only `shared.txt` is present on each side.
|
|
179
|
+
|
|
180
|
+
**If a future mutagen starts writing those files again:** `src/cinna/sync_session.py:list_conflicts` already walks for `*.conflict.<side>.<ts>` and is the path to surface them. The `cinna sync conflicts` CLI subcommand still uses it. The TUI currently does not — it sources conflicts from JSON via `_extract_conflicts`.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## 8. Per-file conflict resolution via delete + `mutagen sync reset`
|
|
185
|
+
|
|
186
|
+
**What we rely on:** `mutagen sync reset <session>` clears the session's sync history (the common-ancestor record) without recreating the session. On the next scan, mutagen has no notion of what changed since when.
|
|
187
|
+
|
|
188
|
+
By itself this does nothing useful for conflicts — both sides still have divergent content, so mutagen re-detects the conflict. But combined with deleting the *losing* side's file first, it forces propagation:
|
|
189
|
+
|
|
190
|
+
| User picks | Action | Effect |
|
|
191
|
+
|------------|-----------------------------------------------------|------------------------------------------------------------------------------------------|
|
|
192
|
+
| REMOTE | `rm <alpha file>` then `mutagen sync reset <s>` | Alpha empty, beta has content, no ancestor → mutagen propagates beta → alpha. |
|
|
193
|
+
| LOCAL | `rm <beta file>` then `mutagen sync reset <s>` | Beta empty, alpha has content, no ancestor → mutagen propagates alpha → beta. |
|
|
194
|
+
|
|
195
|
+
**Reproduce — Take REMOTE:**
|
|
196
|
+
```bash
|
|
197
|
+
# Set up conflict as in §6, then:
|
|
198
|
+
rm /tmp/c-a/shared.txt
|
|
199
|
+
mutagen sync reset cap-conflict
|
|
200
|
+
sleep 3
|
|
201
|
+
cat /tmp/c-a/shared.txt /tmp/c-b/shared.txt # both = REMOTE
|
|
202
|
+
mutagen sync list --template '{{json .}}' cap-conflict | python3 -c 'import json,sys; print(len(json.load(sys.stdin)[0].get("conflicts",[])))'
|
|
203
|
+
# → 0
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Reproduce — Take LOCAL:** symmetric — `rm /tmp/c-b/shared.txt` instead.
|
|
207
|
+
|
|
208
|
+
**Multi-conflict safety**: deleting one conflict file + reset only converges that one path. Other conflicts in the same session remain because both their sides still hold content. Verified by setting up two conflicts (`x.txt` and `y.txt`), resolving one with delete+reset, and observing the other untouched in the post-reset JSON.
|
|
209
|
+
|
|
210
|
+
**Why we don't switch sync mode:** `two-way-resolved` would auto-resolve in alpha's favor, but its effect is session-wide — every conflict would resolve the same way. There's no per-file mode flag. The delete + reset approach is per-file.
|
|
211
|
+
|
|
212
|
+
**Where we use it:** `src/cinna/sync_tui.py:_resolve_selected`. For the beta-side delete cinna shells out to `cinna exec rm -f -- <path>` since beta lives on the remote agent.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## 9. The SSH-shim contract
|
|
217
|
+
|
|
218
|
+
**What we rely on:** mutagen invokes its SSH transport via the binary named `ssh` found in `$MUTAGEN_SSH_PATH`. `MUTAGEN_SSH_PATH` is a **directory** (not a binary path); mutagen searches it for an executable literally named `ssh`. The daemon captures its environment at startup, so any change to `MUTAGEN_SSH_PATH` requires restarting `mutagen daemon`.
|
|
219
|
+
|
|
220
|
+
The remote URL must use OpenSSH-style `user@host:/path`, not `ssh://`. Mutagen's URL parser distinguishes the two and treats `ssh://` as a different transport.
|
|
221
|
+
|
|
222
|
+
**Where we use it:** `src/cinna/sync_session.py:_ensure_ssh_shim_dir`, `_mutagen_env`, `_restart_daemon`, and the failure marker constants `_STALE_DAEMON_MARKERS` that trigger an automatic daemon restart on `sync create`.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## 10. `mutagen daemon` is shared across sessions
|
|
227
|
+
|
|
228
|
+
**What we rely on:** a single `mutagen daemon` instance manages all sessions on the host, regardless of which user / project created them. `mutagen daemon stop` terminates ALL sessions across all projects; they auto-resume on the next `mutagen sync list` (or any subcommand that talks to the daemon).
|
|
229
|
+
|
|
230
|
+
**Implication:** when cinna restarts the daemon to refresh stale env (`_restart_daemon`), other consumers of mutagen on the same machine experience a brief pause in their syncs. The pause is silent — they don't get a notification.
|
|
231
|
+
|
|
232
|
+
**Reproduce:**
|
|
233
|
+
```bash
|
|
234
|
+
mutagen sync list # note any non-cinna sessions
|
|
235
|
+
mutagen daemon stop
|
|
236
|
+
mutagen daemon start
|
|
237
|
+
mutagen sync list # same sessions reappear, all paused for a moment
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## When to revisit this doc
|
|
243
|
+
|
|
244
|
+
- Bumping `mutagen` past `0.18.1` (any minor or major version).
|
|
245
|
+
- Adding a UI feature that needs a Mutagen behavior we haven't documented.
|
|
246
|
+
- After any user-reported sync bug that turns out to be a Mutagen-version-specific behavior change.
|
|
247
|
+
|
|
248
|
+
Walking through §1–§8 with the reproduction commands takes ~5 minutes and catches the breaking changes that have historically bit us.
|
|
@@ -408,7 +408,7 @@ def dev():
|
|
|
408
408
|
|
|
409
409
|
st = sync_session.start(config, root)
|
|
410
410
|
console.status(f"Sync session created ({st.state}) — attaching live view. Press Ctrl-C to stop.")
|
|
411
|
-
sync_session.run_foreground(config)
|
|
411
|
+
sync_session.run_foreground(config, root)
|
|
412
412
|
console.status("Sync session terminated.")
|
|
413
413
|
|
|
414
414
|
|
|
@@ -46,7 +46,7 @@ sync:
|
|
|
46
46
|
- .pytest_cache/
|
|
47
47
|
- .DS_Store
|
|
48
48
|
scan:
|
|
49
|
-
mode:
|
|
49
|
+
mode: full
|
|
50
50
|
"""
|
|
51
51
|
|
|
52
52
|
|
|
@@ -297,12 +297,12 @@ def stop(config: CinnaConfig) -> None:
|
|
|
297
297
|
_run_mutagen(["sync", "terminate", session_name(config.agent_id)], config)
|
|
298
298
|
|
|
299
299
|
|
|
300
|
-
def run_foreground(config: CinnaConfig) -> int:
|
|
301
|
-
"""Attach the terminal to the Mutagen sync session via a
|
|
300
|
+
def run_foreground(config: CinnaConfig, workspace_root: Path) -> int:
|
|
301
|
+
"""Attach the terminal to the Mutagen sync session via a live TUI.
|
|
302
302
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
303
|
+
Three tabs: Sync (status + per-file activity log), Details (raw
|
|
304
|
+
``mutagen sync list --long``), and Conflicts (interactive resolution
|
|
305
|
+
for files Mutagen couldn't auto-merge).
|
|
306
306
|
|
|
307
307
|
Blocks until the user presses ``q`` / Ctrl-C. On return the Mutagen
|
|
308
308
|
session is terminated so sync does not outlive the TUI.
|
|
@@ -315,7 +315,7 @@ def run_foreground(config: CinnaConfig) -> int:
|
|
|
315
315
|
env = _mutagen_env(config)
|
|
316
316
|
rc = 0
|
|
317
317
|
try:
|
|
318
|
-
rc = run_tui(config, session, env)
|
|
318
|
+
rc = run_tui(config, session, env, workspace_root)
|
|
319
319
|
except KeyboardInterrupt:
|
|
320
320
|
rc = 0
|
|
321
321
|
finally:
|
|
@@ -343,9 +343,10 @@ def _to_status(config: CinnaConfig, session: dict) -> SyncStatus:
|
|
|
343
343
|
"""
|
|
344
344
|
raw_state = (session.get("status") or session.get("state") or "").lower()
|
|
345
345
|
paused = bool(session.get("paused"))
|
|
346
|
+
base = base_status(raw_state)
|
|
346
347
|
if paused:
|
|
347
348
|
state = "paused"
|
|
348
|
-
elif
|
|
349
|
+
elif base in {"watching", "scanning", "staging", "transitioning", "saving", "reconciling", "connected"}:
|
|
349
350
|
state = "connected"
|
|
350
351
|
elif raw_state in {"disconnected", "connecting"}:
|
|
351
352
|
state = "disconnected"
|
|
@@ -380,6 +381,16 @@ def _safe_int(v) -> int:
|
|
|
380
381
|
return 0
|
|
381
382
|
|
|
382
383
|
|
|
384
|
+
def base_status(raw: str) -> str:
|
|
385
|
+
"""Strip Mutagen's side suffix (e.g. ``staging-beta`` → ``staging``).
|
|
386
|
+
|
|
387
|
+
Mutagen distinguishes the receiving side in its status string while a
|
|
388
|
+
transfer is in flight, but most consumers care about the phase, not the
|
|
389
|
+
direction.
|
|
390
|
+
"""
|
|
391
|
+
return raw.split("-", 1)[0] if "-" in raw else raw
|
|
392
|
+
|
|
393
|
+
|
|
383
394
|
@dataclass
|
|
384
395
|
class Conflict:
|
|
385
396
|
path: Path
|
|
@@ -413,6 +424,64 @@ def list_conflicts(config: CinnaConfig, workspace_root: Path) -> list[Conflict]:
|
|
|
413
424
|
return results
|
|
414
425
|
|
|
415
426
|
|
|
427
|
+
@dataclass
|
|
428
|
+
class ConflictGroup:
|
|
429
|
+
"""Two-sided view of one conflicted path.
|
|
430
|
+
|
|
431
|
+
Mutagen may write one or both ``.conflict.<side>.<ts>`` copies depending
|
|
432
|
+
on which side it considered the winner. ``canonical`` is the real
|
|
433
|
+
workspace path the two copies are versions of.
|
|
434
|
+
"""
|
|
435
|
+
canonical: Path
|
|
436
|
+
alpha_copy: Path | None = None
|
|
437
|
+
beta_copy: Path | None = None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _canonical_from_conflict(p: Path) -> Path:
|
|
441
|
+
"""``foo/bar.txt.conflict.alpha.20260101`` → ``foo/bar.txt``."""
|
|
442
|
+
name = p.name
|
|
443
|
+
idx = name.find(".conflict.")
|
|
444
|
+
if idx <= 0:
|
|
445
|
+
return p
|
|
446
|
+
return p.parent / name[:idx]
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def group_conflicts(conflicts: list[Conflict]) -> list[ConflictGroup]:
|
|
450
|
+
"""Bucket flat conflict-copy paths by their canonical workspace path."""
|
|
451
|
+
groups: dict[Path, ConflictGroup] = {}
|
|
452
|
+
for c in conflicts:
|
|
453
|
+
canonical = _canonical_from_conflict(c.path)
|
|
454
|
+
g = groups.setdefault(canonical, ConflictGroup(canonical=canonical))
|
|
455
|
+
if c.kind == "alpha":
|
|
456
|
+
g.alpha_copy = c.path
|
|
457
|
+
elif c.kind == "beta":
|
|
458
|
+
g.beta_copy = c.path
|
|
459
|
+
return sorted(groups.values(), key=lambda g: str(g.canonical))
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def resolve_conflict(group: ConflictGroup, side: str) -> None:
|
|
463
|
+
"""Apply the user's choice: keep ``side``'s version at ``canonical``.
|
|
464
|
+
|
|
465
|
+
side: ``"alpha"`` (local) or ``"beta"`` (remote).
|
|
466
|
+
|
|
467
|
+
When the named side's conflict copy exists, it replaces the canonical
|
|
468
|
+
file. When it doesn't, the canonical file is already that side's content,
|
|
469
|
+
so we only delete the loser's copy. Mutagen picks up the change on its
|
|
470
|
+
next scan and propagates the resolution to the other endpoint.
|
|
471
|
+
"""
|
|
472
|
+
if side not in ("alpha", "beta"):
|
|
473
|
+
raise ValueError(f"side must be 'alpha' or 'beta', got {side!r}")
|
|
474
|
+
target = group.alpha_copy if side == "alpha" else group.beta_copy
|
|
475
|
+
other = group.beta_copy if side == "alpha" else group.alpha_copy
|
|
476
|
+
|
|
477
|
+
if target is not None and target.exists():
|
|
478
|
+
if group.canonical.exists() and group.canonical.is_file():
|
|
479
|
+
group.canonical.unlink()
|
|
480
|
+
target.rename(group.canonical)
|
|
481
|
+
if other is not None and other.exists():
|
|
482
|
+
other.unlink()
|
|
483
|
+
|
|
484
|
+
|
|
416
485
|
def session_log_dir(workspace_root: Path) -> Path:
|
|
417
486
|
"""Where we cache per-session breadcrumbs (exec history, etc.)."""
|
|
418
487
|
return config_dir(workspace_root) / "sync"
|