Module: Twin::Conflict

Defined in:
lib/twin/conflict.rb

Overview

What would a sync actually change? mtimes cannot answer that — a directory's mtime records the last entry added or removed (after a sync: the sync itself), a file's mtime moves on a cat > copy that changed nothing, and rsync equalises directory mtimes on every run anyway. So we ask rsync, which has the answer already and knows its own matching rules better than any reimplementation would:

1. What would a *forced* run transfer?
 One dry-run without --update. Empty means fully in sync — the common
 case and the cheap exit: one stat-walk per job and no more.

2. Of that, what does --update hold back?
 A second dry-run with --update. Everything the forced run would move
 but the normal one would not is exactly the set --update protects —
 files the target owns more recently.

3. What differs only in timestamp?
 rsync's itemize flags say so (">f..t" — time, same size). Those files
 are checksummed; identical content is noise, not drift, and noise is
 what turns a prompt into a reflex. Sync both sides of a tree in
 either order and you get dozens of them.

Defined Under Namespace

Classes: Drift, Entry

Constant Summary collapse

ENTRY_LINE =

rsync --itemize-changes line → [flags, relative path]. Change lines start with an update type and a file type (">f.st...... lib/foo.rb") or "*deleting"; attribute-only lines (leading ".") and the surrounding prose do not match — they describe no change a sync would make.

/\A(\*deleting|[<>ch][fdLDS]\S*)\s+(.+?)\s*\z/

Class Method Summary collapse

Class Method Details

.assemble(forced, normal_rels, equal) ⇒ Object

Pure assembly of a Drift from parsed entries. A file the normal run would also transfer flows source→target as intended; one only the forced run would touch is being held back by --update — the target owns it more recently. Content decides whether that is a conflict or noise.



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/twin/conflict.rb', line 86

def assemble(forced, normal_rels, equal)
  d = Drift.new(pending: [], time_only: [], conflicts: [])
  forced.each do |e|
    rel = e[:rel]
    case e[:kind]
    when :deleted, :new then d.pending << rel
    when :content
      normal_rels.include?(rel) ? d.pending << rel : d.conflicts << yield(rel)
    when :time
      if equal[rel]
        d.time_only << rel
      elsif normal_rels.include?(rel)
        d.pending << rel
      else
        d.conflicts << yield(rel)
      end
    end
  end
  d
end

.classify(flags) ⇒ Object

Itemize flags → what kind of change this is. :deleted — target-only file, removed by Delete: true :new — does not exist on the target yet (files and directories) :content — size differs, so the bytes certainly do :time — timestamp only; content equality still to be determined :attrs — permissions/owner, no change a sync-file cares about



126
127
128
129
130
131
132
133
# File 'lib/twin/conflict.rb', line 126

def classify(flags)
  return :deleted if flags == "*deleting"
  body = flags[2..].to_s
  return :new     if body.include?("+")
  return :content if body.include?("s")
  return :time    if body.include?("t") || body.include?("T")
  :attrs
end

.conflict_entry(job, rel) ⇒ Object



153
154
155
156
157
158
159
160
# File 'lib/twin/conflict.rb', line 153

def conflict_entry(job, rel)
  src = resolve(job.source_path, rel)
  tgt = job.remote? ? job.target_path : resolve(job.target_path, rel)
  Entry.new(
    job: job, rel: rel, source_path: src, target_path: tgt,
    source_mtime: mtime(src), target_mtime: job.remote? ? nil : mtime(tgt),
  )
end

.detect(cfg, job) ⇒ Object

Target-side changes worth asking about, in the order rsync reports them. Empty when --update holds nothing back, or holds back only identical files.



78
79
80
# File 'lib/twin/conflict.rb', line 78

def detect(cfg, job)
  drift(cfg, job)&.conflicts || []
end

.diff(entry) ⇒ Object

Unified diff for one entry, or a short note when it can't be produced.



191
192
193
194
195
196
197
198
199
200
201
# File 'lib/twin/conflict.rb', line 191

def diff(entry)
  return "  (remote target — no diff available)" if entry.job.remote?
  return "  (binary or unreadable)" unless text?(entry.source_path) && text?(entry.target_path)

  out, _status = Twin::Sync.run([
    "diff", "-u",
    "--label", "target (#{entry.target_path})", entry.target_path,
    "--label", "source (#{entry.source_path})", entry.source_path,
  ])
  out.empty? ? "  (no textual difference)" : out
end

.digest(path) ⇒ Object



176
# File 'lib/twin/conflict.rb', line 176

def digest(path) = Digest::SHA256.file(path).hexdigest

.drift(cfg, job) ⇒ Object

Classify one job's drift by asking rsync. nil for render jobs (they compare content already and never use --update) and when a side is missing (status reports that on its own).



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/twin/conflict.rb', line 60

def drift(cfg, job)
  return nil if job.render
  return nil unless job.source_exists && job.target_exists

  forced = itemized(Twin::Sync.rsync_args(cfg, job, dry_run: true, force: true))
  return Drift.new(pending: [], time_only: [], conflicts: []) if forced.empty?

  normal_rels = itemized(Twin::Sync.rsync_args(cfg, job, dry_run: true, force: false))
                .map { |e| e[:rel] }.to_set
  equal = equality_map(job, forced.filter_map { |e| e[:rel] if e[:kind] == :time })

  assemble(forced, normal_rels, equal) do |rel|
    conflict_entry(job, rel)
  end
end

.equality_map(job, rels) ⇒ Object

Content equality for the :time candidates, => bool. Local pairs are compared directly; remote targets get one batched md5 round per job (STAT_SCRIPT-style), and an unanswered path counts as differing.



138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/twin/conflict.rb', line 138

def equality_map(job, rels)
  return {} if rels.empty?
  if job.remote?
    _host, rbase = Twin::Remote.split(job.target_path)
    remote_paths = rels.to_h { |r| [r, job.directory ? File.join(rbase, r) : rbase] }
    sums = Twin::Remote.md5_paths(Twin::Remote.split(job.target)[0], remote_paths.values) || {}
    rels.to_h do |r|
      local = local_md5(resolve(job.source_path, r))
      [r, !local.nil? && sums[remote_paths[r]] == local]
    end
  else
    rels.to_h { |r| [r, same_content?(resolve(job.source_path, r), resolve(job.target_path, r))] }
  end
end

.itemized(args) ⇒ Object

Run rsync and parse its itemize output into [kind:, ...].



108
109
110
111
112
113
114
115
116
117
118
# File 'lib/twin/conflict.rb', line 108

def itemized(args)
  output, status = Twin::Sync.run(args)
  return [] unless status.success?
  output.lines.filter_map do |line|
    m = ENTRY_LINE.match(line)
    next unless m
    kind = classify(m[1])
    next if kind == :attrs
    { rel: m[2], kind: kind }
  end
end

.local_md5(path) ⇒ Object



178
179
180
181
182
# File 'lib/twin/conflict.rb', line 178

def local_md5(path)
  Digest::MD5.file(path).hexdigest
rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR
  nil
end

.mtime(path) ⇒ Object



184
185
186
187
188
# File 'lib/twin/conflict.rb', line 184

def mtime(path)
  File.mtime(path)
rescue Errno::ENOENT, Errno::EACCES
  nil
end

.resolve(base, rel) ⇒ Object

A job path may be a single file — rsync then itemizes its basename, and the job path is already the full path.



164
165
166
# File 'lib/twin/conflict.rb', line 164

def resolve(base, rel)
  File.directory?(base) ? File.join(base, rel) : base
end

.same_content?(a, b) ⇒ Boolean

Returns:

  • (Boolean)


168
169
170
171
172
173
174
# File 'lib/twin/conflict.rb', line 168

def same_content?(a, b)
  return false unless File.file?(a) && File.file?(b)
  return false unless File.size(a) == File.size(b)
  digest(a) == digest(b)
rescue Errno::ENOENT, Errno::EACCES
  false
end

.text?(path) ⇒ Boolean

Cheap heuristic: a NUL byte in the first 8 KiB means binary.

Returns:

  • (Boolean)


204
205
206
207
208
# File 'lib/twin/conflict.rb', line 204

def text?(path)
  File.open(path, "rb") { |f| !f.read(8192).to_s.include?("\0") }
rescue Errno::ENOENT, Errno::EACCES
  false
end