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/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
|
|
data/lib/twin/remote.rb
ADDED
|
@@ -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"]
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
102
|
+
FileUtils.mkdir_p(File.dirname(tgt))
|
|
104
103
|
end
|
|
105
104
|
|
|
106
|
-
output, status = run(
|
|
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
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.
|
|
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
|
|
82
|
-
files
|
|
85
|
+
summary: Sync configuration between machines, from self-documenting Markdown files
|
|
83
86
|
test_files: []
|
|
84
87
|
...
|