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}