Skip to main content

wsx_core/git/
worktree.rs

1// Worktree CRUD — all via git CLI
2// ref: git-worktree(1) — https://git-scm.com/docs/git-worktree
3
4use super::git_cmd;
5use crate::model::workspace::WorktreeInfo;
6use anyhow::{bail, Context, Result};
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9
10#[derive(Debug, Clone)]
11pub struct WorktreeEntry {
12    pub name: String,
13    pub path: PathBuf,
14    pub branch: String,
15    pub is_main: bool,
16}
17
18/// List worktrees via `git worktree list --porcelain`.
19pub fn list_worktrees(repo_path: &Path) -> Result<Vec<WorktreeEntry>> {
20    let output = super::output_with_timeout(
21        git_cmd(repo_path).args(["worktree", "list", "--porcelain"]),
22        std::time::Duration::from_secs(5),
23    )
24    .context("git worktree list")?;
25    parse_porcelain_output(&String::from_utf8_lossy(&output.stdout), repo_path)
26}
27
28fn parse_porcelain_output(output: &str, repo_path: &Path) -> Result<Vec<WorktreeEntry>> {
29    let mut entries = Vec::new();
30    let mut current_path: Option<PathBuf> = None;
31    let mut current_branch: Option<String> = None;
32    let mut first = true;
33
34    for line in output.lines() {
35        if line.is_empty() {
36            if let Some(path) = current_path.take() {
37                let branch = current_branch.take().unwrap_or_else(|| "HEAD".to_string());
38                let name = derive_name(&path, &branch, first);
39                entries.push(WorktreeEntry {
40                    name,
41                    path,
42                    branch,
43                    is_main: first,
44                });
45                first = false;
46            }
47        } else if let Some(p) = line.strip_prefix("worktree ") {
48            current_path = Some(PathBuf::from(p.trim()));
49        } else if let Some(b) = line.strip_prefix("branch ") {
50            let b = b.trim().strip_prefix("refs/heads/").unwrap_or(b.trim());
51            current_branch = Some(b.to_string());
52        }
53    }
54
55    // Last entry (no trailing blank line)
56    if let Some(path) = current_path {
57        let branch = current_branch.unwrap_or_else(|| "HEAD".to_string());
58        let name = derive_name(&path, &branch, first);
59        entries.push(WorktreeEntry {
60            name,
61            path,
62            branch,
63            is_main: first,
64        });
65    }
66
67    if entries.is_empty() {
68        entries.push(WorktreeEntry {
69            name: "main".to_string(),
70            path: repo_path.to_path_buf(),
71            branch: "main".to_string(),
72            is_main: true,
73        });
74    }
75
76    Ok(entries)
77}
78
79fn derive_name(path: &Path, branch: &str, is_main: bool) -> String {
80    if is_main {
81        return "main".to_string();
82    }
83    path.file_name()
84        .map(|n| n.to_string_lossy().to_string())
85        .unwrap_or_else(|| branch.replace('/', "-"))
86}
87
88/// Convert WorktreeEntry list to WorktreeInfo list (no sessions yet — populated by refresh_all).
89pub fn to_worktree_infos(
90    entries: Vec<WorktreeEntry>,
91    aliases: &std::collections::HashMap<String, String>,
92) -> Vec<WorktreeInfo> {
93    entries
94        .into_iter()
95        .map(|e| {
96            let alias = aliases.get(&e.branch).cloned();
97            WorktreeInfo {
98                name: e.name,
99                branch: e.branch,
100                path: e.path,
101                is_main: e.is_main,
102                alias,
103                sessions: Vec::new(),
104                expanded: true,
105                git_info: None,
106                fetch_failed: false,
107                fetch_fail_count: 0,
108                fetch_fail_reason: None,
109                last_fetched: None,
110                git_info_fetched_at: None,
111            }
112        })
113        .collect()
114}
115
116/// `git worktree add -b {branch} {path} {base_branch}`
117pub fn create_worktree(repo_path: &Path, branch: &str, base_branch: &str) -> Result<PathBuf> {
118    let parent = repo_path.parent().context("repo has no parent dir")?;
119    let repo_name = repo_path
120        .file_name()
121        .context("repo has no name")?
122        .to_string_lossy();
123    let slug = branch.replace('/', "-").replace(
124        |c: char| !c.is_alphanumeric() && c != '-' && c != '_' && c != '.',
125        "-",
126    );
127    let wt_path = parent.join(format!("{}-{}", repo_name, slug));
128
129    let status = git_cmd(repo_path)
130        .args([
131            "worktree",
132            "add",
133            "-b",
134            branch,
135            &wt_path.to_string_lossy(),
136            base_branch,
137        ])
138        .stdout(Stdio::null())
139        .stderr(Stdio::null())
140        .status()
141        .context("git worktree add failed")?;
142
143    if !status.success() {
144        bail!("git worktree add exited {}", status);
145    }
146    Ok(wt_path)
147}
148
149/// `git worktree remove --force {path}` then `git branch -d {branch}`
150pub fn remove_worktree(repo_path: &Path, worktree_path: &Path, branch: &str) -> Result<()> {
151    let status = git_cmd(repo_path)
152        .args([
153            "worktree",
154            "remove",
155            "--force",
156            &worktree_path.to_string_lossy(),
157        ])
158        .stdout(Stdio::null())
159        .stderr(Stdio::null())
160        .status()
161        .context("git worktree remove failed")?;
162
163    if !status.success() {
164        bail!("git worktree remove exited {}", status);
165    }
166
167    // Best-effort branch deletion
168    let _ = git_cmd(repo_path)
169        .args(["branch", "-d", branch])
170        .stdout(Stdio::null())
171        .stderr(Stdio::null())
172        .status();
173
174    Ok(())
175}
176
177/// Return non-main worktrees whose branches are merged into `default_branch`.
178pub fn merged_worktrees(repo_path: &Path, default_branch: &str) -> Result<Vec<WorktreeEntry>> {
179    let output = git_cmd(repo_path)
180        .args(["branch", "--merged", default_branch])
181        .output()
182        .context("git branch --merged failed")?;
183    let merged: std::collections::HashSet<String> = String::from_utf8_lossy(&output.stdout)
184        .lines()
185        .map(|line| line.trim().trim_start_matches('*').trim().to_string())
186        .filter(|branch| {
187            !branch.is_empty() && branch != default_branch && !branch.starts_with("HEAD")
188        })
189        .collect();
190    Ok(list_worktrees(repo_path)?
191        .into_iter()
192        .filter(|entry| !entry.is_main && merged.contains(&entry.branch))
193        .collect())
194}
195
196/// Delete worktrees whose branches are merged into `default_branch`.
197pub fn clean_merged(repo_path: &Path, default_branch: &str) -> Result<Vec<String>> {
198    let mut removed = Vec::new();
199    for entry in merged_worktrees(repo_path, default_branch)? {
200        remove_worktree(repo_path, &entry.path, &entry.branch)?;
201        removed.push(entry.branch);
202    }
203    Ok(removed)
204}
205
206/// Check if branch is an ancestor of default_branch (i.e., merged).
207pub fn is_branch_merged(repo_path: &Path, branch: &str, default_branch: &str) -> bool {
208    git_cmd(repo_path)
209        .args(["merge-base", "--is-ancestor", branch, default_branch])
210        .status()
211        .map(|s| s.success())
212        .unwrap_or(false)
213}