1use 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
24const 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
42const GIT_TERMINAL_PROMPT: &str = "GIT_TERMINAL_PROMPT";
47
48const 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
72pub 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 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
145fn kind_name(kind: Kind) -> &'static str {
149 match kind {
150 Kind::Repo => "repo",
151 Kind::Worktree => "worktree",
152 Kind::Submodule => "submodule",
153 }
154}
155
156fn 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
182fn 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 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 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 fn backtick_tokens(line: &str) -> Vec<&str> {
274 line.split('`').skip(1).step_by(2).collect()
275 }
276
277 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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}