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;
9use time::OffsetDateTime;
10
11use crate::commit::assemble_identity;
12#[cfg(unix)]
13use crate::commit_encoding::decode_bytes;
14use crate::config::ConfigSet;
15use crate::ident::{parse_signature, Signature};
16use crate::ident_config::{env_user_name, ident_default_name};
17
18/// Environment access used for identity resolution.
19pub trait IdentityEnv {
20    /// Return a UTF-8 environment variable, if it exists and is valid Unicode.
21    fn var(&self, key: &str) -> Option<String>;
22
23    /// Return a raw environment variable value, preserving non-UTF-8 bytes on Unix.
24    fn var_os(&self, key: &str) -> Option<OsString>;
25}
26
27/// Whether `GIT_AUTHOR_NAME` / `GIT_COMMITTER_NAME` is unset vs set (possibly empty).
28///
29/// Git treats a set-but-empty value as an explicit override: it must not fall through
30/// to `user.name` or passwd/GECOS fallback (`t7518-ident-corner-cases`).
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub enum GitIdentityNameEnv {
33    /// Variable is not present in the environment.
34    Unset,
35    /// Present after trimming whitespace (may be `""`).
36    Set(String),
37}
38
39/// Author vs committer for `GIT_*` / `author.*` / `committer.*` lookup.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum IdentRole {
42    /// Author identity.
43    Author,
44    /// Committer identity.
45    Committer,
46}
47
48impl IdentRole {
49    fn env_name_key(self) -> &'static str {
50        match self {
51            IdentRole::Author => "GIT_AUTHOR_NAME",
52            IdentRole::Committer => "GIT_COMMITTER_NAME",
53        }
54    }
55
56    fn env_email_key(self) -> &'static str {
57        match self {
58            IdentRole::Author => "GIT_AUTHOR_EMAIL",
59            IdentRole::Committer => "GIT_COMMITTER_EMAIL",
60        }
61    }
62
63    fn config_name_key(self) -> &'static str {
64        match self {
65            IdentRole::Author => "author.name",
66            IdentRole::Committer => "committer.name",
67        }
68    }
69
70    fn config_email_key(self) -> &'static str {
71        match self {
72            IdentRole::Author => "author.email",
73            IdentRole::Committer => "committer.email",
74        }
75    }
76
77    /// Environment variable that overrides this role's timestamp
78    /// (`GIT_AUTHOR_DATE` / `GIT_COMMITTER_DATE`).
79    #[must_use]
80    pub fn env_date_key(self) -> &'static str {
81        match self {
82            IdentRole::Author => "GIT_AUTHOR_DATE",
83            IdentRole::Committer => "GIT_COMMITTER_DATE",
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. User-facing setup guidance is rendered by `grit-cli`.
104    #[error("email auto-detection is disabled (user.useConfigOnly) and no configured email is available")]
105    AutoDetectionDisabled {
106        /// Identity role being resolved.
107        role: IdentRole,
108    },
109    /// No configured or environment email is available and auto-detection is not allowed.
110    #[error("no configured email is available for auto-detection")]
111    MissingConfiguredEmail {
112        /// Identity role being resolved.
113        role: IdentRole,
114    },
115    /// Git rejects empty ident names.
116    #[error("empty ident name (for <{email}>) not allowed")]
117    EmptyName {
118        /// Email address associated with the attempted identity.
119        email: String,
120        /// Identity role being resolved.
121        role: IdentRole,
122    },
123    /// Git rejects names containing only "crud" characters.
124    #[error("invalid ident name: '{name}'")]
125    InvalidName {
126        /// Rejected name.
127        name: String,
128    },
129    /// The role's date override (`GIT_AUTHOR_DATE` / `GIT_COMMITTER_DATE`) is not a date Git accepts.
130    #[error("invalid date '{date}'")]
131    InvalidDate {
132        /// The rejected override value.
133        date: String,
134        /// Identity role being resolved.
135        role: IdentRole,
136    },
137    /// The resolved name or email cannot be written into an identity line.
138    #[error(transparent)]
139    InvalidSignature(#[from] crate::ident::SignatureError),
140}
141
142/// Read a `GIT_*_NAME` variable like Git's `getenv`: unset vs set, preserving explicit empty.
143#[must_use]
144pub fn read_git_identity_name_env_with<E: IdentityEnv>(env: &E, key: &str) -> GitIdentityNameEnv {
145    let Some(os) = env.var_os(key) else {
146        return GitIdentityNameEnv::Unset;
147    };
148    #[cfg(unix)]
149    {
150        use std::os::unix::ffi::OsStrExt;
151        let bytes = os.as_bytes();
152        let s = if std::str::from_utf8(bytes).is_ok() {
153            String::from_utf8_lossy(bytes).into_owned()
154        } else {
155            decode_bytes(Some("ISO8859-1"), bytes)
156        };
157        GitIdentityNameEnv::Set(s.trim().to_owned())
158    }
159    #[cfg(not(unix))]
160    {
161        let s = os.to_str().map(|t| t.trim().to_owned()).unwrap_or_default();
162        GitIdentityNameEnv::Set(s)
163    }
164}
165
166/// The value of a `GIT_*_NAME` variable when it is set (possibly to `""`).
167fn name_env_with<E: IdentityEnv>(env: &E, key: &str) -> Option<String> {
168    match read_git_identity_name_env_with(env, key) {
169        GitIdentityNameEnv::Set(name) => Some(name),
170        GitIdentityNameEnv::Unset => None,
171    }
172}
173
174/// `value` trimmed, or [`None`] when it is missing or blank.
175fn non_blank(value: Option<String>) -> Option<String> {
176    value.map(|v| v.trim().to_owned()).filter(|v| !v.is_empty())
177}
178
179fn use_config_only(config: &ConfigSet) -> bool {
180    matches!(config.get_bool("user.useConfigOnly"), Some(Ok(true)))
181}
182
183fn config_mail_given(config: &ConfigSet) -> bool {
184    ["user.email", "author.email", "committer.email"]
185        .iter()
186        .any(|key| non_blank(config.get(key)).is_some())
187}
188
189fn ident_name_has_non_crud(name: &str) -> bool {
190    name.chars().any(|c| {
191        let o = c as u32;
192        !(o <= 32
193            || c == ','
194            || c == ':'
195            || c == ';'
196            || c == '<'
197            || c == '>'
198            || c == '"'
199            || c == '\\'
200            || c == '\'')
201    })
202}
203
204fn synthetic_email_with<E: IdentityEnv>(env: &E) -> String {
205    let user = env_user_name(env);
206    let host = env.var("HOSTNAME").unwrap_or_else(|| "unknown".to_owned());
207    let domain = if host.contains('.') {
208        host
209    } else {
210        format!("{host}.(none)")
211    };
212    format!("{user}@{domain}")
213}
214
215/// Resolve email for a role when creating commits (honors `user.useConfigOnly`).
216///
217/// Looks at `GIT_<ROLE>_EMAIL`, `<role>.email`, `user.email`, then (unless
218/// `user.useConfigOnly` is set without any configured email) `EMAIL`. Never synthesizes
219/// an address from the user and host names.
220///
221/// # Errors
222///
223/// [`IdentityError::AutoDetectionDisabled`] when `user.useConfigOnly` blocks the `EMAIL`
224/// fallback, [`IdentityError::MissingConfiguredEmail`] when no source provides an email.
225pub fn resolve_email_with<E: IdentityEnv>(
226    env: &E,
227    config: &ConfigSet,
228    role: IdentRole,
229) -> Result<String, IdentityError> {
230    let configured = non_blank(env.var(role.env_email_key()))
231        .or_else(|| non_blank(config.get(role.config_email_key())))
232        .or_else(|| non_blank(config.get("user.email")));
233    if let Some(email) = configured {
234        return Ok(email);
235    }
236    if use_config_only(config) && !config_mail_given(config) {
237        return Err(IdentityError::AutoDetectionDisabled { role });
238    }
239    non_blank(env.var("EMAIL")).ok_or(IdentityError::MissingConfiguredEmail { role })
240}
241
242/// Resolve name for a role when creating commits.
243///
244/// A set `GIT_<ROLE>_NAME` wins even when empty; otherwise `<role>.name`, then Git's
245/// default name (`user.name`, or the system user name).
246///
247/// # Errors
248///
249/// Fails like [`resolve_email_with`] when no email is available, with
250/// [`IdentityError::EmptyName`] for an empty name and [`IdentityError::InvalidName`] for a
251/// name made only of characters Git strips.
252pub fn resolve_name_with<E: IdentityEnv>(
253    env: &E,
254    config: &ConfigSet,
255    role: IdentRole,
256) -> Result<String, IdentityError> {
257    let email = resolve_email_with(env, config, role)?;
258
259    let name = name_env_with(env, role.env_name_key()).unwrap_or_else(|| {
260        non_blank(config.get(role.config_name_key()))
261            .unwrap_or_else(|| ident_default_name(config, env))
262    });
263
264    if name.is_empty() {
265        return Err(IdentityError::EmptyName { email, role });
266    }
267
268    if !ident_name_has_non_crud(&name) {
269        return Err(IdentityError::InvalidName { name });
270    }
271
272    Ok(name)
273}
274
275/// Resolve the full identity line (`Name <email> <epoch> <offset>`) for `role` when
276/// creating a commit, tag or notes commit.
277///
278/// Name and email come from [`resolve_name_with`] and [`resolve_email_with`], so this fails
279/// exactly when they do. The timestamp is the role's [`IdentRole::env_date_key`] variable
280/// when set and non-blank, otherwise `now`.
281///
282/// # Parameters
283///
284/// - `env`: environment supplying `GIT_*` identity and date variables.
285/// - `config`: configuration supplying `user.*`, `author.*` and `committer.*`.
286/// - `role`: whether to resolve the author or the committer.
287/// - `now`: timestamp (with its UTC offset) used when no date override is set.
288///
289/// # Errors
290///
291/// Returns [`IdentityError`] when no usable name or email is configured.
292pub fn resolve_identity_with<E: IdentityEnv>(
293    env: &E,
294    config: &ConfigSet,
295    role: IdentRole,
296    now: OffsetDateTime,
297) -> Result<String, IdentityError> {
298    let name = resolve_name_with(env, config, role)?;
299    let email = resolve_email_with(env, config, role)?;
300    let date = env
301        .var(role.env_date_key())
302        .filter(|d| !d.trim().is_empty());
303    Ok(assemble_identity(&name, &email, date.as_deref(), now))
304}
305
306/// Resolve the identity for `role` as a typed [`Signature`] when creating a commit, tag, stash,
307/// or notes commit.
308///
309/// Resolution is the same as [`resolve_identity_with`]; the date override must also be a date
310/// Git accepts, and the name and email must be writable into an object.
311///
312/// # Parameters
313///
314/// - `env`: environment supplying `GIT_*` identity and date variables.
315/// - `config`: configuration supplying `user.*`, `author.*` and `committer.*`.
316/// - `role`: whether to resolve the author or the committer.
317/// - `now`: timestamp (with its UTC offset) used when no date override is set.
318///
319/// # Errors
320///
321/// The [`resolve_identity_with`] errors, plus [`IdentityError::InvalidDate`] for an unparsable
322/// date override and [`IdentityError::InvalidSignature`] for a name or email holding `<`, `>`,
323/// or a control character.
324pub fn resolve_signature_with<E: IdentityEnv>(
325    env: &E,
326    config: &ConfigSet,
327    role: IdentRole,
328    now: OffsetDateTime,
329) -> Result<Signature, IdentityError> {
330    let name = resolve_name_with(env, config, role)?;
331    let email = resolve_email_with(env, config, role)?;
332    let date = env
333        .var(role.env_date_key())
334        .filter(|d| !d.trim().is_empty());
335    let line = assemble_identity(&name, &email, date.as_deref(), now);
336    let time = parse_signature(&line)
337        .ok_or_else(|| IdentityError::InvalidDate {
338            date: date.unwrap_or_default(),
339            role,
340        })?
341        .time;
342    Ok(Signature::new(name, email, time)?)
343}
344
345/// Best-effort committer identity line (`Name <email> <epoch> <offset>`) for reflog entries.
346///
347/// Never fails: the name comes from `GIT_COMMITTER_NAME` (or, when that is unset,
348/// `GIT_AUTHOR_NAME`), `committer.name`, `user.name`, the system user name, then
349/// `"Unknown"`; the email from `GIT_COMMITTER_EMAIL`, `GIT_AUTHOR_EMAIL`, `committer.email`,
350/// `user.email`, `EMAIL`, then a synthesized `user@host` address. It always stamps `now`
351/// (callers that want `GIT_COMMITTER_DATE` honoured fold it into `now`).
352///
353/// # Parameters
354///
355/// - `env`: environment supplying `GIT_*` identity variables.
356/// - `config`: configuration supplying `committer.*` and `user.*`.
357/// - `now`: timestamp (with its UTC offset) recorded on the line.
358#[must_use]
359pub fn resolve_loose_committer_identity_with<E: IdentityEnv>(
360    env: &E,
361    config: &ConfigSet,
362    now: OffsetDateTime,
363) -> String {
364    let name = name_env_with(env, "GIT_COMMITTER_NAME")
365        .or_else(|| name_env_with(env, "GIT_AUTHOR_NAME"))
366        .filter(|name| !name.is_empty())
367        .or_else(|| non_blank(config.get("committer.name")))
368        .or_else(|| non_blank(config.get("user.name")))
369        .or_else(|| Some(ident_default_name(config, env)).filter(|name| !name.is_empty()))
370        .unwrap_or_else(|| "Unknown".to_owned());
371    let email = non_blank(env.var("GIT_COMMITTER_EMAIL"))
372        .or_else(|| non_blank(env.var("GIT_AUTHOR_EMAIL")))
373        .or_else(|| non_blank(config.get("committer.email")))
374        .or_else(|| non_blank(config.get("user.email")))
375        .or_else(|| non_blank(env.var("EMAIL")))
376        .unwrap_or_else(|| synthetic_email_with(env));
377    assemble_identity(&name, &email, None, now)
378}
379
380#[cfg(test)]
381mod tests {
382    use std::collections::HashMap;
383    use std::ffi::OsString;
384
385    use super::*;
386
387    struct MapEnv(HashMap<String, String>);
388
389    impl IdentityEnv for MapEnv {
390        fn var(&self, key: &str) -> Option<String> {
391            self.0.get(key).cloned()
392        }
393
394        fn var_os(&self, key: &str) -> Option<OsString> {
395            self.var(key).map(OsString::from)
396        }
397    }
398
399    #[test]
400    fn strict_email_resolution_fails_without_configured_sources() {
401        let env = MapEnv(HashMap::from([("USER".to_owned(), "alice".to_owned())]));
402        let config = ConfigSet::default();
403        let err = resolve_email_with(&env, &config, IdentRole::Author).expect_err("strict");
404        assert_eq!(
405            err,
406            IdentityError::MissingConfiguredEmail {
407                role: IdentRole::Author
408            }
409        );
410    }
411
412    #[test]
413    fn strict_email_falls_back_to_email_env_unless_config_only() {
414        let env = vars(&[("EMAIL", " mail@example.com ")]);
415        let mut config = ConfigSet::default();
416        assert_eq!(
417            resolve_email_with(&env, &config, IdentRole::Author).expect("EMAIL"),
418            "mail@example.com"
419        );
420        config
421            .add_command_override("user.useConfigOnly", "true")
422            .expect("override");
423        assert_eq!(
424            resolve_email_with(&env, &config, IdentRole::Author).expect_err("config only"),
425            IdentityError::AutoDetectionDisabled {
426                role: IdentRole::Author
427            }
428        );
429        config
430            .add_command_override("author.email", "  ")
431            .expect("override");
432        config
433            .add_command_override("committer.email", "c@example.com")
434            .expect("override");
435        assert_eq!(
436            resolve_email_with(&env, &config, IdentRole::Author).expect("mail given"),
437            "mail@example.com",
438            "a configured email for any role re-enables the EMAIL fallback"
439        );
440    }
441
442    #[test]
443    fn strict_name_keeps_set_but_empty_env_override() {
444        let env = vars(&[("GIT_AUTHOR_NAME", "  "), ("GIT_AUTHOR_EMAIL", "a@b.c")]);
445        let mut config = ConfigSet::default();
446        config
447            .add_command_override("user.name", "Alice")
448            .expect("override");
449        assert_eq!(
450            resolve_name_with(&env, &config, IdentRole::Author).expect_err("empty"),
451            IdentityError::EmptyName {
452                email: "a@b.c".to_owned(),
453                role: IdentRole::Author
454            }
455        );
456        let env = vars(&[("GIT_AUTHOR_EMAIL", "a@b.c")]);
457        config
458            .add_command_override("author.name", " Author Name ")
459            .expect("override");
460        assert_eq!(
461            resolve_name_with(&env, &config, IdentRole::Author).expect("config"),
462            "Author Name"
463        );
464        assert_eq!(
465            resolve_name_with(
466                &vars(&[("GIT_AUTHOR_EMAIL", "a@b.c"), ("GIT_AUTHOR_NAME", ",;")]),
467                &config,
468                IdentRole::Author
469            )
470            .expect_err("crud"),
471            IdentityError::InvalidName {
472                name: ",;".to_owned()
473            }
474        );
475    }
476
477    #[test]
478    fn strict_email_honors_user_email_config() {
479        let env = MapEnv(HashMap::new());
480        let mut config = ConfigSet::default();
481        config
482            .add_command_override("user.email", "me@example.com")
483            .expect("override");
484        let email = resolve_email_with(&env, &config, IdentRole::Committer).expect("configured");
485        assert_eq!(email, "me@example.com");
486    }
487
488    fn vars(pairs: &[(&str, &str)]) -> MapEnv {
489        MapEnv(
490            pairs
491                .iter()
492                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
493                .collect(),
494        )
495    }
496
497    fn fixed_now() -> OffsetDateTime {
498        OffsetDateTime::from_unix_timestamp(1_112_911_993)
499            .expect("epoch")
500            .to_offset(time::UtcOffset::from_hms(-7, 0, 0).expect("offset"))
501    }
502
503    #[test]
504    fn identity_line_uses_now_without_date_override() {
505        let env = vars(&[
506            ("GIT_AUTHOR_NAME", "A U Thor"),
507            ("GIT_AUTHOR_EMAIL", "author@example.com"),
508        ]);
509        let line =
510            resolve_identity_with(&env, &ConfigSet::default(), IdentRole::Author, fixed_now())
511                .expect("identity");
512        assert_eq!(line, "A U Thor <author@example.com> 1112911993 -0700");
513    }
514
515    #[test]
516    fn identity_line_honours_only_its_own_role_date() {
517        let env = vars(&[
518            ("GIT_AUTHOR_NAME", "A U Thor"),
519            ("GIT_AUTHOR_EMAIL", "author@example.com"),
520            ("GIT_COMMITTER_NAME", "C O Mitter"),
521            ("GIT_COMMITTER_EMAIL", "committer@example.com"),
522            ("GIT_COMMITTER_DATE", "@1234567890 +0100"),
523        ]);
524        let config = ConfigSet::default();
525        let committer = resolve_identity_with(&env, &config, IdentRole::Committer, fixed_now())
526            .expect("committer");
527        assert_eq!(
528            committer,
529            "C O Mitter <committer@example.com> 1234567890 +0100"
530        );
531        let author =
532            resolve_identity_with(&env, &config, IdentRole::Author, fixed_now()).expect("author");
533        assert_eq!(author, "A U Thor <author@example.com> 1112911993 -0700");
534    }
535
536    #[test]
537    fn identity_line_treats_blank_date_override_as_unset() {
538        let env = vars(&[
539            ("GIT_AUTHOR_NAME", "A U Thor"),
540            ("GIT_AUTHOR_EMAIL", "author@example.com"),
541            ("GIT_AUTHOR_DATE", "  "),
542        ]);
543        let line =
544            resolve_identity_with(&env, &ConfigSet::default(), IdentRole::Author, fixed_now())
545                .expect("identity");
546        assert_eq!(line, "A U Thor <author@example.com> 1112911993 -0700");
547    }
548
549    #[test]
550    fn identity_line_fails_like_strict_email_resolution() {
551        let env = vars(&[("USER", "alice"), ("GIT_AUTHOR_NAME", "Alice")]);
552        let err =
553            resolve_identity_with(&env, &ConfigSet::default(), IdentRole::Author, fixed_now())
554                .expect_err("no configured email");
555        assert_eq!(
556            err,
557            IdentityError::MissingConfiguredEmail {
558                role: IdentRole::Author
559            }
560        );
561    }
562
563    #[test]
564    fn signature_matches_identity_line_and_honours_role_date() {
565        let env = vars(&[
566            ("GIT_COMMITTER_NAME", "C O Mitter"),
567            ("GIT_COMMITTER_EMAIL", "committer@example.com"),
568            ("GIT_COMMITTER_DATE", "@1234567890 +0100"),
569        ]);
570        let config = ConfigSet::default();
571        let sig = resolve_signature_with(&env, &config, IdentRole::Committer, fixed_now())
572            .expect("signature");
573        assert_eq!(sig.name, "C O Mitter");
574        assert_eq!(sig.email, "committer@example.com");
575        assert_eq!(
576            sig.header_line(),
577            resolve_identity_with(&env, &config, IdentRole::Committer, fixed_now()).expect("line")
578        );
579    }
580
581    #[test]
582    fn signature_rejects_unparsable_date_and_unwritable_email() {
583        let env = vars(&[
584            ("GIT_AUTHOR_NAME", "A U Thor"),
585            ("GIT_AUTHOR_EMAIL", "author@example.com"),
586            ("GIT_AUTHOR_DATE", "not a date"),
587        ]);
588        assert_eq!(
589            resolve_signature_with(&env, &ConfigSet::default(), IdentRole::Author, fixed_now()),
590            Err(IdentityError::InvalidDate {
591                date: "not a date".to_owned(),
592                role: IdentRole::Author,
593            })
594        );
595        let env = vars(&[
596            ("GIT_AUTHOR_NAME", "A U Thor"),
597            ("GIT_AUTHOR_EMAIL", "a@b> x"),
598        ]);
599        assert!(matches!(
600            resolve_signature_with(&env, &ConfigSet::default(), IdentRole::Author, fixed_now()),
601            Err(IdentityError::InvalidSignature(_))
602        ));
603        let env = vars(&[("USER", "alice"), ("GIT_AUTHOR_NAME", "Alice")]);
604        assert_eq!(
605            resolve_signature_with(&env, &ConfigSet::default(), IdentRole::Author, fixed_now()),
606            Err(IdentityError::MissingConfiguredEmail {
607                role: IdentRole::Author
608            })
609        );
610    }
611
612    #[test]
613    fn loose_committer_identity_never_fails_and_ignores_date_override() {
614        let env = vars(&[
615            ("USER", "alice"),
616            ("HOSTNAME", "host.example"),
617            ("GIT_COMMITTER_DATE", "@1234567890 +0100"),
618        ]);
619        let mut config = ConfigSet::default();
620        config
621            .add_command_override("user.name", "Alice")
622            .expect("override");
623        let line = resolve_loose_committer_identity_with(&env, &config, fixed_now());
624        assert_eq!(line, "Alice <alice@host.example> 1112911993 -0700");
625    }
626
627    #[test]
628    fn loose_committer_identity_prefers_committer_then_author_env() {
629        let env = vars(&[
630            ("GIT_COMMITTER_NAME", "C O Mitter"),
631            ("GIT_AUTHOR_NAME", "A U Thor"),
632            ("GIT_COMMITTER_EMAIL", " "),
633            ("GIT_AUTHOR_EMAIL", "author@example.com"),
634        ]);
635        let line = resolve_loose_committer_identity_with(&env, &ConfigSet::default(), fixed_now());
636        assert_eq!(line, "C O Mitter <author@example.com> 1112911993 -0700");
637
638        let env = vars(&[("GIT_AUTHOR_NAME", "A U Thor"), ("EMAIL", "e@example.com")]);
639        let line = resolve_loose_committer_identity_with(&env, &ConfigSet::default(), fixed_now());
640        assert_eq!(line, "A U Thor <e@example.com> 1112911993 -0700");
641    }
642
643    #[test]
644    fn loose_committer_identity_empty_committer_name_skips_author_env() {
645        let env = vars(&[
646            ("GIT_COMMITTER_NAME", ""),
647            ("GIT_AUTHOR_NAME", "A U Thor"),
648            ("USER", "alice"),
649            ("HOSTNAME", "box"),
650        ]);
651        let mut config = ConfigSet::default();
652        config
653            .add_command_override("user.name", "User Name")
654            .expect("override");
655        config
656            .add_command_override("committer.name", " Committer Name ")
657            .expect("override");
658        config
659            .add_command_override("user.email", "user@example.com")
660            .expect("override");
661        let line = resolve_loose_committer_identity_with(&env, &config, fixed_now());
662        assert_eq!(line, "Committer Name <user@example.com> 1112911993 -0700");
663
664        let mut config = ConfigSet::default();
665        config
666            .add_command_override("user.name", "User Name")
667            .expect("override");
668        let line = resolve_loose_committer_identity_with(&env, &config, fixed_now());
669        assert_eq!(line, "User Name <alice@box.(none)> 1112911993 -0700");
670    }
671
672    #[test]
673    fn env_date_key_matches_role() {
674        assert_eq!(IdentRole::Author.env_date_key(), "GIT_AUTHOR_DATE");
675        assert_eq!(IdentRole::Committer.env_date_key(), "GIT_COMMITTER_DATE");
676    }
677}