Module: Twin::Conflict
- Defined in:
- lib/twin/conflict.rb
Overview
Finding out which files on the target would be silently skipped by rsync's --update, and whether that actually matters.
Job#conflict is no help here. It compares the mtime of the job's own path,
and for a directory job that is the directory's mtime — which says nothing
about the files inside it. Worse, it is wrong in exactly the case that
matters: editing a file in place leaves its directory's mtime untouched, and
rsync -a equalises directory mtimes on every run anyway. A hand-edit on
the target is therefore invisible to it.
So we ask rsync, which has the answer already and knows its own matching rules better than any reimplementation would:
1. Which files does --update hold back?
Dry-run twice, once with --update and once without. Everything the
second run would transfer but the first would not is exactly the set
--update protects.
2. Of those, which differ in content?
Only these are worth asking about. A file that is merely newer — same
bytes, later timestamp — is noise, 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.
The first dry-run is also the cheap exit: when a forced run would move nothing, the job is fully in sync and the second run is skipped. That is the common case, so a quiet sync costs one extra stat-walk per job and no more.
Defined Under Namespace
Classes: Entry
Constant Summary collapse
- ITEMIZE_LINE =
rsync --itemize-changes line → relative path. Change lines start with an update type and a file type (">f.st...... lib/foo.rb"); "*deleting" and the surrounding prose do not match.
/\A[<>ch][fdLDS]\S*\s+(.+?)\s*\z/
Class Method Summary collapse
-
.detect(cfg, job) ⇒ Object
Real conflicts for one job, in the order rsync reports them.
-
.diff(entry) ⇒ Object
Unified diff for one entry, or a short note when it can't be produced.
- .digest(path) ⇒ Object
-
.entry_for(job, rel) ⇒ Object
Build an Entry unless source and target hold the same bytes.
-
.held_back_paths(cfg, job) ⇒ Object
Relative paths that --update would skip: (would transfer forced) minus (would transfer normally).
- .itemized_paths(args) ⇒ Object
- .mtime(path) ⇒ Object
-
.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.
- .same_content?(a, b) ⇒ Boolean
-
.text?(path) ⇒ Boolean
Cheap heuristic: a NUL byte in the first 8 KiB means binary.
Class Method Details
.detect(cfg, job) ⇒ Object
Real conflicts for one job, in the order rsync reports them. Empty when --update holds nothing back, or holds back only identical files.
55 56 57 58 59 60 61 |
# File 'lib/twin/conflict.rb', line 55 def detect(cfg, job) # Render jobs compare by content already and never use --update. return [] if job.render return [] unless job.source_exists && job.target_exists held_back_paths(cfg, job).filter_map { |rel| entry_for(job, rel) } end |
.diff(entry) ⇒ Object
Unified diff for one entry, or a short note when it can't be produced.
124 125 126 127 128 129 130 131 132 133 134 |
# File 'lib/twin/conflict.rb', line 124 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
115 |
# File 'lib/twin/conflict.rb', line 115 def digest(path) = Digest::SHA256.file(path).hexdigest |
.entry_for(job, rel) ⇒ Object
Build an Entry unless source and target hold the same bytes.
86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/twin/conflict.rb', line 86 def entry_for(job, rel) src = resolve(job.source_path, rel) tgt = resolve(job.target_path, rel) # Remote targets can't be read here; report them rather than assume. unless job.remote? return nil if same_content?(src, tgt) end Entry.new( job: job, rel: rel, source_path: src, target_path: tgt, source_mtime: mtime(src), target_mtime: job.remote? ? nil : mtime(tgt), ) end |
.held_back_paths(cfg, job) ⇒ Object
Relative paths that --update would skip: (would transfer forced) minus (would transfer normally).
65 66 67 68 69 70 71 |
# File 'lib/twin/conflict.rb', line 65 def held_back_paths(cfg, job) forced = itemized_paths(Twin::Sync.rsync_args(cfg, job, dry_run: true, force: true)) return [] if forced.empty? # nothing to move at all — no need to ask rsync twice normal = itemized_paths(Twin::Sync.rsync_args(cfg, job, dry_run: true, force: false)) forced - normal end |
.itemized_paths(args) ⇒ Object
73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/twin/conflict.rb', line 73 def itemized_paths(args) output, status = Twin::Sync.run(args) return [] unless status.success? output.lines.filter_map do |line| m = ITEMIZE_LINE.match(line) next unless m rel = m[1] next if rel == "./" || rel.end_with?("/") # directories carry no content rel end end |
.mtime(path) ⇒ Object
117 118 119 120 121 |
# File 'lib/twin/conflict.rb', line 117 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.
103 104 105 |
# File 'lib/twin/conflict.rb', line 103 def resolve(base, rel) File.directory?(base) ? File.join(base, rel) : base end |
.same_content?(a, b) ⇒ Boolean
107 108 109 110 111 112 113 |
# File 'lib/twin/conflict.rb', line 107 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.
137 138 139 140 141 |
# File 'lib/twin/conflict.rb', line 137 def text?(path) File.open(path, "rb") { |f| !f.read(8192).to_s.include?("\0") } rescue Errno::ENOENT, Errno::EACCES false end |