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/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
- # Sync one Job. Returns [success, combined_output].
24
- def run_job(cfg, job, dry_run: false)
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
- args = ["rsync", "-av", "--update"]
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
- output, status = run(args)
44
- return [false, output] unless status.success?
45
-
46
- if !job.cmd.empty? && !dry_run
47
- cmd_out, cmd_status = run(["sh", "-c", job.cmd])
48
- output += "\ncmd: #{job.cmd}\n#{cmd_out}"
49
- return [false, output] unless cmd_status.success?
50
- end
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
- [true, output]
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
@@ -1,3 +1,3 @@
1
1
  module Twin
2
- VERSION = "0.1.3"
2
+ VERSION = "0.3.0"
3
3
  end
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.1.3
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: