Skip to main content

ito_core/
repo_paths.rs

1//! Repository and worktree path resolution.
2//!
3//! This module contains business logic for computing repository roots and
4//! worktree layout paths. Adapter layers (CLI, web) should call these APIs and
5//! only format output.
6
7use crate::errors::{CoreError, CoreResult};
8use ito_config::ConfigContext;
9use ito_config::ito_dir::{absolutize_and_normalize, get_ito_path, lexical_normalize};
10use ito_config::load_cascading_project_config;
11use ito_config::types::{ItoConfig, WorktreeStrategy};
12use std::path::{Path, PathBuf};
13
14/// Distinguishes bare repositories from non-bare working trees.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum GitRepoKind {
17    /// The current directory resolves to a bare Git repository (no worktree).
18    Bare,
19    /// The current directory resolves to a non-bare Git repository.
20    NonBare,
21}
22
23impl GitRepoKind {
24    /// Returns true when this represents a bare repository.
25    pub fn is_bare(self) -> bool {
26        match self {
27            Self::Bare => true,
28            Self::NonBare => false,
29        }
30    }
31}
32
33/// Whether worktrees are enabled for the current project configuration.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum WorktreeFeature {
36    /// Worktrees are enabled.
37    Enabled,
38    /// Worktrees are disabled.
39    Disabled,
40}
41
42impl WorktreeFeature {
43    /// Returns true when worktrees are enabled.
44    pub fn is_enabled(self) -> bool {
45        match self {
46            Self::Enabled => true,
47            Self::Disabled => false,
48        }
49    }
50}
51
52/// Resolved repository and Ito roots for a given invocation.
53#[derive(Debug, Clone)]
54pub struct ResolvedEnv {
55    /// The selected working-tree root (Git top-level if inside a worktree, or
56    /// nearest Ito root/cwd fallback), normalized to an absolute path when possible.
57    pub worktree_root: PathBuf,
58    /// The repository's common project root (when available), otherwise the worktree root.
59    pub project_root: PathBuf,
60    /// The resolved Ito directory path for this invocation.
61    pub ito_root: PathBuf,
62    /// Whether the repository is bare.
63    pub git_repo_kind: GitRepoKind,
64}
65
66/// Selector for a specific worktree path.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum WorktreeSelector {
69    /// The main/default worktree.
70    Main,
71    /// A worktree for a branch name.
72    Branch(String),
73    /// A worktree for a change ID/name.
74    Change(String),
75}
76
77/// Derived worktree layout paths for the current project.
78#[derive(Debug, Clone)]
79pub struct ResolvedWorktreePaths {
80    /// Whether worktrees are enabled.
81    pub feature: WorktreeFeature,
82    /// Configured worktree strategy.
83    pub strategy: WorktreeStrategy,
84    /// Root directory that contains branch/change worktrees.
85    pub worktrees_root: Option<PathBuf>,
86    /// Root directory of the main worktree.
87    pub main_worktree_root: Option<PathBuf>,
88}
89
90impl ResolvedWorktreePaths {
91    /// Resolves a worktree path for the given selector.
92    pub fn path_for_selector(&self, selector: &WorktreeSelector) -> Option<PathBuf> {
93        if !self.feature.is_enabled() {
94            return None;
95        }
96
97        match selector {
98            WorktreeSelector::Main => self.main_worktree_root.clone(),
99            WorktreeSelector::Branch(branch) => {
100                self.worktrees_root.as_ref().map(|p| p.join(branch))
101            }
102            WorktreeSelector::Change(change) => {
103                self.worktrees_root.as_ref().map(|p| p.join(change))
104            }
105        }
106    }
107}
108
109/// Resolve repository and Ito-related roots for the current working directory.
110///
111/// This function determines the worktree root, project root, Ito directory, and
112/// whether the current repository is bare.
113pub fn resolve_env(ctx: &ConfigContext) -> CoreResult<ResolvedEnv> {
114    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
115    resolve_env_from_cwd(&cwd, ctx)
116}
117
118/// Resolve repository and Ito-related roots for a specific `cwd`.
119pub fn resolve_env_from_cwd(cwd: &Path, ctx: &ConfigContext) -> CoreResult<ResolvedEnv> {
120    let is_bare = git_is_bare_repo(cwd).unwrap_or(false);
121    let git_repo_kind = if is_bare {
122        GitRepoKind::Bare
123    } else {
124        GitRepoKind::NonBare
125    };
126
127    // If we're inside a git worktree, prefer the actual worktree root.
128    let worktree_root = git_show_toplevel(cwd)
129        .or_else(|| find_nearest_ito_root(cwd))
130        .unwrap_or_else(|| cwd.to_path_buf());
131    let worktree_root = absolutize_and_normalize(&worktree_root)
132        .unwrap_or_else(|_| lexical_normalize(&worktree_root));
133
134    let ito_root = get_ito_path(&worktree_root, ctx);
135    if !ito_root.is_dir() {
136        if git_repo_kind.is_bare() {
137            return Err(CoreError::validation(bare_repo_error_message_raw(cwd)));
138        }
139        return Err(CoreError::not_found(format!(
140            "No Ito directory found (expected {}). Run `ito init <project-dir>` or cd into an initialized worktree.",
141            ito_root.to_string_lossy()
142        )));
143    }
144
145    let project_root = git_common_root(&worktree_root).unwrap_or_else(|| worktree_root.clone());
146    let project_root = absolutize_and_normalize(&project_root)
147        .unwrap_or_else(|_| lexical_normalize(&project_root));
148
149    Ok(ResolvedEnv {
150        worktree_root,
151        project_root,
152        ito_root,
153        git_repo_kind,
154    })
155}
156
157/// Computes worktree layout paths from the resolved environment and repository-local configuration.
158pub fn resolve_worktree_paths(
159    env: &ResolvedEnv,
160    ctx: &ConfigContext,
161) -> CoreResult<ResolvedWorktreePaths> {
162    // Load config relative to the current worktree root so repo-local sources
163    // like `ito.json` and `.ito.json` resolve within the working checkout.
164    let cfg = load_cascading_project_config(&env.worktree_root, &env.ito_root, ctx);
165    let typed: ItoConfig = serde_json::from_value(cfg.merged)
166        .map_err(|e| CoreError::serde("parse Ito configuration", e.to_string()))?;
167
168    let wt = typed.worktrees;
169    let feature = if wt.enabled {
170        WorktreeFeature::Enabled
171    } else {
172        WorktreeFeature::Disabled
173    };
174    let strategy = wt.strategy;
175    let default_branch = wt.default_branch;
176    let dir_name = wt.layout.dir_name;
177
178    let base = resolve_base_dir(env, &wt.layout.base_dir);
179
180    let (worktrees_root, main_worktree_root) = if feature.is_enabled() {
181        match strategy {
182            WorktreeStrategy::CheckoutSubdir => {
183                let wt_root = base.join(format!(".{dir_name}"));
184                (Some(wt_root), Some(base))
185            }
186            WorktreeStrategy::CheckoutSiblings => {
187                let project_name = base
188                    .file_name()
189                    .and_then(|s| s.to_str())
190                    .unwrap_or("project");
191                let parent = base.parent().unwrap_or(&base);
192                let wt_root = parent.join(format!("{project_name}-{dir_name}"));
193                (Some(wt_root), Some(base))
194            }
195            WorktreeStrategy::BareControlSiblings => {
196                let wt_root = base.join(&dir_name);
197                let main = base.join(&default_branch);
198                (Some(wt_root), Some(main))
199            }
200        }
201    } else {
202        (None, None)
203    };
204
205    Ok(ResolvedWorktreePaths {
206        feature,
207        strategy,
208        worktrees_root,
209        main_worktree_root,
210    })
211}
212
213fn resolve_base_dir(env: &ResolvedEnv, configured: &Option<String>) -> PathBuf {
214    let Some(raw) = configured
215        .as_ref()
216        .map(|s| s.trim())
217        .filter(|s| !s.is_empty())
218    else {
219        return env.project_root.clone();
220    };
221
222    let p = PathBuf::from(raw);
223    let p = if p.is_absolute() {
224        p
225    } else {
226        env.project_root.join(p)
227    };
228    absolutize_and_normalize(&p).unwrap_or_else(|_| lexical_normalize(&p))
229}
230
231fn bare_repo_error_message_raw(cwd: &Path) -> String {
232    // Best-effort hint: in bare/control layouts, `main/` is typically the primary worktree.
233    let main = cwd.join("main");
234    let master = cwd.join("master");
235    let hint = if main.is_dir() {
236        format!("cd \"{}\"", main.to_string_lossy())
237    } else if master.is_dir() {
238        format!("cd \"{}\"", master.to_string_lossy())
239    } else {
240        "cd <worktree-dir>".to_string()
241    };
242
243    format!("Ito must be run from a git worktree (not the bare repository). Try: {hint}")
244}
245
246fn find_nearest_ito_root(start: &Path) -> Option<PathBuf> {
247    let mut cur = start.to_path_buf();
248    loop {
249        if cur.join(".ito").is_dir() {
250            return Some(cur);
251        }
252        let parent = cur.parent()?.to_path_buf();
253        cur = parent;
254    }
255}
256
257fn git_show_toplevel(cwd: &Path) -> Option<PathBuf> {
258    let out = git_output(
259        cwd,
260        &["rev-parse", "--path-format=absolute", "--show-toplevel"],
261    )
262    .or_else(|| git_output(cwd, &["rev-parse", "--show-toplevel"]))?;
263    let out = out.trim();
264    if out.is_empty() {
265        return None;
266    }
267
268    let p = PathBuf::from(out);
269    let p = if p.is_absolute() { p } else { cwd.join(p) };
270    Some(absolutize_and_normalize(&p).unwrap_or_else(|_| lexical_normalize(&p)))
271}
272
273fn git_common_root(worktree_root: &Path) -> Option<PathBuf> {
274    let common = git_output(
275        worktree_root,
276        &["rev-parse", "--path-format=absolute", "--git-common-dir"],
277    )
278    .or_else(|| git_output(worktree_root, &["rev-parse", "--git-common-dir"]))?;
279    let common = common.trim();
280    if common.is_empty() {
281        return None;
282    }
283
284    let common = PathBuf::from(common);
285    let common = if common.is_absolute() {
286        common
287    } else {
288        worktree_root.join(common)
289    };
290    let common = absolutize_and_normalize(&common).unwrap_or_else(|_| lexical_normalize(&common));
291
292    common.parent().map(Path::to_path_buf)
293}
294
295fn git_is_bare_repo(cwd: &Path) -> Option<bool> {
296    let out = git_output(cwd, &["rev-parse", "--is-bare-repository"])?;
297    let out = out.trim().to_ascii_lowercase();
298    if out == "true" {
299        return Some(true);
300    }
301    if out == "false" {
302        return Some(false);
303    }
304    None
305}
306
307fn git_output(cwd: &Path, args: &[&str]) -> Option<String> {
308    let mut command = std::process::Command::new("git");
309    command.args(args).current_dir(cwd);
310
311    // Ignore injected git environment variables to avoid surprises.
312    for (k, _v) in std::env::vars_os() {
313        let k = k.to_string_lossy();
314        if k.starts_with("GIT_") {
315            command.env_remove(k.as_ref());
316        }
317    }
318
319    let output = command.output().ok()?;
320    if !output.status.success() {
321        return None;
322    }
323    Some(String::from_utf8_lossy(&output.stdout).to_string())
324}