1pub use std::path::PathBuf;
2
3pub fn fmt_oid(id: &gix::ObjectId) -> String {
5 gix::hash::Prefix::new(id, 7)
6 .map(|p| p.to_string())
7 .unwrap_or_else(|_| id.to_string())
8}
9
10#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
11pub enum SubmoduleStatus {
12 Clean,
13 AheadOfParent,
14 BehindRemote,
15 Detached,
16 Dirty,
17 Orphaned,
18 Uninitialized,
19}
20
21impl SubmoduleStatus {
22 pub fn priority(&self) -> u8 {
23 match self {
24 Self::Dirty => 0,
25 Self::Orphaned => 1,
26 Self::Detached => 2,
27 Self::Uninitialized => 3,
28 Self::BehindRemote => 4,
29 Self::AheadOfParent => 5,
30 Self::Clean => 6,
31 }
32 }
33}
34
35#[derive(Debug, Clone, serde::Serialize)]
36pub struct Submodule {
37 pub name: String,
38 pub path: PathBuf,
39 pub url: String,
40 pub tracked_branch: String,
41 pub parent_pointer: gix::ObjectId,
42 pub local_head: gix::ObjectId,
43 pub remote_head: gix::ObjectId,
44 pub status: SubmoduleStatus,
45 pub ahead_count: usize,
46 pub behind_count: usize,
47 pub remote_unreachable: bool,
48}
49
50#[derive(Debug, Clone, serde::Serialize)]
51pub struct RepoState {
52 pub root_path: PathBuf,
53 pub submodules: Vec<Submodule>,
54 pub total: usize,
55 pub clean_count: usize,
56 pub needs_attention: Vec<String>,
57}
58
59#[derive(Debug, Clone, Default, serde::Serialize)]
60pub struct AggregateStatus {
61 pub total: usize,
62 pub clean: usize,
63 pub ahead_of_parent: usize,
64 pub behind_remote: usize,
65 pub detached: usize,
66 pub dirty: usize,
67 pub orphaned: usize,
68 pub uninitialized: usize,
69}
70
71impl AggregateStatus {
72 pub fn from_submodules(submodules: &[Submodule]) -> Self {
73 let mut clean = 0;
74 let mut ahead = 0;
75 let mut behind = 0;
76 let mut detached = 0;
77 let mut dirty = 0;
78 let mut orphaned = 0;
79 let mut uninit = 0;
80 for sm in submodules {
81 match sm.status {
82 SubmoduleStatus::Clean => clean += 1,
83 SubmoduleStatus::AheadOfParent => ahead += 1,
84 SubmoduleStatus::BehindRemote => behind += 1,
85 SubmoduleStatus::Detached => detached += 1,
86 SubmoduleStatus::Dirty => dirty += 1,
87 SubmoduleStatus::Orphaned => orphaned += 1,
88 SubmoduleStatus::Uninitialized => uninit += 1,
89 }
90 }
91 AggregateStatus {
92 total: submodules.len(),
93 clean,
94 ahead_of_parent: ahead,
95 behind_remote: behind,
96 detached,
97 dirty,
98 orphaned,
99 uninitialized: uninit,
100 }
101 }
102}
103
104#[derive(Debug, Clone)]
105pub struct HealthIssue {
106 pub submodule_name: String,
107 pub status: String,
108 pub description: String,
109 pub suggested_action: String,
110}
111
112pub fn describe_issue(status: &SubmoduleStatus) -> (String, String) {
113 match status {
114 SubmoduleStatus::AheadOfParent => (
115 "本地领先于父仓库记录".into(),
116 "运行 sync_to_parent 更新父仓库指针".into(),
117 ),
118 SubmoduleStatus::BehindRemote => (
119 "远程有更新,本地落后".into(),
120 "运行 code sync 获取最新代码".into(),
121 ),
122 SubmoduleStatus::Detached => (
123 "处于游离 HEAD 状态".into(),
124 "运行 checkout_branch 切换到跟踪分支".into(),
125 ),
126 SubmoduleStatus::Dirty => ("有未提交的修改".into(), "提交或 stash 当前修改".into()),
127 SubmoduleStatus::Orphaned => (
128 "父仓库记录的 commit 在远程已不存在".into(),
129 "需手动干预".into(),
130 ),
131 SubmoduleStatus::Uninitialized => ("尚未初始化".into(), "运行 init 初始化子模块".into()),
132 SubmoduleStatus::Clean => unreachable!(),
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
142 fn test_status_priority_ordering() {
143 assert!(SubmoduleStatus::Dirty.priority() < SubmoduleStatus::Clean.priority());
144 assert!(SubmoduleStatus::Orphaned.priority() < SubmoduleStatus::BehindRemote.priority());
145 }
146 #[test]
147 fn test_clean_is_lowest_priority() {
148 for s in &[
149 SubmoduleStatus::Dirty,
150 SubmoduleStatus::Orphaned,
151 SubmoduleStatus::Detached,
152 SubmoduleStatus::Uninitialized,
153 SubmoduleStatus::BehindRemote,
154 SubmoduleStatus::AheadOfParent,
155 ] {
156 assert!(s.priority() < SubmoduleStatus::Clean.priority());
157 }
158 }
159 #[test]
160 fn test_all_priorities_are_unique() {
161 let p: Vec<u8> = [
162 SubmoduleStatus::Dirty,
163 SubmoduleStatus::Orphaned,
164 SubmoduleStatus::Detached,
165 SubmoduleStatus::Uninitialized,
166 SubmoduleStatus::BehindRemote,
167 SubmoduleStatus::AheadOfParent,
168 SubmoduleStatus::Clean,
169 ]
170 .iter()
171 .map(|s| s.priority())
172 .collect();
173 let mut s = p.clone();
174 s.sort();
175 s.dedup();
176 assert_eq!(p.len(), s.len());
177 }
178 #[test]
179 fn test_status_debug_output() {
180 assert_eq!(format!("{:?}", SubmoduleStatus::Clean), "Clean");
181 }
182 #[test]
183 fn test_status_clone_eq() {
184 assert_eq!(SubmoduleStatus::Dirty, SubmoduleStatus::Dirty);
185 }
186
187 #[test]
189 fn test_fmt_oid_truncates() {
190 let oid = gix::ObjectId::from_hex(b"abcdef1234567890abcdef1234567890abcdef12").unwrap();
191 assert_eq!(fmt_oid(&oid), "abcdef1");
192 }
193 #[test]
194 fn test_fmt_oid_short() {
195 let oid = gix::ObjectId::null(gix::hash::Kind::Sha1);
197 assert_eq!(fmt_oid(&oid).len(), 7);
198 }
199 #[test]
200 fn test_fmt_oid_null() {
201 let oid = gix::ObjectId::null(gix::hash::Kind::Sha1);
202 let s = fmt_oid(&oid);
203 assert_eq!(s.len(), 7);
204 assert!(s.chars().all(|c| c == '0'));
205 }
206
207 #[test]
209 fn test_submodule_builder() {
210 let sm = Submodule {
211 name: "test".into(),
212 path: PathBuf::from("libs/test"),
213 url: "https://example.com/test.git".into(),
214 tracked_branch: "main".into(),
215 parent_pointer: gix::ObjectId::null(gix::hash::Kind::Sha1),
216 local_head: gix::ObjectId::null(gix::hash::Kind::Sha1),
217 remote_head: gix::ObjectId::null(gix::hash::Kind::Sha1),
218 status: SubmoduleStatus::BehindRemote,
219 ahead_count: 0,
220 behind_count: 3,
221 remote_unreachable: false,
222 };
223 assert_eq!(sm.name, "test");
224 }
225
226 #[test]
228 fn test_aggregate_status_default() {
229 assert_eq!(AggregateStatus::default().total, 0);
230 }
231 #[test]
232 fn test_aggregate_status_from_submodules() {
233 let sm = |s| Submodule {
234 name: String::new(),
235 path: PathBuf::new(),
236 url: String::new(),
237 tracked_branch: "main".into(),
238 parent_pointer: gix::ObjectId::null(gix::hash::Kind::Sha1),
239 local_head: gix::ObjectId::null(gix::hash::Kind::Sha1),
240 remote_head: gix::ObjectId::null(gix::hash::Kind::Sha1),
241 status: s,
242 ahead_count: 0,
243 behind_count: 0,
244 remote_unreachable: false,
245 };
246 let agg = AggregateStatus::from_submodules(&[
247 sm(SubmoduleStatus::Clean),
248 sm(SubmoduleStatus::Dirty),
249 sm(SubmoduleStatus::Orphaned),
250 ]);
251 assert_eq!(agg.total, 3);
252 assert_eq!(agg.clean, 1);
253 assert_eq!(agg.dirty, 1);
254 assert_eq!(agg.orphaned, 1);
255 }
256 #[test]
257 fn test_aggregate_status_all_variants() {
258 let sm = |s| Submodule {
259 name: String::new(),
260 path: PathBuf::new(),
261 url: String::new(),
262 tracked_branch: "main".into(),
263 parent_pointer: gix::ObjectId::null(gix::hash::Kind::Sha1),
264 local_head: gix::ObjectId::null(gix::hash::Kind::Sha1),
265 remote_head: gix::ObjectId::null(gix::hash::Kind::Sha1),
266 status: s,
267 ahead_count: 0,
268 behind_count: 0,
269 remote_unreachable: false,
270 };
271 let agg = AggregateStatus::from_submodules(&[
272 sm(SubmoduleStatus::Clean),
273 sm(SubmoduleStatus::AheadOfParent),
274 sm(SubmoduleStatus::BehindRemote),
275 sm(SubmoduleStatus::Detached),
276 sm(SubmoduleStatus::Dirty),
277 sm(SubmoduleStatus::Orphaned),
278 sm(SubmoduleStatus::Uninitialized),
279 ]);
280 assert_eq!(agg.total, 7);
281 assert_eq!(agg.clean, 1);
282 }
283
284 #[test]
286 fn test_repo_state_empty() {
287 let s = RepoState {
288 root_path: PathBuf::from("/tmp"),
289 submodules: vec![],
290 total: 0,
291 clean_count: 0,
292 needs_attention: vec![],
293 };
294 assert_eq!(s.total, 0);
295 }
296
297 #[test]
299 fn test_describe_issue_ahead_of_parent() {
300 let (d, a) = describe_issue(&SubmoduleStatus::AheadOfParent);
301 assert!(d.contains("领先"));
302 assert!(a.contains("sync"));
303 }
304 #[test]
305 fn test_describe_issue_behind_remote() {
306 let (d, a) = describe_issue(&SubmoduleStatus::BehindRemote);
307 assert!(d.contains("落后"));
308 assert!(a.contains("sync"));
309 }
310 #[test]
311 fn test_describe_issue_detached() {
312 let (d, a) = describe_issue(&SubmoduleStatus::Detached);
313 assert!(d.contains("游离"));
314 assert!(a.contains("checkout"));
315 }
316 #[test]
317 fn test_describe_issue_dirty() {
318 let (d, _a) = describe_issue(&SubmoduleStatus::Dirty);
319 assert!(d.contains("修改"));
320 }
321 #[test]
322 fn test_describe_issue_orphaned() {
323 let (d, _a) = describe_issue(&SubmoduleStatus::Orphaned);
324 assert!(d.contains("不存在"));
325 }
326 #[test]
327 fn test_describe_issue_uninitialized() {
328 let (d, _a) = describe_issue(&SubmoduleStatus::Uninitialized);
329 assert!(d.contains("初始化"));
330 }
331 #[test]
332 #[should_panic(expected = "unreachable")]
333 fn test_describe_issue_clean_panics() {
334 describe_issue(&SubmoduleStatus::Clean);
335 }
336}