1use crate::cli::UI;
2use crate::ops::oplog;
3use crate::ops::utils::short_oid;
4use anyhow::{bail, Result};
5use git2::{Repository, WorktreeAddOptions, WorktreeLockStatus, WorktreePruneOptions};
6use std::path::{Path, PathBuf};
7
8#[derive(Debug)]
10struct WorktreeInfo {
11 name: String,
12 path: PathBuf,
13 head_oid: String,
14 branch: String,
15 is_main: bool,
16 locked: Option<String>,
17}
18
19fn gather_worktrees(repo: &Repository) -> Result<Vec<WorktreeInfo>> {
21 let mut entries = Vec::new();
22
23 let main_path = repo
25 .workdir()
26 .map(|p| p.to_path_buf())
27 .unwrap_or_else(|| repo.path().to_path_buf());
28
29 let head_oid = repo
30 .head()
31 .ok()
32 .and_then(|h| h.target())
33 .map(|oid| short_oid(&oid))
34 .unwrap_or_else(|| "???????".to_string());
35
36 let branch = repo
37 .head()
38 .ok()
39 .and_then(|h| {
40 if h.is_branch() {
41 h.shorthand().map(|s| s.to_string())
42 } else {
43 Some("(detached)".to_string())
44 }
45 })
46 .unwrap_or_else(|| "(detached)".to_string());
47
48 entries.push(WorktreeInfo {
49 name: "(main)".to_string(),
50 path: main_path,
51 head_oid,
52 branch,
53 is_main: true,
54 locked: None,
55 });
56
57 let worktree_names = repo.worktrees()?;
59 for name in worktree_names.iter().flatten() {
60 if let Ok(wt) = repo.find_worktree(name) {
61 let wt_path = wt.path().to_path_buf();
62
63 let (wt_oid, wt_branch) = if let Ok(wt_repo) = Repository::open(&wt_path) {
65 let oid = wt_repo
66 .head()
67 .ok()
68 .and_then(|h| h.target())
69 .map(|oid| short_oid(&oid))
70 .unwrap_or_else(|| "???????".to_string());
71 let br = wt_repo
72 .head()
73 .ok()
74 .and_then(|h| {
75 if h.is_branch() {
76 h.shorthand().map(|s| s.to_string())
77 } else {
78 Some("(detached)".to_string())
79 }
80 })
81 .unwrap_or_else(|| "???????".to_string());
82 (oid, br)
83 } else {
84 ("???????".to_string(), "???????".to_string())
85 };
86
87 let locked = match wt.is_locked() {
88 Ok(WorktreeLockStatus::Locked(reason)) => Some(
89 reason.unwrap_or_else(|| "(no reason)".to_string()),
90 ),
91 _ => None,
92 };
93
94 entries.push(WorktreeInfo {
95 name: name.to_string(),
96 path: wt_path,
97 head_oid: wt_oid,
98 branch: wt_branch,
99 is_main: false,
100 locked,
101 });
102 }
103 }
104
105 Ok(entries)
106}
107
108pub fn list(path: &Path, ui: &UI) -> Result<()> {
109 let repo = Repository::open(path)?;
110 let entries = gather_worktrees(&repo)?;
111
112 for entry in &entries {
113 let lock_indicator = if let Some(ref reason) = entry.locked {
114 format!(" [locked: {}]", reason)
115 } else {
116 String::new()
117 };
118
119 let label = if entry.is_main {
120 format!("{} (main)", entry.path.display())
121 } else {
122 format!("{}", entry.path.display())
123 };
124
125 ui.branch_item(
126 &label,
127 &entry.head_oid,
128 &format!("{}{}", entry.branch, lock_indicator),
129 entry.is_main,
130 );
131 }
132
133 ui.info(format!("{} worktree(s)", entries.len()));
134 Ok(())
135}
136
137pub fn list_compact(path: &Path) -> Result<String> {
138 let repo = Repository::open(path)?;
139 let entries = gather_worktrees(&repo)?;
140
141 let mut lines = Vec::new();
142 for entry in &entries {
143 let marker = if entry.is_main { "*" } else { " " };
144 let lock = if entry.locked.is_some() { " [locked]" } else { "" };
145 lines.push(format!(
146 "{} {} {} {}{}",
147 marker,
148 entry.path.display(),
149 entry.head_oid,
150 entry.branch,
151 lock
152 ));
153 }
154
155 Ok(lines.join("\n"))
156}
157
158pub fn list_json(path: &Path) -> Result<String> {
160 let repo = Repository::open(path)?;
161 let entries = gather_worktrees(&repo)?;
162
163 let json_entries: Vec<serde_json::Value> = entries
164 .iter()
165 .map(|e| {
166 serde_json::json!({
167 "name": e.name,
168 "path": e.path.display().to_string(),
169 "head": e.head_oid,
170 "branch": e.branch,
171 "is_main": e.is_main,
172 "locked": e.locked,
173 })
174 })
175 .collect();
176
177 Ok(serde_json::to_string_pretty(&json_entries)?)
178}
179
180pub fn add(
181 path: &Path,
182 name: &str,
183 worktree_path: &Path,
184 branch: Option<&str>,
185 ui: &UI,
186) -> Result<()> {
187 let desc = format!(
188 "worktree add '{}' at '{}'",
189 name,
190 worktree_path.display()
191 );
192 oplog::with_oplog(path, "worktree", &desc, || {
193 add_inner(path, name, worktree_path, branch, ui)
194 })
195}
196
197fn add_inner(
198 path: &Path,
199 name: &str,
200 worktree_path: &Path,
201 branch: Option<&str>,
202 ui: &UI,
203) -> Result<()> {
204 let repo = Repository::open(path)?;
205
206 if let Some(parent) = worktree_path.parent() {
208 std::fs::create_dir_all(parent)?;
209 }
210
211 if worktree_path.exists() {
212 bail!(
213 "Directory already exists: {}",
214 worktree_path.display()
215 );
216 }
217
218 let mut opts = WorktreeAddOptions::new();
219
220 if let Some(branch_name) = branch {
221 if let Ok(branch_ref) = repo.find_branch(branch_name, git2::BranchType::Local) {
223 let reference = branch_ref.into_reference();
224 opts.reference(Some(&reference));
225 opts.checkout_existing(true);
226 repo.worktree(name, worktree_path, Some(&opts))?;
227 ui.success(format!(
228 "Created worktree '{}' at '{}' (existing branch '{}')",
229 name,
230 worktree_path.display(),
231 branch_name
232 ));
233 } else {
234 let head_commit = repo.head()?.peel_to_commit()?;
236 let new_branch = repo.branch(branch_name, &head_commit, false)?;
237 let reference = new_branch.into_reference();
238 opts.reference(Some(&reference));
239 repo.worktree(name, worktree_path, Some(&opts))?;
240 ui.success(format!(
241 "Created worktree '{}' at '{}' (new branch '{}')",
242 name,
243 worktree_path.display(),
244 branch_name
245 ));
246 }
247 } else {
248 repo.worktree(name, worktree_path, Some(&opts))?;
250 ui.success(format!(
251 "Created worktree '{}' at '{}' (new branch '{}')",
252 name,
253 worktree_path.display(),
254 name
255 ));
256 }
257
258 Ok(())
259}
260
261pub fn remove(path: &Path, name: &str, force: bool, ui: &UI) -> Result<()> {
262 let desc = format!("worktree remove '{}'", name);
263 oplog::with_oplog(path, "worktree", &desc, || {
264 remove_inner(path, name, force, ui)
265 })
266}
267
268fn remove_inner(path: &Path, name: &str, force: bool, ui: &UI) -> Result<()> {
269 let repo = Repository::open(path)?;
270 let wt = repo.find_worktree(name)?;
271 let wt_path = wt.path().to_path_buf();
272
273 if let Ok(WorktreeLockStatus::Locked(reason)) = wt.is_locked() {
275 if !force {
276 let reason_msg = reason.unwrap_or_else(|| "no reason given".to_string());
277 bail!(
278 "Worktree '{}' is locked ({}). Use --force to remove anyway.",
279 name,
280 reason_msg
281 );
282 }
283 wt.unlock()?;
285 }
286
287 if !force && wt_path.exists() {
289 if let Ok(wt_repo) = Repository::open(&wt_path) {
290 let mut opts = git2::StatusOptions::new();
291 opts.include_untracked(true);
292 if let Ok(statuses) = wt_repo.statuses(Some(&mut opts)) {
293 if !statuses.is_empty() {
294 bail!(
295 "Worktree '{}' has uncommitted changes. Use --force to remove anyway.",
296 name
297 );
298 }
299 }
300 }
301 }
302
303 let mut prune_opts = WorktreePruneOptions::new();
305 prune_opts.valid(true).working_tree(true);
306 if force {
307 prune_opts.locked(true);
308 }
309 wt.prune(Some(&mut prune_opts))?;
310
311 if wt_path.exists() {
313 std::fs::remove_dir_all(&wt_path)?;
314 }
315
316 ui.success(format!("Removed worktree '{}'", name));
317 Ok(())
318}
319
320pub fn lock(path: &Path, name: &str, reason: Option<&str>, ui: &UI) -> Result<()> {
321 let repo = Repository::open(path)?;
322 let wt = repo.find_worktree(name)?;
323
324 if let Ok(WorktreeLockStatus::Locked(_)) = wt.is_locked() {
325 bail!("Worktree '{}' is already locked", name);
326 }
327
328 wt.lock(reason)?;
329
330 if let Some(r) = reason {
331 ui.success(format!("Locked worktree '{}' ({})", name, r));
332 } else {
333 ui.success(format!("Locked worktree '{}'", name));
334 }
335 Ok(())
336}
337
338pub fn unlock(path: &Path, name: &str, ui: &UI) -> Result<()> {
339 let repo = Repository::open(path)?;
340 let wt = repo.find_worktree(name)?;
341
342 if let Ok(WorktreeLockStatus::Unlocked) = wt.is_locked() {
343 bail!("Worktree '{}' is not locked", name);
344 }
345
346 wt.unlock()?;
347 ui.success(format!("Unlocked worktree '{}'", name));
348 Ok(())
349}
350
351pub fn prune(path: &Path, dry_run: bool, ui: &UI) -> Result<()> {
352 let repo = Repository::open(path)?;
353 let worktree_names = repo.worktrees()?;
354
355 let mut pruned = 0;
356
357 for name in worktree_names.iter().flatten() {
358 if let Ok(wt) = repo.find_worktree(name) {
359 let mut opts = WorktreePruneOptions::new();
360 if let Ok(true) = wt.is_prunable(Some(&mut opts)) {
361 if dry_run {
362 ui.info(format!("Would prune: {} ({})", name, wt.path().display()));
363 } else {
364 let mut prune_opts = WorktreePruneOptions::new();
365 prune_opts.working_tree(true);
366 match wt.prune(Some(&mut prune_opts)) {
367 Ok(()) => {
368 ui.success(format!("Pruned: {}", name));
369 pruned += 1;
370 }
371 Err(e) => {
372 ui.error(format!("Failed to prune '{}': {}", name, e));
373 }
374 }
375 }
376 }
377 }
378 }
379
380 if pruned == 0 && !dry_run {
381 ui.info("No stale worktrees to prune.".to_string());
382 }
383
384 Ok(())
385}