mj_controller/import/
edit_targets.rs1use 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
10pub(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 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
118pub 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}