magellan 4.9.1

Deterministic codebase mapping tool for local development
Documentation
use anyhow::{Context, Result};
use git2::{Repository, Sort};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
use walkdir::WalkDir;

use crate::manifest::detect_include_paths_from_root;
use crate::temporal::SnapshotFileInput;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemporalCommit {
    pub commit_oid: String,
    pub tree_oid: String,
    pub author_time: i64,
    pub commit_time: i64,
    pub commit_message: String,
    pub parent_oids: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TemporalSweepSelection {
    pub every_n: Option<usize>,
    pub tags_only: bool,
    pub merge_commits_only: bool,
    pub since_commit_time: Option<i64>,
    pub until_commit_time: Option<i64>,
}

fn tagged_commit_oids(repo: &Repository) -> Result<BTreeSet<String>> {
    let mut tagged = BTreeSet::new();
    let tag_names = repo.tag_names(None)?;
    for name in tag_names.iter().flatten().flatten() {
        if let Ok(object) = repo.revparse_single(name) {
            if let Ok(commit) = object.peel_to_commit() {
                tagged.insert(commit.id().to_string());
            }
        }
    }
    Ok(tagged)
}

fn apply_selection(
    commits: Vec<TemporalCommit>,
    selection: &TemporalSweepSelection,
    tagged_commits: &BTreeSet<String>,
) -> Vec<TemporalCommit> {
    let mut selected: Vec<TemporalCommit> = commits
        .into_iter()
        .filter(|commit| {
            selection
                .since_commit_time
                .map(|since| commit.commit_time >= since)
                .unwrap_or(true)
        })
        .filter(|commit| {
            selection
                .until_commit_time
                .map(|until| commit.commit_time <= until)
                .unwrap_or(true)
        })
        .filter(|commit| !selection.merge_commits_only || commit.parent_oids.len() > 1)
        .filter(|commit| !selection.tags_only || tagged_commits.contains(&commit.commit_oid))
        .collect();

    if let Some(n) = selection.every_n {
        let stride = n.max(1);
        selected = selected
            .into_iter()
            .enumerate()
            .filter_map(|(idx, commit)| (idx % stride == 0).then_some(commit))
            .collect();
    }

    selected
}

pub fn list_commits(
    repo_root: &Path,
    selection: &TemporalSweepSelection,
) -> Result<Vec<TemporalCommit>> {
    let repo = Repository::open(repo_root)
        .with_context(|| format!("Failed to open git repository at {}", repo_root.display()))?;
    let mut revwalk = repo.revwalk()?;
    revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?;
    revwalk.push_head()?;

    let mut commits = Vec::new();
    for oid in revwalk {
        let oid = oid?;
        let commit = repo.find_commit(oid)?;
        commits.push(TemporalCommit {
            commit_oid: oid.to_string(),
            tree_oid: commit.tree_id().to_string(),
            author_time: commit.author().when().seconds(),
            commit_time: commit.committer().when().seconds(),
            commit_message: commit.summary().ok().flatten().unwrap_or("").to_string(),
            parent_oids: commit
                .parent_ids()
                .map(|parent| parent.to_string())
                .collect(),
        });
    }

    let tagged_commits = tagged_commit_oids(&repo)?;
    Ok(apply_selection(commits, selection, &tagged_commits))
}

#[derive(Debug)]
pub struct ManagedWorktree {
    repo_root: PathBuf,
    path: PathBuf,
    _base_dir: TempDir,
}

impl ManagedWorktree {
    pub fn path(&self) -> &Path {
        &self.path
    }

    fn cleanup(&self) {
        let _ = Command::new("git")
            .arg("-C")
            .arg(&self.repo_root)
            .args(["worktree", "remove", "--force"])
            .arg(&self.path)
            .output();
        let _ = Command::new("git")
            .arg("-C")
            .arg(&self.repo_root)
            .args(["worktree", "prune"])
            .output();
    }
}

impl Drop for ManagedWorktree {
    fn drop(&mut self) {
        self.cleanup();
    }
}

pub fn create_detached_worktree(repo_root: &Path, commit_oid: &str) -> Result<ManagedWorktree> {
    let base_dir = tempfile::Builder::new()
        .prefix("magellan-temporal-sweep-")
        .tempdir()
        .context("Failed to create worktree directory for git checkout")?;
    let worktree_path = base_dir.path().join("checkout");

    let output = Command::new("git")
        .arg("-C")
        .arg(repo_root)
        .args(["worktree", "add", "--detach"])
        .arg(&worktree_path)
        .arg(commit_oid)
        .output()
        .with_context(|| {
            format!(
                "Failed to execute git worktree add for '{}' in {}",
                commit_oid,
                repo_root.display()
            )
        })?;

    if !output.status.success() {
        anyhow::bail!(
            "git worktree add failed for '{}': {}",
            commit_oid,
            String::from_utf8_lossy(&output.stderr)
        );
    }

    Ok(ManagedWorktree {
        repo_root: repo_root.to_path_buf(),
        path: worktree_path,
        _base_dir: base_dir,
    })
}

pub fn collect_snapshot_files(repo_root: &Path) -> Result<Vec<SnapshotFileInput>> {
    let include_paths = detect_include_paths_from_root(repo_root);
    let mut candidate_paths = BTreeSet::new();

    for include_path in include_paths {
        let include_root = repo_root.join(include_path.trim_end_matches('/'));
        if !include_root.exists() {
            continue;
        }

        for entry in WalkDir::new(&include_root) {
            let entry = entry?;
            if !entry.file_type().is_file() {
                continue;
            }

            let path = entry.path();
            if path
                .components()
                .any(|component| component.as_os_str() == ".git")
            {
                continue;
            }
            candidate_paths.insert(path.to_path_buf());
        }
    }

    let mut files = Vec::with_capacity(candidate_paths.len());
    for path in candidate_paths {
        let source = std::fs::read(&path)
            .with_context(|| format!("Failed to read snapshot file {}", path.display()))?;
        files.push(SnapshotFileInput { path, source });
    }

    Ok(files)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_commit(oid: &str, commit_time: i64, parents: usize) -> TemporalCommit {
        TemporalCommit {
            commit_oid: oid.to_string(),
            tree_oid: format!("tree-{oid}"),
            author_time: commit_time,
            commit_time,
            commit_message: oid.to_string(),
            parent_oids: (0..parents).map(|idx| format!("p{idx}")).collect(),
        }
    }

    #[test]
    fn test_apply_selection_every_n() {
        let commits = vec![
            sample_commit("c1", 1, 1),
            sample_commit("c2", 2, 1),
            sample_commit("c3", 3, 1),
            sample_commit("c4", 4, 1),
        ];
        let selected = apply_selection(
            commits,
            &TemporalSweepSelection {
                every_n: Some(2),
                ..TemporalSweepSelection::default()
            },
            &BTreeSet::new(),
        );
        let oids: Vec<_> = selected
            .into_iter()
            .map(|commit| commit.commit_oid)
            .collect();
        assert_eq!(oids, vec!["c1".to_string(), "c3".to_string()]);
    }

    #[test]
    fn test_apply_selection_combined_filters() {
        let commits = vec![
            sample_commit("c1", 10, 1),
            sample_commit("c2", 20, 2),
            sample_commit("c3", 30, 1),
            sample_commit("c4", 40, 2),
        ];
        let tagged: BTreeSet<String> = ["c2".to_string(), "c4".to_string()].into_iter().collect();
        let selected = apply_selection(
            commits,
            &TemporalSweepSelection {
                tags_only: true,
                merge_commits_only: true,
                since_commit_time: Some(15),
                until_commit_time: Some(35),
                ..TemporalSweepSelection::default()
            },
            &tagged,
        );
        let oids: Vec<_> = selected
            .into_iter()
            .map(|commit| commit.commit_oid)
            .collect();
        assert_eq!(oids, vec!["c2".to_string()]);
    }
}