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
20#[derive(Debug, Clone)]
22pub struct GitFlow {
23 root: PathBuf,
24 config: GitFlowConfig,
25}
26
27#[derive(Debug, Clone)]
29pub struct BranchInfo {
30 pub name: String,
32 pub ahead: usize,
34 pub behind: usize,
36 pub last_commit: String,
38}
39
40impl GitFlow {
41 pub fn new(root: impl AsRef<Path>) -> Self {
44 Self {
45 root: root.as_ref().to_path_buf(),
46 config: GitFlowConfig::default(),
47 }
48 }
49
50 pub fn feature_start(&self, phase: u32) -> Result<String, GitError> {
55 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
56 info!("creating feature branch: {branch}");
57 self.git(["checkout", &self.config.develop])?;
58 self.git(["checkout", "-b", &branch])?;
59 Ok(branch)
60 }
61
62 pub fn feature_start_force(&self, phase: u32) -> Result<String, GitError> {
64 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
65 warn!("force-creating feature branch: {branch}");
66 self.git(["checkout", &self.config.develop])?;
67 self.git(["checkout", "-B", &branch])?;
68 Ok(branch)
69 }
70
71 pub fn feature_finish(&self, phase: u32) -> Result<String, GitError> {
73 let branch = self.merge_feature_into_develop(phase)?;
74 self.git(["branch", "-d", &branch])?;
75 Ok(branch)
76 }
77
78 pub fn merge_feature_into_develop(&self, phase: u32) -> Result<String, GitError> {
83 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
84 info!("merging feature branch: {branch}");
85 self.git(["checkout", &self.config.develop])?;
86 self.git(["merge", "--no-ff", &branch])?;
87 Ok(branch)
88 }
89
90 pub fn is_merged_into_develop(&self, phase: u32) -> bool {
95 let branch = format!("{}phase-{:02}", self.config.feature_prefix, phase);
96 if !self.branch_exists(&branch) {
97 return false;
98 }
99
100 Command::new("git")
101 .args(["merge-base", "--is-ancestor", &branch, &self.config.develop])
102 .current_dir(&self.root)
103 .output()
104 .map(|output| output.status.success())
105 .unwrap_or(false)
106 }
107
108 pub fn release_start(&self, version: &str) -> Result<String, GitError> {
115 let branch = format!("release/{version}");
116 info!("creating release branch: {branch}");
117 self.git(["checkout", "-B", &branch])?;
118 Ok(branch)
119 }
120
121 pub fn release_finish(&self, version: &str) -> Result<String, GitError> {
123 let branch = format!("release/{version}");
124 info!("finishing release branch: {branch}");
125 self.git(["checkout", &self.config.main])?;
126 self.git(["merge", "--no-ff", &branch])?;
127 self.git(["-c", "tag.gpgSign=false", "tag", &format!("v{version}")])?;
134 self.git(["checkout", &self.config.develop])?;
135 self.git(["merge", "--no-ff", &branch])?;
136 self.git(["branch", "-d", &branch])?;
137 Ok(branch)
138 }
139
140 pub fn tag(&self, tag: &str) -> Result<(), GitError> {
149 info!("tagging {tag}");
150 self.git(["-c", "tag.gpgSign=false", "tag", tag])
151 }
152
153 pub fn delete_branch(&self, branch: &str, force: bool) -> Result<(), GitError> {
159 if branch == self.config.main || branch == self.config.develop {
160 return Err(GitError::Command(format!(
161 "refusing to delete protected branch `{branch}`"
162 )));
163 }
164 let flag = if force { "-D" } else { "-d" };
165 if force {
166 warn!("force-deleting branch: {branch}");
167 } else {
168 info!("deleting branch: {branch}");
169 }
170 self.git(["branch", flag, branch])
171 }
172
173 pub fn branch_exists(&self, branch: &str) -> bool {
175 Command::new("git")
176 .args([
177 "rev-parse",
178 "--verify",
179 "--quiet",
180 &format!("refs/heads/{branch}"),
181 ])
182 .current_dir(&self.root)
183 .output()
184 .map(|o| o.status.success())
185 .unwrap_or(false)
186 }
187
188 pub fn branch_tip(&self, branch: &str) -> Result<String, GitError> {
190 Ok(self.git_output(["rev-parse", branch])?.trim().to_string())
191 }
192
193 pub fn ensure_branch(&self, branch: &str, start_point: &str) -> Result<(), GitError> {
196 if self.branch_exists(branch) {
197 return Ok(());
198 }
199 self.git(["branch", branch, start_point])
200 }
201
202 pub fn checkout(&self, branch: &str) -> Result<(), GitError> {
204 debug!("checking out branch: {branch}");
205 self.git(["checkout", branch])
206 }
207
208 pub fn delete_remote_branch(&self, branch: &str) -> Result<(), GitError> {
210 info!("deleting remote branch: {branch}");
211 self.git(["push", "origin", "--delete", branch])
212 }
213
214 pub fn has_remote(&self) -> bool {
216 self.git_output(["remote"])
217 .map(|s| !s.trim().is_empty())
218 .unwrap_or(false)
219 }
220
221 pub fn push(&self, branch: &str) -> Result<(), GitError> {
223 info!("pushing branch: {branch}");
224 self.git(["push", "-u", "origin", branch])
225 }
226
227 pub fn cleanup_merged(&self) -> Result<Vec<String>, GitError> {
243 let output = self.git_output(["branch", "--merged", &self.config.develop])?;
244 let protected = [self.config.main.as_str(), self.config.develop.as_str()];
245 let mut deleted = Vec::new();
246 for line in output.lines() {
247 let branch = line
253 .strip_prefix("* ")
254 .or_else(|| line.strip_prefix("+ "))
255 .unwrap_or(line)
256 .trim();
257 if branch.is_empty() || branch.starts_with('(') || protected.contains(&branch) {
260 continue;
261 }
262 info!("cleaning up merged branch: {branch}");
263 match self.git(["branch", "-D", branch]) {
264 Ok(()) => deleted.push(branch.to_string()),
265 Err(err) => warn!("could not delete merged branch {branch}: {err}"),
266 }
267 }
268 Ok(deleted)
269 }
270
271 pub fn commit_all(&self, message: &str) -> Result<(), GitError> {
274 debug!("committing all changes: {message}");
275 self.git(["add", "."])?;
276 match self.git_raw(&["commit", "--allow-empty", "-m", message]) {
278 Ok(()) => Ok(()),
279 Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
282 Err(e) => Err(e),
283 }
284 }
285
286 pub fn commit_path(&self, relative_path: &str, message: &str) -> Result<(), GitError> {
295 debug!("committing {relative_path}: {message}");
296 self.git(["add", relative_path])?;
302 match self.git_raw_combined(&["commit", "-m", message, "--", relative_path]) {
303 Ok(()) => Ok(()),
304 Err(GitError::Command(ref msg)) if msg.contains("nothing to commit") => Ok(()),
309 Err(e) => Err(e),
310 }
311 }
312
313 pub fn divergence_from_develop(&self) -> Result<(usize, usize), GitError> {
319 let current = self
320 .git_output(["rev-parse", "--abbrev-ref", "HEAD"])?
321 .trim()
322 .to_string();
323 if current == self.config.develop {
324 return Ok((0, 0));
325 }
326 let ahead = self
327 .rev_count(&format!("{}..{current}", self.config.develop))
328 .unwrap_or(0);
329 let behind = self
330 .rev_count(&format!("{current}..{}", self.config.develop))
331 .unwrap_or(0);
332 Ok((ahead, behind))
333 }
334
335 pub fn list_feature_branches(&self) -> Result<Vec<BranchInfo>, GitError> {
340 let prefix = &self.config.feature_prefix;
341 let branches = self.git_output(["branch", "--format=%(refname:short)"])?;
342 let mut result = Vec::new();
343 for name in branches.lines().map(|l| l.trim()) {
344 if name.is_empty()
345 || name == self.config.main
346 || name == self.config.develop
347 || !name.starts_with(prefix)
348 {
349 continue;
350 }
351 let ahead = self
352 .rev_count(&format!("{dev}..{name}", dev = self.config.develop))
353 .unwrap_or(0);
354 let behind = self
355 .rev_count(&format!("{name}..{dev}", dev = self.config.develop))
356 .unwrap_or(0);
357 let last_commit = self
358 .git_output(["log", "-1", "--format=%aI", name])
359 .map(|s| s.trim().to_string())
360 .unwrap_or_default();
361 result.push(BranchInfo {
362 name: name.to_string(),
363 ahead,
364 behind,
365 last_commit,
366 });
367 }
368 result.sort_by(|a, b| a.name.cmp(&b.name));
370 Ok(result)
371 }
372
373 fn rev_count(&self, range: &str) -> Option<usize> {
375 self.git_output(["rev-list", "--count", range])
376 .ok()
377 .and_then(|s| s.trim().parse().ok())
378 }
379
380 fn git_raw(&self, args: &[&str]) -> Result<(), GitError> {
381 debug!("git {}", args.join(" "));
382 let output = Command::new("git")
388 .args(args)
389 .env("LC_ALL", "C")
390 .env("LANG", "C")
391 .current_dir(&self.root)
392 .output()?;
393 if output.status.success() {
394 Ok(())
395 } else {
396 Err(GitError::Command(stderr_or_status(&output)))
397 }
398 }
399
400 fn git_raw_combined(&self, args: &[&str]) -> Result<(), GitError> {
414 debug!("git {}", args.join(" "));
415 let output = Command::new("git")
416 .args(args)
417 .env("LC_ALL", "C")
418 .env("LANG", "C")
419 .current_dir(&self.root)
420 .output()?;
421 if output.status.success() {
422 Ok(())
423 } else {
424 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
425 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
426 let combined = match (stderr.is_empty(), stdout.is_empty()) {
427 (false, false) => format!("{stderr}\n{stdout}"),
428 (false, true) => stderr,
429 (true, false) => stdout,
430 (true, true) => format!("exited with {}", output.status),
431 };
432 Err(GitError::Command(combined))
433 }
434 }
435
436 fn git<const N: usize>(&self, args: [&str; N]) -> Result<(), GitError> {
437 debug!("git {}", args.iter().copied().collect::<Vec<_>>().join(" "));
438 let output = Command::new("git")
439 .args(args)
440 .current_dir(&self.root)
441 .output()?;
442 if output.status.success() {
443 Ok(())
444 } else {
445 Err(GitError::Command(stderr_or_status(&output)))
446 }
447 }
448
449 fn git_output<const N: usize>(&self, args: [&str; N]) -> Result<String, GitError> {
450 let output = Command::new("git")
451 .args(args)
452 .current_dir(&self.root)
453 .output()?;
454 if output.status.success() {
455 Ok(String::from_utf8_lossy(&output.stdout).to_string())
456 } else {
457 Err(GitError::Command(stderr_or_status(&output)))
458 }
459 }
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467pub enum AncestorStatus {
468 Ancestor,
470 Diverged,
474 RefAbsent,
479}
480
481pub fn origin_main_ancestor_status(project_root: &Path) -> AncestorStatus {
488 let ref_exists = Command::new("git")
489 .args(["rev-parse", "--verify", "--quiet", "origin/main"])
490 .current_dir(project_root)
491 .output()
492 .map(|out| out.status.success())
493 .unwrap_or(false);
494 if !ref_exists {
495 return AncestorStatus::RefAbsent;
496 }
497 let is_ancestor = Command::new("git")
498 .args(["merge-base", "--is-ancestor", "origin/main", "HEAD"])
499 .current_dir(project_root)
500 .output()
501 .map(|out| out.status.success())
502 .unwrap_or(false);
503 if is_ancestor {
504 AncestorStatus::Ancestor
505 } else {
506 AncestorStatus::Diverged
507 }
508}
509
510pub fn publish_order(project_root: &Path) -> Vec<String> {
517 let Ok(root_contents) = std::fs::read_to_string(project_root.join("Cargo.toml")) else {
518 return Vec::new();
519 };
520 let member_paths = workspace_member_paths(&root_contents);
521
522 let mut members: Vec<(String, String)> = Vec::new();
523 for path in &member_paths {
524 let manifest = project_root.join(path).join("Cargo.toml");
525 let Ok(contents) = std::fs::read_to_string(&manifest) else {
526 continue;
527 };
528 let name = package_name(&contents).unwrap_or_else(|| path.clone());
529 members.push((name, contents));
530 }
531
532 let names: Vec<String> = members.iter().map(|(name, _)| name.clone()).collect();
533 let mut edges: Vec<(String, String)> = Vec::new();
534 for (name, contents) in &members {
535 for other in &names {
536 if other != name && member_depends_on(contents, other) {
537 edges.push((name.clone(), other.clone()));
538 }
539 }
540 }
541 topo_sort(names, edges)
542}
543
544fn workspace_member_paths(contents: &str) -> Vec<String> {
549 let Some(start) = contents.find("members") else {
550 return Vec::new();
551 };
552 let rest = &contents[start..];
553 let Some(open) = rest.find('[') else {
554 return Vec::new();
555 };
556 let Some(close) = rest[open..].find(']') else {
557 return Vec::new();
558 };
559 let inner = &rest[open + 1..open + close];
560 inner
561 .split(',')
562 .filter_map(|fragment| {
563 let fragment = fragment.trim();
564 let fragment = fragment.strip_prefix('"')?.strip_suffix('"')?;
565 (!fragment.is_empty()).then(|| fragment.to_string())
566 })
567 .collect()
568}
569
570fn package_name(contents: &str) -> Option<String> {
572 let mut current = String::new();
573 for line in contents.lines() {
574 let trimmed = line.trim();
575 if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
576 current = inner.trim().to_string();
577 continue;
578 }
579 if current == "package"
580 && let Some((key, value)) = trimmed.split_once('=')
581 && key.trim() == "name"
582 {
583 return Some(value.trim().trim_matches('"').to_string());
584 }
585 }
586 None
587}
588
589fn member_depends_on(contents: &str, dep_name: &str) -> bool {
600 let mut current = String::new();
601 for line in contents.lines() {
602 let trimmed = line.trim();
603 if let Some(inner) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
604 current = inner.trim().to_string();
605 if let Some(name) = current.strip_prefix("dependencies.")
606 && name == dep_name
607 {
608 return true;
609 }
610 continue;
611 }
612 if current != "dependencies" {
613 continue;
614 }
615 let key = trimmed.split(['.', '=']).next().unwrap_or("").trim();
616 if key == dep_name {
617 return true;
618 }
619 }
620 false
621}
622
623fn topo_sort(names: Vec<String>, edges: Vec<(String, String)>) -> Vec<String> {
629 let mut result = Vec::new();
630 let mut published: Vec<String> = Vec::new();
631 let mut remaining = names;
632 while !remaining.is_empty() {
633 let ready: Vec<String> = remaining
634 .iter()
635 .filter(|name| {
636 edges
637 .iter()
638 .filter(|(dependent, _)| dependent == *name)
639 .all(|(_, dep)| published.contains(dep))
640 })
641 .cloned()
642 .collect();
643 if ready.is_empty() {
644 result.extend(remaining);
645 break;
646 }
647 for name in &ready {
648 published.push(name.clone());
649 result.push(name.clone());
650 }
651 remaining.retain(|name| !ready.contains(name));
652 }
653 result
654}
655
656#[derive(Debug, Clone, Copy, PartialEq, Eq)]
664pub enum SigningStatus {
665 NoAgent,
667 AgentEmpty,
669 KeysListed,
672 Unknown(i32),
675}
676
677pub fn classify_ssh_add_status(exit_code: i32) -> SigningStatus {
680 match exit_code {
681 2 => SigningStatus::NoAgent,
682 1 => SigningStatus::AgentEmpty,
683 0 => SigningStatus::KeysListed,
684 other => SigningStatus::Unknown(other),
685 }
686}
687
688#[derive(Debug, Clone, PartialEq, Eq)]
694pub enum SigningViability {
695 Viable { fingerprint: Option<String> },
698 NotViable { reason: String },
700 Unknown { reason: String },
703}
704
705fn git_config(project_root: &Path, key: &str) -> Option<String> {
708 let output = Command::new("git")
709 .args(["config", "--get", key])
710 .current_dir(project_root)
711 .output()
712 .ok()?;
713 if !output.status.success() {
714 return None;
715 }
716 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
717 (!value.is_empty()).then_some(value)
718}
719
720fn public_key_fingerprint(pub_key_path: &Path) -> Option<String> {
724 let path_str = pub_key_path.to_str()?;
725 let output = Command::new("ssh-keygen")
726 .args(["-lf", path_str])
727 .output()
728 .ok()?;
729 if !output.status.success() {
730 return None;
731 }
732 String::from_utf8_lossy(&output.stdout)
734 .split_whitespace()
735 .nth(1)
736 .map(str::to_string)
737}
738
739fn inline_signing_key_blob(signingkey: &str) -> Option<&str> {
752 let trimmed = signingkey.trim();
753 if let Some(remainder) = trimmed.strip_prefix("key::") {
754 Some(remainder)
755 } else if trimmed.starts_with("ssh-") {
756 Some(trimmed)
757 } else {
758 None
759 }
760}
761
762fn inline_key_fingerprint(key_blob: &str) -> Option<String> {
779 let mut child = Command::new("ssh-keygen")
780 .args(["-lf", "-"])
781 .stdin(Stdio::piped())
782 .stdout(Stdio::piped())
783 .stderr(Stdio::piped())
784 .spawn()
785 .ok()?;
786
787 let mut stdin = child.stdin.take()?;
792 stdin.write_all(key_blob.as_bytes()).ok()?;
793 drop(stdin);
794
795 let output = child.wait_with_output().ok()?;
796 if !output.status.success() {
797 return None;
798 }
799 String::from_utf8_lossy(&output.stdout)
800 .split_whitespace()
801 .nth(1)
802 .map(str::to_string)
803}
804
805fn check_ssh_signing_viability(project_root: &Path) -> SigningViability {
812 let Some(signingkey) = git_config(project_root, "user.signingkey") else {
813 return SigningViability::NotViable {
814 reason: "gpg.format=ssh but user.signingkey is not set".into(),
815 };
816 };
817
818 let inline_blob = inline_signing_key_blob(&signingkey);
822
823 if inline_blob.is_none() {
830 let key_path = Path::new(&signingkey);
831 if !key_path.exists() {
832 return SigningViability::NotViable {
833 reason: "user.signingkey is set but the key file does not exist".into(),
834 };
835 }
836 }
837
838 let output = match Command::new("ssh-add").arg("-l").output() {
839 Ok(out) => out,
840 Err(_) => {
841 return SigningViability::Unknown {
842 reason: "cannot verify signing viability — ssh-add not found".into(),
843 };
844 }
845 };
846 let exit_code = output.status.code().unwrap_or(-1);
847 match classify_ssh_add_status(exit_code) {
848 SigningStatus::NoAgent => SigningViability::NotViable {
849 reason: "no ssh-agent reachable (SSH_AUTH_SOCK unset or dead)".into(),
850 },
851 SigningStatus::AgentEmpty => SigningViability::NotViable {
852 reason: "ssh-agent reachable but has no identities loaded".into(),
853 },
854 SigningStatus::KeysListed => {
855 let stdout = String::from_utf8_lossy(&output.stdout);
856 let fingerprint = match inline_blob {
861 Some(blob) => inline_key_fingerprint(blob),
862 None => public_key_fingerprint(Path::new(&signingkey)),
863 };
864 match fingerprint {
865 Some(fingerprint) if stdout.contains(&fingerprint) => SigningViability::Viable {
866 fingerprint: Some(fingerprint),
867 },
868 Some(_) => SigningViability::NotViable {
869 reason: "ssh-agent has keys loaded, but not the configured signing key".into(),
870 },
871 None => SigningViability::Unknown {
872 reason: "cannot verify signing viability — ssh-keygen not found or the key \
873 is unreadable"
874 .into(),
875 },
876 }
877 }
878 SigningStatus::Unknown(code) => SigningViability::Unknown {
879 reason: format!("ssh-add -l exited with an unexpected code {code}"),
880 },
881 }
882}
883
884fn check_gpg_signing_viability(project_root: &Path) -> SigningViability {
887 let Some(signingkey) = git_config(project_root, "user.signingkey") else {
888 return SigningViability::Unknown {
889 reason: "cannot verify signing viability — user.signingkey is not set".into(),
890 };
891 };
892 let output = match Command::new("gpg")
893 .args(["--list-secret-keys", &signingkey])
894 .output()
895 {
896 Ok(out) => out,
897 Err(_) => {
898 return SigningViability::Unknown {
899 reason: "cannot verify signing viability — gpg not found".into(),
900 };
901 }
902 };
903 if output.status.success() {
904 SigningViability::Viable {
905 fingerprint: Some(signingkey),
906 }
907 } else {
908 SigningViability::NotViable {
909 reason: "no secret key found for the configured user.signingkey".into(),
910 }
911 }
912}
913
914pub fn check_signing_viability(project_root: &Path) -> SigningViability {
921 match git_config(project_root, "gpg.format").as_deref() {
922 Some("ssh") => check_ssh_signing_viability(project_root),
923 _ => check_gpg_signing_viability(project_root),
924 }
925}
926
927fn stderr_or_status(output: &std::process::Output) -> String {
928 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
929 if stderr.is_empty() {
930 format!("exited with {}", output.status)
931 } else {
932 stderr
933 }
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939 use tempfile::TempDir;
940
941 fn git(root: &Path, args: &[&str]) {
943 let output = crate::test_support::git_command(root)
944 .args(args)
945 .output()
946 .expect("spawn git");
947 assert!(
948 output.status.success(),
949 "git {args:?} failed: {}",
950 String::from_utf8_lossy(&output.stderr)
951 );
952 }
953
954 fn current_branch(root: &Path) -> String {
955 let output = crate::test_support::git_command(root)
956 .args(["rev-parse", "--abbrev-ref", "HEAD"])
957 .output()
958 .expect("rev-parse");
959 String::from_utf8_lossy(&output.stdout).trim().to_string()
960 }
961
962 fn commit_file(root: &Path, name: &str) {
963 std::fs::write(root.join(name), name).unwrap();
964 git(root, &["add", "."]);
965 git(root, &["commit", "-q", "-m", &format!("add {name}")]);
966 }
967
968 fn init_repo() -> TempDir {
970 let dir = tempfile::tempdir().unwrap();
971 let root = dir.path();
972 git(root, &["init", "-q"]);
973 git(root, &["config", "user.email", "test@example.com"]);
974 git(root, &["config", "user.name", "Test"]);
975 git(root, &["config", "commit.gpgsign", "false"]);
976 git(root, &["config", "tag.gpgsign", "false"]);
977 git(root, &["config", "core.hooksPath", "/dev/null"]);
979 commit_file(root, "README.md");
980 git(root, &["branch", "-M", "main"]);
981 git(root, &["checkout", "-q", "-b", "develop"]);
982 dir
983 }
984
985 fn flow(root: &Path) -> GitFlow {
986 GitFlow::new(root)
987 }
988
989 #[test]
990 fn feature_start_branches_from_develop() {
991 let repo = init_repo();
992 let root = repo.path();
993 let branch = flow(root).feature_start(3).expect("feature_start");
994 assert_eq!(branch, "feature/phase-03");
995 assert_eq!(current_branch(root), "feature/phase-03");
996 }
997
998 #[test]
999 fn list_feature_branches_reports_ahead_and_behind_semantics() {
1000 let repo = init_repo();
1001 let root = repo.path();
1002 let gf = flow(root);
1003
1004 gf.feature_start(12).expect("feature_start");
1005 commit_file(root, "feature-one.txt");
1006 commit_file(root, "feature-two.txt");
1007 git(root, &["checkout", "-q", "develop"]);
1008 commit_file(root, "develop-only.txt");
1009
1010 let branches = gf.list_feature_branches().unwrap();
1011 let branch = branches
1012 .iter()
1013 .find(|branch| branch.name == "feature/phase-12")
1014 .unwrap();
1015
1016 assert_eq!(branch.ahead, 2);
1017 assert_eq!(branch.behind, 1);
1018 }
1019
1020 #[test]
1021 fn feature_finish_merges_into_develop_and_deletes() {
1022 let repo = init_repo();
1023 let root = repo.path();
1024 let gf = flow(root);
1025
1026 gf.feature_start(1).expect("start");
1027 commit_file(root, "feature.txt");
1028
1029 let branch = gf.feature_finish(1).expect("finish");
1030 assert_eq!(branch, "feature/phase-01");
1031 assert_eq!(current_branch(root), "develop");
1032
1033 let branches = crate::test_support::git_command(root)
1035 .args(["branch"])
1036 .output()
1037 .unwrap();
1038 let listing = String::from_utf8_lossy(&branches.stdout);
1039 assert!(!listing.contains("feature/phase-01"));
1040 assert!(root.join("feature.txt").exists());
1041 }
1042
1043 #[test]
1044 fn release_start_and_finish_tags_main_and_merges_both() {
1045 let repo = init_repo();
1046 let root = repo.path();
1047 let gf = flow(root);
1048
1049 commit_file(root, "work.txt");
1051 let branch = gf.release_start("1.2.0").expect("release_start");
1052 assert_eq!(branch, "release/1.2.0");
1053
1054 gf.release_finish("1.2.0").expect("release_finish");
1055 assert_eq!(current_branch(root), "develop");
1056
1057 let tags = crate::test_support::git_command(root)
1059 .args(["tag"])
1060 .output()
1061 .unwrap();
1062 assert!(String::from_utf8_lossy(&tags.stdout).contains("v1.2.0"));
1063
1064 let branches = crate::test_support::git_command(root)
1066 .args(["branch"])
1067 .output()
1068 .unwrap();
1069 assert!(!String::from_utf8_lossy(&branches.stdout).contains("release/1.2.0"));
1070 }
1071
1072 #[test]
1079 fn tag_stays_lightweight_when_gpgsign_is_forced_on() {
1080 let repo = init_repo();
1081 let root = repo.path();
1082 git(root, &["config", "tag.gpgsign", "true"]);
1086
1087 flow(root)
1088 .tag("v9.9.9")
1089 .expect("tag must not block on $EDITOR");
1090
1091 let tags = crate::test_support::git_command(root)
1092 .args(["tag", "-l"])
1093 .output()
1094 .unwrap();
1095 assert!(String::from_utf8_lossy(&tags.stdout).contains("v9.9.9"));
1096
1097 let obj_type = crate::test_support::git_command(root)
1101 .args(["cat-file", "-t", "v9.9.9"])
1102 .output()
1103 .unwrap();
1104 assert_eq!(
1105 String::from_utf8_lossy(&obj_type.stdout).trim(),
1106 "commit",
1107 "tag() must stay lightweight even when tag.gpgsign=true"
1108 );
1109 }
1110
1111 #[test]
1112 fn commit_path_stages_only_the_given_path_leaving_other_dirt_uncommitted() {
1113 let repo = init_repo();
1117 let root = repo.path();
1118 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1119 std::fs::write(root.join("unrelated.txt"), "not part of this commit\n").unwrap();
1120
1121 crate::test_support::git_command(root)
1126 .args(["add", "unrelated.txt"])
1127 .status()
1128 .unwrap();
1129
1130 flow(root)
1131 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1132 .expect("commit_path");
1133
1134 let committed = crate::test_support::git_command(root)
1135 .args(["log", "-1", "--name-only", "--pretty=format:"])
1136 .output()
1137 .unwrap();
1138 let committed_files = String::from_utf8_lossy(&committed.stdout);
1139 assert!(committed_files.contains("CHANGELOG.md"));
1140 assert!(!committed_files.contains("unrelated.txt"));
1141
1142 let status = crate::test_support::git_command(root)
1143 .args(["status", "--porcelain"])
1144 .output()
1145 .unwrap();
1146 let status = String::from_utf8_lossy(&status.stdout);
1147 assert!(
1148 status.contains("A unrelated.txt"),
1149 "unrelated.txt must remain staged-but-uncommitted, got: {status}"
1150 );
1151 }
1152
1153 fn rev_list_count(root: &Path) -> u32 {
1156 let output = crate::test_support::git_command(root)
1157 .args(["rev-list", "--count", "HEAD"])
1158 .output()
1159 .unwrap();
1160 assert!(output.status.success(), "git rev-list --count HEAD failed");
1161 String::from_utf8_lossy(&output.stdout)
1162 .trim()
1163 .parse::<u32>()
1164 .expect("rev-list --count HEAD must print an integer")
1165 }
1166
1167 #[test]
1175 fn commit_path_twice_with_identical_content_creates_only_one_commit() {
1176 let repo = init_repo();
1177 let root = repo.path();
1178 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1179
1180 flow(root)
1181 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1182 .expect("first commit_path call");
1183 let n1 = rev_list_count(root);
1184
1185 flow(root)
1188 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1189 .expect("second commit_path call");
1190 let n2 = rev_list_count(root);
1191
1192 assert_eq!(
1193 n2, n1,
1194 "a repeat commit_path call on unchanged content must not add a \
1195 commit: n1={n1}, n2={n2}"
1196 );
1197 }
1198
1199 #[test]
1207 fn commit_path_with_no_changes_returns_ok_without_committing() {
1208 let repo = init_repo();
1209 let root = repo.path();
1210 std::fs::write(root.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1211 flow(root)
1212 .commit_path("CHANGELOG.md", "docs: add changelog entry")
1213 .expect("initial commit_path");
1214 let n1 = rev_list_count(root);
1215
1216 let result = flow(root).commit_path("CHANGELOG.md", "docs: add changelog entry");
1219 let n2 = rev_list_count(root);
1220
1221 assert!(
1222 result.is_ok(),
1223 "no-op call must return Ok(()), got: {result:?}"
1224 );
1225 assert_eq!(
1226 n2, n1,
1227 "no-op call must not create a commit: n1={n1}, n2={n2}"
1228 );
1229 }
1230
1231 #[test]
1237 fn commit_path_on_nonexistent_path_still_errors() {
1238 let repo = init_repo();
1239 let root = repo.path();
1240
1241 let result = flow(root).commit_path("does-not-exist.md", "docs: add changelog entry");
1242
1243 assert!(
1244 result.is_err(),
1245 "commit_path on an unknown pathspec must still error, got: {result:?}"
1246 );
1247 }
1248
1249 #[test]
1250 fn release_start_branches_from_current_head_not_develop() {
1251 let repo = init_repo();
1252 let root = repo.path();
1253 let gf = flow(root);
1254
1255 gf.feature_start(5).expect("feature_start");
1257 commit_file(root, "feature-only.txt");
1258 let feature_tip = gf.branch_tip("feature/phase-05").expect("feature tip");
1259
1260 let branch = gf.release_start("2.0.0").expect("release_start");
1261 assert_eq!(branch, "release/2.0.0");
1262 assert_eq!(current_branch(root), "release/2.0.0");
1263
1264 let release_tip = gf.branch_tip("release/2.0.0").expect("release tip");
1267 let is_ancestor = crate::test_support::git_command(root)
1268 .args(["merge-base", "--is-ancestor", &feature_tip, &release_tip])
1269 .output()
1270 .unwrap()
1271 .status
1272 .success();
1273 assert!(
1274 is_ancestor,
1275 "release branch must descend from the shipped feature commit"
1276 );
1277 assert!(root.join("feature-only.txt").exists());
1278 }
1279
1280 #[test]
1281 fn cleanup_merged_removes_merged_but_keeps_protected() {
1282 let repo = init_repo();
1283 let root = repo.path();
1284 let gf = flow(root);
1285
1286 gf.feature_start(2).expect("start");
1288 commit_file(root, "f.txt");
1289 gf.feature_finish(2).expect("finish");
1290
1291 git(root, &["branch", "stale-merged"]);
1293
1294 let deleted = gf.cleanup_merged().expect("cleanup");
1295 assert!(deleted.contains(&"stale-merged".to_string()));
1296 assert!(!deleted.contains(&"develop".to_string()));
1298 assert!(!deleted.contains(&"main".to_string()));
1299 }
1300
1301 #[test]
1308 fn cleanup_merged_is_relative_to_develop_not_current_head() {
1309 let repo = init_repo();
1310 let root = repo.path();
1311 let gf = flow(root);
1312
1313 git(root, &["checkout", "-q", "-b", "topic", "develop"]);
1317 commit_file(root, "topic-only.txt");
1318 git(root, &["checkout", "-q", "-b", "premature", "topic"]);
1319
1320 git(root, &["checkout", "-q", "topic"]);
1328
1329 let _ = gf.cleanup_merged();
1330 assert!(
1331 gf.branch_exists("premature"),
1332 "premature is merged into topic (current HEAD) but not into \
1333 develop — it must survive cleanup_merged when the baseline is develop"
1334 );
1335 }
1336
1337 #[test]
1345 fn cleanup_merged_skips_worktree_branch_and_continues_sweep() {
1346 let repo = init_repo();
1347 let root = repo.path();
1348 let gf = flow(root);
1349
1350 git(
1353 root,
1354 &["checkout", "-q", "-b", "worktree-merged", "develop"],
1355 );
1356 commit_file(root, "g.txt");
1357 git(root, &["checkout", "-q", "develop"]);
1358 git(root, &["merge", "-q", "--no-ff", "worktree-merged"]);
1359
1360 let wt_dir = tempfile::tempdir().unwrap();
1363 git(
1364 root,
1365 &[
1366 "worktree",
1367 "add",
1368 wt_dir.path().to_str().unwrap(),
1369 "worktree-merged",
1370 ],
1371 );
1372
1373 git(root, &["branch", "aa-stale"]);
1377 git(root, &["branch", "zz-stale"]);
1378
1379 let deleted = gf
1380 .cleanup_merged()
1381 .expect("a skipped worktree branch must not abort the sweep");
1382 assert!(deleted.contains(&"aa-stale".to_string()));
1383 assert!(deleted.contains(&"zz-stale".to_string()));
1384 assert!(
1385 !deleted.contains(&"worktree-merged".to_string()),
1386 "worktree checkout cannot be deleted"
1387 );
1388 assert!(gf.branch_exists("worktree-merged"));
1389 }
1390
1391 #[test]
1396 fn cleanup_merged_deletes_when_head_is_not_on_develop() {
1397 let repo = init_repo();
1398 let root = repo.path();
1399 let gf = flow(root);
1400
1401 git(root, &["checkout", "-q", "-b", "old", "develop"]);
1404 git(root, &["checkout", "-q", "develop"]);
1405 git(root, &["checkout", "-q", "-b", "merged-feature", "develop"]);
1406 commit_file(root, "h.txt");
1407 git(root, &["checkout", "-q", "develop"]);
1408 git(root, &["merge", "-q", "--no-ff", "merged-feature"]);
1409 git(root, &["checkout", "-q", "old"]);
1410
1411 let deleted = gf.cleanup_merged().expect("cleanup");
1412 assert!(
1413 deleted.contains(&"merged-feature".to_string()),
1414 "merged-into-develop branch must be deleted even when HEAD is elsewhere: {deleted:?}"
1415 );
1416 assert!(!gf.branch_exists("merged-feature"));
1417 }
1418
1419 #[test]
1420 fn delete_branch_removes_unmerged_with_force_and_protects_trunk() {
1421 let repo = init_repo();
1422 let root = repo.path();
1423 let gf = flow(root);
1424
1425 gf.feature_start(8).expect("start");
1427 commit_file(root, "unmerged.txt");
1428 git(root, &["checkout", "-q", "develop"]);
1430
1431 assert!(gf.delete_branch("feature/phase-08", false).is_err());
1433 gf.delete_branch("feature/phase-08", true)
1434 .expect("force delete");
1435 let branches = crate::test_support::git_command(root)
1436 .args(["branch"])
1437 .output()
1438 .unwrap();
1439 assert!(!String::from_utf8_lossy(&branches.stdout).contains("feature/phase-08"));
1440
1441 assert!(gf.delete_branch("develop", true).is_err());
1443 assert!(gf.delete_branch("main", true).is_err());
1444 }
1445
1446 #[test]
1447 fn merge_of_missing_branch_is_an_error() {
1448 let repo = init_repo();
1449 let root = repo.path();
1450 let err = flow(root).feature_finish(99).unwrap_err();
1453 assert!(matches!(err, GitError::Command(_)));
1454 }
1455
1456 #[test]
1461 fn workspace_member_paths_parses_multiline_array() {
1462 let contents = "[workspace]\nresolver = \"2\"\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n";
1463 assert_eq!(
1464 workspace_member_paths(contents),
1465 vec![
1466 "crates/devflow-core".to_string(),
1467 "crates/devflow-cli".to_string()
1468 ]
1469 );
1470 }
1471
1472 #[test]
1473 fn package_name_reads_the_package_section() {
1474 let contents = "[package]\nname = \"devflow-core\"\nversion.workspace = true\n";
1475 assert_eq!(package_name(contents), Some("devflow-core".to_string()));
1476 }
1477
1478 #[test]
1479 fn member_depends_on_matches_dotted_workspace_shorthand() {
1480 let contents = "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\nclap.workspace = true\n";
1481 assert!(member_depends_on(contents, "devflow-core"));
1482 assert!(!member_depends_on(contents, "serde"));
1483 }
1484
1485 #[test]
1491 fn member_depends_on_matches_long_form_dependency_section() {
1492 let contents = "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n\n[dependencies.clap]\nversion = \"4\"\n";
1493 assert!(member_depends_on(contents, "devflow-core"));
1494 assert!(member_depends_on(contents, "clap"));
1495 assert!(!member_depends_on(contents, "serde"));
1496 }
1497
1498 #[test]
1499 fn topo_sort_orders_dependency_before_dependent() {
1500 let names = vec!["devflow".to_string(), "devflow-core".to_string()];
1501 let edges = vec![("devflow".to_string(), "devflow-core".to_string())];
1502 assert_eq!(
1503 topo_sort(names, edges),
1504 vec!["devflow-core".to_string(), "devflow".to_string()]
1505 );
1506 }
1507
1508 #[test]
1509 fn topo_sort_falls_back_to_input_order_on_a_cycle() {
1510 let names = vec!["a".to_string(), "b".to_string()];
1513 let edges = vec![
1514 ("a".to_string(), "b".to_string()),
1515 ("b".to_string(), "a".to_string()),
1516 ];
1517 let result = topo_sort(names, edges);
1518 assert_eq!(result.len(), 2);
1519 }
1520
1521 #[test]
1522 fn publish_order_derives_core_before_cli_from_a_fixture_workspace() {
1523 let dir = tempfile::tempdir().unwrap();
1524 let root = dir.path();
1525 std::fs::write(
1526 root.join("Cargo.toml"),
1527 "[workspace]\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n",
1528 )
1529 .unwrap();
1530 std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1531 std::fs::write(
1532 root.join("crates/devflow-core/Cargo.toml"),
1533 "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1534 )
1535 .unwrap();
1536 std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1537 std::fs::write(
1538 root.join("crates/devflow-cli/Cargo.toml"),
1539 "[package]\nname = \"devflow\"\n\n[dependencies]\ndevflow-core.workspace = true\n",
1540 )
1541 .unwrap();
1542
1543 assert_eq!(
1544 publish_order(root),
1545 vec!["devflow-core".to_string(), "devflow".to_string()]
1546 );
1547 }
1548
1549 #[test]
1556 fn publish_order_recognizes_long_form_dependency_section_self_dependency() {
1557 let dir = tempfile::tempdir().unwrap();
1558 let root = dir.path();
1559 std::fs::write(
1560 root.join("Cargo.toml"),
1561 "[workspace]\nmembers = [\n \"crates/devflow-core\",\n \"crates/devflow-cli\",\n]\n",
1562 )
1563 .unwrap();
1564 std::fs::create_dir_all(root.join("crates/devflow-core")).unwrap();
1565 std::fs::write(
1566 root.join("crates/devflow-core/Cargo.toml"),
1567 "[package]\nname = \"devflow-core\"\n\n[dependencies]\n",
1568 )
1569 .unwrap();
1570 std::fs::create_dir_all(root.join("crates/devflow-cli")).unwrap();
1571 std::fs::write(
1572 root.join("crates/devflow-cli/Cargo.toml"),
1573 "[package]\nname = \"devflow\"\n\n[dependencies.devflow-core]\nworkspace = true\n",
1574 )
1575 .unwrap();
1576
1577 assert_eq!(
1578 publish_order(root),
1579 vec!["devflow-core".to_string(), "devflow".to_string()],
1580 "the long-form dependency section must still order devflow-core before devflow"
1581 );
1582 }
1583
1584 #[test]
1589 fn origin_main_ancestor_status_is_ref_absent_without_a_remote() {
1590 let repo = init_repo();
1591 let root = repo.path();
1592 assert_eq!(origin_main_ancestor_status(root), AncestorStatus::RefAbsent);
1593 }
1594
1595 #[test]
1596 fn origin_main_ancestor_status_is_ancestor_when_head_is_up_to_date() {
1597 let repo = init_repo();
1598 let root = repo.path();
1599 let head = crate::test_support::git_command(root)
1600 .args(["rev-parse", "HEAD"])
1601 .output()
1602 .unwrap();
1603 let head_sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
1604 git(root, &["update-ref", "refs/remotes/origin/main", &head_sha]);
1605 assert_eq!(origin_main_ancestor_status(root), AncestorStatus::Ancestor);
1606 }
1607
1608 #[test]
1613 fn classify_ssh_add_status_maps_all_three_documented_exit_codes() {
1614 assert_eq!(classify_ssh_add_status(2), SigningStatus::NoAgent);
1615 assert_eq!(classify_ssh_add_status(1), SigningStatus::AgentEmpty);
1616 assert_eq!(classify_ssh_add_status(0), SigningStatus::KeysListed);
1617 assert_eq!(classify_ssh_add_status(7), SigningStatus::Unknown(7));
1618 }
1619
1620 static HOME_ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1627
1628 #[test]
1629 fn check_signing_viability_degrades_when_gpg_format_unset_and_no_signingkey() {
1630 let _lock = HOME_ENV_MUTEX.lock().unwrap();
1633 let repo = init_repo();
1634 let root = repo.path();
1635 let fake_home = tempfile::tempdir().unwrap();
1636 let original_home = std::env::var_os("HOME");
1637 unsafe { std::env::set_var("HOME", fake_home.path()) };
1640
1641 let result = check_signing_viability(root);
1642
1643 match original_home {
1645 Some(home) => unsafe { std::env::set_var("HOME", home) },
1646 None => unsafe { std::env::remove_var("HOME") },
1647 }
1648
1649 match result {
1650 SigningViability::Unknown { reason } => {
1651 assert!(
1652 reason.contains("user.signingkey"),
1653 "unexpected reason: {reason}"
1654 );
1655 }
1656 other => panic!("expected Unknown (fail-soft), got: {other:?}"),
1657 }
1658 }
1659
1660 #[test]
1668 fn check_signing_viability_never_reports_key_file_missing_for_inline_key() {
1669 const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
1670 let inline_values = [
1671 "key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
1672 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFAKEFIXTUREKEYMATERIALZZZZZZZZZZZZZZZZZZZZZZ devflow-fixture",
1673 ];
1674 for value in inline_values {
1675 let repo = init_repo();
1676 let root = repo.path();
1677 git(root, &["config", "gpg.format", "ssh"]);
1678 git(root, &["config", "user.signingkey", value]);
1679
1680 let result = check_signing_viability(root);
1681
1682 if let SigningViability::NotViable { reason } = &result {
1683 assert_ne!(
1684 reason, MISSING_FILE_REASON,
1685 "inline signingkey value {value:?} incorrectly classified as a \
1686 missing file: {result:?}"
1687 );
1688 }
1689 }
1690 }
1691
1692 #[test]
1698 fn inline_signing_key_blob_follows_git_prefix_precedence() {
1699 assert_eq!(
1700 inline_signing_key_blob("key::ssh-rsa AAAAB3 id"),
1701 Some("ssh-rsa AAAAB3 id")
1702 );
1703 assert_eq!(
1704 inline_signing_key_blob("key::ssh-ed25519 AAAAC3 id"),
1705 Some("ssh-ed25519 AAAAC3 id")
1706 );
1707 assert_eq!(
1708 inline_signing_key_blob("key::ecdsa-sha2-nistp256 AAAAE2 id"),
1709 Some("ecdsa-sha2-nistp256 AAAAE2 id")
1710 );
1711 assert_eq!(inline_signing_key_blob("key::"), Some(""));
1712 assert_eq!(
1713 inline_signing_key_blob("ssh-ed25519 AAAAC3 id"),
1714 Some("ssh-ed25519 AAAAC3 id")
1715 );
1716 assert_eq!(
1717 inline_signing_key_blob(" key::ssh-ed25519 AAAAC3 id "),
1718 Some("ssh-ed25519 AAAAC3 id")
1719 );
1720 assert_eq!(inline_signing_key_blob("ssh-key.pub"), Some("ssh-key.pub"));
1723 assert_eq!(
1724 inline_signing_key_blob("/home/operator/.ssh/id_ed25519.pub"),
1725 None
1726 );
1727 assert_eq!(
1730 inline_signing_key_blob("ecdsa-sha2-nistp256 AAAAE2 id"),
1731 None
1732 );
1733 assert_eq!(
1734 inline_signing_key_blob("sk-ssh-ed25519@openssh.com AAAAG id"),
1735 None
1736 );
1737 assert_eq!(inline_signing_key_blob("ABCD1234"), None);
1738 }
1739
1740 #[test]
1746 fn check_signing_viability_still_reports_missing_file_for_a_path_value() {
1747 const MISSING_FILE_REASON: &str = "user.signingkey is set but the key file does not exist";
1748 let path_values = [
1749 "/nonexistent/path/to/a/signing/key/that/does/not/exist",
1750 "ecdsa-sha2-nistp256 AAAAE2 devflow-fixture",
1751 "sk-ssh-ed25519@openssh.com AAAAG devflow-fixture",
1752 ];
1753 for value in path_values {
1754 let repo = init_repo();
1755 let root = repo.path();
1756 git(root, &["config", "gpg.format", "ssh"]);
1757 git(root, &["config", "user.signingkey", value]);
1758
1759 let result = check_signing_viability(root);
1760
1761 assert_eq!(
1762 result,
1763 SigningViability::NotViable {
1764 reason: MISSING_FILE_REASON.to_string(),
1765 },
1766 "value {value:?} did not take the path branch: {result:?}"
1767 );
1768 }
1769 }
1770
1771 #[test]
1780 fn inline_key_fingerprint_matches_the_path_branch_for_the_same_key() {
1781 let dir = tempfile::tempdir().unwrap();
1782 let key_path = dir.path().join("devflow-fixture-key");
1783 let keygen = Command::new("ssh-keygen")
1784 .args([
1785 "-t",
1786 "ed25519",
1787 "-f",
1788 key_path.to_str().unwrap(),
1789 "-N",
1790 "",
1791 "-q",
1792 ])
1793 .output()
1794 .expect("spawn ssh-keygen");
1795 assert!(
1796 keygen.status.success(),
1797 "ssh-keygen fixture setup failed: {}",
1798 String::from_utf8_lossy(&keygen.stderr)
1799 );
1800 let pub_key_path = dir.path().join("devflow-fixture-key.pub");
1801 let blob = std::fs::read_to_string(&pub_key_path)
1802 .unwrap()
1803 .trim()
1804 .to_string();
1805
1806 let inline_fp = inline_key_fingerprint(&blob);
1809 assert!(
1810 inline_fp.is_some(),
1811 "inline_key_fingerprint returned None for a real key"
1812 );
1813 let inline_fp = inline_fp.unwrap();
1814 assert!(
1815 inline_fp.starts_with("SHA256:"),
1816 "unexpected fingerprint shape: {inline_fp}"
1817 );
1818
1819 let path_fp = public_key_fingerprint(&pub_key_path);
1820 assert!(
1821 path_fp.is_some(),
1822 "public_key_fingerprint returned None for a real key"
1823 );
1824 let path_fp = path_fp.unwrap();
1825
1826 assert_eq!(inline_fp, path_fp);
1827
1828 let prefixed = format!("key::{blob}");
1832 let classified_blob = inline_signing_key_blob(&prefixed).unwrap();
1833 let chained_fp = inline_key_fingerprint(classified_blob).unwrap();
1834 assert_eq!(chained_fp, path_fp);
1835 }
1836
1837 #[test]
1844 fn check_signing_viability_never_hard_fails_on_an_unparseable_inline_key() {
1845 const NO_AGENT_REASON: &str = "no ssh-agent reachable (SSH_AUTH_SOCK unset or dead)";
1846 const AGENT_EMPTY_REASON: &str = "ssh-agent reachable but has no identities loaded";
1847 let unparseable_values = ["key::", "key::this is not a key at all"];
1848 for value in unparseable_values {
1849 let repo = init_repo();
1850 let root = repo.path();
1851 git(root, &["config", "gpg.format", "ssh"]);
1852 git(root, &["config", "user.signingkey", value]);
1853
1854 let result = check_signing_viability(root);
1855
1856 if let SigningViability::NotViable { reason } = &result {
1857 assert!(
1858 reason == NO_AGENT_REASON || reason == AGENT_EMPTY_REASON,
1859 "value {value:?} produced an unexpected hard fail: {result:?}"
1860 );
1861 }
1862 }
1863
1864 assert_eq!(inline_key_fingerprint(""), None);
1865 assert_eq!(inline_key_fingerprint("not a key\n"), None);
1866 }
1867}