Skip to main content

git_policy_cli/
lfs_upload_settings.rs

1//! What:
2//!  The configuration the Git LFS upload plan reads, and the answers derived from it: the default remote, the branch
3//!  `HEAD` names, and whether a value reads as true.
4//! Why:
5//!  The plan and the manual-push probe must agree on the destination of a bare push, so both read it here. The entries
6//!  come from a snapshot the caller already holds or from one `config --get-regexp`, never one start per key.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! const settings = readSettings(query); const remote = defaultRemote(branch, settings);
11//! ```
12
13/// The configuration snapshot the caller may already hold.
14use super::config_snapshot::ConfigSnapshot;
15/// Owned OS text for arguments.
16use std::ffi::OsString;
17
18/// A read-only Git query: its trimmed standard output when it finished (status zero, or status one with no output, which
19///  is how `config --get-regexp` and `symbolic-ref --quiet` say "nothing"), or what went wrong.
20pub type Query<'a> = &'a mut dyn FnMut(&[&str]) -> Result<String, String>;
21
22/// Configuration entries the plan reads: `(key, value)` with Git's normalised key.
23pub type Settings = Vec<(String, String)>;
24
25/// Why the plan could not be worked out; the push is refused with this text rather than run without its upload.
26///
27/// In TS you'd write (pseudocode):
28/// ```ts
29/// class PlanError extends Error {}
30/// ```
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct PlanError(pub String);
33
34/// The `config --get-regexp` pattern that selects exactly the keys `wanted_key` accepts.
35pub const SETTINGS_REGEXP: &str =
36    "^(branch\\..*\\.(pushremote|remote)|remote\\.pushdefault|remote\\..*\\.(push|pushurl|mirror|url)|push\\.default|url\\..*\\.pushinsteadof)$";
37
38/// Whether the plan reads this configuration key (Git's normalised spelling).
39pub(super) fn wanted_key(key: &str) -> bool {
40    let remote_variable = |suffixes: &[&str]| -> bool {
41        return key.strip_prefix("remote.").is_some_and(|rest| {
42            return rest.rsplit_once('.').is_some_and(|(_name, variable)| {
43                return suffixes.contains(&variable);
44            });
45        });
46    };
47    let branch_variable: bool = key.strip_prefix("branch.").is_some_and(|rest| {
48        return rest.rsplit_once('.').is_some_and(|(_name, variable)| {
49            return variable == "pushremote" || variable == "remote";
50        });
51    });
52    let push_rewrite: bool = key.strip_prefix("url.").is_some_and(|rest| {
53        return rest.ends_with(".pushinsteadof");
54    });
55    return branch_variable
56        || push_rewrite
57        || key == "remote.pushdefault"
58        || key == "push.default"
59        || remote_variable(&["push", "pushurl", "mirror", "url"]);
60}
61
62/// The settings the plan reads, out of a snapshot the caller already holds, so no Git start is needed for them.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// snapshot.entries.filter(wantedKey).map(({ key, value }) => [key, value])
67/// ```
68pub fn settings_from_snapshot(snapshot: &ConfigSnapshot) -> Settings {
69    return snapshot
70        .entries()
71        .iter()
72        .filter(|entry| {
73            return wanted_key(entry.key.as_str());
74        })
75        .map(|entry| {
76            let value: String = entry
77                .value
78                .as_deref()
79                .map(|bytes| {
80                    return String::from_utf8_lossy(bytes).into_owned();
81                })
82                .unwrap_or_default();
83            return (entry.key.clone(), value);
84        })
85        .collect();
86}
87
88/// The settings the plan reads, from one `config --get-regexp` start.
89///
90/// In TS you'd write (pseudocode):
91/// ```ts
92/// await git(['config', '--get-regexp', SETTINGS_REGEXP])
93/// ```
94pub fn read_settings(query: Query<'_>) -> Result<Settings, PlanError> {
95    let listing: String = query(&["config", "--get-regexp", SETTINGS_REGEXP])
96        .map_err(|why| {
97            return PlanError(format!("git config --get-regexp: {why}"));
98        })?;
99    return Ok(listing
100        .lines()
101        .map(|line| {
102            // A key written without a value is listed alone.
103            let (key, value) = line.split_once(' ').unwrap_or((line, ""));
104            return (key.to_owned(), value.trim().to_owned());
105        })
106        .collect());
107}
108
109/// The last value of a setting, the last one written winning as in Git.
110pub(super) fn setting<'a>(all: &'a [(String, String)], key: &str) -> Option<&'a str> {
111    return all
112        .iter()
113        .rev()
114        .find(|(name, _)| {
115            return name == key;
116        })
117        .map(|(_, value)| {
118            return value.as_str();
119        });
120}
121
122/// Every value of a setting, in the order written.
123pub(super) fn values<'a>(all: &'a [(String, String)], key: &str) -> Vec<&'a str> {
124    return all
125        .iter()
126        .filter(|(name, _)| {
127            return name == key;
128        })
129        .map(|(_, value)| {
130            return value.as_str();
131        })
132        .collect();
133}
134
135/// The branch `HEAD` names, from the full ref so that a tag with the same name cannot change the answer.
136pub(super) fn current_branch(query: Query<'_>) -> Result<Option<String>, PlanError> {
137    let full: String = query(&["symbolic-ref", "--quiet", "HEAD"]).map_err(|why| {
138        return PlanError(format!("git symbolic-ref HEAD: {why}"));
139    })?;
140    return Ok(full.strip_prefix("refs/heads/").map(str::to_owned));
141}
142
143/// The remote Git pushes to when the caller names none: the branch's push remote, the default push remote, the
144///  branch's remote, then `origin`.
145pub(super) fn default_remote(branch: Option<&str>, all: &[(String, String)]) -> String {
146    let named = |variable: &str| -> Option<String> {
147        return branch.and_then(|name| {
148            return setting(all, &format!("branch.{name}.{variable}")).map(str::to_owned);
149        });
150    };
151    return named("pushremote")
152        .or_else(|| {
153            return setting(all, "remote.pushdefault").map(str::to_owned);
154        })
155        .or_else(|| {
156            return named("remote");
157        })
158        .unwrap_or_else(|| {
159            return String::from("origin");
160        });
161}
162
163/// What:
164///  The remote a push goes to: the one the caller named, or the one Git picks for a bare push.
165/// Why:
166///   The manual-push probe and the upload plan must agree on the destination, so both read it here.
167///
168/// In TS you'd write (pseudocode):
169/// ```ts
170/// named ?? defaultRemote(branch, settings)
171/// ```
172pub fn resolve_push_remote(named: Option<OsString>, query: Query<'_>) -> Result<OsString, PlanError> {
173    if let Some(found) = named {
174        return Ok(found);
175    }
176    let branch: Option<String> = current_branch(query)?;
177    let all: Settings = read_settings(query)?;
178    return Ok(OsString::from(default_remote(branch.as_deref(), all.as_slice())));
179}
180
181/// Whether Git reads this configuration text as true.
182pub(super) fn reads_as_true(value: &str) -> bool {
183    return ["true", "yes", "on", "1", ""].iter().any(|spelling| {
184        return value.eq_ignore_ascii_case(spelling);
185    });
186}