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