This commit is contained in:
2026-08-11 13:30:09 +07:00
parent e77cfe29d9
commit 6f381c68c3
8 changed files with 261 additions and 9 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ publish.workspace = true
signed_core = { path = "../signed_core" }
nostr.workspace = true
gix.workspace = true
gix = { workspace = true, features = ["revision"] }
anyhow.workspace = true
[dev-dependencies]
+151
View File
@@ -140,6 +140,19 @@ fn sanitize_path_component(id: &str) -> String {
sanitized
}
/// Metadata of a commit, as shown in the repository browser's file header.
#[derive(Debug, Clone)]
pub struct FileCommit {
/// Shortened commit id (7+ hex chars, disambiguated if needed).
pub id: String,
/// First line of the commit message.
pub summary: String,
/// Author name.
pub author: String,
/// Author time, seconds since the Unix epoch.
pub time: i64,
}
/// Relative paths of all entries in the worktree (files and directories),
/// directories first, then alphabetically within each group. The `.git`
/// directory is skipped.
@@ -209,6 +222,57 @@ pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
}
/// Find the most recent commit that changed `rel` (a path relative to the
/// worktree), like `git log -1 -- <rel>` does for non-merge commits.
///
/// Walks history from `HEAD` newest-first and returns the first commit whose
/// tree entry for `rel` differs from its first parent's; a merge that only
/// changed the file through its second parent is therefore not reported.
/// Returns `Ok(None)` if no commit touched the file (e.g. untracked files).
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
use gix::traverse::commit::simple::CommitTimeOrder;
let head = repo.head_id()?;
let walk = repo
.rev_walk([head])
.sorting(gix::revision::walk::Sorting::ByCommitTime(
CommitTimeOrder::NewestFirst,
));
for info in walk.all()? {
let info = info?;
let commit = info.object()?;
let blob = commit.tree()?.lookup_entry_by_path(rel)?;
let parent_blob = match info.parent_ids().next() {
Some(parent) => parent
.object()?
.into_commit()
.tree()?
.lookup_entry_by_path(rel)?,
None => None,
};
if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) {
let author = commit.author()?;
let message = commit.message()?;
return Ok(Some(FileCommit {
id: commit.id().shorten_or_id().to_string(),
summary: String::from_utf8_lossy(message.title).trim().to_string(),
author: String::from_utf8_lossy(author.name).trim().to_string(),
time: author.time()?.seconds,
}));
}
}
Ok(None)
}
/// Like [`last_commit`], but opens the repository located at `workdir`
/// (for non-bare clones the clone root is the worktree) first.
pub fn worktree_last_commit(workdir: &Path, rel: &Path) -> Result<Option<FileCommit>> {
last_commit(&gix::open(workdir)?, rel)
}
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
@@ -330,6 +394,93 @@ mod tests {
);
}
/// Stage everything and create a commit with the git CLI (like
/// [`apply_patch`], the crate already shells out to the CLI).
fn commit_all(repo: &gix::Repository, message: &str) {
let dir = repo.workdir().expect("workdir");
let run = |args: &[&str]| {
let status = Command::new("git")
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "Test Author")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test Author")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env("GIT_EDITOR", "true")
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
};
run(&["add", "-A"]);
run(&["commit", "-m", message]);
}
#[test]
fn last_commit_returns_most_recent_change() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
std::fs::write(dir.path().join("a.txt"), b"two").expect("write");
commit_all(&repo, "change a");
// A commit touching another file must not be reported for a.txt.
std::fs::write(dir.path().join("b.txt"), b"other").expect("write");
commit_all(&repo, "add b");
let commit = last_commit(&repo, Path::new("a.txt"))
.expect("lookup")
.expect("found");
assert_eq!(commit.summary, "change a");
assert_eq!(commit.author, "Test Author");
assert!(!commit.id.is_empty());
assert!(commit.time > 0);
}
#[test]
fn last_commit_returns_none_for_untracked_files() {
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
std::fs::write(dir.path().join("untracked.txt"), b"x").expect("write");
let commit = last_commit(&repo, Path::new("untracked.txt")).expect("lookup");
assert!(commit.is_none());
}
#[test]
fn last_commit_reports_merge_commits() {
let (dir, repo) = fixture(&[("a.txt", b"base")]);
commit_all(&repo, "initial");
let run = |args: &[&str]| {
let status = Command::new("git")
.current_dir(dir.path())
.env("GIT_AUTHOR_NAME", "Test Author")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test Author")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env("GIT_EDITOR", "true")
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
};
run(&["checkout", "-b", "feature"]);
std::fs::write(dir.path().join("a.txt"), b"feature").expect("write");
commit_all(&repo, "feature change");
run(&["checkout", "-"]);
// --no-ff forces a merge commit; it is the latest commit changing a.txt.
run(&["merge", "--no-ff", "--no-edit", "feature"]);
let commit = last_commit(&repo, Path::new("a.txt"))
.expect("lookup")
.expect("found");
assert_eq!(
commit.id,
repo.head_id().expect("head").shorten_or_id().to_string()
);
assert!(commit.summary.starts_with("Merge branch"));
}
#[test]
fn find_readme_prefers_markdown() {
let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]);