1use 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
17pub 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 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
87pub 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
115pub 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
148pub 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 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
176pub 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
203pub 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}