add commit panel
This commit is contained in:
@@ -8,7 +8,7 @@ publish.workspace = true
|
||||
signed_core = { path = "../signed_core" }
|
||||
|
||||
nostr.workspace = true
|
||||
gix = { workspace = true, features = ["revision"] }
|
||||
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
||||
anyhow.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
||||
use gix::interrupt::IS_INTERRUPTED;
|
||||
use gix::progress::Discard;
|
||||
use signed_core::RepoAddr;
|
||||
@@ -153,6 +154,9 @@ pub struct FileCommit {
|
||||
pub id: String,
|
||||
/// First line of the commit message.
|
||||
pub summary: String,
|
||||
/// Rest of the commit message after the title; `None` when there is no
|
||||
/// body (single-line commit messages).
|
||||
pub description: Option<String>,
|
||||
/// Author name.
|
||||
pub author: String,
|
||||
/// Author time, seconds since the Unix epoch.
|
||||
@@ -243,6 +247,10 @@ fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
Ok(FileCommit {
|
||||
id: commit.id().shorten_or_id().to_string(),
|
||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
||||
description: message
|
||||
.body
|
||||
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
||||
.filter(|body| !body.is_empty()),
|
||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||
time: author.time()?.seconds,
|
||||
})
|
||||
@@ -385,6 +393,287 @@ pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
|
||||
all_commits(&open_with_cache(workdir)?)
|
||||
}
|
||||
|
||||
/// The kind of a [`DiffLine`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffLineKind {
|
||||
/// An unchanged context line, present on both sides.
|
||||
Context,
|
||||
/// A line added by the commit.
|
||||
Addition,
|
||||
/// A line removed by the commit.
|
||||
Deletion,
|
||||
}
|
||||
|
||||
/// One line of a file diff.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffLine {
|
||||
pub kind: DiffLineKind,
|
||||
/// 1-based line number in the old version, if the line exists there.
|
||||
pub old: Option<u32>,
|
||||
/// 1-based line number in the new version, if the line exists there.
|
||||
pub new: Option<u32>,
|
||||
/// Line content without the trailing newline.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A hunk of a file diff, like `@@ -a,b +c,d @@`, with the lines between the
|
||||
/// two headers (context around the change, then removals and additions).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffHunk {
|
||||
/// 1-based start line in the old version.
|
||||
pub old_start: u32,
|
||||
/// Number of old lines covered by the hunk.
|
||||
pub old_lines: u32,
|
||||
/// 1-based start line in the new version.
|
||||
pub new_start: u32,
|
||||
/// Number of new lines covered by the hunk.
|
||||
pub new_lines: u32,
|
||||
pub lines: Vec<DiffLine>,
|
||||
}
|
||||
|
||||
/// How a file changed in a commit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffStatus {
|
||||
Added,
|
||||
Modified,
|
||||
Deleted,
|
||||
Renamed,
|
||||
Copied,
|
||||
}
|
||||
|
||||
/// The diff of one file in a commit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileDiff {
|
||||
/// Path of the file relative to the repo root (the destination path for
|
||||
/// renames and copies).
|
||||
pub path: String,
|
||||
/// Previous path, for renames and copies.
|
||||
pub old_path: Option<String>,
|
||||
pub status: DiffStatus,
|
||||
/// Number of added lines; 0 for binary files.
|
||||
pub insertions: usize,
|
||||
/// Number of removed lines; 0 for binary files.
|
||||
pub deletions: usize,
|
||||
/// True if either version is binary (then `hunks` is empty).
|
||||
pub binary: bool,
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
}
|
||||
|
||||
/// The changes of one commit: every file it added, modified, deleted or
|
||||
/// renamed, with line-level hunks for text files.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommitDiff {
|
||||
pub files: Vec<FileDiff>,
|
||||
}
|
||||
|
||||
/// The changes of the commit `id` (short or full) in the repository at
|
||||
/// `workdir`, compared against its first parent (the empty tree for the root
|
||||
/// commit), like `git show`. Directory entries and submodules are skipped;
|
||||
/// their contents are reported as individual file changes. Files are sorted
|
||||
/// by path.
|
||||
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
|
||||
commit_diff(&open_with_cache(workdir)?, id)
|
||||
}
|
||||
|
||||
fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
|
||||
use gix::diff::blob::platform::prepare_diff::Operation;
|
||||
use gix::object::tree::diff::Change;
|
||||
use gix::objs::tree::EntryKind;
|
||||
|
||||
let commit_id = repo.rev_parse_single(id.as_bytes())?;
|
||||
let commit = commit_id.object()?.into_commit();
|
||||
let new_tree = commit.tree()?;
|
||||
let old_tree = match commit.parent_ids().next() {
|
||||
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let changes = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None)?;
|
||||
let mut cache = repo.diff_resource_cache_for_tree_diff()?;
|
||||
|
||||
let mut files = Vec::new();
|
||||
for change in changes {
|
||||
let attached = Change::from_change_ref(change.to_ref(), repo, repo);
|
||||
|
||||
// The tree diff also reports directory entries; only their contents
|
||||
// are listed, so skip trees and submodule gitlinks.
|
||||
let (path, old_path, status) = match attached {
|
||||
Change::Addition {
|
||||
location,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||
(location.to_owned(), None, DiffStatus::Added)
|
||||
}
|
||||
Change::Deletion {
|
||||
location,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||
(location.to_owned(), None, DiffStatus::Deleted)
|
||||
}
|
||||
Change::Modification {
|
||||
location,
|
||||
previous_entry_mode,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||
&& !matches!(
|
||||
previous_entry_mode.kind(),
|
||||
EntryKind::Tree | EntryKind::Commit
|
||||
) =>
|
||||
{
|
||||
(location.to_owned(), None, DiffStatus::Modified)
|
||||
}
|
||||
Change::Rewrite {
|
||||
location,
|
||||
source_location,
|
||||
source_entry_mode,
|
||||
entry_mode,
|
||||
copy,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||
&& !matches!(
|
||||
source_entry_mode.kind(),
|
||||
EntryKind::Tree | EntryKind::Commit
|
||||
) =>
|
||||
{
|
||||
let status = if copy {
|
||||
DiffStatus::Copied
|
||||
} else {
|
||||
DiffStatus::Renamed
|
||||
};
|
||||
(
|
||||
location.to_owned(),
|
||||
Some(source_location.to_owned()),
|
||||
status,
|
||||
)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Always diff with the built-in algorithm: external diff drivers
|
||||
// would shell out, which is out of scope for a read-only viewer.
|
||||
let platform = attached.diff(&mut cache)?;
|
||||
platform
|
||||
.resource_cache
|
||||
.options
|
||||
.skip_internal_diff_if_external_is_configured = true;
|
||||
let outcome = platform.resource_cache.prepare_diff()?;
|
||||
|
||||
let (binary, hunks, insertions, deletions) = match outcome.operation {
|
||||
Operation::InternalDiff { algorithm } => {
|
||||
let input = outcome.interned_input();
|
||||
let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input);
|
||||
|
||||
let mut hunks = Vec::new();
|
||||
let mut insertions = 0usize;
|
||||
let mut deletions = 0usize;
|
||||
let collector = HunkCollector {
|
||||
hunks: &mut hunks,
|
||||
insertions: &mut insertions,
|
||||
deletions: &mut deletions,
|
||||
};
|
||||
gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default())
|
||||
.consume()?;
|
||||
(false, hunks, insertions, deletions)
|
||||
}
|
||||
Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0),
|
||||
Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"),
|
||||
};
|
||||
|
||||
files.push(FileDiff {
|
||||
path: String::from_utf8_lossy(&path).into_owned(),
|
||||
old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()),
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
binary,
|
||||
hunks,
|
||||
});
|
||||
}
|
||||
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
Ok(CommitDiff { files })
|
||||
}
|
||||
|
||||
/// Collects the hunks of one blob diff while tracking per-line numbers.
|
||||
///
|
||||
/// The unified-diff headers give the 1-based start line of the hunk in each
|
||||
/// file; context lines advance both counters, removals only the old one and
|
||||
/// additions only the new one, so each line ends up with its real line
|
||||
/// numbers in both versions.
|
||||
struct HunkCollector<'a> {
|
||||
hunks: &'a mut Vec<DiffHunk>,
|
||||
insertions: &'a mut usize,
|
||||
deletions: &'a mut usize,
|
||||
}
|
||||
|
||||
impl ConsumeHunk for HunkCollector<'_> {
|
||||
type Out = ();
|
||||
|
||||
fn consume_hunk(
|
||||
&mut self,
|
||||
header: HunkHeader,
|
||||
lines: &[(GixLineKind, &[u8])],
|
||||
) -> std::io::Result<()> {
|
||||
let mut old_ln = header.before_hunk_start;
|
||||
let mut new_ln = header.after_hunk_start;
|
||||
let mut out = Vec::with_capacity(lines.len());
|
||||
|
||||
for (kind, content) in lines {
|
||||
let text = String::from_utf8_lossy(content).into_owned();
|
||||
let line = match kind {
|
||||
GixLineKind::Context => {
|
||||
let line = DiffLine {
|
||||
kind: DiffLineKind::Context,
|
||||
old: Some(old_ln),
|
||||
new: Some(new_ln),
|
||||
text,
|
||||
};
|
||||
old_ln += 1;
|
||||
new_ln += 1;
|
||||
line
|
||||
}
|
||||
GixLineKind::Remove => {
|
||||
*self.deletions += 1;
|
||||
let line = DiffLine {
|
||||
kind: DiffLineKind::Deletion,
|
||||
old: Some(old_ln),
|
||||
new: None,
|
||||
text,
|
||||
};
|
||||
old_ln += 1;
|
||||
line
|
||||
}
|
||||
GixLineKind::Add => {
|
||||
*self.insertions += 1;
|
||||
let line = DiffLine {
|
||||
kind: DiffLineKind::Addition,
|
||||
old: None,
|
||||
new: Some(new_ln),
|
||||
text,
|
||||
};
|
||||
new_ln += 1;
|
||||
line
|
||||
}
|
||||
};
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
self.hunks.push(DiffHunk {
|
||||
old_start: header.before_hunk_start,
|
||||
old_lines: header.before_hunk_len,
|
||||
new_start: header.after_hunk_start,
|
||||
new_lines: header.after_hunk_len,
|
||||
lines: out,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(self) {}
|
||||
}
|
||||
|
||||
/// The commit HEAD points to, like `git log -1`. Returns `Ok(None)` for a
|
||||
/// repository without commits yet (unborn HEAD).
|
||||
pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
||||
@@ -397,6 +686,10 @@ pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
||||
Ok(Some(FileCommit {
|
||||
id: commit.id().shorten_or_id().to_string(),
|
||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
||||
description: message
|
||||
.body
|
||||
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
||||
.filter(|body| !body.is_empty()),
|
||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||
time: author.time()?.seconds,
|
||||
}))
|
||||
@@ -929,4 +1222,161 @@ mod tests {
|
||||
.any(|p| p.to_string_lossy() == "b.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_diff_lists_added_modified_and_deleted_files() {
|
||||
let (dir, repo) = fixture(&[("keep.txt", b"keep"), ("mod.txt", b"one\ntwo\nthree\n")]);
|
||||
commit_all(&repo, "initial");
|
||||
|
||||
std::fs::write(dir.path().join("mod.txt"), b"one\ntwo!\nthree\n").expect("write");
|
||||
std::fs::write(dir.path().join("new.txt"), b"hello\n").expect("write");
|
||||
std::fs::remove_file(dir.path().join("keep.txt")).expect("remove");
|
||||
commit_all(&repo, "changes");
|
||||
|
||||
let head = repo.head_id().expect("head").shorten_or_id().to_string();
|
||||
let diff = worktree_commit_diff(dir.path(), &head).expect("diff");
|
||||
|
||||
let by_path: HashMap<&str, &FileDiff> = diff
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| (file.path.as_str(), file))
|
||||
.collect();
|
||||
assert_eq!(by_path.len(), 3);
|
||||
|
||||
let added = by_path["new.txt"];
|
||||
assert_eq!(added.status, DiffStatus::Added);
|
||||
assert_eq!(added.insertions, 1);
|
||||
assert_eq!(added.deletions, 0);
|
||||
assert_eq!(added.hunks.len(), 1);
|
||||
assert_eq!(added.hunks[0].lines.len(), 1);
|
||||
assert_eq!(added.hunks[0].lines[0].kind, DiffLineKind::Addition);
|
||||
assert_eq!(added.hunks[0].lines[0].old, None);
|
||||
assert_eq!(added.hunks[0].lines[0].new, Some(1));
|
||||
assert_eq!(added.hunks[0].lines[0].text, "hello");
|
||||
|
||||
let modified = by_path["mod.txt"];
|
||||
assert_eq!(modified.status, DiffStatus::Modified);
|
||||
assert_eq!(modified.insertions, 1);
|
||||
assert_eq!(modified.deletions, 1);
|
||||
assert!(!modified.binary);
|
||||
let lines = &modified.hunks[0].lines;
|
||||
// One hunk with context around the single-line change: the removed
|
||||
// line is old 2, the added line is new 2.
|
||||
assert!(lines.iter().any(|line| {
|
||||
line.kind == DiffLineKind::Deletion
|
||||
&& line.old == Some(2)
|
||||
&& line.new.is_none()
|
||||
&& line.text == "two"
|
||||
}));
|
||||
assert!(lines.iter().any(|line| {
|
||||
line.kind == DiffLineKind::Addition
|
||||
&& line.old.is_none()
|
||||
&& line.new == Some(2)
|
||||
&& line.text == "two!"
|
||||
}));
|
||||
assert!(lines.iter().any(|line| {
|
||||
line.kind == DiffLineKind::Context && line.old == Some(1) && line.new == Some(1)
|
||||
}));
|
||||
|
||||
let deleted = by_path["keep.txt"];
|
||||
assert_eq!(deleted.status, DiffStatus::Deleted);
|
||||
assert_eq!(deleted.deletions, 1);
|
||||
assert_eq!(deleted.hunks[0].lines[0].kind, DiffLineKind::Deletion);
|
||||
assert_eq!(deleted.hunks[0].lines[0].old, Some(1));
|
||||
assert_eq!(deleted.hunks[0].lines[0].new, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_diff_reports_binary_files_without_hunks() {
|
||||
let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]);
|
||||
commit_all(&repo, "initial");
|
||||
|
||||
std::fs::write(_dir.path().join("blob.bin"), b"\x00\x03").expect("write");
|
||||
commit_all(&repo, "binary change");
|
||||
|
||||
let head = repo.head_id().expect("head").shorten_or_id().to_string();
|
||||
let diff = worktree_commit_diff(_dir.path(), &head).expect("diff");
|
||||
let file = diff
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.path == "blob.bin")
|
||||
.expect("file");
|
||||
assert!(file.binary);
|
||||
assert!(file.hunks.is_empty());
|
||||
assert_eq!(file.insertions, 0);
|
||||
assert_eq!(file.deletions, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_diff_resolves_short_ids_and_root_commit() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one\n")]);
|
||||
commit_all(&repo, "initial");
|
||||
|
||||
// The root commit diffs against the empty tree: everything is added.
|
||||
let head = repo.head_id().expect("head").shorten_or_id().to_string();
|
||||
let diff = worktree_commit_diff(dir.path(), &head).expect("diff");
|
||||
assert_eq!(diff.files.len(), 1);
|
||||
assert_eq!(diff.files[0].path, "a.txt");
|
||||
assert_eq!(diff.files[0].status, DiffStatus::Added);
|
||||
assert_eq!(diff.files[0].insertions, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_commit_includes_message_body() {
|
||||
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "title");
|
||||
|
||||
// A single-line message has no body.
|
||||
let head = head_commit(&repo).expect("head").expect("commit");
|
||||
assert_eq!(head.summary, "title");
|
||||
assert_eq!(head.description, None);
|
||||
|
||||
// A message with a body exposes it, trimmed.
|
||||
let dir = _dir.path();
|
||||
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([
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"title two",
|
||||
"-m",
|
||||
"line one\n\nline two",
|
||||
])
|
||||
.status()
|
||||
.expect("spawn git");
|
||||
assert!(status.success(), "git commit failed");
|
||||
|
||||
let head = head_commit(&repo).expect("head").expect("commit");
|
||||
assert_eq!(head.summary, "title two");
|
||||
assert_eq!(head.description.as_deref(), Some("line one\n\nline two"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_diff_reports_renames() {
|
||||
let (_dir, repo) = fixture(&[("old.txt", b"same content\n")]);
|
||||
commit_all(&repo, "initial");
|
||||
|
||||
std::fs::rename(_dir.path().join("old.txt"), _dir.path().join("new.txt")).expect("rename");
|
||||
commit_all(&repo, "rename");
|
||||
|
||||
let head = repo.head_id().expect("head").shorten_or_id().to_string();
|
||||
let diff = worktree_commit_diff(_dir.path(), &head).expect("diff");
|
||||
let file = diff
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.path == "new.txt")
|
||||
.expect("file");
|
||||
assert_eq!(file.status, DiffStatus::Renamed);
|
||||
assert_eq!(file.old_path.as_deref(), Some("old.txt"));
|
||||
// A pure rename has no content change; the file is still listed.
|
||||
assert!(file.hunks.is_empty());
|
||||
assert_eq!(file.insertions, 0);
|
||||
assert_eq!(file.deletions, 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user