Skip to main content

git_ark/
backup.rs

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
30/// Parse post-receive stdin (`<old-sha> <new-sha> <refname>` per line) into the
31/// list of updated ref names.
32pub 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
39/// Does a ref name (e.g. `refs/heads/main`) match a `backup_refs` pattern?
40/// `**`/`*` match everything; a full ref matches exactly; a bare branch name
41/// (`main`) matches `refs/heads/<name>`.
42pub fn ref_matches(refname: &str, pattern: &str) -> bool {
43    pattern == "**"
44        || pattern == "*"
45        || pattern == refname
46        || format!("refs/heads/{pattern}") == refname
47}
48
49/// Should a push that updated `updated_refs` trigger the S3 backup, given the
50/// configured `backup_refs`?
51pub 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/// Which refs the S3 bundle should contain, derived from the effective
58/// `backup_refs`.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum BundleSelection {
61    /// Bundle every ref (`git bundle create - --all`) — the `**`/`*` case.
62    All,
63    /// Bundle exactly these concrete, existing refs (may be empty → no-op).
64    Refs(Vec<String>),
65}
66
67/// Resolve the effective `backup_refs` into a concrete bundle selection so the
68/// S3 bundle contains ONLY the selected branches (a non-selected branch is then
69/// genuinely absent from S3).
70///
71/// A `**`/`*` pattern means "everything" → [`BundleSelection::All`]. Otherwise
72/// each pattern is resolved to a concrete, existing ref: a full `refs/…` is used
73/// as-is when it exists; a bare name (`main`) becomes `refs/heads/<name>` when it
74/// exists; patterns whose ref doesn't exist are dropped. Order is preserved and
75/// duplicates removed. An empty result is a no-op (the caller handles it).
76pub 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
94/// Build the per-ref summary block printed to the hook's stdout after a push —
95/// one line per pushed ref (in receive order) showing which durable tiers it
96/// reached.
97///
98/// Every pushed ref lands on the host (NAS). A ref additionally shows "encrypted
99/// S3" when it matches the effective `backup_refs`, and "GitHub" when its branch
100/// is among the mirror's configured branches. `✓` marks a ref that went beyond
101/// NAS; `○` marks a NAS-only ref. The ref column is left-aligned and padded to
102/// the longest pushed ref name (min 14).
103pub fn summarize_refs(
104    updated: &[String],
105    effective_backup_refs: &[String],
106    github_branches: &[String],
107) -> String {
108    // Display name: drop `refs/heads/`; other refs (e.g. tags) are shown whole.
109    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
143/// A host-level disk line for the push summary. `✓` when healthy, `⚠ … low`
144/// when the caller judged it low. Informational — never affects the backup.
145pub 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
155/// Human-readable bytes, binary units, one decimal (e.g. "1.8 GB", "56.0 TB").
156/// `pub(crate)` so `hostcmd::format_status_row` can reuse it for `status`'s
157/// free-size column instead of re-implementing the same formatting.
158pub(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    // Bundle only the selected refs (not the whole repo) so a non-selected
180    // branch stays off S3 entirely.
181    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                // Nothing selected exists — a no-op. run_backup is only reached
190                // when a pushed ref matched, so this is defensive.
191                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    // History first, then latest — latest is the "known-good newest" pointer.
207    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        // A push touching several refs including main is gated in.
281        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    /// A repo with a `main` and a `feature` branch.
301    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        // A `**` anywhere in the list wins.
339        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        // `main` exists (listed twice → deduped); `nope` and `refs/heads/ghost`
361        // don't exist and are dropped.
362        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        // Column padded to the longest name (`big-feature`, 11) but min 14, then
431        // two spaces before the tier list.
432        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        // Nothing matches backup_refs or github → every ref is ○ NAS only.
442        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        // Tags keep their full name; both go to S3 under `**`.
455        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}