use crate::{aliases, detect, readers};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Serialize, Deserialize)]
pub struct CommitLink {
pub sha: String,
pub short_sha: String,
pub timestamp: DateTime<Utc>,
pub branch: String,
pub message: String,
pub active_sessions: Vec<String>,
}
const COMMITS_FILE: &str = ".context/commits.jsonl";
pub fn run_link_commit(repo_root: &Path, quiet: bool) -> Result<()> {
let context_dir = repo_root.join(".context");
if !context_dir.is_dir() {
if !quiet {
eprintln!(".context/ not found. Run `memex init` first.");
}
return Ok(());
}
let sha = git_output(repo_root, &["rev-parse", "HEAD"])?;
let short_sha = if sha.len() >= 7 { &sha[..7] } else { &sha };
let branch = git_output(repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
.unwrap_or_else(|_| "detached".to_string());
let message = git_output(repo_root, &["log", "-1", "--format=%s"]).unwrap_or_default();
let timestamp = git_commit_timestamp(repo_root, "HEAD").unwrap_or_else(Utc::now);
let active_sessions = find_active_sessions(repo_root, timestamp, &branch)?;
let link = CommitLink {
sha: sha.clone(),
short_sha: short_sha.to_string(),
timestamp,
branch,
message,
active_sessions,
};
let commits_path = repo_root.join(COMMITS_FILE);
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&commits_path)
.with_context(|| format!("open {}", commits_path.display()))?;
let line = serde_json::to_string(&link)?;
writeln!(file, "{}", line)?;
if !quiet {
println!(
"Linked commit {} to {} session(s).",
short_sha,
link.active_sessions.len()
);
}
Ok(())
}
pub fn load_commit_links(repo_root: &Path) -> Result<Vec<CommitLink>> {
let commits_path = repo_root.join(COMMITS_FILE);
if !commits_path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&commits_path)
.with_context(|| format!("read {}", commits_path.display()))?;
let mut links = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<CommitLink>(line) {
Ok(link) => links.push(link),
Err(e) => {
eprintln!("warning: skipping malformed commit link: {}", e);
}
}
}
Ok(links)
}
fn git_commit_timestamp(repo_root: &Path, commitish: &str) -> Option<DateTime<Utc>> {
let raw = git_output(repo_root, &["show", "-s", "--format=%cI", commitish]).ok()?;
let dt = DateTime::parse_from_rfc3339(&raw).ok()?;
Some(dt.with_timezone(&Utc))
}
fn find_active_sessions(
repo_root: &Path,
commit_ts: DateTime<Utc>,
commit_branch: &str,
) -> Result<Vec<String>> {
let repo_roots = aliases::ensure_current_repo_roots(repo_root)?;
let agents = detect::detect_agents(&repo_roots);
if !agents.any() {
return Ok(Vec::new());
}
let sessions = readers::read_all_sessions(&repo_roots, &agents, 3, true);
let mut selected = select_active_session_filenames(commit_ts, commit_branch, &sessions);
if selected.is_empty() {
selected = select_recent_context_files_by_mtime(repo_root, commit_ts)?;
}
Ok(selected)
}
fn select_active_session_filenames(
commit_ts: DateTime<Utc>,
commit_branch: &str,
sessions: &[crate::types::Session],
) -> Vec<String> {
let window_start = commit_ts - chrono::Duration::hours(2);
let window_end = commit_ts + chrono::Duration::minutes(5);
let prefer_branch = commit_branch != "detached" && commit_branch != "HEAD";
let mut candidates: Vec<(bool, DateTime<Utc>, String)> = Vec::new();
for s in sessions {
let (start, end) = match (s.started_at, s.ended_at) {
(Some(a), Some(b)) => (a, b),
(Some(a), None) => (a, a),
(None, Some(b)) => (b, b),
(None, None) => continue,
};
if end < window_start || start > window_end {
continue;
}
let branch_match = prefer_branch && s.branch.as_deref() == Some(commit_branch);
let rank_time = s.ended_at.or(s.started_at).unwrap_or(end);
candidates.push((branch_match, rank_time, s.filename()));
}
candidates.sort_by(|a, b| {
b.0.cmp(&a.0)
.then_with(|| b.1.cmp(&a.1))
.then_with(|| b.2.cmp(&a.2))
});
let mut seen = HashSet::new();
let mut out = Vec::new();
for (_, _, fname) in candidates {
if seen.insert(fname.clone()) {
out.push(fname);
if out.len() >= 8 {
break;
}
}
}
out
}
fn select_recent_context_files_by_mtime(
repo_root: &Path,
commit_ts: DateTime<Utc>,
) -> Result<Vec<String>> {
let sessions_dir = repo_root.join(".context/sessions");
if !sessions_dir.is_dir() {
return Ok(Vec::new());
}
let cutoff = commit_ts - chrono::Duration::hours(2);
let mut recent = Vec::new();
for entry in fs::read_dir(&sessions_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if !name.ends_with(".md") || name == ".gitkeep" {
continue;
}
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
let mod_time: DateTime<Utc> = modified.into();
if mod_time > cutoff {
recent.push(name);
}
}
}
}
recent.sort();
recent.reverse();
if recent.len() > 8 {
recent.truncate(8);
}
Ok(recent)
}
fn git_output(repo_root: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(repo_root)
.output()
.with_context(|| format!("run git {}", args.join(" ")))?;
if !output.status.success() {
anyhow::bail!("git {} failed", args.join(" "));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg(test)]
mod tests {
use super::select_active_session_filenames;
use crate::types::{Session, Turn};
use chrono::{TimeZone, Utc};
fn mk_session(
tool: &str,
session_id: &str,
branch: Option<&str>,
started_at: Option<chrono::DateTime<Utc>>,
ended_at: Option<chrono::DateTime<Utc>>,
) -> Session {
Session {
tool: tool.to_string(),
session_id: session_id.to_string(),
project_path: "/repo".to_string(),
branch: branch.map(|s| s.to_string()),
started_at,
ended_at,
turns: vec![Turn {
role: "user".to_string(),
content: "hi".to_string(),
timestamp: started_at,
}],
files_changed: Vec::new(),
}
}
#[test]
fn selects_only_sessions_overlapping_time_window() {
let commit_ts = Utc.with_ymd_and_hms(2026, 2, 10, 12, 0, 0).unwrap();
let in_window = mk_session(
"codex-cli",
"s1",
Some("feat"),
Some(commit_ts - chrono::Duration::hours(1)),
Some(commit_ts - chrono::Duration::minutes(5)),
);
let too_old = mk_session(
"codex-cli",
"s2",
Some("feat"),
Some(commit_ts - chrono::Duration::hours(5)),
Some(commit_ts - chrono::Duration::hours(3)),
);
let too_new = mk_session(
"codex-cli",
"s3",
Some("feat"),
Some(commit_ts + chrono::Duration::hours(1)),
Some(commit_ts + chrono::Duration::hours(2)),
);
let sessions = vec![too_old.clone(), in_window.clone(), too_new.clone()];
let out = select_active_session_filenames(commit_ts, "feat", &sessions);
assert_eq!(out.len(), 1);
assert_eq!(out[0], in_window.filename());
}
#[test]
fn prefers_branch_match_over_recency() {
let commit_ts = Utc.with_ymd_and_hms(2026, 2, 10, 12, 0, 0).unwrap();
let branch_match = mk_session(
"claude-code",
"s1",
Some("feat"),
Some(commit_ts - chrono::Duration::minutes(50)),
Some(commit_ts - chrono::Duration::minutes(40)),
);
let other_branch_more_recent = mk_session(
"claude-code",
"s2",
Some("main"),
Some(commit_ts - chrono::Duration::minutes(10)),
Some(commit_ts - chrono::Duration::minutes(1)),
);
let sessions = vec![other_branch_more_recent.clone(), branch_match.clone()];
let out = select_active_session_filenames(commit_ts, "feat", &sessions);
assert_eq!(out.len(), 2);
assert_eq!(out[0], branch_match.filename());
assert_eq!(out[1], other_branch_more_recent.filename());
}
}