thoughts_tool/git/
clone.rs1use 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
19fn 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 if let Some(parent) = options.target_path.parent() {
36 std::fs::create_dir_all(parent).context("Failed to create clone directory")?;
37 }
38
39 let _lock = FileLock::lock_exclusive(clone_lock_path(&options.target_path)?)?;
41
42 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 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 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_git_repo_with_origin(&target, "git@github.com:org/repo.git");
139
140 let options = CloneOptions {
142 url: "https://github.com/org/repo".to_string(),
143 target_path: target,
144 branch: None,
145 };
146
147 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_git_repo_with_origin(&target, "git@github.com:alice/utils.git");
160
161 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 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}