git_subcopy/
lib.rs

1use std::{collections::HashMap, fs, path::{PathBuf, Path}};
2
3use anyhow::{anyhow, Context, Result};
4use git2::{
5    build::RepoBuilder,
6    Config,
7    Oid,
8    Repository,
9    ResetType,
10    TreeWalkMode,
11    TreeWalkResult,
12};
13use log::{debug, info};
14use tempfile::Builder;
15use walkdir::WalkDir;
16
17fn path_to_string(path: &Path) -> Result<&str> {
18    path.to_str().ok_or_else(|| anyhow!("path must be valid utf-8"))
19}
20
21#[derive(Debug, Default)]
22pub struct SubcopyConfigOption {
23    pub url: Option<String>,
24    pub rev: Option<String>,
25    pub upstream_path: Option<PathBuf>,
26    pub local_path: PathBuf,
27}
28#[derive(Debug, Default)]
29pub struct SubcopyConfig {
30    pub url: String,
31    pub rev: String,
32    pub upstream_path: PathBuf,
33}
34
35pub struct App {
36    cache_dir: PathBuf,
37}
38impl App {
39    pub fn new() -> Result<Self> {
40        Ok(Self {
41            cache_dir: dirs::cache_dir().map(|mut path| {
42                path.push(env!("CARGO_PKG_NAME"));
43                path
44            }).ok_or_else(|| anyhow!("can't choose a cache directory"))?,
45        })
46    }
47
48    pub fn fetch(&self, url: &str, update_existing: bool) -> Result<Repository> {
49        let path = self.cache_dir.join(base64::encode_config(url, base64::URL_SAFE_NO_PAD));
50
51        if path.exists() {
52            let repo = Repository::open_bare(&path).context("failed to open cached bare repository")?;
53
54            if update_existing {
55                info!("Fetching upstream in existing repository...");
56                repo.remote_anonymous(url).context("failed to create anonymous remote")?
57                    .fetch(&[], None, None).context("failed to fetch from anonymous remote")?;
58            }
59            Ok(repo)
60        } else {
61            info!("Cloning new repository...");
62            Ok(RepoBuilder::new()
63               .bare(true)
64               .clone(url, &path)
65               .context("failed to clone repository")?)
66        }
67    }
68
69    pub fn extract(&self, repo: &'_ Repository, rev: Oid, upstream_path: &Path, local_path: &Path) -> Result<()> {
70        info!("Extracting files...");
71
72        let tree = repo.find_object(rev, None).context("failed to find object at revision")?
73            .peel_to_tree().context("failed to turn object into a tree")?;
74        let entry = tree.get_path(upstream_path).context("failed to get path")?;
75        let object = entry.to_object(&repo).context("failed to get path's object")?;
76
77        if let Ok(blob) = object.peel_to_blob() {
78            fs::write(local_path, blob.content()).context("failed to write file")?;
79        } else {
80            let tree = object.peel_to_tree()?;
81
82            fs::create_dir_all(local_path)?;
83            let mut error = None;
84            tree.walk(TreeWalkMode::PreOrder, |dir, entry| {
85                let inner = || -> Result<()> {
86                    let object = entry.to_object(&repo)?;
87                    let mut path = local_path.join(dir);
88                    path.push(entry.name().ok_or_else(|| anyhow!("name is not utf-8 encoded"))?);
89
90                    if let Ok(blob) = object.peel_to_blob() {
91                        fs::write(path, blob.content()).context("failed to write file")?;
92                    } else if object.peel_to_tree().is_ok() {
93                        fs::create_dir_all(path)?;
94                    }
95                    Ok(())
96                };
97                match inner() {
98                    Ok(()) => TreeWalkResult::Ok,
99                    Err(err) => {
100                        error = Some(err);
101                        TreeWalkResult::Abort
102                    }
103                }
104            })?;
105            if let Some(err) = error {
106                return Err(err);
107            }
108        }
109        Ok(())
110    }
111
112    pub fn canonicalize(&self, repo: &Repository, local_path: &Path) -> Result<PathBuf> {
113        let workdir = repo.workdir().ok_or_else(|| anyhow!("repository is bare and has no workdir"))?
114            .canonicalize().context("failed to find full path to repository workdir")?;
115        let local_path = local_path.canonicalize().context("failed to find full path to destination directory")?;
116        let relative = local_path.strip_prefix(&workdir).context("destination directory not in a repository")?;
117
118        Ok(relative.to_path_buf())
119    }
120
121    pub fn register(&self, url: &str, rev: Oid, upstream_path: &Path, local_path: &Path) -> Result<()> {
122        let repo = Repository::open_from_env()?;
123        let relative = self.canonicalize(&repo, local_path)?;
124        let workdir = repo.workdir().expect("canonicalize has already checked this");
125
126        let relative_str = path_to_string(&relative)?;
127
128        let mut config = Config::open(&workdir.join(".gitcopies")).context("failed to open .gitcopies")?;
129        config.set_str(&format!("subcopy.{}.url", relative_str), url)?;
130        config.set_str(&format!("subcopy.{}.rev", relative_str), &rev.to_string())?;
131        config.set_str(&format!("subcopy.{}.upstreamPath", relative_str), path_to_string(upstream_path)?)?;
132        Ok(())
133    }
134
135    pub fn list(&self) -> Result<HashMap<String, SubcopyConfigOption>> {
136        let repo = Repository::open_from_env()?;
137        let workdir = repo.workdir().ok_or_else(|| anyhow!("repository is bare and has no workdir"))?;
138        let mut config = Config::open(&workdir.join(".gitcopies")).context("failed to open .gitcopies")?;
139        let snapshot = config.snapshot().context("failed to take a snapshot of config")?;
140
141        let mut map: HashMap<String, SubcopyConfigOption> = HashMap::new();
142
143        for entry in &snapshot.entries(Some(r"^subcopy\..*\.(url|rev|upstreampath)$")).context("failed to iter config entries")? {
144            let entry = entry.context("failed to read config entry")?;
145            let name = entry.name().ok_or_else(|| anyhow!("entry name was not valid utf-8"))?;
146
147            let withoutend = name.rsplitn(2, '.').nth(1).ok_or_else(|| anyhow!("incomplete subcopy property name"))?;
148            let middle = withoutend.splitn(2, '.').nth(1).ok_or_else(|| anyhow!("incomplete subcopy property name"))?;
149            let slot = map.entry(middle.to_owned()).or_insert_with(|| SubcopyConfigOption {
150                local_path: PathBuf::from(&middle),
151                ..SubcopyConfigOption::default()
152            });
153
154            if name.ends_with("url") {
155                slot.url = entry.value().map(String::from);
156            } else if name.ends_with("rev") {
157                slot.rev = entry.value().map(String::from);
158            } else if name.ends_with("upstreampath") {
159                slot.upstream_path = entry.value().map(PathBuf::from);
160            }
161        }
162
163        Ok(map)
164    }
165
166    pub fn get(&self, key: &Path) -> Result<SubcopyConfig> {
167        let repo = Repository::open_from_env()?;
168        let key = self.canonicalize(&repo, key)?;
169
170        let workdir = repo.workdir().ok_or_else(|| anyhow!("repository is bare and has no workdir"))?;
171        let mut config = Config::open(&workdir.join(".gitcopies")).context("failed to open .gitcopies")?;
172        let snapshot = config.snapshot().context("failed to take a snapshot of config")?;
173
174        let key = path_to_string(&key)?;
175
176        Ok(SubcopyConfig {
177            url: snapshot.get_string(&format!("subcopy.{}.url", key))?,
178            rev: snapshot.get_string(&format!("subcopy.{}.rev", key))?,
179            upstream_path: snapshot.get_path(&format!("subcopy.{}.upstreamPath", key))?,
180        })
181    }
182
183    pub fn with_repo<F, T>(&self, url: &str, rev: &str, upstream_path: &Path, local_path: &Path, callback: F) -> Result<T>
184    where
185        F: FnOnce(&Repository) -> Result<T>,
186    {
187        let tmp = Builder::new().prefix("git-subcopy").tempdir().context("failed to get temporary directory")?;
188        let upstream_repo = {
189            let upstream_bare = self.fetch(url, false).context("failed to fetch source repository")?;
190            let upstream_bare_path = upstream_bare.path().canonicalize().context("failed to get full cache path")?;
191            let upstream_str = path_to_string(&upstream_bare_path)?;
192
193            info!("Cloning cached repo...");
194            Repository::clone(&upstream_str, tmp.path())
195                .context("failed to clone cache of upstream repository")?
196        };
197
198        upstream_repo.remote("upstream", url).context("failed to add upstream remote")?;
199
200        let rev = upstream_repo.revparse_single(rev).context("failed to parse revision")?;
201        upstream_repo.reset(&rev, ResetType::Hard, None).context("failed to reset repository")?;
202
203        info!("Copying changes...");
204        let upstream_path = tmp.path().join(upstream_path);
205
206        if local_path.is_file() {
207            debug!("{} -> {}", local_path.display(), upstream_path.display());
208            fs::copy(local_path, &upstream_path).context("failed to copy file")?;
209        } else {
210            for entry in WalkDir::new(local_path) {
211                let entry = entry.context("failed to read directory entry")?;
212
213                let from = entry.path();
214                let to_relative = entry.path().strip_prefix(local_path).context("walkdir should always have prefix")?;
215                let to = upstream_path.join(to_relative);
216
217                debug!("{} -> {}", from.display(), to.display());
218                if entry.file_type().is_dir() {
219                    fs::create_dir_all(&to).context("failed to copy dir")?;
220                } else {
221                    fs::copy(from, &to).context("failed to copy file")?;
222                }
223            }
224        }
225
226        let ret = callback(&upstream_repo)?;
227
228        if upstream_path.is_file() {
229            debug!("{} -> {}", upstream_path.display(), upstream_path.display());
230            fs::copy(&upstream_path, local_path).context("failed to copy file")?;
231        } else {
232            for entry in WalkDir::new(&upstream_path).into_iter().filter_entry(|e| e.file_name().to_str() != Some(".git")) {
233                let entry = entry.context("failed to read directory entry")?;
234
235                let from = entry.path();
236                let to_relative = entry.path().strip_prefix(&upstream_path).context("walkdir should always have prefix")?;
237                let to = local_path.join(to_relative);
238
239                debug!("{} -> {}", from.display(), to.display());
240                if entry.file_type().is_dir() {
241                    fs::create_dir_all(&to).context("failed to copy dir")?;
242                } else {
243                    fs::copy(from, &to).context("failed to copy file")?;
244                }
245            }
246        }
247
248        Ok(ret)
249    }
250}