Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ This repository contains `gitoxide` - a pure Rust implementation of Git. This do

## Development Practices

### AI Agent Communication

- AI agents communicating through a person's account must identify themselves, for example in issue or PR descriptions and comments.
- AI assistance that does not replace the person as the speaker, such as proofreading or wording polish, does not require identification.
- Attributing AI assistance in commit metadata, for example with an `Assisted-by:` or `Co-authored-by:` trailer, is welcome but not required.

### Test-First Development

- Protect against regression and make implementing features easy
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ AI agents communicating through a person's account must identify themselves, for
PR descriptions and comments. AI assistance that does not replace the person as the speaker, such as
proofreading or wording polish, does not require identification.

Attributing AI assistance in commit metadata, for example with a `Co-authored-by` trailer, is welcome but not
required. Code is reviewed the same way regardless of its origin.
Attributing AI assistance in commit metadata, for example with an `Assisted-by:` or `Co-authored-by:` trailer,
is welcome but not required.

For everything else, please have a look at the respective section in the [README] file.

Expand Down
94 changes: 82 additions & 12 deletions gitoxide-core/src/repository/blame.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::ffi::OsStr;

use gix::{bstr::BStr, config::tree};
use anyhow::Context;
use gix::{blame::Start, bstr::BStr, config::tree, utils::AsBStr};

pub fn blame_file(
mut repo: gix::Repository,
Expand All @@ -21,17 +22,23 @@ pub fn blame_file(
let file = gix::path::os_str_into_bstr(file)?;
let file = repo.normalize_path(file)?;

let suspect: gix::ObjectId = repo.head()?.into_peeled_id()?.into();
let cache: Option<gix::commitgraph::Graph> = repo.commit_graph_if_enabled()?;
let mut resource_cache = repo.diff_resource_cache_for_tree_diff()?;
let outcome = gix::blame::file(
&repo.objects,
suspect,
cache,
&mut resource_cache,
file.as_ref(),
options,
let mut resource_cache = repo.diff_resource_cache(
// TODO(blame): Git uses something akin to `ToGitUnlessBinaryToTextIsPresent` here, but with a specialty: textconv output is converted
// to Git, which isn't happening for normal diffing. In theory, this shouldn't be a problem as it's always apples to apples,
// at least in theory.
gix::diff::blob::pipeline::Mode::ToGit,
gix::diff::blob::pipeline::WorktreeRoots {
old_root: repo.workdir().map(ToOwned::to_owned),
new_root: None,
},
)?;
let start = start_for_blame(&repo, file.as_bstr(), &mut resource_cache)?;
// The worktree root is only for constructing `start`; historical `OldOrSource` resources must be loaded by
// object ID, and we make sure that worktree contents can't possibly be used.
resource_cache.filter.roots = Default::default();
resource_cache.clear_resource_cache_keep_allocation();
let outcome = gix::blame::file(&repo.objects, start, cache, &mut resource_cache, file.as_ref(), options)?;
let statistics = outcome.statistics;
show_blame_entries(out, outcome, file.as_ref())?;

Expand All @@ -41,11 +48,70 @@ pub fn blame_file(
Ok(())
}

/// Start at `HEAD`, overlaying diffable worktree contents so uncommitted changes are included.
/// Missing or binary worktree files fall back to blaming `HEAD` directly.
fn start_for_blame<'a>(
repo: &'a gix::Repository,
file: &'a gix::bstr::BStr,
resources: &mut gix::diff::blob::Platform,
) -> anyhow::Result<gix::blame::Start<'a>> {
let first_suspect: gix::ObjectId = repo.head()?.into_peeled_id()?.into();
let Some(workdir) = repo.workdir() else {
return Ok(Start::Commit(first_suspect));
};
let path = workdir.join(gix::path::from_bstr(file));
let metadata = match std::fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(err) if gix::fs::io_err::is_not_found(err.kind(), err.raw_os_error()) => {
return Ok(Start::Commit(first_suspect));
}
Err(err) => return Err(err).with_context(|| format!("Could not read metadata of '{}'", path.display())),
};
// State the correct type here so that the resource cache and its possibly converted bytes match the actual type, i.e.
// - read the file for blobs
// - read the symlink bytes themselves, the target path for symlinks.
let entry_kind = if metadata.file_type().is_symlink() {
gix::objs::tree::EntryKind::Link
} else {
// executable bits don't matter.
gix::objs::tree::EntryKind::Blob
};
resources.set_resource(
repo.object_hash().null(),
entry_kind,
file,
gix::diff::blob::ResourceKind::OldOrSource,
&repo.objects,
)?;
let contents = resources
.resource(gix::diff::blob::ResourceKind::OldOrSource)
.and_then(|resource| match resource.data {
gix::diff::blob::platform::resource::Data::Buffer { buf, .. } => Some(buf.to_owned()),
gix::diff::blob::platform::resource::Data::Binary { .. }
| gix::diff::blob::platform::resource::Data::Missing => None,
});

Ok(contents
.map(|contents| Start::Contents {
first_suspect,
contents: contents.into(),
})
.unwrap_or(Start::Commit(first_suspect)))
}

fn show_blame_entries(
mut out: impl std::io::Write,
outcome: gix::blame::Outcome,
source_file_name: &BStr,
) -> Result<(), std::io::Error> {
let num_digits_for_line_number = {
let largest_line_number = outcome
.entries
.last()
.map_or(0, |entry| entry.range_in_blamed_file().end);
(largest_line_number.checked_ilog10().unwrap_or(0) + 1) as usize
};

for (entry, lines_in_hunk) in outcome.entries_with_lines() {
for ((actual_lno, source_lno), line) in entry
.range_in_blamed_file()
Expand All @@ -54,15 +120,19 @@ fn show_blame_entries(
{
write!(
out,
"{short_id} {line_no} ",
"{short_id} {line_no:>num_digits_for_line_number$} ",
short_id = entry.commit_id.to_hex_with_len(8),
line_no = actual_lno + 1,
)?;

let source_file_name = entry.source_file_name.as_ref().map_or(source_file_name, BStr::new);
write!(out, "{source_file_name} ")?;

write!(out, "{src_line_no} {line}", src_line_no = source_lno + 1)?;
write!(
out,
"{src_line_no:>num_digits_for_line_number$} {line}",
src_line_no = source_lno + 1
)?;
}
}

Expand Down
Loading
Loading