1use crate::config::GitFlowConfig;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7use tracing::{debug, info, warn};
8
9#[derive(Debug, thiserror::Error)]
11pub enum GitError {
12 #[error("failed to execute git: {0}")]
14 Io(#[from] std::io::Error),
15 #[error("git command failed: {0}")]
17 Command(String),
18}
19
20pub const REPO_LOCAL_GIT_VARS: &[&str] = &[
28 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
29 "GIT_CONFIG",
30 "GIT_CONFIG_PARAMETERS",
31 "GIT_CONFIG_COUNT",
32 "GIT_OBJECT_DIRECTORY",
33 "GIT_DIR",
34 "GIT_WORK_TREE",
35 "GIT_IMPLICIT_WORK_TREE",
36 "GIT_GRAFT_FILE",
37 "GIT_INDEX_FILE",
38 "GIT_NO_REPLACE_OBJECTS",
39 "GIT_REPLACE_REF_BASE",
40 "GIT_PREFIX",
41 "GIT_SHALLOW_FILE",
42 "GIT_COMMON_DIR",
43];
44
45pub const ALSO_REDIRECTING_GIT_VARS: &[&str] = &[
56 "GIT_NAMESPACE",
57 "GIT_DISCOVERY_ACROSS_FILESYSTEM",
58 "GIT_CEILING_DIRECTORIES",
59];
60
61pub fn git_command(repo: &Path) -> Command {
73 hermetic_command("git", repo)
74}
75
76pub fn hermetic_command(program: &str, dir: &Path) -> Command {
88 let mut cmd = Command::new(program);
89 cmd.current_dir(dir);
90 for var in REPO_LOCAL_GIT_VARS.iter().chain(ALSO_REDIRECTING_GIT_VARS) {
91 cmd.env_remove(var);
92 }
93 cmd
94}
95
96#[derive(Debug, Clone)]
98pub struct GitFlow {
99 root: PathBuf,
100 config: GitFlowConfig,
101}
102
103#[derive(Debug, Clone)]
105pub struct BranchInfo {
106 pub name: String,
108 pub ahead: usize,
110 pub behind: usize,
112 pub last_commit: String,
114}
115
116impl GitFlow {
117 pub fn new(root: impl AsRef<Path>) -> Self {
120 Self {
121 root: root.as_ref().to_path_buf(),
122 config: GitFlowConfig::default(),
123 }
124 }
125
126 pub fn feature_start(&self, phase: u32) -> Result<String, GitError> {
131 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
132 info!("creating feature branch: {branch}");
133 self.git(["checkout", &self.config.develop])?;
134 self.git(["checkout", "-b", &branch])?;
135 Ok(branch)
136 }
137
138 pub fn feature_start_force(&self, phase: u32) -> Result<String, GitError> {
140 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
141 warn!("force-creating feature branch: {branch}");
142 self.git(["checkout", &self.config.develop])?;
143 self.git(["checkout", "-B", &branch])?;
144 Ok(branch)
145 }
146
147 pub fn feature_finish(&self, phase: u32) -> Result<String, GitError> {
149 let branch = self.merge_feature_into_develop(phase)?;
150 self.git(["branch", "-d", &branch])?;
151 Ok(branch)
152 }
153
154 pub fn merge_feature_into_develop(&self, phase: u32) -> Result<String, GitError> {
159 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
160 info!("merging feature branch: {branch}");
161 self.git(["checkout", &self.config.develop])?;
162 self.git(["merge", "--no-ff", &branch])?;
163 Ok(branch)
164 }
165
166 pub fn is_merged_into_develop(&self, phase: u32) -> bool {
171 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
172 if !self.branch_exists(&branch) {
173 return false;
174 }
175
176 git_command(&self.root)
177 .args(["merge-base", "--is-ancestor", &branch, &self.config.develop])
178 .output()
179 .map(|output| output.status.success())
180 .unwrap_or(false)
181 }
182
183 pub fn release_start(&self, version: &str) -> Result<String, GitError> {
190 let branch = format!("release/{version}");
191 info!("creating release branch: {branch}");
192 self.git(["checkout", "-B", &branch])?;
193 Ok(branch)
194 }
195
196 pub fn release_finish(&self, version: &str) -> Result<String, GitError> {
198 let branch = format!("release/{version}");
199 info!("finishing release branch: {branch}");
200 self.git(["checkout", &self.config.main])?;
201 self.git(["merge", "--no-ff", &branch])?;
202 self.git(["-c", "tag.gpgSign=false", "tag", &format!("v{version}")])?;
209 self.git(["checkout", &self.config.develop])?;
210 self.git(["merge", "--no-ff", &branch])?;
211 self.git(["branch", "-d", &branch])?;
212 Ok(branch)
213 }
214
215 pub fn tag(&self, tag: &str) -> Result<(), GitError> {
224 info!("tagging {tag}");
225 self.git(["-c", "tag.gpgSign=false", "tag", tag])
226 }
227
228 pub fn delete_branch(&self, branch: &str, force: bool) -> Result<(), GitError> {
234 if branch == self.config.main || branch == self.config.develop {
235 return Err(GitError::Command(format!(
236 "refusing to delete protected branch `{branch}`"
237 )));
238 }
239 let flag = if force { "-D" } else { "-d" };
240 if force {
241 warn!("force-deleting branch: {branch}");
242 } else {
243 info!("deleting branch: {branch}");
244 }
245 self.git(["branch", flag, branch])
246 }
247
248 pub fn branch_exists(&self, branch: &str) -> bool {
250 git_command(&self.root)
251 .args([
252 "rev-parse",
253 "--verify",
254 "--quiet",
255 &format!("refs/heads/{branch}"),
256 ])
257 .output()
258 .map(|o| o.status.success())
259 .unwrap_or(false)
260 }
261
262 pub fn branch_tip(&self, branch: &str) -> Result<String, GitError> {
264 Ok(self.git_output(["rev-parse", branch])?.trim().to_string())
265 }
266
267 pub fn ensure_branch(&self, branch: &str, start_point: &str) -> Result<(), GitError> {
270 if self.branch_exists(branch) {
271 return Ok(());
272 }
273 self.git(["branch", branch, start_point])
274 }
275
276 pub fn checkout(&self, branch: &str) -> Result<(), GitError> {
278 debug!("checking out branch: {branch}");
279 self.git(["checkout", branch])
280 }
281
282 pub fn delete_remote_branch(&self, branch: &str) -> Result<(), GitError> {
284 info!("deleting remote branch: {branch}");
285 self.git(["push", "origin", "--delete", branch])
286 }
287
288 pub fn has_remote(&self) -> bool {
290 self.git_output(["remote"])
291 .map(|s| !s.trim().is_empty())
292 .unwrap_or(false)
293 }
294
295 pub fn push(&self, branch: &str) -> Result<(), GitError> {
297 info!("pushing branch: {branch}");
298 self.git(["push", "-u", "origin", branch])
299 }
300
301 pub fn cleanup_merged(&self) -> Result<Vec<String>, GitError> {
317 let output = self.git_output(["branch", "--merged", &self.config.develop])?;
318 let protected = [self.config.main.as_str(), self.config.develop.as_str()];
319 let mut deleted = Vec::new();
320 for line in output.lines() {
321 let branch = line
327 .strip_prefix("* ")
328 .or_else(|| line.strip_prefix("+ "))
329 .unwrap_or(line)
330 .trim();
331 if branch.is_empty() || branch.starts_with('(') || protected.contains(&branch) {
334 continue;
335 }
336 info!("cleaning up merged branch: {branch}");
337 match self.git(["branch", "-D", branch]) {
338 Ok(()) => deleted.push(branch.to_string()),
339 Err(err) => warn!("could not delete merged branch {branch}: {err}"),
340 }
341 }
342 Ok(deleted)
343 }
344
345 pub fn commit_all(&self, message: &str) -> Result<(), GitError> {
348 debug!("committing all changes: {message}");
349 self.git(["add", "."])?;
350 match self.git_raw(&["commit", "--allow-empty", "-m", message]) {
352 Ok(()) => Ok(()),
353 Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
356 Err(e) => Err(e),
357 }
358 }
359
360 pub fn commit_path(&self, relative_path: &str, message: &str) -> Result<(), GitError> {
369 debug!("committing {relative_path}: {message}");
370 self.git(["add", relative_path])?;
376 match self.git_raw_combined(&["commit", "-m", message, "--", relative_path]) {
377 Ok(()) => Ok(()),
378 Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
383 Err(e) => Err(e),
384 }
385 }
386
387 pub fn divergence_from_develop(&self) -> Result<(usize, usize), GitError> {
393 let current = self
394 .git_output(["rev-parse", "--abbrev-ref", "HEAD"])?
395 .trim()
396 .to_string();
397 if current == self.config.develop {
398 return Ok((0, 0));
399 }
400 let ahead = self
401 .rev_count(&format!("{}..{current}", self.config.develop))
402 .unwrap_or(0);
403 let behind = self
404 .rev_count(&format!("{current}..{}", self.config.develop))
405 .unwrap_or(0);
406 Ok((ahead, behind))
407 }
408
409 pub fn list_feature_branches(&self) -> Result<Vec<BranchInfo>, GitError> {
414 let prefix = &self.config.feature_prefix;
415 let branches = self.git_output(["branch", "--format=%(refname:short)"])?;
416 let mut result = Vec::new();
417 for name in branches.lines().map(|l| l.trim()) {
418 if name.is_empty()
419 || name == self.config.main
420 || name == self.config.develop
421 || !name.starts_with(prefix)
422 {
423 continue;
424 }
425 let ahead = self
426 .rev_count(&format!("{dev}..{name}", dev = self.config.develop))
427 .unwrap_or(0);
428 let behind = self
429 .rev_count(&format!("{name}..{dev}", dev = self.config.develop))
430 .unwrap_or(0);
431 let last_commit = self
432 .git_output(["log", "-1", "--format=%aI", name])
433 .map(|s| s.trim().to_string())
434 .unwrap_or_default();
435 result.push(BranchInfo {
436 name: name.to_string(),
437 ahead,
438 behind,
439 last_commit,
440 });
441 }
442 result.sort_by(|a, b| a.name.cmp(&b.name));
444 Ok(result)
445 }
446
447 fn rev_count(&self, range: &str) -> Option<usize> {
449 self.git_output(["rev-list", "--count", range])
450 .ok()
451 .and_then(|s| s.trim().parse().ok())
452 }
453
454 fn git_raw(&self, args: &[&str]) -> Result<(), GitError> {
455 debug!("git {}", args.join(" "));
456 let output = git_command(&self.root)
462 .args(args)
463 .env("LC_ALL", "C")
464 .env("LANG", "C")
465 .output()?;
466 if output.status.success() {
467 Ok(())
468 } else {
469 Err(GitError::Command(stderr_or_status(&output)))
470 }
471 }
472
473 fn git_raw_combined(&self, args: &[&str]) -> Result<(), GitError> {
487 debug!("git {}", args.join(" "));
488 let output = git_command(&self.root)
489 .args(args)
490 .env("LC_ALL", "C")
491 .env("LANG", "C")
492 .output()?;
493 if output.status.success() {
494 Ok(())
495 } else {
496 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
497 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
498 let combined = match (stderr.is_empty(), stdout.is_empty()) {
499 (false, false) => format!("{stderr}\n{stdout}"),
500 (false, true) => stderr,
501 (true, false) => stdout,
502 (true, true) => format!("exited with {}", output.status),
503 };
504 Err(GitError::Command(combined))
505 }
506 }
507
508 fn git<const N: usize>(&self, args: [&str; N]) -> Result<(), GitError> {
509 debug!("git {}", args.iter().copied().collect::<Vec<_>>().join(" "));
510 let output = git_command(&self.root).args(args).output()?;
511 if output.status.success() {
512 Ok(())
513 } else {
514 Err(GitError::Command(stderr_or_status(&output)))
515 }
516 }
517
518 fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<String, GitError> {
519 let output = git_command(&self.root).args(args).output()?;
520 if output.status.success() {
521 Ok(String::from_utf8_lossy(&output.stdout).to_string())
522 } else {
523 Err(GitError::Command(stderr_or_status(&output)))
524 }
525 }
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
533pub enum AncestorStatus {
534 Ancestor,
536 Diverged,
540 RefAbsent,
545}
546
547pub fn origin_main_ancestor_status(project_root: &Path) -> AncestorStatus {
554 let ref_exists = git_command(project_root)
555 .args(["rev-parse", "--verify", "--quiet", "origin/main"])
556 .output()
557 .map(|out| out.status.success())
558 .unwrap_or(false);
559 if !ref_exists {
560 return AncestorStatus::RefAbsent;
561 }
562 let is_ancestor = git_command(project_root)
563 .args(["merge-base", "--is-ancestor", "origin/main", "HEAD"])
564 .output()
565 .map(|out| out.status.success())
566 .unwrap_or(false);
567 if is_ancestor {
568 AncestorStatus::Ancestor
569 } else {
570 AncestorStatus::Diverged
571 }
572}
573
574pub fn publish_order(project_root: &Path) -> Vec<String> {
581 let Ok(root_contents) = std::fs::read_to_string(project_root.join("Cargo.toml")) else {
582 return Vec::new();
583 };
584 let member_paths = workspace_member_paths(&root_contents);
585
586 let mut members: Vec<(String, String)> = Vec::new();
587 for path in &member_paths {
588 let manifest = project_root.join(path).join("Cargo.toml");
589 let Ok(contents) = std::fs::read_to_string(&manifest) else {
590 continue;
591 };
592 let name = package_name(&contents).unwrap_or_else(|| path.clone());
593 members.push((name, contents));
594 }
595
596 let names: Vec<String> = members.iter().map(|(name, _)| name.clone()).collect();
597 let mut edges: Vec<(String, String)> = Vec::new();
598 for (name, contents) in &members {
599 for other in &names {
600 if other != name && member_depends_on(contents, other) {
601 edges.push((name.clone(), other.clone()));
602 }
603 }
604 }
605 topo_sort(names, edges)
606}
607
608fn workspace_member_paths(contents: &str) -> Vec<String> {
613 let Some(start) = contents.find("members") else {
614 return Vec::new();
615 };
616 let rest = &contents[start..];
617 let Some(open) = rest.find('[') else {
618 return Vec::new();
619 };
620 let Some(close) = rest[open..].find(']') else {
621 return Vec::new();
622 };
623 let inner = &rest[open + 1..open + close];
624 inner
625 .split(',')
626 .filter_map(|fragment| {
627 let fragment = fragment.trim();
628 let fragment = fragment.strip_prefix('"')?.strip_suffix('"')?;
629 (!fragment.is_empty()).then(|| fragment.to_string())
630 })
631 .collect()
632}
633
634fn package_name(contents: &str) -> Option<String> {
636 let mut current = String::new();
637 for line in contents.lines() {
638 let trimmed = line.trim();
639 if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
640 current = inner.trim().to_string();
641 continue;
642 }
643 if current == "package"
644 && let Some((key, value)) = trimmed.split_once('=')
645 && key.trim() == "name"
646 {
647 return Some(value.trim().trim_matches('"').to_string());
648 }
649 }
650 None
651}
652
653fn member_depends_on(contents: &str, dep_name: &str) -> bool {
664 let mut current = String::new();
665 for line in contents.lines() {
666 let trimmed = line.trim();
667 if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
668 current = inner.trim().to_string();
669 if let Some(name) = current.strip_prefix("dependencies.")
670 && name == dep_name
671 {
672 return true;
673 }
674 continue;
675 }
676 if current != "dependencies" {
677 continue;
678 }
679 let key = trimmed.split(['.', '=']).next().unwrap_or("").trim();
680 if key == dep_name {
681 return true;
682 }
683 }
684 false
685}
686
687fn topo_sort(names: Vec<String>, edges: Vec<(String, String)>) -> Vec<String> {
693 let mut result = Vec::new();
694 let mut published: Vec<String> = Vec::new();
695 let mut remaining = names;
696 while !remaining.is_empty() {
697 let ready: Vec<String> = remaining
698 .iter()
699 .filter(|name| {
700 edges
701 .iter()
702 .filter(|(dependent, _)| dependent == *name)
703 .all(|(_, dep)| published.contains(dep))
704 })
705 .cloned()
706 .collect();
707 if ready.is_empty() {
708 result.extend(remaining);
709 break;
710 }
711 for name in &ready {
712 published.push(name.clone());
713 result.push(name.clone());
714 }
715 remaining.retain(|name| !ready.contains(name));
716 }
717 result
718}
719
720#[derive(Debug, Clone, Copy, PartialEq, Eq)]
728pub enum SigningStatus {
729 NoAgent,
731 AgentEmpty,
733 KeysListed,
736 Unknown(i32),
739}
740
741pub fn classify_ssh_add_status(exit_code: i32) -> SigningStatus {
744 match exit_code {
745 2 => SigningStatus::NoAgent,
746 1 => SigningStatus::AgentEmpty,
747 0 => SigningStatus::KeysListed,
748 other => SigningStatus::Unknown(other),
749 }
750}
751
752#[derive(Debug, Clone, PartialEq, Eq)]
758pub enum SigningViability {
759 Viable { fingerprint: Option<String> },
762 NotViable { reason: String },
764 Unknown { reason: String },
767}
768
769fn git_config(project_root: &Path, key: &str) -> Option<String> {
772 let output = git_command(project_root)
773 .args(["config", "--get", key])
774 .output()
775 .ok()?;
776 if !output.status.success() {
777 return None;
778 }
779 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
780 (!value.is_empty()).then_some(value)
781}
782
783fn public_key_fingerprint(pub_key_path: &Path) -> Option<String> {
787 let path_str = pub_key_path.to_str()?;
788 let output = Command::new("ssh-keygen")
789 .args(["-lf", path_str])
790 .output()
791 .ok()?;
792 if !output.status.success() {
793 return None;
794 }
795 String::from_utf8_lossy(&output.stdout)
797 .split_whitespace()
798 .nth(1)
799 .map(str::to_string)
800}
801
802fn inline_signing_key_blob(signingkey: &str) -> Option<&str> {
815 let trimmed = signingkey.trim();
816 if let Some(remainder) = trimmed.strip_prefix("key::") {
817 Some(remainder)
818 } else if trimmed.starts_with("ssh-") {
819 Some(trimmed)
820 } else {
821 None
822 }
823}
824
825fn inline_key_fingerprint(key_blob: &str) -> Option<String> {
842 let mut child = Command::new("ssh-keygen")
843 .args(["-lf", "-"])
844 .stdin(Stdio::piped())
845 .stdout(Stdio::piped())
846 .stderr(Stdio::piped())
847 .spawn()
848 .ok()?;
849
850 let mut stdin = child.stdin.take()?;
855 stdin.write_all(key_blob.as_bytes()).ok()?;
856 drop(stdin);
857
858 let output = child.wait_with_output().ok()?;
859 if !output.status.success() {
860 return None;
861 }
862 String::from_utf8_lossy(&output.stdout)
863 .split_whitespace()
864 .nth(1)
865 .map(str::to_string)
866}
867
868fn check_ssh_signing_viability(project_root: &Path) -> SigningViability {
875 let Some(signingkey) = git_config(project_root, "user.signingkey") else {
876 return SigningViability::NotViable {
877 reason: "gpg.format=ssh but user.signingkey is not set".into(),
878 };
879 };
880
881 let inline_blob = inline_signing_key_blob(&signingkey);
885
886 if inline_blob.is_none() {
893 let key_path = Path::new(&signingkey);
894 if !key_path.exists() {
895 return SigningViability::NotViable {
896 reason: "user.signingkey is set but the key file does not exist".into(),
897 };
898 }
899 }
900
901 let output = match Command::new("ssh-add").arg("-l").output() {
902 Ok(out) => out,
903 Err(_) => {
904 return SigningViability::Unknown {
905 reason: "cannot verify signing viability — ssh-add not found".into(),
906 };
907 }
908 };
909 let exit_code = output.status.code().unwrap_or(-1);
910 match classify_ssh_add_status(exit_code) {
911 SigningStatus::NoAgent => SigningViability::NotViable {
912 reason: "no ssh-agent reachable (SSH_AUTH_SOCK unset or dead)".into(),
913 },
914 SigningStatus::AgentEmpty => SigningViability::NotViable {
915 reason: "ssh-agent reachable but has no identities loaded".into(),
916 },
917 SigningStatus::KeysListed => {
918 let stdout = String::from_utf8_lossy(&output.stdout);
919 let fingerprint = match inline_blob {
924 Some(blob) => inline_key_fingerprint(blob),
925 None => public_key_fingerprint(Path::new(&signingkey)),
926 };
927 match fingerprint {
928 Some(fingerprint) if stdout.contains(&fingerprint) => SigningViability::Viable {
929 fingerprint: Some(fingerprint),
930 },
931 Some(_) => SigningViability::NotViable {
932 reason: "ssh-agent has keys loaded, but not the configured signing key".into(),
933 },
934 None => SigningViability::Unknown {
935 reason: "cannot verify signing viability — ssh-keygen not found or the key \
936 is unreadable"
937 .into(),
938 },
939 }
940 }
941 SigningStatus::Unknown(code) => SigningViability::Unknown {
942 reason: format!("ssh-add -l exited with an unexpected code {code}"),
943 },
944 }
945}
946
947fn check_gpg_signing_viability(project_root: &Path) -> SigningViability {
950 let Some(signingkey) = git_config(project_root, "user.signingkey") else {
951 return SigningViability::Unknown {
952 reason: "cannot verify signing viability — user.signingkey is not set".into(),
953 };
954 };
955 let output = match Command::new("gpg")
956 .args(["--list-secret-keys", &signingkey])
957 .output()
958 {
959 Ok(out) => out,
960 Err(_) => {
961 return SigningViability::Unknown {
962 reason: "cannot verify signing viability — gpg not found".into(),
963 };
964 }
965 };
966 if output.status.success() {
967 SigningViability::Viable {
968 fingerprint: Some(signingkey),
969 }
970 } else {
971 SigningViability::NotViable {
972 reason: "no secret key found for the configured user.signingkey".into(),
973 }
974 }
975}
976
977pub fn check_signing_viability(project_root: &Path) -> SigningViability {
984 match git_config(project_root, "gpg.format").as_deref() {
985 Some("ssh") => check_ssh_signing_viability(project_root),
986 _ => check_gpg_signing_viability(project_root),
987 }
988}
989
990fn stderr_or_status(output: &std::process::Output) -> String {
991 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
992 if stderr.is_empty() {
993 format!("exited with {}", output.status)
994 } else {
995 stderr
996 }
997}
998
999#[cfg(test)]
1000mod tests {
1001 use super::*;
1002 use tempfile::TempDir;
1003
1004 fn git(root: &Path, args: &[&str]) {
1006 let output = crate::test_support::git_command(root)
1007 .args(args)
1008 .output()
1009 .expect("spawn git");
1010 assert!(
1011 output.status.success(),
1012 "git {args:?} failed: {}",
1013 String::from_utf8_lossy(&output.stderr)
1014 );
1015 }
1016
1017 fn current_branch(root: &Path) -> String {
1018 let output = crate::test_support::git_command(root)
1019 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1020 .output()
1021 .expect("rev-parse");
1022 String::from_utf8_lossy(&output.stdout).trim().to_string()
1023 }
1024
1025 fn commit_file(root: &Path, name: &str) {
1026 std::fs::write(root.join(name), name).unwrap();
1027 git(root, &["add", "."]);
1028 git(root, &["commit", "-q", "-m", &format!("add {name}")]);
1029 }
1030
1031 fn init_repo() -> TempDir {
1033 let dir = tempfile::tempdir().unwrap();
1034 let root = dir.path();
1035 git(root, &["init", "-q"]);
1036 git(root, &["config", "user.email", "test@example.com"]);
1037 git(root, &["config", "user.name", "Test"]);
1038 git(root, &["config", "commit.gpgsign", "false"]);
1039 git(root, &["config", "tag.gpgsign", "false"]);
1040 git(root, &["config", "core.hooksPath", "/dev/null"]);
1042 commit_file(root, "README.md");
1043 git(root, &["branch", "-M", "main"]);
1044 git(root, &["checkout", "-q", "-b", "develop"]);
1045 dir
1046 }
1047
1048 fn flow(root: &Path) -> GitFlow {
1049 GitFlow::new(root)
1050 }
1051
1052 #[test]
1053 fn feature_start_branches_from_develop() {
1054 let repo = init_repo();
1055 let root = repo.path();
1056 let branch = flow(root).feature_start(3).expect("feature_start");
1057 assert_eq!(branch, "feature/phase-03");
1058 assert_eq!(current_branch(root), "feature/phase-03");
1059 }
1060
1061 #[test]
1062 fn list_feature_branches_reports_ahead_and_behind_semantics() {
1063 let repo = init_repo();
1064 let root = repo.path();
1065 let gf = flow(root);
1066
1067 gf.feature_start(12).expect("feature_start");
1068 commit_file(root, "feature-one.txt");
1069 commit_file(root, "feature-two.txt");
1070 git(root, &["checkout", "-q", "develop"]);
1071 commit_file(root, "develop-only.txt");
1072
1073 let branches = gf.list_feature_branches().unwrap();
1074 let branch = branches
1075 .iter()
1076 .find(|branch| branch.name == "feature/phase-12")
1077 .unwrap();
1078
1079 assert_eq!(branch.ahead, 2);
1080 assert_eq!(branch.behind, 1);
1081 }
1082
1083 #[test]
1084 fn feature_finish_merges_into_develop_and_deletes() {
1085 let repo = init_repo();
1086 let root = repo.path();
1087 let gf = flow(root);
1088
1089 gf.feature_start(1).expect("start");
1090 commit_file(root, "feature.txt");
1091
1092 let branch = gf.feature_finish(1).expect("finish");
1093 assert_eq!(branch, "feature/phase-01");
1094 assert_eq!(current_branch(root), "develop");
1095
1096 let branches = crate::test_support::git_command(root)
1098 .args(["branch"])
1099 .output()
1100 .unwrap();
1101 let listing = String::from_utf8_lossy(&branches.stdout);
1102 assert!(!listing.contains("feature/phase-01"));
1103 assert!(root.join("feature.txt").exists());
1104 }
1105
1106 #[test]
1107 fn release_start_and_finish_tags_main_and_merges_both() {
1108 let repo = init_repo();
1109 let root = repo.path();
1110 let gf = flow(root);
1111
1112 commit_file(root, "work.txt");
1114 let branch = gf.release_start("1.2.0").expect("release_start");
1115 assert_eq!(branch, "release/1.2.0");
1116
1117 gf.release_finish("1.2.0").expect("release_finish");
1118 assert_eq!(current_branch(root), "develop");
1119
1120 let tags = crate::test_support::git_command(root)
1122 .args(["tag"])
1123 .output()
1124 .unwrap();
1125 assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
1126
1127 let branches = crate::test_support::git_command(root)
1129 .args(["branch"])
1130 .output()
1131 .unwrap();
1132 assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
1133 }
1134
1135 #[test]
1142 fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
1143 let repo = init_repo();
1144 let root = repo.path();
1145 git(root, &["config", "tag.gpgsign", "true"]);
1149
1150 flow(root)
1151 .tag("v9.9.9")
1152 .expect("tag must not block on $EDITOR");
1153
1154 let tags = crate::test_support::git_command(root)
1155 .args(["tag", "-l"])
1156 .output()
1157 .unwrap();
1158 assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
1159
1160 let obj_type = crate::test_support::git_command(root)
1164 .args(["cat-file", "-t", "v9.9.9"])
1165 .output()
1166 .unwrap();
1167 assert_eq!(
1168 String::from_utf8_lossy(&obj_type.stdout).trim(),
1169 "commit",
1170 "tag() must stay lightweight even when tag.gpgsign=true"
1171 );
1172 }
1173
1174 #[test]
1175 fn commit_path_stages_only_the_given_path_leaving_other_dirt_uncommitted() {
1176 let repo = init_repo();
1180 let root = repo.path();
1181 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1182 std::fs::write(root.join("unrelated.txt"), "not part of this commit\n").unwrap();
1183
1184 crate::test_support::git_command(root)
1189 .args(["add", "unrelated.txt"])
1190 .status()
1191 .unwrap();
1192
1193 flow(root)
1194 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1195 .expect("commit_path");
1196
1197 let committed = crate::test_support::git_command(root)
1198 .args(["log", "-1", "--name-only", "--pretty=format:"])
1199 .output()
1200 .unwrap();
1201 let committed_files = String::from_utf8_lossy(&committed.stdout);
1202 assert!(committed_files.contains("CHANGELOG.md"));
1203 assert!(!committed_files.contains("unrelated.txt"));
1204
1205 let status = crate::test_support::git_command(root)
1206 .args(["status", "--porcelain"])
1207 .output()
1208 .unwrap();
1209 let status = String::from_utf8_lossy(&status.stdout);
1210 assert!(
1211 status.contains("A unrelated.txt"),
1212 "unrelated.txt must remain staged-but-uncommitted, got: {status}"
1213 );
1214 }
1215
1216 fn rev_list_count(root: &Path) -> u32 {
1219 let output = crate::test_support::git_command(root)
1220 .args(["rev-list", "--count", "HEAD"])
1221 .output()
1222 .unwrap();
1223 assert!(output.status.success(), "git rev-list --count HEAD failed");
1224 String::from_utf8_lossy(&output.stdout)
1225 .trim()
1226 .parse::<u32>()
1227 .expect("rev-list --count HEAD must print an integer")
1228 }
1229
1230 #[test]
1238 fn commit_path_twice_with_identical_content_creates_only_one_commit() {
1239 let repo = init_repo();
1240 let root = repo.path();
1241 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1242
1243 flow(root)
1244 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1245 .expect("first commit_path call");
1246 let n1 = rev_list_count(root);
1247
1248 flow(root)
1251 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1252 .expect("second commit_path call");
1253 let n2 = rev_list_count(root);
1254
1255 assert_eq!(
1256 n2, n1,
1257 "a repeat commit_path call on unchanged content must not add a \
1258 commit: n1={n1}, n2={n2}"
1259 );
1260 }
1261
1262 #[test]
1270 fn commit_path_with_no_changes_returns_ok_without_committing() {
1271 let repo = init_repo();
1272 let root = repo.path();
1273 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1274 flow(root)
1275 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1276 .expect("initial commit_path");
1277 let n1 = rev_list_count(root);
1278
1279 let result = flow(root).commit_path("CHANGELOG.md", "docs: add changelog entry");
1282 let n2 = rev_list_count(root);
1283
1284 assert!(
1285 result.is_ok(),
1286 "no-op call must return Ok(()), got: {result:?}"
1287 );
1288 assert_eq!(
1289 n2, n1,
1290 "no-op call must not create a commit: n1={n1}, n2={n2}"
1291 );
1292 }
1293
1294 #[test]
1300 fn commit_path_on_nonexistent_path_still_errors() {
1301 let repo = init_repo();
1302 let root = repo.path();
1303
1304 let result = flow(root).commit_path("does-not-exist.md", "docs: add changelog entry");
1305
1306 assert!(
1307 result.is_err(),
1308 "commit_path on an unknown pathspec must still error, got: {result:?}"
1309 );
1310 }
1311
1312 #[test]
1313 fn release_start_branches_from_current_head_not_develop() {
1314 let repo = init_repo();
1315 let root = repo.path();
1316 let gf = flow(root);
1317
1318 gf.feature_start(5).expect("feature_start");
1320 commit_file(root, "feature-only.txt");
1321 let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
1322
1323 let branch = gf.release_start("2.0.0").expect("release_start");
1324 assert_eq!(branch, "release/2.0.0");
1325 assert_eq!(current_branch(root), "release/2.0.0");
1326
1327 let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
1330 let is_ancestor = crate::test_support::git_command(root)
1331 .args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
1332 .output()
1333 .unwrap()
1334 .status
1335 .success();
1336 assert!(
1337 is_ancestor,
1338 "release branch must descend from the shipped feature commit"
1339 );
1340 assert!(root.join("feature-only.txt").exists());
1341 }
1342
1343 #[test]
1344 fn cleanup_merged_removes_merged_but_keeps_protected() {
1345 let repo = init_repo();
1346 let root = repo.path();
1347 let gf = flow(root);
1348
1349 gf.feature_start(2).expect("start");
1351 commit_file(root, "f.txt");
1352 gf.feature_finish(2).expect("finish");
1353
1354 git(root, &["branch", "stale-merged"]);
1356
1357 let deleted = gf.cleanup_merged().expect("cleanup");
1358 assert!(deleted.contains(&"stale-merged".to_string()));
1359 assert!(!deleted.contains(&"develop".to_string()));
1361 assert!(!deleted.contains(&"main".to_string()));
1362 }
1363
1364 #[test]
1371 fn cleanup_merged_is_relative_to_develop_not_current_head() {
1372 let repo = init_repo();
1373 let root = repo.path();
1374 let gf = flow(root);
1375
1376 git(root, &["checkout", "-q", "-b", "topic", "develop"]);
1380 commit_file(root, "topic-only.txt");
1381 git(root, &["checkout", "-q", "-b", "premature", "topic"]);
1382
1383 git(root, &["checkout", "-q", "topic"]);
1391
1392 let _ = gf.cleanup_merged();
1393 assert!(
1394 gf.branch_exists("premature"),
1395 "premature is merged into topic (current HEAD) but not into \
1396 develop — it must survive cleanup_merged when the baseline is develop"
1397 );
1398 }
1399
1400 #[test]
1408 fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
1409 let repo = init_repo();
1410 let root = repo.path();
1411 let gf = flow(root);
1412
1413 git(
1416 root,
1417 &["checkout", "-q", "-b", "worktree-merged", "develop"],
1418 );
1419 commit_file(root, "g.txt");
1420 git(root, &["checkout", "-q", "develop"]);
1421 git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
1422
1423 let wt_dir = tempfile::tempdir().unwrap();
1426 git(
1427 root,
1428 &[
1429 "worktree",
1430 "add",
1431 wt_dir.path().to_str().unwrap(),
1432 "worktree-merged",
1433 ],
1434 );
1435
1436 git(root, &["branch", "aa-stale"]);
1440 git(root, &["branch", "zz-stale"]);
1441
1442 let deleted = gf
1443 .cleanup_merged()
1444 .expect("a skipped worktree branch must not abort the sweep");
1445 assert!(deleted.contains(&"aa-stale".to_string()));
1446 assert!(deleted.contains(&"zz-stale".to_string()));
1447 assert!(
1448 !deleted.contains(&"worktree-merged".to_string()),
1449 "worktree checkout cannot be deleted"
1450 );
1451 assert!(gf.branch_exists("worktree-merged"));
1452 }
1453
1454 #[test]
1459 fn cleanup_merged_deletes_when_head_is_not_on_develop() {
1460 let repo = init_repo();
1461 let root = repo.path();
1462 let gf = flow(root);
1463
1464 git(root, &["checkout", "-q", "-b", "old", "develop"]);
1467 git(root, &["checkout", "-q", "develop"]);
1468 git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
1469 commit_file(root, "h.txt");
1470 git(root, &["checkout", "-q", "develop"]);
1471 git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
1472 git(root, &["checkout", "-q", "old"]);
1473
1474 let deleted = gf.cleanup_merged().expect("cleanup");
1475 assert!(
1476 deleted.contains(&"merged-feature".to_string()),
1477 "merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
1478 );
1479 assert!(!gf.branch_exists("merged-feature"));
1480 }
1481
1482 #[test]
1483 fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
1484 let repo = init_repo();
1485 let root = repo.path();
1486 let gf = flow(root);
1487
1488 gf.feature_start(8).expect("start");
1490 commit_file(root, "unmerged.txt");
1491 git(root, &["checkout", "-q", "develop"]);
1493
1494 assert!(gf.delete_branch("feature/phase-08", false).is_err());
1496 gf.delete_branch("feature/phase-08", true)
1497 .expect("force delete");
1498 let branches = crate::test_support::git_command(root)
1499 .args(["branch"])
1500 .output()
1501 .unwrap();
1502 assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
1503
1504 assert!(gf.delete_branch("develop", true).is_err());
1506 assert!(gf.delete_branch("main", true).is_err());
1507 }
1508
1509 #[test]
1510 fn merge_of_missing_branch_is_an_error() {
1511 let repo = init_repo();
1512 let root = repo.path();
1513 let err = flow(root).feature_finish(99).unwrap_err();
1516 assert!(matches!(err, GitError::Command(_)));
1517 }
1518
1519 #[test]
1524 fn workspace_member_paths_parses_multiline_array() {
1525 let contents = "[workspace]\nresolver = \"2\"\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n";
1526 assert_eq!(
1527 workspace_member_paths(contents),
1528 vec![
1529 "crates/devflow-core".to_string(),
1530 "crates/devflow-cli".to_string()
1531 ]
1532 );
1533 }
1534
1535 #[test]
1536 fn package_name_reads_the_package_section() {
1537 let contents = "[package]\nname = \"devflow-core\"\nversion.workspace = true\n";
1538 assert_eq!(package_name(contents), Some("devflow-core".to_string()));
1539 }
1540
1541 #[test]
1542 fn member_depends_on_matches_dotted_workspace_shorthand() {
1543 let contents = "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\nclap.workspace = true\n";
1544 assert!(member_depends_on(contents, "devflow-core"));
1545 assert!(!member_depends_on(contents, "serde"));
1546 }
1547
1548 #[test]
1554 fn member_depends_on_matches_long_form_dependency_section() {
1555 let contents = "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n\n[dependencies.clap]\nversion = \"4\"\n";
1556 assert!(member_depends_on(contents, "devflow-core"));
1557 assert!(member_depends_on(contents, "clap"));
1558 assert!(!member_depends_on(contents, "serde"));
1559 }
1560
1561 #[test]
1562 fn topo_sort_orders_dependency_before_dependent() {
1563 let names = vec!["devflow".to_string(), "devflow-core".to_string()];
1564 let edges = vec![("devflow".to_string(), "devflow-core".to_string())];
1565 assert_eq!(
1566 topo_sort(names, edges),
1567 vec!["devflow-core".to_string(), "devflow".to_string()]
1568 );
1569 }
1570
1571 #[test]
1572 fn topo_sort_falls_back_to_input_order_on_a_cycle() {
1573 let names = vec!["a".to_string(), "b".to_string()];
1576 let edges = vec![
1577 ("a".to_string(), "b".to_string()),
1578 ("b".to_string(), "a".to_string()),
1579 ];
1580 let result = topo_sort(names, edges);
1581 assert_eq!(result.len(), 2);
1582 }
1583
1584 #[test]
1585 fn publish_order_derives_core_before_cli_from_a_fixture_workspace() {
1586 let dir = tempfile::tempdir().unwrap();
1587 let root = dir.path();
1588 std::fs::write(
1589 root.join("Cargo.toml"),
1590 "[workspace]\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n",
1591 )
1592 .unwrap();
1593 std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1594 std::fs::write(
1595 root.join("crates/devflow-core/Cargo.toml"),
1596 "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1597 )
1598 .unwrap();
1599 std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1600 std::fs::write(
1601 root.join("crates/devflow-cli/Cargo.toml"),
1602 "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\n",
1603 )
1604 .unwrap();
1605
1606 assert_eq!(
1607 publish_order(root),
1608 vec!["devflow-core".to_string(), "devflow".to_string()]
1609 );
1610 }
1611
1612 #[test]
1619 fn publish_order_recognizes_long_form_dependency_section_self_dependency() {
1620 let dir = tempfile::tempdir().unwrap();
1621 let root = dir.path();
1622 std::fs::write(
1623 root.join("Cargo.toml"),
1624 "[workspace]\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n",
1625 )
1626 .unwrap();
1627 std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1628 std::fs::write(
1629 root.join("crates/devflow-core/Cargo.toml"),
1630 "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1631 )
1632 .unwrap();
1633 std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1634 std::fs::write(
1635 root.join("crates/devflow-cli/Cargo.toml"),
1636 "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n",
1637 )
1638 .unwrap();
1639
1640 assert_eq!(
1641 publish_order(root),
1642 vec!["devflow-core".to_string(), "devflow".to_string()],
1643 "the long-form dependency section must still order devflow-core before devflow"
1644 );
1645 }
1646
1647 #[test]
1652 fn origin_main_ancestor_status_is_ref_absent_without_a_remote() {
1653 let repo = init_repo();
1654 let root = repo.path();
1655 assert_eq!(origin_main_ancestor_status(root), AncestorStatus::RefAbsent);
1656 }
1657
1658 #[test]
1659 fn origin_main_ancestor_status_is_ancestor_when_head_is_up_to_date() {
1660 let repo = init_repo();
1661 let root = repo.path();
1662 let head = crate::test_support::git_command(root)
1663 .args(["rev-parse", "HEAD"])
1664 .output()
1665 .unwrap();
1666 let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1667 git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1668 assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1669 }
1670
1671 #[test]
1680 fn hermetic_command_resolves_caller_root_even_under_a_hostile_git_dir() {
1681 let real_repo = init_repo();
1682 let real_root = real_repo.path();
1683
1684 let foreign_repo = TempDir::new().unwrap();
1685 git(foreign_repo.path(), &["init", "-q"]);
1686
1687 let output = git_command(real_root)
1688 .args(["rev-parse", "--show-toplevel"])
1689 .env("GIT_DIR", foreign_repo.path().join(".git"))
1693 .output()
1694 .expect("spawn git");
1695 assert!(
1696 output.status.success(),
1697 "rev-parse --show-toplevel failed: {}",
1698 String::from_utf8_lossy(&output.stderr)
1699 );
1700
1701 let resolved = std::fs::canonicalize(String::from_utf8_lossy(&output.stdout).trim())
1702 .expect("canonicalize resolved toplevel");
1703 let expected = std::fs::canonicalize(real_root).expect("canonicalize real_root");
1704 assert_eq!(
1705 resolved, expected,
1706 "hermetic_command must resolve real_root even with a foreign GIT_DIR set"
1707 );
1708 }
1709
1710 #[test]
1731 fn origin_main_ancestor_status_holds_under_a_hostile_git_dir() {
1732 let repo = init_repo();
1733 let root = repo.path();
1734 let head = crate::test_support::git_command(root)
1735 .args(["rev-parse", "HEAD"])
1736 .output()
1737 .unwrap();
1738 let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1739 git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1740
1741 let cmd = git_command(root);
1743 assert!(
1744 cmd.get_envs()
1745 .any(|(key, value)| key == "GIT_DIR" && value.is_none()),
1746 "origin_main_ancestor_status's own Command must mark GIT_DIR for removal"
1747 );
1748
1749 assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1751 }
1752
1753 #[test]
1761 fn git_command_marks_every_redirecting_var_for_removal() {
1762 let cmd = git_command(Path::new("/tmp"));
1763 let removed: Vec<&str> = cmd
1764 .get_envs()
1765 .filter(|(_, value)| value.is_none())
1766 .filter_map(|(key, _)| key.to_str())
1767 .collect();
1768
1769 for var in REPO_LOCAL_GIT_VARS.iter().chain(ALSO_REDIRECTING_GIT_VARS) {
1770 assert!(
1771 removed.contains(var),
1772 "{var} is not cleared by git_command — a fixture inheriting it \
1773 would operate on that repository instead of its tempdir"
1774 );
1775 }
1776 }
1777
1778 #[test]
1782 fn git_command_preserves_git_exec_path() {
1783 let cmd = git_command(Path::new("/tmp"));
1784 assert!(
1785 !cmd.get_envs()
1786 .any(|(key, value)| key == "GIT_EXEC_PATH" && value.is_none()),
1787 "GIT_EXEC_PATH must not be cleared"
1788 );
1789 }
1790
1791 #[test]
1795 fn local_env_vars_match_git() {
1796 let output = git_command(Path::new("/tmp"))
1797 .args(["rev-parse", "--local-env-vars"])
1798 .output()
1799 .expect("run `git rev-parse --local-env-vars`");
1800 assert!(
1801 output.status.success(),
1802 "`git rev-parse --local-env-vars` failed"
1803 );
1804
1805 let mut from_git: Vec<String> = String::from_utf8_lossy(&output.stdout)
1806 .lines()
1807 .map(str::trim)
1808 .filter(|line| !line.is_empty())
1809 .map(str::to_string)
1810 .collect();
1811 let mut ours: Vec<String> = REPO_LOCAL_GIT_VARS
1812 .iter()
1813 .map(|v| (*v).to_string())
1814 .collect();
1815 from_git.sort();
1816 ours.sort();
1817
1818 assert_eq!(
1819 ours, from_git,
1820 "REPO_LOCAL_GIT_VARS has drifted from `git rev-parse --local-env-vars`"
1821 );
1822 }
1823
1824 #[test]
1829 fn classify_ssh_add_status_maps_all_three_documented_exit_codes() {
1830 assert_eq!(classify_ssh_add_status(2), SigningStatus::NoAgent);
1831 assert_eq!(classify_ssh_add_status(1), SigningStatus::AgentEmpty);
1832 assert_eq!(classify_ssh_add_status(0), SigningStatus::KeysListed);
1833 assert_eq!(classify_ssh_add_status(7), SigningStatus::Unknown(7));
1834 }
1835
1836 static HOME_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1843
1844 #[test]
1845 fn check_signing_viability_degrades_when_gpg_format_unset_and_no_signingkey() {
1846 let _lock = HOME_ENV_MUTEX.lock().unwrap();
1849 let repo = init_repo();
1850 let root = repo.path();
1851 let fake_home = tempfile::tempdir().unwrap();
1852 let original_home = std::env::var_os("HOME");
1853 unsafe { std::env::set_var("HOME", fake_home.path()) };
1856
1857 let result = check_signing_viability(root);
1858
1859 match original_home {
1861 Some(home) => unsafe { std::env::set_var("HOME", home) },
1862 None => unsafe { std::env::remove_var("HOME") },
1863 }
1864
1865 match result {
1866 SigningViability::Unknown { reason } => {
1867 assert!(
1868 reason.contains("user.signingkey"),
1869 "unexpected reason: {reason}"
1870 );
1871 }
1872 other => panic!("expected Unknown (fail-soft), got: {other:?}"),
1873 }
1874 }
1875
1876 #[test]
1884 fn check_signing_viability_never_reports_key_file_missing_for_inline_key() {
1885 const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
1886 let inline_values = [
1887 "key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
1888 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
1889 ];
1890 for value in inline_values {
1891 let repo = init_repo();
1892 let root = repo.path();
1893 git(root, &["config", "gpg.format", "ssh"]);
1894 git(root, &["config", "user.signingkey", value]);
1895
1896 let result = check_signing_viability(root);
1897
1898 if let SigningViability::NotViable { reason } = &result {
1899 assert_ne!(
1900 reason, MISSING_FILE_REASON,
1901 "inline signingkey value {value:?} incorrectly classified as a \
1902 missing file: {result:?}"
1903 );
1904 }
1905 }
1906 }
1907
1908 #[test]
1914 fn inline_signing_key_blob_follows_git_prefix_precedence() {
1915 assert_eq!(
1916 inline_signing_key_blob("key::ssh-rsa AAAAB3 id"),
1917 Some("ssh-rsa AAAAB3 id")
1918 );
1919 assert_eq!(
1920 inline_signing_key_blob("key::ssh-ed25519 AAAAC3 id"),
1921 Some("ssh-ed25519 AAAAC3 id")
1922 );
1923 assert_eq!(
1924 inline_signing_key_blob("key::ecdsa-sha2-nistp256 AAAAE2 id"),
1925 Some("ecdsa-sha2-nistp256 AAAAE2 id")
1926 );
1927 assert_eq!(inline_signing_key_blob("key::"), Some(""));
1928 assert_eq!(
1929 inline_signing_key_blob("ssh-ed25519 AAAAC3 id"),
1930 Some("ssh-ed25519 AAAAC3 id")
1931 );
1932 assert_eq!(
1933 inline_signing_key_blob(" key::ssh-ed25519 AAAAC3 id "),
1934 Some("ssh-ed25519 AAAAC3 id")
1935 );
1936 assert_eq!(inline_signing_key_blob("ssh-key.pub"), Some("ssh-key.pub"));
1939 assert_eq!(
1940 inline_signing_key_blob("/home/operator/.ssh/id_ed25519.pub"),
1941 None
1942 );
1943 assert_eq!(
1946 inline_signing_key_blob("ecdsa-sha2-nistp256 AAAAE2 id"),
1947 None
1948 );
1949 assert_eq!(
1950 inline_signing_key_blob("sk-ssh-ed25519@openssh.com AAAAG id"),
1951 None
1952 );
1953 assert_eq!(inline_signing_key_blob("ABCD1234"), None);
1954 }
1955
1956 #[test]
1962 fn check_signing_viability_still_reports_missing_file_for_a_path_value() {
1963 const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
1964 let path_values = [
1965 "/nonexistent/path/to/a/signing/key/that/does/not/exist",
1966 "ecdsa-sha2-nistp256 AAAAE2 devflow-fixture",
1967 "sk-ssh-ed25519@openssh.com AAAAG devflow-fixture",
1968 ];
1969 for value in path_values {
1970 let repo = init_repo();
1971 let root = repo.path();
1972 git(root, &["config", "gpg.format", "ssh"]);
1973 git(root, &["config", "user.signingkey", value]);
1974
1975 let result = check_signing_viability(root);
1976
1977 assert_eq!(
1978 result,
1979 SigningViability::NotViable {
1980 reason: MISSING_FILE_REASON.to_string(),
1981 },
1982 "value {value:?} did not take the path branch: {result:?}"
1983 );
1984 }
1985 }
1986
1987 #[test]
1996 fn inline_key_fingerprint_matches_the_path_branch_for_the_same_key() {
1997 let dir = tempfile::tempdir().unwrap();
1998 let key_path = dir.path().join("devflow-fixture-key");
1999 let keygen = Command::new("ssh-keygen")
2000 .args([
2001 "-t",
2002 "ed25519",
2003 "-f",
2004 key_path.to_str().unwrap(),
2005 "-N",
2006 "",
2007 "-q",
2008 ])
2009 .output()
2010 .expect("spawn ssh-keygen");
2011 assert!(
2012 keygen.status.success(),
2013 "ssh-keygen fixture setup failed: {}",
2014 String::from_utf8_lossy(&keygen.stderr)
2015 );
2016 let pub_key_path = dir.path().join("devflow-fixture-key.pub");
2017 let blob = std::fs::read_to_string(&pub_key_path)
2018 .unwrap()
2019 .trim()
2020 .to_string();
2021
2022 let inline_fp = inline_key_fingerprint(&blob);
2025 assert!(
2026 inline_fp.is_some(),
2027 "inline_key_fingerprint returned None for a real key"
2028 );
2029 let inline_fp = inline_fp.unwrap();
2030 assert!(
2031 inline_fp.starts_with("SHA256:"),
2032 "unexpected fingerprint shape: {inline_fp}"
2033 );
2034
2035 let path_fp = public_key_fingerprint(&pub_key_path);
2036 assert!(
2037 path_fp.is_some(),
2038 "public_key_fingerprint returned None for a real key"
2039 );
2040 let path_fp = path_fp.unwrap();
2041
2042 assert_eq!(inline_fp, path_fp);
2043
2044 let prefixed = format!("key::{blob}");
2048 let classified_blob = inline_signing_key_blob(&prefixed).unwrap();
2049 let chained_fp = inline_key_fingerprint(classified_blob).unwrap();
2050 assert_eq!(chained_fp, path_fp);
2051 }
2052
2053 #[test]
2060 fn check_signing_viability_never_hard_fails_on_an_unparseable_inline_key() {
2061 const NO_AGENT_REASON: &str = "no ssh-agent reachable (SSH_AUTH_SOCK unset or dead)";
2062 const AGENT_EMPTY_REASON: &str = "ssh-agent reachable but has no identities loaded";
2063 let unparseable_values = ["key::", "key::this is not a key at all"];
2064 for value in unparseable_values {
2065 let repo = init_repo();
2066 let root = repo.path();
2067 git(root, &["config", "gpg.format", "ssh"]);
2068 git(root, &["config", "user.signingkey", value]);
2069
2070 let result = check_signing_viability(root);
2071
2072 if let SigningViability::NotViable { reason } = &result {
2073 assert!(
2074 reason == NO_AGENT_REASON || reason == AGENT_EMPTY_REASON,
2075 "value {value:?} produced an unexpected hard fail: {result:?}"
2076 );
2077 }
2078 }
2079
2080 assert_eq!(inline_key_fingerprint(""), None);
2081 assert_eq!(inline_key_fingerprint("not a key\n"), None);
2082 }
2083}