Skip to main content

git_forensic/
attribution.rs

1//! Attribution timeline — who did what, when, from which timezone.
2//!
3//! Every commit carries two identities: the **author** (who wrote the change)
4//! and the **committer** (who applied it). This module flattens a set of commits
5//! into a single time-ordered stream of identity events — the who-did-what-when
6//! backbone an examiner builds a narrative on. The timezone offset is retained
7//! because it can corroborate or contradict a claimed location.
8
9use git_core::{CommitObject, GitHash, GitRepo, Result};
10
11/// Which identity an attribution event came from.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[repr(u8)]
14pub enum Role {
15    /// The author — who wrote the change.
16    Author = 0,
17    /// The committer — who applied it to the repository.
18    Committer = 1,
19}
20
21/// One identity event on the attribution timeline.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct AttributionEvent {
24    /// The commit this identity is attached to.
25    pub commit: GitHash,
26    /// Author or committer.
27    pub role: Role,
28    /// Identity name.
29    pub name: String,
30    /// Identity email.
31    pub email: String,
32    /// Event time (epoch seconds).
33    pub timestamp: i64,
34    /// Timezone offset of the recorded time, in seconds east of UTC.
35    pub tz_offset_secs: i32,
36}
37
38/// Build a time-ordered attribution timeline from a set of commits.
39///
40/// Each commit contributes two events (author, then committer). Events are
41/// sorted by timestamp ascending; ties keep author before committer.
42#[must_use]
43pub fn attribution_timeline(commits: &[CommitObject]) -> Vec<AttributionEvent> {
44    let mut events = Vec::with_capacity(commits.len() * 2);
45    for c in commits {
46        for (role, sig) in [(Role::Author, &c.author), (Role::Committer, &c.committer)] {
47            events.push(AttributionEvent {
48                commit: c.hash,
49                role,
50                name: sig.name.clone(),
51                email: sig.email.clone(),
52                timestamp: sig.timestamp,
53                tz_offset_secs: sig.tz_offset_secs,
54            });
55        }
56    }
57    events.sort_by(|a, b| {
58        a.timestamp
59            .cmp(&b.timestamp)
60            .then((a.role as u8).cmp(&(b.role as u8)))
61    });
62    events
63}
64
65/// Distinct `(name, email)` identities appearing across `commits`, in first-seen
66/// order. A surprising count or unexpected identity is a lead, not a verdict.
67#[must_use]
68pub fn distinct_identities(commits: &[CommitObject]) -> Vec<(String, String)> {
69    let mut seen = Vec::new();
70    for c in commits {
71        for sig in [&c.author, &c.committer] {
72            let id = (sig.name.clone(), sig.email.clone());
73            if !seen.contains(&id) {
74                seen.push(id);
75            }
76        }
77    }
78    seen
79}
80
81/// Walk every commit reachable from `from` and build its attribution timeline.
82///
83/// # Errors
84/// Propagates any [`git_core`] read error encountered while walking.
85pub fn attribution_repo(repo: &GitRepo, from: GitHash) -> Result<Vec<AttributionEvent>> {
86    let mut commits = Vec::new();
87    for commit in repo.walk_commits(from) {
88        commits.push(commit?);
89    }
90    Ok(attribution_timeline(&commits))
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use git_core::Signature;
97
98    fn sig(name: &str, ts: i64, tz: i32) -> Signature {
99        Signature {
100            name: name.into(),
101            email: format!("{name}@x"),
102            timestamp: ts,
103            tz_offset_secs: tz,
104        }
105    }
106
107    fn commit(hex: &str, author: Signature, committer: Signature) -> CommitObject {
108        CommitObject {
109            hash: GitHash::from_hex(hex).unwrap(),
110            tree: GitHash::from_hex("89abcdef0123456789abcdef0123456789abcdef").unwrap(),
111            parents: vec![],
112            author,
113            committer,
114            message: "m".into(),
115            is_signed: false,
116        }
117    }
118
119    #[test]
120    fn timeline_is_time_ordered_author_before_committer() {
121        let c1 = commit(
122            "0123456789abcdef0123456789abcdef01234567",
123            sig("alice", 1_000, 0),
124            sig("bob", 2_000, 3600),
125        );
126        let c2 = commit(
127            "1123456789abcdef0123456789abcdef01234567",
128            sig("carol", 1_500, -7200),
129            sig("carol", 1_500, -7200),
130        );
131        let tl = attribution_timeline(&[c1, c2]);
132        // 2 commits → 4 events, sorted by time: alice@1000, carol-author@1500,
133        // carol-committer@1500, bob@2000.
134        let times: Vec<i64> = tl.iter().map(|e| e.timestamp).collect();
135        assert_eq!(times, vec![1_000, 1_500, 1_500, 2_000]);
136        assert_eq!(tl[0].name, "alice");
137        assert_eq!(tl[0].role, Role::Author);
138        assert_eq!(tl[1].role, Role::Author); // carol author before committer at the tie
139        assert_eq!(tl[2].role, Role::Committer);
140        assert_eq!(tl[3].name, "bob");
141        assert_eq!(tl[3].tz_offset_secs, 3600);
142    }
143
144    #[test]
145    fn distinct_identities_dedup_in_first_seen_order() {
146        let c = commit(
147            "0123456789abcdef0123456789abcdef01234567",
148            sig("alice", 1, 0),
149            sig("bob", 2, 0),
150        );
151        let ids = distinct_identities(std::slice::from_ref(&c));
152        assert_eq!(
153            ids,
154            vec![
155                ("alice".into(), "alice@x".into()),
156                ("bob".into(), "bob@x".into())
157            ]
158        );
159        // committer == author → a single identity, no duplicate.
160        let solo = commit(
161            "1123456789abcdef0123456789abcdef01234567",
162            sig("alice", 1, 0),
163            sig("alice", 2, 0),
164        );
165        assert_eq!(distinct_identities(&[solo]).len(), 1);
166    }
167}