mark-twin 0.4.1 → 0.4.2
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/README.md +95 -23
- data/lib/twin/cli.rb +29 -5
- data/lib/twin/sync.rb +23 -0
- data/lib/twin/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 07baefa770954e6be785cd591c727fb287b7a64dcf84d4c83eb3c747978da7ba
|
|
4
|
+
data.tar.gz: d29597f8f7b20380b658bd9127c5a72f904f8baea2e3dd20756a7431317e53bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 43c56ce8ed70a445dd3dabf803e68d25651cafe7b4a04e90f4202680592ac447345ab0f55e0dbd09970004d553c84868ae6aec6a643f48cd71f5d2583e0c20a4
|
|
7
|
+
data.tar.gz: 25043aaa4ddf2f30ed8b546f52e68dccd008493414a9c8447d44c2c36acc45d32e375977b0d8b927623d2e5d6e937f0b2b56ed28cb8be53c2eeedeea08c73871
|
data/README.md
CHANGED
|
@@ -4,12 +4,11 @@
|
|
|
4
4
|
[](https://github.com/rhsev/mark-twin/actions/workflows/test.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
Sync configuration between
|
|
8
|
-
|
|
7
|
+
Sync configuration between Macs (or to any host you can reach over ssh) from
|
|
8
|
+
self-documenting Markdown files.
|
|
9
9
|
|
|
10
|
-
A sync-file is a normal Markdown document. The prose
|
|
11
|
-
|
|
12
|
-
what twin acts on:
|
|
10
|
+
A sync-file is a normal Markdown document. The prose is for you, or for an AI
|
|
11
|
+
assistant. twin only uses the fenced YAML blocks:
|
|
13
12
|
|
|
14
13
|
````markdown
|
|
15
14
|
---
|
|
@@ -21,7 +20,7 @@ Target: admin@macbook:/Users/admin
|
|
|
21
20
|
## Fish Shell
|
|
22
21
|
|
|
23
22
|
Shell config, including completions and abbreviations. `local.fish` stays
|
|
24
|
-
machine-specific
|
|
23
|
+
machine-specific, the MacBook keeps its own.
|
|
25
24
|
|
|
26
25
|
```yaml
|
|
27
26
|
Program: Fish Shell
|
|
@@ -31,29 +30,27 @@ Own: conf.d/local.fish
|
|
|
31
30
|
```
|
|
32
31
|
````
|
|
33
32
|
|
|
34
|
-
`twin`
|
|
35
|
-
|
|
36
|
-
year *why* a path is synced, not just that it is.
|
|
33
|
+
`twin` shows you what differs and syncs what you choose. At its core, it's just
|
|
34
|
+
`rsync`, but a year later the file still tells you *why* you did it that way.
|
|
37
35
|
|
|
38
|
-
That is the whole idea, and for most entries it stays
|
|
39
|
-
rest of this README is long because twin
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
need.
|
|
36
|
+
That is the whole idea, and for most entries it stays as small as the excerpt
|
|
37
|
+
above. The rest of this README is long because twin also covers more complex
|
|
38
|
+
cases: files that are specific to the other computer, files that have changed
|
|
39
|
+
on both sides since the last sync, and paths that differ per host.
|
|
43
40
|
|
|
44
41
|
## Why Markdown
|
|
45
42
|
|
|
46
|
-
- **You can read it later.** The reason a path is synced
|
|
47
|
-
path, in prose
|
|
43
|
+
- **You can read it later.** The reason a path is synced sits next to the
|
|
44
|
+
path, in prose.
|
|
48
45
|
- **Machines can read it too.** The YAML blocks are extracted by
|
|
49
46
|
[grubber](https://github.com/rhsev/grubber), so the same file can feed other
|
|
50
47
|
tools, not just twin.
|
|
51
48
|
- **It stays editable by hand.** No generated state, no database. Add a block
|
|
52
49
|
in your editor and twin picks it up.
|
|
53
|
-
- **
|
|
54
|
-
|
|
55
|
-
the next entry, and what comes back is valid for grubber and still
|
|
56
|
-
by you
|
|
50
|
+
- **Ask your AI to help you write it.** Markdown with YAML blocks is what
|
|
51
|
+
language models are good at. Show this README and an existing sync-file,
|
|
52
|
+
describe the next entry, and what comes back is valid for grubber and still
|
|
53
|
+
readable by you.
|
|
57
54
|
|
|
58
55
|
## Screenshots
|
|
59
56
|
|
|
@@ -93,7 +90,7 @@ gem install ./mark-twin-*.gem
|
|
|
93
90
|
|
|
94
91
|
## Getting started
|
|
95
92
|
|
|
96
|
-
**1.
|
|
93
|
+
**1. Choose ssh or a mounted volume.** `Target:` takes either form, and
|
|
97
94
|
neither is the special case:
|
|
98
95
|
|
|
99
96
|
```yaml
|
|
@@ -130,7 +127,7 @@ an editor — it finds the matching sync-file, derives the relative path,
|
|
|
130
127
|
suggests excludes for what it sees in the directory, and appends a block with a
|
|
131
128
|
prose stub.
|
|
132
129
|
|
|
133
|
-
**4.
|
|
130
|
+
**4. Check first:**
|
|
134
131
|
|
|
135
132
|
```bash
|
|
136
133
|
twin status # what differs
|
|
@@ -151,18 +148,50 @@ twin status # listing with source/target mtimes
|
|
|
151
148
|
twin sync -p grubber # sync one program by name pattern
|
|
152
149
|
twin sync --file=repos # sync all programs from a sync-file
|
|
153
150
|
twin sync --dry-run # preview without writing
|
|
151
|
+
twin sync -v # rsync's full output instead of just the changes
|
|
154
152
|
twin log # recent journal entries (-n N, --json)
|
|
155
153
|
twin doctor # check tools, renderers, and targets
|
|
154
|
+
twin --version # which twin is actually running
|
|
156
155
|
twin --help # show usage
|
|
157
156
|
```
|
|
158
157
|
|
|
159
158
|
A file argument without `/` is matched by substring against sync-file names in
|
|
160
159
|
`sync_dir`; anything containing `/` is treated as a path, file or directory.
|
|
161
160
|
|
|
161
|
+
By default a sync prints only what changed — the lines below, plus anything a
|
|
162
|
+
`Cmd` produced and any error. `-v` adds rsync's headers and transfer summaries
|
|
163
|
+
back. The bare `twin` picker needs a terminal and says so instead of waiting
|
|
164
|
+
when there is none, so cron jobs and `ssh host twin …` fail with a usable
|
|
165
|
+
message rather than hanging.
|
|
166
|
+
|
|
162
167
|
Every job is journaled to `~/.local/state/twin/log.jsonl` — one JSON line with
|
|
163
168
|
timestamp, program, path and outcome. `twin sync` exits non-zero if any job
|
|
164
169
|
failed.
|
|
165
170
|
|
|
171
|
+
### Reading a dry-run
|
|
172
|
+
|
|
173
|
+
`--dry-run` passes rsync's `--itemize-changes` through, which is precise but
|
|
174
|
+
terse. The first two characters are what matter:
|
|
175
|
+
|
|
176
|
+
| Line | Means |
|
|
177
|
+
|---|---|
|
|
178
|
+
| `>f+++++++++` | new file, does not exist on the target yet |
|
|
179
|
+
| `>f.s.......` | `s` is set: the size differs, so the content really changed |
|
|
180
|
+
| `>f..t......` | only `t`: same size, later timestamp — usually identical bytes |
|
|
181
|
+
| `.f...p.....` | no transfer at all, permissions only |
|
|
182
|
+
| `.d..tp.....` | a directory's own timestamp or permissions |
|
|
183
|
+
| `*deleting` | removed on the target (`Delete: true` jobs) |
|
|
184
|
+
|
|
185
|
+
Two rules cover most of it: a leading `>` means data would move and a leading
|
|
186
|
+
`.` means it would not, and among the flags `s` is the one that proves the
|
|
187
|
+
content changed rather than just a timestamp.
|
|
188
|
+
|
|
189
|
+
Only the `>` and `*deleting` lines appear by default — the `.` ones are what
|
|
190
|
+
`-v` adds back, along with rsync's headers and byte counts. So a job that
|
|
191
|
+
prints nothing under its name moved nothing, and a run full of `>f..t......`
|
|
192
|
+
lines is twin re-stamping files whose contents already match, which is normal
|
|
193
|
+
after syncing a tree in both directions.
|
|
194
|
+
|
|
166
195
|
## Sync-files
|
|
167
196
|
|
|
168
197
|
One Markdown file per relationship. Frontmatter sets it up, YAML blocks define
|
|
@@ -209,7 +238,7 @@ reports them apart:
|
|
|
209
238
|
|
|
210
239
|
The distinction is documentation, not mechanism. Six months on, `Own:` still
|
|
211
240
|
says "deliberate, the other machine maintains this", where the same entry sitting
|
|
212
|
-
in `Exclude:` between `*.dwarf` and `.DS_Store`
|
|
241
|
+
in `Exclude:` between `*.dwarf` and `.DS_Store` is something you once
|
|
213
242
|
filtered out.
|
|
214
243
|
|
|
215
244
|
### Cmd: doing something after a sync
|
|
@@ -262,6 +291,49 @@ Target: ralf@server:/srv/www
|
|
|
262
291
|
---
|
|
263
292
|
```
|
|
264
293
|
|
|
294
|
+
## Two shapes of sync
|
|
295
|
+
|
|
296
|
+
The mechanics are the same either way, but what you *mean* differs, and it
|
|
297
|
+
decides how you should answer everything below.
|
|
298
|
+
|
|
299
|
+
**A mirror.** Both machines are yours, both get worked on, and each direction
|
|
300
|
+
is its own entry with `Source:` and `Target:` swapped. Neither side is more
|
|
301
|
+
right than the other; a file being newer over there is ordinary, and the
|
|
302
|
+
question is which version you want. This is what the two-Macs examples in this
|
|
303
|
+
README describe.
|
|
304
|
+
|
|
305
|
+
**A deploy.** One side is the truth and the other only runs it — a server, a
|
|
306
|
+
container, a NAS. The rule that makes this work is short: *the target is never
|
|
307
|
+
a source.* Nothing gets edited over there, so anything that shows up as a
|
|
308
|
+
target-side change means the rule was broken, and that is worth stopping for
|
|
309
|
+
rather than waving through.
|
|
310
|
+
|
|
311
|
+
Write the rule into the sync-file itself, in the prose where the next person —
|
|
312
|
+
you, in a year — will read it:
|
|
313
|
+
|
|
314
|
+
````markdown
|
|
315
|
+
---
|
|
316
|
+
Active: 1
|
|
317
|
+
Label: mini → dylan
|
|
318
|
+
Source: /Volumes/lightning/Git/rhsev/dy.lan
|
|
319
|
+
Target: /Volumes/docker/dylan
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
# Dylan
|
|
323
|
+
|
|
324
|
+
Deploy to the container. **Work happens locally; the target is never a
|
|
325
|
+
source.** A `target_newer` in `twin status` is therefore not a normal
|
|
326
|
+
state — find out who edited over there before overwriting it.
|
|
327
|
+
````
|
|
328
|
+
|
|
329
|
+
The practical difference is which answer you pre-arrange for automation. On a
|
|
330
|
+
mirror there is rarely a right answer in advance, so run those syncs by hand,
|
|
331
|
+
or with `--skip-conflicts` and read the log. On a deploy `--force` matches the
|
|
332
|
+
model — the source *is* the truth — but it discards a target-side edit without
|
|
333
|
+
showing it to you first. That is precisely the trade the prompt exists to make
|
|
334
|
+
deliberate, so prefer `--skip-conflicts` for scheduled runs and keep `--force`
|
|
335
|
+
for the moment you have looked and decided.
|
|
336
|
+
|
|
265
337
|
## When the target has changed too
|
|
266
338
|
|
|
267
339
|
A sync has a direction: the source wins. But targets get edited — a quick fix
|
data/lib/twin/cli.rb
CHANGED
|
@@ -24,12 +24,13 @@ module Twin
|
|
|
24
24
|
twin list [--all] [--label X] [--file X] [--json]
|
|
25
25
|
twin status [--all] [--label X] [--file X] [--json]
|
|
26
26
|
twin sync [-p PATTERN] [--label X] [--file X] [--all] [--dry-run]
|
|
27
|
-
[--quiet] [--skip-unavailable]
|
|
27
|
+
[--quiet] [-v] [--skip-unavailable]
|
|
28
28
|
[--force] [--skip-conflicts]
|
|
29
29
|
twin add <path> scaffold a new sync entry for a local path
|
|
30
30
|
twin log [-n N] [--json] recent journal entries (default 20)
|
|
31
31
|
twin doctor check tools, renderers, and sync targets
|
|
32
32
|
twin --help show this message
|
|
33
|
+
twin --version show the running version
|
|
33
34
|
|
|
34
35
|
FILE ARGUMENT:
|
|
35
36
|
bare name (no /) → matched by substring against sync-file names
|
|
@@ -63,6 +64,8 @@ module Twin
|
|
|
63
64
|
when "doctor" then cmd_doctor(cfg)
|
|
64
65
|
when "-h", "--help", "help"
|
|
65
66
|
puts USAGE
|
|
67
|
+
when "-V", "--version", "version"
|
|
68
|
+
puts "twin #{Twin::VERSION}"
|
|
66
69
|
when /\A-/
|
|
67
70
|
warn "unknown option: #{first}"
|
|
68
71
|
warn "Run 'twin --help' for usage."
|
|
@@ -79,6 +82,17 @@ module Twin
|
|
|
79
82
|
# ── picker → sync ──────────────────────────────────────────────────────────
|
|
80
83
|
|
|
81
84
|
def pick_and_sync(cfg, file:)
|
|
85
|
+
# fzf without a terminal does not fail — it waits, silently, for input
|
|
86
|
+
# that will never come. Every unattended caller lands here: cron,
|
|
87
|
+
# `ssh host twin …`, an agent running commands. Say so and point at the
|
|
88
|
+
# command that does work.
|
|
89
|
+
unless $stdin.tty? && $stdout.tty?
|
|
90
|
+
warn "twin: the picker needs a terminal."
|
|
91
|
+
warn " use `twin sync#{file ? " --file=#{file}" : ""}` for an unattended run" \
|
|
92
|
+
" (add --dry-run to preview)."
|
|
93
|
+
exit 1
|
|
94
|
+
end
|
|
95
|
+
|
|
82
96
|
programs = Scanner.load_programs(cfg, file: file, show_all: false)
|
|
83
97
|
if programs.empty?
|
|
84
98
|
warn "no active programs found#{" in #{file}" if file}"
|
|
@@ -181,16 +195,18 @@ module Twin
|
|
|
181
195
|
sync_jobs(cfg, p, p.active_jobs,
|
|
182
196
|
dry_run: opts[:dry_run], quiet: opts[:quiet],
|
|
183
197
|
skip_unavailable: opts[:skip_unavailable],
|
|
184
|
-
force: opts[:force], skip_conflicts: opts[:skip_conflicts]
|
|
198
|
+
force: opts[:force], skip_conflicts: opts[:skip_conflicts],
|
|
199
|
+
verbose: opts[:verbose])
|
|
185
200
|
end
|
|
186
201
|
exit 1 unless results.all?
|
|
187
202
|
end
|
|
188
203
|
|
|
189
204
|
# Sync the given jobs. Returns true when every attempted job succeeded.
|
|
190
205
|
# quiet: print only conflicts, errors, and jobs that changed something
|
|
206
|
+
# verbose: print rsync's full output instead of just the changes
|
|
191
207
|
# skip_unavailable: skip jobs whose target is unmounted/unreachable instead of aborting
|
|
192
208
|
def sync_jobs(cfg, program, jobs, dry_run: false, quiet: false, skip_unavailable: false,
|
|
193
|
-
force: false, skip_conflicts: false)
|
|
209
|
+
force: false, skip_conflicts: false, verbose: false)
|
|
194
210
|
jobs = jobs.select { |j| j.active == 1 }
|
|
195
211
|
return true if jobs.empty?
|
|
196
212
|
|
|
@@ -230,7 +246,8 @@ module Twin
|
|
|
230
246
|
header_printed = true
|
|
231
247
|
end
|
|
232
248
|
puts " • #{job.path}"
|
|
233
|
-
|
|
249
|
+
shown = verbose || !success ? output : Twin::Sync.summarize(output.to_s)
|
|
250
|
+
puts shown.gsub(/^/, " ") if shown && !shown.strip.empty?
|
|
234
251
|
warn " error syncing #{job.path}" unless success
|
|
235
252
|
end
|
|
236
253
|
all_ok
|
|
@@ -353,6 +370,11 @@ module Twin
|
|
|
353
370
|
def cmd_doctor(cfg)
|
|
354
371
|
ok = true
|
|
355
372
|
|
|
373
|
+
# First line, because "which version am I actually running" is the
|
|
374
|
+
# question behind a surprising number of the others.
|
|
375
|
+
puts "twin #{Twin::VERSION}"
|
|
376
|
+
puts
|
|
377
|
+
|
|
356
378
|
puts "Tools"
|
|
357
379
|
%w[grubber rsync fzf].each do |bin|
|
|
358
380
|
if tool_available?(bin)
|
|
@@ -446,11 +468,13 @@ module Twin
|
|
|
446
468
|
|
|
447
469
|
def parse_sync_opts(args)
|
|
448
470
|
opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false,
|
|
449
|
-
quiet: false, skip_unavailable: false, force: false, skip_conflicts: false
|
|
471
|
+
quiet: false, skip_unavailable: false, force: false, skip_conflicts: false,
|
|
472
|
+
verbose: false }
|
|
450
473
|
OptionParser.new do |o|
|
|
451
474
|
o.on("--all") { opts[:show_all] = true }
|
|
452
475
|
o.on("--label=L") { |v| opts[:label] = v }
|
|
453
476
|
o.on("--file=F") { |v| opts[:file] = v }
|
|
477
|
+
o.on("-v", "--verbose") { opts[:verbose] = true }
|
|
454
478
|
o.on("--force") { opts[:force] = true }
|
|
455
479
|
o.on("--skip-conflicts") { opts[:skip_conflicts] = true }
|
|
456
480
|
o.on("-p", "--pattern=P") { |v| opts[:pattern] = v }
|
data/lib/twin/sync.rb
CHANGED
|
@@ -18,6 +18,29 @@ module Twin
|
|
|
18
18
|
output.lines.any? { |l| ITEMIZE_CHANGE.match?(l) }
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# rsync's own noise: the header, the blank line, and the transfer summary.
|
|
22
|
+
# Everything twin adds itself (cmd output, "skipped:", error text) survives,
|
|
23
|
+
# as does any itemize line describing an actual change.
|
|
24
|
+
# Spaces are escaped on purpose: /x ignores literal whitespace, and these
|
|
25
|
+
# patterns are all multi-word.
|
|
26
|
+
RSYNC_NOISE = /
|
|
27
|
+
\A(sending|receiving)\ incremental\ file\ list\z |
|
|
28
|
+
\Acreated\ directory\ |
|
|
29
|
+
\Asent\ [\d,]+\ bytes |
|
|
30
|
+
\Atotal\ size\ is\ [\d,]+
|
|
31
|
+
/x
|
|
32
|
+
|
|
33
|
+
# Drop everything from an rsync run that does not describe a change.
|
|
34
|
+
# An itemize line whose first column is "." moved no data — permissions or
|
|
35
|
+
# a timestamp on an existing file — and is exactly the material that made
|
|
36
|
+
# the interesting lines hard to find.
|
|
37
|
+
def summarize(output)
|
|
38
|
+
output.lines.reject do |line|
|
|
39
|
+
l = line.rstrip
|
|
40
|
+
l.empty? || RSYNC_NOISE.match?(l) || (l.start_with?(".") && l =~ /\A\.[fdLDS]/)
|
|
41
|
+
end.join
|
|
42
|
+
end
|
|
43
|
+
|
|
21
44
|
# True if the path lives on a mounted volume other than the root filesystem.
|
|
22
45
|
# Walks up parents until it finds a mount point (different device than parent)
|
|
23
46
|
# or hits "/" (path is on the root volume, not externally mounted).
|
data/lib/twin/version.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.
|
|
4
|
+
version: 0.4.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ralf Hülsmann
|
|
@@ -99,6 +99,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
99
99
|
requirements: []
|
|
100
100
|
rubygems_version: 4.0.4
|
|
101
101
|
specification_version: 4
|
|
102
|
-
summary: Sync configuration between
|
|
102
|
+
summary: Sync configuration between Macs and any ssh host, from self-documenting Markdown
|
|
103
|
+
files
|
|
103
104
|
test_files: []
|
|
104
105
|
...
|