mark-twin 0.1.3 → 0.3.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/lib/twin/cli.rb CHANGED
@@ -1,10 +1,13 @@
1
1
  require "optparse"
2
2
  require "json"
3
3
  require "set"
4
+ require "time" # Time#iso8601 for --json output
4
5
 
5
6
  require_relative "config"
6
7
  require_relative "scanner"
7
8
  require_relative "sync"
9
+ require_relative "journal"
10
+ require_relative "add"
8
11
  require_relative "picker"
9
12
 
10
13
  module Twin
@@ -21,6 +24,10 @@ module Twin
21
24
  twin list [--all] [--label X] [--file X] [--json]
22
25
  twin status [--all] [--label X] [--file X] [--json]
23
26
  twin sync [-p PATTERN] [--label X] [--file X] [--all] [--dry-run]
27
+ [--quiet] [--skip-unavailable]
28
+ twin add <path> scaffold a new sync entry for a local path
29
+ twin log [-n N] [--json] recent journal entries (default 20)
30
+ twin doctor check tools, renderers, and sync targets
24
31
  twin --help show this message
25
32
 
26
33
  FILE ARGUMENT:
@@ -44,6 +51,9 @@ module Twin
44
51
  when "list" then cmd_list(cfg, argv.drop(1))
45
52
  when "status" then cmd_status(cfg, argv.drop(1))
46
53
  when "sync" then cmd_sync(cfg, argv.drop(1))
54
+ when "add" then cmd_add(cfg, argv.drop(1))
55
+ when "log" then cmd_log(argv.drop(1))
56
+ when "doctor" then cmd_doctor(cfg)
47
57
  when "-h", "--help", "help"
48
58
  puts USAGE
49
59
  when /\A-/
@@ -134,6 +144,7 @@ module Twin
134
144
  p.jobs.each do |j|
135
145
  src = j.source_exists ? j.source_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
136
146
  tgt = j.target_exists ? j.target_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
147
+ tgt = "(unreachable)" if j.target_unreachable
137
148
  conflict = j.conflict ? (tty ? " #{Picker.colorize(:target_newer, "!")}" : " !") : ""
138
149
  puts " #{j.path}#{conflict}"
139
150
  puts " src #{src}"
@@ -153,31 +164,40 @@ module Twin
153
164
  end
154
165
 
155
166
  if programs.empty?
156
- puts "no matching programs"
167
+ puts "no matching programs" unless opts[:quiet]
157
168
  return
158
169
  end
159
170
 
160
- programs.each { |p| sync_program(cfg, p, dry_run: opts[:dry_run]) }
161
- end
162
-
163
- def sync_program(cfg, program, dry_run: false)
164
- sync_jobs(cfg, program, program.active_jobs, dry_run: dry_run)
171
+ results = programs.map do |p|
172
+ sync_jobs(cfg, p, p.active_jobs,
173
+ dry_run: opts[:dry_run], quiet: opts[:quiet],
174
+ skip_unavailable: opts[:skip_unavailable])
175
+ end
176
+ exit 1 unless results.all?
165
177
  end
166
178
 
167
- def sync_jobs(cfg, program, jobs, dry_run: false)
179
+ # Sync the given jobs. Returns true when every attempted job succeeded.
180
+ # quiet: print only conflicts, errors, and jobs that changed something
181
+ # skip_unavailable: skip jobs whose target is unmounted/unreachable instead of aborting
182
+ def sync_jobs(cfg, program, jobs, dry_run: false, quiet: false, skip_unavailable: false)
168
183
  jobs = jobs.select { |j| j.active == 1 }
169
- return if jobs.empty?
170
-
171
- # one mount check per unique target root
172
- checked = Set.new
173
- jobs.each do |j|
174
- next if checked.include?(j.target)
175
- unless Twin::Sync.mounted?(j.target)
176
- warn "abort: #{j.target} is not a mounted volume"
184
+ return true if jobs.empty?
185
+
186
+ # one availability check per unique target root:
187
+ # local targets must be mounted volumes, remote ones reachable via ssh
188
+ availability = {}
189
+ jobs.each { |j| availability[j.target] ||= target_availability(j) }
190
+ jobs, unavailable = jobs.partition { |j| availability[j.target] == :ok }
191
+
192
+ unavailable.map { |j| availability[j.target] }.uniq.each do |reason|
193
+ if skip_unavailable
194
+ puts "skipped: #{reason}" unless quiet
195
+ else
196
+ warn "abort: #{reason}"
177
197
  exit 1
178
198
  end
179
- checked << j.target
180
199
  end
200
+ return true if jobs.empty?
181
201
 
182
202
  conflicts = jobs.select(&:conflict)
183
203
  unless conflicts.empty?
@@ -186,13 +206,157 @@ module Twin
186
206
  warn "continuing sync (--update skips newer files on target)."
187
207
  end
188
208
 
189
- puts "→ #{program.name}"
209
+ header_printed = false
210
+ all_ok = true
190
211
  jobs.each do |job|
191
- success, output = Twin::Sync.run_job(cfg, job, dry_run: dry_run)
212
+ success, output, transferred = Twin::Sync.run_job(cfg, job, dry_run: dry_run)
213
+ Twin::Journal.record(job, success: success, transferred: transferred, output: output) unless dry_run
214
+ all_ok &&= success
215
+ next if quiet && success && !transferred
216
+
217
+ unless header_printed
218
+ puts "→ #{program.name}"
219
+ header_printed = true
220
+ end
192
221
  puts " • #{job.path}"
193
222
  puts output.gsub(/^/, " ") if output && !output.strip.empty?
194
223
  warn " error syncing #{job.path}" unless success
195
224
  end
225
+ all_ok
226
+ end
227
+
228
+ # :ok, or a human-readable reason the target can't be synced right now.
229
+ def target_availability(job)
230
+ if job.remote?
231
+ host, = Twin::Remote.split(job.target)
232
+ return :ok if Twin::Remote.reachable?(host)
233
+ "#{host} is not reachable via ssh"
234
+ else
235
+ return :ok if Twin::Sync.mounted?(job.target)
236
+ "#{job.target} is not a mounted volume"
237
+ end
238
+ end
239
+
240
+ # ── add ────────────────────────────────────────────────────────────────────
241
+
242
+ def cmd_add(cfg, args)
243
+ result = Twin::Add.run(cfg, args)
244
+ return unless result && result[:dry_run]
245
+ cmd_sync(cfg, ["-p", result[:program],
246
+ "--file=#{File.basename(result[:file])}", "--dry-run"])
247
+ end
248
+
249
+ # ── log ────────────────────────────────────────────────────────────────────
250
+
251
+ def cmd_log(args)
252
+ n = 20
253
+ json = false
254
+ OptionParser.new do |o|
255
+ o.on("-n N", Integer) { |v| n = v }
256
+ o.on("--json") { json = true }
257
+ end.parse!(args)
258
+
259
+ entries = Twin::Journal.tail(n)
260
+ if json
261
+ puts JSON.pretty_generate(entries)
262
+ return
263
+ end
264
+ if entries.empty?
265
+ puts "journal is empty (#{Twin::Journal.log_path})"
266
+ return
267
+ end
268
+
269
+ tty = $stdout.tty?
270
+ entries.each do |e|
271
+ ts = Time.parse(e["ts"]).strftime("%Y-%m-%d %H:%M:%S")
272
+ mark = e["ok"] ? "✓" : "✗"
273
+ mark = Picker.colorize(e["ok"] ? :in_sync : :both_missing, mark) if tty
274
+ note = e["ok"] ? (e["changed"] ? "changed" : "no-op") : "error: #{e["error"]}"
275
+ puts "#{ts} #{mark} #{e["program"]} #{e["path"]} (#{note})"
276
+ end
277
+ end
278
+
279
+ # ── doctor ─────────────────────────────────────────────────────────────────
280
+
281
+ def cmd_doctor(cfg)
282
+ ok = true
283
+
284
+ puts "Tools"
285
+ %w[grubber rsync fzf].each do |bin|
286
+ if tool_available?(bin)
287
+ puts " ✓ #{bin}"
288
+ else
289
+ puts " ✗ #{bin} (required — not found in PATH)"
290
+ ok = false
291
+ end
292
+ end
293
+
294
+ puts "\nRenderers (preview)"
295
+ found_renderer = false
296
+ %w[apex glow bat].each do |bin|
297
+ if tool_available?(bin)
298
+ puts " ✓ #{bin}"
299
+ found_renderer = true
300
+ else
301
+ puts " – #{bin} (not installed)"
302
+ end
303
+ end
304
+ puts " ⚠ no renderer found — file preview will fall back to cat" unless found_renderer
305
+
306
+ puts "\nTemplating"
307
+ if cfg.hosts.empty?
308
+ puts " – no hosts configured"
309
+ else
310
+ %i[host target].each do |attr|
311
+ name = cfg.send(attr)
312
+ if name.empty?
313
+ puts " ✗ #{attr} not set in config"
314
+ ok = false
315
+ elsif cfg.hosts.key?(name)
316
+ puts " ✓ #{attr}: #{name}"
317
+ else
318
+ puts " ✗ #{attr} #{name.inspect} not found in hosts"
319
+ ok = false
320
+ end
321
+ end
322
+ end
323
+
324
+ puts "\nTargets"
325
+ begin
326
+ programs = Scanner.load_programs(cfg, show_all: true)
327
+ puts " ✓ all template tokens resolved" unless cfg.hosts.empty?
328
+ targets = programs.flat_map(&:jobs).map(&:target).uniq.sort
329
+ if targets.empty?
330
+ puts " (no programs loaded)"
331
+ else
332
+ targets.each do |tgt|
333
+ if Twin::Remote.remote?(tgt)
334
+ host, = Twin::Remote.split(tgt)
335
+ if Twin::Remote.reachable?(host)
336
+ puts " ✓ #{tgt} (ssh)"
337
+ else
338
+ puts " ✗ #{tgt} (ssh: #{host} not reachable)"
339
+ ok = false
340
+ end
341
+ elsif Twin::Sync.mounted?(tgt)
342
+ puts " ✓ #{tgt}"
343
+ else
344
+ puts " ✗ #{tgt} (not mounted)"
345
+ ok = false
346
+ end
347
+ end
348
+ end
349
+ rescue => e
350
+ puts " ✗ #{e.message}"
351
+ ok = false
352
+ end
353
+
354
+ puts ok ? "\nAll checks passed." : "\nSome checks failed."
355
+ exit 1 unless ok
356
+ end
357
+
358
+ def tool_available?(name)
359
+ system("command -v #{name} > /dev/null 2>&1")
196
360
  end
197
361
 
198
362
  # ── option parsing ─────────────────────────────────────────────────────────
@@ -209,13 +373,16 @@ module Twin
209
373
  end
210
374
 
211
375
  def parse_sync_opts(args)
212
- opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false }
376
+ opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false,
377
+ quiet: false, skip_unavailable: false }
213
378
  OptionParser.new do |o|
214
379
  o.on("--all") { opts[:show_all] = true }
215
380
  o.on("--label=L") { |v| opts[:label] = v }
216
381
  o.on("--file=F") { |v| opts[:file] = v }
217
382
  o.on("-p", "--pattern=P") { |v| opts[:pattern] = v }
218
383
  o.on("--dry-run") { opts[:dry_run] = true }
384
+ o.on("-q", "--quiet") { opts[:quiet] = true }
385
+ o.on("--skip-unavailable") { opts[:skip_unavailable] = true }
219
386
  end.parse!(args)
220
387
  opts
221
388
  end
data/lib/twin/config.rb CHANGED
@@ -5,7 +5,8 @@ module Twin
5
5
  class Config
6
6
  attr_accessor :sync_dir, :global_excludes,
7
7
  :apex_theme, :apex_width,
8
- :apex_code_highlight, :apex_code_highlight_theme
8
+ :apex_code_highlight, :apex_code_highlight_theme,
9
+ :hosts, :host, :target
9
10
 
10
11
  DEFAULTS = {
11
12
  "global_excludes" => [".DS_Store"],
@@ -13,6 +14,9 @@ module Twin
13
14
  "apex_width" => nil,
14
15
  "apex_code_highlight" => nil,
15
16
  "apex_code_highlight_theme" => nil,
17
+ "hosts" => {},
18
+ "host" => "",
19
+ "target" => "",
16
20
  }.freeze
17
21
 
18
22
  def initialize(data = {})
@@ -23,6 +27,27 @@ module Twin
23
27
  @apex_width = merged["apex_width"]
24
28
  @apex_code_highlight = merged["apex_code_highlight"]
25
29
  @apex_code_highlight_theme = merged["apex_code_highlight_theme"]
30
+ @hosts = merged["hosts"] || {}
31
+ @host = ENV["TWIN_HOST"] || merged["host"].to_s
32
+ @target = merged["target"].to_s
33
+ end
34
+
35
+ # Build the flat substitution map {src.home => ..., dst.home => ..., dst.mount => ...}.
36
+ # Returns empty hash when no hosts are configured (substitution becomes a no-op).
37
+ def var_map
38
+ return {} if hosts.empty?
39
+ raise "host not set in config" if host.empty?
40
+ raise "target not set in config" if target.empty?
41
+
42
+ src_host = hosts[host]
43
+ dst_host = hosts[target]
44
+ raise "unknown host #{host.inspect} (not in hosts)" unless src_host
45
+ raise "unknown target #{target.inspect} (not in hosts)" unless dst_host
46
+
47
+ map = {}
48
+ src_host.each { |k, v| map["src.#{k}"] = v.to_s }
49
+ dst_host.each { |k, v| map["dst.#{k}"] = v.to_s }
50
+ map
26
51
  end
27
52
 
28
53
  def self.load
@@ -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
data/lib/twin/picker.rb CHANGED
@@ -17,6 +17,7 @@ module Twin
17
17
  missing_target: "!",
18
18
  missing_source: "!",
19
19
  both_missing: "✗",
20
+ unreachable: "?",
20
21
  disabled: "·",
21
22
  }.freeze
22
23
 
@@ -27,6 +28,7 @@ module Twin
27
28
  missing_target: "\e[31m", # red
28
29
  missing_source: "\e[31m", # red
29
30
  both_missing: "\e[31m", # red
31
+ unreachable: "\e[31m", # red
30
32
  disabled: "\e[2m", # dim
31
33
  }.freeze
32
34
 
@@ -0,0 +1,72 @@
1
+ require "open3"
2
+
3
+ module Twin
4
+ # Remote (ssh) targets, written exactly as rsync understands them:
5
+ # "user@host:/path" or "host:/path". A target counts as remote when a colon
6
+ # appears before the first slash. Sources stay local — twin pushes.
7
+ module Remote
8
+ module_function
9
+
10
+ SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"].freeze
11
+
12
+ def remote?(target)
13
+ %r{\A[^/]+:}.match?(target.to_s)
14
+ end
15
+
16
+ # "user@host:/path" → ["user@host", "/path"]
17
+ def split(target)
18
+ host, path = target.to_s.split(":", 2)
19
+ [host, path.to_s]
20
+ end
21
+
22
+ # Non-interactive reachability probe (BatchMode: never asks for a password).
23
+ def reachable?(host)
24
+ system("ssh", *SSH_OPTS, host, "true", out: File::NULL, err: File::NULL)
25
+ end
26
+
27
+ # Stat many paths in one ssh round-trip. Paths go over stdin (one per
28
+ # line), the remote loop answers "path<TAB>epoch" or "path<TAB>-" for
29
+ # missing ones. Tries BSD stat first, then GNU — covers macOS and Linux.
30
+ # Returns {path => Time or nil-if-missing}, or nil when ssh itself failed.
31
+ STAT_SCRIPT = <<~SH.freeze
32
+ while IFS= read -r p; do
33
+ if [ -e "$p" ]; then
34
+ printf '%s\t%s\n' "$p" "$(stat -f %m -- "$p" 2>/dev/null || stat -c %Y -- "$p")"
35
+ else
36
+ printf '%s\t-\n' "$p"
37
+ fi
38
+ done
39
+ SH
40
+
41
+ def stat_paths(host, paths)
42
+ return {} if paths.empty?
43
+ out, _err, status = Open3.capture3(
44
+ "ssh", *SSH_OPTS, host, STAT_SCRIPT,
45
+ stdin_data: paths.join("\n") + "\n"
46
+ )
47
+ return nil unless status.success?
48
+
49
+ result = {}
50
+ out.each_line do |line|
51
+ path, mtime = line.chomp.split("\t", 2)
52
+ next unless path && mtime
53
+ result[path] = mtime == "-" ? nil : Time.at(mtime.to_i)
54
+ end
55
+ result
56
+ rescue Errno::ENOENT
57
+ nil # ssh not installed
58
+ end
59
+
60
+ # Create a directory on the remote side (mkdir -p equivalent).
61
+ def mkdir_p(host, dir)
62
+ _out, _err, status = Open3.capture3("ssh", *SSH_OPTS, host, "mkdir", "-p", shellesc(dir))
63
+ status.success?
64
+ end
65
+
66
+ # Escape one argument for the remote shell (ssh joins args with spaces and
67
+ # hands the string to a shell — local exec-style arrays don't protect it).
68
+ def shellesc(s)
69
+ "'" + s.gsub("'", "'\\\\''") + "'"
70
+ end
71
+ end
72
+ end
data/lib/twin/scanner.rb CHANGED
@@ -1,22 +1,30 @@
1
1
  require "json"
2
2
  require "open3"
3
3
 
4
+ require_relative "remote"
5
+
4
6
  module Twin
5
7
  # One YAML block from a sync-file, enriched with live filesystem state.
6
8
  Job = Struct.new(
7
9
  :program, :path, :description, :active, :excludes, :label,
8
- :source, :target, :cmd, :sync_file,
10
+ :source, :target, :cmd, :delete, :render, :render_outdated, :target_path_field, :sync_file,
9
11
  :source_exists, :target_exists, :source_mtime, :target_mtime, :conflict,
12
+ :target_unreachable,
10
13
  keyword_init: true,
11
14
  ) do
12
15
  def source_path = File.join(source, path)
13
- def target_path = File.join(target, path)
16
+ def target_path = File.join(target, target_path_field || path)
17
+ def remote? = Twin::Remote.remote?(target)
14
18
 
15
19
  def status
16
20
  return :disabled if active != 1
21
+ return :unreachable if target_unreachable
17
22
  return :both_missing if !source_exists && !target_exists
18
23
  return :missing_source unless source_exists
19
24
  return :missing_target unless target_exists
25
+ # Render jobs compare by content, not mtime — a rendered target's mtime
26
+ # bears no relation to the template's.
27
+ return render_outdated ? :source_newer : :in_sync if render
20
28
  return :target_newer if conflict
21
29
  return :in_sync if source_mtime.nil? || target_mtime.nil?
22
30
  delta = source_mtime - target_mtime
@@ -38,7 +46,7 @@ module Twin
38
46
  # Aggregate status across jobs — worst first.
39
47
  def status
40
48
  states = jobs.map(&:status)
41
- %i[both_missing missing_source missing_target target_newer source_newer disabled in_sync]
49
+ %i[unreachable both_missing missing_source missing_target target_newer source_newer disabled in_sync]
42
50
  .find { |s| states.include?(s) } || :in_sync
43
51
  end
44
52
 
@@ -65,7 +73,36 @@ module Twin
65
73
  rescue JSON::ParserError => e
66
74
  raise "grubber returned invalid JSON: #{e.message}"
67
75
  end
68
- records.filter_map { |r| build_job(r) }
76
+ vars = cfg.var_map
77
+ jobs = records.filter_map do |r|
78
+ context = "#{r["Program"]} in #{File.basename(r["_note_file"].to_s)}"
79
+ build_job(Twin::Template.substitute_record(r, vars, context: context), vars)
80
+ end
81
+ fill_remote_stats(jobs)
82
+ jobs
83
+ end
84
+
85
+ # Remote targets can't be stat'ed locally — batch them into one ssh
86
+ # round-trip per host. A failed ssh marks the jobs unreachable instead
87
+ # of aborting the scan (local jobs stay usable).
88
+ def fill_remote_stats(jobs)
89
+ jobs.select { |j| j.remote? && j.active == 1 }
90
+ .group_by { |j| Twin::Remote.split(j.target).first }
91
+ .each do |host, host_jobs|
92
+ stats = Twin::Remote.stat_paths(host, host_jobs.map { |j| Twin::Remote.split(j.target_path).last })
93
+ host_jobs.each do |j|
94
+ rpath = Twin::Remote.split(j.target_path).last
95
+ if stats.nil?
96
+ j.target_unreachable = true
97
+ next
98
+ end
99
+ mtime = stats[rpath]
100
+ j.target_exists = !mtime.nil?
101
+ j.target_mtime = mtime
102
+ j.conflict = j.source_exists && mtime && j.source_mtime &&
103
+ mtime - j.source_mtime >= 60
104
+ end
105
+ end
69
106
  end
70
107
 
71
108
  def load_programs(cfg, file: nil, label: nil, show_all: false)
@@ -98,38 +135,57 @@ module Twin
98
135
  .map { |(name, _file), js| Program.new(name: name, jobs: js) }
99
136
  end
100
137
 
101
- def build_job(r)
102
- path = r["Path"].to_s
103
- source = r["Source"].to_s
104
- target = r["Target"].to_s
138
+ def build_job(r, vars = {})
139
+ path = r["Path"].to_s
140
+ source = r["Source"].to_s
141
+ target = r["Target"].to_s
142
+ target_path_field = r["Target-Path"].then { |v| v.to_s.empty? ? nil : v.to_s }
105
143
  return nil if path.empty? || source.empty? || target.empty?
106
144
 
145
+ render = r["Render"] == true
107
146
  excludes = (r["Exclude"] || "").split(",").map(&:strip).reject(&:empty?)
147
+ remote = Twin::Remote.remote?(target)
148
+
149
+ if render && remote
150
+ raise "#{r["Program"]}: Render is not supported for remote targets (#{target})"
151
+ end
108
152
 
109
153
  src_full = File.join(source, path)
110
- tgt_full = File.join(target, path)
154
+ tgt_full = File.join(target, target_path_field || path)
111
155
  src_exists, src_mtime = stat(src_full)
112
- tgt_exists, tgt_mtime = stat(tgt_full)
156
+ # Remote targets are stat'ed in one batched ssh call after all jobs are
157
+ # built (fill_remote_stats) — until then they read as missing.
158
+ tgt_exists, tgt_mtime = remote ? [false, nil] : stat(tgt_full)
159
+
160
+ # Render jobs: status is content-based (mtime is meaningless for a rendered
161
+ # target). conflict stays false so the mtime conflict-warning skips them.
162
+ render_outdated = render ? render_outdated?(src_full, tgt_full, vars, path) : nil
163
+
113
164
  # Same 60s tolerance as Job#status, so mtime jitter never flags a conflict.
114
- conflict = src_exists && tgt_exists && tgt_mtime && src_mtime &&
165
+ conflict = !render && src_exists && tgt_exists && tgt_mtime && src_mtime &&
115
166
  tgt_mtime - src_mtime >= 60
116
167
 
117
168
  Job.new(
118
- program: r["Program"].to_s,
119
- path: path,
120
- description: r["Description"].to_s,
121
- active: (r["Active"] || 0).to_i,
122
- excludes: excludes,
123
- label: r["Label"].to_s,
124
- source: source,
125
- target: target,
126
- cmd: r["Cmd"].to_s,
127
- sync_file: r["_note_file"].to_s,
128
- source_exists: src_exists,
129
- target_exists: tgt_exists,
130
- source_mtime: src_mtime,
131
- target_mtime: tgt_mtime,
132
- conflict: !!conflict,
169
+ program: r["Program"].to_s,
170
+ path: path,
171
+ description: r["Description"].to_s,
172
+ active: (r["Active"] || 0).to_i,
173
+ excludes: excludes,
174
+ label: r["Label"].to_s,
175
+ source: source,
176
+ target: target,
177
+ cmd: r["Cmd"].to_s,
178
+ delete: r["Delete"] == true,
179
+ render: render,
180
+ render_outdated: render_outdated,
181
+ target_path_field: target_path_field,
182
+ sync_file: r["_note_file"].to_s,
183
+ source_exists: src_exists,
184
+ target_exists: tgt_exists,
185
+ source_mtime: src_mtime,
186
+ target_mtime: tgt_mtime,
187
+ conflict: !!conflict,
188
+ target_unreachable: false,
133
189
  )
134
190
  end
135
191
 
@@ -139,5 +195,17 @@ module Twin
139
195
  rescue Errno::ENOENT, Errno::EACCES
140
196
  [false, nil]
141
197
  end
198
+
199
+ # For a render job: is the target out of date with the rendered template?
200
+ # nil when source is missing/a directory (status falls through to those).
201
+ # True when target is absent or content differs, or the template can't be
202
+ # rendered (unresolved token) — i.e. needs attention.
203
+ def render_outdated?(src_full, tgt_full, vars, context)
204
+ return nil unless File.file?(src_full)
205
+ rendered = Twin::Template.render_file(src_full, vars, context: context)
206
+ !File.exist?(tgt_full) || File.binread(tgt_full) != rendered
207
+ rescue
208
+ true
209
+ end
142
210
  end
143
211
  end