Skip to main content

omni_dev/git/
repository.rs

1//! Git repository operations
2
3use 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
11/// Git repository wrapper
12pub struct GitRepository {
13    repo: Repository,
14}
15
16/// Working directory status
17#[derive(Debug)]
18pub struct WorkingDirectoryStatus {
19    /// Whether the working directory has no changes
20    pub clean: bool,
21    /// List of files with uncommitted changes
22    pub untracked_changes: Vec<FileStatus>,
23}
24
25/// File status information
26#[derive(Debug)]
27pub struct FileStatus {
28    /// Git status flags (e.g., "AM", "??", "M ")
29    pub status: String,
30    /// Path to the file relative to repository root
31    pub file: String,
32}
33
34impl GitRepository {
35    /// Open repository at current directory
36    pub fn open() -> Result<Self> {
37        let repo = Repository::open(".").context("Not in a git repository")?;
38
39        Ok(Self { repo })
40    }
41
42    /// Open repository at specified path
43    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    /// Get working directory status
50    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                // Skip ignored files - they should not affect clean status
63                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    /// Check if working directory is clean
85    pub fn is_working_directory_clean(&self) -> Result<bool> {
86        let status = self.get_working_directory_status()?;
87        Ok(status.clean)
88    }
89
90    /// Get repository path
91    pub fn path(&self) -> &std::path::Path {
92        self.repo.path()
93    }
94
95    /// Get workdir path
96    pub fn workdir(&self) -> Option<&std::path::Path> {
97        self.repo.workdir()
98    }
99
100    /// Get access to the underlying git2::Repository
101    pub fn repository(&self) -> &Repository {
102        &self.repo
103    }
104
105    /// Get current branch name
106    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    /// Check if a branch exists
119    pub fn branch_exists(&self, branch_name: &str) -> Result<bool> {
120        // Check if it exists as a local branch
121        if self
122            .repo
123            .find_branch(branch_name, git2::BranchType::Local)
124            .is_ok()
125        {
126            return Ok(true);
127        }
128
129        // Check if it exists as a remote branch
130        if self
131            .repo
132            .find_branch(branch_name, git2::BranchType::Remote)
133            .is_ok()
134        {
135            return Ok(true);
136        }
137
138        // Check if we can resolve it as a reference
139        if self.repo.revparse_single(branch_name).is_ok() {
140            return Ok(true);
141        }
142
143        Ok(false)
144    }
145
146    /// Parse commit range and get commits
147    pub fn get_commits_in_range(&self, range: &str) -> Result<Vec<CommitInfo>> {
148        let mut commits = Vec::new();
149
150        if range == "HEAD" {
151            // Single HEAD commit
152            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            // Range format like HEAD~3..HEAD
159            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            // Parse start and end commits
168            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            // Walk from end_commit back to start_commit (exclusive)
185            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                // Skip merge commits
201                if commit.parent_count() > 1 {
202                    continue;
203                }
204
205                commits.push(CommitInfo::from_git_commit(&self.repo, &commit)?);
206            }
207
208            // Reverse to get chronological order (oldest first)
209            commits.reverse();
210        } else {
211            // Single commit by hash or reference
212            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
226/// Format git status flags into string representation
227fn 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
261/// Extract hostname from a git URL (e.g., "git@github.com:user/repo.git" -> "github.com")
262fn extract_hostname_from_git_url(url: &str) -> Option<String> {
263    if let Some(ssh_url) = url.strip_prefix("git@") {
264        // SSH URL format: git@hostname:path
265        ssh_url.split(':').next().map(|s| s.to_string())
266    } else if let Some(https_url) = url.strip_prefix("https://") {
267        // HTTPS URL format: https://hostname/path
268        https_url.split('/').next().map(|s| s.to_string())
269    } else if let Some(http_url) = url.strip_prefix("http://") {
270        // HTTP URL format: http://hostname/path
271        http_url.split('/').next().map(|s| s.to_string())
272    } else {
273        None
274    }
275}
276
277/// Get SSH identity file for a given host from SSH config
278fn 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    // Open and parse the SSH config file
288    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    // Query the config for the specific host
296    let params = config.query(hostname);
297
298    // Get the identity file from the config
299    if let Some(identity_files) = &params.identity_file {
300        if let Some(first_identity) = identity_files.first() {
301            // Expand ~ to home directory
302            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    /// Push current branch to remote
320    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        // Get remote
327        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        // Set up refspec for push
337        let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
338        debug!("Using refspec: {}", refspec);
339
340        // Extract hostname from remote URL for SSH config lookup
341        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        // Push with authentication callbacks
349        let mut push_options = git2::PushOptions::new();
350        let mut callbacks = git2::RemoteCallbacks::new();
351        let mut auth_attempts = 0;
352
353        // Try to use credentials from git credential helper or SSH agent
354        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            // Bail out after 3 attempts to prevent infinite loops
362            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            // Try different authentication methods
375            if allowed_types.contains(git2::CredentialType::SSH_KEY) {
376                // On first attempt, try SSH config first since we know the exact key to use
377                // This avoids the issue where SSH agent returns OK but has no valid keys
378
379                // Try to get SSH key from SSH config
380                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                // Only try SSH agent on first attempt
399                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                // Try default SSH key locations as fallback
412                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            // If all else fails, try default
436            debug!("Falling back to default credentials");
437            git2::Cred::default()
438        });
439
440        push_options.remote_callbacks(callbacks);
441
442        // Perform the push
443        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                // Set upstream branch after successful push
452                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                                // Log but don't fail - the push succeeded
465                                error!("Failed to set upstream branch: {}", e);
466                            }
467                        }
468                    }
469                    Err(e) => {
470                        // Log but don't fail - the push succeeded
471                        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    /// Check if branch exists on remote
497    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        // Extract hostname from remote URL for SSH config lookup
512        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        // Connect to remote to get refs
520        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            // Bail out after 3 attempts to prevent infinite loops
532            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            // Try different authentication methods
545            if allowed_types.contains(git2::CredentialType::SSH_KEY) {
546                // On first attempt, try SSH config first since we know the exact key to use
547                // This avoids the issue where SSH agent returns OK but has no valid keys
548
549                // Try to get SSH key from SSH config
550                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                // Only try SSH agent on first attempt
569                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                // Try default SSH key locations as fallback
582                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            // If all else fails, try default
606            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        // Check if the remote branch exists
632        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}