1use anyhow::{Context, Result};
10use serde::Deserialize;
11use std::path::Path;
12use std::process::Command;
13
14#[derive(Debug, Clone, Default, Deserialize)]
16pub struct RepoPolicy {
17 #[serde(default)]
20 pub backup_refs: Vec<String>,
21 #[serde(default)]
23 pub github: Option<GithubPolicy>,
24}
25
26#[derive(Debug, Clone, Deserialize)]
28pub struct GithubPolicy {
29 #[serde(default = "default_true")]
31 pub enabled: bool,
32 #[serde(default)]
34 pub visibility: Visibility,
35 pub owner: String,
37 #[serde(default)]
39 pub repo: Option<String>,
40 #[serde(default)]
42 pub branches: Vec<String>,
43}
44
45fn default_true() -> bool {
46 true
47}
48
49#[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 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 pub fn parse(yaml: &str) -> Result<RepoPolicy> {
81 if yaml.trim().is_empty() {
84 return Ok(RepoPolicy::default());
85 }
86 let parsed: Option<RepoPolicy> =
88 serde_yaml::from_str(yaml).context("parsing .git-ark.yml")?;
89 Ok(parsed.unwrap_or_default())
90 }
91}
92
93pub fn read_repo_policy(repo: &Path) -> Result<Option<RepoPolicy>> {
100 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 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 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 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 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 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 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 assert!(g.enabled);
298 assert_eq!(g.visibility, Visibility::Private);
299 }
300}