1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum GitRepoKind {
18 Bare,
20 NonBare,
22}
23
24impl GitRepoKind {
25 pub fn is_bare(self) -> bool {
27 match self {
28 Self::Bare => true,
29 Self::NonBare => false,
30 }
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum WorktreeFeature {
37 Enabled,
39 Disabled,
41}
42
43impl WorktreeFeature {
44 pub fn is_enabled(self) -> bool {
46 match self {
47 Self::Enabled => true,
48 Self::Disabled => false,
49 }
50 }
51}
52
53#[derive(Debug, Clone)]
55pub struct ResolvedEnv {
56 pub worktree_root: PathBuf,
59 pub project_root: PathBuf,
61 pub ito_root: PathBuf,
63 pub git_repo_kind: GitRepoKind,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum WorktreeSelector {
70 Main,
72 Branch(String),
74 Change(String),
76}
77
78#[derive(Debug, Clone)]
80pub struct ResolvedWorktreePaths {
81 pub feature: WorktreeFeature,
83 pub strategy: WorktreeStrategy,
85 pub worktrees_root: Option<PathBuf>,
87 pub main_worktree_root: Option<PathBuf>,
89}
90
91impl ResolvedWorktreePaths {
92 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
110pub 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
119pub 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 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
158pub fn resolve_worktree_paths(
160 env: &ResolvedEnv,
161 ctx: &ConfigContext,
162) -> CoreResult<ResolvedWorktreePaths> {
163 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
214pub fn coordination_worktree_path(
235 config: &CoordinationBranchConfig,
236 ito_path: &Path,
237 org: &str,
238 repo: &str,
239) -> PathBuf {
240 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 let base = match std::env::var("XDG_DATA_HOME") {
252 Ok(v) if !v.trim().is_empty() => PathBuf::from(v),
253 _ => {
254 match std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
256 Ok(home) => PathBuf::from(home).join(".local").join("share"),
257 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 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 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}