Diff OVERview: An Experimental Supplement to Git Diffs

I've been thinking about version control and how it could be more effective. I've used Git and centralized repositories like GitHub and Bitbucket, for most of my programming life. In general, it has served me well enough as a foundational version control tool. My gripe is the general lack of additional tooling built on top of it, particularly for viewing and understanding changes in their semantic context.

The problem with diffs as presented by most diffing tools (e.g. GitHub's PR interface) is that they're too raw, too detached from the system I'm building. They're just a cleaned up presentation of what is still essentially a textual diff.

When I read a diff, I spend significant time putting the textual changes in the context of the system before mentally comparing the system's behavior between versions. In some cases, especially when reviewing a large set of changes (of which I've been on both the giving and receiving ends 😅), I've felt like I wanted to see an overview of high-level changes, before going into the full diff. My reasoning is that if I can establish the context of the changes more quickly, I can get to the heart of a PR faster while eliminating some cruft around my current workflow.

I recently decided to test that theory and ended up with a tool called dover ("Diff OVERview"). The rest of this post will explain what it is, how it works, and plans for upcoming improvements.

dover

dover is a CLI tool that provides a semantic, high-level overview of Git-based diffs for Rust code. It has two major dependencies: libgit2 for interacting with Git repositories and syn for working with Rust code.

When run from the command line (like dover diff [commit1 [commit2]]), dover will:

  1. Collect the set of changes between the two commits with libgit2,
  2. Filter out any non-Rust source files,
  3. Parse the files with syn to get ASTs,
  4. Convert the ASTs into an "overview" AST containing only the things we want to diff,
  5. Diff the overviews of each file,
  6. Output the results.

The crux of the project is really the overview AST: an opinionated subset of syntactic elements that we want to compare.

For each file in the git diff, dover generates a top-level Overview that looks like this:

#[derive(Debug)]
pub struct Overview {
    path: PathBuf,
    uses: Uses,
    structs: Structs,
    enums: Enums,
    traits: Traits,
    functions: Functions,
    impls: Impls,
}

Each overview element, except path, is itself a subset of its full AST so, for example, a standalone function looks like this:

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Function {
    vis: Visibility,
    r#const: Option<Const>,
    r#async: Option<Async>,
    r#unsafe: Option<Unsafe>,
    abi: Option<Abi>,
    name: String,
    generics: Generics,
    inputs: Inputs,
    output: ReturnType,
    original_fn: syn::ItemFn,
    source: SourceFile,
}

All elements originate from their counterparts in syn but with "extra" information removed. For example, an "overview" of a function does not include the function body.

Sample Diff

Suppose we have these two arbitrary, made-up sample files:

// old somefile.rs

use std::io::{Read, Write};
use tokio::net::TcpListener;

enum ApplicationProtocol<T: Clone> {
    Http { t: T },
    Https,
    Stun,
    Smtp,
}

pub fn build<T, U, V>(i: u32, y: T) -> T
where
    U: Read,
    V: Write,
{
    i
}

struct RandomStruct {
    field1: u8,
    field2: u16,
    field3: u32,
}
impl RandomStruct {
    fn new(field1: u8, field2: u16, field3: u32) -> Self {
        Self {
            field1,
            field2,
            field3,
        }
    }
    fn do_it(&self, x: String) {
        println!("do_it called with x: {}", x);
    }
}

// new - somefile.rs

use std::io::{Read, Seek, Write};
use tokio::net::TcpStream;

async fn build<T, U, V>(x: String, y: T) -> u32
where
    U: Read,
    V: Write + Send,
{
    todo!()
}

enum ApplicationProtocol<T: Clone + Send, U> {
    Dns,
    Http { t: T, u: U },
    Https { t: T },
}

struct RandomStruct {
    field1: u8,
    field3: u32,
}
impl RandomStruct {
    fn new(field1: u8, field3: u32) -> Self {
        Self { field1, field3 }
    }
    pub fn do_it(&self, x: String, y: String) {
        println!("do_it called with ({x}, {y})");
    }
    pub fn do_it_again(&self) -> Result<(), String> {
        println!("I'm doing it again!");
        Ok(())
    }
}

Notice that in the second file, certain fields and variants were added or removed, interfaces were changed, and some items were reordered.

A dover diff of those files to stdout looks like this: An example dover diff to
stdout

If we use the --to-html flag when diffing, dover generates a static webpage showing the same contents: An example dover diff as
HTML

There's still plenty of room for polish and bugfixes, but the general idea has mostly taken shape. A few things dover does include:

One key point is that the diff is opinionated, and not everyone agrees on what is and isn't relevant to an "overview" diff. After finishing the remaining syntactic elements (check the roadmap), the last thing I'd like to add is a dover.toml file for customizing what's included in the diff. I'd like customization to include what syntactic elements to diff, as well as the order of appearance of the major "groups" (structs, traits, etc.).

Is it Useful?

I've used dover when reviewing large diffs and found it works mostly as intended, especially when trying to filter out what a PR is not (like when someone changes a small set of interfaces and the diff shows 30+ files changed, but it's mostly signature-related fallout).

I created the HTML output with the vision of integrating it into GitHub PRs, so that whenever someone opens a PR, a GitHub workflow will run dover and paste a link to the HTML diff. Integrating with our existing workflows while running automatically will let me see if the idea has actual long-term merit, or if it's just a neat trick. Time will tell!

Conclusion

dover is available on crates.io/crates/dover if you want to try it out: cargo install dover.

If you use it and would like to leave feedback or suggestions, feel free to contact me on GitHub! It'd be great to hear other thoughts on dover, diffs, development tools, or the state of programming in general.

Thanks for reading!