mark-twin 0.2.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 +85 -6
- data/README.md +270 -129
- data/bin/twin +5 -0
- data/lib/twin/add.rb +177 -0
- data/lib/twin/cli.rb +189 -26
- data/lib/twin/conflict.rb +143 -0
- data/lib/twin/journal.rb +49 -0
- data/lib/twin/picker.rb +2 -0
- data/lib/twin/remote.rb +72 -0
- data/lib/twin/scanner.rb +58 -5
- data/lib/twin/sync.rb +58 -16
- data/lib/twin/version.rb +1 -1
- data/lib/twin.rb +4 -0
- metadata +6 -3
data/bin/twin
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
|
+
# Sync-files are UTF-8 regardless of the environment's locale — without this,
|
|
4
|
+
# a launchd/cron context (no LANG) reads them as US-ASCII and chokes on "→".
|
|
5
|
+
Encoding.default_external = Encoding::UTF_8
|
|
6
|
+
Encoding.default_internal = Encoding::UTF_8
|
|
7
|
+
|
|
3
8
|
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
4
9
|
|
|
5
10
|
require "twin"
|
data/lib/twin/add.rb
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
require_relative "template"
|
|
4
|
+
|
|
5
|
+
module Twin
|
|
6
|
+
# `twin add <path>` — guided scaffolding of a new sync entry. Turns the
|
|
7
|
+
# judgment calls of the "add a sync" recipe (which sync-file? which Path?
|
|
8
|
+
# what to exclude? deploy hook?) into prompts with sensible defaults, then
|
|
9
|
+
# appends a Markdown block to the chosen sync-file.
|
|
10
|
+
module Add
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Directories that are usually machine-generated or heavy — offered as
|
|
14
|
+
# exclude defaults when present in the source directory.
|
|
15
|
+
SUGGEST_EXCLUDES = %w[.git node_modules .venv __pycache__ dist build target].freeze
|
|
16
|
+
|
|
17
|
+
# ── pure helpers (unit-tested) ────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
# Frontmatter of a sync-file as a Hash (Source/Target token-substituted),
|
|
20
|
+
# or nil when the file has none / it isn't a Hash.
|
|
21
|
+
def frontmatter(file, vars = {})
|
|
22
|
+
text = File.read(file)
|
|
23
|
+
return nil unless text.start_with?("---\n")
|
|
24
|
+
body = text[4..].split(/^---\s*$/, 2).first
|
|
25
|
+
data = begin
|
|
26
|
+
YAML.safe_load(body.to_s)
|
|
27
|
+
rescue Psych::SyntaxError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
return nil unless data.is_a?(Hash)
|
|
31
|
+
%w[Source Target].each do |k|
|
|
32
|
+
next unless data[k].is_a?(String)
|
|
33
|
+
data[k] = Twin::Template.substitute(data[k], vars, context: File.basename(file))
|
|
34
|
+
end
|
|
35
|
+
data
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Sync-files in dir whose Source is an ancestor of path.
|
|
41
|
+
# Returns [[file, frontmatter], …].
|
|
42
|
+
def candidates(dir, path, vars = {})
|
|
43
|
+
Dir.glob(File.join(dir, "*.md")).sort.filter_map do |f|
|
|
44
|
+
fm = frontmatter(f, vars)
|
|
45
|
+
next unless fm && fm["Source"].is_a?(String) && !fm["Source"].empty?
|
|
46
|
+
root = File.expand_path(fm["Source"])
|
|
47
|
+
next unless path == root || path.start_with?(root + "/")
|
|
48
|
+
[f, fm]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def relative_path(root, path)
|
|
53
|
+
path == root ? "." : path[(root.length + 1)..]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def suggest_excludes(path)
|
|
57
|
+
return [] unless File.directory?(path)
|
|
58
|
+
SUGGEST_EXCLUDES.filter_map do |e|
|
|
59
|
+
full = File.join(path, e)
|
|
60
|
+
next unless File.exist?(full)
|
|
61
|
+
File.directory?(full) ? "#{e}/" : e
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_block(program:, path:, description: "", excludes: [], delete: false, cmd: "", prose: "")
|
|
66
|
+
yaml = ["Program: #{program}", "Path: #{path}"]
|
|
67
|
+
yaml << "Description: #{description}" unless description.empty?
|
|
68
|
+
yaml << "Exclude: #{excludes.join(',')}" unless excludes.empty?
|
|
69
|
+
yaml << "Delete: true" if delete
|
|
70
|
+
yaml << "Cmd: #{cmd}" unless cmd.empty?
|
|
71
|
+
prose = "TODO: document why this path is synced." if prose.empty?
|
|
72
|
+
"\n## #{program}\n\n#{prose}\n\n```yaml\n#{yaml.join("\n")}\n```\n"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def frontmatter_text(source:, target:, label: "")
|
|
76
|
+
lines = ["---", "Active: 1"]
|
|
77
|
+
lines << "Label: #{label}" unless label.empty?
|
|
78
|
+
lines << "Source: #{source}" << "Target: #{target}" << "---" << ""
|
|
79
|
+
lines.join("\n")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# ── interactive flow ──────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
# Returns {program:, file:, dry_run:} on success (dry_run: whether the
|
|
85
|
+
# user asked for one), nil when nothing was written.
|
|
86
|
+
def run(cfg, args)
|
|
87
|
+
raw = args.first
|
|
88
|
+
raise "usage: twin add <path>" if raw.nil? || raw.empty?
|
|
89
|
+
path = File.expand_path(raw)
|
|
90
|
+
raise "not found: #{path}" unless File.exist?(path)
|
|
91
|
+
|
|
92
|
+
vars = cfg.var_map
|
|
93
|
+
picked = pick_sync_file(cfg, path, vars)
|
|
94
|
+
return nil unless picked
|
|
95
|
+
file, fm = picked
|
|
96
|
+
|
|
97
|
+
root = File.expand_path(fm["Source"])
|
|
98
|
+
rel = relative_path(root, path)
|
|
99
|
+
|
|
100
|
+
if File.exist?(file) && File.read(file).match?(/^Path:\s*#{Regexp.escape(rel)}\s*$/)
|
|
101
|
+
raise "#{File.basename(file)} already has a block with Path: #{rel}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
program = ask("Program name", File.basename(path))
|
|
105
|
+
prose = ask("Why is this synced? (one line of prose)")
|
|
106
|
+
desc = ask("Description (short, for listings)", program)
|
|
107
|
+
excl = ask("Exclude (comma-separated)", suggest_excludes(path).join(","))
|
|
108
|
+
.split(",").map(&:strip).reject(&:empty?)
|
|
109
|
+
delete = yes?(ask("Mirror deletions on target (Delete: true)? (y/N)", "n"))
|
|
110
|
+
cmd = ask("Post-sync Cmd (empty for none)")
|
|
111
|
+
|
|
112
|
+
block = build_block(program: program, path: rel, description: desc,
|
|
113
|
+
excludes: excl, delete: delete, cmd: cmd, prose: prose)
|
|
114
|
+
File.open(file, "a") { |f| f.write(block) }
|
|
115
|
+
|
|
116
|
+
puts "\nadded #{program.inspect} to #{File.basename(file)}"
|
|
117
|
+
puts " #{File.join(root, rel)} → #{File.join(fm['Target'].to_s, rel)}"
|
|
118
|
+
|
|
119
|
+
dry = yes?(ask("Run a dry-run now? (Y/n)", "y"))
|
|
120
|
+
{ program: program, file: file, dry_run: dry }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Choose (or create) the sync-file covering path.
|
|
124
|
+
# Returns [file, frontmatter] or nil.
|
|
125
|
+
def pick_sync_file(cfg, path, vars)
|
|
126
|
+
cands = candidates(cfg.sync_dir, path, vars)
|
|
127
|
+
case cands.size
|
|
128
|
+
when 0 then offer_new_sync_file(cfg, path, vars)
|
|
129
|
+
when 1
|
|
130
|
+
file, fm = cands.first
|
|
131
|
+
puts "sync-file: #{File.basename(file)} (#{fm['Source']} → #{fm['Target']})"
|
|
132
|
+
cands.first
|
|
133
|
+
else
|
|
134
|
+
puts "multiple sync-files cover #{path}:"
|
|
135
|
+
cands.each_with_index do |(f, fm), i|
|
|
136
|
+
puts " #{i + 1}) #{File.basename(f)} (#{fm['Source']} → #{fm['Target']})"
|
|
137
|
+
end
|
|
138
|
+
n = ask("Which one?", "1").to_i
|
|
139
|
+
cands[n - 1] or raise "invalid choice: #{n}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def offer_new_sync_file(cfg, path, vars)
|
|
144
|
+
puts "no sync-file in #{cfg.sync_dir} covers #{path}"
|
|
145
|
+
return nil unless yes?(ask("Create a new sync-file? (y/N)", "n"))
|
|
146
|
+
|
|
147
|
+
name = ask("File name", "#{File.basename(path).sub(/\A\./, '')}.md")
|
|
148
|
+
name += ".md" unless name.end_with?(".md")
|
|
149
|
+
file = File.join(cfg.sync_dir, name)
|
|
150
|
+
raise "already exists: #{file}" if File.exist?(file)
|
|
151
|
+
|
|
152
|
+
source = ask("Source base on this machine", File.dirname(path))
|
|
153
|
+
target = ask("Target base (mount path or user@host:/path)")
|
|
154
|
+
raise "Target is required" if target.empty?
|
|
155
|
+
label = ask("Label (e.g. mini → server)")
|
|
156
|
+
|
|
157
|
+
File.write(file, frontmatter_text(source: source, target: target, label: label))
|
|
158
|
+
puts "created #{File.basename(file)}"
|
|
159
|
+
fm = { "Source" => source, "Target" => target }
|
|
160
|
+
%w[Source Target].each do |k|
|
|
161
|
+
fm[k] = Twin::Template.substitute(fm[k], vars, context: name)
|
|
162
|
+
end
|
|
163
|
+
[file, fm]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def ask(prompt, default = "")
|
|
167
|
+
print default.empty? ? "#{prompt}: " : "#{prompt} [#{default}]: "
|
|
168
|
+
$stdout.flush
|
|
169
|
+
ans = $stdin.gets
|
|
170
|
+
raise "aborted (stdin closed)" if ans.nil?
|
|
171
|
+
ans = ans.strip
|
|
172
|
+
ans.empty? ? default : ans
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def yes?(answer) = answer.match?(/\Ay/i)
|
|
176
|
+
end
|
|
177
|
+
end
|
data/lib/twin/cli.rb
CHANGED
|
@@ -6,6 +6,8 @@ require "time" # Time#iso8601 for --json output
|
|
|
6
6
|
require_relative "config"
|
|
7
7
|
require_relative "scanner"
|
|
8
8
|
require_relative "sync"
|
|
9
|
+
require_relative "journal"
|
|
10
|
+
require_relative "add"
|
|
9
11
|
require_relative "picker"
|
|
10
12
|
|
|
11
13
|
module Twin
|
|
@@ -22,6 +24,10 @@ module Twin
|
|
|
22
24
|
twin list [--all] [--label X] [--file X] [--json]
|
|
23
25
|
twin status [--all] [--label X] [--file X] [--json]
|
|
24
26
|
twin sync [-p PATTERN] [--label X] [--file X] [--all] [--dry-run]
|
|
27
|
+
[--quiet] [--skip-unavailable]
|
|
28
|
+
[--force] [--skip-conflicts]
|
|
29
|
+
twin add <path> scaffold a new sync entry for a local path
|
|
30
|
+
twin log [-n N] [--json] recent journal entries (default 20)
|
|
25
31
|
twin doctor check tools, renderers, and sync targets
|
|
26
32
|
twin --help show this message
|
|
27
33
|
|
|
@@ -29,6 +35,12 @@ module Twin
|
|
|
29
35
|
bare name (no /) → matched by substring against sync-file names
|
|
30
36
|
contains / → resolved as path; file or directory both work
|
|
31
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
|
+
|
|
32
44
|
CONFIG:
|
|
33
45
|
~/.config/twin/config.yaml
|
|
34
46
|
TWIN_SYNC_DIR overrides sync_dir
|
|
@@ -46,6 +58,8 @@ module Twin
|
|
|
46
58
|
when "list" then cmd_list(cfg, argv.drop(1))
|
|
47
59
|
when "status" then cmd_status(cfg, argv.drop(1))
|
|
48
60
|
when "sync" then cmd_sync(cfg, argv.drop(1))
|
|
61
|
+
when "add" then cmd_add(cfg, argv.drop(1))
|
|
62
|
+
when "log" then cmd_log(argv.drop(1))
|
|
49
63
|
when "doctor" then cmd_doctor(cfg)
|
|
50
64
|
when "-h", "--help", "help"
|
|
51
65
|
puts USAGE
|
|
@@ -137,10 +151,13 @@ module Twin
|
|
|
137
151
|
p.jobs.each do |j|
|
|
138
152
|
src = j.source_exists ? j.source_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
|
|
139
153
|
tgt = j.target_exists ? j.target_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
|
|
154
|
+
tgt = "(unreachable)" if j.target_unreachable
|
|
140
155
|
conflict = j.conflict ? (tty ? " #{Picker.colorize(:target_newer, "!")}" : " !") : ""
|
|
141
156
|
puts " #{j.path}#{conflict}"
|
|
142
157
|
puts " src #{src}"
|
|
143
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?
|
|
144
161
|
end
|
|
145
162
|
end
|
|
146
163
|
end
|
|
@@ -156,46 +173,179 @@ module Twin
|
|
|
156
173
|
end
|
|
157
174
|
|
|
158
175
|
if programs.empty?
|
|
159
|
-
puts "no matching programs"
|
|
176
|
+
puts "no matching programs" unless opts[:quiet]
|
|
160
177
|
return
|
|
161
178
|
end
|
|
162
179
|
|
|
163
|
-
programs.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
180
|
+
results = programs.map do |p|
|
|
181
|
+
sync_jobs(cfg, p, p.active_jobs,
|
|
182
|
+
dry_run: opts[:dry_run], quiet: opts[:quiet],
|
|
183
|
+
skip_unavailable: opts[:skip_unavailable],
|
|
184
|
+
force: opts[:force], skip_conflicts: opts[:skip_conflicts])
|
|
185
|
+
end
|
|
186
|
+
exit 1 unless results.all?
|
|
168
187
|
end
|
|
169
188
|
|
|
170
|
-
|
|
189
|
+
# Sync the given jobs. Returns true when every attempted job succeeded.
|
|
190
|
+
# quiet: print only conflicts, errors, and jobs that changed something
|
|
191
|
+
# skip_unavailable: skip jobs whose target is unmounted/unreachable instead of aborting
|
|
192
|
+
def sync_jobs(cfg, program, jobs, dry_run: false, quiet: false, skip_unavailable: false,
|
|
193
|
+
force: false, skip_conflicts: false)
|
|
171
194
|
jobs = jobs.select { |j| j.active == 1 }
|
|
172
|
-
return if jobs.empty?
|
|
173
|
-
|
|
174
|
-
# one
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
195
|
+
return true if jobs.empty?
|
|
196
|
+
|
|
197
|
+
# one availability check per unique target root:
|
|
198
|
+
# local targets must be mounted volumes, remote ones reachable via ssh
|
|
199
|
+
availability = {}
|
|
200
|
+
jobs.each { |j| availability[j.target] ||= target_availability(j) }
|
|
201
|
+
jobs, unavailable = jobs.partition { |j| availability[j.target] == :ok }
|
|
202
|
+
|
|
203
|
+
unavailable.map { |j| availability[j.target] }.uniq.each do |reason|
|
|
204
|
+
if skip_unavailable
|
|
205
|
+
puts "skipped: #{reason}" unless quiet
|
|
206
|
+
else
|
|
207
|
+
warn "abort: #{reason}"
|
|
180
208
|
exit 1
|
|
181
209
|
end
|
|
182
|
-
checked << j.target
|
|
183
210
|
end
|
|
211
|
+
return true if jobs.empty?
|
|
184
212
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
191
219
|
|
|
192
|
-
|
|
220
|
+
header_printed = false
|
|
221
|
+
all_ok = true
|
|
193
222
|
jobs.each do |job|
|
|
194
|
-
success, output, = 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)
|
|
224
|
+
Twin::Journal.record(job, success: success, transferred: transferred, output: output) unless dry_run
|
|
225
|
+
all_ok &&= success
|
|
226
|
+
next if quiet && success && !transferred
|
|
227
|
+
|
|
228
|
+
unless header_printed
|
|
229
|
+
puts "→ #{program.name}"
|
|
230
|
+
header_printed = true
|
|
231
|
+
end
|
|
195
232
|
puts " • #{job.path}"
|
|
196
233
|
puts output.gsub(/^/, " ") if output && !output.strip.empty?
|
|
197
234
|
warn " error syncing #{job.path}" unless success
|
|
198
235
|
end
|
|
236
|
+
all_ok
|
|
237
|
+
end
|
|
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
|
+
|
|
300
|
+
# :ok, or a human-readable reason the target can't be synced right now.
|
|
301
|
+
def target_availability(job)
|
|
302
|
+
if job.remote?
|
|
303
|
+
host, = Twin::Remote.split(job.target)
|
|
304
|
+
return :ok if Twin::Remote.reachable?(host)
|
|
305
|
+
"#{host} is not reachable via ssh"
|
|
306
|
+
else
|
|
307
|
+
return :ok if Twin::Sync.mounted?(job.target)
|
|
308
|
+
"#{job.target} is not a mounted volume"
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# ── add ────────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
def cmd_add(cfg, args)
|
|
315
|
+
result = Twin::Add.run(cfg, args)
|
|
316
|
+
return unless result && result[:dry_run]
|
|
317
|
+
cmd_sync(cfg, ["-p", result[:program],
|
|
318
|
+
"--file=#{File.basename(result[:file])}", "--dry-run"])
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# ── log ────────────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
def cmd_log(args)
|
|
324
|
+
n = 20
|
|
325
|
+
json = false
|
|
326
|
+
OptionParser.new do |o|
|
|
327
|
+
o.on("-n N", Integer) { |v| n = v }
|
|
328
|
+
o.on("--json") { json = true }
|
|
329
|
+
end.parse!(args)
|
|
330
|
+
|
|
331
|
+
entries = Twin::Journal.tail(n)
|
|
332
|
+
if json
|
|
333
|
+
puts JSON.pretty_generate(entries)
|
|
334
|
+
return
|
|
335
|
+
end
|
|
336
|
+
if entries.empty?
|
|
337
|
+
puts "journal is empty (#{Twin::Journal.log_path})"
|
|
338
|
+
return
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
tty = $stdout.tty?
|
|
342
|
+
entries.each do |e|
|
|
343
|
+
ts = Time.parse(e["ts"]).strftime("%Y-%m-%d %H:%M:%S")
|
|
344
|
+
mark = e["ok"] ? "✓" : "✗"
|
|
345
|
+
mark = Picker.colorize(e["ok"] ? :in_sync : :both_missing, mark) if tty
|
|
346
|
+
note = e["ok"] ? (e["changed"] ? "changed" : "no-op") : "error: #{e["error"]}"
|
|
347
|
+
puts "#{ts} #{mark} #{e["program"]} #{e["path"]} (#{note})"
|
|
348
|
+
end
|
|
199
349
|
end
|
|
200
350
|
|
|
201
351
|
# ── doctor ─────────────────────────────────────────────────────────────────
|
|
@@ -252,7 +402,15 @@ module Twin
|
|
|
252
402
|
puts " (no programs loaded)"
|
|
253
403
|
else
|
|
254
404
|
targets.each do |tgt|
|
|
255
|
-
if Twin::
|
|
405
|
+
if Twin::Remote.remote?(tgt)
|
|
406
|
+
host, = Twin::Remote.split(tgt)
|
|
407
|
+
if Twin::Remote.reachable?(host)
|
|
408
|
+
puts " ✓ #{tgt} (ssh)"
|
|
409
|
+
else
|
|
410
|
+
puts " ✗ #{tgt} (ssh: #{host} not reachable)"
|
|
411
|
+
ok = false
|
|
412
|
+
end
|
|
413
|
+
elsif Twin::Sync.mounted?(tgt)
|
|
256
414
|
puts " ✓ #{tgt}"
|
|
257
415
|
else
|
|
258
416
|
puts " ✗ #{tgt} (not mounted)"
|
|
@@ -287,13 +445,18 @@ module Twin
|
|
|
287
445
|
end
|
|
288
446
|
|
|
289
447
|
def parse_sync_opts(args)
|
|
290
|
-
opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false
|
|
448
|
+
opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false,
|
|
449
|
+
quiet: false, skip_unavailable: false, force: false, skip_conflicts: false }
|
|
291
450
|
OptionParser.new do |o|
|
|
292
451
|
o.on("--all") { opts[:show_all] = true }
|
|
293
452
|
o.on("--label=L") { |v| opts[:label] = v }
|
|
294
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 }
|
|
295
456
|
o.on("-p", "--pattern=P") { |v| opts[:pattern] = v }
|
|
296
457
|
o.on("--dry-run") { opts[:dry_run] = true }
|
|
458
|
+
o.on("-q", "--quiet") { opts[:quiet] = true }
|
|
459
|
+
o.on("--skip-unavailable") { opts[:skip_unavailable] = true }
|
|
297
460
|
end.parse!(args)
|
|
298
461
|
opts
|
|
299
462
|
end
|
|
@@ -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/journal.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Twin
|
|
6
|
+
# Append-only sync journal: one JSON line per synced job in
|
|
7
|
+
# ~/.local/state/twin/log.jsonl. Answers "did yesterday's sync actually
|
|
8
|
+
# run, and what did it do?" — and stays machine-readable (jq/grubber).
|
|
9
|
+
# Journal failures never break a sync; they degrade to a warning.
|
|
10
|
+
module Journal
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def state_dir
|
|
14
|
+
ENV["TWIN_STATE_DIR"] || File.join(Dir.home, ".local", "state", "twin")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def log_path = File.join(state_dir, "log.jsonl")
|
|
18
|
+
|
|
19
|
+
# Record one job result. Dry-runs are not journaled.
|
|
20
|
+
def record(job, success:, transferred:, output: nil)
|
|
21
|
+
entry = {
|
|
22
|
+
ts: Time.now.iso8601,
|
|
23
|
+
program: job.program,
|
|
24
|
+
path: job.path,
|
|
25
|
+
target: job.target,
|
|
26
|
+
ok: success,
|
|
27
|
+
changed: transferred,
|
|
28
|
+
}
|
|
29
|
+
unless success
|
|
30
|
+
entry[:error] = output.to_s.lines.map(&:strip).reject(&:empty?).last.to_s[0, 200]
|
|
31
|
+
end
|
|
32
|
+
FileUtils.mkdir_p(state_dir)
|
|
33
|
+
File.open(log_path, "a") { |f| f.puts(JSON.generate(entry)) }
|
|
34
|
+
rescue SystemCallError => e
|
|
35
|
+
warn "journal: #{e.message}" unless @warned
|
|
36
|
+
@warned = true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Last n entries, oldest first. Unparseable lines are skipped.
|
|
40
|
+
def tail(n)
|
|
41
|
+
return [] unless File.exist?(log_path)
|
|
42
|
+
File.readlines(log_path).last(n).filter_map do |line|
|
|
43
|
+
JSON.parse(line)
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|