diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Cargo.lock | 191 | ||||
| -rw-r--r-- | Cargo.toml | 12 | ||||
| -rw-r--r-- | LICENSE.md | 51 | ||||
| -rw-r--r-- | README.md | 47 | ||||
| -rw-r--r-- | src/lib.rs | 200 | ||||
| -rw-r--r-- | src/main.rs | 47 | ||||
| -rw-r--r-- | testdata/a | 24 | ||||
| -rw-r--r-- | testdata/ancestor | 10 | ||||
| -rw-r--r-- | testdata/b | 26 | ||||
| -rw-r--r-- | testdata/expected2 | 43 | ||||
| -rw-r--r-- | testdata/expected3 | 41 | ||||
| -rw-r--r-- | testdata/tricky | 13 | ||||
| -rw-r--r-- | tests/test_cmd.rs | 62 |
14 files changed, 768 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c93b059 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,191 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "assert_cmd" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1835b7f27878de8525dc71410b5a31cdcc5f230aed5ba5df968e09c201b23d" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "bstr" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "zsh_history" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "thiserror", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2ec537a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "zsh_history" +version = "0.1.0" +edition = "2024" + +[dependencies] +assert_cmd = "2.0.16" +thiserror = "2.0.12" + +[[bin]] +name = "zsh_history" +path = "src/main.rs" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d76dbaf --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,51 @@ +# License + +The crate zsh_history is dual licensed under the terms of the Apache License, +Version 2.0, and the MIT License. + +You may obtain copies of the two licenses at + +* https://www.apache.org/licenses/LICENSE-2.0 and +* https://opensource.org/licenses/MIT, respectively. + +The following two notices apply to every file of the project. + +## The Apache License + +``` +Copyright 2025 Christoph Groth + +Licensed under the Apache License, Version 2.0 (the “License”); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +``` + +## The MIT License + +``` +Copyright 2025 Christoph Groth + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..b462db3 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +zsh_history +=========== + +A tool (and Rust library) for merging zsh history files in zsh’s extended format. + +One possible application is synchronization of shell histories across multiple machines in a decentralized way. + +The extended format ([see `EXTENDED_HISTORY`](https://zsh.sourceforge.io/Doc/Release/Options.html#History)) is required so that zsh records timestamps together with the commands. +These timestamps are used for automatic robust conflict resolution. + +Installation +------------ + +```shell +cargo install --git https://git.grothesque.org/zsh_history +``` + +([Cargo](https://www.rust-lang.org/tools/install) is Rust’s package manager.) + +Usage +----- + +```shell +zsh_history merge A B >MERGED +zsh_history merge A B ANCESTOR >MERGED +``` +If the input files are valid zsh history files, the merged output will be also valid. + +Conflicts are resolved automatically such that all history variants are preserved +(A’s variant is output before B’s variant). +The output contains all commands from A and B, with the exception of those commands that are clearly identified as deleted (by comparing with ANCESTOR) by one or both sides. +If a command has been deleted by one side but modified by the other, the conflict is resolved by preserving the modified version. + +The tool performs a single timestamp-synchronized pass through all inputs. +At any timestamp value it considers at most one command from each input. +Thus, if multiple consecutive commands have the same timestamp, and A and B differ, +the merging will likely get out of sync, and detect spurious conflicts that will lead to commands being output twice (once for A and for B inputs). +This is a theoretical problem that should almost never occur in practice. + +The duration field is preserved but otherwise ignored. + +Use with the Unison file synchronizer +------------------------------------- + +The [Unison file synchronizer](https://github.com/bcpierce00/unison) can be configured to merge specific files using external tools when a conflict is detected. +It can even keep ancestors around automatically. +See the section “Merging Conflicting Versions” in the Unison manual. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7977d17 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,200 @@ +use std::io::{self, BufRead as _}; +use std::str; + +/// A single zsh extended history item. +#[derive(Debug)] +pub struct Item<'a> { + pub time: u64, + pub duration: u64, + pub cmd: &'a [u8], +} + +impl Item<'_> { + /// Writes one item in the format b": <timestamp>:<duration>;<command>\n". + pub fn write<W: io::Write>(&self, out: &mut W) -> Result<(), Error> { + write!(out, ": {}:{};", self.time, self.duration)?; + out.write_all(self.cmd)?; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Utf8(#[from] str::Utf8Error), + #[error(transparent)] + Int(#[from] std::num::ParseIntError), + #[error("Format error: {0}")] + Format(String), +} + +/// A reader that parses extended zsh history items from a stream. +/// It reuses an internal buffer so that items are yielded without copying. +pub struct Reader<R: io::Read> { + reader: io::BufReader<R>, + buf: Vec<u8>, +} + +impl<R: io::Read> Reader<R> { + pub fn new(inner: R) -> Self { + Reader { + reader: io::BufReader::new(inner), + buf: Vec::with_capacity(1024), + } + } + + /// Parses self.buf into an Item. + /// Expected format: b": <timestamp>:<duration>;<command>" + /// This method borrows from self.buf. + fn parse(&self) -> Result<Item<'_>, Error> { + let mut item = &self.buf[..]; + + if let Some(stripped) = item.strip_prefix(b":").or_else(|| item.strip_prefix(b"\\:")) { + item = stripped; + } else { + return Err(Error::Format(format!( + "Item does not start with ':' or '\\:' : {:?}", + str::from_utf8(&item)? + ))); + } + + item = item.strip_prefix(b" ").unwrap_or(item); + + let colon_pos = item.iter().position(|&c| c == b':') + .ok_or_else(|| Error::Format("Missing colon between timestamp and duration.".into()))?; + let time: u64 = str::from_utf8(&item[..colon_pos])?.parse()?; + + let remainder = &item[colon_pos + 1..]; + let semi_pos = remainder.iter().position(|&c| c == b';') + .ok_or_else(|| Error::Format("Missing semicolon in item.".into()))?; + let duration: u64 = str::from_utf8(&remainder[..semi_pos])?.parse()?; + let cmd = &remainder[semi_pos + 1..]; + + Ok(Item { time, duration, cmd }) + } + + // Reads and returns the next item, or None on EOF. + pub fn read_item(&mut self) -> Result<Option<Item<'_>>, Error> { + self.buf.clear(); + + loop { + if self.reader.read_until(b'\n', &mut self.buf)? == 0 { + return if self.buf.is_empty() { + Ok(None) + } else { + Ok(Some(self.parse()?)) + } + } + + // If a command ends with a backslash, zsh makes sure to prepend a space, such that + // backslash-quoted newlines mean: command continues on next line. + if !self.buf.ends_with(b"\\\n") { + break; + } + } + Ok(Some(self.parse()?)) + } +} + +pub fn merge<R1: io::Read, R2: io::Read, R3: io::Read, W: io::Write>( + mut left: Reader<R1>, + mut right: Reader<R2>, + mut ancestor: Reader<R3>, + out: &mut W, +) -> Result<(), Error> { + // Get the first item from each stream. + let mut l = left.read_item()?; + let mut r = right.read_item()?; + let mut a = ancestor.read_item()?; + + let out = &mut io::BufWriter::new(out); + + // While any stream still has an item: + while l.is_some() || r.is_some() || a.is_some() { + // Determine the current earliest time over all non-None items. + let current_time = [l.as_ref(), r.as_ref(), a.as_ref()] + .iter() + .filter_map(|opt| opt.map(|i| i.time)) + .min() + .unwrap(); + + // Grab the "chunk": all items whose time equals the current time. + // (After this step, each of left, right, and ancestor is either Some(item) + // with the current time or None.) + let l_chunk = l.as_ref().filter(|i| i.time == current_time); + let r_chunk = r.as_ref().filter(|i| i.time == current_time); + let a_chunk = a.as_ref().filter(|i| i.time == current_time); + + match (l_chunk, r_chunk, a_chunk) { + // Case 1: Both left and right are present. + (Some(l), Some(r), a_opt) => { + if l.cmd == r.cmd { + // They agree: output it. + l.write(out)?; + } else { + // They differ. Check against ancestor. + if let Some(a) = a_opt { + if l.cmd == a.cmd && r.cmd != a.cmd { + r.write(out)?; + } else if r.cmd == a.cmd && l.cmd != a.cmd { + l.write(out)?; + } else { + // Otherwise both differ, so output both. + l.write(out)?; + r.write(out)?; + } + } else { + // No ancestor: output both. + l.write(out)?; + r.write(out)?; + } + } + } + + // Case 2a: Left is present; right is missing. + (Some(l), None, a_opt) => { + if let Some(a) = a_opt { + if l.cmd == a.cmd { + // Left == ancestor: deletion. + } else { + l.write(out)?; + } + } else { + l.write(out)?; + } + } + + // Case 2b: Right is present; left is missing. + (None, Some(r), a_opt) => { + if let Some(a) = a_opt { + if r.cmd == a.cmd { + } else { + r.write(out)?; + } + } else { + r.write(out)?; + } + } + + // Case 3: Neither left nor right have a item. + (None, None, Some(_)) => { + // Interpret as a deletion. + } + (None, None, None) => unreachable!(), + } + + // Advance the readers. + if l_chunk.is_some() { + l = left.read_item()?; + } + if r_chunk.is_some() { + r = right.read_item()?; + } + if a_chunk.is_some() { + a = ancestor.read_item()?; + } + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..044852b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,47 @@ +use std::io; +use zsh_history as zh; + +fn print_usage(prog: &str) { + eprintln!("Usage:"); + eprintln!(" {} merge <left> <right> # two–way merge", prog); + eprintln!(" {} merge <left> <right> <ancestor> # three–way merge", prog); +} + +fn main() -> Result<(), Box<dyn std::error::Error>> { + let args: Vec<String> = std::env::args().collect(); + let prog = &args[0]; + + if args.len() < 4 || args.len() > 5 || args[1] != "merge" { + print_usage(prog); + return Ok(()); + } + + // Open files and create readers in one go. + let mut readers: Vec<_> = args[2..] + .iter() + .map(|name| { + std::fs::File::open(name) + .map_err(zh::Error::Io) + .map(zh::Reader::new) + }) + .collect::<Result<_, _>>()?; + + let mut out = io::stdout().lock(); + + let result = match readers.len() { + 2 => { + let ancestor = zh::Reader::new(io::empty()); + zh::merge(readers.remove(0), readers.remove(0), ancestor, &mut out) + } + 3 => zh::merge( + readers.remove(0), + readers.remove(0), + readers.remove(0), + &mut out, + ), + _ => unreachable!(), + }; + + result?; + Ok(()) +} diff --git a/testdata/a b/testdata/a new file mode 100644 index 0000000..42d5016 --- /dev/null +++ b/testdata/a @@ -0,0 +1,24 @@ +: 1:0;b +: 1:0;a +: 1:0;a +: 1:0;a +: 2:0;cc +: 3:0;dd +: 4:0;f +: 5:0;e +: 6:3;g +: 7:3;hh +: 9:0;l +: 9:0;m +: 9:0;nn +: 9:0;o +: 9:0;p +: 9:0;q +: 9:0;t +: 11:0;L +: 12:0;M +: 13:0;NN +: 14:0;O +: 15:0;P +: 16:0;Q +: 19:0;T diff --git a/testdata/ancestor b/testdata/ancestor new file mode 100644 index 0000000..6d6afe2 --- /dev/null +++ b/testdata/ancestor @@ -0,0 +1,10 @@ +: 0:0;a +: 1:0;b +: 2:0;c +: 3:0;d +: 4:0;e +: 5:0;f +: 6:3;g +: 7:3;h +: 8:3;i +: 9:3;j diff --git a/testdata/b b/testdata/b new file mode 100644 index 0000000..61ed43e --- /dev/null +++ b/testdata/b @@ -0,0 +1,26 @@ +: 1:0;b +: 2:0;c +: 3:0;ddd +: 4:0;f +: 5:0;e +: 6:3;g +: 8:3;ii +: 9:3;j +: 9:0;k +: 9:0;m +: 9:0;n +: 9:0;o +: 9:0;q +: 9:0;p +: 9:0;r +: 9:0;r +: 9:0;s +: 10:0;K +: 12:0;M +: 13:0;N +: 14:0;O +: 15:0;Q +: 16:0;P +: 17:0;R +: 17:0;R +: 18:0;S diff --git a/testdata/expected2 b/testdata/expected2 new file mode 100644 index 0000000..60a4031 --- /dev/null +++ b/testdata/expected2 @@ -0,0 +1,43 @@ +: 1:0;b +: 1:0;a +: 1:0;a +: 1:0;a +: 2:0;cc +: 2:0;c +: 3:0;dd +: 3:0;ddd +: 4:0;f +: 5:0;e +: 6:3;g +: 7:3;hh +: 8:3;ii +: 9:0;l +: 9:3;j +: 9:0;m +: 9:0;k +: 9:0;nn +: 9:0;m +: 9:0;o +: 9:0;n +: 9:0;p +: 9:0;o +: 9:0;q +: 9:0;t +: 9:0;p +: 9:0;r +: 9:0;r +: 9:0;s +: 10:0;K +: 11:0;L +: 12:0;M +: 13:0;NN +: 13:0;N +: 14:0;O +: 15:0;P +: 15:0;Q +: 16:0;Q +: 16:0;P +: 17:0;R +: 17:0;R +: 18:0;S +: 19:0;T diff --git a/testdata/expected3 b/testdata/expected3 new file mode 100644 index 0000000..43513b7 --- /dev/null +++ b/testdata/expected3 @@ -0,0 +1,41 @@ +: 1:0;b +: 1:0;a +: 1:0;a +: 1:0;a +: 2:0;cc +: 3:0;dd +: 3:0;ddd +: 4:0;f +: 5:0;e +: 6:3;g +: 7:3;hh +: 8:3;ii +: 9:0;l +: 9:0;m +: 9:0;k +: 9:0;nn +: 9:0;m +: 9:0;o +: 9:0;n +: 9:0;p +: 9:0;o +: 9:0;q +: 9:0;t +: 9:0;p +: 9:0;r +: 9:0;r +: 9:0;s +: 10:0;K +: 11:0;L +: 12:0;M +: 13:0;NN +: 13:0;N +: 14:0;O +: 15:0;P +: 15:0;Q +: 16:0;Q +: 16:0;P +: 17:0;R +: 17:0;R +: 18:0;S +: 19:0;T diff --git a/testdata/tricky b/testdata/tricky new file mode 100644 index 0000000..dd0b842 --- /dev/null +++ b/testdata/tricky @@ -0,0 +1,13 @@ +: 1742639203:0;echo some tricky corner cases: +: 1742639224:0;echo two-line command \\ +line 2 +: 1742639255:0;echo three-line-command \\ +line 2\\ +line 3 ends with a backslash\\ +: 1742639288:0;echo \ * +: 1742639331:0;echo this line ends with three backslashes \\\\ +line 2 +: 1742639341:0;echo backslash followed by a _single_ space \ +: 1742639405:0;echo \\ +: 1111111111:0;echo this looks like a command, but it is a second line +: 1742639433:0;echo diff --git a/tests/test_cmd.rs b/tests/test_cmd.rs new file mode 100644 index 0000000..16f639f --- /dev/null +++ b/tests/test_cmd.rs @@ -0,0 +1,62 @@ +use assert_cmd::cargo::CommandCargoExt as _; +use std::process::{Command, Stdio}; + +const NULL: &str = "/dev/null"; +const TRICKY: &str = "testdata/tricky"; +const A: &str = "testdata/a"; +const B: &str = "testdata/b"; +const ANCESTOR: &str = "testdata/ancestor"; +const EXPECTED2: &str = "testdata/expected2"; +const EXPECTED3: &str = "testdata/expected3"; + +fn merge_then_diff(args: &[&str], expected: &str) -> Result<(), Box<dyn std::error::Error>> { + let mut merge = Command::cargo_bin("zsh_history")? + .args(args) + .stdout(Stdio::piped()) + .spawn()?; + + let binary_stdout = merge.stdout.take().expect("Failed to capture stdout"); + + let mut diff = Command::new("diff") + .args(&["-u", expected, "-"]) + .stdin(Stdio::from(binary_stdout)) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn()?; + + let binary_status = merge.wait()?; + if !binary_status.success() { + return Err(format!("Merge failed with status {:?}", binary_status).into()); + } + + let diff_status = diff.wait()?; + if !diff_status.success() { + return Err(format!( + "Merging {} produced unexpected result (see diff above).", + args.join(" ") + ) + .into()); + } + + Ok(()) +} + +#[test] +fn test_pass_through() -> Result<(), Box<dyn std::error::Error>> { + for file in [NULL, TRICKY] { + merge_then_diff(&["merge", file, NULL], file)?; + merge_then_diff(&["merge", NULL, file], file)?; + merge_then_diff(&["merge", file, file], file)?; + } + Ok(()) +} + +#[test] +fn test_merge() -> Result<(), Box<dyn std::error::Error>> { + merge_then_diff(&["merge", A, B], EXPECTED2) +} + +#[test] +fn test_merge_with_ancestor() -> Result<(), Box<dyn std::error::Error>> { + merge_then_diff(&["merge", A, B, ANCESTOR], EXPECTED3) +} |
