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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ $ drift 1.1.0 main --pretty
"diverged": false,
"commits_scanned": 14,
"merge_commits_skipped": 2,
"ignored_commits_skipped": 1,
"drift_commit_count": 2,
"drift_commits": [
"0f1e2d3c4b5a69788796a5b4c3d2e1f098765432",
Expand Down Expand Up @@ -311,6 +312,7 @@ $ drift 1.1.0 main --pretty
| `diverged` | Whether each ref has moved since they last agreed |
| `commits_scanned` | Non-merge commits classified |
| `merge_commits_skipped` | Merge commits traversed but not classified |
| `ignored_commits_skipped` | Commits with a `Drift: ignore` trailer, traversed but not classified |
| `drift_commit_count` | How many of the scanned commits drifted |
| `drift_commits` | Their shas, newest first |
| `drift_authors` | Distinct `Name <email>` of the drift commit authors, sorted |
Expand Down Expand Up @@ -431,6 +433,12 @@ written into the merge commit itself is therefore never attributed to a commit:
`--log` mode does not report it at all, and `--tree` mode reports the path under
`unattributed_paths`. Make it a separate commit if it needs an author and an age.

**Trailers.** A non-merge commit whose message carries a `Drift: ignore` trailer is
traversed but never classified, the same as a merge: it is counted under
`ignored_commits_skipped` instead of `commits_scanned`, and `--log` mode does not
report it. In `--tree` mode a path it alone changed has no commit to carry it and
reports under `unattributed_paths`.

**Root commits.** A commit with no parent is diffed against the empty tree, so
every file it adds counts.

Expand Down
10 changes: 8 additions & 2 deletions src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub(crate) struct Summary {
pub driftignore_present: bool,
pub commits_scanned: usize,
pub merge_commits_skipped: usize,
pub ignored_commits_skipped: usize,
pub diverged: bool,
pub commits: Vec<DriftCommit>,
pub unattributed_paths: Vec<String>,
Expand All @@ -75,6 +76,8 @@ pub struct Report {
pub commits_scanned: usize,
/// Merge commits traversed but not classified.
pub merge_commits_skipped: usize,
/// Commits carrying a `Drift: ignore` trailer, traversed but not classified.
pub ignored_commits_skipped: usize,
pub drift_commit_count: usize,
/// Drift commit shas, newest first.
pub drift_commits: Vec<String>,
Expand All @@ -84,8 +87,8 @@ pub struct Report {
pub drift_paths: Vec<String>,
/// Drifting paths that no commit in the range explains, sorted. Only `tree` mode
/// can produce these. Either the difference comes from the `from` side, which means
/// the refs diverged, or a merge commit wrote the content itself, which needs no
/// divergence because a merge is never classified.
/// the refs diverged, or a merge commit or a `Drift: ignore` commit wrote the
/// content itself, which needs no divergence because neither is ever classified.
pub unattributed_paths: Vec<String>,
pub oldest_drift: Option<DriftBound>,
pub newest_drift: Option<DriftBound>,
Expand All @@ -106,6 +109,7 @@ impl Report {
driftignore_present,
commits_scanned,
merge_commits_skipped,
ignored_commits_skipped,
diverged,
mut commits,
unattributed_paths,
Expand Down Expand Up @@ -139,6 +143,7 @@ impl Report {
diverged,
commits_scanned,
merge_commits_skipped,
ignored_commits_skipped,
drift_commit_count: commits.len(),
drift_commits: commits.iter().map(|c| c.sha.clone()).collect(),
drift_authors: authors,
Expand Down Expand Up @@ -182,6 +187,7 @@ mod tests {
driftignore_present: true,
commits_scanned: 3,
merge_commits_skipped: 1,
ignored_commits_skipped: 0,
diverged: false,
commits,
unattributed_paths: Vec::new(),
Expand Down
25 changes: 24 additions & 1 deletion src/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ pub fn analyze(

let mut scanned = 0usize;
let mut merges = 0usize;
let mut ignored = 0usize;
let mut commits = Vec::new();
let mut attributed = BTreeSet::new();

Expand All @@ -92,9 +93,16 @@ pub fn analyze(
merges += 1;
continue;
}
scanned += 1;

let commit = info.object().map_err(git)?;
if has_ignore_trailer(&commit)? {
// The author has already vouched for this change; it carries no drift of
// its own to attribute.
ignored += 1;
continue;
}
scanned += 1;

let changed = changed_paths(repo, &commit, options)?;
let drifting: Vec<String> = changed
.into_iter()
Expand Down Expand Up @@ -139,6 +147,7 @@ pub fn analyze(
driftignore_present: driftignore.is_present(),
commits_scanned: scanned,
merge_commits_skipped: merges,
ignored_commits_skipped: ignored,
diverged,
commits,
unattributed_paths,
Expand All @@ -163,6 +172,20 @@ fn resolve<'repo>(repo: &'repo gix::Repository, rev: &str) -> Result<gix::Commit
})
}

/// Whether a commit carries a `Drift: ignore` trailer.
///
/// The token and value are matched ASCII case-insensitively, since gix normalizes
/// neither: it only trims surrounding whitespace.
fn has_ignore_trailer(commit: &gix::Commit<'_>) -> Result<bool> {
let message = commit.message().map_err(git)?;
Ok(message.body().is_some_and(|body| {
body.trailers().any(|trailer| {
trailer.token.eq_ignore_ascii_case(b"Drift")
&& trailer.value.eq_ignore_ascii_case(b"ignore")
})
}))
}

/// The file paths a commit changed relative to its first parent.
///
/// A root commit is diffed against the empty tree, so every file it adds counts.
Expand Down
41 changes: 41 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,47 @@ fn a_rename_into_an_ignored_path_still_drifts_at_the_source() {
);
}

#[test]
fn a_drift_ignore_trailer_excludes_its_commit() {
let (mut fixture, base) = baseline();
fixture.write("src/main.rs", "fn main() { println!(); }\n");
fixture.commit("Reformat main\n\nDrift: ignore");
fixture.write("src/other.rs", "// other\n");
let head = fixture.commit("Add other file");

let report = fixture.report(&base, &head);
assert_eq!(report["ignored_commits_skipped"], 1);
assert_eq!(report["commits_scanned"], 1);
assert_eq!(report["drift_commits"], serde_json::json!([head]));
}

#[test]
fn a_drift_ignore_trailer_matches_regardless_of_case() {
let (mut fixture, base) = baseline();
fixture.write("src/main.rs", "fn main() { println!(); }\n");
fixture.commit("Reformat main\n\ndrift: IGNORE");
let head = fixture.head();

let report = fixture.report(&base, &head);
assert_eq!(report["ignored_commits_skipped"], 1);
assert_eq!(report["commits_scanned"], 0);
}

#[test]
fn a_drift_ignore_commit_that_writes_its_own_content_leaves_an_unattributed_path() {
let (mut fixture, base) = baseline();
fixture.write("src/main.rs", "fn main() { println!(); }\n");
let head = fixture.commit("Reformat main\n\nDrift: ignore");

let report = fixture.report_with(&["--tree"], &base, &head);
assert_eq!(report["ignored_commits_skipped"], 1);
assert_eq!(report["drift_commit_count"], 0);
assert_eq!(
report["unattributed_paths"],
serde_json::json!(["src/main.rs"])
);
}

#[test]
fn merges_are_skipped_but_their_commits_are_scanned() {
let (mut fixture, base) = baseline();
Expand Down