1use std::path::PathBuf;
2
3#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
4pub struct CommitHash(pub String);
5
6impl std::fmt::Display for CommitHash {
7 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
8 write!(f, "{}", &self.0[..self.0.len().min(7)])
9 }
10}
11
12impl Default for CommitHash {
13 fn default() -> Self {
14 Self(String::from("0000000000000000000000000000000000000000"))
15 }
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
19pub enum SubmoduleStatus {
20 Clean,
21 AheadOfParent,
22 BehindRemote,
23 Detached,
24 Dirty,
25 Orphaned,
26 Uninitialized,
27}
28
29impl SubmoduleStatus {
30 pub fn priority(&self) -> u8 {
31 match self {
32 Self::Dirty => 0,
33 Self::Orphaned => 1,
34 Self::Detached => 2,
35 Self::Uninitialized => 3,
36 Self::BehindRemote => 4,
37 Self::AheadOfParent => 5,
38 Self::Clean => 6,
39 }
40 }
41}
42
43#[derive(Debug, Clone, serde::Serialize)]
44pub struct Submodule {
45 pub name: String,
46 pub path: PathBuf,
47 pub url: String,
48 pub tracked_branch: String,
49 pub parent_pointer: CommitHash,
50 pub local_head: CommitHash,
51 pub remote_head: CommitHash,
52 pub status: SubmoduleStatus,
53 pub ahead_count: usize,
54 pub behind_count: usize,
55 pub remote_unreachable: bool,
56}
57
58#[derive(Debug, Clone, serde::Serialize)]
59pub struct RepoState {
60 pub root_path: PathBuf,
61 pub submodules: Vec<Submodule>,
62 pub total: usize,
63 pub clean_count: usize,
64 pub needs_attention: Vec<String>,
65}
66
67impl RepoState {
68 pub fn scan(root: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
69 Self::scan_with_options(root, false)
70 }
71
72 pub fn scan_offline(root: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
73 Self::scan_with_options(root, true)
74 }
75
76 fn scan_with_options(root: &std::path::Path, offline: bool) -> Result<Self, Box<dyn std::error::Error>> {
77 let repo = match git2::Repository::open(root) {
78 Ok(r) => r,
79 Err(e) => return Err(format!("无法打开 Git 仓库 '{}': {}", root.display(), e).into()),
80 };
81 let gitmodules_path = root.join(".gitmodules");
82
83 let submodules = if gitmodules_path.exists() {
84 let mut git_submodules = repo.submodules()?;
85 git_submodules.sort_by(|a, b| a.name().cmp(&b.name()));
86 git_submodules
87 .iter()
88 .map(|sm| Self::scan_single_submodule(root, sm, &repo, offline))
89 .collect::<Result<Vec<_>, _>>()?
90 } else {
91 Vec::new()
92 };
93
94 let total = submodules.len();
95 let clean_count = submodules
96 .iter()
97 .filter(|s| s.status == SubmoduleStatus::Clean)
98 .count();
99 let needs_attention: Vec<String> = submodules
100 .iter()
101 .filter(|s| s.status != SubmoduleStatus::Clean)
102 .map(|s| s.name.clone())
103 .collect();
104
105 Ok(RepoState {
106 root_path: root.to_path_buf(),
107 submodules,
108 total,
109 clean_count,
110 needs_attention,
111 })
112 }
113
114 fn scan_single_submodule(
115 root: &std::path::Path,
116 sm: &git2::Submodule,
117 repo: &git2::Repository,
118 offline: bool,
119 ) -> Result<Submodule, Box<dyn std::error::Error>> {
120 let name = sm.name().unwrap_or("unknown").to_string();
121 let sm_path = sm.path();
122 let full_sm_path = root.join(sm_path);
123 let url = sm.url().unwrap_or("").to_string();
124 let branch = sm.branch().unwrap_or("main").to_string();
125
126 let raw_status = repo.submodule_status(&name, git2::SubmoduleIgnore::None)?;
127 let is_uninitialized = raw_status.is_wd_uninitialized();
128 let head_oid = sm.head_id().unwrap_or_else(git2::Oid::zero);
129 let parent_pointer = CommitHash(head_oid.to_string());
130
131 let (local_head, remote_head, is_detached, ahead_count, behind_count, is_orphaned, remote_unreachable) =
132 Self::scan_submodule_remote_state(&full_sm_path, &branch, &parent_pointer, is_uninitialized, offline);
133
134 let is_dirty = !is_uninitialized
135 && ahead_count == 0
136 && (raw_status.is_wd_modified()
137 || raw_status.is_index_modified()
138 || raw_status.is_wd_untracked());
139
140 let status = Self::determine_submodule_status(
141 is_uninitialized, is_dirty, is_detached, is_orphaned,
142 remote_unreachable, ahead_count, behind_count, &local_head, &parent_pointer,
143 );
144
145 Ok(Submodule {
146 name,
147 path: sm_path.to_path_buf(),
148 url,
149 tracked_branch: branch,
150 parent_pointer,
151 local_head,
152 remote_head,
153 status,
154 ahead_count,
155 behind_count,
156 remote_unreachable,
157 })
158 }
159
160 fn scan_submodule_remote_state(
161 full_sm_path: &std::path::Path, branch: &str, parent_pointer: &CommitHash, is_uninitialized: bool, offline: bool,
162 ) -> (CommitHash, CommitHash, bool, usize, usize, bool, bool) {
163 if is_uninitialized {
164 return Self::default_submodule_state();
165 }
166 let Ok(sub_repo) = git2::Repository::open(full_sm_path) else {
167 return Self::default_submodule_state();
168 };
169 let (local, detached) = Self::open_submodule_head(&sub_repo);
170 if !offline {
171 Self::fetch_submodule_remote(&sub_repo);
172 }
173 let (remote, unreachable) = Self::resolve_submodule_remote(&sub_repo, branch);
174 let (ahead, behind, orphaned) = Self::compute_submodule_diff(&sub_repo, &local, parent_pointer, &remote, unreachable);
175 (local, remote, detached, ahead, behind, orphaned, unreachable)
176 }
177
178 fn default_submodule_state() -> (CommitHash, CommitHash, bool, usize, usize, bool, bool) {
179 (CommitHash::default(), CommitHash::default(), false, 0, 0, false, false)
180 }
181
182 fn open_submodule_head(sub_repo: &git2::Repository) -> (CommitHash, bool) {
183 let local = sub_repo.head().ok().and_then(|r| r.target()).map(|o| CommitHash(o.to_string())).unwrap_or_default();
184 let detached = sub_repo.head().ok().map(|r| !r.is_branch()).unwrap_or(false);
185 (local, detached)
186 }
187
188 fn fetch_submodule_remote(sub_repo: &git2::Repository) {
189 if let Ok(mut sub_remote) = sub_repo.find_remote("origin") {
190 let mut fetch_opts = git2::FetchOptions::new();
191 fetch_opts.download_tags(git2::AutotagOption::None);
192 let mut callbacks = git2::RemoteCallbacks::new();
193 callbacks.transfer_progress(|_| true);
194 fetch_opts.remote_callbacks(callbacks);
195 let _ = sub_remote.fetch(&["+refs/heads/*:refs/remotes/origin/*"], Some(&mut fetch_opts), None);
196 }
197 }
198
199 fn resolve_submodule_remote(sub_repo: &git2::Repository, branch: &str) -> (CommitHash, bool) {
200 sub_repo.find_reference(&format!("refs/remotes/origin/{}", branch)).ok()
201 .and_then(|r| r.target())
202 .map(|o| (CommitHash(o.to_string()), false))
203 .unwrap_or_else(|| (CommitHash::default(), true))
204 }
205
206 fn compute_submodule_diff(
207 sub_repo: &git2::Repository, local: &CommitHash, parent_pointer: &CommitHash,
208 remote: &CommitHash, unreachable: bool,
209 ) -> (usize, usize, bool) {
210 let ahead = count_between_opt(sub_repo, parse_oid(parent_pointer), parse_oid(local));
211 let behind = if unreachable { 0 } else { count_between_opt(sub_repo, parse_oid(local), parse_oid(remote)) };
212 let orphaned = if !unreachable && remote != &CommitHash::default() && parent_pointer != remote {
213 let (p, r) = (parse_oid(parent_pointer), parse_oid(remote));
214 match (p, r) {
215 (Some(p_oid), Some(r_oid)) => sub_repo.merge_base(r_oid, p_oid).map(|base| base != p_oid).unwrap_or(true),
216 _ => false,
217 }
218 } else { false };
219 (ahead, behind, orphaned)
220 }
221
222 fn determine_submodule_status(
223 is_uninitialized: bool, is_dirty: bool, is_detached: bool, is_orphaned: bool,
224 remote_unreachable: bool, ahead_count: usize, behind_count: usize,
225 local_head: &CommitHash, parent_pointer: &CommitHash,
226 ) -> SubmoduleStatus {
227 if is_uninitialized { return SubmoduleStatus::Uninitialized; }
228 if is_dirty { return SubmoduleStatus::Dirty; }
229 if is_detached { return SubmoduleStatus::Detached; }
230 if is_orphaned && !remote_unreachable { return SubmoduleStatus::Orphaned; }
231 if (remote_unreachable && local_head != parent_pointer) || (ahead_count > 0 && behind_count == 0) {
232 return SubmoduleStatus::AheadOfParent;
233 }
234 if behind_count > 0 && !remote_unreachable { return SubmoduleStatus::BehindRemote; }
235 SubmoduleStatus::Clean
236 }
237
238 pub fn scan_all(root: &std::path::Path) -> Result<(Vec<Submodule>, AggregateStatus), Box<dyn std::error::Error>> {
239 let state = Self::scan(root)?;
240 let agg = AggregateStatus::from_submodules(&state.submodules);
241 Ok((state.submodules, agg))
242 }
243}
244
245#[derive(Debug, Clone, Default, serde::Serialize)]
246pub struct AggregateStatus {
247 pub total: usize,
248 pub clean: usize,
249 pub ahead_of_parent: usize,
250 pub behind_remote: usize,
251 pub detached: usize,
252 pub dirty: usize,
253 pub orphaned: usize,
254 pub uninitialized: usize,
255}
256
257impl AggregateStatus {
258 pub fn from_submodules(submodules: &[Submodule]) -> Self {
259 let mut clean = 0; let mut ahead = 0; let mut behind = 0;
260 let mut detached = 0; let mut dirty = 0; let mut orphaned = 0; let mut uninit = 0;
261 for sm in submodules {
262 match sm.status {
263 SubmoduleStatus::Clean => clean += 1,
264 SubmoduleStatus::AheadOfParent => ahead += 1,
265 SubmoduleStatus::BehindRemote => behind += 1,
266 SubmoduleStatus::Detached => detached += 1,
267 SubmoduleStatus::Dirty => dirty += 1,
268 SubmoduleStatus::Orphaned => orphaned += 1,
269 SubmoduleStatus::Uninitialized => uninit += 1,
270 }
271 }
272 AggregateStatus { total: submodules.len(), clean, ahead_of_parent: ahead, behind_remote: behind, detached, dirty, orphaned, uninitialized: uninit }
273 }
274}
275
276pub struct GitSubmoduleEditor {
279 root: PathBuf,
280 offline: bool,
281}
282
283impl GitSubmoduleEditor {
284 pub fn new(root: PathBuf) -> Self {
285 Self { root, offline: false }
286 }
287
288 pub fn set_offline(&mut self, offline: bool) {
289 self.offline = offline;
290 }
291
292 pub fn fetch_submodule(path: &std::path::Path) -> Result<(), ()> {
293 let has_remote = std::process::Command::new("git")
294 .args(["remote", "get-url", "origin"]).current_dir(path).output()
295 .map(|o| o.status.success()).unwrap_or(false);
296 if !has_remote { return Ok(()); }
297 std::process::Command::new("git").args(["fetch", "origin"]).current_dir(path).output()
298 .map(|o| if o.status.success() { Ok(()) } else { Err(()) }).unwrap_or(Err(()))
299 }
300
301 pub fn push_submodule(path: &std::path::Path) -> Result<(), String> {
302 if !path.exists() { return Ok(()); }
303 let branch = std::process::Command::new("git").args(["rev-parse", "--abbrev-ref", "HEAD"])
304 .current_dir(path).output().ok()
305 .and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) } else { None })
306 .unwrap_or_default();
307 if branch.is_empty() || branch == "HEAD" { return Ok(()); }
308 if !std::process::Command::new("git").args(["remote", "get-url", "origin"])
309 .current_dir(path).output().map(|o| o.status.success()).unwrap_or(false) { return Ok(()); }
310 let tracking = format!("origin/{}", branch);
311 let ahead = std::process::Command::new("git").args(["rev-list", "--count", &format!("{}..{}", tracking, branch)])
312 .current_dir(path).output().ok()
313 .and_then(|o| if o.status.success() { String::from_utf8_lossy(&o.stdout).trim().parse::<i32>().ok() } else { None })
314 .unwrap_or(0);
315 if ahead <= 0 { return Ok(()); }
316 std::process::Command::new("git").args(["push", "origin", &branch]).current_dir(path).output()
317 .map(|o| if o.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&o.stderr).trim().to_string()) })
318 .unwrap_or_else(|e| Err(format!("git push 无法执行: {}", e)))
319 }
320
321 pub fn update_parent_pointer(repo: &git2::Repository, sm_path: &std::path::Path, name: &str) -> Result<(), Box<dyn std::error::Error>> {
322 let mut index = repo.index()?;
323 index.add_path(sm_path)?; index.write()?;
324 let tree_id = index.write_tree()?;
325 let tree = repo.find_tree(tree_id)?;
326 let head = repo.head()?;
327 let parent = head.peel_to_commit()?;
328 let signature = repo.signature()?;
329 repo.commit(Some("HEAD"), &signature, &signature, &format!("chore: 更新子模块 '{}' 指针", name), &tree, &[&parent])?;
330 Ok(())
331 }
332
333 pub fn push_parent(repo: &git2::Repository, root: &std::path::Path) -> Result<(), String> {
334 if !std::process::Command::new("git").args(["remote", "get-url", "origin"])
335 .current_dir(root).output().map(|o| o.status.success()).unwrap_or(false) { return Ok(()); }
336 let branch = repo.head().ok().and_then(|r| r.shorthand().map(|s| s.to_string())).unwrap_or_default();
337 if branch.is_empty() { return Err("无法检测当前分支".into()); }
338 std::process::Command::new("git").args(["push", "origin", &branch]).current_dir(root).output()
339 .map(|o| if o.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&o.stderr).trim().to_string()) })
340 .unwrap_or_else(|e| Err(format!("git push 无法执行: {}", e)))
341 }
342
343 pub fn revert_parent_commit(root: &std::path::Path) {
344 std::process::Command::new("git").args(["reset", "--hard", "HEAD~1"]).current_dir(root).output().ok();
345 }
346
347 pub fn root(&self) -> &std::path::Path {
348 &self.root
349 }
350
351 pub fn sync_to_parent(&self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
352 let repo = git2::Repository::open(&self.root)?;
353 let sm = repo.find_submodule(name)?;
354 let sm_path = sm.path();
355 let full_sm_path = self.root.join(sm_path);
356
357 if full_sm_path.exists() {
358 Self::fetch_submodule(&full_sm_path).ok();
359 }
360 Self::push_submodule(&full_sm_path).map_err(|e| format!("子模块 push 失败: {}", e))?;
361 Self::update_parent_pointer(&repo, sm_path, name)?;
362 if let Err(e) = Self::push_parent(&repo, &self.root) {
363 Self::revert_parent_commit(&self.root);
364 return Err(format!("父仓库 push 失败 (已回滚提交): {}", e).into());
365 }
366 println!(" ✓ {}", name);
367 Ok(())
368 }
369
370 pub fn sync_all_to_parent(&self) -> Result<(), Box<dyn std::error::Error>> {
371 let repo = git2::Repository::open(&self.root)?;
372 let submodules = repo.submodules()?;
373 println!("同步 {} 个子模块", submodules.len());
374 for sm in submodules.iter() {
375 let name = sm.name().unwrap_or("unknown").to_string();
376 match self.sync_to_parent(&name) {
377 Ok(()) => {}
378 Err(e) => println!(" {:<35} ✗ 失败: {}", name, e),
379 }
380 }
381 Ok(())
382 }
383
384 pub fn status(&self) -> Result<Vec<HealthIssue>, Box<dyn std::error::Error>> {
385 let state = RepoState::scan(&self.root)?;
386 let mut issues = Vec::new();
387 for sm in &state.submodules {
388 if sm.status != SubmoduleStatus::Clean {
389 let (description, action) = describe_issue(&sm.status);
390 issues.push(HealthIssue {
391 submodule_name: sm.name.clone(),
392 status: format!("{:?}", sm.status),
393 description,
394 suggested_action: action,
395 });
396 }
397 }
398 Ok(issues)
399 }
400}
401
402fn parse_oid(h: &CommitHash) -> Option<git2::Oid> {
403 git2::Oid::from_str(&h.0).ok()
404}
405
406fn count_between_opt(repo: &git2::Repository, from: Option<git2::Oid>, to: Option<git2::Oid>) -> usize {
407 let (Some(from), Some(to)) = (from, to) else { return 0; };
408 if from == to { return 0; }
409 let mut walk = match repo.revwalk() { Ok(w) => w, Err(_) => return 0, };
410 if walk.push(to).is_err() || walk.hide(from).is_err() { return 0; }
411 walk.count()
412}
413
414#[derive(Debug, Clone)]
415pub struct HealthIssue {
416 pub submodule_name: String,
417 pub status: String,
418 pub description: String,
419 pub suggested_action: String,
420}
421
422fn describe_issue(status: &SubmoduleStatus) -> (String, String) {
423 match status {
424 SubmoduleStatus::AheadOfParent => ("本地领先于父仓库记录".into(), "运行 sync_to_parent 更新父仓库指针".into()),
425 SubmoduleStatus::BehindRemote => ("远程有更新,本地落后".into(), "运行 update 获取最新代码".into()),
426 SubmoduleStatus::Detached => ("处于游离 HEAD 状态".into(), "运行 checkout_branch 切换到跟踪分支".into()),
427 SubmoduleStatus::Dirty => ("有未提交的修改".into(), "提交或 stash 当前修改".into()),
428 SubmoduleStatus::Orphaned => ("父仓库记录的 commit 在远程已不存在".into(), "需手动干预".into()),
429 SubmoduleStatus::Uninitialized => ("尚未初始化".into(), "运行 init 初始化子模块".into()),
430 SubmoduleStatus::Clean => unreachable!(),
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use std::process::Command;
438
439 fn git_init(path: &std::path::Path) {
440 Command::new("git").args(["init", "-b", "main"]).current_dir(path).output().unwrap();
441 Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
442 Command::new("git").args(["config", "user.name", "Test"]).current_dir(path).output().unwrap();
443 }
444
445 fn git_commit(path: &std::path::Path, msg: &str) {
446 std::fs::write(path.join("file"), msg).unwrap();
447 Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
448 Command::new("git").args(["commit", "-m", msg]).current_dir(path).output().unwrap();
449 }
450
451 fn setup_repo_with_submodule(tmp: &std::path::Path) -> std::path::PathBuf {
452 let parent = tmp.join("parent");
453 let sub = tmp.join("sub");
454 std::fs::create_dir_all(&sub).unwrap(); git_init(&sub); git_commit(&sub, "init sub");
455 std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init parent");
456 Command::new("git").args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
457 Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
458 parent
459 }
460
461 #[test] fn test_status_priority_ordering() {
463 assert!(SubmoduleStatus::Dirty.priority() < SubmoduleStatus::Clean.priority());
464 assert!(SubmoduleStatus::Orphaned.priority() < SubmoduleStatus::BehindRemote.priority());
465 }
466 #[test] fn test_clean_is_lowest_priority() {
467 for s in &[SubmoduleStatus::Dirty, SubmoduleStatus::Orphaned, SubmoduleStatus::Detached, SubmoduleStatus::Uninitialized, SubmoduleStatus::BehindRemote, SubmoduleStatus::AheadOfParent] {
468 assert!(s.priority() < SubmoduleStatus::Clean.priority());
469 }
470 }
471 #[test] fn test_all_priorities_are_unique() {
472 let p: Vec<u8> = [SubmoduleStatus::Dirty, SubmoduleStatus::Orphaned, SubmoduleStatus::Detached, SubmoduleStatus::Uninitialized, SubmoduleStatus::BehindRemote, SubmoduleStatus::AheadOfParent, SubmoduleStatus::Clean].iter().map(|s| s.priority()).collect();
473 let mut s = p.clone(); s.sort(); s.dedup();
474 assert_eq!(p.len(), s.len());
475 }
476 #[test] fn test_status_debug_output() { assert_eq!(format!("{:?}", SubmoduleStatus::Clean), "Clean"); }
477 #[test] fn test_status_clone_eq() { assert_eq!(SubmoduleStatus::Dirty, SubmoduleStatus::Dirty); }
478
479 fn dh() -> CommitHash { CommitHash::default() }
481 fn h(s: &str) -> CommitHash { CommitHash(s.to_string()) }
482 #[test] fn test_determine_status_uninitialized() { assert_eq!(RepoState::determine_submodule_status(true, false, false, false, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Uninitialized); }
483 #[test] fn test_determine_status_dirty() { assert_eq!(RepoState::determine_submodule_status(false, true, false, false, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Dirty); }
484 #[test] fn test_determine_status_detached() { assert_eq!(RepoState::determine_submodule_status(false, false, true, false, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Detached); }
485 #[test] fn test_determine_status_orphaned() { assert_eq!(RepoState::determine_submodule_status(false, false, false, true, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Orphaned); }
486 #[test] fn test_determine_status_ahead_of_parent() {
487 assert_eq!(RepoState::determine_submodule_status(false, false, false, false, true, 0, 0, &h("abc"), &dh()), SubmoduleStatus::AheadOfParent);
488 assert_eq!(RepoState::determine_submodule_status(false, false, false, false, false, 5, 0, &dh(), &dh()), SubmoduleStatus::AheadOfParent);
489 assert_eq!(RepoState::determine_submodule_status(false, false, false, false, false, 5, 3, &dh(), &dh()), SubmoduleStatus::BehindRemote);
490 }
491 #[test] fn test_determine_status_behind_remote() {
492 assert_eq!(RepoState::determine_submodule_status(false, false, false, false, false, 0, 3, &dh(), &dh()), SubmoduleStatus::BehindRemote);
493 assert_eq!(RepoState::determine_submodule_status(false, false, false, false, true, 0, 3, &dh(), &dh()), SubmoduleStatus::Clean);
494 }
495 #[test] fn test_determine_status_clean() { assert_eq!(RepoState::determine_submodule_status(false, false, false, false, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Clean); }
496
497 #[test] fn test_commit_hash_display_truncates() { assert_eq!(CommitHash("abcdef1234567890".to_string()).to_string(), "abcdef1"); }
499 #[test] fn test_commit_hash_display_short() { assert_eq!(CommitHash("abc".to_string()).to_string(), "abc"); }
500 #[test] fn test_commit_hash_display_empty() { assert_eq!(CommitHash(String::new()).to_string(), ""); }
501 #[test] fn test_commit_hash_equality() { assert_eq!(CommitHash("abc".to_string()), CommitHash("abc".to_string())); }
502 #[test] fn test_commit_hash_default() { assert_eq!(CommitHash::default().0, "0000000000000000000000000000000000000000"); }
503 #[test] fn test_commit_hash_clone() { let a = CommitHash("deadbeef".to_string()); assert_eq!(a, a.clone()); }
504
505 #[test] fn test_submodule_builder() {
507 let sm = Submodule { name: "test".into(), path: PathBuf::from("libs/test"), url: "https://example.com/test.git".into(), tracked_branch: "main".into(), parent_pointer: CommitHash("aaa".into()), local_head: CommitHash("bbb".into()), remote_head: CommitHash("ccc".into()), status: SubmoduleStatus::BehindRemote, ahead_count: 0, behind_count: 3, remote_unreachable: false };
508 assert_eq!(sm.name, "test");
509 }
510
511 #[test] fn test_aggregate_status_default() { assert_eq!(AggregateStatus::default().total, 0); }
513 #[test] fn test_aggregate_status_from_submodules() {
514 let sm = |s| Submodule { name: String::new(), path: PathBuf::new(), url: String::new(), tracked_branch: "main".into(), parent_pointer: CommitHash::default(), local_head: CommitHash::default(), remote_head: CommitHash::default(), status: s, ahead_count: 0, behind_count: 0, remote_unreachable: false };
515 let agg = AggregateStatus::from_submodules(&[sm(SubmoduleStatus::Clean), sm(SubmoduleStatus::Dirty), sm(SubmoduleStatus::Orphaned)]);
516 assert_eq!(agg.total, 3); assert_eq!(agg.clean, 1); assert_eq!(agg.dirty, 1); assert_eq!(agg.orphaned, 1);
517 }
518 #[test] fn test_aggregate_status_all_variants() {
519 let sm = |s| Submodule { name: String::new(), path: PathBuf::new(), url: String::new(), tracked_branch: "main".into(), parent_pointer: CommitHash::default(), local_head: CommitHash::default(), remote_head: CommitHash::default(), status: s, ahead_count: 0, behind_count: 0, remote_unreachable: false };
520 let agg = AggregateStatus::from_submodules(&[sm(SubmoduleStatus::Clean), sm(SubmoduleStatus::AheadOfParent), sm(SubmoduleStatus::BehindRemote), sm(SubmoduleStatus::Detached), sm(SubmoduleStatus::Dirty), sm(SubmoduleStatus::Orphaned), sm(SubmoduleStatus::Uninitialized)]);
521 assert_eq!(agg.total, 7); assert_eq!(agg.clean, 1);
522 }
523
524 #[test] fn test_parse_oid_valid() { assert!(parse_oid(&CommitHash("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0".into())).is_some()); }
526 #[test] fn test_parse_oid_invalid() { assert!(parse_oid(&CommitHash("not-a-hex-string".into())).is_none()); }
527 #[test] fn test_parse_oid_empty() { assert!(parse_oid(&CommitHash(String::new())).is_none()); }
528 #[test] fn test_count_between_opt_both_none() { let t = tempfile::tempdir().unwrap(); git_init(t.path()); let r = git2::Repository::open(t.path()).unwrap(); assert_eq!(count_between_opt(&r, None, None), 0); }
529 #[test] fn test_count_between_opt_equal_oids() { let t = tempfile::tempdir().unwrap(); git_init(t.path()); git_commit(t.path(), "c1"); let r = git2::Repository::open(t.path()).unwrap(); let h = r.head().unwrap().target().unwrap(); assert_eq!(count_between_opt(&r, Some(h), Some(h)), 0); }
530 #[test] fn test_count_between_opt_from_to() { let t = tempfile::tempdir().unwrap(); git_init(t.path()); git_commit(t.path(), "c1"); let r = git2::Repository::open(t.path()).unwrap(); let c1 = r.head().unwrap().target().unwrap(); git_commit(t.path(), "c2"); let c2 = r.head().unwrap().target().unwrap(); assert_eq!(count_between_opt(&r, Some(c1), Some(c2)), 1); }
531 #[test] fn test_count_between_opt_revwalk_fail() { let t = tempfile::tempdir().unwrap(); git_init(t.path()); let r = git2::Repository::open(t.path()).unwrap(); let o = git2::Oid::from_str("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0").ok(); assert_eq!(count_between_opt(&r, o, o), 0); }
532
533 #[test] fn test_scan_no_gitmodules() { assert!(RepoState::scan(&tempfile::tempdir().unwrap().path()).is_err()); }
535 #[test] fn test_scan_git_repo_but_no_submodules() { let t = tempfile::tempdir().unwrap(); git_init(t.path()); git_commit(t.path(), "initial"); assert_eq!(RepoState::scan(t.path()).unwrap().total, 0); }
536 #[test] fn test_scan_non_git_directory() { let t = tempfile::tempdir().unwrap(); std::fs::write(t.path().join(".gitmodules"), "").unwrap(); assert!(RepoState::scan(t.path()).is_err()); }
537 #[test] fn test_scan_with_submodule() { let t = tempfile::tempdir().unwrap(); let p = setup_repo_with_submodule(t.path()); let s = RepoState::scan(&p).unwrap(); assert_eq!(s.total, 1); assert_eq!(s.submodules[0].name, "libs/sub"); }
538 #[test] fn test_scan_all_no_gitmodules() { assert!(RepoState::scan_all(&tempfile::tempdir().unwrap().path()).is_err()); }
539 #[test] fn test_scan_all_with_submodule() { let t = tempfile::tempdir().unwrap(); let p = setup_repo_with_submodule(t.path()); let (subs, _) = RepoState::scan_all(&p).unwrap(); assert_eq!(subs.len(), 1); }
540 #[test] fn test_repo_state_empty() { let s = RepoState { root_path: PathBuf::from("/tmp"), submodules: vec![], total: 0, clean_count: 0, needs_attention: vec![] }; assert_eq!(s.total, 0); }
541
542 #[test] fn test_editor_new_and_root() { let e = GitSubmoduleEditor::new(PathBuf::from("/tmp")); assert_eq!(e.root(), std::path::Path::new("/tmp")); }
544 #[test] fn test_editor_sync_to_parent() { let t = tempfile::tempdir().unwrap(); let p = setup_repo_with_submodule(t.path()); assert!(GitSubmoduleEditor::new(p).sync_to_parent("libs/sub").is_ok()); }
545 #[test] fn test_editor_sync_to_parent_nonexistent() { let t = tempfile::tempdir().unwrap(); std::fs::create_dir_all(t.path().join(".git")).unwrap(); assert!(GitSubmoduleEditor::new(t.path().to_path_buf()).sync_to_parent("no-such-module").is_err()); }
546 #[test] fn test_editor_sync_all_to_parent() { let t = tempfile::tempdir().unwrap(); let p = setup_repo_with_submodule(t.path()); assert!(GitSubmoduleEditor::new(p).sync_all_to_parent().is_ok()); }
547 #[test] fn test_editor_sync_all_to_parent_no_submodules() { let t = tempfile::tempdir().unwrap(); git_init(t.path()); git_commit(t.path(), "initial"); assert!(GitSubmoduleEditor::new(t.path().to_path_buf()).sync_all_to_parent().is_ok()); }
548 #[test] fn test_editor_status() { let t = tempfile::tempdir().unwrap(); let p = setup_repo_with_submodule(t.path()); assert!(GitSubmoduleEditor::new(p).status().unwrap().is_empty()); }
549 #[test] fn test_editor_status_with_gitmodules_but_no_repo() { let t = tempfile::tempdir().unwrap(); std::fs::write(t.path().join(".gitmodules"), "").unwrap(); assert!(GitSubmoduleEditor::new(t.path().to_path_buf()).status().is_err()); }
550
551 #[test] fn test_editor_sync_with_remote_push() {
552 let tmp = tempfile::tempdir().unwrap();
553 let bare_sub = tmp.path().join("bare-sub");
554 let bare_parent = tmp.path().join("bare-parent");
555 for b in [&bare_sub, &bare_parent] { Command::new("git").args(["init", "--bare", &b.to_string_lossy()]).output().unwrap(); }
556 let sub = tmp.path().join("sub");
557 Command::new("git").args(["clone", &bare_sub.to_string_lossy(), &sub.to_string_lossy()]).current_dir(tmp.path()).output().unwrap();
558 git_init(&sub); git_commit(&sub, "init"); Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
559 let parent = tmp.path().join("parent");
560 std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init parent");
561 Command::new("git").args(["remote", "add", "origin", &bare_parent.to_string_lossy()]).current_dir(&parent).output().unwrap();
562 Command::new("git").args(["submodule", "add", &bare_sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
563 Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
564 git_commit(&sub, "ahead"); Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
565 Command::new("git").args(["fetch", "origin"]).current_dir(&parent.join("libs/sub")).output().unwrap();
566 assert!(GitSubmoduleEditor::new(parent).sync_to_parent("libs/sub").is_ok(), "sync failed");
567 }
568
569 #[test] fn test_editor_status_with_dirty_submodule() {
570 let t = tempfile::tempdir().unwrap(); let p = setup_repo_with_submodule(t.path());
571 std::fs::write(p.join("libs/sub/new-file"), "content").unwrap();
572 let issues = GitSubmoduleEditor::new(p).status().unwrap();
573 assert!(!issues.is_empty()); assert_eq!(issues[0].status, "Dirty");
574 }
575
576 #[test] fn test_describe_issue_ahead_of_parent() { let (d, a) = describe_issue(&SubmoduleStatus::AheadOfParent); assert!(d.contains("领先")); assert!(a.contains("sync")); }
578 #[test] fn test_describe_issue_behind_remote() { let (d, a) = describe_issue(&SubmoduleStatus::BehindRemote); assert!(d.contains("落后")); assert!(a.contains("update")); }
579 #[test] fn test_describe_issue_detached() { let (d, a) = describe_issue(&SubmoduleStatus::Detached); assert!(d.contains("游离")); assert!(a.contains("checkout")); }
580 #[test] fn test_describe_issue_dirty() { let (d, a) = describe_issue(&SubmoduleStatus::Dirty); assert!(d.contains("修改")); }
581 #[test] fn test_describe_issue_orphaned() { let (d, a) = describe_issue(&SubmoduleStatus::Orphaned); assert!(d.contains("不存在")); }
582 #[test] fn test_describe_issue_uninitialized() { let (d, a) = describe_issue(&SubmoduleStatus::Uninitialized); assert!(d.contains("初始化")); }
583 #[test] #[should_panic(expected = "unreachable")] fn test_describe_issue_clean_panics() { describe_issue(&SubmoduleStatus::Clean); }
584
585 #[test] fn test_scan_with_uninitialized_submodule() {
587 let tmp = tempfile::tempdir().unwrap(); let parent = tmp.path().join("parent");
588 std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init");
589 let sub = tmp.path().join("sub"); std::fs::create_dir_all(&sub).unwrap(); git_init(&sub); git_commit(&sub, "init");
590 Command::new("git").args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
591 Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
592 Command::new("git").args(["submodule", "deinit", "-f", "libs/sub"]).current_dir(&parent).output().unwrap();
593 assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].status, SubmoduleStatus::Uninitialized);
594 }
595
596 #[test] fn test_scan_with_detached_submodule() {
597 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
598 let sm_path = parent.join("libs/sub");
599 let hash = String::from_utf8_lossy(&Command::new("git").args(["rev-parse", "HEAD"]).current_dir(&sm_path).output().unwrap().stdout).trim().to_string();
600 Command::new("git").args(["checkout", "--detach", &hash]).current_dir(&sm_path).output().unwrap();
601 assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].status, SubmoduleStatus::Detached);
602 }
603
604 #[test] fn test_scan_with_ahead_via_remote_unreachable() {
605 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
606 let sm_path = parent.join("libs/sub");
607 std::fs::write(sm_path.join("new-file"), "content").unwrap();
608 Command::new("git").args(["add", "."]).current_dir(&sm_path).output().unwrap();
609 Command::new("git").args(["commit", "-m", "ahead commit"]).current_dir(&sm_path).output().unwrap();
610 Command::new("git").args(["remote", "remove", "origin"]).current_dir(&sm_path).output().unwrap();
611 let state = RepoState::scan(&parent).unwrap();
612 assert_eq!(state.submodules[0].status, SubmoduleStatus::AheadOfParent);
613 }
614
615 #[test] fn test_scan_with_subrepo_open_error() {
616 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
617 let sm_git = parent.join("libs/sub/.git");
618 if sm_git.is_dir() { std::fs::remove_dir_all(&sm_git).unwrap(); } else { std::fs::remove_file(&sm_git).unwrap(); }
619 assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].local_head, CommitHash::default());
620 }
621
622 #[test] fn test_scan_with_behind_remote() {
623 let tmp = tempfile::tempdir().unwrap(); let parent = tmp.path().join("parent"); let sub = tmp.path().join("sub"); let bare = tmp.path().join("bare");
624 std::fs::create_dir_all(&bare).unwrap(); Command::new("git").args(["init", "--bare", &bare.to_string_lossy()]).current_dir(tmp.path()).output().unwrap();
625 Command::new("git").args(["clone", &bare.to_string_lossy(), &sub.to_string_lossy()]).current_dir(tmp.path()).output().unwrap();
626 git_init(&sub); git_commit(&sub, "init"); Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
627 std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init parent");
628 Command::new("git").args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
629 Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
630 git_commit(&sub, "remote ahead"); Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
631 Command::new("git").args(["fetch", "origin"]).current_dir(&parent.join("libs/sub")).output().unwrap();
632 assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].behind_count, 1);
633 }
634
635 #[test] fn test_scan_with_orphaned_submodule() {
636 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
637 let sm_path = parent.join("libs/sub");
638 Command::new("git").args(["remote", "remove", "origin"]).current_dir(&sm_path).output().unwrap();
639 let ref_dir = parent.join(".git/modules/libs/sub/refs/remotes/origin");
640 std::fs::create_dir_all(&ref_dir).unwrap();
641 std::fs::write(ref_dir.join("main"), "1111111111111111111111111111111111111111\n").unwrap();
642 assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].status, SubmoduleStatus::Orphaned);
643 }
644
645 #[test] fn test_scan_with_ahead_of_parent_clean() {
646 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
647 git_commit(&parent.join("libs/sub"), "ahead commit");
648 assert!(RepoState::scan(&parent).unwrap().submodules[0].ahead_count > 0);
649 }
650
651 #[test] fn test_orphaned_parse_oid_failure() {
652 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
653 let ref_dir = parent.join(".git/modules/libs/sub/refs/remotes/origin");
654 if !ref_dir.exists() { std::fs::create_dir_all(&ref_dir).unwrap(); }
655 std::fs::write(ref_dir.join("main"), "not-a-valid-oid\n").unwrap();
656 assert!(!RepoState::scan(&parent).unwrap().submodules.is_empty());
657 }
658
659 #[test] fn test_ahead_of_parent_via_ahead_count() {
660 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
661 let sm_path = parent.join("libs/sub");
662 Command::new("git").args(["remote", "remove", "origin"]).current_dir(&sm_path).output().unwrap();
663 std::fs::write(sm_path.join("new-file"), "content").unwrap();
664 Command::new("git").args(["add", "."]).current_dir(&sm_path).output().unwrap();
665 Command::new("git").args(["commit", "-m", "ahead"]).current_dir(&sm_path).output().unwrap();
666 let state = RepoState::scan(&parent).unwrap();
667 assert_eq!(state.submodules[0].ahead_count, 1);
668 }
669}