Skip to main content

git_ark/
repo_policy.rs

1//! Per-repo backup + mirror policy, committed to the repo as `.git-ark.yml`.
2//!
3//! A repo declares its own policy in a `.git-ark.yml` at its root. On push the
4//! hook reads that file straight out of the bare repo (`git show HEAD:…`) and
5//! uses it to (a) override which refs earn the durable S3 backup and (b) mirror
6//! named branches to GitHub. Absent (or unreadable) → no policy, and the caller
7//! falls back to the central `backup_refs` with no mirror.
8
9use anyhow::{Context, Result};
10use serde::Deserialize;
11use std::path::Path;
12use std::process::Command;
13
14/// A repo's committed `.git-ark.yml`.
15#[derive(Debug, Clone, Default, Deserialize)]
16pub struct RepoPolicy {
17    /// Refs whose push earns the durable, encrypted S3 backup. When non-empty
18    /// this overrides the host's central `backup_refs`; empty → fall back to it.
19    #[serde(default)]
20    pub backup_refs: Vec<String>,
21    /// Optional GitHub mirror. Omitted (or `enabled: false`) → no mirror.
22    #[serde(default)]
23    pub github: Option<GithubPolicy>,
24}
25
26/// The `github:` block of a `.git-ark.yml`.
27#[derive(Debug, Clone, Deserialize)]
28pub struct GithubPolicy {
29    /// Set `false` to keep the block but turn mirroring off. Defaults to `true`.
30    #[serde(default = "default_true")]
31    pub enabled: bool,
32    /// Visibility of the GitHub repo when git-ark creates it. Default: private.
33    #[serde(default)]
34    pub visibility: Visibility,
35    /// GitHub org or user that owns the mirror.
36    pub owner: String,
37    /// GitHub repo name; defaults to this repo's own name when omitted.
38    #[serde(default)]
39    pub repo: Option<String>,
40    /// Branches to push to the mirror.
41    #[serde(default)]
42    pub branches: Vec<String>,
43}
44
45fn default_true() -> bool {
46    true
47}
48
49/// Visibility of a created GitHub mirror. Defaults to `Private` — a mirror
50/// should never be more exposed than the operator asked for.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum Visibility {
53    #[default]
54    Private,
55    Public,
56}
57
58impl<'de> Deserialize<'de> for Visibility {
59    /// Accept `private`/`public` case-insensitively (`Private`, `PUBLIC`, …).
60    fn deserialize<D>(d: D) -> std::result::Result<Self, D::Error>
61    where
62        D: serde::Deserializer<'de>,
63    {
64        let s = String::deserialize(d)?;
65        match s.trim().to_ascii_lowercase().as_str() {
66            "private" => Ok(Visibility::Private),
67            "public" => Ok(Visibility::Public),
68            other => Err(serde::de::Error::custom(format!(
69                "invalid visibility {other:?}; expected 'private' or 'public'"
70            ))),
71        }
72    }
73}
74
75impl RepoPolicy {
76    /// Parse a `.git-ark.yml` document. A null document — an empty file,
77    /// whitespace, or comments only — is a valid "no policy" and yields all
78    /// defaults rather than an error. Deserializing into `Option` maps that null
79    /// to `None` instead of failing on the absent struct.
80    pub fn parse(yaml: &str) -> Result<RepoPolicy> {
81        // Whitespace-only (incl. tabs, which YAML's scanner rejects as illegal
82        // indentation) short-circuits to defaults before serde_yaml sees it.
83        if yaml.trim().is_empty() {
84            return Ok(RepoPolicy::default());
85        }
86        // A comments-only document is a valid null → map that to defaults too.
87        let parsed: Option<RepoPolicy> =
88            serde_yaml::from_str(yaml).context("parsing .git-ark.yml")?;
89        Ok(parsed.unwrap_or_default())
90    }
91}
92
93/// Read the committed `.git-ark.yml` from a bare repo's current `HEAD`.
94///
95/// Runs `git -C <repo> show HEAD:.git-ark.yml`. If the command fails — no such
96/// file, or the repo has no `HEAD` yet — this is not an error: return `Ok(None)`
97/// and let the caller fall back to the central config (no override, no mirror).
98/// A file that exists but fails to parse *is* surfaced as an error.
99pub fn read_repo_policy(repo: &Path) -> Result<Option<RepoPolicy>> {
100    // The policy at the default branch's tip wins. A freshly `git init --bare`
101    // repo can have HEAD pointing at an unborn `master` while the pushed branch
102    // is `main`, so try main, then master, then HEAD.
103    for refname in ["refs/heads/main", "refs/heads/master", "HEAD"] {
104        let out = Command::new("git")
105            .arg("-C")
106            .arg(repo)
107            .args(["show", &format!("{refname}:.git-ark.yml")])
108            .output()
109            .with_context(|| format!("spawning git show {refname}:.git-ark.yml"))?;
110        if out.status.success() {
111            let yaml = String::from_utf8_lossy(&out.stdout);
112            return Ok(Some(RepoPolicy::parse(&yaml)?));
113        }
114    }
115    // No committed policy on any candidate ref (missing file / no such ref).
116    Ok(None)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn reads_policy_from_main_when_head_is_unborn_master() {
125        use std::process::Command;
126        let dir = tempfile::tempdir().unwrap();
127        let work = dir.path().join("work");
128        let bare = dir.path().join("r.git");
129        let g = |args: &[&str], cwd: &std::path::Path| {
130            assert!(
131                Command::new("git")
132                    .args(args)
133                    .current_dir(cwd)
134                    .status()
135                    .unwrap()
136                    .success(),
137                "git {args:?}"
138            );
139        };
140        std::fs::create_dir(&work).unwrap();
141        g(&["init", "-q", "-b", "main"], &work);
142        g(&["config", "user.email", "t@t"], &work);
143        g(&["config", "user.name", "t"], &work);
144        std::fs::write(work.join(".git-ark.yml"), "backup_refs:\n  - main\n").unwrap();
145        g(&["add", "."], &work);
146        g(&["commit", "-qm", "policy"], &work);
147        assert!(Command::new("git")
148            .args([
149                "clone",
150                "--bare",
151                work.to_str().unwrap(),
152                bare.to_str().unwrap()
153            ])
154            .status()
155            .unwrap()
156            .success());
157        // Reproduce the bug: HEAD points at an unborn `master`, content is on `main`.
158        g(&["symbolic-ref", "HEAD", "refs/heads/master"], &bare);
159        let policy = read_repo_policy(&bare)
160            .unwrap()
161            .expect("policy found via main");
162        assert_eq!(policy.backup_refs, vec!["main".to_string()]);
163    }
164
165    #[test]
166    fn parses_full_policy() {
167        let yaml = r#"
168backup_refs:
169  - main
170  - long-lived-feature
171github:
172  enabled: true
173  visibility: private
174  owner: some-org
175  repo: custom-name
176  branches:
177    - main
178"#;
179        let p = RepoPolicy::parse(yaml).unwrap();
180        assert_eq!(p.backup_refs, vec!["main", "long-lived-feature"]);
181        let g = p.github.unwrap();
182        assert!(g.enabled);
183        assert_eq!(g.visibility, Visibility::Private);
184        assert_eq!(g.owner, "some-org");
185        assert_eq!(g.repo.as_deref(), Some("custom-name"));
186        assert_eq!(g.branches, vec!["main"]);
187    }
188
189    #[test]
190    fn minimal_github_applies_defaults() {
191        // No `enabled`, no `visibility`, no `repo`, no `branches`.
192        let yaml = r#"
193github:
194  owner: someone
195"#;
196        let p = RepoPolicy::parse(yaml).unwrap();
197        assert!(p.backup_refs.is_empty());
198        let g = p.github.unwrap();
199        assert!(g.enabled, "enabled defaults to true");
200        assert_eq!(
201            g.visibility,
202            Visibility::Private,
203            "visibility defaults to private"
204        );
205        assert_eq!(g.owner, "someone");
206        assert_eq!(g.repo, None);
207        assert!(g.branches.is_empty());
208    }
209
210    #[test]
211    fn visibility_is_case_insensitive() {
212        let cases = [
213            ("private", Visibility::Private),
214            ("Private", Visibility::Private),
215            ("PRIVATE", Visibility::Private),
216            ("public", Visibility::Public),
217            ("Public", Visibility::Public),
218            ("PUBLIC", Visibility::Public),
219        ];
220        for (word, want) in cases {
221            let yaml = format!("github:\n  owner: o\n  visibility: {word}\n");
222            let p = RepoPolicy::parse(&yaml).unwrap();
223            assert_eq!(p.github.unwrap().visibility, want, "visibility {word:?}");
224        }
225    }
226
227    #[test]
228    fn rejects_unknown_visibility() {
229        let yaml = "github:\n  owner: o\n  visibility: secret\n";
230        assert!(RepoPolicy::parse(yaml).is_err());
231    }
232
233    #[test]
234    fn no_github_block_yields_none() {
235        let yaml = "backup_refs:\n  - main\n";
236        let p = RepoPolicy::parse(yaml).unwrap();
237        assert_eq!(p.backup_refs, vec!["main"]);
238        assert!(p.github.is_none());
239    }
240
241    #[test]
242    fn empty_document_is_all_defaults() {
243        let p = RepoPolicy::parse("{}").unwrap();
244        assert!(p.backup_refs.is_empty());
245        assert!(p.github.is_none());
246    }
247
248    #[test]
249    fn empty_and_whitespace_only_files_are_defaults() {
250        // A 0-byte or whitespace/comment-only committed .git-ark.yml is a valid
251        // "no policy", not a parse error.
252        for body in ["", "   \n\t  \n", "# just a comment\n"] {
253            let p = RepoPolicy::parse(body).unwrap();
254            assert!(p.backup_refs.is_empty(), "backup_refs for {body:?}");
255            assert!(p.github.is_none(), "github for {body:?}");
256        }
257    }
258
259    #[test]
260    fn read_repo_policy_none_when_no_repo() {
261        // A path that is not a git repo → git show fails → Ok(None).
262        let d = tempfile::tempdir().unwrap();
263        let got = read_repo_policy(d.path()).unwrap();
264        assert!(got.is_none());
265    }
266
267    #[test]
268    fn read_repo_policy_reads_committed_file() {
269        // A real repo with a committed .git-ark.yml is read back from HEAD.
270        let work = tempfile::tempdir().unwrap();
271        let git = |args: &[&str]| {
272            let ok = Command::new("git")
273                .args(args)
274                .current_dir(work.path())
275                .status()
276                .unwrap()
277                .success();
278            assert!(ok, "git {args:?}");
279        };
280        git(&["init", "-q"]);
281        git(&["config", "user.email", "t@t"]);
282        git(&["config", "user.name", "t"]);
283        std::fs::write(
284            work.path().join(".git-ark.yml"),
285            "backup_refs:\n  - main\ngithub:\n  owner: acme\n  branches:\n    - main\n",
286        )
287        .unwrap();
288        git(&["add", "."]);
289        git(&["commit", "-qm", "add policy"]);
290
291        let p = read_repo_policy(work.path()).unwrap().unwrap();
292        assert_eq!(p.backup_refs, vec!["main"]);
293        let g = p.github.unwrap();
294        assert_eq!(g.owner, "acme");
295        assert_eq!(g.branches, vec!["main"]);
296        // Defaults still applied through the git-read path.
297        assert!(g.enabled);
298        assert_eq!(g.visibility, Visibility::Private);
299    }
300}