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    #[error(
102        "no email was given and auto-detection is disabled\n\n\
103*** Please tell me who you are.\n\n\
104Run\n\n\
105  git config --global user.email \"you@example.com\"\n\
106  git config --global user.name \"Your Name\"\n\n\
107to set your account's default identity.\n\
108Omit --global to set the identity only in this repository.\n"
109    )]
110    AutoDetectionDisabled {
111        /// Identity role being resolved.
112        role: IdentRole,
113    },
114    /// Git rejects empty ident names.
115    #[error("empty ident name (for <{email}>) not allowed")]
116    EmptyName {
117        /// Email address associated with the attempted identity.
118        email: String,
119        /// Identity role being resolved.
120        role: IdentRole,
121    },
122    /// Git rejects names containing only "crud" characters.
123    #[error("invalid ident name: '{name}'")]
124    InvalidName {
125        /// Rejected name.
126        name: String,
127    },
128}
129
130/// Read a `GIT_*_NAME` variable like Git's `getenv`: unset vs set, preserving explicit empty.
131#[must_use]
132pub fn read_git_identity_name_env_with<E: IdentityEnv>(env: &E, key: &str) -> GitIdentityNameEnv {
133    let Some(os) = env.var_os(key) else {
134        return GitIdentityNameEnv::Unset;
135    };
136    #[cfg(unix)]
137    {
138        use std::os::unix::ffi::OsStrExt;
139        let bytes = os.as_bytes();
140        let s = if std::str::from_utf8(bytes).is_ok() {
141            String::from_utf8_lossy(bytes).into_owned()
142        } else {
143            decode_bytes(Some("ISO8859-1"), bytes)
144        };
145        GitIdentityNameEnv::Set(s.trim().to_owned())
146    }
147    #[cfg(not(unix))]
148    {
149        let s = os.to_str().map(|t| t.trim().to_owned()).unwrap_or_default();
150        GitIdentityNameEnv::Set(s)
151    }
152}
153
154/// Read `GIT_AUTHOR_NAME` / `GIT_COMMITTER_NAME` from the supplied environment.
155///
156/// Returns [`None`] when the variable is unset or set to whitespace only. A set-but-empty
157/// value (after trim) is still [`None`] here; use [`read_git_identity_name_env_with`] when the
158/// distinction matters.
159#[must_use]
160pub fn read_git_identity_name_from_env_with<E: IdentityEnv>(env: &E, key: &str) -> Option<String> {
161    match read_git_identity_name_env_with(env, key) {
162        GitIdentityNameEnv::Unset => None,
163        GitIdentityNameEnv::Set(s) if s.is_empty() => None,
164        GitIdentityNameEnv::Set(s) => Some(s),
165    }
166}
167
168fn use_config_only(config: &ConfigSet) -> bool {
169    match config.get_bool("user.useConfigOnly") {
170        Some(Ok(b)) => b,
171        Some(Err(_)) => false,
172        None => false,
173    }
174}
175
176fn config_mail_given(config: &ConfigSet) -> bool {
177    ["user.email", "author.email", "committer.email"]
178        .iter()
179        .any(|key| config.get(key).is_some_and(|v| !v.trim().is_empty()))
180}
181
182fn ident_name_has_non_crud(name: &str) -> bool {
183    name.chars().any(|c| {
184        let o = c as u32;
185        !(o <= 32
186            || c == ','
187            || c == ':'
188            || c == ';'
189            || c == '<'
190            || c == '>'
191            || c == '"'
192            || c == '\\'
193            || c == '\'')
194    })
195}
196
197fn synthetic_email_with<E: IdentityEnv>(env: &E) -> String {
198    let user = env
199        .var("USER")
200        .or_else(|| env.var("USERNAME"))
201        .unwrap_or_else(|| "unknown".to_owned());
202    let host = env.var("HOSTNAME").unwrap_or_else(|| "unknown".to_owned());
203    let domain = if host.contains('.') {
204        host
205    } else {
206        format!("{host}.(none)")
207    };
208    format!("{user}@{domain}")
209}
210
211fn resolve_email_inner_with<E: IdentityEnv>(
212    env: &E,
213    config: &ConfigSet,
214    role: IdentRole,
215    honor_use_config_only: bool,
216) -> Result<String, IdentityError> {
217    if let Some(v) = env.var(role.env_email_key()) {
218        let t = v.trim();
219        if !t.is_empty() {
220            return Ok(t.to_owned());
221        }
222    }
223
224    if let Some(v) = config.get(role.config_email_key()) {
225        let t = v.trim();
226        if !t.is_empty() {
227            return Ok(t.to_owned());
228        }
229    }
230
231    if let Some(v) = config.get("user.email") {
232        let t = v.trim();
233        if !t.is_empty() {
234            return Ok(t.to_owned());
235        }
236    }
237
238    if honor_use_config_only && use_config_only(config) && !config_mail_given(config) {
239        return Err(IdentityError::AutoDetectionDisabled { role });
240    }
241
242    if let Some(v) = env.var("EMAIL") {
243        let t = v.trim();
244        if !t.is_empty() {
245            return Ok(t.to_owned());
246        }
247    }
248
249    Ok(synthetic_email_with(env))
250}
251
252/// Resolve email for a role when creating commits (honors `user.useConfigOnly`).
253pub fn resolve_email_with<E: IdentityEnv>(
254    env: &E,
255    config: &ConfigSet,
256    role: IdentRole,
257) -> Result<String, IdentityError> {
258    resolve_email_inner_with(env, config, role, true)
259}
260
261/// Resolve email without failing on `user.useConfigOnly` (e.g. `git var -l`, reflog-style).
262#[must_use]
263pub fn resolve_email_lenient_with<E: IdentityEnv>(
264    env: &E,
265    config: &ConfigSet,
266    role: IdentRole,
267) -> String {
268    resolve_email_inner_with(env, config, role, false).unwrap_or_else(|_| synthetic_email_with(env))
269}
270
271/// Name from env and config without erroring (for `git var -l`).
272#[must_use]
273pub fn peek_name_with<E: IdentityEnv>(
274    env: &E,
275    config: &ConfigSet,
276    role: IdentRole,
277) -> Option<String> {
278    match read_git_identity_name_env_with(env, role.env_name_key()) {
279        GitIdentityNameEnv::Set(s) => {
280            if s.is_empty() {
281                None
282            } else {
283                Some(s)
284            }
285        }
286        GitIdentityNameEnv::Unset => {
287            if let Some(v) = config.get(role.config_name_key()) {
288                let t = v.trim();
289                if !t.is_empty() {
290                    return Some(t.to_owned());
291                }
292            }
293            let d = ident_default_name(config);
294            if d.is_empty() {
295                None
296            } else {
297                Some(d)
298            }
299        }
300    }
301}
302
303/// Resolve name for a role when creating commits.
304pub fn resolve_name_with<E: IdentityEnv>(
305    env: &E,
306    config: &ConfigSet,
307    role: IdentRole,
308) -> Result<String, IdentityError> {
309    let email = resolve_email_inner_with(env, config, role, true)?;
310
311    let name: String = match read_git_identity_name_env_with(env, role.env_name_key()) {
312        GitIdentityNameEnv::Set(s) => s,
313        GitIdentityNameEnv::Unset => {
314            if let Some(v) = config.get(role.config_name_key()) {
315                let t = v.trim();
316                if !t.is_empty() {
317                    t.to_owned()
318                } else {
319                    ident_default_name(config)
320                }
321            } else {
322                ident_default_name(config)
323            }
324        }
325    };
326
327    if name.is_empty() {
328        return Err(IdentityError::EmptyName { email, role });
329    }
330
331    if !ident_name_has_non_crud(&name) {
332        return Err(IdentityError::InvalidName { name });
333    }
334
335    Ok(name)
336}
337
338/// Committer name/email for reflog and other non-strict contexts: never errors; always has an email.
339#[must_use]
340pub fn resolve_loose_committer_parts_with<E: IdentityEnv>(
341    env: &E,
342    config: &ConfigSet,
343) -> (String, String) {
344    let name = match read_git_identity_name_env_with(env, "GIT_COMMITTER_NAME") {
345        GitIdentityNameEnv::Set(s) => {
346            if s.is_empty() {
347                None
348            } else {
349                Some(s)
350            }
351        }
352        GitIdentityNameEnv::Unset => read_git_identity_name_from_env_with(env, "GIT_AUTHOR_NAME"),
353    }
354    .or_else(|| {
355        config
356            .get("committer.name")
357            .map(|s| s.trim().to_owned())
358            .filter(|s| !s.is_empty())
359    })
360    .or_else(|| {
361        config
362            .get("user.name")
363            .map(|s| s.trim().to_owned())
364            .filter(|s| !s.is_empty())
365    })
366    .or_else(|| {
367        let d = ident_default_name(config);
368        if d.is_empty() {
369            None
370        } else {
371            Some(d)
372        }
373    })
374    .unwrap_or_else(|| "Unknown".to_owned());
375
376    let email = env
377        .var("GIT_COMMITTER_EMAIL")
378        .map(|s| s.trim().to_owned())
379        .filter(|s| !s.is_empty())
380        .or_else(|| {
381            env.var("GIT_AUTHOR_EMAIL")
382                .map(|s| s.trim().to_owned())
383                .filter(|s| !s.is_empty())
384        })
385        .or_else(|| {
386            config
387                .get("committer.email")
388                .map(|s| s.trim().to_owned())
389                .filter(|s| !s.is_empty())
390        })
391        .or_else(|| {
392            config
393                .get("user.email")
394                .map(|s| s.trim().to_owned())
395                .filter(|s| !s.is_empty())
396        })
397        .or_else(|| {
398            env.var("EMAIL")
399                .map(|s| s.trim().to_owned())
400                .filter(|s| !s.is_empty())
401        })
402        .unwrap_or_else(|| synthetic_email_with(env));
403
404    (name, email)
405}