1use std::collections::BTreeSet;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use crate::error::PawError;
11use crate::specs::SpecEntry;
12
13pub fn validate_repo(path: &Path) -> Result<PathBuf, PawError> {
17 let output = Command::new("git")
18 .current_dir(path)
19 .args(["rev-parse", "--show-toplevel"])
20 .output()
21 .map_err(|e| PawError::BranchError(format!("failed to run git: {e}")))?;
22
23 if !output.status.success() {
24 return Err(PawError::NotAGitRepo);
25 }
26
27 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
28 Ok(PathBuf::from(root))
29}
30
31pub fn list_branches(repo_root: &Path) -> Result<Vec<String>, PawError> {
38 let output = Command::new("git")
39 .current_dir(repo_root)
40 .args(["branch", "-a", "--format=%(refname:short)"])
41 .output()
42 .map_err(|e| PawError::BranchError(format!("failed to run git branch: {e}")))?;
43
44 if !output.status.success() {
45 let stderr = String::from_utf8_lossy(&output.stderr);
46 return Err(PawError::BranchError(format!(
47 "git branch failed: {stderr}"
48 )));
49 }
50
51 let stdout = String::from_utf8_lossy(&output.stdout);
52 let branches: BTreeSet<String> = stdout
53 .lines()
54 .filter(|line| !line.trim().is_empty() && !line.contains("HEAD"))
55 .map(|line| {
56 let mut branch_name = line.trim().to_string();
58
59 if let Some(stripped) = branch_name.strip_prefix("refs/remotes/") {
61 branch_name = stripped.to_string();
62 }
63 if let Some(stripped) = branch_name.strip_prefix("origin/") {
65 branch_name = stripped.to_string();
66 }
67
68 branch_name
69 })
70 .collect();
71
72 let mut unique: Vec<String> = branches.into_iter().collect();
74 unique.sort();
75 Ok(unique)
76}
77
78pub fn worktree_dir_name(project: &str, branch: &str) -> String {
82 let project_safe: String = project
83 .chars()
84 .map(|c| if c.is_alphanumeric() { c } else { '-' })
85 .collect();
86 let branch_safe: String = branch
87 .chars()
88 .map(|c| if c.is_alphanumeric() { c } else { '-' })
89 .collect();
90 format!("{project_safe}-{branch_safe}")
91}
92
93pub fn default_branch(repo_root: &Path) -> Result<String, PawError> {
95 let output = Command::new("git")
96 .current_dir(repo_root)
97 .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
98 .output()
99 .map_err(|e| PawError::BranchError(format!("failed to run git symbolic-ref: {e}")))?;
100
101 if !output.status.success() {
102 let stderr = String::from_utf8_lossy(&output.stderr);
103 return Err(PawError::BranchError(format!(
104 "git symbolic-ref failed: {stderr}"
105 )));
106 }
107
108 let ref_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
109 if let Some(branch) = ref_name.strip_prefix("refs/remotes/origin/") {
110 Ok(branch.to_string())
111 } else {
112 Err(PawError::BranchError(format!(
113 "unexpected ref format: {ref_name}"
114 )))
115 }
116}
117
118pub fn current_branch(repo_root: &Path) -> Result<String, PawError> {
120 let output = Command::new("git")
121 .current_dir(repo_root)
122 .args(["branch", "--show-current"])
123 .output()
124 .map_err(|e| PawError::BranchError(format!("failed to run git branch: {e}")))?;
125
126 if !output.status.success() {
127 let stderr = String::from_utf8_lossy(&output.stderr);
128 return Err(PawError::BranchError(format!(
129 "git branch failed: {stderr}"
130 )));
131 }
132
133 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
134 if branch.is_empty() {
135 return Err(PawError::BranchError(
136 "not on any branch (detached HEAD)".to_string(),
137 ));
138 }
139 Ok(branch)
140}
141
142pub fn project_name(repo_root: &Path) -> String {
144 repo_root
145 .file_name()
146 .and_then(std::ffi::OsStr::to_str)
147 .unwrap_or("unknown")
148 .to_string()
149}
150
151#[derive(Debug)]
153pub struct WorktreeCreation {
154 pub path: PathBuf,
156 pub branch_created: bool,
158}
159
160pub fn create_worktree(repo_root: &Path, branch: &str) -> Result<WorktreeCreation, PawError> {
167 let project = project_name(repo_root);
168 let dir_name = worktree_dir_name(&project, branch);
169
170 let parent = repo_root.parent().ok_or_else(|| {
171 PawError::WorktreeError("cannot determine parent directory of repo".to_string())
172 })?;
173 let worktree_path = parent.join(&dir_name);
174
175 let output = Command::new("git")
177 .current_dir(repo_root)
178 .args(["worktree", "add", &worktree_path.to_string_lossy(), branch])
179 .output()
180 .map_err(|e| PawError::WorktreeError(format!("failed to run git worktree add: {e}")))?;
181
182 if output.status.success() {
183 return Ok(WorktreeCreation {
184 path: worktree_path,
185 branch_created: false,
186 });
187 }
188
189 let stderr = String::from_utf8_lossy(&output.stderr);
190
191 if stderr.contains("invalid reference") {
193 let output = Command::new("git")
194 .current_dir(repo_root)
195 .args([
196 "worktree",
197 "add",
198 "-b",
199 branch,
200 &worktree_path.to_string_lossy(),
201 ])
202 .output()
203 .map_err(|e| {
204 PawError::WorktreeError(format!("failed to run git worktree add -b: {e}"))
205 })?;
206
207 if output.status.success() {
208 return Ok(WorktreeCreation {
209 path: worktree_path,
210 branch_created: true,
211 });
212 }
213
214 let stderr = String::from_utf8_lossy(&output.stderr);
215 return Err(PawError::WorktreeError(format!(
216 "git worktree add -b failed for branch '{branch}': {stderr}"
217 )));
218 }
219
220 Err(PawError::WorktreeError(format!(
221 "git worktree add failed for branch '{branch}': {stderr}"
222 )))
223}
224
225pub fn remove_worktree(repo_root: &Path, worktree_path: &Path) -> Result<(), PawError> {
233 let output = Command::new("git")
240 .current_dir(repo_root)
241 .args([
242 "worktree",
243 "remove",
244 "--force",
245 worktree_path.to_str().unwrap(),
246 ])
247 .output()
248 .map_err(|e| {
249 PawError::WorktreeError(format!(
250 "failed to remove worktree at {}: {e}",
251 worktree_path.display()
252 ))
253 })?;
254
255 if !output.status.success() {
256 let stderr = String::from_utf8_lossy(&output.stderr);
257 return Err(PawError::WorktreeError(format!(
258 "git worktree remove failed for worktree at {}: {stderr}",
259 worktree_path.display()
260 )));
261 }
262
263 Ok(())
264}
265
266pub fn prune_worktrees(repo_root: &Path) -> Result<(), PawError> {
270 let output = Command::new("git")
271 .current_dir(repo_root)
272 .args(["worktree", "prune"])
273 .output()
274 .map_err(|e| PawError::WorktreeError(format!("failed to prune worktrees: {e}")))?;
275
276 if !output.status.success() {
277 let stderr = String::from_utf8_lossy(&output.stderr);
278 return Err(PawError::WorktreeError(format!(
279 "git worktree prune failed: {stderr}"
280 )));
281 }
282
283 Ok(())
284}
285
286pub fn check_uncommitted_specs(
297 repo_root: &Path,
298 specs: &[SpecEntry],
299) -> Result<Vec<String>, PawError> {
300 let mut uncommitted_specs = Vec::new();
301
302 let specs_dir = repo_root.join("specs");
303
304 for spec in specs {
305 let dir_path = specs_dir.join(&spec.id);
306 let file_path = specs_dir.join(format!("{}.md", spec.id));
307
308 let porcelain_target = if dir_path.is_dir() {
309 format!("specs/{}", spec.id)
310 } else if file_path.is_file() {
311 format!("specs/{}.md", spec.id)
312 } else {
313 continue;
314 };
315
316 let output = Command::new("git")
317 .current_dir(repo_root)
318 .args(["status", "--porcelain", "--", &porcelain_target])
319 .output()
320 .map_err(|e| {
321 PawError::BranchError(format!(
322 "failed to run git status for spec {}: {e}",
323 spec.id
324 ))
325 })?;
326
327 if !output.status.success() {
328 let stderr = String::from_utf8_lossy(&output.stderr);
329 return Err(PawError::BranchError(format!(
330 "git status failed for spec {}: {stderr}",
331 spec.id
332 )));
333 }
334
335 let status_output = String::from_utf8_lossy(&output.stdout).trim().to_string();
336 if !status_output.is_empty() {
337 uncommitted_specs.push(spec.id.clone());
338 }
339 }
340
341 Ok(uncommitted_specs)
342}
343
344pub fn merge_branch(repo_root: &Path, branch: &str) -> Result<bool, PawError> {
348 let output = Command::new("git")
349 .current_dir(repo_root)
350 .args(["merge", "--no-ff", "--no-commit", branch])
351 .output()
352 .map_err(|e| {
353 PawError::WorktreeError(format!("failed to run git merge for branch {branch}: {e}"))
354 })?;
355
356 if !output.status.success() {
357 let stderr = String::from_utf8_lossy(&output.stderr);
358 if output.status.code() == Some(1) {
360 return Ok(false);
361 }
362 return Err(PawError::WorktreeError(format!(
363 "git merge failed for branch {branch}: {stderr}"
364 )));
365 }
366
367 Ok(true)
368}
369
370pub fn delete_branch(repo_root: &Path, branch: &str) -> Result<(), PawError> {
372 let output = Command::new("git")
373 .current_dir(repo_root)
374 .args(["branch", "-D", branch])
375 .output()
376 .map_err(|e| PawError::BranchError(format!("failed to delete branch {branch}: {e}")))?;
377
378 if !output.status.success() {
379 let stderr = String::from_utf8_lossy(&output.stderr);
380 return Err(PawError::BranchError(format!(
381 "git branch -D failed for branch {branch}: {stderr}"
382 )));
383 }
384
385 Ok(())
386}
387
388pub fn exclude_from_git(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
394 let exclude_file = worktree_root.join(".git/info/exclude");
395
396 let existing = if exclude_file.exists() {
398 std::fs::read_to_string(&exclude_file).unwrap_or_default()
399 } else {
400 String::new()
401 };
402
403 if !existing.lines().any(|line| line.trim() == filename) {
405 let mut updated = existing;
406 if !updated.ends_with('\n') && !updated.is_empty() {
407 updated.push('\n');
408 }
409 updated.push_str(filename);
410 updated.push('\n');
411
412 if let Some(parent) = exclude_file.parent() {
414 if let Some(git_dir) = parent.parent()
416 && git_dir.is_file()
417 {
418 let main_git_dir = std::fs::read_to_string(git_dir)
421 .ok()
422 .and_then(|s| s.strip_prefix("gitdir: ").map(|s| s.trim().to_owned()))
423 .unwrap_or_default();
424 let main_git_info = PathBuf::from(main_git_dir).join("info");
425 if !main_git_info.try_exists().unwrap_or(false) {
426 std::fs::create_dir_all(&main_git_info).map_err(|e| {
427 PawError::SessionError(format!("failed to create main .git/info: {e}"))
428 })?;
429 }
430 let main_exclude = main_git_info.join("exclude");
431 std::fs::write(&main_exclude, updated).map_err(|e| {
432 PawError::SessionError(format!(
433 "failed to write to main .git/info/exclude: {e}"
434 ))
435 })?;
436 return Ok(());
437 }
438 if parent.exists() && parent.is_file() {
439 std::fs::remove_file(parent).map_err(|e| {
440 PawError::SessionError(format!("failed to remove .git/info file: {e}"))
441 })?;
442 }
443 std::fs::create_dir_all(parent).map_err(|e| {
444 PawError::SessionError(format!("failed to create .git/info directory: {e}"))
445 })?;
446 }
447
448 std::fs::write(&exclude_file, updated).map_err(|e| {
449 PawError::SessionError(format!("failed to write to .git/info/exclude: {e}"))
450 })?;
451 }
452
453 Ok(())
454}
455
456pub fn assume_unchanged(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
462 let _ = std::process::Command::new("git")
463 .current_dir(worktree_root)
464 .args(["update-index", "--assume-unchanged", filename])
465 .status();
466 Ok(())
467}