Skip to main content

grit_lib/
ident_resolve.rs

1//! Git-compatible author/committer identity resolution (see upstream `ident.c`).
2//!
3//! This module keeps environment access injectable so callers can test identity resolution
4//! without mutating process-wide state.
5
6use std::ffi::OsString;
7
8use thiserror::Error;
9
10use crate::commit_encoding::decode_bytes;
11use crate::config::ConfigSet;
12use crate::ident_config::ident_default_name;
13
14/// Environment access used for identity resolution.
15pub trait IdentityEnv {
16    /// Return a UTF-8 environment variable, if it exists and is valid Unicode.
17    fn var(&self, key: &str) -> Option<String>;
18
19    /// Return a raw environment variable value, preserving non-UTF-8 bytes on Unix.
20    fn var_os(&self, key: &str) -> Option<OsString>;
21}
22
23/// Environment provider backed by the current process environment.
24#[derive(Clone, Copy, Debug, Default)]
25pub struct SystemIdentityEnv;
26
27impl IdentityEnv for SystemIdentityEnv {
28    fn var(&self, key: &str) -> Option<String> {
29        std::env::var(key).ok()
30    }
31
32    fn var_os(&self, key: &str) -> Option<OsString> {
33        std::env::var_os(key)
34    }
35}
36
37/// Whether `GIT_AUTHOR_NAME` / `GIT_COMMITTER_NAME` is unset vs set (possibly empty).
38///
39/// Git treats a set-but-empty value as an explicit override: it must not fall through
40/// to `user.name` or passwd/GECOS fallback (`t7518-ident-corner-cases`).
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum GitIdentityNameEnv {
43    /// Variable is not present in the environment.
44    Unset,
45    /// Present after trimming whitespace (may be `""`).
46    Set(String),
47}
48
49/// Author vs committer for `GIT_*` / `author.*` / `committer.*` lookup.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum IdentRole {
52    /// Author identity.
53    Author,
54    /// Committer identity.
55    Committer,
56}
57
58impl IdentRole {
59    fn env_name_key(self) -> &'static str {
60        match self {
61            IdentRole::Author => "GIT_AUTHOR_NAME",
62            IdentRole::Committer => "GIT_COMMITTER_NAME",
63        }
64    }
65
66    fn env_email_key(self) -> &'static str {
67        match self {
68            IdentRole::Author => "GIT_AUTHOR_EMAIL",
69            IdentRole::Committer => "GIT_COMMITTER_EMAIL",
70        }
71    }
72
73    fn config_name_key(self) -> &'static str {
74        match self {
75            IdentRole::Author => "author.name",
76            IdentRole::Committer => "committer.name",
77        }
78    }
79
80    fn config_email_key(self) -> &'static str {
81        match self {
82            IdentRole::Author => "author.email",
83            IdentRole::Committer => "committer.email",
84        }
85    }
86
87    /// Heading Git prints before identity setup advice.
88    #[must_use]
89    pub fn missing_email_hint(self) -> &'static str {
90        match self {
91            IdentRole::Author => "Author identity unknown",
92            IdentRole::Committer => "Committer identity unknown",
93        }
94    }
95}
96
97/// Errors returned by strict identity resolution.
98#[derive(Clone, Debug, Error, PartialEq, Eq)]
99pub enum IdentityError {
100    /// `user.useConfigOnly` disables email auto-detection and no config email was provided.
101    ///
102    /// The Display text here is a terse, functional description of the
103    /// condition. The user-facing setup guidance is rendered by the
104    /// (GPL-licensed) CLI layer, which maps this variant to its own message.
105    #[error("email auto-detection is disabled (user.useConfigOnly) and no configured email is available")]
106    AutoDetectionDisabled {
107        /// Identity role being resolved.
108        role: IdentRole,
109    },
110    /// Git rejects empty ident names.
111    #[error("empty ident name (for <{email}>) not allowed")]
112    EmptyName {
113        /// Email address associated with the attempted identity.
114        email: String,
115        /// Identity role being resolved.
116        role: IdentRole,
117    },
118    /// Git rejects names containing only "crud" characters.
119    #[error("invalid ident name: '{name}'")]
120    InvalidName {
121        /// Rejected name.
122        name: String,
123    },
124}
125
126/// Read a `GIT_*_NAME` variable like Git's `getenv`: unset vs set, preserving explicit empty.
127#[must_use]
128pub fn read_git_identity_name_env_with<E: IdentityEnv>(env: &E, key: &str) -> GitIdentityNameEnv {
129    let Some(os) = env.var_os(key) else {
130        return GitIdentityNameEnv::Unset;
131    };
132    #[cfg(unix)]
133    {
134        use std::os::unix::ffi::OsStrExt;
135        let bytes = os.as_bytes();
136        let s = if std::str::from_utf8(bytes).is_ok() {
137            String::from_utf8_lossy(bytes).into_owned()
138        } else {
139            decode_bytes(Some("ISO8859-1"), bytes)
140        };
141        GitIdentityNameEnv::Set(s.trim().to_owned())
142    }
143    #[cfg(not(unix))]
144    {
145        let s = os.to_str().map(|t| t.trim().to_owned()).unwrap_or_default();
146        GitIdentityNameEnv::Set(s)
147    }
148}
149
150/// Read `GIT_AUTHOR_NAME` / `GIT_COMMITTER_NAME` from the supplied environment.
151///
152/// Returns [`None`] when the variable is unset or set to whitespace only. A set-but-empty
153/// value (after trim) is still [`None`] here; use [`read_git_identity_name_env_with`] when the
154/// distinction matters.
155#[must_use]
156pub fn read_git_identity_name_from_env_with<E: IdentityEnv>(env: &E, key: &str) -> Option<String> {
157    match read_git_identity_name_env_with(env, key) {
158        GitIdentityNameEnv::Unset => None,
159        GitIdentityNameEnv::Set(s) if s.is_empty() => None,
160        GitIdentityNameEnv::Set(s) => Some(s),
161    }
162}
163
164fn use_config_only(config: &ConfigSet) -> bool {
165    match config.get_bool("user.useConfigOnly") {
166        Some(Ok(b)) => b,
167        Some(Err(_)) => false,
168        None => false,
169    }
170}
171
172fn config_mail_given(config: &ConfigSet) -> bool {
173    ["user.email", "author.email", "committer.email"]
174        .iter()
175        .any(|key| config.get(key).is_some_and(|v| !v.trim().is_empty()))
176}
177
178fn ident_name_has_non_crud(name: &str) -> bool {
179    name.chars().any(|c| {
180        let o = c as u32;
181        !(o <= 32
182            || c == ','
183            || c == ':'
184            || c == ';'
185            || c == '<'
186            || c == '>'
187            || c == '"'
188            || c == '\\'
189            || c == '\'')
190    })
191}
192
193fn synthetic_email_with<E: IdentityEnv>(env: &E) -> String {
194    let user = env
195        .var("USER")
196        .or_else(|| env.var("USERNAME"))
197        .unwrap_or_else(|| "unknown".to_owned());
198    let host = env.var("HOSTNAME").unwrap_or_else(|| "unknown".to_owned());
199    let domain = if host.contains('.') {
200        host
201    } else {
202        format!("{host}.(none)")
203    };
204    format!("{user}@{domain}")
205}
206
207fn resolve_email_inner_with<E: IdentityEnv>(
208    env: &E,
209    config: &ConfigSet,
210    role: IdentRole,
211    honor_use_config_only: bool,
212) -> Result<String, IdentityError> {
213    if let Some(v) = env.var(role.env_email_key()) {
214        let t = v.trim();
215        if !t.is_empty() {
216            return Ok(t.to_owned());
217        }
218    }
219
220    if let Some(v) = config.get(role.config_email_key()) {
221        let t = v.trim();
222        if !t.is_empty() {
223            return Ok(t.to_owned());
224        }
225    }
226
227    if let Some(v) = config.get("user.email") {
228        let t = v.trim();
229        if !t.is_empty() {
230            return Ok(t.to_owned());
231        }
232    }
233
234    if honor_use_config_only && use_config_only(config) && !config_mail_given(config) {
235        return Err(IdentityError::AutoDetectionDisabled { role });
236    }
237
238    if let Some(v) = env.var("EMAIL") {
239        let t = v.trim();
240        if !t.is_empty() {
241            return Ok(t.to_owned());
242        }
243    }
244
245    Ok(synthetic_email_with(env))
246}
247
248/// Resolve email for a role when creating commits (honors `user.useConfigOnly`).
249pub fn resolve_email_with<E: IdentityEnv>(
250    env: &E,
251    config: &ConfigSet,
252    role: IdentRole,
253) -> Result<String, IdentityError> {
254    resolve_email_inner_with(env, config, role, true)
255}
256
257/// Resolve email without failing on `user.useConfigOnly` (e.g. `git var -l`, reflog-style).
258#[must_use]
259pub fn resolve_email_lenient_with<E: IdentityEnv>(
260    env: &E,
261    config: &ConfigSet,
262    role: IdentRole,
263) -> String {
264    resolve_email_inner_with(env, config, role, false).unwrap_or_else(|_| synthetic_email_with(env))
265}
266
267/// Name from env and config without erroring (for `git var -l`).
268#[must_use]
269pub fn peek_name_with<E: IdentityEnv>(
270    env: &E,
271    config: &ConfigSet,
272    role: IdentRole,
273) -> Option<String> {
274    match read_git_identity_name_env_with(env, role.env_name_key()) {
275        GitIdentityNameEnv::Set(s) => {
276            if s.is_empty() {
277                None
278            } else {
279                Some(s)
280            }
281        }
282        GitIdentityNameEnv::Unset => {
283            if let Some(v) = config.get(role.config_name_key()) {
284                let t = v.trim();
285                if !t.is_empty() {
286                    return Some(t.to_owned());
287                }
288            }
289            let d = ident_default_name(config);
290            if d.is_empty() {
291                None
292            } else {
293                Some(d)
294            }
295        }
296    }
297}
298
299/// Resolve name for a role when creating commits.
300pub fn resolve_name_with<E: IdentityEnv>(
301    env: &E,
302    config: &ConfigSet,
303    role: IdentRole,
304) -> Result<String, IdentityError> {
305    let email = resolve_email_inner_with(env, config, role, true)?;
306
307    let name: String = match read_git_identity_name_env_with(env, role.env_name_key()) {
308        GitIdentityNameEnv::Set(s) => s,
309        GitIdentityNameEnv::Unset => {
310            if let Some(v) = config.get(role.config_name_key()) {
311                let t = v.trim();
312                if !t.is_empty() {
313                    t.to_owned()
314                } else {
315                    ident_default_name(config)
316                }
317            } else {
318                ident_default_name(config)
319            }
320        }
321    };
322
323    if name.is_empty() {
324        return Err(IdentityError::EmptyName { email, role });
325    }
326
327    if !ident_name_has_non_crud(&name) {
328        return Err(IdentityError::InvalidName { name });
329    }
330
331    Ok(name)
332}
333
334/// Committer name/email for reflog and other non-strict contexts: never errors; always has an email.
335#[must_use]
336pub fn resolve_loose_committer_parts_with<E: IdentityEnv>(
337    env: &E,
338    config: &ConfigSet,
339) -> (String, String) {
340    let name = match read_git_identity_name_env_with(env, "GIT_COMMITTER_NAME") {
341        GitIdentityNameEnv::Set(s) => {
342            if s.is_empty() {
343                None
344            } else {
345                Some(s)
346            }
347        }
348        GitIdentityNameEnv::Unset => read_git_identity_name_from_env_with(env, "GIT_AUTHOR_NAME"),
349    }
350    .or_else(|| {
351        config
352            .get("committer.name")
353            .map(|s| s.trim().to_owned())
354            .filter(|s| !s.is_empty())
355    })
356    .or_else(|| {
357        config
358            .get("user.name")
359            .map(|s| s.trim().to_owned())
360            .filter(|s| !s.is_empty())
361    })
362    .or_else(|| {
363        let d = ident_default_name(config);
364        if d.is_empty() {
365            None
366        } else {
367            Some(d)
368        }
369    })
370    .unwrap_or_else(|| "Unknown".to_owned());
371
372    let email = env
373        .var("GIT_COMMITTER_EMAIL")
374        .map(|s| s.trim().to_owned())
375        .filter(|s| !s.is_empty())
376        .or_else(|| {
377            env.var("GIT_AUTHOR_EMAIL")
378                .map(|s| s.trim().to_owned())
379                .filter(|s| !s.is_empty())
380        })
381        .or_else(|| {
382            config
383                .get("committer.email")
384                .map(|s| s.trim().to_owned())
385                .filter(|s| !s.is_empty())
386        })
387        .or_else(|| {
388            config
389                .get("user.email")
390                .map(|s| s.trim().to_owned())
391                .filter(|s| !s.is_empty())
392        })
393        .or_else(|| {
394            env.var("EMAIL")
395                .map(|s| s.trim().to_owned())
396                .filter(|s| !s.is_empty())
397        })
398        .unwrap_or_else(|| synthetic_email_with(env));
399
400    (name, email)
401}