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.
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.each { |p| sync_program(cfg, p, dry_run: opts[:dry_run]) }
164
- end
165
-
166
- def sync_program(cfg, program, dry_run: false)
167
- sync_jobs(cfg, program, program.active_jobs, dry_run: dry_run)
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
- def sync_jobs(cfg, program, jobs, dry_run: false)
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 mount check per unique target root
175
- checked = Set.new
176
- jobs.each do |j|
177
- next if checked.include?(j.target)
178
- unless Twin::Sync.mounted?(j.target)
179
- warn "abort: #{j.target} is not a mounted volume"
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
- conflicts = jobs.select(&:conflict)
186
- unless conflicts.empty?
187
- warn "warning: target is newer than source:"
188
- conflicts.each { |j| warn " ! #{j.path}" }
189
- warn "continuing sync (--update skips newer files on target)."
190
- end
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
- puts "→ #{program.name}"
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::Sync.mounted?(tgt)
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
@@ -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