mark-twin 0.5.0 → 0.6.1
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 +22 -7
- data/README.md +44 -7
- data/lib/twin/cli.rb +13 -27
- data/lib/twin/conflict.rb +32 -0
- data/lib/twin/picker.rb +91 -25
- data/lib/twin/remote.rb +14 -5
- data/lib/twin/scanner.rb +18 -8
- data/lib/twin/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 519b3a75fa38033ef08e8b3b41c2542e05b4525447ba36ddfc590d6d97d863cc
|
|
4
|
+
data.tar.gz: 13041e31c2c5fd66ceae626b14cb01bb54cd97b4ecc79aa76924056e95b40023
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '07758fd73934de83ac8690f63da7d208c6921af143928c0de4d0a60d230bd50541cef667a5896b95261e0efa742d92669ea21a12874a9ee539e714f8679568dc'
|
|
7
|
+
data.tar.gz: 20cd8530927aa75138ddef786044166dbaded5b82d143890204926d3398d3bfbbbd1ea28346f5d32864e55f69c88ae6c1954cb0a960a5d345347c7bf7336400f
|
data/ARCHITECTURE.md
CHANGED
|
@@ -53,7 +53,7 @@ test/test_pure.rb
|
|
|
53
53
|
|
|
54
54
|
```
|
|
55
55
|
program, path, description, active, excludes, owned, label, source, target, cmd,
|
|
56
|
-
delete, render, render_outdated, target_path_field, sync_file,
|
|
56
|
+
delete, render, render_outdated, target_path_field, sync_file, verify,
|
|
57
57
|
source_exists, target_exists, source_mtime, target_mtime, conflict,
|
|
58
58
|
directory, content_equal, drift
|
|
59
59
|
```
|
|
@@ -68,7 +68,10 @@ content-verified when set. Directory jobs get no mtime judgment at all: a
|
|
|
68
68
|
directory's mtime moves on every sync and on every excluded file, so
|
|
69
69
|
`Job#status` reports `unverified` until `twin status` fills `drift` by asking
|
|
70
70
|
rsync (`Conflict.drift` — paired dry-runs, itemize classification, checksums
|
|
71
|
-
for timestamp-only candidates).
|
|
71
|
+
for timestamp-only candidates). `Verify: false` opts a job out of every
|
|
72
|
+
content round — scanner md5, drift dry-runs, pre-sync conflict detection —
|
|
73
|
+
for entries where the walk itself is the cost; its status stays mtime-based
|
|
74
|
+
(files) or `unverified` (directories).
|
|
72
75
|
|
|
73
76
|
`Job#status` → one of `disabled / unreachable / both_missing / missing_source /
|
|
74
77
|
missing_target / target_newer / in_sync / source_newer / unverified`. Render
|
|
@@ -164,16 +167,28 @@ rationale.
|
|
|
164
167
|
Two stages:
|
|
165
168
|
|
|
166
169
|
1. **Stage 1 — program picker.** Multi-line NUL-separated entries (`--read0`).
|
|
167
|
-
Each entry has a header (icon, program name, job count, sync-
|
|
170
|
+
Each entry has a header (icon, program name, job count, sync-files) and
|
|
168
171
|
indented body lines (one per job). No preview. Single-select. ESC exits.
|
|
172
|
+
Same-named programs (case-insensitive) from different sync-files are merged
|
|
173
|
+
into one entry, and a trailing bracket group joins by convention:
|
|
174
|
+
`livesync [agent]` merges under `livesync`. A picker-only view
|
|
175
|
+
(`Picker.merge_programs`); the data model, `status`, `sync -p` and JSON
|
|
176
|
+
keep the per-file programs. Jobs stay grouped per file in document order,
|
|
177
|
+
so a file's closing `Cmd` block still fires after its own paths.
|
|
169
178
|
2. **Stage 2 — path multi-picker.** Tab-delimited rows (`id\tdisplay`),
|
|
170
|
-
`--with-nth=2` hides the `id`. Multi-select via Tab.
|
|
171
|
-
|
|
172
|
-
|
|
179
|
+
`--with-nth=2` hides the `id`. Multi-select via Tab. On open, directory
|
|
180
|
+
jobs get their drift verified (`Conflict.fill_drift` — affordable for one
|
|
181
|
+
program, unlike for the whole of stage 1). A merged program's rows are
|
|
182
|
+
sectioned per sync-file by dim header rows whose id is `-`; they map to no
|
|
183
|
+
job, so toggling one is inert. Preview pane shows the *compact* view
|
|
184
|
+
(frontmatter + intro + the heading section containing the YAML block for
|
|
185
|
+
the highlighted path) from the job's own sync-file, rendered via
|
|
173
186
|
`apex --plugins -t terminal256`. ESC returns to Stage 1.
|
|
174
187
|
|
|
175
188
|
Compact previews are pre-rendered to per-job tempfiles before fzf launches.
|
|
176
|
-
An `awk` lookup maps `{1}` (the id) → tempfile path.
|
|
189
|
+
An `awk` lookup maps `{1}` (the id) → tempfile path. The Tempfile objects
|
|
190
|
+
stay referenced while fzf runs — a GC'd Tempfile unlinks its file under the
|
|
191
|
+
running preview.
|
|
177
192
|
|
|
178
193
|
## CLI
|
|
179
194
|
|
data/README.md
CHANGED
|
@@ -39,7 +39,9 @@ way.
|
|
|
39
39
|
That is the whole idea, and for most entries it stays as small as the excerpt
|
|
40
40
|
above. The rest of this README is long because twin also covers more complex
|
|
41
41
|
cases: files that are specific to the other computer, files that have changed
|
|
42
|
-
on both sides since the last sync, and paths that differ per host.
|
|
42
|
+
on both sides since the last sync, and paths that differ per host. If you are
|
|
43
|
+
wondering how this differs from chezmoi and its kind:
|
|
44
|
+
[Alternatives](#alternatives).
|
|
43
45
|
|
|
44
46
|
## Why Markdown
|
|
45
47
|
|
|
@@ -58,7 +60,10 @@ on both sides since the last sync, and paths that differ per host.
|
|
|
58
60
|
## Screenshots
|
|
59
61
|
|
|
60
62
|
Stage 1 — program picker. One row per program, color-coded status, indented
|
|
61
|
-
paths underneath
|
|
63
|
+
paths underneath. A name that appears in several sync-files
|
|
64
|
+
(the app in one, its config in another) is one entry, and a trailing bracket
|
|
65
|
+
group ties variants to their base name — `livesync [agent]` lists under
|
|
66
|
+
`livesync`. Stage 2 sections the merged paths per origin:
|
|
62
67
|
|
|
63
68
|

|
|
64
69
|
|
|
@@ -213,7 +218,7 @@ rather than as an error.
|
|
|
213
218
|
|
|
214
219
|
| Field | Where | Meaning |
|
|
215
220
|
|---|---|---|
|
|
216
|
-
| `Program` | block | Group name; blocks sharing it sync together |
|
|
221
|
+
| `Program` | block | Group name; blocks sharing it sync together. A trailing `[…]` lists under the base name in the picker |
|
|
217
222
|
| `Path` | block | Path relative to `Source` (file or directory) |
|
|
218
223
|
| `Source` | either | Absolute base path on this machine |
|
|
219
224
|
| `Target` | either | Absolute base path, or `user@host:/path` for ssh |
|
|
@@ -226,6 +231,7 @@ rather than as an error.
|
|
|
226
231
|
| `Delete` | block | `true` mirrors deletions, with backups |
|
|
227
232
|
| `Cmd` | block | Shell command, run only when bytes actually moved |
|
|
228
233
|
| `Render` | block | `true` substitutes `{{tokens}}` instead of copying |
|
|
234
|
+
| `Verify` | block | `false` skips content verification — for entries too big (see below) |
|
|
229
235
|
|
|
230
236
|
### Exclude or Own?
|
|
231
237
|
|
|
@@ -376,9 +382,19 @@ Two properties make this bearable day to day:
|
|
|
376
382
|
|
|
377
383
|
`twin status` gives the same verdict without syncing: it runs the dry-runs per
|
|
378
384
|
entry and reports what would flow, what merely differs in timestamp, and what
|
|
379
|
-
changed on the target.
|
|
380
|
-
— it marks them `∘` (unverified) rather than guessing,
|
|
381
|
-
would make it slow to open.
|
|
385
|
+
changed on the target. The picker's first stage stays mtime-blind for
|
|
386
|
+
directory entries — it marks them `∘` (unverified) rather than guessing,
|
|
387
|
+
because the dry-runs would make it slow to open. Entering a program runs them
|
|
388
|
+
for just that program, so its directory rows show a real verdict.
|
|
389
|
+
|
|
390
|
+
One escape hatch: `Verify: false` opts an entry out of every content round —
|
|
391
|
+
the md5 checks, the status dry-runs, the picker's verification, the pre-sync
|
|
392
|
+
conflict listing. It exists for entries where the walk itself is the cost: a
|
|
393
|
+
`node_modules` tree on an SMB mount stats tens of thousands of files for one
|
|
394
|
+
verdict. Such an entry stays `∘`/mtime-based, and the picker's second stage
|
|
395
|
+
says so in its header rather than letting the `∘` pass as pending; a sync
|
|
396
|
+
still leaves newer target files alone (`--update` holds), they just aren't
|
|
397
|
+
itemised first.
|
|
382
398
|
|
|
383
399
|
## Automation
|
|
384
400
|
|
|
@@ -443,7 +459,7 @@ it differs from the current target, so a `Cmd` hook fires only on a real change.
|
|
|
443
459
|
## LiveSync LaunchAgent
|
|
444
460
|
|
|
445
461
|
```yaml
|
|
446
|
-
Program: livesync
|
|
462
|
+
Program: livesync [agent]
|
|
447
463
|
Source: "{{src.home}}/Automation/launchd"
|
|
448
464
|
Path: com.ralf.livesync.plist
|
|
449
465
|
Target: "{{dst.mount}}"
|
|
@@ -478,6 +494,27 @@ global_excludes:
|
|
|
478
494
|
Environment overrides: `TWIN_SYNC_DIR`, `TWIN_CONFIG`, `TWIN_HOST` (which host
|
|
479
495
|
twin runs as — lets one config serve both machines).
|
|
480
496
|
|
|
497
|
+
## Alternatives
|
|
498
|
+
|
|
499
|
+
twin overlaps with the dotfile managers without being one: it syncs whatever
|
|
500
|
+
you want, documents included, but it specialises in configuration files.
|
|
501
|
+
Taking [chezmoi](https://chezmoi.io) as the most capable representative of
|
|
502
|
+
its kind:
|
|
503
|
+
|
|
504
|
+
| | twin | dotfile managers (e.g. chezmoi) |
|
|
505
|
+
|---|---|---|
|
|
506
|
+
| Focus | deliberate sync between a few machines | converging many machines from one repo |
|
|
507
|
+
| Model | interactive: pick, preview, sync | git: commit, push, apply |
|
|
508
|
+
| Where the *why* lives | prose next to the YAML block, same file | commit messages, separate docs |
|
|
509
|
+
| Conflicts | stops and asks, diff in hand | three-way merge on apply |
|
|
510
|
+
| Templating | host paths (`{{dst.home}}`) | full engine, secrets from password managers |
|
|
511
|
+
| Scale | two or three Macs/servers | dozens of machines |
|
|
512
|
+
|
|
513
|
+
If you want a fleet converging automatically, or secrets injected at apply
|
|
514
|
+
time, choose chezmoi. twin is built for the smaller case: easy to configure,
|
|
515
|
+
with room to read and think about what the target changed before it gets
|
|
516
|
+
overwritten.
|
|
517
|
+
|
|
481
518
|
## Design
|
|
482
519
|
|
|
483
520
|
Sync instructions and their context in one place. No TUI framework: `fzf` does
|
data/lib/twin/cli.rb
CHANGED
|
@@ -93,7 +93,7 @@ module Twin
|
|
|
93
93
|
exit 1
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
programs = Scanner.load_programs(cfg, file: file, show_all: false)
|
|
96
|
+
programs = Picker.merge_programs(Scanner.load_programs(cfg, file: file, show_all: false))
|
|
97
97
|
if programs.empty?
|
|
98
98
|
warn "no active programs found#{" in #{file}" if file}"
|
|
99
99
|
return
|
|
@@ -125,7 +125,7 @@ module Twin
|
|
|
125
125
|
break if $stdin.gets&.strip == "q"
|
|
126
126
|
|
|
127
127
|
# reload so status reflects what was just synced, stay on this program
|
|
128
|
-
programs = Scanner.load_programs(cfg, file: file, show_all: false)
|
|
128
|
+
programs = Picker.merge_programs(Scanner.load_programs(cfg, file: file, show_all: false))
|
|
129
129
|
selected_key = [program.name, program.sync_file]
|
|
130
130
|
end
|
|
131
131
|
end
|
|
@@ -168,7 +168,11 @@ module Twin
|
|
|
168
168
|
puts " #{j.path}#{conflict}"
|
|
169
169
|
if j.directory
|
|
170
170
|
# Directory mtimes prove nothing — the drift verdict replaces them.
|
|
171
|
-
|
|
171
|
+
note = if j.drift then drift_summary(j.drift)
|
|
172
|
+
elsif j.verify == false then "(not checked — Verify: false)"
|
|
173
|
+
else "(not checked — target unavailable)"
|
|
174
|
+
end
|
|
175
|
+
puts " #{note}"
|
|
172
176
|
else
|
|
173
177
|
src = j.source_exists ? j.source_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
|
|
174
178
|
tgt = j.target_exists ? j.target_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
|
|
@@ -183,30 +187,9 @@ module Twin
|
|
|
183
187
|
end
|
|
184
188
|
end
|
|
185
189
|
|
|
186
|
-
# Ask rsync what a sync of each directory job would actually do
|
|
187
|
-
# dry-runs are subprocess I/O, so a few run in parallel. File jobs are
|
|
188
|
-
# already content-checked by the scanner and need no second look.
|
|
190
|
+
# Ask rsync what a sync of each directory job would actually do.
|
|
189
191
|
def verify_drift(cfg, programs)
|
|
190
|
-
|
|
191
|
-
j.directory && j.active == 1 && !j.target_unreachable &&
|
|
192
|
-
j.source_exists && j.target_exists
|
|
193
|
-
end
|
|
194
|
-
return if jobs.empty?
|
|
195
|
-
|
|
196
|
-
queue = Queue.new
|
|
197
|
-
jobs.each { |j| queue << j }
|
|
198
|
-
Array.new([4, jobs.size].min) do
|
|
199
|
-
Thread.new do
|
|
200
|
-
loop do
|
|
201
|
-
j = begin
|
|
202
|
-
queue.pop(true)
|
|
203
|
-
rescue ThreadError
|
|
204
|
-
break
|
|
205
|
-
end
|
|
206
|
-
j.drift = Twin::Conflict.drift(cfg, j)
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
end.each(&:join)
|
|
192
|
+
Twin::Conflict.fill_drift(cfg, programs.flat_map(&:jobs))
|
|
210
193
|
end
|
|
211
194
|
|
|
212
195
|
def drift_summary(d)
|
|
@@ -314,7 +297,10 @@ module Twin
|
|
|
314
297
|
return true if force
|
|
315
298
|
return false if skip_conflicts || dry_run
|
|
316
299
|
|
|
317
|
-
|
|
300
|
+
# Verify: false jobs skip the detection round (too big to walk); rsync's
|
|
301
|
+
# --update still keeps newer target files, they just aren't listed here.
|
|
302
|
+
conflicts = jobs.reject { |j| j.verify == false }
|
|
303
|
+
.flat_map { |j| Twin::Conflict.detect(cfg, j) }
|
|
318
304
|
return false if conflicts.empty?
|
|
319
305
|
|
|
320
306
|
report_conflicts(conflicts)
|
data/lib/twin/conflict.rb
CHANGED
|
@@ -79,6 +79,38 @@ module Twin
|
|
|
79
79
|
drift(cfg, job)&.conflicts || []
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
# Directory jobs whose drift is still unknown and answerable — both sides
|
|
83
|
+
# present, target reachable, not opted out via Verify: false. File jobs
|
|
84
|
+
# are content-checked by the scanner and need no second look.
|
|
85
|
+
def drift_candidates(jobs)
|
|
86
|
+
jobs.select do |j|
|
|
87
|
+
j.drift.nil? && j.verify != false && j.directory && j.active == 1 &&
|
|
88
|
+
!j.target_unreachable && j.source_exists && j.target_exists
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Fill `drift` on every candidate by asking rsync — the dry-runs are
|
|
93
|
+
# subprocess I/O, so a few run in parallel.
|
|
94
|
+
def fill_drift(cfg, jobs)
|
|
95
|
+
jobs = drift_candidates(jobs)
|
|
96
|
+
return if jobs.empty?
|
|
97
|
+
|
|
98
|
+
queue = Queue.new
|
|
99
|
+
jobs.each { |j| queue << j }
|
|
100
|
+
Array.new([4, jobs.size].min) do
|
|
101
|
+
Thread.new do
|
|
102
|
+
loop do
|
|
103
|
+
j = begin
|
|
104
|
+
queue.pop(true)
|
|
105
|
+
rescue ThreadError
|
|
106
|
+
break
|
|
107
|
+
end
|
|
108
|
+
j.drift = drift(cfg, j)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end.each(&:join)
|
|
112
|
+
end
|
|
113
|
+
|
|
82
114
|
# Pure assembly of a Drift from parsed entries. A file the normal run
|
|
83
115
|
# would also transfer flows source→target as intended; one only the
|
|
84
116
|
# forced run would touch is being held back by --update — the target owns
|
data/lib/twin/picker.rb
CHANGED
|
@@ -2,6 +2,7 @@ require "open3"
|
|
|
2
2
|
require "tempfile"
|
|
3
3
|
|
|
4
4
|
require_relative "preview"
|
|
5
|
+
require_relative "conflict"
|
|
5
6
|
|
|
6
7
|
module Twin
|
|
7
8
|
# Two-step fzf picker:
|
|
@@ -14,9 +15,10 @@ module Twin
|
|
|
14
15
|
source_newer: "→",
|
|
15
16
|
target_newer: "←",
|
|
16
17
|
in_sync: "✓",
|
|
17
|
-
# Directory jobs in
|
|
18
|
-
# dry-
|
|
19
|
-
#
|
|
18
|
+
# Directory jobs in stage 1: their mtimes prove nothing, and the
|
|
19
|
+
# dry-runs that would prove something are too slow for the full picker.
|
|
20
|
+
# No claim instead of a wrong one — stage 2 verifies its one program
|
|
21
|
+
# on open, and `twin status` has the full verdict.
|
|
20
22
|
unverified: "∘",
|
|
21
23
|
missing_target: "!",
|
|
22
24
|
missing_source: "!",
|
|
@@ -49,6 +51,29 @@ module Twin
|
|
|
49
51
|
|
|
50
52
|
# ── Stufe 1: program picker ───────────────────────────────────────────────
|
|
51
53
|
|
|
54
|
+
# A trailing bracket group ties a program to its base name by convention:
|
|
55
|
+
# "livesync [agent]" lists under "livesync". Only the suffix form counts —
|
|
56
|
+
# brackets elsewhere are just a name.
|
|
57
|
+
BRACKET_SUFFIX = /\s*\[[^\]]*\]\z/
|
|
58
|
+
|
|
59
|
+
def base_name(name) = name.sub(BRACKET_SUFFIX, "").strip
|
|
60
|
+
|
|
61
|
+
# One stage-1 entry per base name (case-insensitive), even when it appears
|
|
62
|
+
# in several sync-files or as bracketed variants — "everything of
|
|
63
|
+
# fileview" is one decision. The list is sorted by name; document order
|
|
64
|
+
# only orders the *jobs*, program order carries no meaning. Purely a
|
|
65
|
+
# picker view: the data model, `status`, `sync -p` and JSON keep the
|
|
66
|
+
# per-file programs. Jobs stay grouped per file in document order, so a
|
|
67
|
+
# file's closing Cmd block still fires after its own paths.
|
|
68
|
+
def merge_programs(programs)
|
|
69
|
+
programs.group_by { |p| base_name(p.name).downcase }.values.map do |group|
|
|
70
|
+
next group.first if group.size == 1
|
|
71
|
+
name = group.find { |p| !p.name.match?(BRACKET_SUFFIX) }&.name ||
|
|
72
|
+
base_name(group.first.name)
|
|
73
|
+
Program.new(name: name, jobs: group.flat_map(&:jobs))
|
|
74
|
+
end.sort_by { |p| p.name.downcase }
|
|
75
|
+
end
|
|
76
|
+
|
|
52
77
|
# Multi-line entries (NUL-separated). Header line per program, indented
|
|
53
78
|
# body lines per job. Returns the selected Program or nil.
|
|
54
79
|
def pick_program(programs)
|
|
@@ -90,14 +115,45 @@ module Twin
|
|
|
90
115
|
jobs = program.jobs
|
|
91
116
|
return [] if jobs.empty?
|
|
92
117
|
|
|
118
|
+
# Stage 2 covers one program, so the dry-runs that are too slow for the
|
|
119
|
+
# whole picker are affordable here — directory rows get a real verdict
|
|
120
|
+
# instead of `∘`. Already-verified jobs are skipped on re-entry.
|
|
121
|
+
if Conflict.drift_candidates(jobs).any?
|
|
122
|
+
$stderr.print dim("verifying directories …")
|
|
123
|
+
Conflict.fill_drift(cfg, jobs)
|
|
124
|
+
$stderr.print "\r\e[K"
|
|
125
|
+
end
|
|
126
|
+
|
|
93
127
|
path_width = jobs.map { |j| j.path.length }.max
|
|
94
128
|
tempfiles = write_compact_previews(program, jobs)
|
|
95
129
|
preview_cmd, mapfile = build_apex_preview_cmd(tempfiles, cfg)
|
|
96
130
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
131
|
+
# A merged program spans sync-files (and bracketed variants); a dim
|
|
132
|
+
# section row marks each block, named by its file — plus the original
|
|
133
|
+
# program name when it differs from the merged one. Section rows carry
|
|
134
|
+
# "-" instead of an index — fzf can't make them unselectable, but they
|
|
135
|
+
# map to no job, so toggling one is inert.
|
|
136
|
+
section = ->(j) { [j.program, j.sync_file] }
|
|
137
|
+
multi = jobs.map(§ion).uniq.size > 1
|
|
138
|
+
rows = []
|
|
139
|
+
prev = nil
|
|
140
|
+
jobs.each_with_index do |j, i|
|
|
141
|
+
if multi && section.(j) != prev
|
|
142
|
+
file = File.basename(j.sync_file.to_s)
|
|
143
|
+
label = j.program == program.name ? file : "#{j.program} · #{file}"
|
|
144
|
+
rows << "-\t#{dim("[#{label}]")}"
|
|
145
|
+
prev = section.(j)
|
|
146
|
+
end
|
|
147
|
+
icon = STATUS_ICONS[j.status] || "?"
|
|
148
|
+
line = "#{icon} #{j.path.ljust(path_width)} #{job_delta(j)}"
|
|
149
|
+
rows << "#{i}\t#{colorize(j.status, line)}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
header = "#{program.name} — Tab toggles, Enter confirms"
|
|
153
|
+
unverified = jobs.select { |j| j.verify == false }
|
|
154
|
+
unless unverified.empty?
|
|
155
|
+
header += "\n\e[33m⚠ content checks off (Verify: false): " \
|
|
156
|
+
"#{unverified.map(&:path).join(", ")}\e[0m"
|
|
101
157
|
end
|
|
102
158
|
|
|
103
159
|
fzf = [
|
|
@@ -105,7 +161,7 @@ module Twin
|
|
|
105
161
|
"--multi", "--ansi",
|
|
106
162
|
"--delimiter=\t", "--with-nth=2",
|
|
107
163
|
"--prompt=#{program.name} > ",
|
|
108
|
-
"--header=#{
|
|
164
|
+
"--header=#{header}",
|
|
109
165
|
"--preview=#{preview_cmd}",
|
|
110
166
|
"--preview-window=right:60%:wrap",
|
|
111
167
|
"--height=100%", "--reverse",
|
|
@@ -113,45 +169,55 @@ module Twin
|
|
|
113
169
|
"--color=bg+:-1,hl+:reverse",
|
|
114
170
|
]
|
|
115
171
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
172
|
+
loop do
|
|
173
|
+
output, status = Open3.capture2(SH_ENV, *fzf, stdin_data: rows.join("\n"))
|
|
174
|
+
return :back if status.exitstatus == 130 # ESC / Ctrl-C
|
|
175
|
+
return [] unless status.success?
|
|
176
|
+
return [] if output.strip.empty?
|
|
120
177
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
178
|
+
selected = output.lines.filter_map do |line|
|
|
179
|
+
idx = line.split("\t", 2).first
|
|
180
|
+
jobs[idx.to_i] if idx&.match?(/\A\d+\z/)
|
|
181
|
+
end
|
|
182
|
+
return selected unless selected.empty?
|
|
183
|
+
# Enter landed on a section header (or only headers were toggled):
|
|
184
|
+
# nothing real was accepted — reopen rather than read it as "exit".
|
|
124
185
|
end
|
|
125
186
|
ensure
|
|
126
|
-
tempfiles&.each_value { |
|
|
127
|
-
|
|
187
|
+
tempfiles&.each_value { |f| f.close! rescue nil }
|
|
188
|
+
mapfile&.close! rescue nil
|
|
128
189
|
end
|
|
129
190
|
|
|
130
|
-
# Write per-job compact-preview markdown to tempfiles. Returns
|
|
191
|
+
# Write per-job compact-preview markdown to tempfiles. Returns
|
|
192
|
+
# {idx => Tempfile}. The Tempfile objects (not just their paths) must stay
|
|
193
|
+
# referenced while fzf runs: a GC'd Tempfile unlinks its file, and the
|
|
194
|
+
# preview command would find nothing to render. Each job's excerpt comes
|
|
195
|
+
# from its own sync-file — a merged program spans several.
|
|
131
196
|
def write_compact_previews(program, jobs)
|
|
132
197
|
result = {}
|
|
133
198
|
jobs.each_with_index do |job, i|
|
|
134
|
-
compact = Preview.extract_compact(
|
|
199
|
+
compact = Preview.extract_compact(job.sync_file, job.path)
|
|
135
200
|
f = Tempfile.new(["twin-#{i}-", ".md"])
|
|
136
201
|
f.write(compact)
|
|
137
202
|
f.close
|
|
138
|
-
result[i] = f
|
|
203
|
+
result[i] = f
|
|
139
204
|
end
|
|
140
205
|
result
|
|
141
206
|
end
|
|
142
207
|
|
|
143
|
-
# Returns [preview_cmd,
|
|
208
|
+
# Returns [preview_cmd, mapfile]; the caller holds the Tempfile until fzf
|
|
209
|
+
# is done (GC would unlink it) and closes it afterwards.
|
|
144
210
|
def build_apex_preview_cmd(tempfiles, cfg)
|
|
145
211
|
# Map idx → file via a small TSV, awk picks the right one for {1}.
|
|
146
212
|
mapfile = Tempfile.new(["twin-map-", ".tsv"])
|
|
147
|
-
tempfiles.each { |i,
|
|
213
|
+
tempfiles.each { |i, f| mapfile.puts("#{i}\t#{f.path}") }
|
|
148
214
|
mapfile.close
|
|
149
215
|
|
|
150
216
|
render_cmd = pick_renderer(cfg)
|
|
151
217
|
|
|
152
218
|
cmd = %(F=$(awk -v id={1} -F'\\t' '$1==id {print $2}' #{mapfile.path}); ) +
|
|
153
219
|
%([ -n "$F" ] && #{render_cmd})
|
|
154
|
-
[cmd, mapfile
|
|
220
|
+
[cmd, mapfile]
|
|
155
221
|
end
|
|
156
222
|
|
|
157
223
|
# Pick the first available markdown renderer.
|
|
@@ -182,11 +248,11 @@ module Twin
|
|
|
182
248
|
|
|
183
249
|
def render_program_entry(program, name_width, path_width)
|
|
184
250
|
icon = colorize(program.status, STATUS_ICONS[program.status] || "?")
|
|
185
|
-
|
|
251
|
+
files = program.jobs.map { |j| File.basename(j.sync_file.to_s) }.uniq.join(" · ")
|
|
186
252
|
count = program.active_jobs.size
|
|
187
253
|
total = program.jobs.size
|
|
188
254
|
header = "#{icon} #{bold(program.name.ljust(name_width))} " \
|
|
189
|
-
"#{dim("(#{count}/#{total})")} #{dim("[#{
|
|
255
|
+
"#{dim("(#{count}/#{total})")} #{dim("[#{files}]")}"
|
|
190
256
|
|
|
191
257
|
body = program.jobs.map do |j|
|
|
192
258
|
icon = STATUS_ICONS[j.status] || "?"
|
data/lib/twin/remote.rb
CHANGED
|
@@ -64,17 +64,26 @@ module Twin
|
|
|
64
64
|
)
|
|
65
65
|
return nil unless status.success?
|
|
66
66
|
|
|
67
|
+
parse_stats(out)
|
|
68
|
+
rescue Errno::ENOENT
|
|
69
|
+
nil # ssh not installed
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Nur echte Epochen als Zeit werten. Scheitert die ganze stat-Kette, ist
|
|
73
|
+
# das Feld leer — das ist "unbekannt", nicht 1970. Und unbekannt ist
|
|
74
|
+
# nicht fehlend: "-" heißt, die Datei ist nicht da; nil heißt, sie ist
|
|
75
|
+
# da, aber niemand konnte ihre mtime nennen.
|
|
76
|
+
def parse_stats(out)
|
|
67
77
|
result = {}
|
|
68
78
|
out.each_line do |line|
|
|
69
79
|
path, mtime = line.chomp.split("\t", 2)
|
|
70
80
|
next unless path && mtime
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
result[path] =
|
|
82
|
+
if mtime == "-" then :missing
|
|
83
|
+
elsif mtime.match?(/\A\d+\z/) then Time.at(mtime.to_i)
|
|
84
|
+
end
|
|
74
85
|
end
|
|
75
86
|
result
|
|
76
|
-
rescue Errno::ENOENT
|
|
77
|
-
nil # ssh not installed
|
|
78
87
|
end
|
|
79
88
|
|
|
80
89
|
# Checksum many paths in one ssh round-trip, same shape as stat_paths:
|
data/lib/twin/scanner.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Twin
|
|
|
10
10
|
:program, :path, :description, :active, :excludes, :owned, :label,
|
|
11
11
|
:source, :target, :cmd, :delete, :render, :render_outdated, :target_path_field, :sync_file,
|
|
12
12
|
:source_exists, :target_exists, :source_mtime, :target_mtime, :conflict,
|
|
13
|
-
:target_unreachable, :directory, :content_equal, :drift,
|
|
13
|
+
:target_unreachable, :directory, :content_equal, :drift, :verify,
|
|
14
14
|
keyword_init: true,
|
|
15
15
|
) do
|
|
16
16
|
def source_path = File.join(source, path)
|
|
@@ -114,11 +114,14 @@ module Twin
|
|
|
114
114
|
j.target_unreachable = true
|
|
115
115
|
next
|
|
116
116
|
end
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
117
|
+
# :missing = not there; nil = there, but the stat chain could not
|
|
118
|
+
# name an mtime (degrades to unknown, exactly what doctor warns).
|
|
119
|
+
# A path absent from the answer counts as missing, not as unknown.
|
|
120
|
+
mtime = stats.fetch(rpath, :missing)
|
|
121
|
+
j.target_exists = mtime != :missing
|
|
122
|
+
j.target_mtime = mtime.is_a?(Time) ? mtime : nil
|
|
123
|
+
j.conflict = !j.directory && j.source_exists && j.target_mtime &&
|
|
124
|
+
j.source_mtime && j.target_mtime - j.source_mtime >= 60
|
|
122
125
|
end
|
|
123
126
|
verify_remote_file_content(host, host_jobs)
|
|
124
127
|
end
|
|
@@ -129,7 +132,8 @@ module Twin
|
|
|
129
132
|
# content clears the conflict — the timestamps merely disagree.
|
|
130
133
|
def verify_remote_file_content(host, host_jobs)
|
|
131
134
|
candidates = host_jobs.select do |j|
|
|
132
|
-
|
|
135
|
+
j.verify != false && !j.directory && !j.render &&
|
|
136
|
+
j.source_exists && j.target_exists &&
|
|
133
137
|
j.source_mtime && j.target_mtime && (j.target_mtime - j.source_mtime).abs >= 60
|
|
134
138
|
end
|
|
135
139
|
return if candidates.empty?
|
|
@@ -183,6 +187,11 @@ module Twin
|
|
|
183
187
|
return nil if path.empty? || source.empty? || target.empty?
|
|
184
188
|
|
|
185
189
|
render = r["Render"] == true
|
|
190
|
+
# Verify: false — no content verification for this entry, ever: too big
|
|
191
|
+
# or too remote for md5/dry-run rounds (a node_modules tree over SMB).
|
|
192
|
+
# Status falls back to mtime for files and stays ∘ for directories; the
|
|
193
|
+
# pre-sync conflict check skips it (--update still protects the target).
|
|
194
|
+
verify = r["Verify"] != false
|
|
186
195
|
excludes = split_list(r["Exclude"])
|
|
187
196
|
# Own: paths inside the sync scope that the TARGET owns — machine-specific
|
|
188
197
|
# config the source must never clobber. Same rsync effect as Exclude, but
|
|
@@ -214,7 +223,7 @@ module Twin
|
|
|
214
223
|
# (a `cat >` copy before the first twin run). Check before judging;
|
|
215
224
|
# a directory's own mtime is judged not at all (see Job#status).
|
|
216
225
|
content_equal = nil
|
|
217
|
-
if !render && !remote && !directory && src_exists && tgt_exists &&
|
|
226
|
+
if verify && !render && !remote && !directory && src_exists && tgt_exists &&
|
|
218
227
|
src_mtime && tgt_mtime && (tgt_mtime - src_mtime).abs >= 60
|
|
219
228
|
content_equal = Twin::Conflict.same_content?(src_full, tgt_full)
|
|
220
229
|
end
|
|
@@ -248,6 +257,7 @@ module Twin
|
|
|
248
257
|
target_unreachable: false,
|
|
249
258
|
directory: directory,
|
|
250
259
|
content_equal: content_equal,
|
|
260
|
+
verify: verify,
|
|
251
261
|
)
|
|
252
262
|
end
|
|
253
263
|
|
data/lib/twin/version.rb
CHANGED