Skip to main content

thoughts_tool/git/
clone.rs

1use anyhow::{Context, Result};
2use colored::*;
3use std::path::{Path, PathBuf};
4
5use crate::git::progress::InlineProgress;
6use crate::git::utils::{get_remote_url, is_git_repo};
7use crate::repo_identity::RepoIdentity;
8use crate::utils::locks::FileLock;
9
10pub struct CloneOptions {
11    pub url: String,
12    pub target_path: PathBuf,
13    pub branch: Option<String>,
14}
15
16/// Get the clone lock path for a target directory.
17///
18/// Lock file is placed adjacent to the target: `.{dirname}.clone.lock`
19fn clone_lock_path(target_path: &Path) -> Result<PathBuf> {
20    let parent = target_path
21        .parent()
22        .ok_or_else(|| anyhow::anyhow!("No parent directory for clone path"))?;
23    let name = target_path
24        .file_name()
25        .ok_or_else(|| anyhow::anyhow!("No directory name for clone path"))?
26        .to_string_lossy();
27    Ok(parent.join(format!(".{name}.clone.lock")))
28}
29
30pub fn clone_repository(options: &CloneOptions) -> Result<()> {
31    // Ensure parent directory exists (needed for lock file)
32    if let Some(parent) = options.target_path.parent() {
33        std::fs::create_dir_all(parent).context("Failed to create clone directory")?;
34    }
35
36    // Acquire per-target clone lock to prevent concurrent clones
37    let _lock = FileLock::lock_exclusive(clone_lock_path(&options.target_path)?)?;
38
39    // Idempotent check: if target is already a git repo, verify identity matches
40    if options.target_path.exists() && is_git_repo(&options.target_path) {
41        let existing_url = get_remote_url(&options.target_path)?;
42        let want = RepoIdentity::parse(&options.url)?.canonical_key();
43        let have = RepoIdentity::parse(&existing_url)?.canonical_key();
44
45        if want == have {
46            println!(
47                "{} Already cloned: {}",
48                "✓".green(),
49                options.target_path.display()
50            );
51            return Ok(());
52        }
53
54        anyhow::bail!(
55            "Clone target already contains a different repository:\n\
56             \n  target: {}\n  requested: {}\n  existing origin: {}",
57            options.target_path.display(),
58            options.url,
59            existing_url
60        );
61    }
62
63    // Ensure target directory is empty (if it exists but isn't a git repo)
64    if options.target_path.exists() {
65        let entries = std::fs::read_dir(&options.target_path).with_context(|| {
66            format!(
67                "Failed to read target directory: {}",
68                options.target_path.display()
69            )
70        })?;
71        if entries.count() > 0 {
72            anyhow::bail!(
73                "Target directory exists but is not a git repo (and is not empty): {}",
74                options.target_path.display()
75            );
76        }
77    }
78
79    println!("{} {}", "Cloning".green(), options.url);
80    println!("  to: {}", options.target_path.display());
81
82    // SAFETY: progress handler is lock-free and alloc-minimal
83    unsafe {
84        gix::interrupt::init_handler(1, || {}).ok();
85    }
86
87    let url = gix::url::parse(options.url.as_str().into())
88        .with_context(|| format!("Invalid repository URL: {}", options.url))?;
89
90    let mut prepare =
91        gix::prepare_clone(url, &options.target_path).context("Failed to prepare clone")?;
92
93    if let Some(branch) = &options.branch {
94        prepare = prepare
95            .with_ref_name(Some(branch.as_str()))
96            .context("Failed to set target branch")?;
97    }
98
99    let (mut checkout, _fetch_outcome) = prepare
100        .fetch_then_checkout(
101            InlineProgress::new("progress"),
102            &gix::interrupt::IS_INTERRUPTED,
103        )
104        .context("Fetch failed")?;
105
106    let (_repo, _outcome) = checkout
107        .main_worktree(
108            InlineProgress::new("checkout"),
109            &gix::interrupt::IS_INTERRUPTED,
110        )
111        .context("Checkout failed")?;
112
113    println!("\n{} Clone completed successfully", "✓".green());
114    Ok(())
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use git2::Repository;
121    use tempfile::TempDir;
122
123    fn create_git_repo_with_origin(dir: &std::path::Path, origin_url: &str) {
124        let repo = Repository::init(dir).unwrap();
125        repo.remote("origin", origin_url).unwrap();
126    }
127
128    #[test]
129    fn test_idempotent_clone_same_identity() {
130        let dir = TempDir::new().unwrap();
131        let target = dir.path().join("repo");
132        std::fs::create_dir_all(&target).unwrap();
133
134        // Create a git repo with matching origin (SSH format)
135        create_git_repo_with_origin(&target, "git@github.com:org/repo.git");
136
137        // Try to "clone" with HTTPS URL (same canonical identity)
138        let options = CloneOptions {
139            url: "https://github.com/org/repo".to_string(),
140            target_path: target.clone(),
141            branch: None,
142        };
143
144        // Should succeed without actually cloning (idempotent)
145        let result = clone_repository(&options);
146        assert!(result.is_ok(), "Expected success for matching identity");
147    }
148
149    #[test]
150    fn test_clone_fails_for_different_identity() {
151        let dir = TempDir::new().unwrap();
152        let target = dir.path().join("repo");
153        std::fs::create_dir_all(&target).unwrap();
154
155        // Create a git repo with different origin
156        create_git_repo_with_origin(&target, "git@github.com:alice/utils.git");
157
158        // Try to clone a different repo
159        let options = CloneOptions {
160            url: "https://github.com/bob/utils.git".to_string(),
161            target_path: target.clone(),
162            branch: None,
163        };
164
165        let result = clone_repository(&options);
166        assert!(result.is_err(), "Expected error for different identity");
167        let err = result.unwrap_err().to_string();
168        assert!(
169            err.contains("different repository"),
170            "Error should mention different repository: {}",
171            err
172        );
173    }
174
175    #[test]
176    fn test_clone_fails_for_non_git_non_empty() {
177        let dir = TempDir::new().unwrap();
178        let target = dir.path().join("repo");
179        std::fs::create_dir_all(&target).unwrap();
180
181        // Create a non-git file in the directory
182        std::fs::write(target.join("file.txt"), "hello").unwrap();
183
184        let options = CloneOptions {
185            url: "https://github.com/org/repo.git".to_string(),
186            target_path: target.clone(),
187            branch: None,
188        };
189
190        let result = clone_repository(&options);
191        assert!(result.is_err(), "Expected error for non-empty non-git dir");
192        let err = result.unwrap_err().to_string();
193        assert!(
194            err.contains("not a git repo"),
195            "Error should mention not a git repo: {}",
196            err
197        );
198    }
199}