mark-twin 0.2.0 → 0.4.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39f52a8341611b4a17bbeaffae64e21e7ffc2123f1b766df494eb422db697cb6
4
- data.tar.gz: 224e40d39832efc3542c1c3de00e4c6fe30d01c72ca4f84ff5bc9868f15cedd3
3
+ metadata.gz: 5bae405ea7916f59395530f5372cd3bde2aa4ff742ca4cf08142ca16be5c7cd9
4
+ data.tar.gz: b05b48d23901e7c6c2fd313ecc0f8ef2dbf172ab77ef38b5575088031dc186da
5
5
  SHA512:
6
- metadata.gz: 8f217e753b18810cad48b2e6c2f3360a3cc84e4f6449dd14f9f62aae4243b55d44a4d2b8321a8f60228c4ca696e97106f9cdca1e6a54f404436af284130609dd
7
- data.tar.gz: 301088d4bd68082461e9e8d0730d1c921336256e8fe3f3a755f918c6e189fc4238fc4bd7c3f7e4e8879474f9736ef4272a5117ce8b3b97316983031e0656201b
6
+ metadata.gz: 3d10977e33311149b1d90486c553cf6ce773c9c2927a081f3e264dcb9a8d0c97be61bb62883b070cb739ab66aa710d2d359b95db33018f8463bd0ac9987c0a95
7
+ data.tar.gz: ec1eecdf09d77e1c0ef6480edeb889c9d737b87cbd9eda5a9e83e210ca5fc2eeee85d06f86ccb65e81040197a8a4402001eeb79bda60710f44c77cd7e2e16c26
data/ARCHITECTURE.md CHANGED
@@ -19,6 +19,11 @@ sync-files (.md)
19
19
  └──▶ Picker fzf + apex preview, returns selected Program
20
20
 
21
21
 
22
+
23
+
24
+ Conflict paired dry-runs → files --update would hold
25
+ │ back → content compare → ask once, or abort
26
+
22
27
  Sync rsync (or render) per Job, mount check, Cmd hook
23
28
  ```
24
29
 
@@ -27,10 +32,14 @@ sync-files (.md)
27
32
  ```
28
33
  lib/twin/
29
34
  version.rb
35
+ remote.rb ssh targets: detection, reachability, batched stat, mkdir
30
36
  template.rb {{token}} substitution + render-file helper
31
37
  config.rb ~/.config/twin/config.yaml loader; host table → var_map
32
38
  scanner.rb Job, Program structs; grubber + template + stat → grouped Programs
33
39
  sync.rb rsync / render execution, mount check, post-sync hook
40
+ conflict.rb target-side changes: detection via paired dry-runs, diffs
41
+ journal.rb append-only sync journal (~/.local/state/twin/log.jsonl)
42
+ add.rb `twin add` — interactive scaffolding of new sync entries
34
43
  picker.rb fzf wrapper with apex preview
35
44
  cli.rb subcommand dispatcher
36
45
 
@@ -43,13 +52,18 @@ test/test_pure.rb
43
52
  **Job** — one YAML block:
44
53
 
45
54
  ```
46
- program, path, description, active, excludes, label, source, target, cmd,
55
+ program, path, description, active, excludes, owned, label, source, target, cmd,
47
56
  delete, render, render_outdated, target_path_field, sync_file,
48
57
  source_exists, target_exists, source_mtime, target_mtime, conflict
49
58
  ```
50
59
 
51
- `Job#status` one of `disabled / both_missing / missing_source / missing_target /
52
- target_newer / in_sync / source_newer`. Render jobs derive status from content
60
+ `excludes` and `owned` both become `--exclude` (via `Job#all_excludes`); they
61
+ are kept apart so `status` can report intent. `conflict` is the coarse mtime
62
+ flag used by `status` only — conflict *resolution* ignores it and asks rsync
63
+ directly, because a directory's mtime says nothing about edits inside it.
64
+
65
+ `Job#status` → one of `disabled / unreachable / both_missing / missing_source /
66
+ missing_target / target_newer / in_sync / source_newer`. Render jobs derive status from content
53
67
  (`render_outdated`), not mtime; non-render jobs ignore those fields.
54
68
  `Job#target_path` joins `target` with `target_path_field || path`.
55
69
 
@@ -62,6 +76,22 @@ name, jobs
62
76
  `Program#status` aggregates jobs (worst state wins). Selection in the picker
63
77
  operates on Programs, not individual Jobs.
64
78
 
79
+ **Job order is part of the contract.** Jobs of a Program run in the order their
80
+ YAML blocks appear in the sync-file. Sync-files rely on this — a `Cmd` that
81
+ restarts a service belongs in the last block, so it fires after every path is in
82
+ place. Reorder the jobs and a deploy restarts against half-written state,
83
+ without any error to show for it.
84
+
85
+ The order is guaranteed at both ends of the pipeline, not merely observed:
86
+
87
+ - grubber emits records in document order — first block, first record — for all
88
+ three output formats, pinned by its own `TestBlockOrderFollowsDocument`
89
+ across `Extract` and `StreamJSONL`.
90
+ - twin preserves it through `filter_map` and `group_by` (insertion order per
91
+ key), pinned by `test_job_order_follows_document_order`.
92
+
93
+ Neither side may quietly sort.
94
+
65
95
  ## Configuration
66
96
 
67
97
  `~/.config/twin/config.yaml`:
@@ -151,17 +181,60 @@ File argument resolution (`twin <arg>` and `--file=<arg>`):
151
181
  Unknown options (anything starting with `-` that isn't `--help`) print an
152
182
  error pointing at `twin --help` and exit 1.
153
183
 
184
+ `twin sync` returns exit 1 when any job failed. `--quiet` suppresses output
185
+ for successful no-op jobs (conflicts, errors and real transfers still print);
186
+ `--skip-unavailable` skips unmounted/unreachable targets instead of aborting.
187
+ The combination is the unattended-run mode (launchd/cron).
188
+
189
+ Every non-dry-run job lands in the journal (`Journal.record`): one JSON line
190
+ in `~/.local/state/twin/log.jsonl` (`TWIN_STATE_DIR` overrides the directory)
191
+ with timestamp, program, path, target, `ok`, `changed`, and a truncated error
192
+ line on failure. `twin log [-n N] [--json]` reads it back. Journal write
193
+ failures warn once and never break a sync.
194
+
195
+ `twin add <path>` (`add.rb`) scaffolds a new entry: it matches the expanded
196
+ path against the token-substituted `Source:` frontmatter of every sync-file
197
+ (files with no or foreign roots drop out), computes `Path:` relative to the
198
+ chosen root, suggests excludes from a fixed list of generated/heavy dirs found
199
+ in the source (`SUGGEST_EXCLUDES`), rejects paths the file already has a
200
+ block for, and appends heading + prose stub + YAML block. With no covering
201
+ sync-file it can create one (frontmatter from prompts). The pure helpers
202
+ (`frontmatter`, `candidates`, `relative_path`, `suggest_excludes`,
203
+ `build_block`) are unit-tested; the prompt flow reads plain stdin, so it is
204
+ scriptable by piping answers.
205
+
154
206
  `twin doctor` checks required tools (grubber, rsync, fzf), optional renderers
155
207
  (apex, glow, bat), templating (host/target resolve, every `{{token}}` resolves),
156
208
  and whether all configured sync targets are mounted. Exits 1 if any required
157
209
  check fails.
158
210
 
211
+ ## Remote targets
212
+
213
+ `Target: user@host:/path` (rsync notation; colon before the first slash) makes
214
+ a job remote — `Job#remote?`. Sources stay local, twin pushes.
215
+
216
+ - **Stat**: remote paths can't be `File.stat`ed, so `build_job` leaves them
217
+ "missing" and `Scanner.fill_remote_stats` fills them in afterwards — one
218
+ `ssh` round-trip per host for all its paths (`Remote.stat_paths`: paths over
219
+ stdin, `path\tepoch` back; BSD `stat -f %m` with GNU `stat -c %Y` fallback).
220
+ A failed ssh sets `target_unreachable` → status `:unreachable`; the scan
221
+ itself never fails on a dead host.
222
+ - **Reachability** replaces the mount check (`Remote.reachable?`,
223
+ `ssh -o BatchMode=yes … true` — key auth only, never prompts).
224
+ - **rsync** needs no changes: the target string is already in its remote
225
+ syntax. Parent directories are created via `ssh host mkdir -p` first.
226
+ - **`Cmd`** still runs locally (`sh -c`); acting on the server means writing
227
+ an `ssh host '…'` command in the sync-file.
228
+ - **`Render: true` + remote raises** at scan time — render reads/writes target
229
+ content, which twin only does on local (mounted) paths.
230
+
159
231
  ## Sync
160
232
 
161
233
  Before syncing:
162
234
 
163
- 1. **Mount check** — every unique target root must be a mount point
164
- (`File.stat.dev != parent.dev`). Aborts if unmounted.
235
+ 1. **Mount check** — every unique local target root must be a mount point
236
+ (`File.stat.dev != parent.dev`); remote targets must be ssh-reachable.
237
+ Aborts otherwise.
165
238
  2. **Conflict warning** — emits stderr listing jobs where the target is
166
239
  newer than the source. Continues anyway (`rsync --update` skips them).
167
240
 
@@ -171,7 +244,13 @@ Then per Job, **rsync path** (non-render):
171
244
  rsync -av --itemize-changes --update [--delete] [--exclude=...]* src/ tgt/
172
245
  ```
173
246
 
174
- `--delete` is added when the Job has `delete: true` (from `Delete: true`).
247
+ `--delete` is added when the Job has `delete: true` (from `Delete: true`),
248
+ together with `--backup --backup-dir=<target>/.twin-backup/<run-stamp>` —
249
+ deleted and overwritten files are moved aside, not destroyed. The stamp is
250
+ per-process, so one run shares a backup dir; rsync only creates it when it
251
+ actually backs something up. `--exclude=.twin-backup/` protects the backup
252
+ dir from a `Path: "."` sync deleting it. For remote targets the backup dir
253
+ is the path part of the target (it lives on the receiving side).
175
254
  `--itemize-changes` makes change detection deterministic: `Sync.transferred?`
176
255
  matches itemize lines (`/\A[<>ch*][fdLDS]/` — `>f…`, `cd…`, `*deleting`),
177
256
  covering files, directories and deletions, with no scraping of rsync's prose.
data/README.md CHANGED
@@ -4,26 +4,56 @@
4
4
  [![Tests](https://github.com/rhsev/mark-twin/actions/workflows/test.yml/badge.svg)](https://github.com/rhsev/mark-twin/actions/workflows/test.yml)
5
5
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
6
 
7
- Sync configuration folders between two Macs from self-documenting Markdown files.
7
+ Sync configuration between machines, from Markdown files that explain
8
+ themselves.
8
9
 
9
- Sync entries are defined in Markdown files with YAML blockshuman-readable,
10
- self-documenting, and queryable via [grubber](https://github.com/rhsev/grubber).
11
- Selection is interactive via [fzf](https://github.com/junegunn/fzf), with a
12
- Markdown preview rendered by [apex](https://github.com/ttscoff/apex) and
13
- optional post-sync actions on the target via [mi.lan](https://github.com/rhsev/mi.lan).
10
+ A sync-file is a normal Markdown document. The prose explains it to you and
11
+ to whatever assistant you point at the file later. The fenced YAML blocks are
12
+ what twin acts on:
14
13
 
15
- ## Why?
14
+ ````markdown
15
+ ---
16
+ Active: 1
17
+ Source: /Users/admin
18
+ Target: admin@macbook:/Users/admin
19
+ ---
20
+
21
+ ## Fish Shell
22
+
23
+ Shell config, including completions and abbreviations. `local.fish` stays
24
+ machine-specific — the laptop keeps its own.
16
25
 
17
- Sync-definitions in Markdown + YAML are three things at once:
26
+ ```yaml
27
+ Program: Fish Shell
28
+ Path: .config/fish
29
+ Description: Fish Shell configuration
30
+ Own: conf.d/local.fish
31
+ ```
32
+ ````
18
33
 
19
- - **Human-readable.** Plain Markdown, no twin-specific syntax to learn. The
20
- Markdown frame documents *why* a path is synced, not just what, so you can
21
- read your own sync-files in a year and still understand them.
22
- - **Machine-readable.** The YAML blocks are queryable via grubber, so any tool
23
- (twin, but also future ones) can act on the same source of truth.
24
- - **AI-writable.** LLMs handle Markdown + YAML well. You can ask an assistant to
25
- add new entries or refactor existing ones, and the result stays valid for both
26
- humans and grubber.
34
+ `twin` then shows you what differs and syncs what you pick. Under the
35
+ hood it is `rsync`; the point is the file above, which still tells you in a
36
+ year *why* a path is synced, not just that it is.
37
+
38
+ That is the whole idea, and for most entries it stays exactly that small. The
39
+ rest of this README is long because twin does handle the awkward cases — files
40
+ the other machine owns, both sides changed since last time, paths that differ
41
+ per host — not because you need any of them on day one. Skip what you don't
42
+ need.
43
+
44
+ ## Why Markdown
45
+
46
+ - **You can read it later.** The reason a path is synced lives next to the
47
+ path, in prose, not in a comment you stopped writing after the third entry.
48
+ - **Machines can read it too.** The YAML blocks are extracted by
49
+ [grubber](https://github.com/rhsev/grubber), so the same file can feed other
50
+ tools, not just twin.
51
+ - **It stays editable by hand.** No generated state, no database. Add a block
52
+ in your editor and twin picks it up.
53
+ - **And yes, ask your AI to write them.** Markdown with YAML blocks is exactly
54
+ what language models are good at. Show one an existing sync-file, describe
55
+ the next entry, and what comes back is valid for grubber and still readable
56
+ by you — which is the whole bargain of this format.
27
57
 
28
58
  ## Screenshots
29
59
 
@@ -37,22 +67,21 @@ compact preview of the relevant sync-file section, rendered by apex:
37
67
 
38
68
  ![Stage 2 — Fish Shell paths with apex preview](https://raw.githubusercontent.com/rhsev/mark-twin/main/docs/stage_2_fish.png)
39
69
 
40
- ## Installation
41
-
42
- ### 1. Install grubber
43
-
44
- twin parses sync-files via [grubber](https://github.com/rhsev/grubber), a
45
- small Go binary. Download the latest release for your platform from
46
- [github.com/rhsev/grubber/releases](https://github.com/rhsev/grubber/releases)
47
- and put it somewhere in your `PATH` (e.g. `/usr/local/bin/grubber`).
48
-
49
- ### 2. Install twin
70
+ ## Install
50
71
 
51
72
  ```bash
52
73
  gem install mark-twin
74
+ brew install fzf
53
75
  ```
54
76
 
55
- Or from source:
77
+ Plus [grubber](https://github.com/rhsev/grubber/releases), a small Go binary —
78
+ download it and put it in your `PATH`. `rsync` ships with macOS.
79
+
80
+ Optional: one of `apex`, `glow` or `bat` for the preview pane (tried in that
81
+ order, `cat` if none are present). `twin doctor` reports what it found.
82
+
83
+ <details>
84
+ <summary>From source</summary>
56
85
 
57
86
  ```bash
58
87
  git clone https://github.com/rhsev/mark-twin.git
@@ -60,146 +89,235 @@ cd mark-twin
60
89
  gem build mark-twin.gemspec
61
90
  gem install ./mark-twin-*.gem
62
91
  ```
92
+ </details>
63
93
 
64
- ### 3. Other tools
65
-
66
- Also required in `PATH`: `rsync` (preinstalled on macOS), `fzf`
67
- (`brew install fzf`). For the stage-2 preview, one of `apex`, `glow`,
68
- or `bat` is recommended (falls back in that order; `cat` if none are
69
- present).
94
+ ## Getting started
70
95
 
71
- ## Quickstart
96
+ **1. Decide where the other side lives.** `Target:` takes either form, and
97
+ neither is the special case:
72
98
 
73
- Twin assumes the target machine is reachable as a mounted volume (typically
74
- via SMB or NFS). The mount check is enforced before any sync.
99
+ ```yaml
100
+ Target: admin@macbook:/Users/admin # over ssh
101
+ Target: /Volumes/macbook/Users/admin # a mounted volume (SMB, NFS, …)
102
+ ```
75
103
 
76
- 1. **Pick a directory for sync-files** (anywhere; this example uses `~/Sync`):
104
+ Over ssh you need key-based login twin runs `ssh -o BatchMode=yes` and never
105
+ prompts for a password, so set up `ssh-copy-id macbook` first. A mounted volume
106
+ needs no keys but has to be mounted; twin checks before every sync. The
107
+ [comparison below](#mounted-volume-or-ssh) covers the two differences that
108
+ actually matter.
77
109
 
78
- ```bash
79
- mkdir -p ~/Sync
80
- ```
110
+ **2. Point twin at a folder for sync-files**, in `~/.config/twin/config.yaml`:
81
111
 
82
- 2. **Create the config** at `~/.config/twin/config.yaml`:
112
+ ```yaml
113
+ sync_dir: ~/Sync
83
114
 
84
- ```yaml
85
- sync_dir: ~/Sync
86
- global_excludes:
87
- - .DS_Store
88
- - .git/
89
- ```
115
+ global_excludes:
116
+ - .DS_Store
117
+ - .git/
118
+ ```
90
119
 
91
- 3. **Drop a sync-file** into `~/Sync`. The simplest starting point is to copy
92
- one of the [examples](examples/) and adapt the frontmatter:
120
+ **3. Write a sync-file**, or start from one of the [examples](examples/):
93
121
 
94
- ```bash
95
- cp examples/home.md ~/Sync/
96
- $EDITOR ~/Sync/home.md # edit Source: and Target:
97
- ```
122
+ ```bash
123
+ mkdir -p ~/Sync
124
+ cp examples/home.md ~/Sync/
125
+ $EDITOR ~/Sync/home.md # adjust Source: and Target:
126
+ ```
98
127
 
99
- 4. **Run twin**:
128
+ `twin add ~/.config/fish` does the same interactively, if you prefer prompts to
129
+ an editor — it finds the matching sync-file, derives the relative path,
130
+ suggests excludes for what it sees in the directory, and appends a block with a
131
+ prose stub.
100
132
 
101
- ```bash
102
- twin
103
- ```
133
+ **4. Look before you leap:**
104
134
 
105
- Pick a program, then the paths to sync, hit Enter.
135
+ ```bash
136
+ twin status # what differs
137
+ twin sync --dry-run # what a sync would do, without doing it
138
+ twin # interactive: pick a program, pick paths, Enter
139
+ ```
106
140
 
107
- ## Usage
141
+ That is the whole loop. Everything below is detail you can come back for.
108
142
 
109
- Twin has two modes: an **interactive interface** (default — see screenshots
110
- above) and **CLI commands** for status checks and batch sync.
143
+ ## Everyday commands
111
144
 
112
145
  ```bash
113
- twin # TUI — all programs across all sync-files
114
- twin home.md # TUI — one sync-file in sync_dir (by name)
115
- twin /abs/path/to/file.md # TUIany sync-file by absolute path
116
- twin ./relative/dir/ # TUI — all sync-files in a directory
146
+ twin # picker — all programs across all sync-files
147
+ twin home.md # picker — one sync-file in sync_dir (by name)
148
+ twin ./some/dir/ # pickerall sync-files in a directory
117
149
  twin list # plain listing
118
150
  twin status # listing with source/target mtimes
119
151
  twin sync -p grubber # sync one program by name pattern
120
152
  twin sync --file=repos # sync all programs from a sync-file
121
153
  twin sync --dry-run # preview without writing
122
- twin doctor # check tools, renderers, and sync targets
154
+ twin log # recent journal entries (-n N, --json)
155
+ twin doctor # check tools, renderers, and targets
123
156
  twin --help # show usage
124
157
  ```
125
158
 
126
- File argument resolution:
127
-
128
- - bare name (no `/`) → looked up by substring in `sync_dir`
129
- - contains `/` → resolved as path (absolute or relative); file or directory both work
130
-
131
- ## Configuration
159
+ A file argument without `/` is matched by substring against sync-file names in
160
+ `sync_dir`; anything containing `/` is treated as a path, file or directory.
132
161
 
133
- `~/.config/twin/config.yaml`:
162
+ Every job is journaled to `~/.local/state/twin/log.jsonl` — one JSON line with
163
+ timestamp, program, path and outcome. `twin sync` exits non-zero if any job
164
+ failed.
134
165
 
135
- ```yaml
136
- sync_dir: /path/to/sync-files
166
+ ## Sync-files
137
167
 
138
- global_excludes:
139
- - .DS_Store
140
- - .git/
168
+ One Markdown file per relationship. Frontmatter sets it up, YAML blocks define
169
+ the individual paths. Frontmatter fields are merged into every block, so
170
+ `Source:`/`Target:` are usually written once at the top — but a block may
171
+ override them, which is how one file can serve several destinations.
141
172
 
142
- # Optional preview rendering (apex):
143
- # apex_theme: default
144
- # apex_width: 80
145
- # apex_code_highlight: monokai
146
- # apex_code_highlight_theme: dark
147
- ```
173
+ Blocks sharing a `Program` are grouped: they are selected together, synced
174
+ together, and **run in the order they appear in the file**.
148
175
 
149
- Environment overrides: `TWIN_SYNC_DIR`, `TWIN_CONFIG`, `TWIN_HOST` (which host
150
- twin runs as — lets one config serve both machines).
176
+ ### Field reference
151
177
 
152
- ## Sync-files
178
+ Field names are capitalised English. An unknown key is ignored silently and a
179
+ missing `Active` counts as `0`, so a typo shows up as an entry that never syncs
180
+ rather than as an error.
153
181
 
154
- Each Markdown file represents one sync relationship. Frontmatter defines the
155
- relationship (Source/Target); YAML blocks define individual paths.
182
+ | Field | Where | Meaning |
183
+ |---|---|---|
184
+ | `Program` | block | Group name; blocks sharing it sync together |
185
+ | `Path` | block | Path relative to `Source` (file or directory) |
186
+ | `Source` | either | Absolute base path on this machine |
187
+ | `Target` | either | Absolute base path, or `user@host:/path` for ssh |
188
+ | `Target-Path` | block | Path under `Target`, when it differs from `Path` |
189
+ | `Active` | either | `1` syncs, `0` skips. Default `0` |
190
+ | `Description` | block | Shown in listings and the picker |
191
+ | `Label` | either | Free-text grouping, filterable via `--label` |
192
+ | `Exclude` | block | Comma-separated paths that are not part of the sync |
193
+ | `Own` | block | Comma-separated paths the **target** owns |
194
+ | `Delete` | block | `true` mirrors deletions, with backups |
195
+ | `Cmd` | block | Shell command, run only when bytes actually moved |
196
+ | `Render` | block | `true` substitutes `{{tokens}}` instead of copying |
197
+
198
+ ### Exclude or Own?
199
+
200
+ Both keep rsync away from a path and both take a comma-separated list
201
+ (`Exclude: *.log, __pycache__/`). What differs is the meaning, and `twin status`
202
+ reports them apart:
203
+
204
+ - **`Exclude:`** — not part of this sync at all. Build artefacts, logs, caches,
205
+ `.git/`, a test script with no business on the other machine.
206
+ - **`Own:`** — inside the sync scope, but the **target** owns it.
207
+ Machine-specific configuration the source must never clobber:
208
+ `conf.d/local.fish`, `lazy-lock.json`, a per-host credentials file.
209
+
210
+ The distinction is documentation, not mechanism. Six months on, `Own:` still
211
+ says "deliberate, the other machine maintains this", where the same entry sitting
212
+ in `Exclude:` between `*.dwarf` and `.DS_Store` reads like noise you once
213
+ filtered out.
214
+
215
+ ### Cmd: doing something after a sync
216
+
217
+ `Cmd` runs a shell command once rsync has actually transferred bytes — a no-op
218
+ sync runs nothing. Typically a `curl` to a local automation endpoint like
219
+ [mi.lan](https://github.com/rhsev/mi.lan), or an `ssh host '…'` for remote
220
+ targets (`Cmd` always runs locally).
221
+
222
+ It belongs to a block rather than to the program, which is what you want when
223
+ different paths need different follow-ups — reload nginx after its config,
224
+ re-link binaries after `bin/`. For a **restart**, put the command in the
225
+ **last** block: jobs run in file order, so a restart placed earlier brings the
226
+ service back before the remaining paths are written. The same command repeated
227
+ across blocks restarts repeatedly, for the same reason. One command, last block.
228
+
229
+ ### Delete: mirroring removals
230
+
231
+ `Delete: true` adds `--delete`, so files removed from the source disappear on
232
+ the target too. Deleted and overwritten files are moved to
233
+ `<target>/.twin-backup/<timestamp>/` rather than destroyed — a safety net worth
234
+ pruning occasionally. It applies to `Delete` jobs only.
235
+
236
+ ## Mounted volume or ssh
237
+
238
+ Both are first-class. `twin status`, the picker, `Exclude`/`Own`, `Delete` and
239
+ `Cmd` behave identically; remote paths are stat'ed in a single ssh round-trip
240
+ per host, and an unreachable host shows as `?` instead of failing the scan.
241
+
242
+ Two differences are real:
243
+
244
+ | | mounted volume | ssh |
245
+ |---|---|---|
246
+ | Setup | volume must be mounted | ssh key (`ssh-copy-id`) |
247
+ | Before syncing | mount check | reachability check |
248
+ | `Render: true` | supported | **not** supported |
156
249
 
157
- See [examples/home.md](examples/home.md) and [examples/repos.md](examples/repos.md)
158
- for ready-to-adapt templates.
250
+ `Render` needs to read and write file contents on the target, which twin only
251
+ does locally. Keep rendered files on mounted targets, or render locally and sync
252
+ the result.
159
253
 
160
- Minimal example:
254
+ Syncing is push-only in both cases: `Source:` is always this machine.
161
255
 
162
- ````markdown
256
+ ```markdown
163
257
  ---
164
258
  Active: 1
165
- Label: mac-mini → macbook
166
- Source: /Users/admin
167
- Target: /Volumes/macbook/Users/admin
259
+ Label: mini → server
260
+ Source: /Volumes/lightning/Git/Website
261
+ Target: ralf@server:/srv/www
168
262
  ---
263
+ ```
169
264
 
170
- ## Fish Shell
265
+ ## When the target has changed too
171
266
 
172
- Configuration for the fish shell, including completions and abbreviations.
267
+ A sync has a direction: the source wins. But targets get edited — a quick fix
268
+ made on the server at midnight, a config tweaked where it runs. Twin looks for
269
+ that before it moves the first byte, and asks once for the whole program:
173
270
 
174
- ```yaml
175
- Program: Fish Shell
176
- Path: .config/fish
177
- Description: Fish Shell configuration
178
- Exclude: conf.d/local.fish
179
271
  ```
180
- ````
272
+ target has changed since the last sync — 1 file(s) differ:
273
+ ! app/code.rb (target 2h newer)
274
+ syncing would replace them with the source version.
275
+
276
+ overwrite these on the target and sync? [y]es / [d]iff / [n]o (abort)
277
+ ```
278
+
279
+ `d` prints a unified diff per file, then asks again. `n` aborts the entire
280
+ program — nothing is written, so you never end up with half a deploy applied.
281
+
282
+ Two properties make this bearable day to day:
283
+
284
+ - **Content, not timestamps.** A file that is merely newer on the target with
285
+ identical bytes is not a conflict and does not ask. Sync a tree in both
286
+ directions and you collect dozens of those; a prompt that fires on them gets
287
+ answered without being read.
288
+ - **Directory mtimes are ignored.** Editing a file in place leaves its
289
+ directory's mtime untouched, and `rsync -a` equalises those anyway. Twin asks
290
+ rsync what it would actually transfer instead of guessing from a directory.
181
291
 
182
- Frontmatter fields (`Active`, `Label`, `Source`, `Target`) are merged into
183
- every block by grubber. Multiple blocks can share the same `Program` — twin
184
- groups them and treats the program as the unit of selection.
292
+ `twin status` is still mtime-based and cannot see an in-place edit. It is the
293
+ cheap overview; `twin sync` is what decides.
185
294
 
186
- The optional `Cmd` field runs an arbitrary shell command after a successful
187
- sync — typically a `curl` to a local automation endpoint like
188
- [mi.lan](https://github.com/rhsev/mi.lan) to reload a program, restart a
189
- service, or notify another machine. The command only runs when rsync actually
190
- transferred bytes; no-op syncs skip it. See the Helix entry in
191
- [examples/home.md](examples/home.md).
295
+ ## Automation
192
296
 
193
- The optional `Delete: true` field adds `--delete` to the rsync invocation,
194
- so files removed from the source are also removed on the target. Useful for
195
- directory syncs where the target should mirror the source exactly.
297
+ For a scheduled run (launchd, cron):
298
+
299
+ ```bash
300
+ twin sync --quiet --skip-unavailable --skip-conflicts
301
+ ```
302
+
303
+ - `--quiet` — only conflicts, errors and jobs that changed something. A no-op
304
+ run is silent.
305
+ - `--skip-unavailable` — a laptop that isn't docked is skipped, not an error.
306
+ - `--skip-conflicts` — leave target-side changes alone and sync the rest.
307
+ Use `--force` instead to overwrite them.
308
+
309
+ Without a terminal and without one of those two flags, a real conflict aborts
310
+ the run with exit code 1 rather than picking an answer for you. Output and a
311
+ non-zero exit therefore mean something genuinely needs attention — which is
312
+ what launchd's logging wants. The journal records every job regardless.
196
313
 
197
314
  ## Templating
198
315
 
199
- Some configs differ per machine a LaunchAgent plist that points at
200
- `/Volumes/lightning/…` on one Mac and `/Users/ralf/…` on another, a
201
- `settings.json` with a device-specific id. Those used to fall out of twin and
202
- get hand-maintained. Templating folds them back into one source of truth.
316
+ *Skip this until you hit the problem it solves.*
317
+
318
+ Some configs differ per machine a LaunchAgent plist pointing at
319
+ `/Volumes/lightning/…` on one Mac and `/Users/ralf/…` on another. Those used to
320
+ fall out of twin and get hand-maintained.
203
321
 
204
322
  Define a host table in `~/.config/twin/config.yaml`:
205
323
 
@@ -230,9 +348,8 @@ The distinction matters: a file *written* to the mount (`/Volumes/ralf/…`) but
230
348
 
231
349
  `Render: true` turns a block from copy into *render*: twin reads the source as a
232
350
  template, substitutes `{{…}}` in its **content**, and writes the result only if
233
- it differs from the current target (so a `Cmd` hook fires only on a real change).
234
- `Target-Path:` overrides the target-side relative path when it differs from the
235
- source layout:
351
+ it differs from the current target, so a `Cmd` hook fires only on a real change.
352
+ `Target-Path:` overrides the target-side relative path:
236
353
 
237
354
  ````markdown
238
355
  ## LiveSync LaunchAgent
@@ -248,15 +365,35 @@ Cmd: curl -s http://mi.lan/livesync-reload
248
365
  ```
249
366
  ````
250
367
 
251
- `twin doctor` checks that every `{{token}}` across your sync-files resolves, and
252
- `twin status` compares rendered output by content (not mtime). Without a `hosts`
253
- table, templating is inert and literal-path sync-files behave exactly as before.
368
+ `twin doctor` checks that every `{{token}}` resolves, and `twin status` compares
369
+ rendered output by content rather than mtime. Without a `hosts` table,
370
+ templating is inert and literal-path sync-files behave exactly as before.
371
+
372
+ ## Configuration
373
+
374
+ `~/.config/twin/config.yaml`:
375
+
376
+ ```yaml
377
+ sync_dir: /path/to/sync-files
378
+
379
+ global_excludes:
380
+ - .DS_Store
381
+ - .git/
382
+
383
+ # Optional preview rendering (apex):
384
+ # apex_theme: default
385
+ # apex_width: 80
386
+ # apex_code_highlight: monokai
387
+ # apex_code_highlight_theme: dark
388
+ ```
389
+
390
+ Environment overrides: `TWIN_SYNC_DIR`, `TWIN_CONFIG`, `TWIN_HOST` (which host
391
+ twin runs as — lets one config serve both machines).
254
392
 
255
393
  ## Design
256
394
 
257
- Sync instructions and context in one place the same Markdown file holds
258
- both the `Path:` directives and the prose explaining them. No TUI framework:
259
- `fzf` does the interactive part, `apex` the rendering.
395
+ Sync instructions and their context in one place. No TUI framework: `fzf` does
396
+ the interactive part, `apex` the rendering, `rsync` the work.
260
397
 
261
398
  See [ARCHITECTURE.md](ARCHITECTURE.md) for the data model and internals.
262
399
 
@@ -265,3 +402,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the data model and internals.
265
402
  ```bash
266
403
  rake test
267
404
  ```
405
+
406
+ ---
407
+
408
+ *Part of a family of plain-text tools — the [profile page](https://github.com/rhsev) has the map.*