wtg_cli/
repo_manager.rs

1use crate::error::{Result, WtgError};
2use crate::git::GitRepo;
3use git2::{FetchOptions, RemoteCallbacks, Repository};
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7/// Manages repository access for both local and remote repositories
8pub struct RepoManager {
9    local_path: PathBuf,
10    is_remote: bool,
11    owner: Option<String>,
12    repo_name: Option<String>,
13}
14
15impl RepoManager {
16    /// Create a repo manager for the current local repository
17    pub fn local() -> Result<Self> {
18        let repo = Repository::discover(".").map_err(|_| WtgError::NotInGitRepo)?;
19        let path = repo.workdir().ok_or(WtgError::NotInGitRepo)?.to_path_buf();
20
21        Ok(Self {
22            local_path: path,
23            is_remote: false,
24            owner: None,
25            repo_name: None,
26        })
27    }
28
29    /// Create a repo manager for a remote GitHub repository
30    /// This will clone the repo to a cache directory if needed
31    pub fn remote(owner: String, repo: String) -> Result<Self> {
32        let cache_dir = get_cache_dir()?;
33        let repo_cache_path = cache_dir.join(format!("{owner}/{repo}"));
34
35        // Check if already cloned
36        if repo_cache_path.exists() && Repository::open(&repo_cache_path).is_ok() {
37            // Try to update it
38            if let Err(e) = update_remote_repo(&repo_cache_path) {
39                eprintln!("Warning: Failed to update cached repo: {e}");
40                // Continue anyway - use the cached version
41            }
42        } else {
43            // Clone it
44            clone_remote_repo(&owner, &repo, &repo_cache_path)?;
45        }
46
47        Ok(Self {
48            local_path: repo_cache_path,
49            is_remote: true,
50            owner: Some(owner),
51            repo_name: Some(repo),
52        })
53    }
54
55    /// Get the `GitRepo` instance for this managed repository
56    pub fn git_repo(&self) -> Result<GitRepo> {
57        GitRepo::from_path(&self.local_path)
58    }
59
60    /// Get the repository path
61    #[must_use]
62    pub const fn path(&self) -> &PathBuf {
63        &self.local_path
64    }
65
66    /// Check if this is a remote repository
67    #[must_use]
68    pub const fn is_remote(&self) -> bool {
69        self.is_remote
70    }
71
72    /// Get the owner/repo info (only for remote repos)
73    #[must_use]
74    pub fn remote_info(&self) -> Option<(String, String)> {
75        if self.is_remote {
76            Some((self.owner.clone()?, self.repo_name.clone()?))
77        } else {
78            None
79        }
80    }
81}
82
83/// Get the cache directory for remote repositories
84fn get_cache_dir() -> Result<PathBuf> {
85    let cache_dir = dirs::cache_dir()
86        .ok_or_else(|| {
87            WtgError::Io(std::io::Error::new(
88                std::io::ErrorKind::NotFound,
89                "Could not determine cache directory",
90            ))
91        })?
92        .join("wtg")
93        .join("repos");
94
95    if !cache_dir.exists() {
96        std::fs::create_dir_all(&cache_dir)?;
97    }
98
99    Ok(cache_dir)
100}
101
102/// Clone a remote repository using subprocess with filter=blob:none, falling back to git2 if needed
103fn clone_remote_repo(owner: &str, repo: &str, target_path: &Path) -> Result<()> {
104    // Create parent directory
105    if let Some(parent) = target_path.parent() {
106        std::fs::create_dir_all(parent)?;
107    }
108
109    let repo_url = format!("https://github.com/{owner}/{repo}.git");
110
111    eprintln!("🔄 Cloning remote repository {repo_url}...");
112
113    // Try subprocess with --filter=blob:none first (requires Git 2.17+)
114    match clone_with_filter(&repo_url, target_path) {
115        Ok(()) => {
116            eprintln!("✅ Repository cloned successfully (using filter)");
117            Ok(())
118        }
119        Err(e) => {
120            eprintln!("⚠️  Filter clone failed ({e}), falling back to bare clone...");
121            // Fall back to git2 bare clone
122            clone_bare_with_git2(&repo_url, target_path)
123        }
124    }
125}
126
127/// Clone with --filter=blob:none using subprocess
128fn clone_with_filter(repo_url: &str, target_path: &Path) -> Result<()> {
129    let output = Command::new("git")
130        .args([
131            "clone",
132            "--filter=blob:none", // Don't download blobs until needed (Git 2.17+)
133            "--bare",             // Bare repository (no working directory)
134            repo_url,
135            target_path.to_str().ok_or_else(|| {
136                WtgError::Io(std::io::Error::new(
137                    std::io::ErrorKind::InvalidInput,
138                    "Invalid path",
139                ))
140            })?,
141        ])
142        .output()?;
143
144    if !output.status.success() {
145        let error = String::from_utf8_lossy(&output.stderr);
146        return Err(WtgError::Io(std::io::Error::other(format!(
147            "Failed to clone with filter: {error}"
148        ))));
149    }
150
151    Ok(())
152}
153
154/// Clone bare repository using git2 (fallback)
155fn clone_bare_with_git2(repo_url: &str, target_path: &Path) -> Result<()> {
156    // Clone without progress output for cleaner UX
157    let callbacks = RemoteCallbacks::new();
158
159    let mut fetch_options = FetchOptions::new();
160    fetch_options.remote_callbacks(callbacks);
161
162    // Build the repository with options
163    let mut builder = git2::build::RepoBuilder::new();
164    builder.fetch_options(fetch_options);
165    builder.bare(true); // Bare repository - no working directory, only git metadata
166
167    // Clone the repository as bare
168    // This gets all commits, branches, and tags without checking out files
169    builder.clone(repo_url, target_path)?;
170
171    eprintln!("✅ Repository cloned successfully (using bare clone)");
172
173    Ok(())
174}
175
176/// Update an existing cloned remote repository
177fn update_remote_repo(repo_path: &PathBuf) -> Result<()> {
178    eprintln!("🔄 Updating cached repository...");
179
180    // Try subprocess fetch first (works for both filter and non-filter repos)
181    match fetch_with_subprocess(repo_path) {
182        Ok(()) => {
183            eprintln!("✅ Repository updated");
184            Ok(())
185        }
186        Err(_) => {
187            // Fall back to git2
188            fetch_with_git2(repo_path)
189        }
190    }
191}
192
193/// Fetch updates using subprocess
194fn fetch_with_subprocess(repo_path: &Path) -> Result<()> {
195    let args = build_fetch_args(repo_path)?;
196
197    let output = Command::new("git").args(&args).output()?;
198
199    if !output.status.success() {
200        let error = String::from_utf8_lossy(&output.stderr);
201        return Err(WtgError::Io(std::io::Error::other(format!(
202            "Failed to fetch: {error}"
203        ))));
204    }
205
206    Ok(())
207}
208
209/// Build the arguments passed to `git fetch` when refreshing cached repos.
210///
211/// Keeping this logic isolated lets us sanity-check the flags in unit tests so
212/// we don't regress on rejected tag updates again.
213fn build_fetch_args(repo_path: &Path) -> Result<Vec<String>> {
214    let repo_path = repo_path.to_str().ok_or_else(|| {
215        WtgError::Io(std::io::Error::new(
216            std::io::ErrorKind::InvalidInput,
217            "Invalid path",
218        ))
219    })?;
220
221    Ok(vec![
222        "-C".to_string(),
223        repo_path.to_string(),
224        "fetch".to_string(),
225        "--all".to_string(),
226        "--tags".to_string(),
227        "--force".to_string(),
228        "--prune".to_string(),
229    ])
230}
231
232/// Fetch updates using git2 (fallback)
233fn fetch_with_git2(repo_path: &PathBuf) -> Result<()> {
234    let repo = Repository::open(repo_path)?;
235
236    // Find the origin remote
237    let mut remote = repo
238        .find_remote("origin")
239        .or_else(|_| repo.find_remote("upstream"))
240        .map_err(WtgError::Git)?;
241
242    // Fetch without progress output for cleaner UX
243    let callbacks = RemoteCallbacks::new();
244    let mut fetch_options = FetchOptions::new();
245    fetch_options.remote_callbacks(callbacks);
246
247    // Fetch all refs
248    remote.fetch(
249        &["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
250        Some(&mut fetch_options),
251        None,
252    )?;
253
254    eprintln!("✅ Repository updated");
255
256    Ok(())
257}