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 +4 -4
- data/ARCHITECTURE.md +85 -6
- data/README.md +270 -129
- data/bin/twin +5 -0
- data/lib/twin/add.rb +177 -0
- data/lib/twin/cli.rb +189 -26
- data/lib/twin/conflict.rb +143 -0
- data/lib/twin/journal.rb +49 -0
- data/lib/twin/picker.rb +2 -0
- data/lib/twin/remote.rb +72 -0
- data/lib/twin/scanner.rb +58 -5
- data/lib/twin/sync.rb +58 -16
- data/lib/twin/version.rb +1 -1
- data/lib/twin.rb +4 -0
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5bae405ea7916f59395530f5372cd3bde2aa4ff742ca4cf08142ca16be5c7cd9
|
|
4
|
+
data.tar.gz: b05b48d23901e7c6c2fd313ecc0f8ef2dbf172ab77ef38b5575088031dc186da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
`
|
|
52
|
-
|
|
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`)
|
|
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
|
[](https://github.com/rhsev/mark-twin/actions/workflows/test.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
Sync configuration
|
|
7
|
+
Sync configuration between machines, from Markdown files that explain
|
|
8
|
+
themselves.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|

|
|
39
69
|
|
|
40
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
+
**1. Decide where the other side lives.** `Target:` takes either form, and
|
|
97
|
+
neither is the special case:
|
|
72
98
|
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
mkdir -p ~/Sync
|
|
80
|
-
```
|
|
110
|
+
**2. Point twin at a folder for sync-files**, in `~/.config/twin/config.yaml`:
|
|
81
111
|
|
|
82
|
-
|
|
112
|
+
```yaml
|
|
113
|
+
sync_dir: ~/Sync
|
|
83
114
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
- .git/
|
|
89
|
-
```
|
|
115
|
+
global_excludes:
|
|
116
|
+
- .DS_Store
|
|
117
|
+
- .git/
|
|
118
|
+
```
|
|
90
119
|
|
|
91
|
-
3.
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
twin
|
|
103
|
-
```
|
|
133
|
+
**4. Look before you leap:**
|
|
104
134
|
|
|
105
|
-
|
|
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
|
-
|
|
141
|
+
That is the whole loop. Everything below is detail you can come back for.
|
|
108
142
|
|
|
109
|
-
|
|
110
|
-
above) and **CLI commands** for status checks and batch sync.
|
|
143
|
+
## Everyday commands
|
|
111
144
|
|
|
112
145
|
```bash
|
|
113
|
-
twin #
|
|
114
|
-
twin home.md #
|
|
115
|
-
twin /
|
|
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/ # picker — all 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
|
|
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
|
-
|
|
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
|
-
`~/.
|
|
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
|
-
|
|
136
|
-
sync_dir: /path/to/sync-files
|
|
166
|
+
## Sync-files
|
|
137
167
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
150
|
-
twin runs as — lets one config serve both machines).
|
|
176
|
+
### Field reference
|
|
151
177
|
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
254
|
+
Syncing is push-only in both cases: `Source:` is always this machine.
|
|
161
255
|
|
|
162
|
-
|
|
256
|
+
```markdown
|
|
163
257
|
---
|
|
164
258
|
Active: 1
|
|
165
|
-
Label:
|
|
166
|
-
Source: /
|
|
167
|
-
Target: /
|
|
259
|
+
Label: mini → server
|
|
260
|
+
Source: /Volumes/lightning/Git/Website
|
|
261
|
+
Target: ralf@server:/srv/www
|
|
168
262
|
---
|
|
263
|
+
```
|
|
169
264
|
|
|
170
|
-
##
|
|
265
|
+
## When the target has changed too
|
|
171
266
|
|
|
172
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
234
|
-
`Target-Path:` overrides the target-side relative path
|
|
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}}`
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
258
|
-
|
|
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.*
|