1use std::collections::BTreeMap;
2use std::ffi::{OsStr, OsString};
3use std::path::{Path, PathBuf};
4
5use crate::git::Git;
6use crate::paths;
7use crate::{Error, Result};
8
9const TREEBOOT_ROOT_PATH: &str = "TREEBOOT_ROOT_PATH";
10const CODEX_SOURCE_TREE_PATH: &str = "CODEX_SOURCE_TREE_PATH";
11const CONDUCTOR_ROOT_PATH: &str = "CONDUCTOR_ROOT_PATH";
12const SUPERSET_ROOT_PATH: &str = "SUPERSET_ROOT_PATH";
13const CONDUCTOR_DEFAULT_BRANCH: &str = "CONDUCTOR_DEFAULT_BRANCH";
14const TREEBOOT_STRICT: &str = "TREEBOOT_STRICT";
15const TREEBOOT_DANGEROUSLY_ALLOW_SOURCES_OUTSIDE_ROOT: &str =
16 "TREEBOOT_DANGEROUSLY_ALLOW_SOURCES_OUTSIDE_ROOT";
17const TREEBOOT_DANGEROUSLY_ALLOW_TARGETS_OUTSIDE_WORKTREE: &str =
18 "TREEBOOT_DANGEROUSLY_ALLOW_TARGETS_OUTSIDE_WORKTREE";
19
20pub type Environment = BTreeMap<String, OsString>;
22
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct EnvironmentInput {
29 pub treeboot_root_path: Option<OsString>,
31 pub codex_source_tree_path: Option<OsString>,
33 pub conductor_root_path: Option<OsString>,
35 pub superset_root_path: Option<OsString>,
37 pub conductor_default_branch: Option<OsString>,
39 pub treeboot_strict: Option<OsString>,
41 pub treeboot_dangerously_allow_sources_outside_root: Option<OsString>,
44 pub treeboot_dangerously_allow_targets_outside_worktree: Option<OsString>,
47}
48
49impl EnvironmentInput {
50 #[must_use]
52 pub const fn empty() -> Self {
53 Self {
54 treeboot_root_path: None,
55 codex_source_tree_path: None,
56 conductor_root_path: None,
57 superset_root_path: None,
58 conductor_default_branch: None,
59 treeboot_strict: None,
60 treeboot_dangerously_allow_sources_outside_root: None,
61 treeboot_dangerously_allow_targets_outside_worktree: None,
62 }
63 }
64
65 #[must_use]
70 pub fn from_process_env() -> Self {
71 Self {
72 treeboot_root_path: std::env::var_os(TREEBOOT_ROOT_PATH),
73 codex_source_tree_path: std::env::var_os(CODEX_SOURCE_TREE_PATH),
74 conductor_root_path: std::env::var_os(CONDUCTOR_ROOT_PATH),
75 superset_root_path: std::env::var_os(SUPERSET_ROOT_PATH),
76 conductor_default_branch: std::env::var_os(CONDUCTOR_DEFAULT_BRANCH),
77 treeboot_strict: std::env::var_os(TREEBOOT_STRICT),
78 treeboot_dangerously_allow_sources_outside_root: std::env::var_os(
79 TREEBOOT_DANGEROUSLY_ALLOW_SOURCES_OUTSIDE_ROOT,
80 ),
81 treeboot_dangerously_allow_targets_outside_worktree: std::env::var_os(
82 TREEBOOT_DANGEROUSLY_ALLOW_TARGETS_OUTSIDE_WORKTREE,
83 ),
84 }
85 }
86
87 pub fn root_candidates(&self) -> impl Iterator<Item = (&'static str, &OsStr)> {
89 [
90 (
91 TREEBOOT_ROOT_PATH,
92 non_empty_value(&self.treeboot_root_path),
93 ),
94 (
95 CODEX_SOURCE_TREE_PATH,
96 non_empty_value(&self.codex_source_tree_path),
97 ),
98 (
99 CONDUCTOR_ROOT_PATH,
100 non_empty_value(&self.conductor_root_path),
101 ),
102 (
103 SUPERSET_ROOT_PATH,
104 non_empty_value(&self.superset_root_path),
105 ),
106 ]
107 .into_iter()
108 .filter_map(|(name, value)| value.map(|value| (name, value)))
109 }
110
111 #[must_use]
113 pub fn conductor_default_branch(&self) -> Option<&OsStr> {
114 non_empty_value(&self.conductor_default_branch)
115 }
116}
117
118#[derive(Debug, Clone, Default, PartialEq, Eq)]
120pub struct WorktreeOptions {
121 pub cwd: Option<PathBuf>,
123 pub root: Option<PathBuf>,
125 pub environment: EnvironmentInput,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct Worktree {
132 pub root_path: PathBuf,
134 pub worktree_path: PathBuf,
136 pub default_branch: String,
138 pub environment: Environment,
140}
141
142impl Worktree {
143 pub fn discover(options: WorktreeOptions) -> Result<Self> {
151 resolve(&options)
152 }
153}
154
155pub(crate) fn resolve(options: &WorktreeOptions) -> Result<Worktree> {
156 let cwd = options.cwd.as_ref().map_or_else(
157 || std::env::current_dir().map_err(|source| Error::CurrentDir { source }),
158 |path| Ok(path.clone()),
159 )?;
160 let git = Git::new(&cwd);
161 let worktree_path = normalize_existing_path(&git.worktree_path()?)?;
162 let root_path = discover_root_path(options, &cwd, &git)?;
163 let default_branch = discover_default_branch(options, &git)?;
164 let environment = build_environment(&root_path, &worktree_path, &default_branch);
165
166 Ok(Worktree {
167 root_path,
168 worktree_path,
169 default_branch,
170 environment,
171 })
172}
173
174fn discover_root_path(options: &WorktreeOptions, cwd: &Path, git: &Git) -> Result<PathBuf> {
175 if let Some(path) = &options.root {
176 return normalize_existing_path(&resolve_input_path(cwd, path));
177 }
178
179 if let Some((_key, value)) = options.environment.root_candidates().next() {
180 return normalize_existing_path(&resolve_input_path(cwd, &PathBuf::from(value)));
181 }
182
183 git.main_worktree_path()?
184 .map(|path| normalize_existing_path(&path))
185 .transpose()?
186 .ok_or(Error::RootPathNotFound)
187}
188
189fn discover_default_branch(options: &WorktreeOptions, git: &Git) -> Result<String> {
190 if let Some(branch) = options.environment.conductor_default_branch() {
191 return Ok(branch.to_string_lossy().into_owned());
192 }
193
194 git.default_branch()
195}
196
197fn build_environment(root_path: &Path, worktree_path: &Path, default_branch: &str) -> Environment {
198 let root = root_path.as_os_str().to_os_string();
199 let worktree = worktree_path.as_os_str().to_os_string();
200 let branch = OsString::from(default_branch);
201
202 let mut env = Environment::new();
203 env.insert("TREEBOOT_ROOT_PATH".to_owned(), root.clone());
204 env.insert("TREEBOOT_WORKTREE_PATH".to_owned(), worktree.clone());
205 env.insert("TREEBOOT_DEFAULT_BRANCH".to_owned(), branch.clone());
206 env.insert("GIT_SOURCE_TREE_PATH".to_owned(), root.clone());
207 env.insert("GIT_WORKTREE_PATH".to_owned(), worktree.clone());
208 env.insert("CODEX_SOURCE_TREE_PATH".to_owned(), root.clone());
209 env.insert("CODEX_WORKTREE_PATH".to_owned(), worktree.clone());
210 env.insert("CONDUCTOR_ROOT_PATH".to_owned(), root.clone());
211 env.insert("CONDUCTOR_WORKSPACE_PATH".to_owned(), worktree);
212 env.insert("CONDUCTOR_DEFAULT_BRANCH".to_owned(), branch);
213 env.insert("SUPERSET_ROOT_PATH".to_owned(), root);
214 env
215}
216
217fn non_empty_value(value: &Option<OsString>) -> Option<&OsStr> {
218 value.as_deref().filter(|value| !value.is_empty())
219}
220
221pub(crate) fn resolve_worktree_path(worktree_path: &Path, path: &Path) -> PathBuf {
222 if path.is_absolute() {
223 path.to_path_buf()
224 } else {
225 worktree_path.join(path)
226 }
227}
228
229fn resolve_input_path(cwd: &Path, path: &Path) -> PathBuf {
230 if path.is_absolute() {
231 path.to_path_buf()
232 } else {
233 cwd.join(path)
234 }
235}
236
237fn normalize_existing_path(path: &Path) -> Result<PathBuf> {
238 paths::canonicalize(path).map_err(|source| Error::NormalizePath {
239 path: path.to_path_buf(),
240 source,
241 })
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn environment_input_root_candidates_should_ignore_empty_values_in_order() {
250 let environment = EnvironmentInput {
251 treeboot_root_path: Some(OsString::new()),
252 codex_source_tree_path: Some(OsString::from("/codex")),
253 conductor_root_path: Some(OsString::from("/conductor")),
254 superset_root_path: Some(OsString::from("/superset")),
255 ..EnvironmentInput::empty()
256 };
257
258 let candidates = environment
259 .root_candidates()
260 .map(|(name, value)| (name, value.to_os_string()))
261 .collect::<Vec<_>>();
262
263 assert_eq!(
264 candidates,
265 vec![
266 (CODEX_SOURCE_TREE_PATH, OsString::from("/codex")),
267 (CONDUCTOR_ROOT_PATH, OsString::from("/conductor")),
268 (SUPERSET_ROOT_PATH, OsString::from("/superset")),
269 ]
270 );
271 }
272
273 #[test]
274 fn environment_input_conductor_default_branch_should_ignore_empty_value() {
275 let environment = EnvironmentInput {
276 conductor_default_branch: Some(OsString::new()),
277 ..EnvironmentInput::empty()
278 };
279
280 assert_eq!(environment.conductor_default_branch(), None);
281 }
282
283 #[test]
284 fn build_environment_should_set_codex_worktree_to_worktree_path() {
285 let root = Path::new("/repo");
286 let worktree = Path::new("/repo-worktree");
287 let env = build_environment(root, worktree, "main");
288
289 assert_eq!(
290 env.get("CODEX_WORKTREE_PATH"),
291 Some(&OsString::from("/repo-worktree"))
292 );
293 }
294
295 #[test]
296 fn resolve_worktree_path_should_join_relative_paths() {
297 let worktree = Path::new("/repo-worktree");
298
299 assert_eq!(
300 resolve_worktree_path(worktree, Path::new(".env")),
301 PathBuf::from("/repo-worktree/.env")
302 );
303 }
304
305 #[test]
306 fn resolve_worktree_path_should_keep_absolute_paths() {
307 let worktree = Path::new("/repo-worktree");
308
309 assert_eq!(
310 resolve_worktree_path(worktree, Path::new("/tmp/.env")),
311 PathBuf::from("/tmp/.env")
312 );
313 }
314}