thoughts_tool/git/
clone.rs1use 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
16fn 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 if let Some(parent) = options.target_path.parent() {
33 std::fs::create_dir_all(parent).context("Failed to create clone directory")?;
34 }
35
36 let _lock = FileLock::lock_exclusive(clone_lock_path(&options.target_path)?)?;
38
39 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 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 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_git_repo_with_origin(&target, "git@github.com:org/repo.git");
136
137 let options = CloneOptions {
139 url: "https://github.com/org/repo".to_string(),
140 target_path: target.clone(),
141 branch: None,
142 };
143
144 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_git_repo_with_origin(&target, "git@github.com:alice/utils.git");
157
158 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 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}