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
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
18/// List worktrees via `git worktree list --porcelain`.
19pub 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    // Last entry (no trailing blank line)
56    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
88/// Convert WorktreeEntry list to WorktreeInfo list (no sessions yet — populated by refresh_all).
89pub 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
116/// `git worktree add -b {branch} {path} {base_branch}`
117pub 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
149/// `git worktree remove --force {path}` then `git branch -d {branch}`
150pub 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    // Best-effort branch deletion
168    let _ = git_cmd(repo_path)
169        .args(["branch", "-d", branch])
170        .stdout(Stdio::null())
171        .stderr(Stdio::null())
172        .status();
173
174    Ok(())
175}