1use std::path::{Path, PathBuf};
9use std::process::Stdio;
10
11#[must_use]
13pub fn take_push_warning() -> Option<String> {
14 None
15}
16
17#[cfg(test)]
18thread_local! {
19 static LAYER_HOME: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
21 static LAYER_SEAT: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
24}
25
26#[must_use]
28pub fn command_gate(_line: &str, _cwd: Option<&Path>) -> Option<super::Rule> {
29 None
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct GitCall {
36 pub cwd: PathBuf,
37 pub sub: String,
38 pub args: Vec<String>,
39 pub config: Vec<(String, String)>,
40}
41
42pub fn git_calls(line: &str, cwd: Option<&Path>) -> Result<Vec<GitCall>, String> {
50 let mut out = Vec::new();
51 if !line.to_ascii_lowercase().contains("git") {
52 return Ok(out);
53 }
54 let found = std::env::current_dir().ok();
55 let base = cwd.or(found.as_deref()).unwrap_or_else(|| Path::new("."));
56 let mut queue = invocations(line, base, 0);
57 let mut i = 0;
58 while i < queue.len() {
59 let inv = queue[i].clone();
60 i += 1;
61 if inv.depth > 3 {
62 return Err("its git aliases nest too deep to read".into());
63 }
64 let Some(git) = git_of(&inv) else {
65 continue;
66 };
67 match git.shell {
68 Some(shell) => {
69 let extras = invocations(&shell, &git.cwd, inv.depth.saturating_add(1));
70 queue.splice(i..i, extras);
71 }
72 None => out.push(GitCall {
73 cwd: git.cwd,
74 sub: git.sub,
75 args: git.args,
76 config: git.config,
77 }),
78 }
79 }
80 Ok(out)
81}
82
83#[must_use]
85pub fn guard_verdict(line: &str, cwd: Option<&Path>, rules: &[super::Rule]) -> Option<super::Rule> {
86 let fast = super::static_guard_in(line, cwd);
87 let here = cwd.and_then(Path::to_str).map(str::to_string).or_else(|| {
88 std::env::current_dir()
89 .ok()
90 .and_then(|dir| dir.into_os_string().into_string().ok())
91 });
92 let gated = super::redirect_seat_verb(
93 super::gate_push(
94 super::ruled_verdict(rules, line).as_ref(),
95 line,
96 here.as_deref(),
97 ),
98 line,
99 );
100 if gated.as_ref().is_some_and(|rule| rule.verdict == "deny") {
101 return gated;
102 }
103 fast.or(gated)
104}
105
106#[derive(Debug, Clone)]
107struct Inv {
108 cwd: PathBuf,
109 words: Vec<String>,
110 depth: u8,
111}
112
113#[derive(Debug, Clone)]
114struct GitCmd {
115 cwd: PathBuf,
116 config: Vec<(String, String)>,
117 sub: String,
118 args: Vec<String>,
119 shell: Option<String>,
120}
121
122#[derive(Debug, Clone, Default)]
123struct GateConfig {
124 protected: Vec<String>,
125}
126
127fn branch_of(name: &str) -> String {
128 name.trim().trim_start_matches("refs/heads/").to_string()
129}
130
131fn config_layers(root: &Path) -> Vec<PathBuf> {
136 let mut paths = person_layers();
137 paths.push(root.join("ljos.toml"));
138 if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|path| !path.is_empty()) {
139 paths.push(PathBuf::from(path));
140 }
141 paths.push(root.join(".ljos").join("gates.toml"));
142 paths
143}
144
145fn user_config_base() -> Option<PathBuf> {
146 #[cfg(test)]
147 if let Some(path) = LAYER_HOME.with(|slot| slot.borrow().clone()) {
148 return Some(path);
149 }
150 super::config_base()
151}
152
153fn configured_seat() -> Option<String> {
154 #[cfg(test)]
155 if let Some(name) = LAYER_SEAT.with(|slot| slot.borrow().clone()) {
156 return clean_seat(&name);
157 }
158 std::env::var("LJOS_SEAT")
159 .ok()
160 .and_then(|name| clean_seat(&name))
161}
162
163fn clean_seat(name: &str) -> Option<String> {
164 let name = name.trim();
165 if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
166 return None;
167 }
168 Some(name.to_string())
169}
170
171fn read_layer(path: &Path) -> Result<Option<toml::map::Map<String, toml::Value>>, String> {
172 let text = std::fs::read_to_string(path).map_err(|e| {
173 format!(
174 "`{}` cannot be read ({e}). Fix the file, then retry.",
175 path.display()
176 )
177 })?;
178 if text.trim().is_empty() {
179 return Ok(None);
180 }
181 let value: toml::Value = toml::from_str(&text).map_err(|e| {
182 format!(
183 "`{}` does not parse ({e}). Fix the file, then retry.",
184 path.display()
185 )
186 })?;
187 match value {
188 toml::Value::Table(root) => Ok(Some(root)),
189 _ => Err(format!(
190 "`{}` is not a table. Fix the file, then retry.",
191 path.display()
192 )),
193 }
194}
195
196fn apply_gate_file(cfg: &mut GateConfig, path: &Path) -> Result<(), String> {
197 let Some(root) = read_layer(path)? else {
198 return Ok(());
199 };
200 let gates = root.get("gates").and_then(toml::Value::as_table);
201 let src = gates.unwrap_or(&root);
202 if src.contains_key("protected_branches") {
203 cfg.protected = req_strings(src, "protected_branches", path)?
204 .into_iter()
205 .map(|name| branch_of(&name))
206 .filter(|name| !name.is_empty())
207 .collect();
208 }
209 Ok(())
210}
211
212fn req_strings(
213 table: &toml::map::Map<String, toml::Value>,
214 key: &str,
215 path: &Path,
216) -> Result<Vec<String>, String> {
217 let Some(items) = table.get(key).and_then(toml::Value::as_array) else {
218 return Err(format!(
219 "`{}` key `{key}` must be a list of strings. Fix the file, then retry.",
220 path.display()
221 ));
222 };
223 let mut out = Vec::new();
224 for item in items {
225 let Some(text) = item.as_str() else {
226 return Err(format!(
227 "`{}` key `{key}` must be a list of strings. Fix the file, then retry.",
228 path.display()
229 ));
230 };
231 out.push(text.to_string());
232 }
233 Ok(out)
234}
235
236pub(crate) fn lease_protected(cwd: Option<&Path>) -> Option<Vec<String>> {
244 let mut cfg = GateConfig::default();
245 let layers = match cwd.and_then(repo_root) {
246 Some(root) => config_layers(&root),
247 None => user_layers(),
248 };
249 for path in layers {
250 if path.is_file() {
251 apply_gate_file(&mut cfg, &path).ok()?;
252 }
253 }
254 let mut out: Vec<String> = ["main", "master", "develop"].map(String::from).to_vec();
255 out.extend(cfg.protected);
256 Some(out)
257}
258
259fn user_layers() -> Vec<PathBuf> {
262 let mut paths = person_layers();
263 if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|path| !path.is_empty()) {
264 paths.push(PathBuf::from(path));
265 }
266 paths
267}
268
269fn person_layers() -> Vec<PathBuf> {
272 let mut paths = Vec::new();
273 if let Some(base) = user_config_base() {
274 paths.push(base.join("ljos").join("ljos.toml"));
275 if let Some(seat) = configured_seat() {
276 paths.push(base.join("ljos").join("seats").join(seat).join("ljos.toml"));
277 }
278 }
279 paths
280}
281
282#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct ConsensusPush {
286 pub quorum: usize,
287 pub branches: Vec<String>,
288}
289
290pub fn consensus_push(cwd: Option<&Path>) -> Result<ConsensusPush, String> {
300 let mut out = ConsensusPush {
301 quorum: 2,
302 branches: vec!["develop".into(), "main".into()],
303 };
304 for path in person_layers() {
305 let (quorum, branches) = consensus_keys(&path)?;
306 out.quorum = quorum.unwrap_or(out.quorum);
307 out.branches = branches.unwrap_or(out.branches);
308 }
309 let root = cwd.and_then(repo_root);
310 let mut rest: Vec<PathBuf> = root.iter().map(|r| r.join("ljos.toml")).collect();
311 if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|path| !path.is_empty()) {
312 rest.push(PathBuf::from(path));
313 }
314 rest.extend(root.iter().map(|r| r.join(".ljos").join("gates.toml")));
315 for path in rest {
316 let (quorum, branches) = consensus_keys(&path)?;
317 out.quorum = out.quorum.max(quorum.unwrap_or(0));
318 for branch in branches.unwrap_or_default() {
319 if !out.branches.contains(&branch) {
320 out.branches.push(branch);
321 }
322 }
323 }
324 Ok(out)
325}
326
327fn consensus_keys(path: &Path) -> Result<(Option<usize>, Option<Vec<String>>), String> {
328 if !path.is_file() {
329 return Ok((None, None));
330 }
331 let Some(root) = read_layer(path)? else {
332 return Ok((None, None));
333 };
334 let src = root
335 .get("gates")
336 .and_then(toml::Value::as_table)
337 .unwrap_or(&root);
338 let quorum = match src.get("quorum") {
339 None => None,
340 Some(toml::Value::Integer(n)) if *n >= 1 => Some(usize::try_from(*n).unwrap_or(usize::MAX)),
341 Some(_) => {
342 return Err(format!(
343 "`{}` key `quorum` must be a whole number of seats, 1 or more. Fix the file, then retry.",
344 path.display()
345 ))
346 }
347 };
348 let branches = if src.contains_key("consensus_branches") {
349 let names = req_strings(src, "consensus_branches", path)?;
350 Some(
351 names
352 .iter()
353 .map(|name| branch_of(name))
354 .filter(|name| !name.is_empty())
355 .collect(),
356 )
357 } else {
358 None
359 };
360 Ok((quorum, branches))
361}
362
363pub(crate) fn rewrite_layers(cwd: Option<&Path>) -> Vec<PathBuf> {
366 match cwd.and_then(repo_root) {
367 Some(root) => config_layers(&root),
368 None => user_layers(),
369 }
370}
371
372pub(crate) fn rewrite_key(path: &Path) -> Result<Option<bool>, String> {
381 let Some(root) = read_layer(path)? else {
382 return Ok(None);
383 };
384 let src = root
385 .get("gates")
386 .and_then(toml::Value::as_table)
387 .unwrap_or(&root);
388 match src.get("rewrite") {
389 None => Ok(None),
390 Some(toml::Value::Boolean(on)) => Ok(Some(*on)),
391 Some(_) => Err(format!(
392 "`{}` key `rewrite` must be true or false. Fix the file, then retry.",
393 path.display()
394 )),
395 }
396}
397
398#[must_use]
406pub(crate) fn policyd_config() -> Option<PathBuf> {
407 let mut named = None;
408 for path in person_layers() {
409 if !path.is_file() {
410 continue;
411 }
412 let Ok(Some(root)) = read_layer(&path) else {
413 continue;
414 };
415 let raw = root
416 .get("gates")
417 .and_then(|gates| gates.get("policyd_config"))
418 .or_else(|| root.get("policyd_config"))
419 .and_then(toml::Value::as_str);
420 let Some(raw) = raw else {
421 continue;
422 };
423 named = (!raw.is_empty()).then(|| {
424 let found = super::expand(raw);
425 match path.parent() {
426 Some(dir) if found.is_relative() => dir.join(found),
427 _ => found,
428 }
429 });
430 }
431 named
432}
433
434#[must_use]
443pub fn protected_push(call: &GitCall, line: &str, branches: &[String]) -> Option<String> {
444 let unread = |why: String| {
445 Some(format!(
446 "a branch the guard cannot tell from a protected one, since {why}"
447 ))
448 };
449 let cwd = call.cwd.as_path();
450 let args = &call.args;
451 for word in [
452 "GIT_CONFIG",
453 "GIT_DIR",
454 "--git-dir",
455 "GIT_NAMESPACE",
456 "--namespace",
457 ] {
458 if line.contains(word) {
459 return unread(format!("the line sets `{word}`"));
460 }
461 }
462 for (key, _) in &call.config {
463 let key = key.to_ascii_lowercase();
464 if ["remote.", "push.", "branch."]
465 .iter()
466 .any(|p| key.starts_with(p))
467 {
468 return unread(format!("it sets `{key}` for this push"));
469 }
470 }
471 if args.iter().any(|a| has_subst(a)) {
472 return unread("an argument is filled in when the line runs".into());
473 }
474 if let Some(all) = args
475 .iter()
476 .find(|a| matches!(a.as_str(), "--all" | "--mirror" | "--branches" | "--prune"))
477 {
478 return Some(format!("every branch `{all}` reaches"));
479 }
480 if let Some(flag) = args.iter().find(|a| {
481 a.starts_with("--repo")
482 || (a.starts_with("--recurse-submodules")
483 && !a.ends_with("=check")
484 && !a.ends_with("=no"))
485 }) {
486 return unread(format!("`{flag}` moves where it goes"));
487 }
488 if git_text(cwd, &["config", "--get-regexp", r"^remote\..*\.push$"]).is_some() {
489 return unread("a remote in this checkout sets its own push refspecs".into());
490 }
491 let mode = git_config(cwd, "push.default")
492 .unwrap_or_default()
493 .to_ascii_lowercase();
494 let upstream = |branch: &str| git_config(cwd, &format!("branch.{branch}.merge"));
495 let here = || -> Result<Vec<String>, String> {
496 if ["checkout", "switch", "symbolic-ref"]
497 .iter()
498 .any(|w| line.contains(w))
499 {
500 return Err("the line may change branch before it pushes".into());
501 }
502 let head = git_text(cwd, &["symbolic-ref", "-q", "--short", "HEAD"])
503 .filter(|head| !head.is_empty())
504 .ok_or_else(|| "HEAD names no branch".to_string())?;
505 let up = upstream(&head).filter(|_| mode != "current");
506 Ok(std::iter::once(head).chain(up).collect())
507 };
508 let pos = positionals(args);
509 let specs = pos.get(1..).unwrap_or_default();
510 let mut dests = Vec::new();
511 if specs.is_empty() {
512 match mode.as_str() {
513 "nothing" => return None,
514 "matching" => {
515 return Some("every branch both sides have, under `push.default=matching`".into())
516 }
517 _ => match here() {
518 Ok(here) => dests.extend(here),
519 Err(why) => return unread(why),
520 },
521 }
522 }
523 for spec in specs {
524 let spec = spec.strip_prefix('+').unwrap_or(spec);
525 if spec.contains('*') {
526 let dst = spec.rsplit(':').next().unwrap_or(spec);
527 if !dst.starts_with("refs/") || dst.starts_with("refs/heads/") {
528 return Some(format!("the branches `{spec}` matches"));
529 }
530 continue;
531 }
532 match spec.split_once(':') {
533 Some((_, "HEAD" | "@")) => {
534 return unread(format!("`{spec}` lands on what the far side's HEAD names"))
535 }
536 Some((src, "")) => dests.push(src.to_string()),
537 Some((_, dst)) => dests.push(dst.to_string()),
538 None if matches!(spec, "HEAD" | "@") => match here() {
539 Ok(here) => dests.extend(here),
540 Err(why) => return unread(why),
541 },
542 None => {
543 dests.push(spec.to_string());
544 if matches!(mode.as_str(), "upstream" | "tracking") {
545 dests.extend(upstream(spec));
546 }
547 }
548 }
549 }
550 dests.iter().find_map(|dest| {
551 let name = dest
552 .strip_prefix("refs/heads/")
553 .or_else(|| dest.strip_prefix("heads/"))
554 .unwrap_or(dest);
555 branches
556 .iter()
557 .find(|b| b.eq_ignore_ascii_case(name))
558 .map(|b| format!("`{b}`"))
559 })
560}
561
562fn repo_root(cwd: &Path) -> Option<PathBuf> {
563 git_text(cwd, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
564}
565
566fn git_config(cwd: &Path, key: &str) -> Option<String> {
567 let value = git_text(cwd, &["config", "--get", key])?;
568 (!value.is_empty()).then_some(value)
569}
570
571fn git_text(cwd: &Path, args: &[&str]) -> Option<String> {
572 let mut cmd = crate::git_cmd();
573 crate::clear_git_repo_env(&mut cmd);
576 cmd.arg("-C")
577 .arg(cwd)
578 .args(args)
579 .stdin(Stdio::null())
580 .stdout(Stdio::piped())
581 .stderr(Stdio::null());
582 crate::apply_git_command_env(&mut cmd);
583 let out = crate::bounded_output(&mut cmd).ok()?;
584 if !out.status.success() {
585 return None;
586 }
587 Some(
588 String::from_utf8_lossy(&out.stdout)
589 .trim_end_matches(['\r', '\n'])
590 .to_string(),
591 )
592}
593
594fn has_subst(s: &str) -> bool {
595 if s.contains('`') {
596 return true;
597 }
598 let bytes = s.as_bytes();
599 bytes.iter().enumerate().any(|(i, c)| {
600 if *c != b'$' {
601 return false;
602 }
603 matches!(
604 bytes.get(i + 1).copied().unwrap_or(0),
605 b'(' | b'{' | b'_' | b'a'..=b'z' | b'A'..=b'Z'
606 )
607 })
608}
609
610pub(crate) fn is_dry(args: &[String]) -> bool {
611 args.iter().any(|a| {
612 a == "--dry-run"
613 || a == "-n"
614 || (a.starts_with('-') && !a.starts_with("--") && a.chars().skip(1).any(|c| c == 'n'))
615 })
616}
617
618fn positionals(args: &[String]) -> Vec<String> {
619 let mut out = Vec::new();
620 let mut i = 0;
621 while i < args.len() {
622 let a = &args[i];
623 if a == "--" {
624 out.extend(args[i + 1..].iter().cloned());
625 break;
626 }
627 if matches!(
628 a.as_str(),
629 "-o" | "--push-option" | "--receive-pack" | "--exec" | "--repo"
630 ) {
631 i += 2;
632 continue;
633 }
634 if a.starts_with('-') {
635 i += 1;
636 continue;
637 }
638 out.push(a.clone());
639 i += 1;
640 }
641 out
642}
643
644fn invocations(line: &str, base: &Path, depth: u8) -> Vec<Inv> {
645 let mut cwd = base.to_path_buf();
646 let mut out = Vec::new();
647 for sub in super::nested_lines(line, 0) {
648 for seg in super::raw_segments(&sub) {
649 let words = super::shell_words(&seg);
650 if words.is_empty() {
651 continue;
652 }
653 let (at, _) = super::prefix_end(&words);
654 if at >= words.len() {
655 continue;
656 }
657 let cmd = words[at..].to_vec();
658 if cmd.is_empty() {
659 continue;
660 }
661 if super::base_name(&cmd[0]) == "cd" {
662 if let Some(d) = cmd.get(1) {
663 if d != "-" && !d.starts_with('-') {
664 cwd = join_dir(&cwd, d);
665 }
666 }
667 continue;
668 }
669 out.push(Inv {
670 cwd: cwd.clone(),
671 words: cmd,
672 depth,
673 });
674 }
675 }
676 out
677}
678
679fn join_dir(base: &Path, dir: &str) -> PathBuf {
680 let path = Path::new(dir);
681 if path.is_absolute() {
682 path.to_path_buf()
683 } else {
684 base.join(path)
685 }
686}
687
688fn parse_git(inv: &Inv) -> Option<GitCmd> {
689 if super::base_name(&inv.words[0]) != "git" {
690 return None;
691 }
692 let mut i = 1;
693 let mut cwd = inv.cwd.clone();
694 let mut config = Vec::new();
695 while i < inv.words.len() {
696 let w = inv.words[i].as_str();
697 if w == "--" {
698 i += 1;
699 break;
700 }
701 if w == "-C" {
702 if let Some(d) = inv.words.get(i + 1) {
703 cwd = join_dir(&cwd, d);
704 }
705 i += 2;
706 continue;
707 }
708 if w == "-c" {
709 if let Some((k, v)) = inv.words.get(i + 1).and_then(|kv| kv.split_once('=')) {
710 config.push((k.to_string(), v.to_string()));
711 }
712 i += 2;
713 continue;
714 }
715 if let Some((k, v)) = w
716 .strip_prefix("-c")
717 .filter(|s| !s.is_empty() && s.contains('='))
718 .and_then(|kv| kv.split_once('='))
719 {
720 config.push((k.to_string(), v.to_string()));
721 i += 1;
722 continue;
723 }
724 if matches!(
725 w,
726 "--git-dir"
727 | "--work-tree"
728 | "--namespace"
729 | "--config-env"
730 | "--exec-path"
731 | "--super-prefix"
732 ) {
733 i += 2;
734 continue;
735 }
736 if w.starts_with("--git-dir=")
737 || w.starts_with("--work-tree=")
738 || w.starts_with("--namespace=")
739 || w.starts_with("--config-env=")
740 || w.starts_with("--exec-path=")
741 || w.starts_with("--super-prefix=")
742 {
743 i += 1;
744 continue;
745 }
746 if w.starts_with('-') {
747 i += 1;
748 continue;
749 }
750 break;
751 }
752 let sub = inv.words.get(i)?.clone();
753 let args = inv.words.get(i + 1..).unwrap_or(&[]).to_vec();
754 Some(GitCmd {
755 cwd,
756 config,
757 sub,
758 args,
759 shell: None,
760 })
761}
762
763fn git_of(inv: &Inv) -> Option<GitCmd> {
764 let mut git = parse_git(inv)?;
765 let mut seen = std::collections::HashSet::new();
766 while let Some(value) = alias_of(&git) {
767 if !seen.insert(git.sub.clone()) {
768 break;
769 }
770 if let Some(shell) = value.strip_prefix('!') {
771 git.shell = Some(shell.trim().to_string());
772 break;
773 }
774 let mut parts: Vec<String> = value.split_whitespace().map(str::to_string).collect();
775 if parts.is_empty() {
776 break;
777 }
778 git.sub = parts.remove(0);
779 parts.append(&mut git.args);
780 git.args = parts;
781 }
782 Some(git)
783}
784
785fn alias_of(git: &GitCmd) -> Option<String> {
786 if matches!(
787 git.sub.as_str(),
788 "commit"
789 | "push"
790 | "merge"
791 | "checkout"
792 | "switch"
793 | "status"
794 | "diff"
795 | "add"
796 | "log"
797 | "show"
798 | "fetch"
799 | "pull"
800 | "rebase"
801 | "stash"
802 | "branch"
803 | "tag"
804 | "remote"
805 | "init"
806 | "clone"
807 | "restore"
808 | "reset"
809 | "clean"
810 | "rev-parse"
811 | "config"
812 | "help"
813 | "version"
814 | "blame"
815 | "grep"
816 | "mv"
817 | "rm"
818 | "apply"
819 | "cherry-pick"
820 | "revert"
821 | "bisect"
822 | "reflog"
823 ) {
824 return None;
825 }
826 let key = format!("alias.{}", git.sub);
827 if let Some((_, value)) = git.config.iter().rev().find(|(k, _)| k == &key) {
828 return Some(value.clone());
829 }
830 git_config(&git.cwd, &key)
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836
837 const AUTHOR_NAME: &str = "Ada Lovelace";
838 const AUTHOR_EMAIL: &str = "ada@example.com";
839
840 fn git(dir: &Path, args: &[&str]) {
841 let output = crate::git_cmd()
842 .arg("-C")
843 .arg(dir)
844 .args(args)
845 .env("GIT_AUTHOR_NAME", AUTHOR_NAME)
846 .env("GIT_AUTHOR_EMAIL", AUTHOR_EMAIL)
847 .env("GIT_COMMITTER_NAME", AUTHOR_NAME)
848 .env("GIT_COMMITTER_EMAIL", AUTHOR_EMAIL)
849 .output()
850 .unwrap();
851 assert!(
852 output.status.success(),
853 "git {args:?}: {}",
854 String::from_utf8_lossy(&output.stderr)
855 );
856 }
857
858 struct Held {
862 _env: crate::EnvLock,
863 dir: tempfile::TempDir,
864 }
865
866 impl std::ops::Deref for Held {
867 type Target = tempfile::TempDir;
868
869 fn deref(&self) -> &Self::Target {
870 &self.dir
871 }
872 }
873
874 fn repo() -> Held {
875 let env = crate::test_env_lock();
876 let dir = tempfile::tempdir().unwrap();
877 git(dir.path(), &["init", "-q", "-b", "main"]);
878 git(dir.path(), &["config", "user.name", AUTHOR_NAME]);
879 git(dir.path(), &["config", "user.email", AUTHOR_EMAIL]);
880 git(dir.path(), &["config", "commit.gpgsign", "false"]);
881 git(
882 dir.path(),
883 &[
884 "remote",
885 "add",
886 "origin",
887 "https://example.com/acme/widgets.git",
888 ],
889 );
890 Held { _env: env, dir }
891 }
892
893 fn seed(dir: &Path) {
894 std::fs::write(dir.join("f.txt"), "one\n").unwrap();
895 git(dir, &["add", "f.txt"]);
896 git(dir, &["commit", "-q", "-m", "note the seat"]);
897 }
898
899 fn gates(dir: &Path, body: &str) {
900 std::fs::create_dir_all(dir.join(".ljos")).unwrap();
901 std::fs::write(dir.join(".ljos").join("gates.toml"), body).unwrap();
902 }
903
904 #[test]
905 fn git_calls_follow_dirs_shells_and_aliases() {
906 let dir = repo();
907 let root = dir.path();
908 std::fs::create_dir_all(root.join("sub")).unwrap();
909 git(root, &["config", "alias.ci", "commit -q"]);
910 git(
911 root,
912 &["config", "alias.ship", "!git add . && git push origin HEAD"],
913 );
914 let calls = git_calls(
915 "cd sub && git -C .. ci -m x; sh -c 'git ship'; echo $(git cherry-pick abc)",
916 Some(root),
917 )
918 .unwrap();
919 let subs: Vec<&str> = calls.iter().map(|c| c.sub.as_str()).collect();
920 assert_eq!(subs, ["commit", "add", "push", "cherry-pick"], "{calls:?}");
921 assert_eq!(calls[0].args, ["-q", "-m", "x"]);
922 assert_eq!(calls[0].cwd, root.join("sub").join(".."));
923 assert_eq!(calls[2].args, ["origin", "HEAD"]);
924 assert!(git_calls("ls -la", Some(root)).unwrap().is_empty());
925
926 for (name, next) in [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")] {
927 git(
928 root,
929 &["config", &format!("alias.{name}"), &format!("!git {next}")],
930 );
931 }
932 let deep = git_calls("git a", Some(root)).unwrap_err();
933 assert!(deep.contains("too deep"), "{deep}");
934 }
935
936 struct Layers;
937
938 impl Drop for Layers {
939 fn drop(&mut self) {
940 LAYER_HOME.with(|slot| *slot.borrow_mut() = None);
941 LAYER_SEAT.with(|slot| *slot.borrow_mut() = None);
942 }
943 }
944
945 fn layers(home: &Path, seat: Option<&str>) -> Layers {
946 LAYER_HOME.with(|slot| *slot.borrow_mut() = Some(home.to_path_buf()));
947 LAYER_SEAT.with(|slot| *slot.borrow_mut() = seat.map(str::to_string));
948 Layers
949 }
950
951 fn write_toml(path: &Path, body: &str) {
952 if let Some(parent) = path.parent() {
953 std::fs::create_dir_all(parent).unwrap();
954 }
955 std::fs::write(path, body).unwrap();
956 }
957
958 #[test]
959 fn the_person_sets_the_consensus_push_and_a_checkout_only_tightens_it() {
960 let dir = repo();
961 let cwd = dir.path();
962 let home = tempfile::tempdir().unwrap();
963 let _layers = layers(home.path(), Some("box"));
964 let read = || consensus_push(Some(cwd)).unwrap();
965 let names = |all: &[&str]| all.iter().map(|b| (*b).to_string()).collect::<Vec<_>>();
966 assert_eq!(read().quorum, 2);
967 assert_eq!(read().branches, names(&["develop", "main"]));
968 write_toml(
969 &home.path().join("ljos/ljos.toml"),
970 "[gates]\nquorum = 3\nconsensus_branches = [\"main\", \"release\"]\n",
971 );
972 assert_eq!(read().quorum, 3);
973 assert_eq!(read().branches, names(&["main", "release"]));
974 write_toml(
975 &home.path().join("ljos/seats/box/ljos.toml"),
976 "quorum = 1\n",
977 );
978 assert_eq!(read().quorum, 1, "the seat file is the person's too");
979 write_toml(
980 &cwd.join("ljos.toml"),
981 "quorum = 1\nconsensus_branches = []\n",
982 );
983 gates(
984 cwd,
985 "quorum = 2\nconsensus_branches = [\"refs/heads/stable\"]\n",
986 );
987 assert_eq!(read().quorum, 2, "a checkout raises the quorum");
988 assert_eq!(read().branches, names(&["main", "release", "stable"]));
989 write_toml(&cwd.join("ljos.toml"), "quorum = 0\n");
990 let zero = consensus_push(Some(cwd)).unwrap_err();
991 assert!(zero.contains("`quorum`"), "{zero}");
992 write_toml(&cwd.join("ljos.toml"), "quorum = [\n");
993 let broken = consensus_push(Some(cwd)).unwrap_err();
994 assert!(broken.contains("does not parse"), "{broken}");
995 }
996
997 #[test]
998 fn a_tight_protected_branch_list_keeps_each_name() {
999 let dir = repo();
1000 let home = tempfile::tempdir().unwrap();
1001 let _layers = layers(home.path(), None);
1002 let cwd = dir.path();
1003 let want = ["main", "master", "develop", "feat", "y"]
1004 .map(str::to_string)
1005 .to_vec();
1006 for body in [
1007 "protected_branches = [\"feat\",\"y\"]\n",
1008 "protected_branches = [\"feat\", \"y\"]\n",
1009 "protected_branches = [ \"feat\" , \"y\" ]\n",
1010 "protected_branches = ['feat','y']\n",
1011 "protected_branches = [\"feat\",\"y\",]\n",
1012 "protected_branches = [\"feat\",\"y\"]\r\n",
1013 ] {
1014 gates(cwd, body);
1015 assert_eq!(lease_protected(Some(cwd)).as_ref(), Some(&want), "{body:?}");
1016 }
1017 gates(cwd, "protected_branches = \"release\"\n");
1018 assert!(
1019 lease_protected(Some(cwd)).is_none(),
1020 "a value that is not a list asks"
1021 );
1022 }
1023
1024 #[test]
1025 fn a_push_lands_where_git_would_put_it_and_an_unread_one_is_protected() {
1026 let dir = repo();
1027 let cwd = dir.path();
1028 seed(cwd);
1029 git(cwd, &["checkout", "-q", "-b", "topic"]);
1030 let branches = ["develop".to_string(), "main".to_string()];
1031 let lands = |line: &str| {
1032 let calls = git_calls(line, Some(cwd)).unwrap();
1033 let push = calls.iter().find(|c| c.sub == "push").unwrap();
1034 protected_push(push, line, &branches)
1035 };
1036 let unread = |line: &str| {
1037 lands(line).is_some_and(|d| d.starts_with("a branch the guard cannot tell"))
1038 };
1039 for line in [
1040 "git push",
1041 "git push origin",
1042 "git push origin topic",
1043 "git push -u origin HEAD",
1044 "git push origin topic:refs/heads/topic",
1045 "git push origin v1:refs/tags/develop",
1046 "git push origin 'refs/tags/*:refs/tags/*'",
1047 "git push develop topic",
1048 ] {
1049 assert_eq!(lands(line), None, "{line}");
1050 }
1051 for (line, dest) in [
1052 ("git push origin HEAD:develop", "`develop`"),
1053 ("git push origin topic:refs/heads/develop", "`develop`"),
1054 ("git push origin +topic:heads/develop", "`develop`"),
1055 ("git push origin :develop", "`develop`"),
1056 ("git push --delete origin develop", "`develop`"),
1057 ("git push origin topic HEAD:Main", "`main`"),
1058 (
1059 "git push https://example.com/acme/widgets.git main",
1060 "`main`",
1061 ),
1062 ("git push --all origin", "every branch `--all` reaches"),
1063 (
1064 "git push origin 'refs/heads/*:refs/heads/*'",
1065 "the branches `refs/heads/*:refs/heads/*` matches",
1066 ),
1067 ] {
1068 assert_eq!(lands(line).as_deref(), Some(dest), "{line}");
1069 }
1070 for line in [
1071 "git checkout develop && git push",
1072 "GIT_DIR=../other git push origin topic",
1073 "git -c remote.origin.push=refs/heads/topic:refs/heads/develop push origin topic",
1074 "git push origin $BRANCH",
1075 "git push --repo=origin topic",
1076 "git push origin topic:HEAD",
1077 ] {
1078 assert!(unread(line), "{line}: {:?}", lands(line));
1079 }
1080
1081 git(cwd, &["config", "branch.topic.merge", "refs/heads/develop"]);
1082 assert_eq!(lands("git push").as_deref(), Some("`develop`"));
1083 assert_eq!(lands("git push origin HEAD").as_deref(), Some("`develop`"));
1084 assert_eq!(
1085 lands("git push origin topic"),
1086 None,
1087 "simple names the branch"
1088 );
1089 git(cwd, &["config", "push.default", "upstream"]);
1090 assert_eq!(lands("git push origin topic").as_deref(), Some("`develop`"));
1091 git(cwd, &["config", "push.default", "current"]);
1092 assert_eq!(lands("git push"), None);
1093 git(cwd, &["config", "push.default", "nothing"]);
1094 assert_eq!(lands("git push"), None);
1095 git(cwd, &["config", "push.default", "matching"]);
1096 assert!(lands("git push")
1097 .unwrap()
1098 .starts_with("every branch both sides have"));
1099 git(cwd, &["config", "--unset", "push.default"]);
1100 git(
1101 cwd,
1102 &[
1103 "config",
1104 "remote.origin.push",
1105 "refs/heads/topic:refs/heads/develop",
1106 ],
1107 );
1108 assert!(unread("git push origin topic"));
1109 git(cwd, &["config", "--unset", "remote.origin.push"]);
1110 git(cwd, &["checkout", "-q", "--detach"]);
1111 assert!(unread("git push origin HEAD"));
1112 assert_eq!(lands("git push origin HEAD:refs/heads/topic"), None);
1113 }
1114}