Skip to content

Commit a2f66de

Browse files
committed
feat(cli): blame: add --ignore-rev/--ignore-revs-file (#2064)
- Parse multiple --ignore-rev and --ignore-revs-file - Repo-root semantics for relative paths - Clear errors for non-commit/invalid/ambiguous revs - Integrates with gix_blame::Options::with_ignored_revisions() AI-Assisted: tool=Claude Code; agent-mode=no; multiline-completions=yes
1 parent 9700b2a commit a2f66de

File tree

3 files changed

+133
-0
lines changed

3 files changed

+133
-0
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
use bstr::{BString, ByteSlice};
2+
use gix_url::{parse, Url};
3+
#[test]
4+
fn scp_like_keeps_username_literal_no_percent_encoding() {
5+
let cases = [
6+
("[email protected]:org/repo.git", "john+doe"),
7+
("foo%bar@host:repo/path", "foo%bar"),
8+
("user.name@host:repo", "user.name"),
9+
("u_ser-123@host:org/repo", "u_ser-123"),
10+
];
11+
12+
for (input, expected_user) in cases {
13+
let url: Url = parse(input.as_bytes().as_bstr()).expect("parse scp-like");
14+
assert_eq!(url.user(), Some(expected_user), "user() changed for {input}");
15+
let round = url.to_bstring();
16+
assert_eq!(round, BString::from(input), "roundtrip mismatch for {input}");
17+
}
18+
}
19+
20+
#[test]
21+
fn ssh_scheme_behavior_unchanged() {
22+
let input = "ssh://[email protected]/org/repo.git";
23+
let url = gix_url::parse(input.as_bytes().as_bstr()).expect("parse ssh://");
24+
assert_eq!(
25+
url.to_bstring().as_bstr(),
26+
input.as_bytes().as_bstr(),
27+
"ssh:// round-trip changed (should remain consistent)"
28+
);
29+
}

src/plumbing/main.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,92 @@ pub mod async_util {
5454
}
5555
}
5656

57+
/// Parse ignore revisions from command line arguments and files.
58+
/// Returns a HashSet of ObjectIds to ignore during blame attribution.
59+
fn parse_ignore_revisions(
60+
repo: &gix::Repository,
61+
ignore_rev: &[String],
62+
ignore_revs_files: &[std::path::PathBuf],
63+
) -> anyhow::Result<Option<std::collections::HashSet<gix::hash::ObjectId>>> {
64+
let mut ignore_set = std::collections::HashSet::new();
65+
66+
// Helper function to resolve and validate a revision to a commit ObjectId
67+
let resolve_to_commit = |rev_str: &str,
68+
context: Option<(&std::path::Path, usize)>|
69+
-> anyhow::Result<gix::hash::ObjectId> {
70+
let oid = repo.rev_parse_single(rev_str).map_err(|err| {
71+
// Check for ambiguity error and provide exact message format
72+
let err_str = err.to_string();
73+
if err_str.contains("ambiguous") || err_str.contains("Ambiguous") {
74+
anyhow!("error: revision '{}' is ambiguous; please use a longer SHA", rev_str)
75+
} else {
76+
match context {
77+
Some((path, line)) => anyhow!(
78+
"Invalid revision '{}' at line {} in file '{}': revision not found",
79+
rev_str,
80+
line,
81+
path.display()
82+
),
83+
None => anyhow!("Invalid revision '{}': revision not found", rev_str),
84+
}
85+
}
86+
})?;
87+
88+
// Peel to commit if it's a tag or other object
89+
let peeled = repo.find_object(oid)
90+
.with_context(|| "Failed to find object")?
91+
.peel_to_kind(gix::object::Kind::Commit)
92+
.with_context(|| match context {
93+
Some((path, line)) => format!("Revision '{}' at line {} in file '{}' is not a commit (hint: use a commit SHA or tag that points to a commit)", rev_str, line, path.display()),
94+
None => format!("Revision '{}' is not a commit (hint: use a commit SHA or tag that points to a commit)", rev_str),
95+
})?;
96+
97+
Ok(peeled.id)
98+
};
99+
100+
// Parse individual revision arguments
101+
for rev_str in ignore_rev {
102+
let commit_oid = resolve_to_commit(rev_str, None)?;
103+
ignore_set.insert(commit_oid);
104+
}
105+
106+
// Parse ignore-revs-files if provided
107+
for file_path in ignore_revs_files {
108+
// Resolve relative paths from repo root, not CWD
109+
let resolved_path = if file_path.is_absolute() {
110+
file_path.clone()
111+
} else {
112+
let repo_root = repo.workdir().unwrap_or_else(|| repo.git_dir());
113+
repo_root.join(file_path)
114+
};
115+
116+
let file_content = std::fs::read_to_string(&resolved_path)
117+
.with_context(|| format!("Failed to read ignore-revs file: {}", file_path.display()))?;
118+
119+
// Strip UTF-8 BOM if present
120+
let content = file_content.strip_prefix('\u{feff}').unwrap_or(&file_content);
121+
122+
for (line_num, line) in content.lines().enumerate() {
123+
// Handle both \n and \r\n line endings by trimming \r
124+
let line = line.trim_end_matches('\r').trim();
125+
126+
// Skip blank lines and comments (including indented comments)
127+
if line.is_empty() || line.starts_with('#') {
128+
continue;
129+
}
130+
131+
let commit_oid = resolve_to_commit(line, Some((&resolved_path, line_num + 1)))?;
132+
ignore_set.insert(commit_oid);
133+
}
134+
}
135+
136+
if ignore_set.is_empty() {
137+
Ok(None)
138+
} else {
139+
Ok(Some(ignore_set))
140+
}
141+
}
142+
57143
pub fn main() -> Result<()> {
58144
let args: Args = Args::parse_from(gix::env::args_os());
59145
let thread_limit = args.threads;
@@ -1589,6 +1675,8 @@ pub fn main() -> Result<()> {
15891675
file,
15901676
ranges,
15911677
since,
1678+
ignore_rev,
1679+
ignore_revs_file,
15921680
} => prepare_and_run(
15931681
"blame",
15941682
trace,
@@ -1600,6 +1688,9 @@ pub fn main() -> Result<()> {
16001688
let repo = repository(Mode::Lenient)?;
16011689
let diff_algorithm = repo.diff_algorithm()?;
16021690

1691+
// Parse ignore revisions
1692+
let ignored_revs = parse_ignore_revisions(&repo, &ignore_rev, &ignore_revs_file)?;
1693+
16031694
core::repository::blame::blame_file(
16041695
repo,
16051696
&file,
@@ -1610,6 +1701,9 @@ pub fn main() -> Result<()> {
16101701
opts.since = since;
16111702
opts.rewrites = Some(gix::diff::Rewrites::default());
16121703
opts.debug_track_path = false;
1704+
if let Some(ignored_revs) = ignored_revs {
1705+
opts = opts.with_ignored_revisions(ignored_revs);
1706+
}
16131707
opts
16141708
},
16151709
out,

src/plumbing/options/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ pub enum Subcommands {
164164
#[clap(subcommand)]
165165
Free(free::Subcommands),
166166
/// Blame lines in a file.
167+
///
168+
/// Examples:
169+
/// gix blame --ignore-rev 94feb0c5 f.txt
170+
/// gix blame --ignore-revs-file .git-blame-ignore-revs src/lib.rs
167171
Blame {
168172
/// Print additional statistics to help understanding performance.
169173
#[clap(long, short = 's')]
@@ -176,6 +180,12 @@ pub enum Subcommands {
176180
/// Don't consider commits before the given date.
177181
#[clap(long, value_parser=AsTime, value_name = "DATE")]
178182
since: Option<gix::date::Time>,
183+
/// Ignore the specified revision during blame attribution. Can be specified multiple times.
184+
#[clap(long, action=clap::ArgAction::Append)]
185+
ignore_rev: Vec<String>,
186+
/// Read ignore revisions from the specified file, one per line. Paths are resolved relative to the repository root. May be passed multiple times.
187+
#[clap(long, action=clap::ArgAction::Append)]
188+
ignore_revs_file: Vec<std::path::PathBuf>,
179189
},
180190
/// Generate shell completions to stdout or a directory.
181191
#[clap(visible_alias = "generate-completions", visible_alias = "shell-completions")]

0 commit comments

Comments
 (0)