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/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,19 +1,28 @@
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
- :program, :path, :description, :active, :excludes, :label,
9
+ :program, :path, :description, :active, :excludes, :owned, :label,
8
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
16
  def target_path = File.join(target, target_path_field || path)
17
+ def remote? = Twin::Remote.remote?(target)
18
+
19
+ # Everything rsync must not touch: Exclude (not part of the sync at all)
20
+ # plus Own (part of the scope, but the target owns it).
21
+ def all_excludes = excludes + (owned || [])
14
22
 
15
23
  def status
16
24
  return :disabled if active != 1
25
+ return :unreachable if target_unreachable
17
26
  return :both_missing if !source_exists && !target_exists
18
27
  return :missing_source unless source_exists
19
28
  return :missing_target unless target_exists
@@ -41,7 +50,7 @@ module Twin
41
50
  # Aggregate status across jobs — worst first.
42
51
  def status
43
52
  states = jobs.map(&:status)
44
- %i[both_missing missing_source missing_target target_newer source_newer disabled in_sync]
53
+ %i[unreachable both_missing missing_source missing_target target_newer source_newer disabled in_sync]
45
54
  .find { |s| states.include?(s) } || :in_sync
46
55
  end
47
56
 
@@ -69,10 +78,35 @@ module Twin
69
78
  raise "grubber returned invalid JSON: #{e.message}"
70
79
  end
71
80
  vars = cfg.var_map
72
- records.filter_map do |r|
81
+ jobs = records.filter_map do |r|
73
82
  context = "#{r["Program"]} in #{File.basename(r["_note_file"].to_s)}"
74
83
  build_job(Twin::Template.substitute_record(r, vars, context: context), vars)
75
84
  end
85
+ fill_remote_stats(jobs)
86
+ jobs
87
+ end
88
+
89
+ # Remote targets can't be stat'ed locally — batch them into one ssh
90
+ # round-trip per host. A failed ssh marks the jobs unreachable instead
91
+ # of aborting the scan (local jobs stay usable).
92
+ def fill_remote_stats(jobs)
93
+ jobs.select { |j| j.remote? && j.active == 1 }
94
+ .group_by { |j| Twin::Remote.split(j.target).first }
95
+ .each do |host, host_jobs|
96
+ stats = Twin::Remote.stat_paths(host, host_jobs.map { |j| Twin::Remote.split(j.target_path).last })
97
+ host_jobs.each do |j|
98
+ rpath = Twin::Remote.split(j.target_path).last
99
+ if stats.nil?
100
+ j.target_unreachable = true
101
+ next
102
+ end
103
+ mtime = stats[rpath]
104
+ j.target_exists = !mtime.nil?
105
+ j.target_mtime = mtime
106
+ j.conflict = j.source_exists && mtime && j.source_mtime &&
107
+ mtime - j.source_mtime >= 60
108
+ end
109
+ end
76
110
  end
77
111
 
78
112
  def load_programs(cfg, file: nil, label: nil, show_all: false)
@@ -113,12 +147,24 @@ module Twin
113
147
  return nil if path.empty? || source.empty? || target.empty?
114
148
 
115
149
  render = r["Render"] == true
116
- excludes = (r["Exclude"] || "").split(",").map(&:strip).reject(&:empty?)
150
+ excludes = split_list(r["Exclude"])
151
+ # Own: paths inside the sync scope that the TARGET owns — machine-specific
152
+ # config the source must never clobber. Same rsync effect as Exclude, but
153
+ # kept apart so `status` can name the intent instead of hiding it among
154
+ # build artefacts and .DS_Store.
155
+ owned = split_list(r["Own"])
156
+ remote = Twin::Remote.remote?(target)
157
+
158
+ if render && remote
159
+ raise "#{r["Program"]}: Render is not supported for remote targets (#{target})"
160
+ end
117
161
 
118
162
  src_full = File.join(source, path)
119
163
  tgt_full = File.join(target, target_path_field || path)
120
164
  src_exists, src_mtime = stat(src_full)
121
- tgt_exists, tgt_mtime = stat(tgt_full)
165
+ # Remote targets are stat'ed in one batched ssh call after all jobs are
166
+ # built (fill_remote_stats) — until then they read as missing.
167
+ tgt_exists, tgt_mtime = remote ? [false, nil] : stat(tgt_full)
122
168
 
123
169
  # Render jobs: status is content-based (mtime is meaningless for a rendered
124
170
  # target). conflict stays false so the mtime conflict-warning skips them.
@@ -134,6 +180,7 @@ module Twin
134
180
  description: r["Description"].to_s,
135
181
  active: (r["Active"] || 0).to_i,
136
182
  excludes: excludes,
183
+ owned: owned,
137
184
  label: r["Label"].to_s,
138
185
  source: source,
139
186
  target: target,
@@ -148,6 +195,7 @@ module Twin
148
195
  source_mtime: src_mtime,
149
196
  target_mtime: tgt_mtime,
150
197
  conflict: !!conflict,
198
+ target_unreachable: false,
151
199
  )
152
200
  end
153
201
 
@@ -158,6 +206,11 @@ module Twin
158
206
  [false, nil]
159
207
  end
160
208
 
209
+ # Comma-separated block field → array of trimmed, non-empty entries.
210
+ def split_list(value)
211
+ (value || "").split(",").map(&:strip).reject(&:empty?)
212
+ end
213
+
161
214
  # For a render job: is the target out of date with the rendered template?
162
215
  # nil when source is missing/a directory (status falls through to those).
163
216
  # True when target is absent or content differs, or the template can't be
data/lib/twin/sync.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  require "fileutils"
2
2
 
3
+ require_relative "remote"
4
+
3
5
  module Twin
4
6
  module Sync
5
7
  module_function
@@ -35,6 +37,8 @@ module Twin
35
37
  # Render a template Job: read source, substitute {{vars}}, write if changed.
36
38
  # Returns [success, output, changed].
37
39
  def render_job(cfg, job, dry_run: false)
40
+ return [false, "render: remote targets are not supported", false] if job.remote?
41
+
38
42
  src = job.source_path
39
43
  tgt = job.target_path
40
44
 
@@ -81,7 +85,7 @@ module Twin
81
85
 
82
86
  # Sync one Job. Returns [success, combined_output, transferred].
83
87
  # transferred is true when rsync actually moved bytes (false on no-op or dry_run).
84
- def run_job(cfg, job, dry_run: false)
88
+ def run_job(cfg, job, dry_run: false, force: false)
85
89
  return render_job(cfg, job, dry_run: dry_run) if job.render
86
90
 
87
91
  src = job.source_path
@@ -89,26 +93,21 @@ module Twin
89
93
 
90
94
  return [false, "source not found: #{src}", false] unless File.exist?(src)
91
95
 
92
- FileUtils.mkdir_p(File.dirname(tgt))
93
-
94
- args = ["rsync", "-av", "--itemize-changes", "--update"]
95
- args << "--delete" if job.delete
96
- args << "--dry-run" if dry_run
97
- cfg.global_excludes.each { |ex| args << "--exclude=#{ex}" }
98
- job.excludes.each { |ex| args << "--exclude=#{ex}" }
99
-
100
- if File.directory?(src)
101
- args << "#{src}/" << "#{tgt}/"
96
+ if job.remote?
97
+ host, rpath = Twin::Remote.split(tgt)
98
+ unless dry_run || Twin::Remote.mkdir_p(host, File.dirname(rpath))
99
+ return [false, "ssh: could not create #{File.dirname(rpath)} on #{host}", false]
100
+ end
102
101
  else
103
- args << src << tgt
102
+ FileUtils.mkdir_p(File.dirname(tgt))
104
103
  end
105
104
 
106
- output, status = run(args)
105
+ output, status = run(rsync_args(cfg, job, dry_run: dry_run, force: force))
107
106
  return [false, output, false] unless status.success?
108
107
 
109
108
  xfr = !dry_run && transferred?(output)
110
109
 
111
- if job.conflict && !xfr && !dry_run
110
+ if job.conflict && !xfr && !dry_run && !force
112
111
  output += "\nskipped: target is newer, source not synced"
113
112
  end
114
113
 
@@ -128,9 +127,52 @@ module Twin
128
127
  [true, output, xfr]
129
128
  end
130
129
 
130
+ # Full rsync argument vector for a job.
131
+ #
132
+ # force: drop --update, so a file that is newer on the target is overwritten
133
+ # anyway. Only ever set after the user agreed to it (see Twin::Conflict), or
134
+ # via `twin sync --force`.
135
+ def rsync_args(cfg, job, dry_run: false, force: false)
136
+ src = job.source_path
137
+ tgt = job.target_path
138
+
139
+ args = ["rsync", "-av", "--itemize-changes"]
140
+ args << "--update" unless force
141
+ if job.delete
142
+ args << "--delete"
143
+ args.concat(backup_args(job))
144
+ end
145
+ args << "--dry-run" if dry_run
146
+ cfg.global_excludes.each { |ex| args << "--exclude=#{ex}" }
147
+ job.all_excludes.each { |ex| args << "--exclude=#{ex}" }
148
+
149
+ if File.directory?(src)
150
+ args << "#{src}/" << "#{tgt}/"
151
+ else
152
+ args << src << tgt
153
+ end
154
+ args
155
+ end
156
+
157
+ # Safety net for --delete: deleted and overwritten files land in a
158
+ # per-run backup dir on the target side (<target>/.twin-backup/<stamp>).
159
+ # rsync only creates the dir when it actually backs something up.
160
+ # The exclude keeps a backup dir inside the transfer root (Path: ".")
161
+ # from being deleted by the very sync it protects against.
162
+ def backup_args(job)
163
+ root = job.remote? ? Twin::Remote.split(job.target).last : job.target
164
+ dir = File.join(root, ".twin-backup", run_stamp)
165
+ ["--backup", "--backup-dir=#{dir}", "--exclude=.twin-backup/"]
166
+ end
167
+
168
+ # One timestamp per twin process, so a multi-job run shares a backup dir.
169
+ def run_stamp
170
+ @run_stamp ||= Time.now.strftime("%Y-%m-%d_%H%M%S")
171
+ end
172
+
131
173
  # Sync all jobs in a Program. Returns array of [job, success, output].
132
- def run_program(cfg, program, dry_run: false)
133
- program.active_jobs.map { |job| [job, *run_job(cfg, job, dry_run: dry_run)] }
174
+ def run_program(cfg, program, dry_run: false, force: false)
175
+ program.active_jobs.map { |job| [job, *run_job(cfg, job, dry_run: dry_run, force: force)] }
134
176
  end
135
177
 
136
178
  def run(args)
data/lib/twin/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Twin
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/twin.rb CHANGED
@@ -1,8 +1,12 @@
1
1
  require_relative "twin/version"
2
+ require_relative "twin/remote"
2
3
  require_relative "twin/template"
3
4
  require_relative "twin/config"
4
5
  require_relative "twin/scanner"
5
6
  require_relative "twin/sync"
7
+ require_relative "twin/conflict"
8
+ require_relative "twin/journal"
9
+ require_relative "twin/add"
6
10
  require_relative "twin/preview"
7
11
  require_relative "twin/picker"
8
12
  require_relative "twin/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mark-twin
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ralf Hülsmann
@@ -37,10 +37,14 @@ files:
37
37
  - README.md
38
38
  - bin/twin
39
39
  - lib/twin.rb
40
+ - lib/twin/add.rb
40
41
  - lib/twin/cli.rb
41
42
  - lib/twin/config.rb
43
+ - lib/twin/conflict.rb
44
+ - lib/twin/journal.rb
42
45
  - lib/twin/picker.rb
43
46
  - lib/twin/preview.rb
47
+ - lib/twin/remote.rb
44
48
  - lib/twin/scanner.rb
45
49
  - lib/twin/sync.rb
46
50
  - lib/twin/template.rb
@@ -78,7 +82,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
78
82
  requirements: []
79
83
  rubygems_version: 4.0.4
80
84
  specification_version: 4
81
- summary: Sync configuration folders between two Macs from self-documenting Markdown
82
- files
85
+ summary: Sync configuration between machines, from self-documenting Markdown files
83
86
  test_files: []
84
87
  ...