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