Skip to main content

treeboot_core/
context.rs

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
19/// Environment variable map built for scripts and configured commands.
20pub type Environment = BTreeMap<String, OsString>;
21
22/// Explicit environment variable input used while resolving treeboot behavior.
23///
24/// This type only models the process environment variables that treeboot reads.
25/// Unknown process environment variables are intentionally not captured.
26#[derive(Debug, Clone, Default, PartialEq, Eq)]
27pub struct EnvironmentInput {
28    /// Root checkout override from `TREEBOOT_ROOT_PATH`.
29    pub treeboot_root_path: Option<OsString>,
30    /// Root checkout compatibility override from `CODEX_SOURCE_TREE_PATH`.
31    pub codex_source_tree_path: Option<OsString>,
32    /// Root checkout compatibility override from `CONDUCTOR_ROOT_PATH`.
33    pub conductor_root_path: Option<OsString>,
34    /// Root checkout compatibility override from `SUPERSET_ROOT_PATH`.
35    pub superset_root_path: Option<OsString>,
36    /// Default branch compatibility override from `CONDUCTOR_DEFAULT_BRANCH`.
37    pub conductor_default_branch: Option<OsString>,
38    /// Runtime strict-mode override from `TREEBOOT_STRICT`.
39    pub treeboot_strict: Option<OsString>,
40    /// Runtime source-boundary override from
41    /// `TREEBOOT_DANGEROUSLY_ALLOW_SOURCES_OUTSIDE_ROOT`.
42    pub treeboot_dangerously_allow_sources_outside_root: Option<OsString>,
43    /// Runtime target-boundary override from
44    /// `TREEBOOT_DANGEROUSLY_ALLOW_TARGETS_OUTSIDE_WORKTREE`.
45    pub treeboot_dangerously_allow_targets_outside_worktree: Option<OsString>,
46}
47
48impl EnvironmentInput {
49    /// Returns an empty environment input.
50    #[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    /// Captures treeboot's known environment variables from the process.
65    ///
66    /// Empty values are captured as-is; lookup helpers ignore empty values to
67    /// preserve the CLI compatibility behavior.
68    #[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    /// Returns non-empty root path candidates in treeboot precedence order.
87    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    /// Returns the non-empty `CONDUCTOR_DEFAULT_BRANCH` value.
111    #[must_use]
112    pub fn conductor_default_branch(&self) -> Option<&OsStr> {
113        non_empty_value(&self.conductor_default_branch)
114    }
115}
116
117/// Options for discovering a Git worktree.
118#[derive(Debug, Clone, Default, PartialEq, Eq)]
119pub struct WorktreeOptions {
120    /// Directory from which discovery starts. Defaults to the process cwd.
121    pub cwd: Option<PathBuf>,
122    /// Overrides the root checkout used as the file-operation source.
123    pub root: Option<PathBuf>,
124    /// Explicit environment input used for compatibility discovery.
125    pub environment: EnvironmentInput,
126}
127
128/// Resolved Git worktree metadata used by treeboot operations.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Worktree {
131    /// Source checkout used for file operations.
132    pub root_path: PathBuf,
133    /// Current worktree root where targets and commands are anchored.
134    pub worktree_path: PathBuf,
135    /// Best-effort default branch name.
136    pub default_branch: String,
137    /// Canonical treeboot variables and compatibility aliases.
138    pub environment: Environment,
139}
140
141impl Worktree {
142    /// Discovers worktree metadata from the provided options.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if the current directory cannot be read, the directory
147    /// is not inside a Git worktree, Git discovery fails, or no root checkout
148    /// path can be determined.
149    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}