Skip to main content

repon_core/
environment.rs

1//! The environment contract: an Entity's already-computed git facts, turned into
2//! the set-or-unset variable pairs a child (a Launcher or an Action step)
3//! receives. Returns data only: no argv, no shell mode, no terminal content, and
4//! nothing here spawns anything.
5//!
6//! See `docs/spec/core-api.md`'s "The environment contract", `docs/spec/config.md`'s
7//! table of the same name, and [ADR 0018](https://github.com/paulchiu/repon/blob/main/docs/adr/0018-an-action-is-a-fanout-of-pty-backed-steps.md)
8//! and [ADR 0019](https://github.com/paulchiu/repon/blob/main/docs/adr/0019-a-detached-head-is-a-shape-of-head-not-a-worktree-state.md)
9//! for why an unset name must never carry an empty string and why a detached
10//! HEAD must never leak an object id into the branch slot.
11
12use crate::cell::Settled;
13use crate::entity::{DefaultBranch, EntityState, Head, Kind};
14
15const REPON_REPO_PATH: &str = "REPON_REPO_PATH";
16const REPON_REPO_NAME: &str = "REPON_REPO_NAME";
17const REPON_COMMON_DIR: &str = "REPON_COMMON_DIR";
18const REPON_KIND: &str = "REPON_KIND";
19const REPON_BRANCH: &str = "REPON_BRANCH";
20const REPON_HEAD: &str = "REPON_HEAD";
21const REPON_DEFAULT_BRANCH: &str = "REPON_DEFAULT_BRANCH";
22const REPON_ACTION: &str = "REPON_ACTION";
23
24/// The eight `REPON_` variable names, in the order `docs/spec/config.md`'s table
25/// lists them. Read by [`environment`] itself for nothing but this array's own
26/// length; the value each name carries still takes its own match against the
27/// Entity, since a name alone cannot say how to derive one. Also read by this
28/// module's own tests, so a name dropped from [`environment`]'s construction and
29/// a name dropped from this array are the same edit rather than two that could
30/// drift apart.
31const REPON_ENV_VAR_NAMES: [&str; 8] = [
32    REPON_REPO_PATH,
33    REPON_REPO_NAME,
34    REPON_COMMON_DIR,
35    REPON_KIND,
36    REPON_BRANCH,
37    REPON_HEAD,
38    REPON_DEFAULT_BRANCH,
39    REPON_ACTION,
40];
41
42/// The terminal-prompt suppression variable, force-set for every child
43/// regardless of shape: a step that would otherwise block on a credential
44/// prompt behind the alternate screen is a hang with no visible cause
45/// (ADR 0018).
46const GIT_TERMINAL_PROMPT: &str = "GIT_TERMINAL_PROMPT";
47
48/// The fifteen git local environment variables Repon unsets from every child,
49/// exactly `git rev-parse --local-env-vars` on git 2.50.1 (`docs/spec/config.md`).
50/// One array read by both [`environment`] and this module's own tests, so a
51/// variable dropped from one cannot silently drop from the other, and the count
52/// of fifteen is asserted against this array's own length rather than written
53/// twice.
54const GIT_LOCAL_ENV_VARS: [&str; 15] = [
55    "GIT_ALTERNATE_OBJECT_DIRECTORIES",
56    "GIT_CONFIG",
57    "GIT_CONFIG_PARAMETERS",
58    "GIT_CONFIG_COUNT",
59    "GIT_OBJECT_DIRECTORY",
60    "GIT_DIR",
61    "GIT_WORK_TREE",
62    "GIT_IMPLICIT_WORK_TREE",
63    "GIT_GRAFT_FILE",
64    "GIT_INDEX_FILE",
65    "GIT_NO_REPLACE_OBJECTS",
66    "GIT_REPLACE_REF_BASE",
67    "GIT_PREFIX",
68    "GIT_SHALLOW_FILE",
69    "GIT_COMMON_DIR",
70];
71
72/// `entity`'s environment contract: the set-or-unset pairs a Launcher or an
73/// Action step's child receives. `Some` sets a variable, `None` unsets it, so an
74/// absent name can never be misread as one set to the empty string.
75///
76/// Covers exactly the eight `REPON_` variables and all fifteen of git's local
77/// environment variables (`docs/spec/config.md`), plus `GIT_TERMINAL_PROMPT`,
78/// force-set on every call regardless of shape or `action`. `action` names the
79/// running Action; `None` is `REPON_ACTION`'s own unset case, for a Launcher.
80///
81/// Destructures `entity` exhaustively so a Cell added to [`EntityState`] later
82/// fails to compile here rather than silently never reaching the environment.
83pub fn environment(entity: &EntityState, action: Option<&str>) -> Vec<(String, Option<String>)> {
84    let EntityState {
85        key,
86        name,
87        common_dir,
88        kind,
89        branch,
90        sync: _,
91        base: _,
92        dirty: _,
93        state: _,
94        default_branch,
95        diagnostics: _,
96        last_action: _,
97        presence: _,
98        excluded: _,
99        in_progress_operation: _,
100        recent_commits: _,
101    } = entity;
102
103    let (repon_branch, repon_head) = branch_and_head(branch.settled());
104
105    let repon_pairs: [(&str, Option<String>); 8] = [
106        (
107            REPON_REPO_PATH,
108            Some(key.path().to_string_lossy().into_owned()),
109        ),
110        (REPON_REPO_NAME, Some(name.to_string())),
111        (
112            REPON_COMMON_DIR,
113            Some(common_dir.to_string_lossy().into_owned()),
114        ),
115        (REPON_KIND, Some(kind_name(*kind).to_string())),
116        (REPON_BRANCH, repon_branch),
117        (REPON_HEAD, repon_head),
118        (
119            REPON_DEFAULT_BRANCH,
120            default_branch_name(default_branch.settled()),
121        ),
122        (REPON_ACTION, action.map(str::to_string)),
123    ];
124    // Ties this construction to `REPON_ENV_VAR_NAMES`, the same array the count
125    // and presence tests read, so the two cannot drift apart unnoticed.
126    debug_assert_eq!(
127        repon_pairs.each_ref().map(|(name, _)| *name),
128        REPON_ENV_VAR_NAMES,
129        "the environment contract's Repon variable names drifted from REPON_ENV_VAR_NAMES"
130    );
131
132    let mut pairs: Vec<(String, Option<String>)> = repon_pairs
133        .into_iter()
134        .map(|(name, value)| (name.to_string(), value))
135        .collect();
136    pairs.push((GIT_TERMINAL_PROMPT.to_string(), Some("0".to_string())));
137    pairs.extend(
138        GIT_LOCAL_ENV_VARS
139            .iter()
140            .map(|name| (name.to_string(), None)),
141    );
142    pairs
143}
144
145/// `kind`'s lower-case name, `docs/spec/config.md`'s `REPON_KIND` values. No
146/// wildcard arm, so a fourth `Kind` fails to compile here rather than silently
147/// falling through unnamed.
148fn kind_name(kind: Kind) -> &'static str {
149    match kind {
150        Kind::Repo => "repo",
151        Kind::Worktree => "worktree",
152        Kind::Submodule => "submodule",
153    }
154}
155
156/// `branch`'s contribution to `REPON_BRANCH` and `REPON_HEAD`. `Head::Branch`
157/// sets both, its name and its own resolved commit; `Head::Detached` sets only
158/// `REPON_HEAD`, since a detached row's branch slot must never carry an object
159/// id ([ADR 0019](https://github.com/paulchiu/repon/blob/main/docs/adr/0019-a-detached-head-is-a-shape-of-head-not-a-worktree-state.md));
160/// `Head::Unborn` sets only `REPON_BRANCH`, since there is no commit yet.
161/// Anything short of `Known` (`Unknown`, `Failed`, `NotApplicable`, or never yet
162/// probed) unsets both, per `docs/spec/config.md`'s rule that an unresolved
163/// value is unset rather than empty.
164fn branch_and_head(settled: Option<&Settled<Head>>) -> (Option<String>, Option<String>) {
165    let Some(settled) = settled else {
166        return (None, None);
167    };
168    match settled {
169        Settled::Known {
170            value,
171            at: _,
172            stale: _,
173        } => match value {
174            Head::Branch { name, commit } => (Some(name.to_string()), Some(commit.to_string())),
175            Head::Detached(commit) => (None, Some(commit.to_string())),
176            Head::Unborn(name) => (Some(name.to_string()), None),
177        },
178        Settled::Unknown(_) | Settled::Failed(_) | Settled::NotApplicable => (None, None),
179    }
180}
181
182/// `default_branch`'s contribution to `REPON_DEFAULT_BRANCH`: the resolved name
183/// when `Known`, unset for every other shape, `NotApplicable` included, so
184/// `${REPON_DEFAULT_BRANCH:-main}` never substitutes a default branch
185/// `docs/spec/discovery.md` already records as known-wrong for a Submodule.
186fn default_branch_name(settled: Option<&Settled<DefaultBranch>>) -> Option<String> {
187    match settled? {
188        Settled::Known {
189            value,
190            at: _,
191            stale: _,
192        } => Some(value.name().to_string()),
193        Settled::Unknown(_) | Settled::Failed(_) | Settled::NotApplicable => None,
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use std::path::Path;
200    use std::sync::Arc;
201
202    use super::*;
203    use crate::cell::{Generation, Timestamp, Unknown};
204    use crate::entity::EntityKey;
205
206    /// A hex string long enough to be a real object id, distinguishable per call
207    /// so a test can tell two different commits apart.
208    fn commit(hex_tail: &str) -> gix::ObjectId {
209        let hex = format!("{:0>40}", hex_tail);
210        gix::ObjectId::from_hex(hex.as_bytes()).expect("valid hex object id")
211    }
212
213    fn entity(
214        kind: Kind,
215        path: &str,
216        name: &str,
217        common_dir: &str,
218        head: Head,
219        default_branch: Settled<DefaultBranch>,
220    ) -> EntityState {
221        let mut entity = EntityState::new(
222            EntityKey::new(Arc::from(Path::new(path))),
223            Arc::from(name),
224            Arc::from(Path::new(common_dir)),
225            kind,
226        );
227        let generation = Generation::new(1);
228        entity.branch.settle(
229            generation,
230            Settled::Known {
231                value: head,
232                at: Timestamp::now(),
233                stale: false,
234            },
235        );
236        entity.default_branch.settle(generation, default_branch);
237        entity
238    }
239
240    fn known_default_branch(name: &str) -> Settled<DefaultBranch> {
241        Settled::Known {
242            value: DefaultBranch::new(Arc::from(name)),
243            at: Timestamp::now(),
244            stale: false,
245        }
246    }
247
248    fn find<'a>(pairs: &'a [(String, Option<String>)], name: &str) -> Option<&'a Option<String>> {
249        pairs
250            .iter()
251            .find(|(pair_name, _)| pair_name == name)
252            .map(|(_, value)| value)
253    }
254
255    fn git_unset_pairs() -> Vec<(String, Option<String>)> {
256        GIT_LOCAL_ENV_VARS
257            .iter()
258            .map(|name| (name.to_string(), None))
259            .collect()
260    }
261
262    /// `docs/spec/config.md`, read at test time from `CARGO_MANIFEST_DIR` rather
263    /// than with `include_str!`, matching the pattern `lib.rs`'s own
264    /// `public_surface_matches_glossary` test already uses for a file outside
265    /// this crate's own directory.
266    fn spec_config_md() -> String {
267        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
268        std::fs::read_to_string(manifest_dir.join("../../docs/spec/config.md"))
269            .expect("read docs/spec/config.md")
270    }
271
272    /// Every backtick-quoted token on `line`, in order.
273    fn backtick_tokens(line: &str) -> Vec<&str> {
274        line.split('`').skip(1).step_by(2).collect()
275    }
276
277    /// The `REPON_` names `docs/spec/config.md`'s "The environment contract"
278    /// table lists, one per table row, read from the row's own first
279    /// backtick-quoted cell so a value column's own backtick-quoted text (a
280    /// `REPON_KIND` value, say) is never mistaken for a variable name.
281    fn spec_repon_variable_names(spec: &str) -> Vec<String> {
282        let section = spec
283            .split("## The environment contract")
284            .nth(1)
285            .expect("\"The environment contract\" section is present")
286            .split("\n## ")
287            .next()
288            .expect("a following heading or end of file");
289        section
290            .lines()
291            .filter(|line| line.trim_start().starts_with('|'))
292            .filter_map(|line| backtick_tokens(line).first().copied())
293            .filter(|token| token.starts_with("REPON_"))
294            .map(str::to_string)
295            .collect()
296    }
297
298    /// The git local environment variable names `docs/spec/config.md` lists in
299    /// its own sentence naming them, read from that sentence rather than
300    /// transcribed a second time.
301    fn spec_git_local_env_var_names(spec: &str) -> Vec<String> {
302        let anchor =
303            "Repon unsets all fifteen of git's local environment variables from every child:";
304        let after = spec
305            .split(anchor)
306            .nth(1)
307            .expect("the git local env vars sentence is present");
308        let list = after.split('.').next().expect("a sentence terminator");
309        backtick_tokens(list)
310            .into_iter()
311            .map(str::to_string)
312            .collect()
313    }
314
315    /// Asserts `spec_names` and `array_names` name exactly the same set,
316    /// reporting the specific name either side lacks rather than only a count,
317    /// so a misspelling or a dropped entry fails with the offending name.
318    fn assert_names_match_the_spec(spec_names: &[String], array_names: &[String]) {
319        let missing_from_array: Vec<&String> = spec_names
320            .iter()
321            .filter(|name| !array_names.contains(name))
322            .collect();
323        let missing_from_spec: Vec<&String> = array_names
324            .iter()
325            .filter(|name| !spec_names.contains(name))
326            .collect();
327        assert!(
328            missing_from_array.is_empty(),
329            "named in docs/spec/config.md but missing from the array: {missing_from_array:?}"
330        );
331        assert!(
332            missing_from_spec.is_empty(),
333            "in the array but not named in docs/spec/config.md: {missing_from_spec:?}"
334        );
335    }
336
337    // Criterion 2: the counts are the claim, asserted against the one list both
338    // the production path and these tests read.
339
340    #[test]
341    fn exactly_eight_repon_variable_names_are_declared() {
342        assert_eq!(REPON_ENV_VAR_NAMES.len(), 8);
343    }
344
345    #[test]
346    fn exactly_fifteen_git_local_env_vars_are_declared() {
347        assert_eq!(GIT_LOCAL_ENV_VARS.len(), 15);
348    }
349
350    // The array-to-array tests above catch REPON_ENV_VAR_NAMES and
351    // GIT_LOCAL_ENV_VARS drifting from each other; they do nothing about both
352    // drifting together away from the design of record. These two read
353    // `docs/spec/config.md` itself as the independent source of truth.
354
355    #[test]
356    fn repon_env_var_names_match_the_spec_exactly() {
357        let spec = spec_config_md();
358        assert_names_match_the_spec(
359            &spec_repon_variable_names(&spec),
360            &REPON_ENV_VAR_NAMES
361                .iter()
362                .map(|name| name.to_string())
363                .collect::<Vec<_>>(),
364        );
365    }
366
367    #[test]
368    fn git_local_env_var_names_match_the_spec_exactly() {
369        let spec = spec_config_md();
370        assert_names_match_the_spec(
371            &spec_git_local_env_var_names(&spec),
372            &GIT_LOCAL_ENV_VARS
373                .iter()
374                .map(|name| name.to_string())
375                .collect::<Vec<_>>(),
376        );
377    }
378
379    #[test]
380    fn every_declared_repon_variable_is_present_in_the_output() {
381        let row = entity(
382            Kind::Worktree,
383            "/dev/repo",
384            "repo",
385            "/dev/repo/.git",
386            Head::Branch {
387                name: Arc::from("main"),
388                commit: commit("1"),
389            },
390            known_default_branch("origin/main"),
391        );
392
393        let pairs = environment(&row, None);
394
395        for expected in REPON_ENV_VAR_NAMES {
396            assert!(
397                find(&pairs, expected).is_some(),
398                "missing Repon variable: {expected}"
399            );
400        }
401    }
402
403    #[test]
404    fn every_git_local_env_var_is_present_and_unset() {
405        let row = entity(
406            Kind::Worktree,
407            "/dev/repo",
408            "repo",
409            "/dev/repo/.git",
410            Head::Branch {
411                name: Arc::from("main"),
412                commit: commit("1"),
413            },
414            known_default_branch("origin/main"),
415        );
416
417        let pairs = environment(&row, None);
418
419        for expected in GIT_LOCAL_ENV_VARS {
420            match find(&pairs, expected) {
421                Some(value) => assert_eq!(*value, None, "git variable {expected} must be unset"),
422                None => panic!("missing git variable: {expected}"),
423            }
424        }
425    }
426
427    // Criterion 7: no Repon selection state is exported; the produced REPON_
428    // names are exactly the declared eight, so an addition (a Selection, a
429    // cursor, REPON_SET) fails this scan even though it would pass a
430    // presence-only check.
431
432    #[test]
433    fn no_repon_variable_beyond_the_declared_eight_is_ever_produced() {
434        let row = entity(
435            Kind::Worktree,
436            "/dev/repo",
437            "repo",
438            "/dev/repo/.git",
439            Head::Branch {
440                name: Arc::from("main"),
441                commit: commit("1"),
442            },
443            known_default_branch("origin/main"),
444        );
445
446        let pairs = environment(&row, Some("reinstall"));
447
448        let mut produced: Vec<&str> = pairs
449            .iter()
450            .map(|(name, _)| name.as_str())
451            .filter(|name| name.starts_with("REPON_"))
452            .collect();
453        produced.sort_unstable();
454        let mut expected = REPON_ENV_VAR_NAMES.to_vec();
455        expected.sort_unstable();
456
457        assert_eq!(
458            produced, expected,
459            "Repon must export exactly its own eight variables, no Selection or Set state"
460        );
461    }
462
463    // Criterion 3: the head variable across HEAD shapes.
464
465    #[test]
466    fn repon_head_carries_the_resolved_commit_on_an_attached_branch() {
467        let head_commit = commit("aaa");
468        let row = entity(
469            Kind::Worktree,
470            "/dev/repo",
471            "repo",
472            "/dev/repo/.git",
473            Head::Branch {
474                name: Arc::from("main"),
475                commit: head_commit,
476            },
477            known_default_branch("origin/main"),
478        );
479
480        let pairs = environment(&row, None);
481
482        assert_eq!(
483            find(&pairs, REPON_HEAD),
484            Some(&Some(head_commit.to_string())),
485            "an attached branch must carry its own resolved commit"
486        );
487    }
488
489    #[test]
490    fn repon_head_is_unset_on_an_unborn_head() {
491        let row = entity(
492            Kind::Worktree,
493            "/dev/repo",
494            "repo",
495            "/dev/repo/.git",
496            Head::Unborn(Arc::from("main")),
497            known_default_branch("origin/main"),
498        );
499
500        let pairs = environment(&row, None);
501
502        assert_eq!(
503            find(&pairs, REPON_HEAD),
504            Some(&None),
505            "an unborn HEAD has no commit, so REPON_HEAD must be unset"
506        );
507    }
508
509    // Criterion 4: the branch variable never carries an object id.
510
511    #[test]
512    fn a_detached_rows_branch_variable_never_carries_the_resolved_commit() {
513        let head_commit = commit("bbb");
514        let row = entity(
515            Kind::Worktree,
516            "/dev/repo-pr-1",
517            "repo-pr-1",
518            "/dev/repo/.git",
519            Head::Detached(head_commit),
520            known_default_branch("origin/main"),
521        );
522
523        let pairs = environment(&row, None);
524
525        assert_eq!(
526            find(&pairs, REPON_BRANCH),
527            Some(&None),
528            "a detached row's branch variable must be unset, never the resolved commit"
529        );
530        assert_eq!(
531            find(&pairs, REPON_HEAD),
532            Some(&Some(head_commit.to_string())),
533            "REPON_HEAD still carries the commit a detached row's branch slot must not"
534        );
535    }
536
537    // Criterion 5: Unknown and Not-applicable both unset, never empty.
538
539    #[test]
540    fn a_not_applicable_default_branch_unsets_rather_than_emptying_the_variable() {
541        let row = entity(
542            Kind::Submodule,
543            "/repo/vendor/lib",
544            "lib",
545            "/repo/.git/modules/lib",
546            Head::Detached(commit("ccc")),
547            Settled::NotApplicable,
548        );
549
550        let pairs = environment(&row, None);
551
552        // Distinct from `Some(String::new())`: a shell's bare `${VAR-fallback}`
553        // (no colon) only substitutes when the name is unset, so an empty-but-set
554        // value would slip a known-wrong default branch through where an unset
555        // one cannot.
556        assert_eq!(
557            find(&pairs, REPON_DEFAULT_BRANCH),
558            Some(&None),
559            "a Not-applicable default branch must unset the variable, never set it empty"
560        );
561    }
562
563    #[test]
564    fn an_unknown_default_branch_unsets_rather_than_emptying_the_variable() {
565        let row = entity(
566            Kind::Worktree,
567            "/dev/repo",
568            "repo",
569            "/dev/repo/.git",
570            Head::Branch {
571                name: Arc::from("main"),
572                commit: commit("ddd"),
573            },
574            Settled::Unknown(Unknown::NoDefaultBranch),
575        );
576
577        let pairs = environment(&row, None);
578
579        assert_eq!(
580            find(&pairs, REPON_DEFAULT_BRANCH),
581            Some(&None),
582            "an Unknown default branch must unset the variable, never set it empty"
583        );
584    }
585
586    // Criterion 6: terminal-prompt suppression is force-set for every child.
587
588    #[test]
589    fn git_terminal_prompt_is_force_set_across_more_than_one_row_shape() {
590        let attached = entity(
591            Kind::Worktree,
592            "/dev/repo",
593            "repo",
594            "/dev/repo/.git",
595            Head::Branch {
596                name: Arc::from("main"),
597                commit: commit("eee"),
598            },
599            known_default_branch("origin/main"),
600        );
601        let unborn = entity(
602            Kind::Worktree,
603            "/dev/fresh",
604            "fresh",
605            "/dev/fresh/.git",
606            Head::Unborn(Arc::from("main")),
607            Settled::Unknown(Unknown::NoDefaultBranch),
608        );
609
610        for (row, action) in [(&attached, None), (&unborn, Some("reinstall"))] {
611            let pairs = environment(row, action);
612            assert_eq!(
613                find(&pairs, GIT_TERMINAL_PROMPT),
614                Some(&Some("0".to_string())),
615                "GIT_TERMINAL_PROMPT must be force-set regardless of row shape or action"
616            );
617        }
618    }
619
620    // Criterion 8: the four full-pair-list tests.
621
622    #[test]
623    fn the_full_pair_list_for_an_attached_row() {
624        let head_commit = commit("1111");
625        let row = entity(
626            Kind::Worktree,
627            "/dev/repo",
628            "repo",
629            "/dev/parent/.git",
630            Head::Branch {
631                name: Arc::from("feature"),
632                commit: head_commit,
633            },
634            known_default_branch("origin/main"),
635        );
636
637        let mut expected = vec![
638            (REPON_REPO_PATH.to_string(), Some("/dev/repo".to_string())),
639            (REPON_REPO_NAME.to_string(), Some("repo".to_string())),
640            (
641                REPON_COMMON_DIR.to_string(),
642                Some("/dev/parent/.git".to_string()),
643            ),
644            (REPON_KIND.to_string(), Some("worktree".to_string())),
645            (REPON_BRANCH.to_string(), Some("feature".to_string())),
646            (REPON_HEAD.to_string(), Some(head_commit.to_string())),
647            (
648                REPON_DEFAULT_BRANCH.to_string(),
649                Some("origin/main".to_string()),
650            ),
651            (REPON_ACTION.to_string(), Some("reinstall".to_string())),
652            (GIT_TERMINAL_PROMPT.to_string(), Some("0".to_string())),
653        ];
654        expected.extend(git_unset_pairs());
655
656        assert_eq!(environment(&row, Some("reinstall")), expected);
657    }
658
659    #[test]
660    fn the_full_pair_list_for_a_detached_row() {
661        let head_commit = commit("2222");
662        let row = entity(
663            Kind::Worktree,
664            "/dev/repo-pr-7",
665            "repo-pr-7",
666            "/dev/repo/.git",
667            Head::Detached(head_commit),
668            known_default_branch("origin/main"),
669        );
670
671        let mut expected = vec![
672            (
673                REPON_REPO_PATH.to_string(),
674                Some("/dev/repo-pr-7".to_string()),
675            ),
676            (REPON_REPO_NAME.to_string(), Some("repo-pr-7".to_string())),
677            (
678                REPON_COMMON_DIR.to_string(),
679                Some("/dev/repo/.git".to_string()),
680            ),
681            (REPON_KIND.to_string(), Some("worktree".to_string())),
682            (REPON_BRANCH.to_string(), None),
683            (REPON_HEAD.to_string(), Some(head_commit.to_string())),
684            (
685                REPON_DEFAULT_BRANCH.to_string(),
686                Some("origin/main".to_string()),
687            ),
688            (REPON_ACTION.to_string(), None),
689            (GIT_TERMINAL_PROMPT.to_string(), Some("0".to_string())),
690        ];
691        expected.extend(git_unset_pairs());
692
693        assert_eq!(environment(&row, None), expected);
694    }
695
696    #[test]
697    fn the_full_pair_list_for_an_unborn_row() {
698        let row = entity(
699            Kind::Worktree,
700            "/dev/fresh",
701            "fresh",
702            "/dev/fresh/.git",
703            Head::Unborn(Arc::from("main")),
704            known_default_branch("origin/main"),
705        );
706
707        let mut expected = vec![
708            (REPON_REPO_PATH.to_string(), Some("/dev/fresh".to_string())),
709            (REPON_REPO_NAME.to_string(), Some("fresh".to_string())),
710            (
711                REPON_COMMON_DIR.to_string(),
712                Some("/dev/fresh/.git".to_string()),
713            ),
714            (REPON_KIND.to_string(), Some("worktree".to_string())),
715            (REPON_BRANCH.to_string(), Some("main".to_string())),
716            (REPON_HEAD.to_string(), None),
717            (
718                REPON_DEFAULT_BRANCH.to_string(),
719                Some("origin/main".to_string()),
720            ),
721            (REPON_ACTION.to_string(), None),
722            (GIT_TERMINAL_PROMPT.to_string(), Some("0".to_string())),
723        ];
724        expected.extend(git_unset_pairs());
725
726        assert_eq!(environment(&row, None), expected);
727    }
728
729    #[test]
730    fn the_full_pair_list_for_a_submodule_row() {
731        let head_commit = commit("3333");
732        let row = entity(
733            Kind::Submodule,
734            "/repo/vendor/lib",
735            "lib",
736            "/repo/.git/modules/lib",
737            Head::Detached(head_commit),
738            Settled::NotApplicable,
739        );
740
741        let mut expected = vec![
742            (
743                REPON_REPO_PATH.to_string(),
744                Some("/repo/vendor/lib".to_string()),
745            ),
746            (REPON_REPO_NAME.to_string(), Some("lib".to_string())),
747            (
748                REPON_COMMON_DIR.to_string(),
749                Some("/repo/.git/modules/lib".to_string()),
750            ),
751            (REPON_KIND.to_string(), Some("submodule".to_string())),
752            (REPON_BRANCH.to_string(), None),
753            (REPON_HEAD.to_string(), Some(head_commit.to_string())),
754            (REPON_DEFAULT_BRANCH.to_string(), None),
755            (REPON_ACTION.to_string(), Some("fetch".to_string())),
756            (GIT_TERMINAL_PROMPT.to_string(), Some("0".to_string())),
757        ];
758        expected.extend(git_unset_pairs());
759
760        assert_eq!(environment(&row, Some("fetch")), expected);
761    }
762}