Skip to main content

mj_controller/import/
safety.rs

1use super::*;
2
3pub fn import_safety_issues(targets: &SessionEditTargets) -> Result<ImportSafetyIssues> {
4    let mut dirty_git_roots = Vec::new();
5    let mut has_untracked_files = false;
6    for root in &targets.git_roots {
7        let output = Command::new("git")
8            .args(["status", "--porcelain=v1", "--untracked-files=normal"])
9            .current_dir(root)
10            .output()
11            .with_context(|| format!("inspect Git status in {}", root.display()))?;
12        ensure!(
13            output.status.success(),
14            "could not inspect Git status in {}",
15            root.display()
16        );
17        let (tracked, untracked) = String::from_utf8_lossy(&output.stdout).lines().fold(
18            (0_usize, 0_usize),
19            |(tracked, untracked), line| {
20                if line.starts_with("??") {
21                    (tracked, untracked + 1)
22                } else {
23                    (tracked + 1, untracked)
24                }
25            },
26        );
27        has_untracked_files |= untracked > 0;
28        if tracked + untracked > 0 {
29            let mut parts = Vec::new();
30            if tracked > 0 {
31                parts.push(format!(
32                    "{tracked} tracked change{}",
33                    if tracked == 1 { "" } else { "s" }
34                ));
35            }
36            if untracked > 0 {
37                parts.push(format!(
38                    "{untracked} untracked path{}",
39                    if untracked == 1 { "" } else { "s" }
40                ));
41            }
42            dirty_git_roots.push((root.clone(), parts.join(" ยท ")));
43        }
44    }
45    Ok(ImportSafetyIssues {
46        dirty_git_roots,
47        omitted_non_git_dirs: targets.non_git_dirs.clone(),
48        scratch_git_roots: targets.scratch_git_roots.clone(),
49        has_untracked_files,
50    })
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ImportedClaudeSession {
55    pub session_id: String,
56    pub native_session_id: String,
57    pub source_jsonl: PathBuf,
58    pub source_cwd: PathBuf,
59    pub bundle_id: String,
60    pub archive_path: PathBuf,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum ImportArchiveProgress {
65    Repository {
66        current: usize,
67        total: usize,
68        id: String,
69    },
70    UntrackedFile {
71        repository_id: String,
72        current: usize,
73        total: usize,
74        path: PathBuf,
75    },
76    WritingArchive,
77}
78
79pub struct ImportControl<'a> {
80    pub cancelled: &'a AtomicBool,
81    pub progress: &'a (dyn Fn(ImportArchiveProgress) + Sync),
82    pub include_untracked: bool,
83}
84
85impl ImportControl<'_> {
86    pub(super) fn check_cancelled(&self) -> Result<()> {
87        ensure!(!self.cancelled.load(Ordering::Acquire), "import cancelled");
88        Ok(())
89    }
90
91    pub(super) fn report(&self, progress: ImportArchiveProgress) -> Result<()> {
92        self.check_cancelled()?;
93        (self.progress)(progress);
94        Ok(())
95    }
96}
97
98/// Resolve a harness's configuration home without ever modifying it.
99///
100/// The environment override wins; otherwise the harness's default directory
101/// beneath the user's home is used, the same pair `mj setup` discovers.
102pub fn harness_config_home(kind: HarnessKind) -> Result<PathBuf> {
103    let name = kind.display_name();
104    let home = std::env::var_os(kind.home_env())
105        .map(|path| kind.home_from_environment(path))
106        .or_else(|| dirs::home_dir().map(|home| home.join(kind.default_home_leaf())))
107        .with_context(|| format!("cannot determine {name} home; set {}", kind.home_env()))?;
108    ensure!(
109        home.is_dir(),
110        "{name} home is not a directory: {}",
111        home.display()
112    );
113    Ok(home)
114}