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    let Some(target) = target else {
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 main_repo = git::get_main_repo_root(None)?;
79
80    // Try branch lookup (skip if lookup_mode is "worktree")
81    let branch_match = if lookup_mode != Some("worktree") {
82        git::find_worktree_by_intended_branch(&main_repo, target)?
83    } else {
84        None
85    };
86
87    // Try worktree name lookup (skip if lookup_mode is "branch")
88    let worktree_match = if lookup_mode != Some("branch") {
89        git::find_worktree_by_name(&main_repo, target)?
90    } else {
91        None
92    };
93
94    match (branch_match, worktree_match) {
95        (Some(bp), Some(_wp)) => {
96            // Both branch and worktree name match — prefer the branch match.
97            // (Intentional: branch is the canonical identifier in gw.)
98            let repo = git::get_repo_root(Some(&bp))?;
99            Ok(ResolvedTarget {
100                path: bp,
101                branch: target.to_string(),
102                repo,
103            })
104        }
105        (Some(bp), None) => {
106            let repo = git::get_repo_root(Some(&bp))?;
107            Ok(ResolvedTarget {
108                path: bp,
109                branch: target.to_string(),
110                repo,
111            })
112        }
113        (None, Some(wp)) => {
114            let branch =
115                get_branch_for_worktree(&main_repo, &wp).unwrap_or_else(|| target.to_string());
116            let repo = git::get_repo_root(Some(&wp))?;
117            Ok(ResolvedTarget {
118                path: wp,
119                branch,
120                repo,
121            })
122        }
123        (None, None) => Err(CwError::WorktreeNotFound(messages::worktree_not_found(
124            target,
125        ))),
126    }
127}
128
129/// Get worktree metadata (base branch and base repository path).
130///
131/// If metadata is missing, tries to infer from common defaults.
132pub fn get_worktree_metadata(branch: &str, repo: &Path) -> Result<(String, PathBuf)> {
133    let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch);
134    let path_key = format_config_key(CONFIG_KEY_BASE_PATH, branch);
135
136    let base_branch = git::get_config(&base_key, Some(repo));
137    let base_path_str = git::get_config(&path_key, Some(repo));
138
139    if let (Some(bb), Some(bp)) = (base_branch, base_path_str) {
140        return Ok((bb, PathBuf::from(bp)));
141    }
142
143    // Metadata missing — try to infer
144    eprintln!(
145        "Warning: Metadata missing for branch '{}'. Attempting to infer...",
146        branch
147    );
148
149    // Infer base_path from first worktree entry
150    let worktrees = git::parse_worktrees(repo)?;
151    let inferred_base_path = worktrees.first().map(|(_, p)| p.clone()).ok_or_else(|| {
152        CwError::Other(format!(
153            "Cannot infer base repository path for branch '{}'. Use 'gw new' to create worktrees.",
154            branch
155        ))
156    })?;
157
158    // Infer base_branch from common defaults
159    let mut inferred_base_branch: Option<String> = None;
160    for candidate in &["main", "master", "develop"] {
161        if git::branch_exists(candidate, Some(&inferred_base_path)) {
162            inferred_base_branch = Some(candidate.to_string());
163            break;
164        }
165    }
166
167    if inferred_base_branch.is_none() {
168        if let Some((first_branch, _)) = worktrees.first() {
169            if first_branch != "(detached)" {
170                inferred_base_branch = Some(git::normalize_branch_name(first_branch).to_string());
171            }
172        }
173    }
174
175    let base = inferred_base_branch.ok_or_else(|| {
176        CwError::Other(format!(
177            "Cannot infer base branch for '{}'. Use 'gw new' to create worktrees.",
178            branch
179        ))
180    })?;
181
182    eprintln!("  Inferred base branch: {}", base);
183    eprintln!("  Inferred base path: {}", inferred_base_path.display());
184
185    Ok((base, inferred_base_path))
186}
187
188/// Strict target resolution: exact worktree name → exact branch name → exact path.
189///
190/// Unlike [`resolve_worktree_target`], this function performs no fuzzy matching
191/// and no metadata look-ups. It calls [`git::parse_worktrees`] once and applies
192/// three ordered exact-match rules:
193///
194/// 1. **Worktree name** — basename of the worktree directory equals `target`.
195/// 2. **Branch name** — short branch name (after stripping `refs/heads/`) equals `target`.
196/// 3. **Absolute path** — the worktree path equals `target`.
197///
198/// Returns [`CwError::WorktreeNotFound`] when no worktree matches.
199pub fn resolve_target_strict(repo_root: &Path, target: &str) -> Result<StrictTarget> {
200    let worktrees = git::parse_worktrees(repo_root)?;
201
202    // Pre-compute the main worktree path so we can skip it when doing name
203    // lookup (the main repo's basename is rarely meaningful as a worktree name).
204    let main_path = worktrees
205        .first()
206        .map(|(_, p)| git::canonicalize_or(p))
207        .unwrap_or_default();
208
209    // Helper: build a StrictTarget from a raw parse_worktrees entry.
210    let make = |(branch_raw, path): &(String, PathBuf)| -> StrictTarget {
211        let name = path
212            .file_name()
213            .map(|n| n.to_string_lossy().into_owned())
214            .unwrap_or_default();
215        let normalized = git::normalize_branch_name(branch_raw);
216        let branch = if normalized == "(detached)" {
217            None
218        } else {
219            Some(normalized.to_string())
220        };
221        StrictTarget {
222            name,
223            branch,
224            path: path.clone(),
225        }
226    };
227
228    // 1) Exact worktree name (basename of path).
229    for entry in &worktrees {
230        let (_, path) = entry;
231        // Skip main repo entry
232        if git::canonicalize_or(path) == main_path {
233            continue;
234        }
235        let name = path
236            .file_name()
237            .map(|n| n.to_string_lossy())
238            .unwrap_or_default();
239        if name == target {
240            return Ok(make(entry));
241        }
242    }
243
244    // 2) Exact branch name (refs/heads/ prefix stripped).
245    for entry in &worktrees {
246        let (branch_raw, path) = entry;
247        if git::canonicalize_or(path) == main_path {
248            continue;
249        }
250        if git::normalize_branch_name(branch_raw) == target {
251            return Ok(make(entry));
252        }
253    }
254
255    // 3) Exact absolute path (canonicalize both sides to handle symlinks).
256    let abs = PathBuf::from(target);
257    let abs_resolved = git::canonicalize_or(&abs);
258    for entry in &worktrees {
259        let (_, path) = entry;
260        let path_resolved = git::canonicalize_or(path);
261        if path_resolved == abs_resolved {
262            return Ok(make(entry));
263        }
264    }
265
266    Err(CwError::WorktreeNotFound(messages::target_not_found(
267        target,
268    )))
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_parse_repo_branch_target() {
277        assert_eq!(
278            parse_repo_branch_target("myrepo:feat/x"),
279            (Some("myrepo"), "feat/x")
280        );
281        assert_eq!(parse_repo_branch_target("feat/x"), (None, "feat/x"));
282        assert_eq!(parse_repo_branch_target(":feat/x"), (None, ":feat/x"));
283        assert_eq!(parse_repo_branch_target("myrepo:"), (None, "myrepo:"));
284    }
285}