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