1use crate::command::GitCommand;
33use crate::command::status::StatusFormat;
34use crate::command::symbolic_ref::SymbolicRefCommand;
35use crate::error::Result;
36use crate::repo::Repository;
37
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
45pub struct RepoInfo {
46 pub branch: Option<String>,
48 pub upstream: Option<String>,
50 pub default_branch: Option<String>,
54 pub dirty: bool,
57 pub ahead: u32,
59 pub behind: u32,
61}
62
63impl Repository {
64 pub async fn info(&self) -> Result<RepoInfo> {
70 let status_out = self
71 .status()
72 .format(StatusFormat::PorcelainV2)
73 .branch()
74 .execute()
75 .await?;
76
77 let mut info = parse_porcelain_v2(&status_out.stdout);
78
79 let mut sym = SymbolicRefCommand::read("refs/remotes/origin/HEAD").short();
80 sym.current_dir(self.path());
81 if let Ok(target) = sym.execute().await {
82 let short = target
83 .strip_prefix("origin/")
84 .map_or_else(|| target.clone(), str::to_string);
85 if !short.is_empty() {
86 info.default_branch = Some(short);
87 }
88 }
89
90 Ok(info)
91 }
92}
93
94fn parse_porcelain_v2(stdout: &str) -> RepoInfo {
95 let mut info = RepoInfo::default();
96 for line in stdout.lines() {
97 if let Some(rest) = line.strip_prefix("# branch.head ") {
98 if rest != "(detached)" {
99 info.branch = Some(rest.to_string());
100 }
101 } else if let Some(rest) = line.strip_prefix("# branch.upstream ") {
102 info.upstream = Some(rest.to_string());
103 } else if let Some(rest) = line.strip_prefix("# branch.ab ") {
104 let mut parts = rest.split_whitespace();
105 if let Some(a) = parts.next() {
106 info.ahead = a.trim_start_matches('+').parse().unwrap_or(0);
107 }
108 if let Some(b) = parts.next() {
109 info.behind = b.trim_start_matches('-').parse().unwrap_or(0);
110 }
111 } else if !line.is_empty() && !line.starts_with('#') {
112 info.dirty = true;
113 }
114 }
115 info
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn parses_clean_repo_with_upstream() {
124 let input = "\
125# branch.oid abc123
126# branch.head main
127# branch.upstream origin/main
128# branch.ab +0 -0
129";
130 let info = parse_porcelain_v2(input);
131 assert_eq!(info.branch.as_deref(), Some("main"));
132 assert_eq!(info.upstream.as_deref(), Some("origin/main"));
133 assert_eq!(info.ahead, 0);
134 assert_eq!(info.behind, 0);
135 assert!(!info.dirty);
136 }
137
138 #[test]
139 fn parses_dirty_with_ahead_behind() {
140 let input = "\
141# branch.oid abc123
142# branch.head feature
143# branch.upstream origin/feature
144# branch.ab +3 -1
1451 .M N... 100644 100644 100644 aaa bbb hello.txt
146? new.txt
147";
148 let info = parse_porcelain_v2(input);
149 assert_eq!(info.branch.as_deref(), Some("feature"));
150 assert_eq!(info.ahead, 3);
151 assert_eq!(info.behind, 1);
152 assert!(info.dirty);
153 }
154
155 #[test]
156 fn parses_detached_head() {
157 let input = "\
158# branch.oid abc123
159# branch.head (detached)
160";
161 let info = parse_porcelain_v2(input);
162 assert!(info.branch.is_none());
163 assert!(info.upstream.is_none());
164 assert!(!info.dirty);
165 }
166
167 #[test]
168 fn parses_no_upstream() {
169 let input = "\
170# branch.oid abc123
171# branch.head main
172";
173 let info = parse_porcelain_v2(input);
174 assert_eq!(info.branch.as_deref(), Some("main"));
175 assert!(info.upstream.is_none());
176 assert_eq!(info.ahead, 0);
177 assert_eq!(info.behind, 0);
178 }
179}