1use 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
18pub 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 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
88pub 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
116pub 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
149pub 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 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
177pub 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
196pub 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
206pub 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}