Module: Twin::Sync

Defined in:
lib/twin/sync.rb

Constant Summary collapse

ITEMIZE_CHANGE =

A line from ‘rsync –itemize-changes` describing a real change: an itemize code whose first column is the update type (< > c h *) and second the file type (f d L D S), e.g. “>f+++++++++”, “cd+++++++++”, “*deleting”. No-op runs emit no such line; headers (“sending …”, “created directory …”) and the summary don’t match. Deterministic — no scraping of prose.

/\A[<>ch*][fdLDS]/

Class Method Summary collapse

Class Method Details

.mounted?(path) ⇒ Boolean

True if the path lives on a mounted volume other than the root filesystem. Walks up parents until it finds a mount point (different device than parent) or hits “/” (path is on the root volume, not externally mounted).

Returns:

  • (Boolean)


22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/twin/sync.rb', line 22

def mounted?(path)
  return false unless File.exist?(path)
  current = File.expand_path(path)
  until current == "/"
    parent = File.expand_path("..", current)
    return true if File.stat(current).dev != File.stat(parent).dev
    current = parent
  end
  false
rescue Errno::ENOENT
  false
end

.render_job(cfg, job, dry_run: false) ⇒ Object

Render a template Job: read source, substitute {vars}, write if changed. Returns [success, output, changed].



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/twin/sync.rb', line 37

def render_job(cfg, job, dry_run: false)
  src = job.source_path
  tgt = job.target_path

  return [false, "source not found: #{src}", false] unless File.exist?(src)
  return [false, "render: source must be a file, not a directory: #{src}", false] if File.directory?(src)

  begin
    rendered = Twin::Template.render_file(src, cfg.var_map, context: job.path)
  rescue => e
    return [false, e.message, false]
  end

  if dry_run
    return [true, "(dry-run) would render #{File.basename(src)}#{tgt}", false]
  end

  FileUtils.mkdir_p(File.dirname(tgt))

  current = File.exist?(tgt) ? File.binread(tgt) : nil
  changed = (current != rendered)

  if changed
    File.binwrite(tgt, rendered)
    output = "rendered #{File.basename(src)}#{tgt}"
  else
    output = "#{File.basename(src)}: content unchanged"
  end

  if !job.cmd.empty?
    if changed
      cmd_out, cmd_status = run(["sh", "-c", job.cmd])
      output += "\ncmd: #{job.cmd}\n#{cmd_out}"
      unless cmd_status.success?
        output += "cmd failed (exit #{cmd_status.exitstatus})"
        return [false, output, changed]
      end
    else
      output += "\ncmd skipped (content unchanged)"
    end
  end

  [true, output, changed]
end

.run(args) ⇒ Object



136
137
138
139
140
141
142
# File 'lib/twin/sync.rb', line 136

def run(args)
  require "open3"
  stdout, stderr, status = Open3.capture3(*args)
  [stdout + stderr, status]
rescue Errno::ENOENT
  raise "command not found: #{args.first}"
end

.run_job(cfg, job, dry_run: false) ⇒ Object

Sync one Job. Returns [success, combined_output, transferred]. transferred is true when rsync actually moved bytes (false on no-op or dry_run).



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/twin/sync.rb', line 84

def run_job(cfg, job, dry_run: false)
  return render_job(cfg, job, dry_run: dry_run) if job.render

  src = job.source_path
  tgt = job.target_path

  return [false, "source not found: #{src}", false] unless File.exist?(src)

  FileUtils.mkdir_p(File.dirname(tgt))

  args = ["rsync", "-av", "--itemize-changes", "--update"]
  args << "--delete" if job.delete
  args << "--dry-run" if dry_run
  cfg.global_excludes.each { |ex| args << "--exclude=#{ex}" }
  job.excludes.each       { |ex| args << "--exclude=#{ex}" }

  if File.directory?(src)
    args << "#{src}/" << "#{tgt}/"
  else
    args << src << tgt
  end

  output, status = run(args)
  return [false, output, false] unless status.success?

  xfr = !dry_run && transferred?(output)

  if job.conflict && !xfr && !dry_run
    output += "\nskipped: target is newer, source not synced"
  end

  if !job.cmd.empty? && !dry_run
    if xfr
      cmd_out, cmd_status = run(["sh", "-c", job.cmd])
      output += "\ncmd: #{job.cmd}\n#{cmd_out}"
      unless cmd_status.success?
        output += "cmd failed (exit #{cmd_status.exitstatus})"
        return [false, output, xfr]
      end
    else
      output += "\ncmd skipped (nothing transferred)"
    end
  end

  [true, output, xfr]
end

.run_program(cfg, program, dry_run: false) ⇒ Object

Sync all jobs in a Program. Returns array of [job, success, output].



132
133
134
# File 'lib/twin/sync.rb', line 132

def run_program(cfg, program, dry_run: false)
  program.active_jobs.map { |job| [job, *run_job(cfg, job, dry_run: dry_run)] }
end

.transferred?(output) ⇒ Boolean

True when rsync reported at least one changed item.

Returns:

  • (Boolean)


15
16
17
# File 'lib/twin/sync.rb', line 15

def transferred?(output)
  output.lines.any? { |l| ITEMIZE_CHANGE.match?(l) }
end