Skip to main content

mj_controller/import/
edit_targets.rs

1use super::*;
2
3pub fn session_edit_targets(
4    transcript: &ClaudeTranscript,
5    profile_home: &Path,
6) -> Result<SessionEditTargets> {
7    session_edit_targets_with_scratch_prefixes(transcript, profile_home, &scratch_prefixes())
8}
9
10/// Directories whose repositories are throwaway workspaces rather than
11/// projects. A session that writes into one of them is still anchored on its
12/// own repository.
13pub(super) fn scratch_prefixes() -> Vec<PathBuf> {
14    let mut prefixes = Vec::new();
15    let mut remember = |path: PathBuf| {
16        let path = fs::canonicalize(&path).unwrap_or(path);
17        if !prefixes.contains(&path) {
18            prefixes.push(path);
19        }
20    };
21    remember(std::env::temp_dir());
22    for literal in ["/tmp", "/var/tmp", "/dev/shm"] {
23        remember(PathBuf::from(literal));
24    }
25    prefixes
26}
27
28pub(super) fn session_edit_targets_with_scratch_prefixes(
29    transcript: &ClaudeTranscript,
30    profile_home: &Path,
31    scratch_prefixes: &[PathBuf],
32) -> Result<SessionEditTargets> {
33    let profile_home =
34        fs::canonicalize(profile_home).unwrap_or_else(|_| profile_home.to_path_buf());
35    let mut paths = transcript
36        .edited_paths
37        .iter()
38        .map(|path| {
39            if path.is_absolute() {
40                path.clone()
41            } else {
42                transcript.cwd.join(path)
43            }
44        })
45        .filter(|path| {
46            let comparable = canonicalize_existing_ancestor(path);
47            !comparable.starts_with(&profile_home)
48        })
49        .collect::<Vec<_>>();
50    if paths.is_empty() {
51        paths.push(transcript.cwd.clone());
52    }
53
54    let cwd_root = git_root_for_path(&transcript.cwd)?.with_context(|| {
55        format!(
56            "session cwd is not in a usable Git worktree: {}",
57            transcript.cwd.display()
58        )
59    })?;
60    // The session's own repository is authoritative even when it lives under a
61    // temporary directory.
62    let mut git_roots = BTreeSet::from([cwd_root.clone()]);
63    let mut scratch_git_roots = BTreeSet::new();
64    let mut non_git_dirs = BTreeSet::new();
65    for path in paths {
66        if let Some(root) = git_root_for_path(&path)? {
67            if root != cwd_root && is_scratch_root(&root, scratch_prefixes) {
68                scratch_git_roots.insert(root);
69            } else {
70                git_roots.insert(root);
71            }
72        } else {
73            non_git_dirs.insert(edited_directory(&path));
74        }
75    }
76    Ok(SessionEditTargets {
77        git_roots: git_roots.into_iter().collect(),
78        scratch_git_roots: scratch_git_roots.into_iter().collect(),
79        non_git_dirs: non_git_dirs.into_iter().collect(),
80    })
81}
82
83pub(super) fn is_scratch_root(root: &Path, scratch_prefixes: &[PathBuf]) -> bool {
84    scratch_prefixes
85        .iter()
86        .any(|prefix| root.starts_with(prefix))
87}
88
89pub(super) fn canonicalize_existing_ancestor(path: &Path) -> PathBuf {
90    let mut existing = path;
91    let mut suffix = Vec::new();
92    loop {
93        if let Ok(mut canonical) = fs::canonicalize(existing) {
94            for component in suffix.iter().rev() {
95                canonical.push(component);
96            }
97            return canonical;
98        }
99        let Some(name) = existing.file_name() else {
100            return path.to_path_buf();
101        };
102        suffix.push(name.to_os_string());
103        let Some(parent) = existing.parent() else {
104            return path.to_path_buf();
105        };
106        existing = parent;
107    }
108}
109
110pub(super) fn edited_directory(path: &Path) -> PathBuf {
111    if path.is_dir() {
112        path.to_path_buf()
113    } else {
114        path.parent().unwrap_or(path).to_path_buf()
115    }
116}
117
118/// Finds the worktree containing a path, or its nearest existing ancestor.
119pub fn git_root_for_path(path: &Path) -> Result<Option<PathBuf>> {
120    let mut probe = edited_directory(path);
121    while !probe.is_dir() {
122        if !probe.pop() {
123            return Ok(None);
124        }
125    }
126    let output = mj_core::subprocess::run_with_input(
127        Command::new("git")
128            .args(["rev-parse", "--show-toplevel"])
129            .env("LC_ALL", "C")
130            .current_dir(&probe),
131        &[],
132    )
133    .with_context(|| format!("start git in {}", probe.display()))?;
134    if !output.status.success() {
135        let error = String::from_utf8_lossy(&output.stderr);
136        if error.contains("not a git repository") {
137            return Ok(None);
138        }
139        bail!(
140            "inspect Git repository in {}: {}",
141            probe.display(),
142            error.trim()
143        );
144    }
145    let root = String::from_utf8(output.stdout).context("decode Git repository root")?;
146    let root = PathBuf::from(root.trim());
147    Ok(Some(fs::canonicalize(&root).unwrap_or(root)))
148}