1use std::path::Path;
9use std::process::Command;
10
11#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub struct GitBranchInfo {
14 pub branch: String,
16 pub pr_number: Option<i32>,
18 pub issue_number: Option<i32>,
20}
21
22pub fn get_current_branch(cwd: &Path) -> Option<String> {
32 let output = Command::new("git")
33 .args(["rev-parse", "--abbrev-ref", "HEAD"])
34 .current_dir(cwd)
35 .output()
36 .ok()?;
37
38 if !output.status.success() {
39 return None;
40 }
41
42 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
43
44 if branch.is_empty() || branch == "HEAD" {
46 return None;
47 }
48
49 Some(branch)
50}
51
52pub fn parse_branch_info(branch: &str) -> GitBranchInfo {
63 let mut info = GitBranchInfo {
64 branch: branch.to_string(),
65 pr_number: None,
66 issue_number: None,
67 };
68
69 let branch_lower = branch.to_lowercase();
71 let last_component = branch_lower.rsplit('/').next().unwrap_or(&branch_lower);
72
73 if let Some(num) = extract_prefixed_number(last_component, "pr-")
76 .or_else(|| extract_prefixed_number(last_component, "pr"))
77 {
78 info.pr_number = Some(num);
79 return info;
80 }
81
82 if let Some(num) = extract_prefixed_number(last_component, "issue-")
85 .or_else(|| extract_prefixed_number(last_component, "issue"))
86 {
87 info.issue_number = Some(num);
88 return info;
89 }
90
91 if let Some(num) = extract_prefixed_number(last_component, "gh-") {
93 info.issue_number = Some(num);
94 return info;
95 }
96
97 if let Some(num) = extract_prefixed_number(last_component, "fix-")
99 .or_else(|| extract_prefixed_number(last_component, "fix/"))
100 {
101 info.issue_number = Some(num);
102 return info;
103 }
104
105 if let Some(num) = extract_leading_number(last_component) {
107 info.issue_number = Some(num);
108 return info;
109 }
110
111 if let Some(num) = extract_trailing_number(last_component) {
113 info.issue_number = Some(num);
114 return info;
115 }
116
117 info
118}
119
120pub fn get_branch_info(cwd: &Path) -> Option<GitBranchInfo> {
124 get_current_branch(cwd).map(|branch| parse_branch_info(&branch))
125}
126
127pub fn is_branch_changing_command(command: &str) -> bool {
138 let cmd_lower = command.to_lowercase();
139
140 let parts: Vec<&str> = cmd_lower.split(['&', '|', ';']).collect();
142
143 for part in parts {
144 let trimmed = part.trim();
145
146 if !trimmed.starts_with("git ") && !trimmed.starts_with("git\t") {
148 continue;
149 }
150
151 let git_args = trimmed.strip_prefix("git").unwrap_or("").trim();
153
154 if git_args.starts_with("checkout ") || git_args.starts_with("checkout\t") {
157 return true;
158 }
159
160 if git_args.starts_with("switch ") || git_args.starts_with("switch\t") {
162 return true;
163 }
164
165 if git_args.starts_with("worktree ")
167 && (git_args.contains("add ") || git_args.contains("add\t"))
168 {
169 return true;
170 }
171
172 if git_args.starts_with("merge ") || git_args.starts_with("merge\t") {
174 return true;
175 }
176
177 if git_args.starts_with("rebase ") || git_args.starts_with("rebase\t") {
179 return true;
180 }
181
182 if git_args.starts_with("pull ") || git_args.starts_with("pull\t") || git_args == "pull" {
184 return true;
185 }
186 }
187
188 false
189}
190
191fn extract_prefixed_number(s: &str, prefix: &str) -> Option<i32> {
194 let after_prefix = s.strip_prefix(prefix)?;
195 extract_leading_number(after_prefix)
196}
197
198fn extract_leading_number(s: &str) -> Option<i32> {
201 let num_str: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
202 if num_str.is_empty() {
203 return None;
204 }
205 num_str.parse().ok()
206}
207
208fn extract_trailing_number(s: &str) -> Option<i32> {
211 let last_part = s.rsplit('-').next()?;
212 if last_part.chars().all(|c| c.is_ascii_digit()) && !last_part.is_empty() {
214 return last_part.parse().ok();
215 }
216 None
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn test_parse_branch_info_issue_patterns() {
225 let info = parse_branch_info("feature/issue-123");
227 assert_eq!(info.issue_number, Some(123));
228 assert_eq!(info.pr_number, None);
229
230 let info = parse_branch_info("fix/GH-456");
232 assert_eq!(info.issue_number, Some(456));
233
234 let info = parse_branch_info("fix-789-description");
236 assert_eq!(info.issue_number, Some(789));
237
238 let info = parse_branch_info("123-add-feature");
240 assert_eq!(info.issue_number, Some(123));
241
242 let info = parse_branch_info("feature-add-something-42");
244 assert_eq!(info.issue_number, Some(42));
245 }
246
247 #[test]
248 fn test_parse_branch_info_pr_patterns() {
249 let info = parse_branch_info("pr-100");
251 assert_eq!(info.pr_number, Some(100));
252 assert_eq!(info.issue_number, None);
253
254 let info = parse_branch_info("feature/pr-200");
256 assert_eq!(info.pr_number, Some(200));
257 }
258
259 #[test]
260 fn test_parse_branch_info_no_number() {
261 let info = parse_branch_info("main");
262 assert_eq!(info.pr_number, None);
263 assert_eq!(info.issue_number, None);
264
265 let info = parse_branch_info("feature/add-new-thing");
266 assert_eq!(info.pr_number, None);
267 assert_eq!(info.issue_number, None);
268 }
269
270 #[test]
271 fn test_is_branch_changing_command() {
272 assert!(is_branch_changing_command("git checkout main"));
274 assert!(is_branch_changing_command("git checkout -b feature"));
275 assert!(is_branch_changing_command("git switch develop"));
276 assert!(is_branch_changing_command("git worktree add ../wt main"));
277 assert!(is_branch_changing_command("git merge feature"));
278 assert!(is_branch_changing_command("git rebase main"));
279 assert!(is_branch_changing_command("git pull"));
280 assert!(is_branch_changing_command("git pull origin main"));
281
282 assert!(is_branch_changing_command("echo foo && git checkout main"));
284 assert!(is_branch_changing_command("git status; git checkout main"));
285
286 assert!(!is_branch_changing_command("git status"));
288 assert!(!is_branch_changing_command("git add ."));
289 assert!(!is_branch_changing_command("git commit -m 'test'"));
290 assert!(!is_branch_changing_command("git push"));
291 assert!(!is_branch_changing_command("git log"));
292 assert!(!is_branch_changing_command("git diff"));
293 assert!(!is_branch_changing_command("git branch -a"));
294 assert!(!is_branch_changing_command("echo checkout"));
295 assert!(!is_branch_changing_command("cargo test"));
296 }
297
298 #[test]
299 fn test_get_current_branch_in_git_repo() {
300 let Ok(cwd) = std::env::current_dir() else {
302 return; };
304 let branch = get_current_branch(&cwd);
305
306 if let Some(ref b) = branch {
309 assert!(!b.is_empty());
310 assert_ne!(b, "HEAD");
311 }
312 }
313
314 #[test]
315 fn test_get_current_branch_not_git_repo() {
316 let branch = get_current_branch(Path::new("/nonexistent/path/that/should/not/exist"));
318 assert!(branch.is_none());
319 }
320}