commit 0382c9d35940ccc167bcefa668bbaf0e390c98bb
parent f4edadf88d34df998672084826dcd01499bc85d0
Author: mtmn <miro@haravara.org>
Date: Sun, 14 Jun 2026 14:29:01 +0200
dam: better diff, add dam.1 and makefile
Diffstat:
| M | .gitignore | | | 1 | + |
| A | dam/Makefile | | | 62 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | dam/dam.1 | | | 244 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | dam/src/main.rs | | | 78 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------- |
4 files changed, 368 insertions(+), 17 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -8,3 +8,4 @@ zig-out
_build
# rust
rust-project.json
+target
diff --git a/dam/Makefile b/dam/Makefile
@@ -0,0 +1,62 @@
+CARGO ?= cargo
+PREFIX ?= /usr/local
+BINDIR ?= $(PREFIX)/bin
+MANDIR ?= $(PREFIX)/share/man
+DESTDIR ?=
+
+BIN := dam
+MANPAGE := dam.1
+
+TARGET := $(shell git rev-parse --show-toplevel)/target/release/$(BIN)
+
+.DEFAULT_GOAL := help
+
+.PHONY: build
+build: ## Build a release binary
+ $(CARGO) build --release
+
+.PHONY: debug
+debug: ## Build a debug binary
+ $(CARGO) build
+
+.PHONY: run
+run: ## Run dam (pass args with ARGS=...)
+ $(CARGO) run -- $(ARGS)
+
+.PHONY: test
+test: ## Run the test suite
+ $(CARGO) test
+
+.PHONY: check
+check: ## Type-check without building artifacts
+ $(CARGO) check
+
+.PHONY: man
+man: ## Open a preview of dam.1
+ man ./$(MANPAGE)
+
+.PHONY: lint
+lint: ## Run linters (fmt, clippy, man)
+ $(CARGO) fmt
+ $(CARGO) clippy --all-targets -- -D warnings
+ mandoc -T lint $(MANPAGE)
+
+.PHONY: install
+install: build ## Install the binary and man page under PREFIX
+ install -d $(DESTDIR)$(BINDIR) $(DESTDIR)$(MANDIR)/man1
+ install -m 0755 $(TARGET) $(DESTDIR)$(BINDIR)/$(BIN)
+ install -m 0644 $(MANPAGE) $(DESTDIR)$(MANDIR)/man1/$(MANPAGE)
+
+.PHONY: uninstall
+uninstall: ## Remove the installed binary and man page
+ rm -f $(DESTDIR)$(BINDIR)/$(BIN)
+ rm -f $(DESTDIR)$(MANDIR)/man1/$(MANPAGE)
+
+.PHONY: clean
+clean: ## Remove build artifacts
+ $(CARGO) clean
+
+.PHONY: help
+help: ## List available targets
+ @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
+ | awk 'BEGIN {FS = ":.*?## "} {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
diff --git a/dam/dam.1 b/dam/dam.1
@@ -0,0 +1,244 @@
+.TH DAM 1 2026-06-14
+.SH NAME
+dam \- diff-and-merge line-oriented text files between a local directory and a
+remote host over rsync
+.SH SYNOPSIS
+.B dam
+.B \-\-host
+.I host
+.B \-\-local
+.I path
+.B \-\-remote
+.I path
+.RB [ \-\-plan " | " \-\-pull " | " \-\-push ]
+.RB [ \-\-sudo " | " \-\-doas ]
+.RB [ \-\-exit-code ]
+.RB [ \-\-timeout
+.IR seconds ]
+.SH DESCRIPTION
+.B dam
+synchronises line-oriented text files between a local directory and a remote
+host over
+.BR rsync (1),
+using a plan/apply workflow.
+By default it only previews the changes each direction would make;
+.B \-\-pull
+and
+.B \-\-push
+apply them.
+.PP
+.B dam
+is intended for list-like data such as allow-lists, hosts files, and word
+lists.
+Blank lines, duplicates, and the original line ordering are
+.I not
+preserved by the merge, so it is unsuitable for arbitrary documents.
+.PP
+.B \-\-local
+and
+.B \-\-remote
+may be directories or single files.
+When
+.B \-\-local
+is an existing directory,
+.B dam
+syncs the whole tree, recursing into subdirectories; otherwise it treats both
+paths as a single file.
+To mirror a directory into a new local location, create that directory first.
+.SH MERGE STRATEGY
+When a file exists on both sides, the merged result is the sorted,
+de-duplicated
+.I union
+of its local and remote lines, using natural ordering (so
+.I file2
+sorts before
+.IR file10 ).
+A trailing carriage return is stripped, so CRLF and LF inputs compare equal.
+.PP
+When a file exists on only one side, its contents are propagated verbatim,
+without reordering.
+.PP
+The union is symmetric: the merged result is identical regardless of direction;
+the mode only chooses which side gets written.
+.PP
+.B dam never deletes.
+Because the merge is a union, a line removed on one side reappears from the
+other.
+Removing data is a manual edit on both sides (or a one-shot
+.BR rsync (1)),
+not something
+.B dam
+does.
+.SH MODES
+.BR \-\-plan ,
+.BR \-\-pull ,
+and
+.B \-\-push
+are mutually exclusive.
+.B \-\-plan
+is the default when none is given.
+.TP
+.B \-\-plan
+Show the changes each direction would make, without writing anything.
+.TP
+.B \-\-pull
+Apply the merge to the local directory (remote -> local).
+.TP
+.B \-\-push
+Apply the merge to the remote host (local -> remote).
+.SH OPTIONS
+.TP
+.BI \-\-host " host"
+SSH host alias, as configured in
+.IR ~/.ssh/config ,
+to sync with.
+Required.
+.TP
+.BI \-\-local " path"
+Local directory, or single file, containing the line-oriented text.
+Required.
+.TP
+.BI \-\-remote " path"
+Remote directory, or single file, to sync from and to.
+Required.
+.TP
+.B \-\-sudo
+Elevate the
+.I remote
+rsync with
+.BR sudo (8),
+via rsync's
+.BR \-\-rsync-path .
+Mutually exclusive with
+.BR \-\-doas .
+.TP
+.B \-\-doas
+Elevate the
+.I remote
+rsync with
+.BR doas (1),
+via rsync's
+.BR \-\-rsync-path .
+Mutually exclusive with
+.BR \-\-sudo .
+.IP
+.BR \-\-sudo / \-\-doas
+elevate only the rsync process on the remote host (for example, when the remote
+path is root-owned).
+The local rsync always runs unprivileged, so your own SSH config and keys are
+used.
+.TP
+.B \-\-exit-code
+With
+.BR \-\-plan ,
+exit 1 if pending changes are found.
+Useful in CI.
+Processing errors always exit 2.
+.TP
+.BI \-\-timeout " seconds"
+I/O timeout in seconds handed to rsync.
+The default is 30; 0 disables the timeout.
+.SH OUTPUT
+For each file with pending changes,
+.B \-\-plan
+prints the file's path followed by a per-side summary for each side that would
+change: a
+.I local
+or
+.I remote
+tag, then the count of lines that side would gain
+.RB ( +N )
+and lose
+.RB ( \-N ),
+and the changed lines themselves.
+For example:
+.PP
+.RS 4
+.EX
+hosts/blocklist
+ local +1 -0
+ +badsite.example
+ remote +2 -0
+ +ads.example
+ +tracker.example
+.EE
+.RE
+.PP
+Progress and the run summary are written to standard error; the plan diff is
+written to standard output, so the diff can be captured on its own:
+.PP
+.RS 4
+.EX
+dam --host myserver --local ~/lists --remote /srv/lists > changes.diff
+.EE
+.RE
+.PP
+Local files are written atomically: each is staged in a temporary file in the
+same directory and renamed into place, so an interrupted run cannot leave a
+half-written file.
+rsync runs with
+.I ssh -o BatchMode=yes
+and the I/O
+.B \-\-timeout
+so it fails fast in automation rather than blocking on a password or host-key
+prompt.
+.SH EXIT STATUS
+.TP
+.B 0
+Success; nothing pending, or changes were applied with
+.B \-\-pull
+or
+.BR \-\-push .
+.TP
+.B 1
+.B \-\-exit-code
+was set with
+.B \-\-plan
+and pending changes were found.
+.TP
+.B 2
+One or more files failed to process.
+.SH EXAMPLES
+See what would change on either side:
+.PP
+.RS 4
+.EX
+dam --host myserver --local ~/lists --remote /srv/lists
+.EE
+.RE
+.PP
+Bring the local directory up to date:
+.PP
+.RS 4
+.EX
+dam --host myserver --local ~/lists --remote /srv/lists --pull
+.EE
+.RE
+.PP
+Bring the remote up to date:
+.PP
+.RS 4
+.EX
+dam --host myserver --local ~/lists --remote /srv/lists --push
+.EE
+.RE
+.PP
+Sync a single file:
+.PP
+.RS 4
+.EX
+dam --host myserver --local ~/hosts.allow --remote /etc/hosts.allow --pull
+.EE
+.RE
+.PP
+Fail (exit 1) if the two sides have drifted, for use in CI:
+.PP
+.RS 4
+.EX
+dam --host myserver --local ~/lists --remote /srv/lists --plan --exit-code
+.EE
+.RE
+.SH SEE ALSO
+.BR rsync (1),
+.BR ssh (1),
+.BR ssh_config (5)
diff --git a/dam/src/main.rs b/dam/src/main.rs
@@ -370,8 +370,26 @@ impl FileSyncWorker {
}
}
+/// Which end of the sync a planned change applies to.
+#[derive(Clone, Copy)]
+enum Side {
+ Local,
+ Remote,
+}
+
+impl Side {
+ /// The tag printed in front of this side's diff, colour-coded so local and
+ /// remote are easy to tell apart at a glance.
+ fn tag(self) -> console::StyledObject<&'static str> {
+ match self {
+ Side::Local => style(" local ").black().on_cyan().bold(),
+ Side::Remote => style(" remote ").black().on_magenta().bold(),
+ }
+ }
+}
+
/// Preview the changes both directions would make to a single file, printing
-/// a diff per side that has pending changes. Writes nothing.
+/// a per-side diff for each side that has pending changes. Writes nothing.
fn plan(label: &str, local: Option<&str>, remote: Option<&str>, merged: &str) -> FileStatus {
let local_current = local.unwrap_or("");
let remote_current = remote.unwrap_or("");
@@ -382,13 +400,14 @@ fn plan(label: &str, local: Option<&str>, remote: Option<&str>, merged: &str) ->
return FileStatus::Unchanged;
}
- println!("{label}");
+ println!("{}", style(label).bold().underlined());
if local_changes {
- print_diff(" --pull would update local:", local_current, merged);
+ print_side_diff(Side::Local, local_current, merged);
}
if remote_changes {
- print_diff(" --push would update remote:", remote_current, merged);
+ print_side_diff(Side::Remote, remote_current, merged);
}
+ println!();
FileStatus::Updated
}
@@ -489,7 +508,7 @@ fn merge(local: Option<&str>, remote: Option<&str>) -> String {
/// Sorted, de-duplicated union of the non-blank lines of `a` and `b`. A
/// trailing carriage return is stripped so CRLF and LF inputs compare equal.
-fn union_lines<'a>(a: &'a str, b: &'a str) -> Vec<String> {
+fn union_lines(a: &str, b: &str) -> Vec<String> {
let mut seen = HashSet::new();
let mut result: Vec<String> = a
.lines()
@@ -503,20 +522,45 @@ fn union_lines<'a>(a: &'a str, b: &'a str) -> Vec<String> {
result
}
-/// Print a coloured line diff to stdout. `old` is the base and `new` the
-/// proposed content, so `+` lines are additions and `-` lines are removals.
-fn print_diff(header: &str, old: &str, new: &str) {
- println!("{header}");
- let diff = TextDiff::from_lines(old, new);
+/// Print one side's planned change to stdout: a tagged header naming the side
+/// with its added/removed counts, followed by only the changed lines.
+///
+/// `current` is that side's existing content and `merged` the proposed result,
+/// so `+` lines are gained and `-` lines lost. Unchanged lines are omitted to
+/// keep the plan focused on the delta.
+fn print_side_diff(side: Side, current: &str, merged: &str) {
+ // Diff the lines themselves rather than the raw text: `lines()` drops the
+ // terminators, so a missing trailing newline never shows up as a change.
+ let current: Vec<&str> = current.lines().collect();
+ let merged: Vec<&str> = merged.lines().collect();
+ let diff = TextDiff::from_slices(¤t, &merged);
+
+ // `true` marks an inserted line, `false` a deleted one; equal lines are
+ // dropped here so the body below is purely the delta.
+ let mut changes: Vec<(bool, &str)> = Vec::new();
+ let (mut added, mut removed) = (0u32, 0u32);
for change in diff.iter_all_changes() {
- let (sign, styled) = match change.tag() {
- ChangeTag::Delete => ("-", style(change).red()),
- ChangeTag::Insert => ("+", style(change).green()),
- ChangeTag::Equal => (" ", style(change).dim()),
- };
- print!("{sign}{styled}");
+ match change.tag() {
+ ChangeTag::Insert => added += 1,
+ ChangeTag::Delete => removed += 1,
+ ChangeTag::Equal => continue,
+ }
+ changes.push((change.tag() == ChangeTag::Insert, change.value()));
+ }
+
+ println!(
+ " {} {} {}",
+ side.tag(),
+ style(format!("+{added}")).green(),
+ style(format!("-{removed}")).red(),
+ );
+ for (inserted, line) in changes {
+ if inserted {
+ println!(" {}", style(format!("+{line}")).green());
+ } else {
+ println!(" {}", style(format!("-{line}")).red());
+ }
}
- println!();
}
fn main() -> Result<ExitCode> {