Skip to content

Commit f02b093

Browse files
cruesslerStephan Dilly
authored and
Stephan Dilly
committed
Add blame view
This closes #484.
1 parent 7fd7347 commit f02b093

File tree

14 files changed

+716
-73
lines changed

14 files changed

+716
-73
lines changed

asyncgit/src/lib.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ pub enum AsyncNotification {
7777
Fetch,
7878
}
7979

80-
/// current working director `./`
80+
/// current working directory `./`
8181
pub static CWD: &str = "./";
8282

8383
/// helper function to calculate the hash of an arbitrary type that implements the `Hash` trait

asyncgit/src/sync/blame.rs

+189
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
//! Sync git API for fetching a file blame
2+
3+
use super::{utils, CommitId};
4+
use crate::{error::Result, sync::get_commit_info};
5+
use std::io::{BufRead, BufReader};
6+
use std::path::Path;
7+
8+
/// A `BlameHunk` contains all the information that will be shown to the user.
9+
#[derive(Clone, Hash, Debug, PartialEq, Eq)]
10+
pub struct BlameHunk {
11+
///
12+
pub commit_id: CommitId,
13+
///
14+
pub author: String,
15+
///
16+
pub time: i64,
17+
/// `git2::BlameHunk::final_start_line` returns 1-based indices, but
18+
/// `start_line` is 0-based because the `Vec` storing the lines starts at
19+
/// index 0.
20+
pub start_line: usize,
21+
///
22+
pub end_line: usize,
23+
}
24+
25+
/// A `BlameFile` represents as a collection of hunks. This resembles `git2`’s
26+
/// API.
27+
#[derive(Default, Clone, Debug)]
28+
pub struct FileBlame {
29+
///
30+
pub path: String,
31+
///
32+
pub lines: Vec<(Option<BlameHunk>, String)>,
33+
}
34+
35+
///
36+
pub fn blame_file(
37+
repo_path: &str,
38+
file_path: &str,
39+
commit_id: &str,
40+
) -> Result<FileBlame> {
41+
let repo = utils::repo(repo_path)?;
42+
43+
let spec = format!("{}:{}", commit_id, file_path);
44+
let blame = repo.blame_file(Path::new(file_path), None)?;
45+
let object = repo.revparse_single(&spec)?;
46+
let blob = repo.find_blob(object.id())?;
47+
let reader = BufReader::new(blob.content());
48+
49+
let lines: Vec<(Option<BlameHunk>, String)> = reader
50+
.lines()
51+
.enumerate()
52+
.map(|(i, line)| {
53+
// Line indices in a `FileBlame` are 1-based.
54+
let corresponding_hunk = blame.get_line(i + 1);
55+
56+
if let Some(hunk) = corresponding_hunk {
57+
let commit_id = CommitId::new(hunk.final_commit_id());
58+
// Line indices in a `BlameHunk` are 1-based.
59+
let start_line =
60+
hunk.final_start_line().saturating_sub(1);
61+
let end_line =
62+
start_line.saturating_add(hunk.lines_in_hunk());
63+
64+
if let Ok(commit_info) =
65+
get_commit_info(repo_path, &commit_id)
66+
{
67+
let hunk = BlameHunk {
68+
commit_id,
69+
author: commit_info.author.clone(),
70+
time: commit_info.time,
71+
start_line,
72+
end_line,
73+
};
74+
75+
return (
76+
Some(hunk),
77+
line.unwrap_or_else(|_| "".into()),
78+
);
79+
}
80+
}
81+
82+
(None, line.unwrap_or_else(|_| "".into()))
83+
})
84+
.collect();
85+
86+
let file_blame = FileBlame {
87+
path: file_path.into(),
88+
lines,
89+
};
90+
91+
Ok(file_blame)
92+
}
93+
94+
#[cfg(test)]
95+
mod tests {
96+
use crate::error::Result;
97+
use crate::sync::{
98+
blame_file, commit, stage_add_file, tests::repo_init_empty,
99+
BlameHunk,
100+
};
101+
use std::{
102+
fs::{File, OpenOptions},
103+
io::Write,
104+
path::Path,
105+
};
106+
107+
#[test]
108+
fn test_blame() -> Result<()> {
109+
let file_path = Path::new("foo");
110+
let (_td, repo) = repo_init_empty()?;
111+
let root = repo.path().parent().unwrap();
112+
let repo_path = root.as_os_str().to_str().unwrap();
113+
114+
assert!(matches!(
115+
blame_file(&repo_path, "foo", "HEAD"),
116+
Err(_)
117+
));
118+
119+
File::create(&root.join(file_path))?
120+
.write_all(b"line 1\n")?;
121+
122+
stage_add_file(repo_path, file_path)?;
123+
commit(repo_path, "first commit")?;
124+
125+
let blame = blame_file(&repo_path, "foo", "HEAD")?;
126+
127+
assert!(matches!(
128+
blame.lines.as_slice(),
129+
[(
130+
Some(BlameHunk {
131+
author,
132+
start_line: 0,
133+
end_line: 1,
134+
..
135+
}),
136+
line
137+
)] if author == "name" && line == "line 1"
138+
));
139+
140+
let mut file = OpenOptions::new()
141+
.append(true)
142+
.open(&root.join(file_path))?;
143+
144+
file.write(b"line 2\n")?;
145+
146+
stage_add_file(repo_path, file_path)?;
147+
commit(repo_path, "second commit")?;
148+
149+
let blame = blame_file(&repo_path, "foo", "HEAD")?;
150+
151+
assert!(matches!(
152+
blame.lines.as_slice(),
153+
[
154+
(
155+
Some(BlameHunk {
156+
start_line: 0,
157+
end_line: 1,
158+
..
159+
}),
160+
first_line
161+
),
162+
(
163+
Some(BlameHunk {
164+
author,
165+
start_line: 1,
166+
end_line: 2,
167+
..
168+
}),
169+
second_line
170+
)
171+
] if author == "name" && first_line == "line 1" && second_line == "line 2"
172+
));
173+
174+
file.write(b"line 3\n")?;
175+
176+
let blame = blame_file(&repo_path, "foo", "HEAD")?;
177+
178+
assert_eq!(blame.lines.len(), 2);
179+
180+
stage_add_file(repo_path, file_path)?;
181+
commit(repo_path, "third commit")?;
182+
183+
let blame = blame_file(&repo_path, "foo", "HEAD")?;
184+
185+
assert_eq!(blame.lines.len(), 3);
186+
187+
Ok(())
188+
}
189+
}

asyncgit/src/sync/commits_info.rs

+20
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,26 @@ pub fn get_commits_info(
9595
Ok(res)
9696
}
9797

98+
///
99+
pub fn get_commit_info(
100+
repo_path: &str,
101+
commit_id: &CommitId,
102+
) -> Result<CommitInfo> {
103+
scope_time!("get_commit_info");
104+
105+
let repo = repo(repo_path)?;
106+
107+
let commit = repo.find_commit((*commit_id).into())?;
108+
let author = commit.author();
109+
110+
Ok(CommitInfo {
111+
message: commit.message().unwrap_or("").into(),
112+
author: author.name().unwrap_or("<unknown>").into(),
113+
time: commit.time().seconds(),
114+
id: CommitId(commit.id()),
115+
})
116+
}
117+
98118
///
99119
pub fn get_message(
100120
c: &Commit,

asyncgit/src/sync/mod.rs

+5-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//TODO: remove once we have this activated on the toplevel
44
#![deny(clippy::expect_used)]
55

6+
pub mod blame;
67
pub mod branch;
78
mod commit;
89
mod commit_details;
@@ -24,6 +25,7 @@ pub mod status;
2425
mod tags;
2526
pub mod utils;
2627

28+
pub use blame::{blame_file, BlameHunk, FileBlame};
2729
pub use branch::{
2830
branch_compare_upstream, checkout_branch, config_is_pull_rebase,
2931
create_branch, delete_branch, get_branch_remote,
@@ -37,7 +39,9 @@ pub use commit_details::{
3739
get_commit_details, CommitDetails, CommitMessage,
3840
};
3941
pub use commit_files::get_commit_files;
40-
pub use commits_info::{get_commits_info, CommitId, CommitInfo};
42+
pub use commits_info::{
43+
get_commit_info, get_commits_info, CommitId, CommitInfo,
44+
};
4145
pub use diff::get_diff_commit;
4246
pub use hooks::{
4347
hooks_commit_msg, hooks_post_commit, hooks_pre_commit, HookResult,

0 commit comments

Comments
 (0)