Skip to main content

securegit/ops/
worktree.rs

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/// Information about a single worktree for structured output.
9#[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
19/// Gather information about all worktrees (main + secondary).
20fn gather_worktrees(repo: &Repository) -> Result<Vec<WorktreeInfo>> {
21    let mut entries = Vec::new();
22
23    // Main worktree
24    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    // Secondary worktrees
58    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            // Get HEAD info by opening the worktree as a repo
64            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
158/// List worktrees as JSON (for MCP).
159pub 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    // Ensure parent directory exists
207    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        // Check if the branch already exists
222        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            // Create the branch first, then use it
235            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        // No branch specified — git2 creates a new branch named after the worktree
249        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    // Check lock status
274    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        // Unlock before removing
284        wt.unlock()?;
285    }
286
287    // Check for uncommitted changes if not forcing
288    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    // Prune the worktree (removes admin files + working tree)
304    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    // Remove the directory if it still exists (prune with working_tree should handle this)
312    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}