1use crate::git::CommitInfo;
4use anyhow::{Context, Result};
5use git2::{Repository, Status};
6use ssh2_config::{ParseRule, SshConfig};
7use std::io::BufReader;
8use std::path::PathBuf;
9use tracing::{debug, error, info};
10
11pub struct GitRepository {
13 repo: Repository,
14}
15
16#[derive(Debug)]
18pub struct WorkingDirectoryStatus {
19 pub clean: bool,
21 pub untracked_changes: Vec<FileStatus>,
23}
24
25#[derive(Debug)]
27pub struct FileStatus {
28 pub status: String,
30 pub file: String,
32}
33
34impl GitRepository {
35 pub fn open() -> Result<Self> {
37 let repo = Repository::open(".").context("Not in a git repository")?;
38
39 Ok(Self { repo })
40 }
41
42 pub fn open_at<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
44 let repo = Repository::open(path).context("Failed to open git repository")?;
45
46 Ok(Self { repo })
47 }
48
49 pub fn get_working_directory_status(&self) -> Result<WorkingDirectoryStatus> {
51 let statuses = self
52 .repo
53 .statuses(None)
54 .context("Failed to get repository status")?;
55
56 let mut untracked_changes = Vec::new();
57
58 for entry in statuses.iter() {
59 if let Some(path) = entry.path() {
60 let status_flags = entry.status();
61
62 if status_flags.contains(Status::IGNORED) {
64 continue;
65 }
66
67 let status_str = format_status_flags(status_flags);
68
69 untracked_changes.push(FileStatus {
70 status: status_str,
71 file: path.to_string(),
72 });
73 }
74 }
75
76 let clean = untracked_changes.is_empty();
77
78 Ok(WorkingDirectoryStatus {
79 clean,
80 untracked_changes,
81 })
82 }
83
84 pub fn is_working_directory_clean(&self) -> Result<bool> {
86 let status = self.get_working_directory_status()?;
87 Ok(status.clean)
88 }
89
90 pub fn path(&self) -> &std::path::Path {
92 self.repo.path()
93 }
94
95 pub fn workdir(&self) -> Option<&std::path::Path> {
97 self.repo.workdir()
98 }
99
100 pub fn repository(&self) -> &Repository {
102 &self.repo
103 }
104
105 pub fn get_current_branch(&self) -> Result<String> {
107 let head = self.repo.head().context("Failed to get HEAD reference")?;
108
109 if let Some(name) = head.shorthand() {
110 if name != "HEAD" {
111 return Ok(name.to_string());
112 }
113 }
114
115 anyhow::bail!("Repository is in detached HEAD state")
116 }
117
118 pub fn branch_exists(&self, branch_name: &str) -> Result<bool> {
120 if self
122 .repo
123 .find_branch(branch_name, git2::BranchType::Local)
124 .is_ok()
125 {
126 return Ok(true);
127 }
128
129 if self
131 .repo
132 .find_branch(branch_name, git2::BranchType::Remote)
133 .is_ok()
134 {
135 return Ok(true);
136 }
137
138 if self.repo.revparse_single(branch_name).is_ok() {
140 return Ok(true);
141 }
142
143 Ok(false)
144 }
145
146 pub fn get_commits_in_range(&self, range: &str) -> Result<Vec<CommitInfo>> {
148 let mut commits = Vec::new();
149
150 if range == "HEAD" {
151 let head = self.repo.head().context("Failed to get HEAD")?;
153 let commit = head
154 .peel_to_commit()
155 .context("Failed to peel HEAD to commit")?;
156 commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
157 } else if range.contains("..") {
158 let parts: Vec<&str> = range.split("..").collect();
160 if parts.len() != 2 {
161 anyhow::bail!("Invalid range format: {}", range);
162 }
163
164 let start_spec = parts[0];
165 let end_spec = parts[1];
166
167 let start_obj = self
169 .repo
170 .revparse_single(start_spec)
171 .with_context(|| format!("Failed to parse start commit: {}", start_spec))?;
172 let end_obj = self
173 .repo
174 .revparse_single(end_spec)
175 .with_context(|| format!("Failed to parse end commit: {}", end_spec))?;
176
177 let start_commit = start_obj
178 .peel_to_commit()
179 .context("Failed to peel start object to commit")?;
180 let end_commit = end_obj
181 .peel_to_commit()
182 .context("Failed to peel end object to commit")?;
183
184 let mut walker = self.repo.revwalk().context("Failed to create revwalk")?;
186 walker
187 .push(end_commit.id())
188 .context("Failed to push end commit")?;
189 walker
190 .hide(start_commit.id())
191 .context("Failed to hide start commit")?;
192
193 for oid in walker {
194 let oid = oid.context("Failed to get commit OID from walker")?;
195 let commit = self
196 .repo
197 .find_commit(oid)
198 .context("Failed to find commit")?;
199
200 if commit.parent_count() > 1 {
202 continue;
203 }
204
205 commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
206 }
207
208 commits.reverse();
210 } else {
211 let obj = self
213 .repo
214 .revparse_single(range)
215 .with_context(|| format!("Failed to parse commit: {}", range))?;
216 let commit = obj
217 .peel_to_commit()
218 .context("Failed to peel object to commit")?;
219 commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
220 }
221
222 Ok(commits)
223 }
224}
225
226fn format_status_flags(flags: Status) -> String {
228 let mut status = String::new();
229
230 if flags.contains(Status::INDEX_NEW) {
231 status.push('A');
232 } else if flags.contains(Status::INDEX_MODIFIED) {
233 status.push('M');
234 } else if flags.contains(Status::INDEX_DELETED) {
235 status.push('D');
236 } else if flags.contains(Status::INDEX_RENAMED) {
237 status.push('R');
238 } else if flags.contains(Status::INDEX_TYPECHANGE) {
239 status.push('T');
240 } else {
241 status.push(' ');
242 }
243
244 if flags.contains(Status::WT_NEW) {
245 status.push('?');
246 } else if flags.contains(Status::WT_MODIFIED) {
247 status.push('M');
248 } else if flags.contains(Status::WT_DELETED) {
249 status.push('D');
250 } else if flags.contains(Status::WT_TYPECHANGE) {
251 status.push('T');
252 } else if flags.contains(Status::WT_RENAMED) {
253 status.push('R');
254 } else {
255 status.push(' ');
256 }
257
258 status
259}
260
261fn extract_hostname_from_git_url(url: &str) -> Option<String> {
263 if let Some(ssh_url) = url.strip_prefix("git@") {
264 ssh_url.split(':').next().map(|s| s.to_string())
266 } else if let Some(https_url) = url.strip_prefix("https://") {
267 https_url.split('/').next().map(|s| s.to_string())
269 } else if let Some(http_url) = url.strip_prefix("http://") {
270 http_url.split('/').next().map(|s| s.to_string())
272 } else {
273 None
274 }
275}
276
277fn get_ssh_identity_for_host(hostname: &str) -> Option<PathBuf> {
279 let home = std::env::var("HOME").ok()?;
280 let ssh_config_path = PathBuf::from(&home).join(".ssh/config");
281
282 if !ssh_config_path.exists() {
283 debug!("SSH config file not found at: {:?}", ssh_config_path);
284 return None;
285 }
286
287 let file = std::fs::File::open(&ssh_config_path).ok()?;
289 let mut reader = BufReader::new(file);
290
291 let config = SshConfig::default()
292 .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
293 .ok()?;
294
295 let params = config.query(hostname);
297
298 if let Some(identity_files) = ¶ms.identity_file {
300 if let Some(first_identity) = identity_files.first() {
301 let identity_str = first_identity.to_string_lossy();
303 let identity_path = identity_str.replace("~", &home);
304 let path = PathBuf::from(identity_path);
305
306 if path.exists() {
307 debug!("Found SSH key for host '{}': {:?}", hostname, path);
308 return Some(path);
309 } else {
310 debug!("SSH key specified in config but not found: {:?}", path);
311 }
312 }
313 }
314
315 None
316}
317
318impl GitRepository {
319 pub fn push_branch(&self, branch_name: &str, remote_name: &str) -> Result<()> {
321 info!(
322 "Pushing branch '{}' to remote '{}'",
323 branch_name, remote_name
324 );
325
326 debug!("Finding remote '{}'", remote_name);
328 let mut remote = self
329 .repo
330 .find_remote(remote_name)
331 .context("Failed to find remote")?;
332
333 let remote_url = remote.url().unwrap_or("<unknown>");
334 debug!("Remote URL: {}", remote_url);
335
336 let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
338 debug!("Using refspec: {}", refspec);
339
340 let hostname =
342 extract_hostname_from_git_url(remote_url).unwrap_or("github.com".to_string());
343 debug!(
344 "Extracted hostname '{}' from URL '{}'",
345 hostname, remote_url
346 );
347
348 let mut push_options = git2::PushOptions::new();
350 let mut callbacks = git2::RemoteCallbacks::new();
351 let mut auth_attempts = 0;
352
353 callbacks.credentials(move |url, username_from_url, allowed_types| {
355 auth_attempts += 1;
356 debug!(
357 "Credential callback attempt {} - URL: {}, Username: {:?}, Allowed types: {:?}",
358 auth_attempts, url, username_from_url, allowed_types
359 );
360
361 if auth_attempts > 3 {
363 error!(
364 "Too many authentication attempts ({}), giving up",
365 auth_attempts
366 );
367 return Err(git2::Error::from_str(
368 "Authentication failed after multiple attempts",
369 ));
370 }
371
372 let username = username_from_url.unwrap_or("git");
373
374 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
376 if let Some(ssh_key_path) = get_ssh_identity_for_host(&hostname) {
381 let pub_key_path = ssh_key_path.with_extension("pub");
382 debug!("Trying SSH key from config: {:?}", ssh_key_path);
383
384 match git2::Cred::ssh_key(username, Some(&pub_key_path), &ssh_key_path, None) {
385 Ok(cred) => {
386 debug!(
387 "Successfully loaded SSH key from config: {:?}",
388 ssh_key_path
389 );
390 return Ok(cred);
391 }
392 Err(e) => {
393 debug!("Failed to load SSH key from config: {}", e);
394 }
395 }
396 }
397
398 if auth_attempts == 1 {
400 match git2::Cred::ssh_key_from_agent(username) {
401 Ok(cred) => {
402 debug!("SSH agent credentials obtained (attempt {})", auth_attempts);
403 return Ok(cred);
404 }
405 Err(e) => {
406 debug!("SSH agent failed: {}, trying default keys", e);
407 }
408 }
409 }
410
411 let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
413 let ssh_keys = [
414 format!("{}/.ssh/id_ed25519", home),
415 format!("{}/.ssh/id_rsa", home),
416 ];
417
418 for key_path in &ssh_keys {
419 let key_path = PathBuf::from(key_path);
420 if key_path.exists() {
421 let pub_key_path = key_path.with_extension("pub");
422 debug!("Trying default SSH key: {:?}", key_path);
423
424 match git2::Cred::ssh_key(username, Some(&pub_key_path), &key_path, None) {
425 Ok(cred) => {
426 debug!("Successfully loaded SSH key from {:?}", key_path);
427 return Ok(cred);
428 }
429 Err(e) => debug!("Failed to load SSH key from {:?}: {}", key_path, e),
430 }
431 }
432 }
433 }
434
435 debug!("Falling back to default credentials");
437 git2::Cred::default()
438 });
439
440 push_options.remote_callbacks(callbacks);
441
442 debug!("Attempting to push to remote...");
444 match remote.push(&[&refspec], Some(&mut push_options)) {
445 Ok(_) => {
446 info!(
447 "Successfully pushed branch '{}' to remote '{}'",
448 branch_name, remote_name
449 );
450
451 debug!("Setting upstream branch for '{}'", branch_name);
453 match self.repo.find_branch(branch_name, git2::BranchType::Local) {
454 Ok(mut branch) => {
455 let remote_ref = format!("{}/{}", remote_name, branch_name);
456 match branch.set_upstream(Some(&remote_ref)) {
457 Ok(_) => {
458 info!(
459 "Successfully set upstream to '{}'/{}",
460 remote_name, branch_name
461 );
462 }
463 Err(e) => {
464 error!("Failed to set upstream branch: {}", e);
466 }
467 }
468 }
469 Err(e) => {
470 error!("Failed to find local branch to set upstream: {}", e);
472 }
473 }
474
475 Ok(())
476 }
477 Err(e) => {
478 error!("Failed to push branch: {}", e);
479 let error_msg =
480 if e.message().contains("authentication") || e.message().contains("SSH") {
481 format!(
482 "Failed to push branch to remote: {}. \n\nTroubleshooting steps:\n\
483 1. Check if your SSH key is loaded: ssh-add -l\n\
484 2. Test GitHub SSH connection: ssh -T git@github.com\n\
485 3. Use GitHub CLI auth instead: gh auth setup-git",
486 e
487 )
488 } else {
489 format!("Failed to push branch to remote: {}", e)
490 };
491 Err(anyhow::anyhow!(error_msg))
492 }
493 }
494 }
495
496 pub fn branch_exists_on_remote(&self, branch_name: &str, remote_name: &str) -> Result<bool> {
498 debug!(
499 "Checking if branch '{}' exists on remote '{}'",
500 branch_name, remote_name
501 );
502
503 let remote = self
504 .repo
505 .find_remote(remote_name)
506 .context("Failed to find remote")?;
507
508 let remote_url = remote.url().unwrap_or("<unknown>");
509 debug!("Remote URL: {}", remote_url);
510
511 let hostname =
513 extract_hostname_from_git_url(remote_url).unwrap_or("github.com".to_string());
514 debug!(
515 "Extracted hostname '{}' from URL '{}'",
516 hostname, remote_url
517 );
518
519 let mut remote = remote;
521 let mut callbacks = git2::RemoteCallbacks::new();
522 let mut auth_attempts = 0;
523
524 callbacks.credentials(move |url, username_from_url, allowed_types| {
525 auth_attempts += 1;
526 debug!(
527 "Credential callback attempt {} - URL: {}, Username: {:?}, Allowed types: {:?}",
528 auth_attempts, url, username_from_url, allowed_types
529 );
530
531 if auth_attempts > 3 {
533 error!(
534 "Too many authentication attempts ({}), giving up",
535 auth_attempts
536 );
537 return Err(git2::Error::from_str(
538 "Authentication failed after multiple attempts",
539 ));
540 }
541
542 let username = username_from_url.unwrap_or("git");
543
544 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
546 if let Some(ssh_key_path) = get_ssh_identity_for_host(&hostname) {
551 let pub_key_path = ssh_key_path.with_extension("pub");
552 debug!("Trying SSH key from config: {:?}", ssh_key_path);
553
554 match git2::Cred::ssh_key(username, Some(&pub_key_path), &ssh_key_path, None) {
555 Ok(cred) => {
556 debug!(
557 "Successfully loaded SSH key from config: {:?}",
558 ssh_key_path
559 );
560 return Ok(cred);
561 }
562 Err(e) => {
563 debug!("Failed to load SSH key from config: {}", e);
564 }
565 }
566 }
567
568 if auth_attempts == 1 {
570 match git2::Cred::ssh_key_from_agent(username) {
571 Ok(cred) => {
572 debug!("SSH agent credentials obtained (attempt {})", auth_attempts);
573 return Ok(cred);
574 }
575 Err(e) => {
576 debug!("SSH agent failed: {}, trying default keys", e);
577 }
578 }
579 }
580
581 let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
583 let ssh_keys = [
584 format!("{}/.ssh/id_ed25519", home),
585 format!("{}/.ssh/id_rsa", home),
586 ];
587
588 for key_path in &ssh_keys {
589 let key_path = PathBuf::from(key_path);
590 if key_path.exists() {
591 let pub_key_path = key_path.with_extension("pub");
592 debug!("Trying default SSH key: {:?}", key_path);
593
594 match git2::Cred::ssh_key(username, Some(&pub_key_path), &key_path, None) {
595 Ok(cred) => {
596 debug!("Successfully loaded SSH key from {:?}", key_path);
597 return Ok(cred);
598 }
599 Err(e) => debug!("Failed to load SSH key from {:?}: {}", key_path, e),
600 }
601 }
602 }
603 }
604
605 debug!("Falling back to default credentials");
607 git2::Cred::default()
608 });
609
610 debug!("Attempting to connect to remote...");
611 match remote.connect_auth(git2::Direction::Fetch, Some(callbacks), None) {
612 Ok(_) => debug!("Successfully connected to remote"),
613 Err(e) => {
614 error!("Failed to connect to remote: {}", e);
615 let error_msg =
616 if e.message().contains("authentication") || e.message().contains("SSH") {
617 format!(
618 "Failed to connect to remote: {}. \n\nTroubleshooting steps:\n\
619 1. Check if your SSH key is loaded: ssh-add -l\n\
620 2. Test GitHub SSH connection: ssh -T git@github.com\n\
621 3. Use GitHub CLI auth instead: gh auth setup-git",
622 e
623 )
624 } else {
625 format!("Failed to connect to remote: {}", e)
626 };
627 return Err(anyhow::anyhow!(error_msg));
628 }
629 }
630
631 debug!("Listing remote refs...");
633 let refs = remote.list()?;
634 let remote_branch_ref = format!("refs/heads/{}", branch_name);
635 debug!("Looking for remote branch ref: {}", remote_branch_ref);
636
637 for remote_head in refs {
638 debug!("Found remote ref: {}", remote_head.name());
639 if remote_head.name() == remote_branch_ref {
640 info!(
641 "Branch '{}' exists on remote '{}'",
642 branch_name, remote_name
643 );
644 return Ok(true);
645 }
646 }
647
648 info!(
649 "Branch '{}' does not exist on remote '{}'",
650 branch_name, remote_name
651 );
652 Ok(false)
653 }
654}