mark-twin 0.5.0 → 0.6.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 +22 -7
- data/README.md +20 -6
- data/lib/twin/cli.rb +13 -27
- data/lib/twin/conflict.rb +32 -0
- data/lib/twin/picker.rb +91 -25
- data/lib/twin/scanner.rb +10 -3
- 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: 9e643b14b2fe0dd153539ff16d10a0950f3c478d327620dec1b9b04d7af4f340
|
|
4
|
+
data.tar.gz: 4eb24c0280ee5f1085024c1357a6d7b8dd0b7f16198e3cce8b4c9ce5849705b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7d5567dc0637bc767f7ddff5716a2e5031902e787958e1ecd69f54931e737b2946d82c999637d6610d732e5063898c3b717b49148f8e495af328ea2f0b716d03
|
|
7
|
+
data.tar.gz: 94df8bd72ea5548e668b1173e17b9e9bf54efc1fbca4576c109ed64740794decb94eb23fab78c50ed2b0654bc2160bb1a4be85c74a2f9281bd2a178e0f33b270
|
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
|
@@ -58,7 +58,10 @@ on both sides since the last sync, and paths that differ per host.
|
|
|
58
58
|
## Screenshots
|
|
59
59
|
|
|
60
60
|
Stage 1 — program picker. One row per program, color-coded status, indented
|
|
61
|
-
paths underneath
|
|
61
|
+
paths underneath. A name that appears in several sync-files
|
|
62
|
+
(the app in one, its config in another) is one entry, and a trailing bracket
|
|
63
|
+
group ties variants to their base name — `livesync [agent]` lists under
|
|
64
|
+
`livesync`. Stage 2 sections the merged paths per origin:
|
|
62
65
|
|
|
63
66
|

|
|
64
67
|
|
|
@@ -213,7 +216,7 @@ rather than as an error.
|
|
|
213
216
|
|
|
214
217
|
| Field | Where | Meaning |
|
|
215
218
|
|---|---|---|
|
|
216
|
-
| `Program` | block | Group name; blocks sharing it sync together |
|
|
219
|
+
| `Program` | block | Group name; blocks sharing it sync together. A trailing `[…]` lists under the base name in the picker |
|
|
217
220
|
| `Path` | block | Path relative to `Source` (file or directory) |
|
|
218
221
|
| `Source` | either | Absolute base path on this machine |
|
|
219
222
|
| `Target` | either | Absolute base path, or `user@host:/path` for ssh |
|
|
@@ -226,6 +229,7 @@ rather than as an error.
|
|
|
226
229
|
| `Delete` | block | `true` mirrors deletions, with backups |
|
|
227
230
|
| `Cmd` | block | Shell command, run only when bytes actually moved |
|
|
228
231
|
| `Render` | block | `true` substitutes `{{tokens}}` instead of copying |
|
|
232
|
+
| `Verify` | block | `false` skips content verification — for entries too big (see below) |
|
|
229
233
|
|
|
230
234
|
### Exclude or Own?
|
|
231
235
|
|
|
@@ -376,9 +380,19 @@ Two properties make this bearable day to day:
|
|
|
376
380
|
|
|
377
381
|
`twin status` gives the same verdict without syncing: it runs the dry-runs per
|
|
378
382
|
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.
|
|
383
|
+
changed on the target. The picker's first stage stays mtime-blind for
|
|
384
|
+
directory entries — it marks them `∘` (unverified) rather than guessing,
|
|
385
|
+
because the dry-runs would make it slow to open. Entering a program runs them
|
|
386
|
+
for just that program, so its directory rows show a real verdict.
|
|
387
|
+
|
|
388
|
+
One escape hatch: `Verify: false` opts an entry out of every content round —
|
|
389
|
+
the md5 checks, the status dry-runs, the picker's verification, the pre-sync
|
|
390
|
+
conflict listing. It exists for entries where the walk itself is the cost: a
|
|
391
|
+
`node_modules` tree on an SMB mount stats tens of thousands of files for one
|
|
392
|
+
verdict. Such an entry stays `∘`/mtime-based, and the picker's second stage
|
|
393
|
+
says so in its header rather than letting the `∘` pass as pending; a sync
|
|
394
|
+
still leaves newer target files alone (`--update` holds), they just aren't
|
|
395
|
+
itemised first.
|
|
382
396
|
|
|
383
397
|
## Automation
|
|
384
398
|
|
|
@@ -443,7 +457,7 @@ it differs from the current target, so a `Cmd` hook fires only on a real change.
|
|
|
443
457
|
## LiveSync LaunchAgent
|
|
444
458
|
|
|
445
459
|
```yaml
|
|
446
|
-
Program: livesync
|
|
460
|
+
Program: livesync [agent]
|
|
447
461
|
Source: "{{src.home}}/Automation/launchd"
|
|
448
462
|
Path: com.ralf.livesync.plist
|
|
449
463
|
Target: "{{dst.mount}}"
|
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/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)
|
|
@@ -129,7 +129,8 @@ module Twin
|
|
|
129
129
|
# content clears the conflict — the timestamps merely disagree.
|
|
130
130
|
def verify_remote_file_content(host, host_jobs)
|
|
131
131
|
candidates = host_jobs.select do |j|
|
|
132
|
-
|
|
132
|
+
j.verify != false && !j.directory && !j.render &&
|
|
133
|
+
j.source_exists && j.target_exists &&
|
|
133
134
|
j.source_mtime && j.target_mtime && (j.target_mtime - j.source_mtime).abs >= 60
|
|
134
135
|
end
|
|
135
136
|
return if candidates.empty?
|
|
@@ -183,6 +184,11 @@ module Twin
|
|
|
183
184
|
return nil if path.empty? || source.empty? || target.empty?
|
|
184
185
|
|
|
185
186
|
render = r["Render"] == true
|
|
187
|
+
# Verify: false — no content verification for this entry, ever: too big
|
|
188
|
+
# or too remote for md5/dry-run rounds (a node_modules tree over SMB).
|
|
189
|
+
# Status falls back to mtime for files and stays ∘ for directories; the
|
|
190
|
+
# pre-sync conflict check skips it (--update still protects the target).
|
|
191
|
+
verify = r["Verify"] != false
|
|
186
192
|
excludes = split_list(r["Exclude"])
|
|
187
193
|
# Own: paths inside the sync scope that the TARGET owns — machine-specific
|
|
188
194
|
# config the source must never clobber. Same rsync effect as Exclude, but
|
|
@@ -214,7 +220,7 @@ module Twin
|
|
|
214
220
|
# (a `cat >` copy before the first twin run). Check before judging;
|
|
215
221
|
# a directory's own mtime is judged not at all (see Job#status).
|
|
216
222
|
content_equal = nil
|
|
217
|
-
if !render && !remote && !directory && src_exists && tgt_exists &&
|
|
223
|
+
if verify && !render && !remote && !directory && src_exists && tgt_exists &&
|
|
218
224
|
src_mtime && tgt_mtime && (tgt_mtime - src_mtime).abs >= 60
|
|
219
225
|
content_equal = Twin::Conflict.same_content?(src_full, tgt_full)
|
|
220
226
|
end
|
|
@@ -248,6 +254,7 @@ module Twin
|
|
|
248
254
|
target_unreachable: false,
|
|
249
255
|
directory: directory,
|
|
250
256
|
content_equal: content_equal,
|
|
257
|
+
verify: verify,
|
|
251
258
|
)
|
|
252
259
|
end
|
|
253
260
|
|
data/lib/twin/version.rb
CHANGED