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