Skip to main content

thoughts_tool/git/
clone.rs

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