Repository: GitoxideLabs/gitoxide
Pull request #1880: Add `gix diff file`
Author: login: cruessler
Created: 2025-03-08T14:06:53Z | Merged: 2025-03-19T02:03:25Z

This PR adds `gix diff file`.

I copied some code, most notably `FindChangeToPath` from `gix-blame`. Would you recommend I put that code in a shared location, or would you recommend other changes, e. g. using `repo.diff_tree_to_tree` which is also used in `diff::tree`?

Update 2025-03-09: I have removed all `todo!()`s and `TODO`s, so the PR is now ready for review!



Diff:
diff --git a/gitoxide-core/src/repository/diff.rs b/gitoxide-core/src/repository/diff.rs
index 0f507ba8747..0893e4da1e4 100644
--- a/gitoxide-core/src/repository/diff.rs
+++ b/gitoxide-core/src/repository/diff.rs
@@ -1,4 +1,8 @@
+use anyhow::Context;
 use gix::bstr::{BString, ByteSlice};
+use gix::diff::blob::intern::TokenSource;
+use gix::diff::blob::unified_diff::{ContextSize, NewlineSeparator};
+use gix::diff::blob::UnifiedDiff;
 use gix::objs::tree::EntryMode;
 use gix::odb::store::RefreshMode;
 use gix::prelude::ObjectIdExt;
@@ -111,3 +115,86 @@ fn typed_location(mut location: BString, mode: EntryMode) -> BString {
     }
     location
 }
+
+pub fn file(
+    mut repo: gix::Repository,
+    out: &mut dyn std::io::Write,
+    old_revspec: BString,
+    new_revspec: BString,
+) -> Result<(), anyhow::Error> {
+    repo.object_cache_size_if_unset(repo.compute_object_cache_size_for_tree_diffs(&**repo.index_or_empty()?));
+    repo.objects.refresh = RefreshMode::Never;
+
+    let old_resolved_revspec = repo.rev_parse(old_revspec.as_bstr())?;
+    let new_resolved_revspec = repo.rev_parse(new_revspec.as_bstr())?;
+
+    let old_blob_id = old_resolved_revspec
+        .single()
+        .context(format!("rev-spec '{old_revspec}' must resolve to a single object"))?;
+    let new_blob_id = new_resolved_revspec
+        .single()
+        .context(format!("rev-spec '{new_revspec}' must resolve to a single object"))?;
+
+    let (old_path, _) = old_resolved_revspec
+        .path_and_mode()
+        .context(format!("rev-spec '{old_revspec}' must contain a path"))?;
+    let (new_path, _) = new_resolved_revspec
+        .path_and_mode()
+        .context(format!("rev-spec '{new_revspec}' must contain a path"))?;
+
+    let mut resource_cache = repo.diff_resource_cache(
+        gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent,
+        Default::default(),
+    )?;
+
+    resource_cache.set_resource(
+        old_blob_id.into(),
+        gix::object::tree::EntryKind::Blob,
+        old_path,
+        gix::diff::blob::ResourceKind::OldOrSource,
+        &repo.objects,
+    )?;
+    resource_cache.set_resource(
+        new_blob_id.into(),
+        gix::object::tree::EntryKind::Blob,
+        new_path,
+        gix::diff::blob::ResourceKind::NewOrDestination,
+        &repo.objects,
+    )?;
+
+    let outcome = resource_cache.prepare_diff()?;
+
+    use gix::diff::blob::platform::prepare_diff::Operation;
+
+    let algorithm = match outcome.operation {
+        Operation::InternalDiff { algorithm } => algorithm,
+        Operation::ExternalCommand { .. } => {
+            unreachable!("We disabled that")
+        }
+        Operation::SourceOrDestinationIsBinary => {
+            anyhow::bail!("Source or destination is binary and we can't diff that")
+        }
+    };
+
+    let interner = gix::diff::blob::intern::InternedInput::new(
+        tokens_for_diffing(outcome.old.data.as_slice().unwrap_or_default()),
+        tokens_for_diffing(outcome.new.data.as_slice().unwrap_or_default()),
+    );
+
+    let unified_diff = UnifiedDiff::new(
+        &interner,
+        String::new(),
+        NewlineSeparator::AfterHeaderAndLine("\n"),
+        ContextSize::symmetrical(3),
+    );
+
+    let unified_diff = gix::diff::blob::diff(algorithm, &interner, unified_diff)?;
+
+    out.write_all(unified_diff.as_bytes())?;
+
+    Ok(())
+}
+
+pub(crate) fn tokens_for_diffing(data: &[u8]) -> impl TokenSource<Token = &[u8]> {
+    gix::diff::blob::sources::byte_lines(data)
+}
diff --git a/src/plumbing/main.rs b/src/plumbing/main.rs
index d676f04a183..fad69987f3f 100644
--- a/src/plumbing/main.rs
+++ b/src/plumbing/main.rs
@@ -277,6 +277,20 @@ pub fn main() -> Result<()> {
                     core::repository::diff::tree(repository(Mode::Lenient)?, out, old_treeish, new_treeish)
                 },
             ),
+            crate::plumbing::options::diff::SubCommands::File {
+                old_revspec,
+                new_revspec,
+            } => prepare_and_run(
+                "diff-file",
+                trace,
+                verbose,
+                progress,
+                progress_keep_open,
+                None,
+                move |_progress, out, _err| {
+                    core::repository::diff::file(repository(Mode::Lenient)?, out, old_revspec, new_revspec)
+                },
+            ),
         },
         Subcommands::Log(crate::plumbing::options::log::Platform { pathspec }) => prepare_and_run(
             "log",
diff --git a/src/plumbing/options/mod.rs b/src/plumbing/options/mod.rs
index 70e80c56740..b93a8af4469 100644
--- a/src/plumbing/options/mod.rs
+++ b/src/plumbing/options/mod.rs
@@ -517,6 +517,15 @@ pub mod diff {
             #[clap(value_parser = crate::shared::AsBString)]
             new_treeish: BString,
         },
+        /// Diff two versions of a file.
+        File {
+            /// A rev-spec representing the 'before' or old state of the file, like '@~100:file'
+            #[clap(value_parser = crate::shared::AsBString)]
+            old_revspec: BString,
+            /// A rev-spec representing the 'after' or new state of the file, like ':file'
+            #[clap(value_parser = crate::shared::AsBString)]
+            new_revspec: BString,
+        },
     }
 }
 


Reviews:
user: login: Byron
body: Thanks so much for tackling this, and sorry for the late response.
I am still working off my backlog of emails, but I now prioritize by name, yours are next.

With that said, I fortunately (or unfortunately) have a lot of change-requests this time, but I hope they make sense. If not, please do let me know.

The highest-level change starts at the invocation. Here is what I wanted to do when trying it:

```
cargo run --bin gix -- diff file @~100:README.md README.md
```

The benefit of this notation, despite slightly repetitive, is that it is as 'for debug use' as it can get - there is no way someone would expect 'smart' features like rename-tracking, and I like that.

From there, it would still be possible to implement a version closer to what Git currently offers, taking more parameters selectively.

Everything else is in the line-comments.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-16T03:02:17Z
user: login: cruessler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T08:31:56Z
user: login: cruessler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T08:32:22Z
user: login: cruessler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T08:33:01Z
user: login: cruessler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T08:33:46Z
user: login: cruessler
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T08:35:27Z
user: login: Byron
body: Great work, thanks so much!

If you wanted to make a follow-up improvement to drive it to the most-capable version that I can imagine, you could make this work:

```sh
gitoxide ( add-gix-diff-file) [$?]
❯ echo change >>README.md

gitoxide ( add-gix-diff-file) +1 [$!?]
❯ cargo run --bin gix -- diff file :README.md README.md
   Compiling gitoxide v0.41.0 (/Users/byron/dev/github.com/Byron/gitoxide)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.57s
     Running `target/debug/gix diff file ':README.md' README.md`
Error: The ref partially named "README.md" could not be found
```

Note that the `:file` syntax pulls a file from the index, and `file` doesn't work as it's just a path, not a rev-spec. But that error is specific and could be caught. If one then makes the `ObjectId` `null()`, `set_resource()` will automatically read the file from the worktree, allowing to do everything that `set_resource()` allows.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-19T01:42:30Z
user: login: Byron
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T23:00:58Z

Review comments:
diff_hunk: @@ -1,7 +1,10 @@
-use gix::bstr::{BString, ByteSlice};
+use gix::bstr::{BStr, BString, ByteSlice};
+use gix::diff::blob::intern::TokenSource;
+use gix::diff::blob::UnifiedDiffBuilder;
path: gitoxide-core/src/repository/diff.rs
user: login: Byron
body: It's a bit confusing, but there also is a `UnifiedDiff` type which should be used instead. It's an improved version of the builder that handled newlines better.
created_at: 2025-03-16T02:47:36Z
author_association: MEMBER
diff_hunk: @@ -111,3 +114,194 @@ fn typed_location(mut location: BString, mode: EntryMode) -> BString {
     }
     location
 }
+
+pub fn file(
+    mut repo: gix::Repository,
+    out: &mut dyn std::io::Write,
+    old_treeish: BString,
+    new_treeish: BString,
+    path: BString,
+) -> Result<(), anyhow::Error> {
+    repo.object_cache_size_if_unset(repo.compute_object_cache_size_for_tree_diffs(&**repo.index_or_empty()?));
+    repo.objects.refresh = RefreshMode::Never;
+
+    let old_tree_id = repo.rev_parse_single(old_treeish.as_bstr())?;
path: gitoxide-core/src/repository/diff.rs
user: login: Byron
body: Once this is a rev-spec yielding a blob, it can be used for diffing directly.
created_at: 2025-03-16T02:48:07Z
author_association: MEMBER
diff_hunk: @@ -111,3 +114,194 @@ fn typed_location(mut location: BString, mode: EntryMode) -> BString {
     }
     location
 }
+
+pub fn file(
+    mut repo: gix::Repository,
+    out: &mut dyn std::io::Write,
+    old_treeish: BString,
+    new_treeish: BString,
+    path: BString,
+) -> Result<(), anyhow::Error> {
+    repo.object_cache_size_if_unset(repo.compute_object_cache_size_for_tree_diffs(&**repo.index_or_empty()?));
+    repo.objects.refresh = RefreshMode::Never;
+
+    let old_tree_id = repo.rev_parse_single(old_treeish.as_bstr())?;
+    let new_tree_id = repo.rev_parse_single(new_treeish.as_bstr())?;
+
+    let old_tree = old_tree_id.object()?.peel_to_tree()?;
+    let new_tree = new_tree_id.object()?.peel_to_tree()?;
+
+    let mut old_tree_buf = Vec::new();
+    let mut new_tree_buf = Vec::new();
+
+    use gix::diff::object::FindExt;
+
+    let old_tree_iter = repo.objects.find_tree_iter(&old_tree.id(), &mut old_tree_buf)?;
+    let new_tree_iter = repo.objects.find_tree_iter(&new_tree.id(), &mut new_tree_buf)?;
+
+    use gix::diff::tree::{
+        recorder::{self, Location},
+        Recorder,
+    };
+
+    struct FindChangeToPath {
+        inner: Recorder,
+        interesting_path: BString,
+        change: Option<recorder::Change>,
+    }
+
+    impl FindChangeToPath {
+        fn new(interesting_path: &BStr) -> Self {
+            let inner = Recorder::default().track_location(Some(Location::Path));
+
+            FindChangeToPath {
+                inner,
+                interesting_path: interesting_path.into(),
+                change: None,
+            }
+        }
+    }
+
+    use gix::diff::tree::{visit, Visit};
+
+    impl Visit for FindChangeToPath {
+        fn pop_front_tracked_path_and_set_current(&mut self) {
+            self.inner.pop_front_tracked_path_and_set_current();
+        }
+
+        fn push_back_tracked_path_component(&mut self, component: &BStr) {
+            self.inner.push_back_tracked_path_component(component);
+        }
+
+        fn push_path_component(&mut self, component: &BStr) {
+            self.inner.push_path_component(component);
+        }
+
+        fn pop_path_component(&mut self) {
+            self.inner.pop_path_component();
+        }
+
+        fn visit(&mut self, change: visit::Change) -> visit::Action {
+            if self.inner.path() == self.interesting_path {
+                self.change = Some(match change {
+                    visit::Change::Deletion {
+                        entry_mode,
+                        oid,
+                        relation,
+                    } => recorder::Change::Deletion {
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                        relation,
+                    },
+                    visit::Change::Addition {
+                        entry_mode,
+                        oid,
+                        relation,
+                    } => recorder::Change::Addition {
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                        relation,
+                    },
+                    visit::Change::Modification {
+                        previous_entry_mode,
+                        previous_oid,
+                        entry_mode,
+                        oid,
+                    } => recorder::Change::Modification {
+                        previous_entry_mode,
+                        previous_oid,
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                    },
+                });
+
+                visit::Action::Cancel
+            } else {
+                visit::Action::Continue
+            }
+        }
+    }
+
+    let mut recorder = FindChangeToPath::new(path.as_ref());
+    let state = gix::diff::tree::State::default();
+    let result = gix::diff::tree(old_tree_iter, new_tree_iter, state, &repo.objects, &mut recorder);
path: gitoxide-core/src/repository/diff.rs
user: login: Byron
body: Even if one would use `<tree> <tree> <path>` as input, I'd think one can look up `<path>` in each tree without having to diff them.
created_at: 2025-03-16T02:52:28Z
author_association: MEMBER
diff_hunk: @@ -111,3 +114,194 @@ fn typed_location(mut location: BString, mode: EntryMode) -> BString {
     }
     location
 }
+
+pub fn file(
+    mut repo: gix::Repository,
+    out: &mut dyn std::io::Write,
+    old_treeish: BString,
+    new_treeish: BString,
+    path: BString,
+) -> Result<(), anyhow::Error> {
+    repo.object_cache_size_if_unset(repo.compute_object_cache_size_for_tree_diffs(&**repo.index_or_empty()?));
+    repo.objects.refresh = RefreshMode::Never;
+
+    let old_tree_id = repo.rev_parse_single(old_treeish.as_bstr())?;
+    let new_tree_id = repo.rev_parse_single(new_treeish.as_bstr())?;
+
+    let old_tree = old_tree_id.object()?.peel_to_tree()?;
+    let new_tree = new_tree_id.object()?.peel_to_tree()?;
+
+    let mut old_tree_buf = Vec::new();
+    let mut new_tree_buf = Vec::new();
+
+    use gix::diff::object::FindExt;
+
+    let old_tree_iter = repo.objects.find_tree_iter(&old_tree.id(), &mut old_tree_buf)?;
+    let new_tree_iter = repo.objects.find_tree_iter(&new_tree.id(), &mut new_tree_buf)?;
+
+    use gix::diff::tree::{
+        recorder::{self, Location},
+        Recorder,
+    };
+
+    struct FindChangeToPath {
+        inner: Recorder,
+        interesting_path: BString,
+        change: Option<recorder::Change>,
+    }
+
+    impl FindChangeToPath {
+        fn new(interesting_path: &BStr) -> Self {
+            let inner = Recorder::default().track_location(Some(Location::Path));
+
+            FindChangeToPath {
+                inner,
+                interesting_path: interesting_path.into(),
+                change: None,
+            }
+        }
+    }
+
+    use gix::diff::tree::{visit, Visit};
+
+    impl Visit for FindChangeToPath {
+        fn pop_front_tracked_path_and_set_current(&mut self) {
+            self.inner.pop_front_tracked_path_and_set_current();
+        }
+
+        fn push_back_tracked_path_component(&mut self, component: &BStr) {
+            self.inner.push_back_tracked_path_component(component);
+        }
+
+        fn push_path_component(&mut self, component: &BStr) {
+            self.inner.push_path_component(component);
+        }
+
+        fn pop_path_component(&mut self) {
+            self.inner.pop_path_component();
+        }
+
+        fn visit(&mut self, change: visit::Change) -> visit::Action {
+            if self.inner.path() == self.interesting_path {
+                self.change = Some(match change {
+                    visit::Change::Deletion {
+                        entry_mode,
+                        oid,
+                        relation,
+                    } => recorder::Change::Deletion {
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                        relation,
+                    },
+                    visit::Change::Addition {
+                        entry_mode,
+                        oid,
+                        relation,
+                    } => recorder::Change::Addition {
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                        relation,
+                    },
+                    visit::Change::Modification {
+                        previous_entry_mode,
+                        previous_oid,
+                        entry_mode,
+                        oid,
+                    } => recorder::Change::Modification {
+                        previous_entry_mode,
+                        previous_oid,
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                    },
+                });
+
+                visit::Action::Cancel
+            } else {
+                visit::Action::Continue
+            }
+        }
+    }
+
+    let mut recorder = FindChangeToPath::new(path.as_ref());
+    let state = gix::diff::tree::State::default();
+    let result = gix::diff::tree(old_tree_iter, new_tree_iter, state, &repo.objects, &mut recorder);
+
+    let change = match result {
+        Ok(_) | Err(gix::diff::tree::Error::Cancelled) => recorder.change,
+        Err(error) => return Err(error.into()),
+    };
+
+    let Some(change) = change else {
+        anyhow::bail!(
+            "There was no change to {} between {} and {}",
+            &path,
+            old_treeish,
+            new_treeish
+        )
+    };
+
+    let mut resource_cache = repo.diff_resource_cache(gix::diff::blob::pipeline::Mode::ToGit, Default::default())?;
+
+    let (previous_oid, oid) = match change {
+        recorder::Change::Addition { oid, .. } => {
+            // Setting `previous_oid` to `ObjectId::empty_blob` makes `diff` see an addition.
+            (ObjectId::empty_blob(gix::hash::Kind::Sha1), oid)
+        }
+        recorder::Change::Deletion { oid: previous_oid, .. } => {
+            // Setting `oid` to `ObjectId::empty_blob` makes `diff` see a deletion.
+            (previous_oid, ObjectId::empty_blob(gix::hash::Kind::Sha1))
+        }
+        recorder::Change::Modification { previous_oid, oid, .. } => (previous_oid, oid),
+    };
+
+    resource_cache.set_resource(
+        previous_oid,
+        gix::object::tree::EntryKind::Blob,
+        path.as_slice().into(),
+        gix::diff::blob::ResourceKind::OldOrSource,
+        &repo.objects,
+    )?;
+    resource_cache.set_resource(
+        oid,
+        gix::object::tree::EntryKind::Blob,
+        path.as_slice().into(),
+        gix::diff::blob::ResourceKind::NewOrDestination,
+        &repo.objects,
+    )?;
+
+    let outcome = resource_cache.prepare_diff()?;
+
+    let old_data = String::from_utf8_lossy(outcome.old.data.as_slice().unwrap_or_default());
path: gitoxide-core/src/repository/diff.rs
user: login: Byron
body: It should not be necessary to convert to UTF-8, `imara-diff` can diff byte sequences as well. For later display, it's better to use `BStr` which produces a lossy result then so the diff can be done on 'the original'.
created_at: 2025-03-16T02:54:18Z
author_association: MEMBER
diff_hunk: @@ -111,3 +114,194 @@ fn typed_location(mut location: BString, mode: EntryMode) -> BString {
     }
     location
 }
+
+pub fn file(
+    mut repo: gix::Repository,
+    out: &mut dyn std::io::Write,
+    old_treeish: BString,
+    new_treeish: BString,
+    path: BString,
+) -> Result<(), anyhow::Error> {
+    repo.object_cache_size_if_unset(repo.compute_object_cache_size_for_tree_diffs(&**repo.index_or_empty()?));
+    repo.objects.refresh = RefreshMode::Never;
+
+    let old_tree_id = repo.rev_parse_single(old_treeish.as_bstr())?;
+    let new_tree_id = repo.rev_parse_single(new_treeish.as_bstr())?;
+
+    let old_tree = old_tree_id.object()?.peel_to_tree()?;
+    let new_tree = new_tree_id.object()?.peel_to_tree()?;
+
+    let mut old_tree_buf = Vec::new();
+    let mut new_tree_buf = Vec::new();
+
+    use gix::diff::object::FindExt;
+
+    let old_tree_iter = repo.objects.find_tree_iter(&old_tree.id(), &mut old_tree_buf)?;
+    let new_tree_iter = repo.objects.find_tree_iter(&new_tree.id(), &mut new_tree_buf)?;
+
+    use gix::diff::tree::{
+        recorder::{self, Location},
+        Recorder,
+    };
+
+    struct FindChangeToPath {
+        inner: Recorder,
+        interesting_path: BString,
+        change: Option<recorder::Change>,
+    }
+
+    impl FindChangeToPath {
+        fn new(interesting_path: &BStr) -> Self {
+            let inner = Recorder::default().track_location(Some(Location::Path));
+
+            FindChangeToPath {
+                inner,
+                interesting_path: interesting_path.into(),
+                change: None,
+            }
+        }
+    }
+
+    use gix::diff::tree::{visit, Visit};
+
+    impl Visit for FindChangeToPath {
+        fn pop_front_tracked_path_and_set_current(&mut self) {
+            self.inner.pop_front_tracked_path_and_set_current();
+        }
+
+        fn push_back_tracked_path_component(&mut self, component: &BStr) {
+            self.inner.push_back_tracked_path_component(component);
+        }
+
+        fn push_path_component(&mut self, component: &BStr) {
+            self.inner.push_path_component(component);
+        }
+
+        fn pop_path_component(&mut self) {
+            self.inner.pop_path_component();
+        }
+
+        fn visit(&mut self, change: visit::Change) -> visit::Action {
+            if self.inner.path() == self.interesting_path {
+                self.change = Some(match change {
+                    visit::Change::Deletion {
+                        entry_mode,
+                        oid,
+                        relation,
+                    } => recorder::Change::Deletion {
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                        relation,
+                    },
+                    visit::Change::Addition {
+                        entry_mode,
+                        oid,
+                        relation,
+                    } => recorder::Change::Addition {
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                        relation,
+                    },
+                    visit::Change::Modification {
+                        previous_entry_mode,
+                        previous_oid,
+                        entry_mode,
+                        oid,
+                    } => recorder::Change::Modification {
+                        previous_entry_mode,
+                        previous_oid,
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                    },
+                });
+
+                visit::Action::Cancel
+            } else {
+                visit::Action::Continue
+            }
+        }
+    }
+
+    let mut recorder = FindChangeToPath::new(path.as_ref());
+    let state = gix::diff::tree::State::default();
+    let result = gix::diff::tree(old_tree_iter, new_tree_iter, state, &repo.objects, &mut recorder);
+
+    let change = match result {
+        Ok(_) | Err(gix::diff::tree::Error::Cancelled) => recorder.change,
+        Err(error) => return Err(error.into()),
+    };
+
+    let Some(change) = change else {
+        anyhow::bail!(
+            "There was no change to {} between {} and {}",
+            &path,
+            old_treeish,
+            new_treeish
+        )
+    };
+
+    let mut resource_cache = repo.diff_resource_cache(gix::diff::blob::pipeline::Mode::ToGit, Default::default())?;
path: gitoxide-core/src/repository/diff.rs
user: login: Byron
body: It's probably fine to start diffing what's in Git, but it might be nicer to start out with `ToGitUnlessBinaryToTextIsPresent` as it will make it possible to diff `tar` files for instance if this is configured with `.gitattributes` and `.git/config` accordingly.
If one wanted to do what Git does, I think one has to go with `ToWorktreeAndBinaryToText`.
created_at: 2025-03-16T03:00:34Z
author_association: MEMBER
diff_hunk: @@ -111,3 +114,194 @@ fn typed_location(mut location: BString, mode: EntryMode) -> BString {
     }
     location
 }
+
+pub fn file(
+    mut repo: gix::Repository,
+    out: &mut dyn std::io::Write,
+    old_treeish: BString,
+    new_treeish: BString,
+    path: BString,
+) -> Result<(), anyhow::Error> {
+    repo.object_cache_size_if_unset(repo.compute_object_cache_size_for_tree_diffs(&**repo.index_or_empty()?));
+    repo.objects.refresh = RefreshMode::Never;
+
+    let old_tree_id = repo.rev_parse_single(old_treeish.as_bstr())?;
+    let new_tree_id = repo.rev_parse_single(new_treeish.as_bstr())?;
+
+    let old_tree = old_tree_id.object()?.peel_to_tree()?;
+    let new_tree = new_tree_id.object()?.peel_to_tree()?;
+
+    let mut old_tree_buf = Vec::new();
+    let mut new_tree_buf = Vec::new();
+
+    use gix::diff::object::FindExt;
+
+    let old_tree_iter = repo.objects.find_tree_iter(&old_tree.id(), &mut old_tree_buf)?;
+    let new_tree_iter = repo.objects.find_tree_iter(&new_tree.id(), &mut new_tree_buf)?;
+
+    use gix::diff::tree::{
+        recorder::{self, Location},
+        Recorder,
+    };
+
+    struct FindChangeToPath {
+        inner: Recorder,
+        interesting_path: BString,
+        change: Option<recorder::Change>,
+    }
+
+    impl FindChangeToPath {
+        fn new(interesting_path: &BStr) -> Self {
+            let inner = Recorder::default().track_location(Some(Location::Path));
+
+            FindChangeToPath {
+                inner,
+                interesting_path: interesting_path.into(),
+                change: None,
+            }
+        }
+    }
+
+    use gix::diff::tree::{visit, Visit};
+
+    impl Visit for FindChangeToPath {
+        fn pop_front_tracked_path_and_set_current(&mut self) {
+            self.inner.pop_front_tracked_path_and_set_current();
+        }
+
+        fn push_back_tracked_path_component(&mut self, component: &BStr) {
+            self.inner.push_back_tracked_path_component(component);
+        }
+
+        fn push_path_component(&mut self, component: &BStr) {
+            self.inner.push_path_component(component);
+        }
+
+        fn pop_path_component(&mut self) {
+            self.inner.pop_path_component();
+        }
+
+        fn visit(&mut self, change: visit::Change) -> visit::Action {
+            if self.inner.path() == self.interesting_path {
+                self.change = Some(match change {
+                    visit::Change::Deletion {
+                        entry_mode,
+                        oid,
+                        relation,
+                    } => recorder::Change::Deletion {
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                        relation,
+                    },
+                    visit::Change::Addition {
+                        entry_mode,
+                        oid,
+                        relation,
+                    } => recorder::Change::Addition {
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                        relation,
+                    },
+                    visit::Change::Modification {
+                        previous_entry_mode,
+                        previous_oid,
+                        entry_mode,
+                        oid,
+                    } => recorder::Change::Modification {
+                        previous_entry_mode,
+                        previous_oid,
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                    },
+                });
+
+                visit::Action::Cancel
+            } else {
+                visit::Action::Continue
+            }
+        }
+    }
+
+    let mut recorder = FindChangeToPath::new(path.as_ref());
+    let state = gix::diff::tree::State::default();
+    let result = gix::diff::tree(old_tree_iter, new_tree_iter, state, &repo.objects, &mut recorder);
+
+    let change = match result {
+        Ok(_) | Err(gix::diff::tree::Error::Cancelled) => recorder.change,
+        Err(error) => return Err(error.into()),
+    };
+
+    let Some(change) = change else {
+        anyhow::bail!(
+            "There was no change to {} between {} and {}",
+            &path,
+            old_treeish,
+            new_treeish
+        )
+    };
+
+    let mut resource_cache = repo.diff_resource_cache(gix::diff::blob::pipeline::Mode::ToGit, Default::default())?;
path: gitoxide-core/src/repository/diff.rs
user: login: cruessler
body: The PR now uses `ToGitUnlessBinaryToTextIsPresent`.
created_at: 2025-03-17T08:31:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,7 +1,10 @@
-use gix::bstr::{BString, ByteSlice};
+use gix::bstr::{BStr, BString, ByteSlice};
+use gix::diff::blob::intern::TokenSource;
+use gix::diff::blob::UnifiedDiffBuilder;
path: gitoxide-core/src/repository/diff.rs
user: login: cruessler
body: The PR now uses `UnifiedDiff`.
created_at: 2025-03-17T08:32:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,3 +114,194 @@ fn typed_location(mut location: BString, mode: EntryMode) -> BString {
     }
     location
 }
+
+pub fn file(
+    mut repo: gix::Repository,
+    out: &mut dyn std::io::Write,
+    old_treeish: BString,
+    new_treeish: BString,
+    path: BString,
+) -> Result<(), anyhow::Error> {
+    repo.object_cache_size_if_unset(repo.compute_object_cache_size_for_tree_diffs(&**repo.index_or_empty()?));
+    repo.objects.refresh = RefreshMode::Never;
+
+    let old_tree_id = repo.rev_parse_single(old_treeish.as_bstr())?;
path: gitoxide-core/src/repository/diff.rs
user: login: cruessler
body: The PR now uses `rev_parse` because I want to call both `single` and `path_and_mode` on the result.
created_at: 2025-03-17T08:33:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,3 +114,194 @@ fn typed_location(mut location: BString, mode: EntryMode) -> BString {
     }
     location
 }
+
+pub fn file(
+    mut repo: gix::Repository,
+    out: &mut dyn std::io::Write,
+    old_treeish: BString,
+    new_treeish: BString,
+    path: BString,
+) -> Result<(), anyhow::Error> {
+    repo.object_cache_size_if_unset(repo.compute_object_cache_size_for_tree_diffs(&**repo.index_or_empty()?));
+    repo.objects.refresh = RefreshMode::Never;
+
+    let old_tree_id = repo.rev_parse_single(old_treeish.as_bstr())?;
+    let new_tree_id = repo.rev_parse_single(new_treeish.as_bstr())?;
+
+    let old_tree = old_tree_id.object()?.peel_to_tree()?;
+    let new_tree = new_tree_id.object()?.peel_to_tree()?;
+
+    let mut old_tree_buf = Vec::new();
+    let mut new_tree_buf = Vec::new();
+
+    use gix::diff::object::FindExt;
+
+    let old_tree_iter = repo.objects.find_tree_iter(&old_tree.id(), &mut old_tree_buf)?;
+    let new_tree_iter = repo.objects.find_tree_iter(&new_tree.id(), &mut new_tree_buf)?;
+
+    use gix::diff::tree::{
+        recorder::{self, Location},
+        Recorder,
+    };
+
+    struct FindChangeToPath {
+        inner: Recorder,
+        interesting_path: BString,
+        change: Option<recorder::Change>,
+    }
+
+    impl FindChangeToPath {
+        fn new(interesting_path: &BStr) -> Self {
+            let inner = Recorder::default().track_location(Some(Location::Path));
+
+            FindChangeToPath {
+                inner,
+                interesting_path: interesting_path.into(),
+                change: None,
+            }
+        }
+    }
+
+    use gix::diff::tree::{visit, Visit};
+
+    impl Visit for FindChangeToPath {
+        fn pop_front_tracked_path_and_set_current(&mut self) {
+            self.inner.pop_front_tracked_path_and_set_current();
+        }
+
+        fn push_back_tracked_path_component(&mut self, component: &BStr) {
+            self.inner.push_back_tracked_path_component(component);
+        }
+
+        fn push_path_component(&mut self, component: &BStr) {
+            self.inner.push_path_component(component);
+        }
+
+        fn pop_path_component(&mut self) {
+            self.inner.pop_path_component();
+        }
+
+        fn visit(&mut self, change: visit::Change) -> visit::Action {
+            if self.inner.path() == self.interesting_path {
+                self.change = Some(match change {
+                    visit::Change::Deletion {
+                        entry_mode,
+                        oid,
+                        relation,
+                    } => recorder::Change::Deletion {
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                        relation,
+                    },
+                    visit::Change::Addition {
+                        entry_mode,
+                        oid,
+                        relation,
+                    } => recorder::Change::Addition {
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                        relation,
+                    },
+                    visit::Change::Modification {
+                        previous_entry_mode,
+                        previous_oid,
+                        entry_mode,
+                        oid,
+                    } => recorder::Change::Modification {
+                        previous_entry_mode,
+                        previous_oid,
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                    },
+                });
+
+                visit::Action::Cancel
+            } else {
+                visit::Action::Continue
+            }
+        }
+    }
+
+    let mut recorder = FindChangeToPath::new(path.as_ref());
+    let state = gix::diff::tree::State::default();
+    let result = gix::diff::tree(old_tree_iter, new_tree_iter, state, &repo.objects, &mut recorder);
path: gitoxide-core/src/repository/diff.rs
user: login: cruessler
body: Switching to `rev_parse` allowed me to delete this code completely.
created_at: 2025-03-17T08:33:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,3 +114,194 @@ fn typed_location(mut location: BString, mode: EntryMode) -> BString {
     }
     location
 }
+
+pub fn file(
+    mut repo: gix::Repository,
+    out: &mut dyn std::io::Write,
+    old_treeish: BString,
+    new_treeish: BString,
+    path: BString,
+) -> Result<(), anyhow::Error> {
+    repo.object_cache_size_if_unset(repo.compute_object_cache_size_for_tree_diffs(&**repo.index_or_empty()?));
+    repo.objects.refresh = RefreshMode::Never;
+
+    let old_tree_id = repo.rev_parse_single(old_treeish.as_bstr())?;
+    let new_tree_id = repo.rev_parse_single(new_treeish.as_bstr())?;
+
+    let old_tree = old_tree_id.object()?.peel_to_tree()?;
+    let new_tree = new_tree_id.object()?.peel_to_tree()?;
+
+    let mut old_tree_buf = Vec::new();
+    let mut new_tree_buf = Vec::new();
+
+    use gix::diff::object::FindExt;
+
+    let old_tree_iter = repo.objects.find_tree_iter(&old_tree.id(), &mut old_tree_buf)?;
+    let new_tree_iter = repo.objects.find_tree_iter(&new_tree.id(), &mut new_tree_buf)?;
+
+    use gix::diff::tree::{
+        recorder::{self, Location},
+        Recorder,
+    };
+
+    struct FindChangeToPath {
+        inner: Recorder,
+        interesting_path: BString,
+        change: Option<recorder::Change>,
+    }
+
+    impl FindChangeToPath {
+        fn new(interesting_path: &BStr) -> Self {
+            let inner = Recorder::default().track_location(Some(Location::Path));
+
+            FindChangeToPath {
+                inner,
+                interesting_path: interesting_path.into(),
+                change: None,
+            }
+        }
+    }
+
+    use gix::diff::tree::{visit, Visit};
+
+    impl Visit for FindChangeToPath {
+        fn pop_front_tracked_path_and_set_current(&mut self) {
+            self.inner.pop_front_tracked_path_and_set_current();
+        }
+
+        fn push_back_tracked_path_component(&mut self, component: &BStr) {
+            self.inner.push_back_tracked_path_component(component);
+        }
+
+        fn push_path_component(&mut self, component: &BStr) {
+            self.inner.push_path_component(component);
+        }
+
+        fn pop_path_component(&mut self) {
+            self.inner.pop_path_component();
+        }
+
+        fn visit(&mut self, change: visit::Change) -> visit::Action {
+            if self.inner.path() == self.interesting_path {
+                self.change = Some(match change {
+                    visit::Change::Deletion {
+                        entry_mode,
+                        oid,
+                        relation,
+                    } => recorder::Change::Deletion {
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                        relation,
+                    },
+                    visit::Change::Addition {
+                        entry_mode,
+                        oid,
+                        relation,
+                    } => recorder::Change::Addition {
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                        relation,
+                    },
+                    visit::Change::Modification {
+                        previous_entry_mode,
+                        previous_oid,
+                        entry_mode,
+                        oid,
+                    } => recorder::Change::Modification {
+                        previous_entry_mode,
+                        previous_oid,
+                        entry_mode,
+                        oid,
+                        path: self.inner.path_clone(),
+                    },
+                });
+
+                visit::Action::Cancel
+            } else {
+                visit::Action::Continue
+            }
+        }
+    }
+
+    let mut recorder = FindChangeToPath::new(path.as_ref());
+    let state = gix::diff::tree::State::default();
+    let result = gix::diff::tree(old_tree_iter, new_tree_iter, state, &repo.objects, &mut recorder);
+
+    let change = match result {
+        Ok(_) | Err(gix::diff::tree::Error::Cancelled) => recorder.change,
+        Err(error) => return Err(error.into()),
+    };
+
+    let Some(change) = change else {
+        anyhow::bail!(
+            "There was no change to {} between {} and {}",
+            &path,
+            old_treeish,
+            new_treeish
+        )
+    };
+
+    let mut resource_cache = repo.diff_resource_cache(gix::diff::blob::pipeline::Mode::ToGit, Default::default())?;
+
+    let (previous_oid, oid) = match change {
+        recorder::Change::Addition { oid, .. } => {
+            // Setting `previous_oid` to `ObjectId::empty_blob` makes `diff` see an addition.
+            (ObjectId::empty_blob(gix::hash::Kind::Sha1), oid)
+        }
+        recorder::Change::Deletion { oid: previous_oid, .. } => {
+            // Setting `oid` to `ObjectId::empty_blob` makes `diff` see a deletion.
+            (previous_oid, ObjectId::empty_blob(gix::hash::Kind::Sha1))
+        }
+        recorder::Change::Modification { previous_oid, oid, .. } => (previous_oid, oid),
+    };
+
+    resource_cache.set_resource(
+        previous_oid,
+        gix::object::tree::EntryKind::Blob,
+        path.as_slice().into(),
+        gix::diff::blob::ResourceKind::OldOrSource,
+        &repo.objects,
+    )?;
+    resource_cache.set_resource(
+        oid,
+        gix::object::tree::EntryKind::Blob,
+        path.as_slice().into(),
+        gix::diff::blob::ResourceKind::NewOrDestination,
+        &repo.objects,
+    )?;
+
+    let outcome = resource_cache.prepare_diff()?;
+
+    let old_data = String::from_utf8_lossy(outcome.old.data.as_slice().unwrap_or_default());
path: gitoxide-core/src/repository/diff.rs
user: login: cruessler
body: I wasn’t happy with this either, from what I could tell, `UnifiedDiffBuilder` wasnt’t happy with `&[u8]` which is why I tried to circumvent that by using `&str` instead. `UnifiedDiff` does not have the same constraint, so this part of the code also has been removed.
created_at: 2025-03-17T08:35:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -111,3 +115,87 @@ fn typed_location(mut location: BString, mode: EntryMode) -> BString {
     }
     location
 }
+
+pub fn file(
+    mut repo: gix::Repository,
+    out: &mut dyn std::io::Write,
+    old_revspec: BString,
+    new_revspec: BString,
+) -> Result<(), anyhow::Error> {
+    repo.object_cache_size_if_unset(repo.compute_object_cache_size_for_tree_diffs(&**repo.index_or_empty()?));
+    repo.objects.refresh = RefreshMode::Never;
+
+    let old_resolved_revspec = repo.rev_parse(old_revspec.as_bstr())?;
+    let new_resolved_revspec = repo.rev_parse(new_revspec.as_bstr())?;
+
+    let old_blob_id = old_resolved_revspec
+        .single()
+        .context(format!("rev-spec '{old_revspec}' must resolve to a single object"))?;
+    let new_blob_id = new_resolved_revspec
+        .single()
+        .context(format!("rev-spec '{new_revspec}' must resolve to a single object"))?;
+
+    let (old_path, _) = old_resolved_revspec
+        .path_and_mode()
+        .context(format!("rev-spec '{old_revspec}' must contain a path"))?;
+    let (new_path, _) = new_resolved_revspec
+        .path_and_mode()
+        .context(format!("rev-spec '{new_revspec}' must contain a path"))?;
+
+    let mut resource_cache = repo.diff_resource_cache(
+        gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent,
+        Default::default(),
+    )?;
+
+    resource_cache.set_resource(
+        old_blob_id.into(),
+        gix::object::tree::EntryKind::Blob,
+        old_path,
+        gix::diff::blob::ResourceKind::OldOrSource,
+        &repo.objects,
+    )?;
+    resource_cache.set_resource(
+        new_blob_id.into(),
+        gix::object::tree::EntryKind::Blob,
+        new_path,
+        gix::diff::blob::ResourceKind::NewOrDestination,
+        &repo.objects,
+    )?;
+
+    let outcome = resource_cache.prepare_diff()?;
+
+    use gix::diff::blob::platform::prepare_diff::Operation;
+
+    let algorithm = match outcome.operation {
+        Operation::InternalDiff { algorithm } => algorithm,
+        Operation::ExternalCommand { .. } => {
+            // `unreachable!` is also used in [`Platform::lines()`](gix::object::blob::diff::Platform::lines()).
path: gitoxide-core/src/repository/diff.rs
user: login: Byron
body: ```suggestion
```
created_at: 2025-03-19T01:36:10Z
author_association: MEMBER
diff_hunk: @@ -514,6 +514,15 @@ pub mod diff {
             #[clap(value_parser = crate::shared::AsBString)]
             new_treeish: BString,
         },
+        /// Diff two versions of a file.
+        File {
+            /// A rev-spec representing the 'before' or old tree.
path: src/plumbing/options/mod.rs
user: login: Byron
body: ```suggestion
            /// A rev-spec representing the 'before' or old state of the file, like '@~100:file'
```
created_at: 2025-03-19T01:39:00Z
author_association: MEMBER
diff_hunk: @@ -514,6 +514,15 @@ pub mod diff {
             #[clap(value_parser = crate::shared::AsBString)]
             new_treeish: BString,
         },
+        /// Diff two versions of a file.
+        File {
+            /// A rev-spec representing the 'before' or old tree.
+            #[clap(value_parser = crate::shared::AsBString)]
+            old_revspec: BString,
+            /// A rev-spec representing the 'after' or new tree.
path: src/plumbing/options/mod.rs
user: login: Byron
body: ```suggestion
            /// A rev-spec representing the 'after' or new state of the file, like ':file'
```
created_at: 2025-03-19T01:39:30Z
author_association: MEMBER
diff_hunk: @@ -111,3 +115,86 @@ fn typed_location(mut location: BString, mode: EntryMode) -> BString {
     }
     location
 }
+
+pub fn file(
+    mut repo: gix::Repository,
+    out: &mut dyn std::io::Write,
+    old_revspec: BString,
+    new_revspec: BString,
+) -> Result<(), anyhow::Error> {
+    repo.object_cache_size_if_unset(repo.compute_object_cache_size_for_tree_diffs(&**repo.index_or_empty()?));
+    repo.objects.refresh = RefreshMode::Never;
+
+    let old_resolved_revspec = repo.rev_parse(old_revspec.as_bstr())?;
+    let new_resolved_revspec = repo.rev_parse(new_revspec.as_bstr())?;
+
+    let old_blob_id = old_resolved_revspec
+        .single()
+        .context(format!("rev-spec '{old_revspec}' must resolve to a single object"))?;
+    let new_blob_id = new_resolved_revspec
+        .single()
+        .context(format!("rev-spec '{new_revspec}' must resolve to a single object"))?;
+
+    let (old_path, _) = old_resolved_revspec
+        .path_and_mode()
+        .context(format!("rev-spec '{old_revspec}' must contain a path"))?;
+    let (new_path, _) = new_resolved_revspec
+        .path_and_mode()
+        .context(format!("rev-spec '{new_revspec}' must contain a path"))?;
+
+    let mut resource_cache = repo.diff_resource_cache(
+        gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent,
+        Default::default(),
path: gitoxide-core/src/repository/diff.rs
user: login: Byron
body: This should be worktree-roots - one must set the root of source and/or destinations if any of these have a null-id. Then it will be read from the worktree instead.
Please note that `set_resource()` probably still is a bit unintuitive, but it should suffice for this one-shot version of diffing files.
created_at: 2025-03-20T23:00:58Z
author_association: MEMBER

PR comments:
user: login: cruessler
created_at: 2025-03-13T16:50:51Z
body: @Byron Maybe you already noticed, but this PR now passes CI and is ready for review. :-)
author_association: CONTRIBUTOR
user: login: cruessler
created_at: 2025-03-16T18:35:13Z
body: > The highest-level change starts at the invocation. Here is what I wanted to do when trying it:
> 
> ```
> cargo run --bin gix -- diff file @~100:README.md README.md
> ```
> 
> The benefit of this notation, despite slightly repetitive, is that it is as 'for debug use' as it can get - there is no way someone would expect 'smart' features like rename-tracking, and I like that.

Seems like a good idea! Is there maybe already a parser somewhere for this specific syntax?
author_association: CONTRIBUTOR
user: login: cruessler
created_at: 2025-03-17T06:30:32Z
body: It looks like `gix::revision::plumbing::spec::parse` might be what I’m looking for!

https://github.com/cruessler/gitoxide/blob/6acca19163196f8402603cb031cb1d37f7178a43/gix-revision/src/spec/parse/function.rs#L16
author_association: CONTRIBUTOR
user: login: cruessler
created_at: 2025-03-17T06:57:35Z
body: There’s also `Repository::rev_parse_single` which is more high-level.

https://github.com/cruessler/gitoxide/blob/a160f87b183aaf91709e59c1103347030ae387ce/gix/src/repository/revision.rs#L29
author_association: CONTRIBUTOR
user: login: Byron
created_at: 2025-03-17T07:03:19Z
body: Yes, that one. And it is his already used here to parse the trees.
author_association: MEMBER
user: login: cruessler
created_at: 2025-03-17T08:31:30Z
body: Thanks a lot for your review! Changing the general approach allowed me to delete most of the code, making the PR much more lightweight.
author_association: CONTRIBUTOR
user: login: Byron
created_at: 2025-03-19T01:45:19Z
body: (I rebased onto `main` as there was a fix for flaky CI - didn't want to risk the CI run to fail)
author_association: MEMBER
user: login: cruessler
created_at: 2025-03-20T10:53:27Z
body: When I just tried your suggestion locally, it appeared that using `ObjectId::null(…)` is treated as “The file is not there” by `set_resource`. Am I missing something?
author_association: CONTRIBUTOR