1use std::path::{Path, PathBuf};
34use std::process::Command;
35
36#[derive(Debug)]
38pub enum WorktreeError {
39 CreateFailed(String),
41 RemoveFailed(String),
43 BranchDeleteFailed(String),
45 StatusFailed(String),
47 SpawnFailed(String),
49 EmptyBranchName,
51}
52
53impl std::fmt::Display for WorktreeError {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 Self::CreateFailed(msg) => write!(f, "git worktree add failed: {msg}"),
57 Self::RemoveFailed(msg) => write!(f, "git worktree remove failed: {msg}"),
58 Self::BranchDeleteFailed(msg) => write!(f, "git branch -D failed: {msg}"),
59 Self::StatusFailed(msg) => write!(f, "git status --porcelain failed: {msg}"),
60 Self::SpawnFailed(msg) => write!(f, "failed to spawn git: {msg}"),
61 Self::EmptyBranchName => write!(f, "branch name must be non-empty"),
62 }
63 }
64}
65
66impl std::error::Error for WorktreeError {}
67
68#[derive(Debug)]
75pub struct WorktreeSession {
76 path: PathBuf,
78 branch: String,
80 repo_root: PathBuf,
82}
83
84impl WorktreeSession {
85 pub fn create(repo_root: &Path, branch: &str) -> Result<Self, WorktreeError> {
90 if branch.trim().is_empty() {
91 return Err(WorktreeError::EmptyBranchName);
92 }
93 let worktree_path = worktree_path_for(repo_root, branch);
94 let output = Command::new("git")
95 .arg("-C")
96 .arg(repo_root)
97 .arg("worktree")
98 .arg("add")
99 .arg("-b")
100 .arg(branch)
101 .arg(&worktree_path)
102 .output()
103 .map_err(|e| WorktreeError::SpawnFailed(e.to_string()))?;
104
105 if !output.status.success() {
106 return Err(WorktreeError::CreateFailed(
107 String::from_utf8_lossy(&output.stderr).into(),
108 ));
109 }
110
111 Ok(Self {
112 path: worktree_path,
113 branch: branch.to_string(),
114 repo_root: repo_root.to_path_buf(),
115 })
116 }
117
118 pub fn path(&self) -> &Path {
120 &self.path
121 }
122
123 pub fn branch(&self) -> &str {
125 &self.branch
126 }
127
128 pub fn repo_root(&self) -> &Path {
130 &self.repo_root
131 }
132
133 pub fn is_dirty(&self) -> Result<bool, WorktreeError> {
137 let output = Command::new("git")
138 .arg("-C")
139 .arg(&self.path)
140 .arg("status")
141 .arg("--porcelain")
142 .output()
143 .map_err(|e| WorktreeError::SpawnFailed(e.to_string()))?;
144
145 if !output.status.success() {
146 return Err(WorktreeError::StatusFailed(
147 String::from_utf8_lossy(&output.stderr).into(),
148 ));
149 }
150
151 Ok(!output.stdout.is_empty())
152 }
153
154 pub fn auto_close(self) -> Result<(), WorktreeError> {
159 remove_worktree(&self.repo_root, &self.path)?;
160 delete_branch(&self.repo_root, &self.branch)?;
161 Ok(())
162 }
163
164 pub fn auto_close_if_clean(self) -> Result<Option<(PathBuf, String)>, WorktreeError> {
170 if self.is_dirty()? {
171 Ok(Some((self.path, self.branch)))
172 } else {
173 let path = self.path.clone();
174 let branch = self.branch.clone();
175 remove_worktree(&self.repo_root, &path)?;
176 delete_branch(&self.repo_root, &branch)?;
177 Ok(None)
178 }
179 }
180
181 pub fn keep(self) -> (PathBuf, String) {
185 (self.path, self.branch)
186 }
187}
188
189fn remove_worktree(repo_root: &Path, path: &Path) -> Result<(), WorktreeError> {
190 let output = Command::new("git")
191 .arg("-C")
192 .arg(repo_root)
193 .arg("worktree")
194 .arg("remove")
195 .arg("--force")
196 .arg(path)
197 .output()
198 .map_err(|e| WorktreeError::SpawnFailed(e.to_string()))?;
199
200 if !output.status.success() {
201 return Err(WorktreeError::RemoveFailed(String::from_utf8_lossy(&output.stderr).into()));
202 }
203 Ok(())
204}
205
206fn delete_branch(repo_root: &Path, branch: &str) -> Result<(), WorktreeError> {
207 let output = Command::new("git")
208 .arg("-C")
209 .arg(repo_root)
210 .arg("branch")
211 .arg("-D")
212 .arg(branch)
213 .output()
214 .map_err(|e| WorktreeError::SpawnFailed(e.to_string()))?;
215
216 if !output.status.success() {
217 return Err(WorktreeError::BranchDeleteFailed(
218 String::from_utf8_lossy(&output.stderr).into(),
219 ));
220 }
221 Ok(())
222}
223
224fn worktree_path_for(repo_root: &Path, branch: &str) -> PathBuf {
225 let sanitized: String = branch
226 .chars()
227 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
228 .collect();
229 repo_root.join(".git").join("apr-worktrees").join(sanitized)
230}
231
232#[cfg(test)]
233mod tests;