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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +135 -15
- data/README.md +171 -7
- data/bin/twin +5 -0
- data/lib/twin/add.rb +177 -0
- data/lib/twin/cli.rb +186 -19
- data/lib/twin/config.rb +26 -1
- 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 +94 -26
- data/lib/twin/sync.rb +126 -13
- data/lib/twin/template.rb +36 -0
- data/lib/twin/version.rb +1 -1
- data/lib/twin.rb +4 -0
- metadata +5 -1
data/lib/twin/sync.rb
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
|
|
3
|
+
require_relative "remote"
|
|
4
|
+
|
|
3
5
|
module Twin
|
|
4
6
|
module Sync
|
|
5
7
|
module_function
|
|
6
8
|
|
|
9
|
+
# A line from `rsync --itemize-changes` describing a real change: an
|
|
10
|
+
# itemize code whose first column is the update type (< > c h *) and second
|
|
11
|
+
# the file type (f d L D S), e.g. ">f+++++++++", "cd+++++++++", "*deleting".
|
|
12
|
+
# No-op runs emit no such line; headers ("sending …", "created directory …")
|
|
13
|
+
# and the summary don't match. Deterministic — no scraping of prose.
|
|
14
|
+
ITEMIZE_CHANGE = /\A[<>ch*][fdLDS]/
|
|
15
|
+
|
|
16
|
+
# True when rsync reported at least one changed item.
|
|
17
|
+
def transferred?(output)
|
|
18
|
+
output.lines.any? { |l| ITEMIZE_CHANGE.match?(l) }
|
|
19
|
+
end
|
|
20
|
+
|
|
7
21
|
# True if the path lives on a mounted volume other than the root filesystem.
|
|
8
22
|
# Walks up parents until it finds a mount point (different device than parent)
|
|
9
23
|
# or hits "/" (path is on the root volume, not externally mounted).
|
|
@@ -20,16 +34,109 @@ module Twin
|
|
|
20
34
|
false
|
|
21
35
|
end
|
|
22
36
|
|
|
23
|
-
#
|
|
24
|
-
|
|
37
|
+
# Render a template Job: read source, substitute {{vars}}, write if changed.
|
|
38
|
+
# Returns [success, output, changed].
|
|
39
|
+
def render_job(cfg, job, dry_run: false)
|
|
40
|
+
return [false, "render: remote targets are not supported", false] if job.remote?
|
|
41
|
+
|
|
25
42
|
src = job.source_path
|
|
26
43
|
tgt = job.target_path
|
|
27
44
|
|
|
28
|
-
return [false, "source not found: #{src}"] unless File.exist?(src)
|
|
45
|
+
return [false, "source not found: #{src}", false] unless File.exist?(src)
|
|
46
|
+
return [false, "render: source must be a file, not a directory: #{src}", false] if File.directory?(src)
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
rendered = Twin::Template.render_file(src, cfg.var_map, context: job.path)
|
|
50
|
+
rescue => e
|
|
51
|
+
return [false, e.message, false]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if dry_run
|
|
55
|
+
return [true, "(dry-run) would render #{File.basename(src)} → #{tgt}", false]
|
|
56
|
+
end
|
|
29
57
|
|
|
30
58
|
FileUtils.mkdir_p(File.dirname(tgt))
|
|
31
59
|
|
|
32
|
-
|
|
60
|
+
current = File.exist?(tgt) ? File.binread(tgt) : nil
|
|
61
|
+
changed = (current != rendered)
|
|
62
|
+
|
|
63
|
+
if changed
|
|
64
|
+
File.binwrite(tgt, rendered)
|
|
65
|
+
output = "rendered #{File.basename(src)} → #{tgt}"
|
|
66
|
+
else
|
|
67
|
+
output = "#{File.basename(src)}: content unchanged"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if !job.cmd.empty?
|
|
71
|
+
if changed
|
|
72
|
+
cmd_out, cmd_status = run(["sh", "-c", job.cmd])
|
|
73
|
+
output += "\ncmd: #{job.cmd}\n#{cmd_out}"
|
|
74
|
+
unless cmd_status.success?
|
|
75
|
+
output += "cmd failed (exit #{cmd_status.exitstatus})"
|
|
76
|
+
return [false, output, changed]
|
|
77
|
+
end
|
|
78
|
+
else
|
|
79
|
+
output += "\ncmd skipped (content unchanged)"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
[true, output, changed]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Sync one Job. Returns [success, combined_output, transferred].
|
|
87
|
+
# transferred is true when rsync actually moved bytes (false on no-op or dry_run).
|
|
88
|
+
def run_job(cfg, job, dry_run: false)
|
|
89
|
+
return render_job(cfg, job, dry_run: dry_run) if job.render
|
|
90
|
+
|
|
91
|
+
src = job.source_path
|
|
92
|
+
tgt = job.target_path
|
|
93
|
+
|
|
94
|
+
return [false, "source not found: #{src}", false] unless File.exist?(src)
|
|
95
|
+
|
|
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
|
|
101
|
+
else
|
|
102
|
+
FileUtils.mkdir_p(File.dirname(tgt))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
output, status = run(rsync_args(cfg, job, dry_run: dry_run))
|
|
106
|
+
return [false, output, false] unless status.success?
|
|
107
|
+
|
|
108
|
+
xfr = !dry_run && transferred?(output)
|
|
109
|
+
|
|
110
|
+
if job.conflict && !xfr && !dry_run
|
|
111
|
+
output += "\nskipped: target is newer, source not synced"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
if !job.cmd.empty? && !dry_run
|
|
115
|
+
if xfr
|
|
116
|
+
cmd_out, cmd_status = run(["sh", "-c", job.cmd])
|
|
117
|
+
output += "\ncmd: #{job.cmd}\n#{cmd_out}"
|
|
118
|
+
unless cmd_status.success?
|
|
119
|
+
output += "cmd failed (exit #{cmd_status.exitstatus})"
|
|
120
|
+
return [false, output, xfr]
|
|
121
|
+
end
|
|
122
|
+
else
|
|
123
|
+
output += "\ncmd skipped (nothing transferred)"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
[true, output, xfr]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Full rsync argument vector for a job.
|
|
131
|
+
def rsync_args(cfg, job, dry_run: false)
|
|
132
|
+
src = job.source_path
|
|
133
|
+
tgt = job.target_path
|
|
134
|
+
|
|
135
|
+
args = ["rsync", "-av", "--itemize-changes", "--update"]
|
|
136
|
+
if job.delete
|
|
137
|
+
args << "--delete"
|
|
138
|
+
args.concat(backup_args(job))
|
|
139
|
+
end
|
|
33
140
|
args << "--dry-run" if dry_run
|
|
34
141
|
cfg.global_excludes.each { |ex| args << "--exclude=#{ex}" }
|
|
35
142
|
job.excludes.each { |ex| args << "--exclude=#{ex}" }
|
|
@@ -39,17 +146,23 @@ module Twin
|
|
|
39
146
|
else
|
|
40
147
|
args << src << tgt
|
|
41
148
|
end
|
|
149
|
+
args
|
|
150
|
+
end
|
|
42
151
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
152
|
+
# Safety net for --delete: deleted and overwritten files land in a
|
|
153
|
+
# per-run backup dir on the target side (<target>/.twin-backup/<stamp>).
|
|
154
|
+
# rsync only creates the dir when it actually backs something up.
|
|
155
|
+
# The exclude keeps a backup dir inside the transfer root (Path: ".")
|
|
156
|
+
# from being deleted by the very sync it protects against.
|
|
157
|
+
def backup_args(job)
|
|
158
|
+
root = job.remote? ? Twin::Remote.split(job.target).last : job.target
|
|
159
|
+
dir = File.join(root, ".twin-backup", run_stamp)
|
|
160
|
+
["--backup", "--backup-dir=#{dir}", "--exclude=.twin-backup/"]
|
|
161
|
+
end
|
|
51
162
|
|
|
52
|
-
|
|
163
|
+
# One timestamp per twin process, so a multi-job run shares a backup dir.
|
|
164
|
+
def run_stamp
|
|
165
|
+
@run_stamp ||= Time.now.strftime("%Y-%m-%d_%H%M%S")
|
|
53
166
|
end
|
|
54
167
|
|
|
55
168
|
# Sync all jobs in a Program. Returns array of [job, success, output].
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Twin
|
|
2
|
+
module Template
|
|
3
|
+
PATTERN = /\{\{([^}]+)\}\}/
|
|
4
|
+
|
|
5
|
+
# Fields in a grubber record that may contain {{tokens}}.
|
|
6
|
+
FIELDS = %w[Source Target Path Target-Path Exclude Cmd].freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Replace {{token}} in value using vars. Raises on unknown token.
|
|
11
|
+
def substitute(value, vars, context:)
|
|
12
|
+
return value unless value.is_a?(String)
|
|
13
|
+
value.gsub(PATTERN) do
|
|
14
|
+
token = $1
|
|
15
|
+
vars.fetch(token) { raise "unknown template token {{#{token}}} in #{context}" }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Read a template file and substitute {{tokens}} in its content.
|
|
20
|
+
# Binary read preserves bytes exactly; raises on unknown token.
|
|
21
|
+
def render_file(path, vars, context:)
|
|
22
|
+
substitute(File.binread(path), vars, context: context)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Return a copy of grubber record r with FIELDS substituted.
|
|
26
|
+
def substitute_record(r, vars, context:)
|
|
27
|
+
return r if vars.empty?
|
|
28
|
+
result = r.dup
|
|
29
|
+
FIELDS.each do |field|
|
|
30
|
+
next unless result.key?(field) && result[field].is_a?(String)
|
|
31
|
+
result[field] = substitute(result[field], vars, context: context)
|
|
32
|
+
end
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/twin/version.rb
CHANGED
data/lib/twin.rb
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
require_relative "twin/version"
|
|
2
|
+
require_relative "twin/remote"
|
|
3
|
+
require_relative "twin/template"
|
|
2
4
|
require_relative "twin/config"
|
|
3
5
|
require_relative "twin/scanner"
|
|
4
6
|
require_relative "twin/sync"
|
|
7
|
+
require_relative "twin/journal"
|
|
8
|
+
require_relative "twin/add"
|
|
5
9
|
require_relative "twin/preview"
|
|
6
10
|
require_relative "twin/picker"
|
|
7
11
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ralf Hülsmann
|
|
@@ -37,12 +37,16 @@ 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/journal.rb
|
|
42
44
|
- lib/twin/picker.rb
|
|
43
45
|
- lib/twin/preview.rb
|
|
46
|
+
- lib/twin/remote.rb
|
|
44
47
|
- lib/twin/scanner.rb
|
|
45
48
|
- lib/twin/sync.rb
|
|
49
|
+
- lib/twin/template.rb
|
|
46
50
|
- lib/twin/version.rb
|
|
47
51
|
homepage: https://github.com/rhsev/mark-twin
|
|
48
52
|
licenses:
|