Skip to main content

agent_session/
longitudinal.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3
4//! Privacy-safe longitudinal export across native agent sessions and Git.
5
6use chrono::{DateTime, SecondsFormat, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeSet, HashMap};
9use std::fmt;
10use std::fs;
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15use crate::{
16    AgentSession, EditSummary, discover_session_files, parse_session_file, parse_session_path,
17    short_hash,
18};
19
20const RETRIEVAL_BEFORE_MS: i64 = 15 * 60 * 1_000;
21const RETRIEVAL_AFTER_MS: i64 = 24 * 60 * 60 * 1_000;
22const AUDIT_BEFORE_MS: i64 = 24 * 60 * 60 * 1_000;
23const AUDIT_AFTER_MS: i64 = 7 * 24 * 60 * 60 * 1_000;
24
25#[derive(Debug)]
26pub struct ExportError(String);
27
28impl ExportError {
29    fn new(message: impl Into<String>) -> Self {
30        Self(message.into())
31    }
32}
33
34impl fmt::Display for ExportError {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.write_str(&self.0)
37    }
38}
39
40impl std::error::Error for ExportError {}
41
42#[derive(Debug, Clone)]
43pub struct LongitudinalOptions {
44    pub repo: PathBuf,
45    pub head: Option<String>,
46    pub since_ms: i64,
47    pub until_ms: i64,
48    pub session_paths: Vec<PathBuf>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct LongitudinalArtifact {
53    pub schema: String,
54    pub repository: RepositorySummary,
55    pub window: ExportWindow,
56    pub summary: ArtifactSummary,
57    pub sessions: Vec<SessionSummary>,
58    pub events: Vec<NormalizedEvent>,
59    pub commits: Vec<GitCommit>,
60    pub changes: Vec<GitChange>,
61    pub file_lifetimes: Vec<FileLifetime>,
62    pub associations: Vec<EventAssociation>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct RepositorySummary {
67    pub name: String,
68    pub head: String,
69    pub root_id: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ExportWindow {
74    pub since_ms: i64,
75    pub until_ms: i64,
76    pub since: String,
77    pub until: String,
78    pub audit_since_ms: i64,
79    pub audit_until_ms: i64,
80    pub retrieval_before_ms: i64,
81    pub retrieval_after_ms: i64,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
85pub struct ArtifactSummary {
86    pub session_count: usize,
87    pub event_count: usize,
88    pub path_resolvable_event_count: usize,
89    pub write_event_path_count: usize,
90    pub commit_count: usize,
91    pub change_count: usize,
92    pub lifetime_count: usize,
93    pub surviving_lifetime_count: usize,
94    pub association_none_count: usize,
95    pub association_unique_count: usize,
96    pub association_ambiguous_count: usize,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SessionSummary {
101    pub id: String,
102    pub vendor: String,
103    pub model: Option<String>,
104    pub started_at_ms: Option<i64>,
105    pub ended_at_ms: Option<i64>,
106    pub tool_events: usize,
107    pub total_tokens: i64,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct NormalizedEvent {
112    pub id: String,
113    pub session_id: String,
114    pub vendor: String,
115    pub model: Option<String>,
116    pub ts_ms: i64,
117    pub kind: String,
118    pub action: String,
119    pub category: String,
120    pub effect: String,
121    pub status: String,
122    pub prompt_index: usize,
123    pub paths: Vec<String>,
124    #[serde(default)]
125    pub write_paths: Vec<String>,
126    pub path_groups: Vec<String>,
127    pub edit_summary: Option<EditSummary>,
128    pub input_tokens: u64,
129    pub output_tokens: u64,
130    pub cache_tokens: u64,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct GitCommit {
135    pub id: String,
136    pub parents: Vec<String>,
137    pub committed_at_ms: i64,
138    pub author_label: String,
139    pub is_merge: bool,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, Default)]
143pub struct HunkFingerprint {
144    pub before_hash: Option<String>,
145    pub after_hash: Option<String>,
146    pub removed_lines: u64,
147    pub added_lines: u64,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct GitChange {
152    pub id: String,
153    pub commit_id: String,
154    pub committed_at_ms: i64,
155    pub status: String,
156    pub old_path: Option<String>,
157    pub path: String,
158    pub additions: u64,
159    pub deletions: u64,
160    pub lifetime_id: String,
161    pub is_merge: bool,
162    pub hunks: Vec<HunkFingerprint>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct FileLifetime {
167    pub id: String,
168    pub paths: Vec<String>,
169    pub birth_commit: String,
170    pub birth_ms: i64,
171    pub death_commit: Option<String>,
172    pub death_ms: Option<i64>,
173    pub current_path: Option<String>,
174    pub current_bytes: Option<u64>,
175    pub survives_to_head: bool,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct CandidateAssociation {
180    pub change_id: String,
181    pub commit_id: String,
182    pub lifetime_id: String,
183    pub rank: usize,
184    pub delta_ms: i64,
185    pub match_kind: String,
186    pub exact_hunk_match: bool,
187    pub evidence_bin: String,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct EventAssociation {
192    pub id: String,
193    pub event_id: String,
194    pub path: String,
195    pub state: String,
196    pub candidates: Vec<CandidateAssociation>,
197}
198
199#[derive(Debug)]
200struct CommitRecord {
201    commit: GitCommit,
202    all_changes: Vec<GitChange>,
203}
204
205#[derive(Debug)]
206struct LifetimeBuilder {
207    lifetime: FileLifetime,
208}
209
210#[derive(Debug)]
211struct StatusChange {
212    status: String,
213    old_path: Option<String>,
214    path: String,
215}
216
217pub fn build_longitudinal_artifact(
218    options: &LongitudinalOptions,
219) -> Result<LongitudinalArtifact, ExportError> {
220    if options.until_ms <= options.since_ms {
221        return Err(ExportError::new("--until must be after --since"));
222    }
223    let repo = options
224        .repo
225        .canonicalize()
226        .map_err(|error| ExportError::new(format!("cannot resolve repository: {error}")))?;
227    ensure_git_repo(&repo)?;
228    let revision = options.head.as_deref().unwrap_or("HEAD");
229    let head = git_text(&repo, &["rev-parse", "--verify", revision])?
230        .trim()
231        .to_string();
232    let audit_since_ms = options.since_ms.saturating_sub(AUDIT_BEFORE_MS);
233    let audit_until_ms = options.until_ms.saturating_add(AUDIT_AFTER_MS);
234
235    let (commit_records, mut lifetimes) =
236        collect_git_history(&repo, &head, audit_since_ms, audit_until_ms)?;
237    let mut commits = Vec::new();
238    let mut changes = Vec::new();
239    for record in commit_records {
240        if (audit_since_ms..audit_until_ms).contains(&record.commit.committed_at_ms) {
241            commits.push(record.commit);
242            changes.extend(record.all_changes);
243        }
244    }
245    let current_sizes = current_blob_sizes(&repo, &head)?;
246    for lifetime in &mut lifetimes {
247        if let Some(path) = &lifetime.current_path {
248            lifetime.current_bytes = current_sizes.get(path).copied();
249            lifetime.survives_to_head = current_sizes.contains_key(path);
250        }
251    }
252
253    let sessions = load_sessions(options, &repo)?;
254    let (session_summaries, mut events) = normalize_sessions(&repo, sessions, options);
255    events.sort_by(|left, right| {
256        left.ts_ms
257            .cmp(&right.ts_ms)
258            .then_with(|| left.id.cmp(&right.id))
259    });
260    let associations = associate_events(&events, &changes, &lifetimes);
261
262    let mut summary = ArtifactSummary {
263        session_count: session_summaries.len(),
264        event_count: events.len(),
265        path_resolvable_event_count: events
266            .iter()
267            .filter(|event| !event.paths.is_empty())
268            .count(),
269        write_event_path_count: associations.len(),
270        commit_count: commits.len(),
271        change_count: changes.len(),
272        lifetime_count: lifetimes.len(),
273        surviving_lifetime_count: lifetimes
274            .iter()
275            .filter(|lifetime| lifetime.survives_to_head)
276            .count(),
277        ..ArtifactSummary::default()
278    };
279    for association in &associations {
280        match association.state.as_str() {
281            "no_candidate" => summary.association_none_count += 1,
282            "unique_candidate" => summary.association_unique_count += 1,
283            "ambiguous_candidates" => summary.association_ambiguous_count += 1,
284            _ => {}
285        }
286    }
287
288    Ok(LongitudinalArtifact {
289        schema: "agentsight.longitudinal.v1".to_string(),
290        repository: RepositorySummary {
291            name: repo
292                .file_name()
293                .and_then(|name| name.to_str())
294                .unwrap_or("repository")
295                .to_string(),
296            head: head.clone(),
297            root_id: short_hash(repo.to_string_lossy().as_ref(), 16),
298        },
299        window: ExportWindow {
300            since_ms: options.since_ms,
301            until_ms: options.until_ms,
302            since: format_timestamp(options.since_ms),
303            until: format_timestamp(options.until_ms),
304            audit_since_ms,
305            audit_until_ms,
306            retrieval_before_ms: RETRIEVAL_BEFORE_MS,
307            retrieval_after_ms: RETRIEVAL_AFTER_MS,
308        },
309        summary,
310        sessions: session_summaries,
311        events,
312        commits,
313        changes,
314        file_lifetimes: lifetimes,
315        associations,
316    })
317}
318
319pub fn write_longitudinal_artifact(
320    artifact: &LongitudinalArtifact,
321    output: &Path,
322) -> Result<(), ExportError> {
323    if let Some(parent) = output.parent() {
324        fs::create_dir_all(parent).map_err(|error| {
325            ExportError::new(format!("cannot create {}: {error}", parent.display()))
326        })?;
327    }
328    let file_name = output
329        .file_name()
330        .and_then(|name| name.to_str())
331        .unwrap_or("artifact.json");
332    let temporary = output.with_file_name(format!(".{file_name}.tmp-{}", std::process::id()));
333    let mut file = fs::File::create(&temporary).map_err(|error| {
334        ExportError::new(format!("cannot create {}: {error}", temporary.display()))
335    })?;
336    serde_json::to_writer_pretty(&mut file, artifact)
337        .map_err(|error| ExportError::new(format!("cannot serialize artifact: {error}")))?;
338    file.write_all(b"\n")
339        .and_then(|_| file.sync_all())
340        .map_err(|error| ExportError::new(format!("cannot flush artifact: {error}")))?;
341    fs::rename(&temporary, output).map_err(|error| {
342        ExportError::new(format!(
343            "cannot move {} to {}: {error}",
344            temporary.display(),
345            output.display()
346        ))
347    })
348}
349
350fn ensure_git_repo(repo: &Path) -> Result<(), ExportError> {
351    let inside = git_text(repo, &["rev-parse", "--is-inside-work-tree"])?;
352    if inside.trim() != "true" {
353        return Err(ExportError::new(format!(
354            "{} is not a Git worktree",
355            repo.display()
356        )));
357    }
358    Ok(())
359}
360
361fn collect_git_history(
362    repo: &Path,
363    head: &str,
364    audit_since_ms: i64,
365    audit_until_ms: i64,
366) -> Result<(Vec<CommitRecord>, Vec<FileLifetime>), ExportError> {
367    let output = git_bytes(
368        repo,
369        &[
370            "log",
371            "--first-parent",
372            "--reverse",
373            "--format=%H%x1f%P%x1f%ct%x1f%an%x1e",
374            head,
375        ],
376    )?;
377    let mut records = Vec::new();
378    let mut active = HashMap::<String, usize>::new();
379    let mut lifetimes = Vec::<LifetimeBuilder>::new();
380    let mut lifetime_counter = 0usize;
381
382    for raw_record in output.split(|byte| *byte == 0x1e) {
383        let raw_record = trim_ascii(raw_record);
384        if raw_record.is_empty() {
385            continue;
386        }
387        let fields = raw_record.split(|byte| *byte == 0x1f).collect::<Vec<_>>();
388        if fields.len() < 4 {
389            return Err(ExportError::new("unexpected git log record"));
390        }
391        let id = utf8(fields[0], "commit id")?.to_string();
392        let parents = utf8(fields[1], "commit parents")?
393            .split_whitespace()
394            .map(str::to_string)
395            .collect::<Vec<_>>();
396        let committed_at_ms = utf8(fields[2], "commit timestamp")?
397            .parse::<i64>()
398            .map_err(|error| ExportError::new(format!("invalid commit timestamp: {error}")))?
399            .saturating_mul(1_000);
400        let author_label = utf8(fields[3], "commit author")?.trim().to_string();
401        let is_merge = parents.len() > 1;
402        let statuses = commit_statuses(repo, &id, parents.first().map(String::as_str))?;
403        let numstats = commit_numstats(repo, &id, parents.first().map(String::as_str))?;
404        let mut all_changes = Vec::new();
405
406        for (index, status) in statuses.into_iter().enumerate() {
407            let lifetime_index = apply_lifetime_change(
408                &mut active,
409                &mut lifetimes,
410                &mut lifetime_counter,
411                &status,
412                &id,
413                committed_at_ms,
414            );
415            let (additions, deletions) = numstats
416                .get(&status.path)
417                .or_else(|| status.old_path.as_ref().and_then(|path| numstats.get(path)))
418                .copied()
419                .unwrap_or((0, 0));
420            let change_id = format!("{}:{index}", short_hash(&id, 12));
421            let hunks = if (audit_since_ms..audit_until_ms).contains(&committed_at_ms) {
422                commit_hunks(
423                    repo,
424                    &id,
425                    parents.first().map(String::as_str),
426                    status.old_path.as_deref(),
427                    &status.path,
428                )?
429            } else {
430                Vec::new()
431            };
432            all_changes.push(GitChange {
433                id: change_id,
434                commit_id: id.clone(),
435                committed_at_ms,
436                status: status.status,
437                old_path: status.old_path,
438                path: status.path,
439                additions,
440                deletions,
441                lifetime_id: lifetimes[lifetime_index].lifetime.id.clone(),
442                is_merge,
443                hunks,
444            });
445        }
446
447        records.push(CommitRecord {
448            commit: GitCommit {
449                id,
450                parents,
451                committed_at_ms,
452                author_label,
453                is_merge,
454            },
455            all_changes,
456        });
457    }
458
459    Ok((
460        records,
461        lifetimes
462            .into_iter()
463            .map(|builder| builder.lifetime)
464            .collect(),
465    ))
466}
467
468fn apply_lifetime_change(
469    active: &mut HashMap<String, usize>,
470    lifetimes: &mut Vec<LifetimeBuilder>,
471    lifetime_counter: &mut usize,
472    change: &StatusChange,
473    commit_id: &str,
474    committed_at_ms: i64,
475) -> usize {
476    let status = change.status.chars().next().unwrap_or('M');
477    if (status == 'R' || status == 'C')
478        && let Some(old_path) = &change.old_path
479        && let Some(index) = active.remove(old_path)
480    {
481        let lifetime = &mut lifetimes[index].lifetime;
482        if !lifetime.paths.contains(&change.path) {
483            lifetime.paths.push(change.path.clone());
484        }
485        lifetime.current_path = Some(change.path.clone());
486        active.insert(change.path.clone(), index);
487        return index;
488    }
489    if status == 'D'
490        && let Some(index) = active.remove(&change.path)
491    {
492        let lifetime = &mut lifetimes[index].lifetime;
493        lifetime.death_commit = Some(commit_id.to_string());
494        lifetime.death_ms = Some(committed_at_ms);
495        lifetime.current_path = None;
496        return index;
497    }
498    if status != 'A'
499        && let Some(index) = active.get(&change.path).copied()
500    {
501        return index;
502    }
503
504    *lifetime_counter += 1;
505    let index = lifetimes.len();
506    lifetimes.push(LifetimeBuilder {
507        lifetime: FileLifetime {
508            id: format!("file-{:06}", *lifetime_counter),
509            paths: vec![change.path.clone()],
510            birth_commit: commit_id.to_string(),
511            birth_ms: committed_at_ms,
512            death_commit: None,
513            death_ms: None,
514            current_path: Some(change.path.clone()),
515            current_bytes: None,
516            survives_to_head: false,
517        },
518    });
519    active.insert(change.path.clone(), index);
520    index
521}
522
523fn commit_statuses(
524    repo: &Path,
525    commit: &str,
526    first_parent: Option<&str>,
527) -> Result<Vec<StatusChange>, ExportError> {
528    let output = if let Some(parent) = first_parent {
529        git_bytes(
530            repo,
531            &[
532                "diff",
533                "--name-status",
534                "-z",
535                "-M50%",
536                "--no-ext-diff",
537                parent,
538                commit,
539                "--",
540            ],
541        )?
542    } else {
543        git_bytes(
544            repo,
545            &[
546                "diff-tree",
547                "--root",
548                "--no-commit-id",
549                "-r",
550                "--name-status",
551                "-z",
552                "-M50%",
553                commit,
554            ],
555        )?
556    };
557    let tokens = nul_tokens(&output);
558    let mut changes = Vec::new();
559    let mut index = 0usize;
560    while index < tokens.len() {
561        let status = tokens[index].clone();
562        index += 1;
563        let kind = status.chars().next().unwrap_or('M');
564        if kind == 'R' || kind == 'C' {
565            if index + 1 >= tokens.len() {
566                return Err(ExportError::new("truncated rename status"));
567            }
568            changes.push(StatusChange {
569                status,
570                old_path: Some(tokens[index].clone()),
571                path: tokens[index + 1].clone(),
572            });
573            index += 2;
574        } else {
575            let Some(path) = tokens.get(index) else {
576                return Err(ExportError::new("truncated path status"));
577            };
578            changes.push(StatusChange {
579                status,
580                old_path: None,
581                path: path.clone(),
582            });
583            index += 1;
584        }
585    }
586    Ok(changes)
587}
588
589fn commit_numstats(
590    repo: &Path,
591    commit: &str,
592    first_parent: Option<&str>,
593) -> Result<HashMap<String, (u64, u64)>, ExportError> {
594    let output = if let Some(parent) = first_parent {
595        git_bytes(
596            repo,
597            &[
598                "diff",
599                "--numstat",
600                "-z",
601                "-M50%",
602                "--no-ext-diff",
603                parent,
604                commit,
605                "--",
606            ],
607        )?
608    } else {
609        git_bytes(
610            repo,
611            &[
612                "diff-tree",
613                "--root",
614                "--no-commit-id",
615                "-r",
616                "--numstat",
617                "-z",
618                "-M50%",
619                commit,
620            ],
621        )?
622    };
623    let tokens = nul_tokens(&output);
624    let mut stats = HashMap::new();
625    let mut index = 0usize;
626    while index < tokens.len() {
627        let header = &tokens[index];
628        index += 1;
629        let mut fields = header.splitn(3, '\t');
630        let additions = parse_numstat(fields.next().unwrap_or("0"));
631        let deletions = parse_numstat(fields.next().unwrap_or("0"));
632        let path = fields.next().unwrap_or("");
633        if path.is_empty() && index + 1 < tokens.len() {
634            let old_path = tokens[index].clone();
635            let new_path = tokens[index + 1].clone();
636            index += 2;
637            stats.insert(old_path, (additions, deletions));
638            stats.insert(new_path, (additions, deletions));
639        } else if !path.is_empty() {
640            stats.insert(path.to_string(), (additions, deletions));
641        }
642    }
643    Ok(stats)
644}
645
646fn commit_hunks(
647    repo: &Path,
648    commit: &str,
649    first_parent: Option<&str>,
650    old_path: Option<&str>,
651    path: &str,
652) -> Result<Vec<HunkFingerprint>, ExportError> {
653    let mut command = Command::new("git");
654    command.arg("-C").arg(repo);
655    if let Some(parent) = first_parent {
656        command.args(["diff", "--unified=0", "--no-ext-diff", parent, commit, "--"]);
657    } else {
658        command.args([
659            "show",
660            "--format=",
661            "--unified=0",
662            "--no-ext-diff",
663            commit,
664            "--",
665        ]);
666    }
667    if let Some(old_path) = old_path {
668        command.arg(old_path);
669    }
670    command.arg(path);
671    let output = command
672        .output()
673        .map_err(|error| ExportError::new(format!("cannot run git diff: {error}")))?;
674    if !output.status.success() {
675        return Err(command_error("git diff hunks", &output));
676    }
677    let text = String::from_utf8_lossy(&output.stdout);
678    Ok(parse_hunk_fingerprints(&text))
679}
680
681fn parse_hunk_fingerprints(patch: &str) -> Vec<HunkFingerprint> {
682    let mut hunks = Vec::new();
683    let mut before = Vec::new();
684    let mut after = Vec::new();
685    let mut in_hunk = false;
686    let flush =
687        |before: &mut Vec<String>, after: &mut Vec<String>, hunks: &mut Vec<HunkFingerprint>| {
688            if before.is_empty() && after.is_empty() {
689                return;
690            }
691            let before_text = before.join("\n");
692            let after_text = after.join("\n");
693            hunks.push(HunkFingerprint {
694                before_hash: (!before.is_empty()).then(|| content_fingerprint(&before_text)),
695                after_hash: (!after.is_empty()).then(|| content_fingerprint(&after_text)),
696                removed_lines: before.len() as u64,
697                added_lines: after.len() as u64,
698            });
699            before.clear();
700            after.clear();
701        };
702    for line in patch.lines() {
703        if line.starts_with("@@") {
704            flush(&mut before, &mut after, &mut hunks);
705            in_hunk = true;
706        } else if in_hunk && line.starts_with('-') && !line.starts_with("---") {
707            before.push(line[1..].to_string());
708        } else if in_hunk && line.starts_with('+') && !line.starts_with("+++") {
709            after.push(line[1..].to_string());
710        }
711    }
712    flush(&mut before, &mut after, &mut hunks);
713    hunks
714}
715
716fn load_sessions(
717    options: &LongitudinalOptions,
718    repo: &Path,
719) -> Result<Vec<AgentSession>, ExportError> {
720    let mut sessions = Vec::new();
721    if options.session_paths.is_empty() {
722        for candidate in discover_session_files() {
723            let updated_ms = candidate
724                .updated
725                .duration_since(std::time::UNIX_EPOCH)
726                .unwrap_or_default()
727                .as_millis() as i64;
728            // A session may start in the requested window and be resumed weeks
729            // later. Its file mtime then lies after the Git audit horizon, but
730            // the earlier events remain part of the longitudinal trajectory.
731            if !candidate_mtime_may_contain_window(updated_ms, options.since_ms) {
732                continue;
733            }
734            if !candidate_may_match_repo(candidate.agent, &candidate.path, repo) {
735                continue;
736            }
737            if let Some(session) = parse_session_file(&candidate)
738                && session_matches_repo(&session, repo)
739                && session_overlaps(&session, options.since_ms, options.until_ms)
740            {
741                sessions.push(session);
742            }
743        }
744    } else {
745        for path in &options.session_paths {
746            let session = parse_session_path(path).ok_or_else(|| {
747                ExportError::new(format!("cannot parse session {}", path.display()))
748            })?;
749            if session_matches_repo(&session, repo)
750                && session_overlaps(&session, options.since_ms, options.until_ms)
751            {
752                sessions.push(session);
753            }
754        }
755    }
756    sessions.sort_by(|left, right| {
757        left.start_timestamp_ms
758            .cmp(&right.start_timestamp_ms)
759            .then_with(|| left.session_id.cmp(&right.session_id))
760    });
761    Ok(sessions)
762}
763
764fn candidate_mtime_may_contain_window(updated_ms: i64, since_ms: i64) -> bool {
765    updated_ms >= since_ms.saturating_sub(AUDIT_BEFORE_MS)
766}
767
768fn candidate_may_match_repo(agent: &str, path: &Path, repo: &Path) -> bool {
769    let repo_text = repo.to_string_lossy();
770    match agent {
771        crate::AGENT_CLAUDE => {
772            let encoded = repo_text.replace('/', "-");
773            path.to_string_lossy().contains(&encoded)
774        }
775        crate::AGENT_GEMINI => {
776            let project_hash = short_hash(repo_text.as_ref(), 64);
777            path.ancestors().any(|ancestor| {
778                ancestor
779                    .file_name()
780                    .and_then(|name| name.to_str())
781                    .is_some_and(|name| name == project_hash)
782            })
783        }
784        crate::AGENT_CODEX => {
785            let Ok(mut file) = fs::File::open(path) else {
786                return false;
787            };
788            let mut prefix = vec![0u8; 256 * 1024];
789            let Ok(count) = file.read(&mut prefix) else {
790                return false;
791            };
792            prefix.truncate(count);
793            prefix
794                .windows(repo_text.len())
795                .any(|window| window == repo_text.as_bytes())
796        }
797        _ => false,
798    }
799}
800
801fn session_matches_repo(session: &AgentSession, repo: &Path) -> bool {
802    if let Some(cwd) = session.cwd.as_deref() {
803        let cwd = Path::new(cwd);
804        let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
805        return cwd.starts_with(repo);
806    }
807    session.project_hash.as_deref()
808        == Some(short_hash(repo.to_string_lossy().as_ref(), 64).as_str())
809}
810
811fn session_overlaps(session: &AgentSession, since_ms: i64, until_ms: i64) -> bool {
812    let start = session.start_timestamp_ms.unwrap_or_default() as i64;
813    let end = session.end_timestamp_ms.unwrap_or_default() as i64;
814    start < until_ms && end >= since_ms
815}
816
817fn normalize_sessions(
818    repo: &Path,
819    sessions: Vec<AgentSession>,
820    options: &LongitudinalOptions,
821) -> (Vec<SessionSummary>, Vec<NormalizedEvent>) {
822    let mut summaries = Vec::new();
823    let mut events = Vec::new();
824    for session in sessions {
825        let stable_session_id = format!(
826            "{}-{}",
827            session.agent_type,
828            short_hash(&session.session_id, 16)
829        );
830        let project_hash_matches = session.project_hash.as_deref()
831            == Some(short_hash(repo.to_string_lossy().as_ref(), 64).as_str());
832        let cwd = if project_hash_matches {
833            Some(repo)
834        } else {
835            session.cwd.as_deref().map(Path::new)
836        };
837        summaries.push(SessionSummary {
838            id: stable_session_id.clone(),
839            vendor: session.agent_type.clone(),
840            model: session.model.clone(),
841            started_at_ms: session.start_timestamp_ms.map(|value| value as i64),
842            ended_at_ms: session.end_timestamp_ms.map(|value| value as i64),
843            tool_events: session.events.tools.len(),
844            total_tokens: session.usage.total_tokens,
845        });
846        for (index, tool) in session.events.tools.iter().enumerate() {
847            let Some(ts_ms) = tool.ts_ms else {
848                continue;
849            };
850            if !(options.since_ms..options.until_ms).contains(&ts_ms) {
851                continue;
852            }
853            let normalized_refs = tool
854                .path_refs
855                .iter()
856                .filter_map(|reference| {
857                    cwd.and_then(|cwd| {
858                        repo_relative_session_path(repo, cwd, &reference.path)
859                            .map(|path| (path, reference.access.as_str()))
860                    })
861                })
862                .collect::<BTreeSet<_>>();
863            let paths = normalized_refs
864                .iter()
865                .map(|(path, _)| path.clone())
866                .collect::<BTreeSet<_>>()
867                .into_iter()
868                .collect::<Vec<_>>();
869            let write_paths = normalized_refs
870                .iter()
871                .filter(|(_, access)| *access == "write")
872                .map(|(path, _)| path.clone())
873                .collect::<BTreeSet<_>>()
874                .into_iter()
875                .collect::<Vec<_>>();
876            let id_source = format!(
877                "{}:{}:{}:{}:{}",
878                stable_session_id,
879                ts_ms,
880                tool.call_id.as_deref().unwrap_or("none"),
881                tool.prompt_index,
882                index
883            );
884            events.push(NormalizedEvent {
885                id: format!("event-{}", short_hash(&id_source, 20)),
886                session_id: stable_session_id.clone(),
887                vendor: session.agent_type.clone(),
888                model: session.model.clone(),
889                ts_ms,
890                kind: "tool".to_string(),
891                action: tool.tool_name.clone(),
892                category: tool.category.clone(),
893                effect: tool.effect.clone(),
894                status: tool.status.clone(),
895                prompt_index: tool.prompt_index,
896                paths,
897                write_paths,
898                path_groups: tool.path_groups.clone(),
899                edit_summary: tool.edit_summary.clone(),
900                input_tokens: 0,
901                output_tokens: 0,
902                cache_tokens: 0,
903            });
904        }
905        for (index, response) in session.events.llm_responses.iter().enumerate() {
906            let Some(ts_ms) = response.ts_ms else {
907                continue;
908            };
909            if !(options.since_ms..options.until_ms).contains(&ts_ms) {
910                continue;
911            }
912            let id_source = format!("{}:{ts_ms}:llm:{index}", stable_session_id);
913            events.push(NormalizedEvent {
914                id: format!("event-{}", short_hash(&id_source, 20)),
915                session_id: stable_session_id.clone(),
916                vendor: session.agent_type.clone(),
917                model: Some(response.model.clone()),
918                ts_ms,
919                kind: "llm_response".to_string(),
920                action: "model_response".to_string(),
921                category: "model".to_string(),
922                effect: "compute".to_string(),
923                status: "observed".to_string(),
924                prompt_index: response.prompt_index,
925                paths: Vec::new(),
926                write_paths: Vec::new(),
927                path_groups: Vec::new(),
928                edit_summary: None,
929                input_tokens: response.input_tokens,
930                output_tokens: response.output_tokens,
931                cache_tokens: response.cache_tokens,
932            });
933        }
934    }
935    (summaries, events)
936}
937
938fn repo_relative_session_path(repo: &Path, cwd: &Path, event_path: &str) -> Option<String> {
939    let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
940    let prefix = cwd.strip_prefix(repo).ok()?;
941    let joined = prefix.join(event_path);
942    let mut parts = Vec::new();
943    for component in joined.components() {
944        match component {
945            std::path::Component::CurDir => {}
946            std::path::Component::Normal(part) => parts.push(part.to_str()?.to_string()),
947            std::path::Component::ParentDir => {
948                parts.pop()?;
949            }
950            std::path::Component::RootDir | std::path::Component::Prefix(_) => return None,
951        }
952    }
953    (!parts.is_empty()).then(|| parts.join("/"))
954}
955
956fn associate_events(
957    events: &[NormalizedEvent],
958    changes: &[GitChange],
959    lifetimes: &[FileLifetime],
960) -> Vec<EventAssociation> {
961    let lifetime_paths = lifetimes
962        .iter()
963        .map(|lifetime| {
964            (
965                lifetime.id.as_str(),
966                lifetime
967                    .paths
968                    .iter()
969                    .map(String::as_str)
970                    .collect::<BTreeSet<_>>(),
971            )
972        })
973        .collect::<HashMap<_, _>>();
974    let lifetime_intervals = lifetimes
975        .iter()
976        .map(|lifetime| (lifetime.id.as_str(), (lifetime.birth_ms, lifetime.death_ms)))
977        .collect::<HashMap<_, _>>();
978    let mut associations = Vec::new();
979    // Mixed compound commands can retain a primary process/test effect while
980    // still containing segment-local writes (for example `cargo build && cp
981    // ...`). Eligibility is therefore defined by the normalized write-path
982    // evidence, not by the lossy event-level effect label.
983    for event in events.iter().filter(|event| !event.write_paths.is_empty()) {
984        for path in &event.write_paths {
985            let mut candidates = changes
986                .iter()
987                .filter(|change| !change.is_merge)
988                .filter(|change| {
989                    change.committed_at_ms >= event.ts_ms.saturating_sub(RETRIEVAL_BEFORE_MS)
990                        && change.committed_at_ms <= event.ts_ms.saturating_add(RETRIEVAL_AFTER_MS)
991                })
992                .filter_map(|change| {
993                    let direct = change.path == *path
994                        || change.old_path.as_deref().is_some_and(|old| old == path);
995                    let lifetime_match = lifetime_paths
996                        .get(change.lifetime_id.as_str())
997                        .is_some_and(|paths| paths.contains(path.as_str()));
998                    let event_within_lifetime = lifetime_intervals
999                        .get(change.lifetime_id.as_str())
1000                        .is_some_and(|(birth, death)| {
1001                            event.ts_ms >= *birth && death.is_none_or(|death| event.ts_ms < death)
1002                        });
1003                    let prebirth = change.status.starts_with('A')
1004                        && change.path == *path
1005                        && change.committed_at_ms >= event.ts_ms;
1006                    if !prebirth && (!event_within_lifetime || (!direct && !lifetime_match)) {
1007                        return None;
1008                    }
1009                    let exact_hunk_match = event.edit_summary.as_ref().is_some_and(|summary| {
1010                        change
1011                            .hunks
1012                            .iter()
1013                            .any(|hunk| edit_matches_hunk(summary, hunk))
1014                    });
1015                    let match_kind = if prebirth {
1016                        "prebirth"
1017                    } else if direct {
1018                        "direct_path"
1019                    } else {
1020                        "rename_lifetime"
1021                    };
1022                    Some((change, exact_hunk_match, match_kind))
1023                })
1024                .collect::<Vec<_>>();
1025            candidates.sort_by(|left, right| {
1026                let left_key = candidate_sort_key(event, left.0, left.1, left.2);
1027                let right_key = candidate_sort_key(event, right.0, right.1, right.2);
1028                left_key.cmp(&right_key)
1029            });
1030            let candidate_count = candidates.len();
1031            let top_distance_ms = candidates
1032                .first()
1033                .map(|candidate| (candidate.0.committed_at_ms - event.ts_ms).abs());
1034            let second_distance_ms = candidates
1035                .get(1)
1036                .map(|candidate| (candidate.0.committed_at_ms - event.ts_ms).abs());
1037            let mapped = candidates
1038                .into_iter()
1039                .enumerate()
1040                .map(
1041                    |(index, (change, exact_hunk_match, match_kind))| CandidateAssociation {
1042                        change_id: change.id.clone(),
1043                        commit_id: change.commit_id.clone(),
1044                        lifetime_id: change.lifetime_id.clone(),
1045                        rank: index + 1,
1046                        delta_ms: change.committed_at_ms - event.ts_ms,
1047                        match_kind: match_kind.to_string(),
1048                        exact_hunk_match,
1049                        evidence_bin: candidate_evidence_bin(
1050                            candidate_count,
1051                            index,
1052                            top_distance_ms,
1053                            second_distance_ms,
1054                            exact_hunk_match,
1055                            match_kind,
1056                        ),
1057                    },
1058                )
1059                .collect::<Vec<_>>();
1060            let state = match mapped.len() {
1061                0 => "no_candidate",
1062                1 => "unique_candidate",
1063                _ => "ambiguous_candidates",
1064            };
1065            let association_source = format!("{}:{path}", event.id);
1066            associations.push(EventAssociation {
1067                id: format!("assoc-{}", short_hash(&association_source, 20)),
1068                event_id: event.id.clone(),
1069                path: path.clone(),
1070                state: state.to_string(),
1071                candidates: mapped,
1072            });
1073        }
1074    }
1075    associations
1076}
1077
1078fn candidate_sort_key(
1079    event: &NormalizedEvent,
1080    change: &GitChange,
1081    exact_hunk_match: bool,
1082    match_kind: &str,
1083) -> (u8, u8, u8, i64, String) {
1084    let direct_rank = match match_kind {
1085        "direct_path" => 0,
1086        "prebirth" => 1,
1087        "rename_lifetime" => 1,
1088        _ => 2,
1089    };
1090    let direction_rank = if change.committed_at_ms >= event.ts_ms {
1091        0
1092    } else {
1093        1
1094    };
1095    (
1096        u8::from(!exact_hunk_match),
1097        direct_rank,
1098        direction_rank,
1099        (change.committed_at_ms - event.ts_ms).abs(),
1100        change.commit_id.clone(),
1101    )
1102}
1103
1104fn candidate_evidence_bin(
1105    candidate_count: usize,
1106    index: usize,
1107    top_distance_ms: Option<i64>,
1108    second_distance_ms: Option<i64>,
1109    exact_hunk_match: bool,
1110    match_kind: &str,
1111) -> String {
1112    if index == 0 && exact_hunk_match && candidate_count == 1 {
1113        "exact_hunk_unique"
1114    } else if candidate_count == 1 && match_kind == "direct_path" {
1115        "unique_direct_lifetime"
1116    } else if candidate_count == 1 {
1117        "unique_rename_or_prebirth"
1118    } else if index == 0
1119        && top_distance_ms
1120            .zip(second_distance_ms)
1121            .is_some_and(|(top, second)| top.saturating_mul(4) <= second)
1122    {
1123        "multi_close_top"
1124    } else {
1125        "multi_other"
1126    }
1127    .to_string()
1128}
1129
1130fn edit_matches_hunk(edit: &EditSummary, hunk: &HunkFingerprint) -> bool {
1131    let before_matches = edit
1132        .before_hash
1133        .as_ref()
1134        .is_none_or(|hash| hunk.before_hash.as_ref() == Some(hash));
1135    let after_matches = edit
1136        .after_hash
1137        .as_ref()
1138        .is_none_or(|hash| hunk.after_hash.as_ref() == Some(hash));
1139    (edit.before_hash.is_some() || edit.after_hash.is_some()) && before_matches && after_matches
1140}
1141
1142fn current_blob_sizes(repo: &Path, head: &str) -> Result<HashMap<String, u64>, ExportError> {
1143    let output = git_bytes(repo, &["ls-tree", "-r", "-l", "-z", head])?;
1144    let mut sizes = HashMap::new();
1145    for record in output.split(|byte| *byte == 0) {
1146        if record.is_empty() {
1147            continue;
1148        }
1149        let text = utf8(record, "ls-tree record")?;
1150        let Some((metadata, path)) = text.split_once('\t') else {
1151            continue;
1152        };
1153        let size = metadata
1154            .split_whitespace()
1155            .last()
1156            .and_then(|value| value.parse::<u64>().ok());
1157        if let Some(size) = size {
1158            sizes.insert(path.to_string(), size);
1159        }
1160    }
1161    Ok(sizes)
1162}
1163
1164fn git_text(repo: &Path, args: &[&str]) -> Result<String, ExportError> {
1165    let output = git_bytes(repo, args)?;
1166    String::from_utf8(output)
1167        .map_err(|error| ExportError::new(format!("git returned invalid UTF-8: {error}")))
1168}
1169
1170fn git_bytes(repo: &Path, args: &[&str]) -> Result<Vec<u8>, ExportError> {
1171    let output = Command::new("git")
1172        .arg("-C")
1173        .arg(repo)
1174        .args(args)
1175        .output()
1176        .map_err(|error| ExportError::new(format!("cannot run git: {error}")))?;
1177    if !output.status.success() {
1178        return Err(command_error(&format!("git {}", args.join(" ")), &output));
1179    }
1180    Ok(output.stdout)
1181}
1182
1183fn command_error(label: &str, output: &std::process::Output) -> ExportError {
1184    ExportError::new(format!(
1185        "{label} failed with {}: {}",
1186        output.status,
1187        String::from_utf8_lossy(&output.stderr).trim()
1188    ))
1189}
1190
1191fn nul_tokens(output: &[u8]) -> Vec<String> {
1192    output
1193        .split(|byte| *byte == 0)
1194        .filter(|token| !token.is_empty())
1195        .map(|token| String::from_utf8_lossy(token).into_owned())
1196        .collect()
1197}
1198
1199fn parse_numstat(value: &str) -> u64 {
1200    value.parse().unwrap_or(0)
1201}
1202
1203fn trim_ascii(mut value: &[u8]) -> &[u8] {
1204    while value.first().is_some_and(u8::is_ascii_whitespace) {
1205        value = &value[1..];
1206    }
1207    while value.last().is_some_and(u8::is_ascii_whitespace) {
1208        value = &value[..value.len() - 1];
1209    }
1210    value
1211}
1212
1213fn utf8<'a>(value: &'a [u8], label: &str) -> Result<&'a str, ExportError> {
1214    std::str::from_utf8(value)
1215        .map_err(|error| ExportError::new(format!("invalid {label}: {error}")))
1216}
1217
1218fn content_fingerprint(value: &str) -> String {
1219    let normalized = value.replace("\r\n", "\n");
1220    short_hash(normalized.trim_end_matches('\n'), 24)
1221}
1222
1223fn format_timestamp(timestamp_ms: i64) -> String {
1224    DateTime::<Utc>::from_timestamp_millis(timestamp_ms)
1225        .map(|timestamp| timestamp.to_rfc3339_opts(SecondsFormat::Millis, true))
1226        .unwrap_or_else(|| timestamp_ms.to_string())
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231    use super::*;
1232    use std::process::Stdio;
1233
1234    fn git(repo: &Path, args: &[&str]) {
1235        let status = Command::new("git")
1236            .arg("-C")
1237            .arg(repo)
1238            .args(args)
1239            .stdout(Stdio::null())
1240            .status()
1241            .expect("run git");
1242        assert!(status.success(), "git {args:?}");
1243    }
1244
1245    #[test]
1246    fn hunk_fingerprints_match_normalized_edit_blocks() {
1247        let hunks = parse_hunk_fingerprints("@@ -1 +1 @@\n-old\n+new\n");
1248        assert_eq!(hunks.len(), 1);
1249        assert_eq!(hunks[0].before_hash, Some(content_fingerprint("old\n")));
1250        assert_eq!(hunks[0].after_hash, Some(content_fingerprint("new\n")));
1251    }
1252
1253    #[test]
1254    fn resumed_long_running_sessions_are_not_cut_off_by_future_mtime() {
1255        let since_ms = 1_000_000;
1256        assert!(candidate_mtime_may_contain_window(
1257            since_ms + 90 * 24 * 60 * 60 * 1_000,
1258            since_ms,
1259        ));
1260        assert!(!candidate_mtime_may_contain_window(
1261            since_ms - AUDIT_BEFORE_MS - 1,
1262            since_ms,
1263        ));
1264    }
1265
1266    #[test]
1267    fn delete_then_recreate_starts_a_new_lifetime() {
1268        let root = std::env::temp_dir().join(format!(
1269            "agent-session-longitudinal-{}-{}",
1270            std::process::id(),
1271            short_hash("delete-recreate", 8)
1272        ));
1273        let _ = fs::remove_dir_all(&root);
1274        fs::create_dir_all(&root).expect("create fixture repo");
1275        git(&root, &["init", "-q"]);
1276        git(&root, &["config", "user.name", "Fixture"]);
1277        git(&root, &["config", "user.email", "fixture@example.test"]);
1278        fs::write(root.join("file.txt"), "one\n").expect("write file");
1279        git(&root, &["add", "file.txt"]);
1280        git(&root, &["commit", "-q", "-m", "add"]);
1281        fs::remove_file(root.join("file.txt")).expect("remove file");
1282        git(&root, &["add", "-u"]);
1283        git(&root, &["commit", "-q", "-m", "delete"]);
1284        fs::write(root.join("file.txt"), "two\n").expect("recreate file");
1285        git(&root, &["add", "file.txt"]);
1286        git(&root, &["commit", "-q", "-m", "recreate"]);
1287
1288        let (_, lifetimes) = collect_git_history(&root, "HEAD", i64::MIN, i64::MAX)
1289            .expect("collect fixture history");
1290        let matching = lifetimes
1291            .iter()
1292            .filter(|lifetime| lifetime.paths == ["file.txt"])
1293            .collect::<Vec<_>>();
1294        assert_eq!(matching.len(), 2);
1295        assert!(matching[0].death_commit.is_some());
1296        assert!(matching[1].death_commit.is_none());
1297        fs::remove_dir_all(&root).expect("remove fixture repo");
1298    }
1299
1300    #[test]
1301    fn write_in_deleted_path_gap_only_targets_next_add() {
1302        let event = NormalizedEvent {
1303            id: "event".to_string(),
1304            session_id: "session".to_string(),
1305            vendor: "fixture".to_string(),
1306            model: None,
1307            ts_ms: 150,
1308            kind: "tool".to_string(),
1309            action: "write".to_string(),
1310            category: "file".to_string(),
1311            effect: "write".to_string(),
1312            status: "ok".to_string(),
1313            prompt_index: 0,
1314            paths: vec!["file.txt".to_string()],
1315            write_paths: vec!["file.txt".to_string()],
1316            path_groups: Vec::new(),
1317            edit_summary: None,
1318            input_tokens: 0,
1319            output_tokens: 0,
1320            cache_tokens: 0,
1321        };
1322        let changes = vec![
1323            GitChange {
1324                id: "delete".to_string(),
1325                commit_id: "old-commit".to_string(),
1326                committed_at_ms: 100,
1327                status: "D".to_string(),
1328                old_path: None,
1329                path: "file.txt".to_string(),
1330                additions: 0,
1331                deletions: 1,
1332                lifetime_id: "old".to_string(),
1333                is_merge: false,
1334                hunks: Vec::new(),
1335            },
1336            GitChange {
1337                id: "add".to_string(),
1338                commit_id: "new-commit".to_string(),
1339                committed_at_ms: 200,
1340                status: "A".to_string(),
1341                old_path: None,
1342                path: "file.txt".to_string(),
1343                additions: 1,
1344                deletions: 0,
1345                lifetime_id: "new".to_string(),
1346                is_merge: false,
1347                hunks: Vec::new(),
1348            },
1349        ];
1350        let lifetimes = vec![
1351            FileLifetime {
1352                id: "old".to_string(),
1353                paths: vec!["file.txt".to_string()],
1354                birth_commit: "birth-old".to_string(),
1355                birth_ms: 0,
1356                death_commit: Some("old-commit".to_string()),
1357                death_ms: Some(100),
1358                current_path: None,
1359                current_bytes: None,
1360                survives_to_head: false,
1361            },
1362            FileLifetime {
1363                id: "new".to_string(),
1364                paths: vec!["file.txt".to_string()],
1365                birth_commit: "new-commit".to_string(),
1366                birth_ms: 200,
1367                death_commit: None,
1368                death_ms: None,
1369                current_path: Some("file.txt".to_string()),
1370                current_bytes: Some(4),
1371                survives_to_head: true,
1372            },
1373        ];
1374
1375        let associations = associate_events(&[event], &changes, &lifetimes);
1376        assert_eq!(associations.len(), 1);
1377        assert_eq!(associations[0].state, "unique_candidate");
1378        assert_eq!(associations[0].candidates[0].commit_id, "new-commit");
1379        assert_eq!(associations[0].candidates[0].match_kind, "prebirth");
1380    }
1381
1382    #[test]
1383    fn segment_local_write_is_eligible_even_when_primary_effect_is_process() {
1384        let event = NormalizedEvent {
1385            id: "event".to_string(),
1386            session_id: "session".to_string(),
1387            vendor: "fixture".to_string(),
1388            model: None,
1389            ts_ms: 100,
1390            kind: "tool".to_string(),
1391            action: "exec_command".to_string(),
1392            category: "shell".to_string(),
1393            effect: "process".to_string(),
1394            status: "ok".to_string(),
1395            prompt_index: 0,
1396            paths: vec!["file.txt".to_string()],
1397            write_paths: vec!["file.txt".to_string()],
1398            path_groups: Vec::new(),
1399            edit_summary: None,
1400            input_tokens: 0,
1401            output_tokens: 0,
1402            cache_tokens: 0,
1403        };
1404        let change = GitChange {
1405            id: "change".to_string(),
1406            commit_id: "commit".to_string(),
1407            committed_at_ms: 110,
1408            status: "M".to_string(),
1409            old_path: None,
1410            path: "file.txt".to_string(),
1411            additions: 1,
1412            deletions: 0,
1413            lifetime_id: "lifetime".to_string(),
1414            is_merge: false,
1415            hunks: Vec::new(),
1416        };
1417        let lifetime = FileLifetime {
1418            id: "lifetime".to_string(),
1419            paths: vec!["file.txt".to_string()],
1420            birth_commit: "birth".to_string(),
1421            birth_ms: 0,
1422            death_commit: None,
1423            death_ms: None,
1424            current_path: Some("file.txt".to_string()),
1425            current_bytes: Some(4),
1426            survives_to_head: true,
1427        };
1428
1429        let associations = associate_events(&[event], &[change], &[lifetime]);
1430        assert_eq!(associations.len(), 1);
1431        assert_eq!(associations[0].path, "file.txt");
1432    }
1433
1434    #[test]
1435    fn session_paths_can_resolve_from_a_repository_subdirectory() {
1436        let repo = Path::new("/repo");
1437        let cwd = Path::new("/repo/nested");
1438        assert_eq!(
1439            repo_relative_session_path(repo, cwd, "../src/lib.rs").as_deref(),
1440            Some("src/lib.rs")
1441        );
1442        assert!(repo_relative_session_path(repo, cwd, "../../outside.txt").is_none());
1443    }
1444}