1use anyhow::{anyhow, Result};
2use std::io::Write;
3use std::path::Path;
4
5use crate::clock::Clock;
6use crate::config::Config;
7use crate::store::ObjectStore;
8use crate::{crypto, git};
9
10pub fn repo_name(repos_root: &Path, repo: &Path) -> Result<String> {
11 let rel = repo.strip_prefix(repos_root).map_err(|_| {
12 anyhow!(
13 "repo {} not under root {}",
14 repo.display(),
15 repos_root.display()
16 )
17 })?;
18 let s = rel.to_string_lossy();
19 Ok(s.strip_suffix(".git").unwrap_or(&s).to_string())
20}
21
22pub fn latest_key(prefix: &str, name: &str) -> String {
23 format!("{prefix}/{name}/latest.age")
24}
25
26pub fn history_key(prefix: &str, name: &str, ts: &str) -> String {
27 format!("{prefix}/{name}/history/{ts}.age")
28}
29
30pub fn parse_receive_refs(input: &str) -> Vec<String> {
33 input
34 .lines()
35 .filter_map(|l| l.split_whitespace().nth(2).map(str::to_string))
36 .collect()
37}
38
39pub fn ref_matches(refname: &str, pattern: &str) -> bool {
43 pattern == "**"
44 || pattern == "*"
45 || pattern == refname
46 || format!("refs/heads/{pattern}") == refname
47}
48
49pub fn should_back_up(updated_refs: &[String], backup_refs: &[String]) -> bool {
52 updated_refs
53 .iter()
54 .any(|r| backup_refs.iter().any(|p| ref_matches(r, p)))
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum BundleSelection {
61 All,
63 Refs(Vec<String>),
65}
66
67pub fn resolve_bundle_refs(repo: &Path, backup_refs: &[String]) -> Result<BundleSelection> {
77 if backup_refs.iter().any(|p| p == "**" || p == "*") {
78 return Ok(BundleSelection::All);
79 }
80 let mut refs: Vec<String> = Vec::new();
81 for pattern in backup_refs {
82 let candidate = if pattern.starts_with("refs/") {
83 pattern.clone()
84 } else {
85 format!("refs/heads/{pattern}")
86 };
87 if git::ref_exists(repo, &candidate) && !refs.contains(&candidate) {
88 refs.push(candidate);
89 }
90 }
91 Ok(BundleSelection::Refs(refs))
92}
93
94pub fn summarize_refs(
104 updated: &[String],
105 effective_backup_refs: &[String],
106 github_branches: &[String],
107) -> String {
108 let display = |r: &str| r.strip_prefix("refs/heads/").unwrap_or(r).to_string();
110 let names: Vec<String> = updated.iter().map(|r| display(r)).collect();
111 let width = names
112 .iter()
113 .map(|n| n.chars().count())
114 .max()
115 .unwrap_or(0)
116 .max(14);
117
118 let mut lines = Vec::with_capacity(updated.len());
119 for (r, name) in updated.iter().zip(&names) {
120 let s3 = effective_backup_refs.iter().any(|p| ref_matches(r, p));
121 let gh = github_branches
122 .iter()
123 .any(|b| r.strip_prefix("refs/heads/") == Some(b.as_str()) || b == r);
124
125 let mut tiers = vec!["NAS"];
126 if s3 {
127 tiers.push("encrypted S3");
128 }
129 if gh {
130 tiers.push("GitHub");
131 }
132 let mark = if s3 || gh { "✓" } else { "○" };
133 let tier_str = if tiers.len() == 1 {
134 "NAS only".to_string()
135 } else {
136 tiers.join(" + ")
137 };
138 lines.push(format!("{mark} {name:<width$} {tier_str}"));
139 }
140 lines.join("\n")
141}
142
143pub fn disk_line(usage: crate::disk::DiskUsage, low: bool) -> String {
146 let free = human_bytes(usage.free_bytes);
147 let pct = usage.percent_free();
148 if low {
149 format!("⚠ disk {free} free ({pct}%) — running low")
150 } else {
151 format!("✓ disk {free} free ({pct}%)")
152 }
153}
154
155pub(crate) fn human_bytes(n: u64) -> String {
159 const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
160 let mut v = n as f64;
161 let mut i = 0;
162 while v >= 1024.0 && i < U.len() - 1 {
163 v /= 1024.0;
164 i += 1;
165 }
166 format!("{v:.1} {}", U[i])
167}
168
169pub fn run_backup(
170 cfg: &Config,
171 repo: &Path,
172 backup_refs: &[String],
173 store: &dyn ObjectStore,
174 clock: &dyn Clock,
175 out: &mut dyn Write,
176) -> Result<()> {
177 let name = repo_name(&cfg.repos_root, repo)?;
178
179 let selection = resolve_bundle_refs(repo, backup_refs)?;
182
183 writeln!(out, "bundling {name} …").ok();
184 out.flush().ok();
185 let bundle = match &selection {
186 BundleSelection::All => git::bundle_all(repo)?,
187 BundleSelection::Refs(refs) => {
188 if refs.is_empty() {
189 writeln!(out, "○ nothing to back up — no selected refs exist").ok();
192 out.flush().ok();
193 return Ok(());
194 }
195 git::bundle_refs(repo, refs)?
196 }
197 };
198
199 writeln!(out, "encrypting {} bytes …", bundle.len()).ok();
200 out.flush().ok();
201 let ciphertext = crypto::encrypt(&cfg.age_recipient, &bundle)?;
202
203 let ts = clock.timestamp();
204 let hist = history_key(&cfg.s3.prefix, &name, &ts);
205 let latest = latest_key(&cfg.s3.prefix, &name);
206 store.put(&hist, &ciphertext)?;
208 store.put(&latest, &ciphertext)?;
209
210 writeln!(
211 out,
212 "✓ backup → s3://{}/{} + {}",
213 cfg.s3.bucket, latest, hist
214 )
215 .ok();
216 out.flush().ok();
217 Ok(())
218}
219
220#[cfg(test)]
221mod key_tests {
222 use super::*;
223 use std::path::Path;
224 #[test]
225 fn repo_name_strips_root_and_git() {
226 let n = repo_name(
227 Path::new("/srv/repos"),
228 Path::new("/srv/repos/team/app.git"),
229 )
230 .unwrap();
231 assert_eq!(n, "team/app");
232 }
233 #[test]
234 fn key_layout() {
235 assert_eq!(latest_key("git-ark", "app"), "git-ark/app/latest.age");
236 assert_eq!(
237 history_key("git-ark", "app", "2026-01-02T03-04-05Z"),
238 "git-ark/app/history/2026-01-02T03-04-05Z.age"
239 );
240 }
241}
242
243#[cfg(test)]
244mod gate_tests {
245 use super::*;
246
247 fn defaults() -> Vec<String> {
248 vec![
249 "refs/heads/main".to_string(),
250 "refs/heads/master".to_string(),
251 ]
252 }
253 fn refs(items: &[&str]) -> Vec<String> {
254 items.iter().map(|s| s.to_string()).collect()
255 }
256
257 #[test]
258 fn parses_receive_ref_lines() {
259 let input = "aaa bbb refs/heads/main\nccc ddd refs/heads/feature\n";
260 assert_eq!(
261 parse_receive_refs(input),
262 refs(&["refs/heads/main", "refs/heads/feature"])
263 );
264 }
265
266 #[test]
267 fn ref_matching_rules() {
268 assert!(ref_matches("refs/heads/main", "main"));
269 assert!(ref_matches("refs/heads/main", "refs/heads/main"));
270 assert!(ref_matches("refs/heads/anything", "**"));
271 assert!(!ref_matches("refs/heads/feature", "main"));
272 assert!(!ref_matches("refs/tags/v1", "main"));
273 }
274
275 #[test]
276 fn gates_on_main_master_by_default() {
277 assert!(should_back_up(&refs(&["refs/heads/main"]), &defaults()));
278 assert!(should_back_up(&refs(&["refs/heads/master"]), &defaults()));
279 assert!(!should_back_up(&refs(&["refs/heads/feature"]), &defaults()));
280 assert!(should_back_up(
282 &refs(&["refs/heads/feature", "refs/heads/main"]),
283 &defaults()
284 ));
285 }
286
287 #[test]
288 fn double_star_backs_up_every_ref() {
289 let all = vec!["**".to_string()];
290 assert!(should_back_up(&refs(&["refs/heads/feature"]), &all));
291 assert!(should_back_up(&refs(&["refs/tags/v1"]), &all));
292 }
293}
294
295#[cfg(test)]
296mod bundle_selection_tests {
297 use super::*;
298 use std::process::Command;
299
300 fn repo_with_branches(dir: &Path) {
302 let g = |args: &[&str]| {
303 assert!(
304 Command::new("git")
305 .args(args)
306 .current_dir(dir)
307 .status()
308 .unwrap()
309 .success(),
310 "git {args:?}"
311 );
312 };
313 g(&["init", "-q", "-b", "main"]);
314 g(&["config", "user.email", "t@t"]);
315 g(&["config", "user.name", "t"]);
316 std::fs::write(dir.join("a.txt"), "a").unwrap();
317 g(&["add", "."]);
318 g(&["commit", "-qm", "a"]);
319 g(&["branch", "feature"]);
320 }
321
322 fn strs(items: &[&str]) -> Vec<String> {
323 items.iter().map(|s| s.to_string()).collect()
324 }
325
326 #[test]
327 fn double_star_or_star_selects_all() {
328 let d = tempfile::tempdir().unwrap();
329 repo_with_branches(d.path());
330 assert_eq!(
331 resolve_bundle_refs(d.path(), &strs(&["**"])).unwrap(),
332 BundleSelection::All
333 );
334 assert_eq!(
335 resolve_bundle_refs(d.path(), &strs(&["*"])).unwrap(),
336 BundleSelection::All
337 );
338 assert_eq!(
340 resolve_bundle_refs(d.path(), &strs(&["main", "**"])).unwrap(),
341 BundleSelection::All
342 );
343 }
344
345 #[test]
346 fn resolves_bare_and_full_refs_that_exist() {
347 let d = tempfile::tempdir().unwrap();
348 repo_with_branches(d.path());
349 let sel = resolve_bundle_refs(d.path(), &strs(&["main", "refs/heads/feature"])).unwrap();
350 assert_eq!(
351 sel,
352 BundleSelection::Refs(strs(&["refs/heads/main", "refs/heads/feature"]))
353 );
354 }
355
356 #[test]
357 fn drops_nonexistent_and_dedups() {
358 let d = tempfile::tempdir().unwrap();
359 repo_with_branches(d.path());
360 let sel = resolve_bundle_refs(
363 d.path(),
364 &strs(&["main", "nope", "refs/heads/ghost", "refs/heads/main"]),
365 )
366 .unwrap();
367 assert_eq!(sel, BundleSelection::Refs(strs(&["refs/heads/main"])));
368 }
369
370 #[test]
371 fn empty_when_nothing_selected_exists() {
372 let d = tempfile::tempdir().unwrap();
373 repo_with_branches(d.path());
374 let sel = resolve_bundle_refs(d.path(), &strs(&["ghost", "refs/tags/none"])).unwrap();
375 assert_eq!(sel, BundleSelection::Refs(Vec::new()));
376 }
377}
378
379#[cfg(test)]
380mod disk_line_tests {
381 use super::*;
382 use crate::disk::DiskUsage;
383
384 #[test]
385 fn healthy_disk_line_uses_check_mark() {
386 let u = DiskUsage {
387 total_bytes: 56_000_000_000_000,
388 free_bytes: 55_000_000_000_000,
389 };
390 let line = disk_line(u, false);
391 assert!(line.starts_with("✓ disk"), "got: {line}");
392 assert!(line.contains("free"));
393 }
394
395 #[test]
396 fn low_disk_line_warns() {
397 let u = DiskUsage {
398 total_bytes: 20_000_000_000,
399 free_bytes: 1_000_000_000,
400 };
401 let line = disk_line(u, true);
402 assert!(line.starts_with("⚠ disk"), "got: {line}");
403 assert!(line.to_lowercase().contains("low"));
404 }
405
406 #[test]
407 fn human_bytes_formats_binary_units() {
408 assert_eq!(human_bytes(1_500_000_000), "1.4 GB");
409 }
410}
411
412#[cfg(test)]
413mod summary_tests {
414 use super::*;
415
416 fn strs(items: &[&str]) -> Vec<String> {
417 items.iter().map(|s| s.to_string()).collect()
418 }
419
420 #[test]
421 fn three_tier_example() {
422 let updated = strs(&[
423 "refs/heads/main",
424 "refs/heads/big-feature",
425 "refs/heads/scratch",
426 ]);
427 let backup_refs = strs(&["main", "big-feature"]);
428 let github = strs(&["main"]);
429 let out = summarize_refs(&updated, &backup_refs, &github);
430 let expected = "\
433✓ main NAS + encrypted S3 + GitHub
434✓ big-feature NAS + encrypted S3
435○ scratch NAS only";
436 assert_eq!(out, expected);
437 }
438
439 #[test]
440 fn branch_only_push_is_all_nas_only() {
441 let updated = strs(&["refs/heads/scratch", "refs/heads/wip"]);
443 let out = summarize_refs(&updated, &strs(&["main"]), &[]);
444 let expected = "\
445○ scratch NAS only
446○ wip NAS only";
447 assert_eq!(out, expected);
448 }
449
450 #[test]
451 fn double_star_marks_every_ref_s3() {
452 let updated = strs(&["refs/heads/main", "refs/tags/v1"]);
453 let out = summarize_refs(&updated, &strs(&["**"]), &[]);
454 assert!(out.contains("✓ main"));
456 assert!(out.lines().all(|l| l.starts_with('✓')));
457 assert!(out.contains("refs/tags/v1"));
458 assert!(out.lines().all(|l| l.contains("NAS + encrypted S3")));
459 }
460}