Krolyxon
Commit ec28036

ferreniumsrcindexmeta.rs

167 lines · 5.2 KB · Rust

1//! Per-repository metadata, resolved by precedence:
2//! `[[repo]]` in ferrenium.toml > repo git config > `description` file >
3//! defaults.
4
5use std::path::Path;
6
7use gix::bstr::BStr;
8use gix::glob::wildmatch;
9
10use super::scan::Candidate;
11use crate::config::{Config, RepoOverride};
12use crate::git::{self, ObjectId, ThreadSafeRepository};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Readme {
16 pub path: String,
17 pub oid: ObjectId,
18}
19
20#[derive(Debug, Default, Clone, PartialEq, Eq)]
21pub struct RepoMeta {
22 pub description: Option<String>,
23 pub owner: Option<String>,
24 pub section: Option<String>,
25 /// Branch shown by default (may be unborn in an empty repo).
26 pub default_branch: Option<String>,
27 /// Commit the default branch points to; `None` for an empty repo.
28 pub head: Option<ObjectId>,
29 pub branches: usize,
30 pub tags: usize,
31 /// Unix seconds of the newest commit across branch tips.
32 pub last_activity: Option<i64>,
33 pub readme: Option<Readme>,
34 pub refs_fingerprint: u64,
35}
36
37pub struct Inspected {
38 pub gix: ThreadSafeRepository,
39 pub meta: RepoMeta,
40 pub hidden: bool,
41 pub warnings: Vec<String>,
42}
43
44/// git's placeholder `description`, which means "no description".
45const PLACEHOLDER_DESCRIPTION: &str = "Unnamed repository;";
46
47pub fn inspect(config: &Config, candidate: &Candidate) -> anyhow::Result<Inspected> {
48 let gix = git::open(&candidate.git_dir)?;
49 let repo = gix.to_thread_local();
50 let name = candidate.name.as_str();
51 let toml = config.repo_overrides.iter().find(|r| r.name == name);
52 let toml = toml.cloned().unwrap_or_default();
53 let cfg = git::config_meta(&repo);
54 let mut warnings = Vec::new();
55
56 let hidden =
57 is_hidden_by_glob(&config.repos.hide, name) || toml.hidden.or(cfg.hidden).unwrap_or(false);
58 if hidden {
59 return Ok(Inspected {
60 gix,
61 meta: RepoMeta::default(),
62 hidden,
63 warnings,
64 });
65 }
66
67 let section = toml.section.clone().or(cfg.section.clone()).or_else(|| {
68 config
69 .repos
70 .section_from_path
71 .then(|| name.split_once('/').map(|(first, _)| first.to_string()))
72 .flatten()
73 });
74
75 let head_branch = git::head_branch(&repo);
76 let mut default_branch = toml
77 .default_branch
78 .clone()
79 .or(cfg.default_branch.clone())
80 .or(head_branch.clone());
81 let mut head = default_branch
82 .as_deref()
83 .and_then(|b| git::branch_commit(&repo, b));
84 if head.is_none()
85 && let Some(fallback) = head_branch.as_deref()
86 && default_branch.as_deref() != Some(fallback)
87 && let Some(commit) = git::branch_commit(&repo, fallback)
88 {
89 warnings.push(format!(
90 "{name}: default branch {:?} does not exist; using {fallback:?}",
91 default_branch.as_deref().unwrap_or_default()
92 ));
93 default_branch = Some(fallback.to_string());
94 head = Some(commit);
95 }
96
97 let readme_path = toml.readme.clone().or(cfg.readme.clone());
98 let readme = head.and_then(|commit| {
99 let found = git::find_readme(&repo, commit, readme_path.as_deref());
100 if found.is_none()
101 && let Some(path) = &readme_path
102 {
103 warnings.push(format!("{name}: readme {path:?} not found"));
104 }
105 found.map(|(path, oid)| Readme { path, oid })
106 });
107
108 let refs = git::ref_summary(&repo)?;
109 let meta = RepoMeta {
110 description: description(&toml, cfg.description.clone(), &candidate.git_dir),
111 owner: toml.owner.clone().or(cfg.owner.clone()),
112 section,
113 default_branch,
114 head,
115 branches: refs.branches,
116 tags: refs.tags,
117 last_activity: refs.last_activity,
118 readme,
119 refs_fingerprint: refs.fingerprint,
120 };
121 Ok(Inspected {
122 gix,
123 meta,
124 hidden,
125 warnings,
126 })
127}
128
129fn description(toml: &RepoOverride, from_config: Option<String>, git_dir: &Path) -> Option<String> {
130 toml.description
131 .clone()
132 .or(from_config)
133 .or_else(|| {
134 let text = std::fs::read_to_string(git_dir.join("description")).ok()?;
135 let text = text.trim();
136 (!text.is_empty() && !text.starts_with(PLACEHOLDER_DESCRIPTION))
137 .then(|| text.to_string())
138 })
139 .filter(|d| !d.trim().is_empty())
140}
141
142/// `repos.hide` globs: `*` stays within one path component, `**` crosses.
143pub fn is_hidden_by_glob(patterns: &[String], name: &str) -> bool {
144 patterns.iter().any(|pattern| {
145 wildmatch(
146 BStr::new(pattern.as_bytes()),
147 BStr::new(name.as_bytes()),
148 wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
149 )
150 })
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn hide_globs() {
159 let pats = vec!["private/*".to_string(), "*-wip".to_string()];
160 assert!(is_hidden_by_glob(&pats, "private/x"));
161 assert!(!is_hidden_by_glob(&pats, "private/x/y"));
162 assert!(is_hidden_by_glob(&pats, "thing-wip"));
163 assert!(!is_hidden_by_glob(&pats, "dir/thing-wip"));
164 assert!(!is_hidden_by_glob(&pats, "public"));
165 assert!(is_hidden_by_glob(&["**/secret".into()], "a/b/secret"));
166 }
167}