Skip to main content

git_worktree_manager/operations/
helpers.rs

1/// Helper functions shared across operations modules.
2///
3use std::path::{Path, PathBuf};
4
5use crate::constants::{format_config_key, CONFIG_KEY_BASE_BRANCH, CONFIG_KEY_BASE_PATH};
6use crate::error::{CwError, Result};
7use crate::git;
8use crate::messages;
9
10/// Resolved worktree target with named fields for clarity.
11pub struct ResolvedTarget {
12    pub path: PathBuf,
13    pub branch: String,
14    pub repo: PathBuf,
15}
16
17/// Strictly-resolved worktree target: name, branch, and path.
18///
19/// Produced by [`resolve_target_strict`].
20#[derive(Debug)]
21pub struct StrictTarget {
22    /// Basename of the worktree directory (e.g. `"repo-feat-x"`).
23    pub name: String,
24    /// Short branch name (e.g. `"feat-x"`), with `refs/heads/` prefix stripped.
25    /// `None` for detached-HEAD worktrees.
26    pub branch: Option<String>,
27    /// Absolute path to the worktree directory.
28    pub path: PathBuf,
29}
30
31/// Parse 'repo:branch' notation.
32pub fn parse_repo_branch_target(target: &str) -> (Option<&str>, &str) {
33    if let Some((repo, branch)) = target.split_once(':') {
34        if !repo.is_empty() && !branch.is_empty() {
35            return (Some(repo), branch);
36        }
37    }
38    (None, target)
39}
40
41/// Get the branch for a worktree path from parse_worktrees output.
42pub fn get_branch_for_worktree(repo: &Path, worktree_path: &Path) -> Option<String> {
43    let worktrees = git::parse_worktrees(repo).ok()?;
44    let resolved = git::canonicalize_or(worktree_path);
45
46    for (branch, path) in &worktrees {
47        let p_resolved = git::canonicalize_or(path);
48        if p_resolved == resolved {
49            if branch == "(detached)" {
50                return None;
51            }
52            return Some(git::normalize_branch_name(branch).to_string());
53        }
54    }
55    None
56}
57
58/// Resolve worktree target to a [`ResolvedTarget`] with path, branch, and repo.
59///
60/// Supports branch name lookup, worktree directory name lookup,
61/// and disambiguation when both match.
62pub fn resolve_worktree_target(
63    target: Option<&str>,
64    lookup_mode: Option<&str>,
65) -> Result<ResolvedTarget> {
66    if target.is_none() {
67        // Use current directory
68        let cwd = std::env::current_dir()?;
69        let branch = git::get_current_branch(Some(&cwd))?;
70        let repo = git::get_repo_root(Some(&cwd))?;
71        return Ok(ResolvedTarget {
72            path: cwd,
73            branch,
74            repo,
75        });
76    }
77
78    let target = target.unwrap();
79
80    let main_repo = git::get_main_repo_root(None)?;
81
82    // Try branch lookup (skip if lookup_mode is "worktree")
83    let branch_match = if lookup_mode != Some("worktree") {
84        git::find_worktree_by_intended_branch(&main_repo, target)?
85    } else {
86        None
87    };
88
89    // Try worktree name lookup (skip if lookup_mode is "branch")
90    let worktree_match = if lookup_mode != Some("branch") {
91        git::find_worktree_by_name(&main_repo, target)?
92    } else {
93        None
94    };
95
96    match (branch_match, worktree_match) {
97        (Some(bp), Some(wp)) => {
98            let bp_resolved = git::canonicalize_or(&bp);
99            let wp_resolved = git::canonicalize_or(&wp);
100            if bp_resolved == wp_resolved {
101                let repo = git::get_repo_root(Some(&bp))?;
102                Ok(ResolvedTarget {
103                    path: bp,
104                    branch: target.to_string(),
105                    repo,
106                })
107            } else {
108                // Ambiguous — prefer branch match
109                let repo = git::get_repo_root(Some(&bp))?;
110                Ok(ResolvedTarget {
111                    path: bp,
112                    branch: target.to_string(),
113                    repo,
114                })
115            }
116        }
117        (Some(bp), None) => {
118            let repo = git::get_repo_root(Some(&bp))?;
119            Ok(ResolvedTarget {
120                path: bp,
121                branch: target.to_string(),
122                repo,
123            })
124        }
125        (None, Some(wp)) => {
126            let branch =
127                get_branch_for_worktree(&main_repo, &wp).unwrap_or_else(|| target.to_string());
128            let repo = git::get_repo_root(Some(&wp))?;
129            Ok(ResolvedTarget {
130                path: wp,
131                branch,
132                repo,
133            })
134        }
135        (None, None) => Err(CwError::WorktreeNotFound(messages::worktree_not_found(
136            target,
137        ))),
138    }
139}
140
141/// Get worktree metadata (base branch and base repository path).
142///
143/// If metadata is missing, tries to infer from common defaults.
144pub fn get_worktree_metadata(branch: &str, repo: &Path) -> Result<(String, PathBuf)> {
145    let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
146    let path_key = format_config_key(CONFIG_KEY_BASE_PATH, branch);
147
148    let base_branch = git::get_config(&base_key, Some(repo));
149    let base_path_str = git::get_config(&path_key, Some(repo));
150
151    if let (Some(bb), Some(bp)) = (base_branch, base_path_str) {
152        return Ok((bb, PathBuf::from(bp)));
153    }
154
155    // Metadata missing — try to infer
156    eprintln!(
157        "Warning: Metadata missing for branch '{}'. Attempting to infer...",
158        branch
159    );
160
161    // Infer base_path from first worktree entry
162    let worktrees = git::parse_worktrees(repo)?;
163    let inferred_base_path = worktrees.first().map(|(_, p)| p.clone()).ok_or_else(|| {
164        CwError::Git(format!(
165            "Cannot infer base repository path for branch '{}'. Use 'gw new' to create worktrees.",
166            branch
167        ))
168    })?;
169
170    // Infer base_branch from common defaults
171    let mut inferred_base_branch: Option<String> = None;
172    for candidate in &["main", "master", "develop"] {
173        if git::branch_exists(candidate, Some(&inferred_base_path)) {
174            inferred_base_branch = Some(candidate.to_string());
175            break;
176        }
177    }
178
179    if inferred_base_branch.is_none() {
180        if let Some((first_branch, _)) = worktrees.first() {
181            if first_branch != "(detached)" {
182                inferred_base_branch = Some(git::normalize_branch_name(first_branch).to_string());
183            }
184        }
185    }
186
187    let base = inferred_base_branch.ok_or_else(|| {
188        CwError::Git(format!(
189            "Cannot infer base branch for '{}'. Use 'gw new' to create worktrees.",
190            branch
191        ))
192    })?;
193
194    eprintln!("  Inferred base branch: {}", base);
195    eprintln!("  Inferred base path: {}", inferred_base_path.display());
196
197    Ok((base, inferred_base_path))
198}
199
200/// Strict target resolution: exact worktree name → exact branch name → exact path.
201///
202/// Unlike [`resolve_worktree_target`], this function performs no fuzzy matching
203/// and no metadata look-ups. It calls [`git::parse_worktrees`] once and applies
204/// three ordered exact-match rules:
205///
206/// 1. **Worktree name** — basename of the worktree directory equals `target`.
207/// 2. **Branch name** — short branch name (after stripping `refs/heads/`) equals `target`.
208/// 3. **Absolute path** — the worktree path equals `target`.
209///
210/// Returns [`CwError::WorktreeNotFound`] when no worktree matches.
211pub fn resolve_target_strict(repo_root: &Path, target: &str) -> Result<StrictTarget> {
212    let worktrees = git::parse_worktrees(repo_root)?;
213
214    // Pre-compute the main worktree path so we can skip it when doing name
215    // lookup (the main repo's basename is rarely meaningful as a worktree name).
216    let main_path = worktrees
217        .first()
218        .map(|(_, p)| git::canonicalize_or(p))
219        .unwrap_or_default();
220
221    // Helper: build a StrictTarget from a raw parse_worktrees entry.
222    let make = |(branch_raw, path): &(String, PathBuf)| -> StrictTarget {
223        let name = path
224            .file_name()
225            .map(|n| n.to_string_lossy().into_owned())
226            .unwrap_or_default();
227        let normalized = git::normalize_branch_name(branch_raw);
228        let branch = if normalized == "(detached)" {
229            None
230        } else {
231            Some(normalized.to_string())
232        };
233        StrictTarget {
234            name,
235            branch,
236            path: path.clone(),
237        }
238    };
239
240    // 1) Exact worktree name (basename of path).
241    for entry in &worktrees {
242        let (_, path) = entry;
243        // Skip main repo entry
244        if git::canonicalize_or(path) == main_path {
245            continue;
246        }
247        let name = path
248            .file_name()
249            .map(|n| n.to_string_lossy())
250            .unwrap_or_default();
251        if name == target {
252            return Ok(make(entry));
253        }
254    }
255
256    // 2) Exact branch name (refs/heads/ prefix stripped).
257    for entry in &worktrees {
258        let (branch_raw, path) = entry;
259        if git::canonicalize_or(path) == main_path {
260            continue;
261        }
262        if git::normalize_branch_name(branch_raw) == target {
263            return Ok(make(entry));
264        }
265    }
266
267    // 3) Exact absolute path (canonicalize both sides to handle symlinks).
268    let abs = PathBuf::from(target);
269    let abs_resolved = git::canonicalize_or(&abs);
270    for entry in &worktrees {
271        let (_, path) = entry;
272        let path_resolved = git::canonicalize_or(path);
273        if path_resolved == abs_resolved {
274            return Ok(make(entry));
275        }
276    }
277
278    Err(CwError::WorktreeNotFound(messages::target_not_found(
279        target,
280    )))
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn test_parse_repo_branch_target() {
289        assert_eq!(
290            parse_repo_branch_target("myrepo:feat/x"),
291            (Some("myrepo"), "feat/x")
292        );
293        assert_eq!(parse_repo_branch_target("feat/x"), (None, "feat/x"));
294        assert_eq!(parse_repo_branch_target(":feat/x"), (None, ":feat/x"));
295        assert_eq!(parse_repo_branch_target("myrepo:"), (None, "myrepo:"));
296    }
297}