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, ItoConfig, WorktreeStrategy};
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum GitRepoKind {
17 Bare,
19 NonBare,
21}
22
23impl GitRepoKind {
24 pub fn is_bare(self) -> bool {
26 match self {
27 Self::Bare => true,
28 Self::NonBare => false,
29 }
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum WorktreeFeature {
36 Enabled,
38 Disabled,
40}
41
42impl WorktreeFeature {
43 pub fn is_enabled(self) -> bool {
45 match self {
46 Self::Enabled => true,
47 Self::Disabled => false,
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
54pub struct ResolvedEnv {
55 pub worktree_root: PathBuf,
58 pub project_root: PathBuf,
60 pub ito_root: PathBuf,
62 pub git_repo_kind: GitRepoKind,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum WorktreeSelector {
69 Main,
71 Branch(String),
73 Change(String),
75}
76
77#[derive(Debug, Clone)]
79pub struct ResolvedWorktreePaths {
80 pub feature: WorktreeFeature,
82 pub strategy: WorktreeStrategy,
84 pub worktrees_root: Option<PathBuf>,
86 pub main_worktree_root: Option<PathBuf>,
88}
89
90impl ResolvedWorktreePaths {
91 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
109pub 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
118pub 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 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
157pub fn resolve_worktree_paths(
159 env: &ResolvedEnv,
160 ctx: &ConfigContext,
161) -> CoreResult<ResolvedWorktreePaths> {
162 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
213pub fn coordination_worktree_path(
234 config: &CoordinationBranchConfig,
235 ito_path: &Path,
236 org: &str,
237 repo: &str,
238) -> PathBuf {
239 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 let base = match std::env::var("XDG_DATA_HOME") {
251 Ok(v) if !v.trim().is_empty() => PathBuf::from(v),
252 _ => {
253 match std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
255 Ok(home) => PathBuf::from(home).join(".local").join("share"),
256 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 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 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}