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 pub created: bool,
12}
13
14impl Workspace {
15 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 if worktree_path.exists() {
22 return Ok(Self {
23 path: worktree_path,
24 issue,
25 created: false,
26 });
27 }
28
29 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 eprintln!("Fetching origin…");
40 git_fetch(&bare_path)?;
41 }
42
43 let base_branch = detect_default_branch(&bare_path)?;
45 eprintln!("Default branch: {base_branch}");
46
47 let branch = issue.branch_name();
48
49 let branch_exists = branch_exists_remote(&bare_path, &branch);
51
52 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 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 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 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 if let Some(branch) = full.trim().strip_prefix("refs/remotes/origin/") {
140 return Ok(branch.to_string());
141 }
142 }
143
144 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 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 cmd.arg(dest)
201 .arg("--track")
202 .arg(format!("origin/{branch}"));
203 } else {
204 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}