thoughts_tool/git/
clone.rs

1use anyhow::{Context, Result};
2use colored::*;
3use git2::{FetchOptions, Progress, RemoteCallbacks};
4use std::path::PathBuf;
5
6pub struct CloneOptions {
7    pub url: String,
8    pub target_path: PathBuf,
9    pub branch: Option<String>,
10}
11
12pub fn clone_repository(options: &CloneOptions) -> Result<()> {
13    println!("{} {}", "Cloning".green(), options.url);
14    println!("  to: {}", options.target_path.display());
15
16    // Ensure parent directory exists
17    if let Some(parent) = options.target_path.parent() {
18        std::fs::create_dir_all(parent).context("Failed to create clone directory")?;
19    }
20
21    // Ensure target doesn't exist or is empty
22    if options.target_path.exists() {
23        let entries = std::fs::read_dir(&options.target_path)?;
24        if entries.count() > 0 {
25            anyhow::bail!(
26                "Target directory is not empty: {}",
27                options.target_path.display()
28            );
29        }
30    }
31
32    // Set up clone
33    let mut builder = git2::build::RepoBuilder::new();
34    let mut fetch_opts = FetchOptions::new();
35    let mut callbacks = RemoteCallbacks::new();
36
37    // Add SSH credential callback
38    callbacks.credentials(|_url, username_from_url, allowed_types| {
39        // Only handle SSH key authentication
40        if allowed_types.contains(git2::CredentialType::SSH_KEY) {
41            let username = username_from_url.unwrap_or("git");
42
43            // CRITICAL FIX: Only try SSH agent if SSH_AUTH_SOCK is set
44            // This avoids the libssh2 bug where ssh_key_from_agent returns Ok
45            // with invalid credentials when no agent is present
46            // Bug ref: https://github.com/libssh2/libssh2/issues/659
47            if std::env::var("SSH_AUTH_SOCK").is_ok() {
48                // Try SSH agent (might still fail due to libssh2 RSA-SHA2 bug)
49                if let Ok(cred) = git2::Cred::ssh_key_from_agent(username) {
50                    return Ok(cred);
51                }
52                // If agent fails, fall through to key files
53            }
54
55            // Try SSH keys from disk (this is what actually works)
56            let home = dirs::home_dir()
57                .ok_or_else(|| git2::Error::from_str("Cannot find home directory"))?;
58            let ssh_dir = home.join(".ssh");
59
60            // Try keys in order of preference
61            let key_files = [
62                "id_ed25519", // Modern default (Ed25519)
63                "id_rsa",     // Legacy default (RSA)
64                "id_ecdsa",   // Less common (ECDSA)
65            ];
66
67            for key_name in &key_files {
68                let private_key = ssh_dir.join(key_name);
69                if private_key.exists() {
70                    // Try without public key path first (often sufficient)
71                    if let Ok(cred) = git2::Cred::ssh_key(
72                        username,
73                        None, // No public key path
74                        private_key.as_path(),
75                        None, // No passphrase support
76                    ) {
77                        return Ok(cred);
78                    }
79
80                    // If that fails, try with public key
81                    let public_key = ssh_dir.join(format!("{key_name}.pub"));
82                    if public_key.exists()
83                        && let Ok(cred) = git2::Cred::ssh_key(
84                            username,
85                            Some(public_key.as_path()),
86                            private_key.as_path(),
87                            None,
88                        )
89                    {
90                        return Ok(cred);
91                    }
92                }
93            }
94
95            Err(git2::Error::from_str(
96                "SSH authentication failed. No valid SSH keys found in ~/.ssh/\n\
97                 Checked for: id_ed25519, id_rsa, id_ecdsa\n\
98                 Note: Passphrase-protected keys are not currently supported",
99            ))
100        } else if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
101            // Fall back to git credential helper for HTTPS
102            git2::Cred::default()
103        } else {
104            git2::Cred::default()
105        }
106    });
107
108    // Keep existing progress callback
109    callbacks.transfer_progress(|stats: Progress| {
110        let received = stats.received_objects();
111        let total = stats.total_objects();
112
113        if total > 0 {
114            let percent = (received as f32 / total as f32) * 100.0;
115            print!(
116                "\r  {}: {}/{} objects ({:.1}%)",
117                "Progress".cyan(),
118                received,
119                total,
120                percent
121            );
122            std::io::Write::flush(&mut std::io::stdout()).ok();
123        }
124        true
125    });
126
127    fetch_opts.remote_callbacks(callbacks);
128    builder.fetch_options(fetch_opts);
129
130    if let Some(branch) = &options.branch {
131        builder.branch(branch);
132    }
133
134    // Perform clone
135    builder
136        .clone(&options.url, &options.target_path)
137        .context("Failed to clone repository")?;
138
139    println!("\n✓ Clone completed successfully");
140    Ok(())
141}