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::{CoordinationBranchConfig, 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
213/// Resolve the path where the coordination worktree should be stored.
214///
215/// Resolution order:
216///
217/// 1. `config.worktree_path` — when explicitly set, it is used as-is.
218/// 2. `$XDG_DATA_HOME/ito/<org>/<repo>/` — when the `XDG_DATA_HOME` environment
219///    variable is set and non-empty.
220/// 3. `~/.local/share/ito/<org>/<repo>/` — fallback using `$HOME` (or
221///    `$USERPROFILE` on Windows) to locate the home directory.
222/// 4. `<ito_path>/coordination-worktree/` — last-resort fallback when no home
223///    directory can be resolved; mirrors embedded storage behaviour so the
224///    project is never left in a broken state.
225///
226/// The function is pure: it reads environment variables but performs no git
227/// operations. The `org` and `repo` parameters should be obtained from
228/// [`crate::git_remote::resolve_org_repo_from_config_or_remote`] or equivalent.
229///
230/// The `ito_path` parameter is used only for the last-resort fallback (case 4).
231/// Pass the project's `.ito` directory so the fallback path is always absolute
232/// and project-scoped rather than relative to the caller's working directory.
233pub fn coordination_worktree_path(
234    config: &CoordinationBranchConfig,
235    ito_path: &Path,
236    org: &str,
237    repo: &str,
238) -> PathBuf {
239    // 1. Explicit override wins.
240    if let Some(explicit) = config
241        .worktree_path
242        .as_deref()
243        .map(str::trim)
244        .filter(|s| !s.is_empty())
245    {
246        return PathBuf::from(explicit);
247    }
248
249    // 2. XDG_DATA_HOME when set and non-empty.
250    let base = match std::env::var("XDG_DATA_HOME") {
251        Ok(v) if !v.trim().is_empty() => PathBuf::from(v),
252        _ => {
253            // 3. Fall back to ~/.local/share using HOME / USERPROFILE.
254            match std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
255                Ok(home) => PathBuf::from(home).join(".local").join("share"),
256                // 4. Last-resort: use <ito_path>/coordination-worktree so the
257                //    path is always absolute and project-scoped. This mirrors
258                //    embedded storage behaviour and avoids writing to the
259                //    non-persistent /tmp directory or a CWD-relative path.
260                Err(_) => return ito_path.join("coordination-worktree"),
261            }
262        }
263    };
264
265    base.join("ito").join(org).join(repo)
266}
267
268fn resolve_base_dir(env: &ResolvedEnv, configured: &Option<String>) -> PathBuf {
269    let Some(raw) = configured
270        .as_ref()
271        .map(|s| s.trim())
272        .filter(|s| !s.is_empty())
273    else {
274        return env.project_root.clone();
275    };
276
277    let p = PathBuf::from(raw);
278    let p = if p.is_absolute() {
279        p
280    } else {
281        env.project_root.join(p)
282    };
283    absolutize_and_normalize(&p).unwrap_or_else(|_| lexical_normalize(&p))
284}
285
286fn bare_repo_error_message_raw(cwd: &Path) -> String {
287    // Best-effort hint: in bare/control layouts, `main/` is typically the primary worktree.
288    let main = cwd.join("main");
289    let master = cwd.join("master");
290    let hint = if main.is_dir() {
291        format!("cd \"{}\"", main.to_string_lossy())
292    } else if master.is_dir() {
293        format!("cd \"{}\"", master.to_string_lossy())
294    } else {
295        "cd <worktree-dir>".to_string()
296    };
297
298    format!("Ito must be run from a git worktree (not the bare repository). Try: {hint}")
299}
300
301fn find_nearest_ito_root(start: &Path) -> Option<PathBuf> {
302    let mut cur = start.to_path_buf();
303    loop {
304        if cur.join(".ito").is_dir() {
305            return Some(cur);
306        }
307        let parent = cur.parent()?.to_path_buf();
308        cur = parent;
309    }
310}
311
312fn git_show_toplevel(cwd: &Path) -> Option<PathBuf> {
313    let out = git_output(
314        cwd,
315        &["rev-parse", "--path-format=absolute", "--show-toplevel"],
316    )
317    .or_else(|| git_output(cwd, &["rev-parse", "--show-toplevel"]))?;
318    let out = out.trim();
319    if out.is_empty() {
320        return None;
321    }
322
323    let p = PathBuf::from(out);
324    let p = if p.is_absolute() { p } else { cwd.join(p) };
325    Some(absolutize_and_normalize(&p).unwrap_or_else(|_| lexical_normalize(&p)))
326}
327
328fn git_common_root(worktree_root: &Path) -> Option<PathBuf> {
329    let common = git_output(
330        worktree_root,
331        &["rev-parse", "--path-format=absolute", "--git-common-dir"],
332    )
333    .or_else(|| git_output(worktree_root, &["rev-parse", "--git-common-dir"]))?;
334    let common = common.trim();
335    if common.is_empty() {
336        return None;
337    }
338
339    let common = PathBuf::from(common);
340    let common = if common.is_absolute() {
341        common
342    } else {
343        worktree_root.join(common)
344    };
345    let common = absolutize_and_normalize(&common).unwrap_or_else(|_| lexical_normalize(&common));
346
347    common.parent().map(Path::to_path_buf)
348}
349
350fn git_is_bare_repo(cwd: &Path) -> Option<bool> {
351    let out = git_output(cwd, &["rev-parse", "--is-bare-repository"])?;
352    let out = out.trim().to_ascii_lowercase();
353    if out == "true" {
354        return Some(true);
355    }
356    if out == "false" {
357        return Some(false);
358    }
359    None
360}
361
362fn git_output(cwd: &Path, args: &[&str]) -> Option<String> {
363    let mut command = std::process::Command::new("git");
364    command.args(args).current_dir(cwd);
365
366    // Ignore injected git environment variables to avoid surprises.
367    for (k, _v) in std::env::vars_os() {
368        let k = k.to_string_lossy();
369        if k.starts_with("GIT_") {
370            command.env_remove(k.as_ref());
371        }
372    }
373
374    let output = command.output().ok()?;
375    if !output.status.success() {
376        return None;
377    }
378    Some(String::from_utf8_lossy(&output.stdout).to_string())
379}