Skip to main content

worktree_io/
workspace.rs

1use anyhow::{bail, Context, Result};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use crate::issue::IssueRef;
6
7pub struct Workspace {
8    pub path: PathBuf,
9    pub issue: IssueRef,
10    /// true if this call actually created the worktree; false if it already existed
11    pub created: bool,
12}
13
14impl Workspace {
15    /// Open an existing worktree or create a fresh one.
16    pub fn open_or_create(issue: IssueRef) -> Result<Self> {
17        let worktree_path = issue.temp_path();
18        let bare_path = issue.bare_clone_path();
19
20        // Fast path: worktree already exists
21        if worktree_path.exists() {
22            return Ok(Self {
23                path: worktree_path,
24                issue,
25                created: false,
26            });
27        }
28
29        // Ensure the bare clone exists
30        if !bare_path.exists() {
31            eprintln!(
32                "Cloning {} (bare) into {}…",
33                issue.clone_url(),
34                bare_path.display()
35            );
36            bare_clone(&issue.clone_url(), &bare_path)?;
37        } else {
38            // Fetch latest
39            eprintln!("Fetching origin…");
40            git_fetch(&bare_path)?;
41        }
42
43        // Detect the default branch (e.g. "main" or "master")
44        let base_branch = detect_default_branch(&bare_path)?;
45        eprintln!("Default branch: {base_branch}");
46
47        let branch = issue.branch_name();
48
49        // Check whether the branch already exists on the remote
50        let branch_exists = branch_exists_remote(&bare_path, &branch);
51
52        // Create the worktree
53        eprintln!(
54            "Creating worktree {} at {}…",
55            branch,
56            worktree_path.display()
57        );
58        create_worktree(&bare_path, &worktree_path, &branch, &base_branch, branch_exists)?;
59
60        Ok(Self {
61            path: worktree_path,
62            issue,
63            created: true,
64        })
65    }
66}
67
68fn bare_clone(url: &str, dest: &Path) -> Result<()> {
69    if let Some(parent) = dest.parent() {
70        std::fs::create_dir_all(parent)
71            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
72    }
73
74    let status = Command::new("git")
75        .args(["clone", "--bare", url])
76        .arg(dest)
77        .status()
78        .context("Failed to run `git clone --bare`")?;
79
80    if !status.success() {
81        bail!("git clone --bare failed for {url}");
82    }
83
84    // Set up the remote tracking so `git fetch` and `symbolic-ref` work correctly
85    // for a bare clone we need to configure remote.origin.fetch
86    let fetch_refspec = "+refs/heads/*:refs/remotes/origin/*";
87    let status = Command::new("git")
88        .args(["-C"])
89        .arg(dest)
90        .args(["config", "remote.origin.fetch", fetch_refspec])
91        .status()
92        .context("Failed to configure remote.origin.fetch")?;
93
94    if !status.success() {
95        bail!("Failed to set remote.origin.fetch");
96    }
97
98    // Fetch so that refs/remotes/origin/HEAD is populated
99    let status = Command::new("git")
100        .args(["-C"])
101        .arg(dest)
102        .args(["fetch", "origin"])
103        .status()
104        .context("Failed to run `git fetch origin`")?;
105
106    if !status.success() {
107        bail!("git fetch origin failed after bare clone");
108    }
109
110    Ok(())
111}
112
113fn git_fetch(bare: &Path) -> Result<()> {
114    let status = Command::new("git")
115        .args(["-C"])
116        .arg(bare)
117        .args(["fetch", "origin"])
118        .status()
119        .context("Failed to run `git fetch`")?;
120
121    if !status.success() {
122        bail!("git fetch origin failed");
123    }
124    Ok(())
125}
126
127fn detect_default_branch(bare: &Path) -> Result<String> {
128    // Try symbolic-ref first (works when remote HEAD is set)
129    let output = Command::new("git")
130        .args(["-C"])
131        .arg(bare)
132        .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
133        .output()
134        .context("Failed to run `git symbolic-ref`")?;
135
136    if output.status.success() {
137        let full = String::from_utf8_lossy(&output.stdout);
138        // Output looks like "refs/remotes/origin/main\n"
139        if let Some(branch) = full.trim().strip_prefix("refs/remotes/origin/") {
140            return Ok(branch.to_string());
141        }
142    }
143
144    // Fall back: try `git remote show origin` to detect the default branch name
145    let output = Command::new("git")
146        .args(["-C"])
147        .arg(bare)
148        .args(["remote", "show", "origin"])
149        .output()
150        .context("Failed to run `git remote show origin`")?;
151
152    if output.status.success() {
153        let text = String::from_utf8_lossy(&output.stdout);
154        for line in text.lines() {
155            let line = line.trim();
156            if let Some(branch) = line.strip_prefix("HEAD branch: ") {
157                return Ok(branch.to_string());
158            }
159        }
160    }
161
162    // Last resort: try common names
163    for candidate in ["main", "master", "develop"] {
164        let output = Command::new("git")
165            .args(["-C"])
166            .arg(bare)
167            .args(["rev-parse", "--verify", &format!("refs/remotes/origin/{candidate}")])
168            .output()
169            .context("Failed to run `git rev-parse`")?;
170        if output.status.success() {
171            return Ok(candidate.to_string());
172        }
173    }
174
175    bail!("Could not detect default branch for the repository");
176}
177
178fn branch_exists_remote(bare: &Path, branch: &str) -> bool {
179    Command::new("git")
180        .args(["-C"])
181        .arg(bare)
182        .args(["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")])
183        .output()
184        .map(|o| o.status.success())
185        .unwrap_or(false)
186}
187
188fn create_worktree(
189    bare: &Path,
190    dest: &Path,
191    branch: &str,
192    base_branch: &str,
193    branch_exists: bool,
194) -> Result<()> {
195    let mut cmd = Command::new("git");
196    cmd.args(["-C"]).arg(bare).arg("worktree").arg("add");
197
198    if branch_exists {
199        // Check out the existing remote branch, tracking it locally
200        cmd.arg(dest)
201            .arg("--track")
202            .arg(format!("origin/{branch}"));
203    } else {
204        // Create a new branch from the default base
205        cmd.arg(dest)
206            .arg("-b")
207            .arg(branch)
208            .arg(format!("origin/{base_branch}"));
209    }
210
211    let status = cmd.status().context("Failed to run `git worktree add`")?;
212
213    if !status.success() {
214        bail!("git worktree add failed for branch {branch}");
215    }
216    Ok(())
217}