mark-twin 0.4.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bae405ea7916f59395530f5372cd3bde2aa4ff742ca4cf08142ca16be5c7cd9
4
- data.tar.gz: b05b48d23901e7c6c2fd313ecc0f8ef2dbf172ab77ef38b5575088031dc186da
3
+ metadata.gz: 07baefa770954e6be785cd591c727fb287b7a64dcf84d4c83eb3c747978da7ba
4
+ data.tar.gz: d29597f8f7b20380b658bd9127c5a72f904f8baea2e3dd20756a7431317e53bd
5
5
  SHA512:
6
- metadata.gz: 3d10977e33311149b1d90486c553cf6ce773c9c2927a081f3e264dcb9a8d0c97be61bb62883b070cb739ab66aa710d2d359b95db33018f8463bd0ac9987c0a95
7
- data.tar.gz: ec1eecdf09d77e1c0ef6480edeb889c9d737b87cbd9eda5a9e83e210ca5fc2eeee85d06f86ccb65e81040197a8a4402001eeb79bda60710f44c77cd7e2e16c26
6
+ metadata.gz: 43c56ce8ed70a445dd3dabf803e68d25651cafe7b4a04e90f4202680592ac447345ab0f55e0dbd09970004d553c84868ae6aec6a643f48cd71f5d2583e0c20a4
7
+ data.tar.gz: 25043aaa4ddf2f30ed8b546f52e68dccd008493414a9c8447d44c2c36acc45d32e375977b0d8b927623d2e5d6e937f0b2b56ed28cb8be53c2eeedeea08c73871
data/README.md CHANGED
@@ -4,12 +4,11 @@
4
4
  [![Tests](https://github.com/rhsev/mark-twin/actions/workflows/test.yml/badge.svg)](https://github.com/rhsev/mark-twin/actions/workflows/test.yml)
5
5
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
6
 
7
- Sync configuration between machines, from Markdown files that explain
8
- themselves.
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 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:
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 the laptop keeps its own.
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` 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.
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 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.
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 lives next to the
47
- path, in prose, not in a comment you stopped writing after the third entry.
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
- - **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.
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. Decide where the other side lives.** `Target:` takes either form, and
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. Look before you leap:**
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` reads like noise you once
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
- puts output.gsub(/^/, " ") if output && !output.strip.empty?
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
@@ -1,3 +1,3 @@
1
1
  module Twin
2
- VERSION = "0.4.0"
2
+ VERSION = "0.4.2"
3
3
  end
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.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ralf Hülsmann
@@ -23,8 +23,23 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '5.0'
26
- description: twin reads sync-files (Markdown + YAML blocks) via grubber, groups them
27
- by program, and runs rsync. Interactive picker uses fzf with an apex Markdown preview.
26
+ description: |
27
+ Keeping two machines' configuration aligned usually ends in a shell script
28
+ nobody dares touch, because the reasons behind it were never written down.
29
+
30
+ twin puts the reasons and the instructions in the same file. A sync-file is
31
+ ordinary Markdown: the prose explains why a path is synced, and fenced YAML
32
+ blocks say what to do. rsync does the copying.
33
+
34
+ It knows the awkward parts. Paths the other machine owns stay untouched and
35
+ are named as such, not buried among build artefacts. When both sides changed
36
+ since the last sync, twin shows a diff and asks before overwriting anything,
37
+ once for the whole group, so a half-applied deploy is not a state it can
38
+ leave you in. Targets are a local path, a mounted volume, or user@host:/path
39
+ over ssh, with no difference in how they are used.
40
+
41
+ Interactive selection runs through fzf with a rendered Markdown preview; the
42
+ same sync-files drive scriptable status, dry-run and sync commands.
28
43
  email:
29
44
  - huelsmann@sevelen.net
30
45
  executables:
@@ -55,6 +70,8 @@ licenses:
55
70
  metadata:
56
71
  source_code_uri: https://github.com/rhsev/mark-twin
57
72
  bug_tracker_uri: https://github.com/rhsev/mark-twin/issues
73
+ changelog_uri: https://github.com/rhsev/mark-twin/releases
74
+ documentation_uri: https://github.com/rhsev/mark-twin#readme
58
75
  post_install_message: |2+
59
76
 
60
77
  twin requires these external tools in your PATH:
@@ -82,6 +99,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
82
99
  requirements: []
83
100
  rubygems_version: 4.0.4
84
101
  specification_version: 4
85
- summary: Sync configuration between machines, from self-documenting Markdown files
102
+ summary: Sync configuration between Macs and any ssh host, from self-documenting Markdown
103
+ files
86
104
  test_files: []
87
105
  ...