mark-twin 0.3.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 +28 -1
- data/README.md +258 -217
- data/lib/twin/cli.rb +84 -10
- data/lib/twin/conflict.rb +143 -0
- data/lib/twin/scanner.rb +17 -2
- data/lib/twin/sync.rb +13 -8
- data/lib/twin/version.rb +1 -1
- data/lib/twin.rb +1 -0
- metadata +3 -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
|
|
|
@@ -32,6 +37,7 @@ lib/twin/
|
|
|
32
37
|
config.rb ~/.config/twin/config.yaml loader; host table → var_map
|
|
33
38
|
scanner.rb Job, Program structs; grubber + template + stat → grouped Programs
|
|
34
39
|
sync.rb rsync / render execution, mount check, post-sync hook
|
|
40
|
+
conflict.rb target-side changes: detection via paired dry-runs, diffs
|
|
35
41
|
journal.rb append-only sync journal (~/.local/state/twin/log.jsonl)
|
|
36
42
|
add.rb `twin add` — interactive scaffolding of new sync entries
|
|
37
43
|
picker.rb fzf wrapper with apex preview
|
|
@@ -46,11 +52,16 @@ test/test_pure.rb
|
|
|
46
52
|
**Job** — one YAML block:
|
|
47
53
|
|
|
48
54
|
```
|
|
49
|
-
program, path, description, active, excludes, label, source, target, cmd,
|
|
55
|
+
program, path, description, active, excludes, owned, label, source, target, cmd,
|
|
50
56
|
delete, render, render_outdated, target_path_field, sync_file,
|
|
51
57
|
source_exists, target_exists, source_mtime, target_mtime, conflict
|
|
52
58
|
```
|
|
53
59
|
|
|
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
|
+
|
|
54
65
|
`Job#status` → one of `disabled / unreachable / both_missing / missing_source /
|
|
55
66
|
missing_target / target_newer / in_sync / source_newer`. Render jobs derive status from content
|
|
56
67
|
(`render_outdated`), not mtime; non-render jobs ignore those fields.
|
|
@@ -65,6 +76,22 @@ name, jobs
|
|
|
65
76
|
`Program#status` aggregates jobs (worst state wins). Selection in the picker
|
|
66
77
|
operates on Programs, not individual Jobs.
|
|
67
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
|
+
|
|
68
95
|
## Configuration
|
|
69
96
|
|
|
70
97
|
`~/.config/twin/config.yaml`:
|
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
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
Shell config, including completions and abbreviations. `local.fish` stays
|
|
24
|
+
machine-specific — the laptop keeps its own.
|
|
25
|
+
|
|
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,242 +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
154
|
twin log # recent journal entries (-n N, --json)
|
|
123
|
-
twin doctor # check tools, renderers, and
|
|
155
|
+
twin doctor # check tools, renderers, and targets
|
|
124
156
|
twin --help # show usage
|
|
125
157
|
```
|
|
126
158
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
`twin add <path>` scaffolds a new entry interactively, so the judgment calls
|
|
130
|
-
of setting up a sync become prompts with defaults:
|
|
131
|
-
|
|
132
|
-
```
|
|
133
|
-
$ twin add ~/.config/fish
|
|
134
|
-
sync-file: home_macbook.md (/Users/admin → /Volumes/macbook/Users/admin)
|
|
135
|
-
Program name [fish]:
|
|
136
|
-
Why is this synced? (one line of prose): Shell config incl. abbreviations.
|
|
137
|
-
Description (short, for listings) [fish]: Fish Shell configuration
|
|
138
|
-
Exclude (comma-separated) [.git/]:
|
|
139
|
-
Mirror deletions on target (Delete: true)? (y/N) [n]:
|
|
140
|
-
Post-sync Cmd (empty for none):
|
|
141
|
-
|
|
142
|
-
added "fish" to home_macbook.md
|
|
143
|
-
Run a dry-run now? (Y/n) [y]:
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
twin matches the path against the `Source:` roots of your sync-files (asking
|
|
147
|
-
which to use when several match), derives `Path:` relative to that root,
|
|
148
|
-
suggests excludes for what it finds in the directory (`.git/`,
|
|
149
|
-
`node_modules/`, `.venv/`, …), refuses duplicates, and appends a Markdown
|
|
150
|
-
block — prose stub included. If no sync-file covers the path, it offers to
|
|
151
|
-
create one (frontmatter and all), which is also the quickest way to start
|
|
152
|
-
syncing to a new SSH target.
|
|
153
|
-
|
|
154
|
-
Every synced job is journaled to `~/.local/state/twin/log.jsonl` (one JSON
|
|
155
|
-
line per job: timestamp, program, path, outcome). `twin log` shows the recent
|
|
156
|
-
history; `twin sync` exits non-zero when any job failed.
|
|
157
|
-
|
|
158
|
-
### Unattended syncs
|
|
159
|
-
|
|
160
|
-
For a scheduled run (launchd, cron), combine two flags:
|
|
161
|
-
|
|
162
|
-
```bash
|
|
163
|
-
twin sync --quiet --skip-unavailable
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
`--quiet` prints only conflicts, errors, and jobs that actually changed
|
|
167
|
-
something — a no-op run is silent. `--skip-unavailable` skips targets that
|
|
168
|
-
are currently unmounted or unreachable instead of aborting, so a laptop that
|
|
169
|
-
isn't docked doesn't turn into an error. Combined, the run produces output
|
|
170
|
-
(and a non-zero exit) only when something genuinely needs attention, which is
|
|
171
|
-
exactly what launchd's stdout/stderr logging wants; the journal still records
|
|
172
|
-
every job.
|
|
173
|
-
|
|
174
|
-
File argument resolution:
|
|
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.
|
|
175
161
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
## Configuration
|
|
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.
|
|
180
165
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
```yaml
|
|
184
|
-
sync_dir: /path/to/sync-files
|
|
166
|
+
## Sync-files
|
|
185
167
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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.
|
|
189
172
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
# apex_width: 80
|
|
193
|
-
# apex_code_highlight: monokai
|
|
194
|
-
# apex_code_highlight_theme: dark
|
|
195
|
-
```
|
|
173
|
+
Blocks sharing a `Program` are grouped: they are selected together, synced
|
|
174
|
+
together, and **run in the order they appear in the file**.
|
|
196
175
|
|
|
197
|
-
|
|
198
|
-
twin runs as — lets one config serve both machines).
|
|
176
|
+
### Field reference
|
|
199
177
|
|
|
200
|
-
|
|
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.
|
|
201
181
|
|
|
202
|
-
|
|
203
|
-
|
|
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 |
|
|
204
249
|
|
|
205
|
-
|
|
206
|
-
|
|
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.
|
|
207
253
|
|
|
208
|
-
|
|
254
|
+
Syncing is push-only in both cases: `Source:` is always this machine.
|
|
209
255
|
|
|
210
|
-
|
|
256
|
+
```markdown
|
|
211
257
|
---
|
|
212
258
|
Active: 1
|
|
213
|
-
Label:
|
|
214
|
-
Source: /
|
|
215
|
-
Target: /
|
|
259
|
+
Label: mini → server
|
|
260
|
+
Source: /Volumes/lightning/Git/Website
|
|
261
|
+
Target: ralf@server:/srv/www
|
|
216
262
|
---
|
|
263
|
+
```
|
|
217
264
|
|
|
218
|
-
##
|
|
265
|
+
## When the target has changed too
|
|
219
266
|
|
|
220
|
-
|
|
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:
|
|
221
270
|
|
|
222
|
-
```yaml
|
|
223
|
-
Program: Fish Shell
|
|
224
|
-
Path: .config/fish
|
|
225
|
-
Description: Fish Shell configuration
|
|
226
|
-
Exclude: conf.d/local.fish
|
|
227
271
|
```
|
|
228
|
-
|
|
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.
|
|
229
275
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
groups them and treats the program as the unit of selection.
|
|
233
|
-
|
|
234
|
-
The optional `Cmd` field runs an arbitrary shell command after a successful
|
|
235
|
-
sync — typically a `curl` to a local automation endpoint like
|
|
236
|
-
[mi.lan](https://github.com/rhsev/mi.lan) to reload a program, restart a
|
|
237
|
-
service, or notify another machine. The command only runs when rsync actually
|
|
238
|
-
transferred bytes; no-op syncs skip it. See the Helix entry in
|
|
239
|
-
[examples/home.md](examples/home.md).
|
|
276
|
+
overwrite these on the target and sync? [y]es / [d]iff / [n]o (abort)
|
|
277
|
+
```
|
|
240
278
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
directory syncs where the target should mirror the source exactly. As a
|
|
244
|
-
safety net, deleted and overwritten files are moved to a per-run backup
|
|
245
|
-
directory on the target (`<target>/.twin-backup/<timestamp>/`) instead of
|
|
246
|
-
being destroyed — prune it occasionally.
|
|
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.
|
|
247
281
|
|
|
248
|
-
|
|
282
|
+
Two properties make this bearable day to day:
|
|
249
283
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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.
|
|
253
291
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
Active: 1
|
|
257
|
-
Label: mini → server
|
|
258
|
-
Source: /Volumes/lightning/Git/Website
|
|
259
|
-
Target: ralf@server:/srv/www
|
|
260
|
-
---
|
|
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.
|
|
261
294
|
|
|
262
|
-
##
|
|
295
|
+
## Automation
|
|
263
296
|
|
|
264
|
-
|
|
297
|
+
For a scheduled run (launchd, cron):
|
|
265
298
|
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
Path: public
|
|
269
|
-
Description: static site
|
|
270
|
-
Exclude: .git/
|
|
271
|
-
Cmd: ssh ralf@server 'sudo systemctl reload caddy'
|
|
299
|
+
```bash
|
|
300
|
+
twin sync --quiet --skip-unavailable --skip-conflicts
|
|
272
301
|
```
|
|
273
|
-
````
|
|
274
302
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
-
|
|
278
|
-
-
|
|
279
|
-
`
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
- **The mount check becomes a reachability check** — sync aborts if the host
|
|
286
|
-
doesn't answer.
|
|
287
|
-
- **`Cmd` runs locally**, exactly as for mounted targets. To act on the
|
|
288
|
-
server, make the command an `ssh host '…'` call (see example above).
|
|
289
|
-
- **`Render: true` is not supported** for remote targets (twin would have to
|
|
290
|
-
read and write remote file contents). Render locally or keep rendered files
|
|
291
|
-
on mounted targets.
|
|
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.
|
|
292
313
|
|
|
293
314
|
## Templating
|
|
294
315
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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.
|
|
299
321
|
|
|
300
322
|
Define a host table in `~/.config/twin/config.yaml`:
|
|
301
323
|
|
|
@@ -326,9 +348,8 @@ The distinction matters: a file *written* to the mount (`/Volumes/ralf/…`) but
|
|
|
326
348
|
|
|
327
349
|
`Render: true` turns a block from copy into *render*: twin reads the source as a
|
|
328
350
|
template, substitutes `{{…}}` in its **content**, and writes the result only if
|
|
329
|
-
it differs from the current target
|
|
330
|
-
`Target-Path:` overrides the target-side relative path
|
|
331
|
-
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:
|
|
332
353
|
|
|
333
354
|
````markdown
|
|
334
355
|
## LiveSync LaunchAgent
|
|
@@ -344,15 +365,35 @@ Cmd: curl -s http://mi.lan/livesync-reload
|
|
|
344
365
|
```
|
|
345
366
|
````
|
|
346
367
|
|
|
347
|
-
`twin doctor` checks that every `{{token}}`
|
|
348
|
-
|
|
349
|
-
|
|
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).
|
|
350
392
|
|
|
351
393
|
## Design
|
|
352
394
|
|
|
353
|
-
Sync instructions and context in one place
|
|
354
|
-
|
|
355
|
-
`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.
|
|
356
397
|
|
|
357
398
|
See [ARCHITECTURE.md](ARCHITECTURE.md) for the data model and internals.
|
|
358
399
|
|
data/lib/twin/cli.rb
CHANGED
|
@@ -25,6 +25,7 @@ module Twin
|
|
|
25
25
|
twin status [--all] [--label X] [--file X] [--json]
|
|
26
26
|
twin sync [-p PATTERN] [--label X] [--file X] [--all] [--dry-run]
|
|
27
27
|
[--quiet] [--skip-unavailable]
|
|
28
|
+
[--force] [--skip-conflicts]
|
|
28
29
|
twin add <path> scaffold a new sync entry for a local path
|
|
29
30
|
twin log [-n N] [--json] recent journal entries (default 20)
|
|
30
31
|
twin doctor check tools, renderers, and sync targets
|
|
@@ -34,6 +35,12 @@ module Twin
|
|
|
34
35
|
bare name (no /) → matched by substring against sync-file names
|
|
35
36
|
contains / → resolved as path; file or directory both work
|
|
36
37
|
|
|
38
|
+
TARGET-SIDE CHANGES:
|
|
39
|
+
Before syncing, twin looks for files the target changed more recently
|
|
40
|
+
AND whose content differs, then asks once for the whole program.
|
|
41
|
+
--force overwrite them without asking (for automation)
|
|
42
|
+
--skip-conflicts leave them alone, sync everything else
|
|
43
|
+
|
|
37
44
|
CONFIG:
|
|
38
45
|
~/.config/twin/config.yaml
|
|
39
46
|
TWIN_SYNC_DIR overrides sync_dir
|
|
@@ -149,6 +156,8 @@ module Twin
|
|
|
149
156
|
puts " #{j.path}#{conflict}"
|
|
150
157
|
puts " src #{src}"
|
|
151
158
|
puts " dst #{tgt}"
|
|
159
|
+
# Named, not hidden: these belong to the target on purpose.
|
|
160
|
+
puts " own #{j.owned.join(', ')}" unless j.owned.nil? || j.owned.empty?
|
|
152
161
|
end
|
|
153
162
|
end
|
|
154
163
|
end
|
|
@@ -171,7 +180,8 @@ module Twin
|
|
|
171
180
|
results = programs.map do |p|
|
|
172
181
|
sync_jobs(cfg, p, p.active_jobs,
|
|
173
182
|
dry_run: opts[:dry_run], quiet: opts[:quiet],
|
|
174
|
-
skip_unavailable: opts[:skip_unavailable]
|
|
183
|
+
skip_unavailable: opts[:skip_unavailable],
|
|
184
|
+
force: opts[:force], skip_conflicts: opts[:skip_conflicts])
|
|
175
185
|
end
|
|
176
186
|
exit 1 unless results.all?
|
|
177
187
|
end
|
|
@@ -179,7 +189,8 @@ module Twin
|
|
|
179
189
|
# Sync the given jobs. Returns true when every attempted job succeeded.
|
|
180
190
|
# quiet: print only conflicts, errors, and jobs that changed something
|
|
181
191
|
# skip_unavailable: skip jobs whose target is unmounted/unreachable instead of aborting
|
|
182
|
-
def sync_jobs(cfg, program, jobs, dry_run: false, quiet: false, skip_unavailable: false
|
|
192
|
+
def sync_jobs(cfg, program, jobs, dry_run: false, quiet: false, skip_unavailable: false,
|
|
193
|
+
force: false, skip_conflicts: false)
|
|
183
194
|
jobs = jobs.select { |j| j.active == 1 }
|
|
184
195
|
return true if jobs.empty?
|
|
185
196
|
|
|
@@ -199,17 +210,17 @@ module Twin
|
|
|
199
210
|
end
|
|
200
211
|
return true if jobs.empty?
|
|
201
212
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
213
|
+
# Decide about target-side changes BEFORE the first byte moves: a partly
|
|
214
|
+
# applied program is worse than none at all. resolve_conflicts returns
|
|
215
|
+
# false when the run must not happen.
|
|
216
|
+
force = resolve_conflicts(cfg, jobs, dry_run: dry_run, quiet: quiet,
|
|
217
|
+
force: force, skip_conflicts: skip_conflicts)
|
|
218
|
+
return false if force == :abort
|
|
208
219
|
|
|
209
220
|
header_printed = false
|
|
210
221
|
all_ok = true
|
|
211
222
|
jobs.each do |job|
|
|
212
|
-
success, output, transferred = Twin::Sync.run_job(cfg, job, dry_run: dry_run)
|
|
223
|
+
success, output, transferred = Twin::Sync.run_job(cfg, job, dry_run: dry_run, force: force)
|
|
213
224
|
Twin::Journal.record(job, success: success, transferred: transferred, output: output) unless dry_run
|
|
214
225
|
all_ok &&= success
|
|
215
226
|
next if quiet && success && !transferred
|
|
@@ -225,6 +236,67 @@ module Twin
|
|
|
225
236
|
all_ok
|
|
226
237
|
end
|
|
227
238
|
|
|
239
|
+
# Settle what happens to files the target changed more recently, before any
|
|
240
|
+
# job runs. Returns true (overwrite them), false (leave them, --update keeps
|
|
241
|
+
# them) or :abort (sync nothing at all).
|
|
242
|
+
#
|
|
243
|
+
# The mtime pre-filter on each Job is coarse and fires often; only files
|
|
244
|
+
# whose content really differs reach the prompt. A prompt that cries wolf
|
|
245
|
+
# gets answered without reading it.
|
|
246
|
+
def resolve_conflicts(cfg, jobs, dry_run:, quiet:, force:, skip_conflicts:)
|
|
247
|
+
return true if force
|
|
248
|
+
return false if skip_conflicts || dry_run
|
|
249
|
+
|
|
250
|
+
conflicts = jobs.flat_map { |j| Twin::Conflict.detect(cfg, j) }
|
|
251
|
+
return false if conflicts.empty?
|
|
252
|
+
|
|
253
|
+
report_conflicts(conflicts)
|
|
254
|
+
|
|
255
|
+
unless $stdin.tty? && $stdout.tty?
|
|
256
|
+
warn ""
|
|
257
|
+
warn "abort: target has changes of its own and there is no terminal to ask."
|
|
258
|
+
warn " re-run with --force to overwrite them, or --skip-conflicts to keep them."
|
|
259
|
+
return :abort
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
loop do
|
|
263
|
+
print "\noverwrite these on the target and sync? [y]es / [d]iff / [n]o (abort) "
|
|
264
|
+
$stdout.flush
|
|
265
|
+
case $stdin.gets&.strip&.downcase
|
|
266
|
+
when "y", "yes" then return true
|
|
267
|
+
when "n", "no", "", nil then puts "aborted — nothing was synced."; return :abort
|
|
268
|
+
when "d", "diff" then show_diffs(conflicts)
|
|
269
|
+
else puts "please answer y, d or n."
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def report_conflicts(conflicts)
|
|
275
|
+
warn "target has changed since the last sync — #{conflicts.size} file(s) differ:"
|
|
276
|
+
conflicts.each do |c|
|
|
277
|
+
delta = c.age_delta
|
|
278
|
+
age = delta ? " (target #{format_age(delta)} newer)" : ""
|
|
279
|
+
warn " ! #{c.job.path == c.rel ? c.rel : File.join(c.job.path, c.rel)}#{age}"
|
|
280
|
+
end
|
|
281
|
+
warn "syncing would replace them with the source version."
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def show_diffs(conflicts)
|
|
285
|
+
conflicts.each do |c|
|
|
286
|
+
puts
|
|
287
|
+
puts "── #{c.rel} " + "─" * [0, 60 - c.rel.length].max
|
|
288
|
+
puts Twin::Conflict.diff(c)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def format_age(seconds)
|
|
293
|
+
s = seconds.to_i.abs
|
|
294
|
+
return "#{s}s" if s < 90
|
|
295
|
+
return "#{s / 60}m" if s < 5400
|
|
296
|
+
return "#{s / 3600}h" if s < 172_800
|
|
297
|
+
"#{s / 86_400}d"
|
|
298
|
+
end
|
|
299
|
+
|
|
228
300
|
# :ok, or a human-readable reason the target can't be synced right now.
|
|
229
301
|
def target_availability(job)
|
|
230
302
|
if job.remote?
|
|
@@ -374,11 +446,13 @@ module Twin
|
|
|
374
446
|
|
|
375
447
|
def parse_sync_opts(args)
|
|
376
448
|
opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false,
|
|
377
|
-
quiet: false, skip_unavailable: false }
|
|
449
|
+
quiet: false, skip_unavailable: false, force: false, skip_conflicts: false }
|
|
378
450
|
OptionParser.new do |o|
|
|
379
451
|
o.on("--all") { opts[:show_all] = true }
|
|
380
452
|
o.on("--label=L") { |v| opts[:label] = v }
|
|
381
453
|
o.on("--file=F") { |v| opts[:file] = v }
|
|
454
|
+
o.on("--force") { opts[:force] = true }
|
|
455
|
+
o.on("--skip-conflicts") { opts[:skip_conflicts] = true }
|
|
382
456
|
o.on("-p", "--pattern=P") { |v| opts[:pattern] = v }
|
|
383
457
|
o.on("--dry-run") { opts[:dry_run] = true }
|
|
384
458
|
o.on("-q", "--quiet") { opts[:quiet] = true }
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
require_relative "remote"
|
|
4
|
+
|
|
5
|
+
module Twin
|
|
6
|
+
# Finding out which files on the target would be silently skipped by rsync's
|
|
7
|
+
# --update, and whether that actually matters.
|
|
8
|
+
#
|
|
9
|
+
# `Job#conflict` is no help here. It compares the mtime of the job's own path,
|
|
10
|
+
# and for a directory job that is the directory's mtime — which says nothing
|
|
11
|
+
# about the files inside it. Worse, it is wrong in exactly the case that
|
|
12
|
+
# matters: editing a file in place leaves its directory's mtime untouched, and
|
|
13
|
+
# `rsync -a` equalises directory mtimes on every run anyway. A hand-edit on
|
|
14
|
+
# the target is therefore invisible to it.
|
|
15
|
+
#
|
|
16
|
+
# So we ask rsync, which has the answer already and knows its own matching
|
|
17
|
+
# rules better than any reimplementation would:
|
|
18
|
+
#
|
|
19
|
+
# 1. Which files does --update hold back?
|
|
20
|
+
# Dry-run twice, once with --update and once without. Everything the
|
|
21
|
+
# second run would transfer but the first would not is exactly the set
|
|
22
|
+
# --update protects.
|
|
23
|
+
#
|
|
24
|
+
# 2. Of those, which differ in content?
|
|
25
|
+
# Only these are worth asking about. A file that is merely newer — same
|
|
26
|
+
# bytes, later timestamp — is noise, and noise is what turns a prompt
|
|
27
|
+
# into a reflex. Sync both sides of a tree in either order and you get
|
|
28
|
+
# dozens of them.
|
|
29
|
+
#
|
|
30
|
+
# The first dry-run is also the cheap exit: when a forced run would move
|
|
31
|
+
# nothing, the job is fully in sync and the second run is skipped. That is the
|
|
32
|
+
# common case, so a quiet sync costs one extra stat-walk per job and no more.
|
|
33
|
+
module Conflict
|
|
34
|
+
# One file the target owns more recently than the source, with content that
|
|
35
|
+
# actually differs. `same_content` is nil when it could not be determined
|
|
36
|
+
# (remote targets) — treated as a conflict, because guessing in the other
|
|
37
|
+
# direction would overwrite work.
|
|
38
|
+
Entry = Struct.new(:job, :rel, :source_path, :target_path,
|
|
39
|
+
:source_mtime, :target_mtime, keyword_init: true) do
|
|
40
|
+
def age_delta
|
|
41
|
+
return nil unless source_mtime && target_mtime
|
|
42
|
+
target_mtime - source_mtime
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# rsync --itemize-changes line → relative path. Change lines start with an
|
|
47
|
+
# update type and a file type (">f.st...... lib/foo.rb"); "*deleting" and
|
|
48
|
+
# the surrounding prose do not match.
|
|
49
|
+
ITEMIZE_LINE = /\A[<>ch][fdLDS]\S*\s+(.+?)\s*\z/
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# Real conflicts for one job, in the order rsync reports them.
|
|
54
|
+
# Empty when --update holds nothing back, or holds back only identical files.
|
|
55
|
+
def detect(cfg, job)
|
|
56
|
+
# Render jobs compare by content already and never use --update.
|
|
57
|
+
return [] if job.render
|
|
58
|
+
return [] unless job.source_exists && job.target_exists
|
|
59
|
+
|
|
60
|
+
held_back_paths(cfg, job).filter_map { |rel| entry_for(job, rel) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Relative paths that --update would skip: (would transfer forced) minus
|
|
64
|
+
# (would transfer normally).
|
|
65
|
+
def held_back_paths(cfg, job)
|
|
66
|
+
forced = itemized_paths(Twin::Sync.rsync_args(cfg, job, dry_run: true, force: true))
|
|
67
|
+
return [] if forced.empty? # nothing to move at all — no need to ask rsync twice
|
|
68
|
+
|
|
69
|
+
normal = itemized_paths(Twin::Sync.rsync_args(cfg, job, dry_run: true, force: false))
|
|
70
|
+
forced - normal
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def itemized_paths(args)
|
|
74
|
+
output, status = Twin::Sync.run(args)
|
|
75
|
+
return [] unless status.success?
|
|
76
|
+
output.lines.filter_map do |line|
|
|
77
|
+
m = ITEMIZE_LINE.match(line)
|
|
78
|
+
next unless m
|
|
79
|
+
rel = m[1]
|
|
80
|
+
next if rel == "./" || rel.end_with?("/") # directories carry no content
|
|
81
|
+
rel
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Build an Entry unless source and target hold the same bytes.
|
|
86
|
+
def entry_for(job, rel)
|
|
87
|
+
src = resolve(job.source_path, rel)
|
|
88
|
+
tgt = resolve(job.target_path, rel)
|
|
89
|
+
|
|
90
|
+
# Remote targets can't be read here; report them rather than assume.
|
|
91
|
+
unless job.remote?
|
|
92
|
+
return nil if same_content?(src, tgt)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
Entry.new(
|
|
96
|
+
job: job, rel: rel, source_path: src, target_path: tgt,
|
|
97
|
+
source_mtime: mtime(src), target_mtime: job.remote? ? nil : mtime(tgt),
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A job path may be a single file — rsync then itemizes its basename, and
|
|
102
|
+
# the job path is already the full path.
|
|
103
|
+
def resolve(base, rel)
|
|
104
|
+
File.directory?(base) ? File.join(base, rel) : base
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def same_content?(a, b)
|
|
108
|
+
return false unless File.file?(a) && File.file?(b)
|
|
109
|
+
return false unless File.size(a) == File.size(b)
|
|
110
|
+
digest(a) == digest(b)
|
|
111
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
112
|
+
false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def digest(path) = Digest::SHA256.file(path).hexdigest
|
|
116
|
+
|
|
117
|
+
def mtime(path)
|
|
118
|
+
File.mtime(path)
|
|
119
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Unified diff for one entry, or a short note when it can't be produced.
|
|
124
|
+
def diff(entry)
|
|
125
|
+
return " (remote target — no diff available)" if entry.job.remote?
|
|
126
|
+
return " (binary or unreadable)" unless text?(entry.source_path) && text?(entry.target_path)
|
|
127
|
+
|
|
128
|
+
out, _status = Twin::Sync.run([
|
|
129
|
+
"diff", "-u",
|
|
130
|
+
"--label", "target (#{entry.target_path})", entry.target_path,
|
|
131
|
+
"--label", "source (#{entry.source_path})", entry.source_path,
|
|
132
|
+
])
|
|
133
|
+
out.empty? ? " (no textual difference)" : out
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Cheap heuristic: a NUL byte in the first 8 KiB means binary.
|
|
137
|
+
def text?(path)
|
|
138
|
+
File.open(path, "rb") { |f| !f.read(8192).to_s.include?("\0") }
|
|
139
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
140
|
+
false
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
data/lib/twin/scanner.rb
CHANGED
|
@@ -6,7 +6,7 @@ require_relative "remote"
|
|
|
6
6
|
module Twin
|
|
7
7
|
# One YAML block from a sync-file, enriched with live filesystem state.
|
|
8
8
|
Job = Struct.new(
|
|
9
|
-
:program, :path, :description, :active, :excludes, :label,
|
|
9
|
+
:program, :path, :description, :active, :excludes, :owned, :label,
|
|
10
10
|
:source, :target, :cmd, :delete, :render, :render_outdated, :target_path_field, :sync_file,
|
|
11
11
|
:source_exists, :target_exists, :source_mtime, :target_mtime, :conflict,
|
|
12
12
|
:target_unreachable,
|
|
@@ -16,6 +16,10 @@ module Twin
|
|
|
16
16
|
def target_path = File.join(target, target_path_field || path)
|
|
17
17
|
def remote? = Twin::Remote.remote?(target)
|
|
18
18
|
|
|
19
|
+
# Everything rsync must not touch: Exclude (not part of the sync at all)
|
|
20
|
+
# plus Own (part of the scope, but the target owns it).
|
|
21
|
+
def all_excludes = excludes + (owned || [])
|
|
22
|
+
|
|
19
23
|
def status
|
|
20
24
|
return :disabled if active != 1
|
|
21
25
|
return :unreachable if target_unreachable
|
|
@@ -143,7 +147,12 @@ module Twin
|
|
|
143
147
|
return nil if path.empty? || source.empty? || target.empty?
|
|
144
148
|
|
|
145
149
|
render = r["Render"] == true
|
|
146
|
-
excludes = (r["Exclude"]
|
|
150
|
+
excludes = split_list(r["Exclude"])
|
|
151
|
+
# Own: paths inside the sync scope that the TARGET owns — machine-specific
|
|
152
|
+
# config the source must never clobber. Same rsync effect as Exclude, but
|
|
153
|
+
# kept apart so `status` can name the intent instead of hiding it among
|
|
154
|
+
# build artefacts and .DS_Store.
|
|
155
|
+
owned = split_list(r["Own"])
|
|
147
156
|
remote = Twin::Remote.remote?(target)
|
|
148
157
|
|
|
149
158
|
if render && remote
|
|
@@ -171,6 +180,7 @@ module Twin
|
|
|
171
180
|
description: r["Description"].to_s,
|
|
172
181
|
active: (r["Active"] || 0).to_i,
|
|
173
182
|
excludes: excludes,
|
|
183
|
+
owned: owned,
|
|
174
184
|
label: r["Label"].to_s,
|
|
175
185
|
source: source,
|
|
176
186
|
target: target,
|
|
@@ -196,6 +206,11 @@ module Twin
|
|
|
196
206
|
[false, nil]
|
|
197
207
|
end
|
|
198
208
|
|
|
209
|
+
# Comma-separated block field → array of trimmed, non-empty entries.
|
|
210
|
+
def split_list(value)
|
|
211
|
+
(value || "").split(",").map(&:strip).reject(&:empty?)
|
|
212
|
+
end
|
|
213
|
+
|
|
199
214
|
# For a render job: is the target out of date with the rendered template?
|
|
200
215
|
# nil when source is missing/a directory (status falls through to those).
|
|
201
216
|
# True when target is absent or content differs, or the template can't be
|
data/lib/twin/sync.rb
CHANGED
|
@@ -85,7 +85,7 @@ module Twin
|
|
|
85
85
|
|
|
86
86
|
# Sync one Job. Returns [success, combined_output, transferred].
|
|
87
87
|
# transferred is true when rsync actually moved bytes (false on no-op or dry_run).
|
|
88
|
-
def run_job(cfg, job, dry_run: false)
|
|
88
|
+
def run_job(cfg, job, dry_run: false, force: false)
|
|
89
89
|
return render_job(cfg, job, dry_run: dry_run) if job.render
|
|
90
90
|
|
|
91
91
|
src = job.source_path
|
|
@@ -102,12 +102,12 @@ module Twin
|
|
|
102
102
|
FileUtils.mkdir_p(File.dirname(tgt))
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
output, status = run(rsync_args(cfg, job, dry_run: dry_run))
|
|
105
|
+
output, status = run(rsync_args(cfg, job, dry_run: dry_run, force: force))
|
|
106
106
|
return [false, output, false] unless status.success?
|
|
107
107
|
|
|
108
108
|
xfr = !dry_run && transferred?(output)
|
|
109
109
|
|
|
110
|
-
if job.conflict && !xfr && !dry_run
|
|
110
|
+
if job.conflict && !xfr && !dry_run && !force
|
|
111
111
|
output += "\nskipped: target is newer, source not synced"
|
|
112
112
|
end
|
|
113
113
|
|
|
@@ -128,18 +128,23 @@ module Twin
|
|
|
128
128
|
end
|
|
129
129
|
|
|
130
130
|
# Full rsync argument vector for a job.
|
|
131
|
-
|
|
131
|
+
#
|
|
132
|
+
# force: drop --update, so a file that is newer on the target is overwritten
|
|
133
|
+
# anyway. Only ever set after the user agreed to it (see Twin::Conflict), or
|
|
134
|
+
# via `twin sync --force`.
|
|
135
|
+
def rsync_args(cfg, job, dry_run: false, force: false)
|
|
132
136
|
src = job.source_path
|
|
133
137
|
tgt = job.target_path
|
|
134
138
|
|
|
135
|
-
args = ["rsync", "-av", "--itemize-changes"
|
|
139
|
+
args = ["rsync", "-av", "--itemize-changes"]
|
|
140
|
+
args << "--update" unless force
|
|
136
141
|
if job.delete
|
|
137
142
|
args << "--delete"
|
|
138
143
|
args.concat(backup_args(job))
|
|
139
144
|
end
|
|
140
145
|
args << "--dry-run" if dry_run
|
|
141
146
|
cfg.global_excludes.each { |ex| args << "--exclude=#{ex}" }
|
|
142
|
-
job.
|
|
147
|
+
job.all_excludes.each { |ex| args << "--exclude=#{ex}" }
|
|
143
148
|
|
|
144
149
|
if File.directory?(src)
|
|
145
150
|
args << "#{src}/" << "#{tgt}/"
|
|
@@ -166,8 +171,8 @@ module Twin
|
|
|
166
171
|
end
|
|
167
172
|
|
|
168
173
|
# Sync all jobs in a Program. Returns array of [job, success, output].
|
|
169
|
-
def run_program(cfg, program, dry_run: false)
|
|
170
|
-
program.active_jobs.map { |job| [job, *run_job(cfg, job, dry_run: dry_run)] }
|
|
174
|
+
def run_program(cfg, program, dry_run: false, force: false)
|
|
175
|
+
program.active_jobs.map { |job| [job, *run_job(cfg, job, dry_run: dry_run, force: force)] }
|
|
171
176
|
end
|
|
172
177
|
|
|
173
178
|
def run(args)
|
data/lib/twin/version.rb
CHANGED
data/lib/twin.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mark-twin
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ralf Hülsmann
|
|
@@ -40,6 +40,7 @@ files:
|
|
|
40
40
|
- lib/twin/add.rb
|
|
41
41
|
- lib/twin/cli.rb
|
|
42
42
|
- lib/twin/config.rb
|
|
43
|
+
- lib/twin/conflict.rb
|
|
43
44
|
- lib/twin/journal.rb
|
|
44
45
|
- lib/twin/picker.rb
|
|
45
46
|
- lib/twin/preview.rb
|
|
@@ -81,7 +82,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
81
82
|
requirements: []
|
|
82
83
|
rubygems_version: 4.0.4
|
|
83
84
|
specification_version: 4
|
|
84
|
-
summary: Sync configuration
|
|
85
|
-
files
|
|
85
|
+
summary: Sync configuration between machines, from self-documenting Markdown files
|
|
86
86
|
test_files: []
|
|
87
87
|
...
|