1use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use tempfile::TempDir;
7use tracing::info;
8
9use crate::error::{Error, Result};
10
11#[derive(Debug)]
13pub struct Workspace {
14 pub path: PathBuf,
16 pub label: String,
18 pub source_uri: Option<String>,
20 _temp: Option<TempDir>,
22}
23
24impl Workspace {
25 pub fn is_remote(&self) -> bool {
27 self.source_uri.is_some()
28 }
29}
30
31pub fn resolve(target: &str, clone_depth: usize) -> Result<Workspace> {
33 let target = target.trim();
34 if target.is_empty() {
35 return Err(Error::Config("empty repository target".into()));
36 }
37
38 if looks_like_remote(target) {
39 let uri = normalize_remote(target);
40 return clone_remote(&uri, clone_depth);
41 }
42
43 let path = PathBuf::from(target);
44 if !path.exists() {
45 if target.contains('/') && !target.starts_with('.') && !target.starts_with('/') {
47 return Err(Error::Config(format!(
48 "path does not exist: {target} (did you mean a remote URI? try https://{target})"
49 )));
50 }
51 return Err(Error::Io(std::io::Error::new(
52 std::io::ErrorKind::NotFound,
53 format!("path does not exist: {target}"),
54 )));
55 }
56
57 Ok(Workspace {
58 path,
59 label: target.to_string(),
60 source_uri: None,
61 _temp: None,
62 })
63}
64
65pub fn looks_like_remote(target: &str) -> bool {
67 let t = target.trim();
68 if t.is_empty() {
69 return false;
70 }
71
72 let lower = t.to_ascii_lowercase();
73 if lower.starts_with("https://")
74 || lower.starts_with("http://")
75 || lower.starts_with("git://")
76 || lower.starts_with("ssh://")
77 || lower.starts_with("git@")
78 || lower.starts_with("file://")
79 {
80 return true;
81 }
82
83 if let Some((host, rest)) = t.split_once(':')
85 && !host.contains('/')
86 && !host.is_empty()
87 && rest.contains('/')
88 && !Path::new(t).exists()
89 {
90 if host.len() == 1 && host.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
92 return false;
93 }
94 return true;
95 }
96
97 false
98}
99
100fn normalize_remote(target: &str) -> String {
101 let t = target.trim();
102 if t.starts_with("git@") || t.contains("://") || looks_like_scp(t) {
103 return t.to_string();
104 }
105 t.to_string()
106}
107
108fn looks_like_scp(t: &str) -> bool {
109 t.split_once(':')
110 .is_some_and(|(host, rest)| !host.contains('/') && rest.contains('/'))
111}
112
113fn clone_remote(uri: &str, depth: usize) -> Result<Workspace> {
114 let depth = depth.max(1);
115 let temp = TempDir::new().map_err(Error::Io)?;
116 let dest = temp.path().join(repo_dirname(uri));
117
118 info!(%uri, depth, dest = %dest.display(), "cloning remote repository");
119
120 let output = Command::new("git")
121 .args([
122 "clone",
123 "--depth",
124 &depth.to_string(),
125 "--quiet",
126 uri,
127 dest.to_str()
128 .ok_or_else(|| Error::Config("clone destination path is not valid UTF-8".into()))?,
129 ])
130 .output()
131 .map_err(|e| {
132 Error::Git(format!(
133 "failed to run git clone (is git installed and on PATH?): {e}"
134 ))
135 })?;
136
137 if !output.status.success() {
138 let stderr = String::from_utf8_lossy(&output.stderr);
139 let stdout = String::from_utf8_lossy(&output.stdout);
140 let detail = [stderr.trim(), stdout.trim()]
141 .into_iter()
142 .find(|s| !s.is_empty())
143 .unwrap_or("git clone failed");
144 return Err(Error::Git(format!("git clone {uri}: {detail}")));
145 }
146
147 if !dest.join(".git").exists() {
148 return Err(Error::Git(format!(
149 "git clone completed but no .git found at {}",
150 dest.display()
151 )));
152 }
153
154 Ok(Workspace {
155 path: dest,
156 label: uri.to_string(),
157 source_uri: Some(uri.to_string()),
158 _temp: Some(temp),
159 })
160}
161
162fn repo_dirname(uri: &str) -> String {
163 let trimmed = uri.trim_end_matches('/').trim_end_matches(".git");
164 let name = trimmed.rsplit(['/', ':']).next().unwrap_or("repo").trim();
165 if name.is_empty() {
166 "repo".into()
167 } else {
168 name.to_string()
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn detects_https_and_ssh() {
178 assert!(looks_like_remote("https://github.com/thanos/ex_arrow"));
179 assert!(looks_like_remote("http://example.com/r.git"));
180 assert!(looks_like_remote("git@github.com:thanos/ex_arrow.git"));
181 assert!(looks_like_remote(
182 "ssh://git@github.com/thanos/ex_arrow.git"
183 ));
184 assert!(looks_like_remote("git://github.com/thanos/ex_arrow.git"));
185 assert!(looks_like_remote("file:///tmp/foo.git"));
186 }
187
188 #[test]
189 fn rejects_local_paths() {
190 assert!(!looks_like_remote("."));
191 assert!(!looks_like_remote("./repo"));
192 assert!(!looks_like_remote("../repo"));
193 assert!(!looks_like_remote("/tmp/repo"));
194 assert!(!looks_like_remote("C:\\Users\\repo"));
195 }
196
197 #[test]
198 fn dirname_from_uri() {
199 assert_eq!(
200 repo_dirname("https://github.com/thanos/ex_arrow"),
201 "ex_arrow"
202 );
203 assert_eq!(
204 repo_dirname("git@github.com:thanos/ex_arrow.git"),
205 "ex_arrow"
206 );
207 }
208}