Skip to main content

qtcloud_devops_cli/git/
submodule.rs

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
276// ===== git operations =====
277
278pub 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 rebase_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 output = std::process::Command::new("git")
311            .args(["rebase", &format!("origin/{}", branch)])
312            .current_dir(path).output()
313            .map_err(|e| format!("git rebase 无法执行: {}", e))?;
314        if !output.status.success() {
315            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
316            if stderr.contains("up to date") || stderr.contains("up-to-date") {
317                return Ok(());
318            }
319            return Err(format!("rebase 冲突,需手动处理:解决冲突后 git rebase --continue,或 git rebase --abort 放弃\n{}", stderr));
320        }
321        Ok(())
322    }
323
324    pub fn push_submodule(path: &std::path::Path) -> Result<(), String> {
325        if !path.exists() { return Ok(()); }
326        let branch = std::process::Command::new("git").args(["rev-parse", "--abbrev-ref", "HEAD"])
327            .current_dir(path).output().ok()
328            .and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) } else { None })
329            .unwrap_or_default();
330        if branch.is_empty() || branch == "HEAD" { return Ok(()); }
331        if !std::process::Command::new("git").args(["remote", "get-url", "origin"])
332            .current_dir(path).output().map(|o| o.status.success()).unwrap_or(false) { return Ok(()); }
333        let tracking = format!("origin/{}", branch);
334        let ahead = std::process::Command::new("git").args(["rev-list", "--count", &format!("{}..{}", tracking, branch)])
335            .current_dir(path).output().ok()
336            .and_then(|o| if o.status.success() { String::from_utf8_lossy(&o.stdout).trim().parse::<i32>().ok() } else { None })
337            .unwrap_or(0);
338        if ahead <= 0 { return Ok(()); }
339        std::process::Command::new("git").args(["push", "origin", &branch]).current_dir(path).output()
340            .map(|o| if o.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&o.stderr).trim().to_string()) })
341            .unwrap_or_else(|e| Err(format!("git push 无法执行: {}", e)))
342    }
343
344    pub fn update_parent_pointer(repo: &git2::Repository, sm_path: &std::path::Path, name: &str) -> Result<(), Box<dyn std::error::Error>> {
345        let mut index = repo.index()?;
346        index.add_path(sm_path)?; index.write()?;
347        let tree_id = index.write_tree()?;
348        let tree = repo.find_tree(tree_id)?;
349        let head = repo.head()?;
350        let parent = head.peel_to_commit()?;
351        let signature = repo.signature()?;
352        repo.commit(Some("HEAD"), &signature, &signature, &format!("chore: 更新子模块 '{}' 指针", name), &tree, &[&parent])?;
353        Ok(())
354    }
355
356    pub fn push_parent(repo: &git2::Repository, root: &std::path::Path) -> Result<(), String> {
357        if !std::process::Command::new("git").args(["remote", "get-url", "origin"])
358            .current_dir(root).output().map(|o| o.status.success()).unwrap_or(false) { return Ok(()); }
359        let branch = repo.head().ok().and_then(|r| r.shorthand().map(|s| s.to_string())).unwrap_or_default();
360        if branch.is_empty() { return Err("无法检测当前分支".into()); }
361        std::process::Command::new("git").args(["push", "origin", &branch]).current_dir(root).output()
362            .map(|o| if o.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&o.stderr).trim().to_string()) })
363            .unwrap_or_else(|e| Err(format!("git push 无法执行: {}", e)))
364    }
365
366    pub fn revert_parent_commit(root: &std::path::Path) {
367        std::process::Command::new("git").args(["reset", "--hard", "HEAD~1"]).current_dir(root).output().ok();
368    }
369
370    pub fn root(&self) -> &std::path::Path {
371        &self.root
372    }
373
374    pub fn sync_to_parent(&self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
375        let repo = git2::Repository::open(&self.root)?;
376        let sm = repo.find_submodule(name)?;
377        let sm_path = sm.path();
378        let full_sm_path = self.root.join(sm_path);
379
380        if full_sm_path.exists() {
381            Self::fetch_submodule(&full_sm_path).ok();
382            Self::rebase_submodule(&full_sm_path)?;
383        }
384        Self::push_submodule(&full_sm_path).map_err(|e| format!("子模块 push 失败: {}", e))?;
385        Self::update_parent_pointer(&repo, sm_path, name)?;
386        if let Err(e) = Self::push_parent(&repo, &self.root) {
387            Self::revert_parent_commit(&self.root);
388            return Err(format!("父仓库 push 失败 (已回滚提交): {}", e).into());
389        }
390        println!("  ✓ {}", name);
391        Ok(())
392    }
393
394    pub fn sync_all_to_parent(&self) -> Result<(), Box<dyn std::error::Error>> {
395        let repo = git2::Repository::open(&self.root)?;
396        let submodules = repo.submodules()?;
397        println!("同步 {} 个子模块", submodules.len());
398        for sm in submodules.iter() {
399            let name = sm.name().unwrap_or("unknown").to_string();
400            match self.sync_to_parent(&name) {
401                Ok(()) => {}
402                Err(e) => println!("  {:<35} ✗ 失败: {}", name, e),
403            }
404        }
405        Ok(())
406    }
407
408    pub fn status(&self) -> Result<Vec<HealthIssue>, Box<dyn std::error::Error>> {
409        let state = RepoState::scan(&self.root)?;
410        let mut issues = Vec::new();
411        for sm in &state.submodules {
412            if sm.status != SubmoduleStatus::Clean {
413                let (description, action) = describe_issue(&sm.status);
414                issues.push(HealthIssue {
415                    submodule_name: sm.name.clone(),
416                    status: format!("{:?}", sm.status),
417                    description,
418                    suggested_action: action,
419                });
420            }
421        }
422        Ok(issues)
423    }
424}
425
426fn parse_oid(h: &CommitHash) -> Option<git2::Oid> {
427    git2::Oid::from_str(&h.0).ok()
428}
429
430fn count_between_opt(repo: &git2::Repository, from: Option<git2::Oid>, to: Option<git2::Oid>) -> usize {
431    let (Some(from), Some(to)) = (from, to) else { return 0; };
432    if from == to { return 0; }
433    let mut walk = match repo.revwalk() { Ok(w) => w, Err(_) => return 0, };
434    if walk.push(to).is_err() || walk.hide(from).is_err() { return 0; }
435    walk.count()
436}
437
438#[derive(Debug, Clone)]
439pub struct HealthIssue {
440    pub submodule_name: String,
441    pub status: String,
442    pub description: String,
443    pub suggested_action: String,
444}
445
446fn describe_issue(status: &SubmoduleStatus) -> (String, String) {
447    match status {
448        SubmoduleStatus::AheadOfParent => ("本地领先于父仓库记录".into(), "运行 sync_to_parent 更新父仓库指针".into()),
449        SubmoduleStatus::BehindRemote => ("远程有更新,本地落后".into(), "运行 code sync 获取最新代码".into()),
450        SubmoduleStatus::Detached => ("处于游离 HEAD 状态".into(), "运行 checkout_branch 切换到跟踪分支".into()),
451        SubmoduleStatus::Dirty => ("有未提交的修改".into(), "提交或 stash 当前修改".into()),
452        SubmoduleStatus::Orphaned => ("父仓库记录的 commit 在远程已不存在".into(), "需手动干预".into()),
453        SubmoduleStatus::Uninitialized => ("尚未初始化".into(), "运行 init 初始化子模块".into()),
454        SubmoduleStatus::Clean => unreachable!(),
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use std::process::Command;
462
463    fn git_init(path: &std::path::Path) {
464        Command::new("git").args(["init", "-b", "main"]).current_dir(path).output().unwrap();
465        Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
466        Command::new("git").args(["config", "user.name", "Test"]).current_dir(path).output().unwrap();
467    }
468
469    fn git_commit(path: &std::path::Path, msg: &str) {
470        std::fs::write(path.join("file"), msg).unwrap();
471        Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
472        Command::new("git").args(["commit", "-m", msg]).current_dir(path).output().unwrap();
473    }
474
475    fn setup_repo_with_submodule(tmp: &std::path::Path) -> std::path::PathBuf {
476        let parent = tmp.join("parent");
477        let sub = tmp.join("sub");
478        std::fs::create_dir_all(&sub).unwrap(); git_init(&sub); git_commit(&sub, "init sub");
479        std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init parent");
480        Command::new("git").args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
481        Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
482        parent
483    }
484
485    // ---- SubmoduleStatus tests ----
486    #[test] fn test_status_priority_ordering() {
487        assert!(SubmoduleStatus::Dirty.priority() < SubmoduleStatus::Clean.priority());
488        assert!(SubmoduleStatus::Orphaned.priority() < SubmoduleStatus::BehindRemote.priority());
489    }
490    #[test] fn test_clean_is_lowest_priority() {
491        for s in &[SubmoduleStatus::Dirty, SubmoduleStatus::Orphaned, SubmoduleStatus::Detached, SubmoduleStatus::Uninitialized, SubmoduleStatus::BehindRemote, SubmoduleStatus::AheadOfParent] {
492            assert!(s.priority() < SubmoduleStatus::Clean.priority());
493        }
494    }
495    #[test] fn test_all_priorities_are_unique() {
496        let p: Vec<u8> = [SubmoduleStatus::Dirty, SubmoduleStatus::Orphaned, SubmoduleStatus::Detached, SubmoduleStatus::Uninitialized, SubmoduleStatus::BehindRemote, SubmoduleStatus::AheadOfParent, SubmoduleStatus::Clean].iter().map(|s| s.priority()).collect();
497        let mut s = p.clone(); s.sort(); s.dedup();
498        assert_eq!(p.len(), s.len());
499    }
500    #[test] fn test_status_debug_output() { assert_eq!(format!("{:?}", SubmoduleStatus::Clean), "Clean"); }
501    #[test] fn test_status_clone_eq() { assert_eq!(SubmoduleStatus::Dirty, SubmoduleStatus::Dirty); }
502
503    // ---- determine_submodule_status ----
504    fn dh() -> CommitHash { CommitHash::default() }
505    fn h(s: &str) -> CommitHash { CommitHash(s.to_string()) }
506    #[test] fn test_determine_status_uninitialized() { assert_eq!(RepoState::determine_submodule_status(true, false, false, false, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Uninitialized); }
507    #[test] fn test_determine_status_dirty() { assert_eq!(RepoState::determine_submodule_status(false, true, false, false, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Dirty); }
508    #[test] fn test_determine_status_detached() { assert_eq!(RepoState::determine_submodule_status(false, false, true, false, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Detached); }
509    #[test] fn test_determine_status_orphaned() { assert_eq!(RepoState::determine_submodule_status(false, false, false, true, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Orphaned); }
510    #[test] fn test_determine_status_ahead_of_parent() {
511        assert_eq!(RepoState::determine_submodule_status(false, false, false, false, true, 0, 0, &h("abc"), &dh()), SubmoduleStatus::AheadOfParent);
512        assert_eq!(RepoState::determine_submodule_status(false, false, false, false, false, 5, 0, &dh(), &dh()), SubmoduleStatus::AheadOfParent);
513        assert_eq!(RepoState::determine_submodule_status(false, false, false, false, false, 5, 3, &dh(), &dh()), SubmoduleStatus::BehindRemote);
514    }
515    #[test] fn test_determine_status_behind_remote() {
516        assert_eq!(RepoState::determine_submodule_status(false, false, false, false, false, 0, 3, &dh(), &dh()), SubmoduleStatus::BehindRemote);
517        assert_eq!(RepoState::determine_submodule_status(false, false, false, false, true, 0, 3, &dh(), &dh()), SubmoduleStatus::Clean);
518    }
519    #[test] fn test_determine_status_clean() { assert_eq!(RepoState::determine_submodule_status(false, false, false, false, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Clean); }
520
521    // ---- CommitHash ----
522    #[test] fn test_commit_hash_display_truncates() { assert_eq!(CommitHash("abcdef1234567890".to_string()).to_string(), "abcdef1"); }
523    #[test] fn test_commit_hash_display_short() { assert_eq!(CommitHash("abc".to_string()).to_string(), "abc"); }
524    #[test] fn test_commit_hash_display_empty() { assert_eq!(CommitHash(String::new()).to_string(), ""); }
525    #[test] fn test_commit_hash_equality() { assert_eq!(CommitHash("abc".to_string()), CommitHash("abc".to_string())); }
526    #[test] fn test_commit_hash_default() { assert_eq!(CommitHash::default().0, "0000000000000000000000000000000000000000"); }
527    #[test] fn test_commit_hash_clone() { let a = CommitHash("deadbeef".to_string()); assert_eq!(a, a.clone()); }
528
529    // ---- Submodule ----
530    #[test] fn test_submodule_builder() {
531        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 };
532        assert_eq!(sm.name, "test");
533    }
534
535    // ---- AggregateStatus ----
536    #[test] fn test_aggregate_status_default() { assert_eq!(AggregateStatus::default().total, 0); }
537    #[test] fn test_aggregate_status_from_submodules() {
538        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 };
539        let agg = AggregateStatus::from_submodules(&[sm(SubmoduleStatus::Clean), sm(SubmoduleStatus::Dirty), sm(SubmoduleStatus::Orphaned)]);
540        assert_eq!(agg.total, 3); assert_eq!(agg.clean, 1); assert_eq!(agg.dirty, 1); assert_eq!(agg.orphaned, 1);
541    }
542    #[test] fn test_aggregate_status_all_variants() {
543        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 };
544        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)]);
545        assert_eq!(agg.total, 7); assert_eq!(agg.clean, 1);
546    }
547
548    // ---- parse_oid / count_between ----
549    #[test] fn test_parse_oid_valid() { assert!(parse_oid(&CommitHash("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0".into())).is_some()); }
550    #[test] fn test_parse_oid_invalid() { assert!(parse_oid(&CommitHash("not-a-hex-string".into())).is_none()); }
551    #[test] fn test_parse_oid_empty() { assert!(parse_oid(&CommitHash(String::new())).is_none()); }
552    #[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); }
553    #[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); }
554    #[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); }
555    #[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); }
556
557    // ---- scan tests ----
558    #[test] fn test_scan_no_gitmodules() { assert!(RepoState::scan(&tempfile::tempdir().unwrap().path()).is_err()); }
559    #[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); }
560    #[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()); }
561    #[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"); }
562    #[test] fn test_scan_all_no_gitmodules() { assert!(RepoState::scan_all(&tempfile::tempdir().unwrap().path()).is_err()); }
563    #[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); }
564    #[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); }
565
566    // ---- GitSubmoduleEditor ----
567    #[test] fn test_editor_new_and_root() { let e = GitSubmoduleEditor::new(PathBuf::from("/tmp")); assert_eq!(e.root(), std::path::Path::new("/tmp")); }
568    #[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()); }
569    #[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()); }
570    #[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()); }
571    #[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()); }
572    #[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()); }
573    #[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()); }
574
575    #[test] fn test_editor_sync_with_remote_push() {
576        let tmp = tempfile::tempdir().unwrap();
577        let bare_sub = tmp.path().join("bare-sub");
578        let bare_parent = tmp.path().join("bare-parent");
579        for b in [&bare_sub, &bare_parent] { Command::new("git").args(["init", "--bare", &b.to_string_lossy()]).output().unwrap(); }
580        let sub = tmp.path().join("sub");
581        Command::new("git").args(["clone", &bare_sub.to_string_lossy(), &sub.to_string_lossy()]).current_dir(tmp.path()).output().unwrap();
582        git_init(&sub); git_commit(&sub, "init"); Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
583        let parent = tmp.path().join("parent");
584        std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init parent");
585        Command::new("git").args(["remote", "add", "origin", &bare_parent.to_string_lossy()]).current_dir(&parent).output().unwrap();
586        Command::new("git").args(["submodule", "add", &bare_sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
587        Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
588        git_commit(&sub, "ahead"); Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
589        Command::new("git").args(["fetch", "origin"]).current_dir(&parent.join("libs/sub")).output().unwrap();
590        assert!(GitSubmoduleEditor::new(parent).sync_to_parent("libs/sub").is_ok(), "sync failed");
591    }
592
593    #[test] fn test_editor_sync_rebase_catches_up() {
594        let tmp = tempfile::tempdir().unwrap();
595        let bare_sub = tmp.path().join("bare-sub");
596        let bare_parent = tmp.path().join("bare-parent");
597        for b in [&bare_sub, &bare_parent] { Command::new("git").args(["init", "--bare", &b.to_string_lossy()]).output().unwrap(); }
598        let sub = tmp.path().join("sub");
599        Command::new("git").args(["clone", &bare_sub.to_string_lossy(), &sub.to_string_lossy()]).current_dir(tmp.path()).output().unwrap();
600        git_init(&sub); git_commit(&sub, "init");
601        Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
602        let init_hash = String::from_utf8_lossy(&Command::new("git").args(["rev-parse", "HEAD"]).current_dir(&sub).output().unwrap().stdout).trim().to_string();
603        let parent = tmp.path().join("parent");
604        std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init parent");
605        Command::new("git").args(["remote", "add", "origin", &bare_parent.to_string_lossy()]).current_dir(&parent).output().unwrap();
606        Command::new("git").args(["submodule", "add", &bare_sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
607        Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
608        let sm_path = parent.join("libs/sub");
609        assert_eq!(
610            String::from_utf8_lossy(&Command::new("git").args(["rev-parse", "HEAD"]).current_dir(&sm_path).output().unwrap().stdout).trim().to_string(),
611            init_hash, "submodule starts at init"
612        );
613        git_commit(&sub, "remote ahead");
614        Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
615        let remote_hash = String::from_utf8_lossy(&Command::new("git").args(["rev-parse", "HEAD"]).current_dir(&sub).output().unwrap().stdout).trim().to_string();
616        assert!(GitSubmoduleEditor::new(parent).sync_to_parent("libs/sub").is_ok(), "sync failed");
617        assert_eq!(
618            String::from_utf8_lossy(&Command::new("git").args(["rev-parse", "HEAD"]).current_dir(&sm_path).output().unwrap().stdout).trim().to_string(),
619            remote_hash, "submodule caught up to remote after sync"
620        );
621    }
622
623    #[test] fn test_editor_status_with_dirty_submodule() {
624        let t = tempfile::tempdir().unwrap(); let p = setup_repo_with_submodule(t.path());
625        std::fs::write(p.join("libs/sub/new-file"), "content").unwrap();
626        let issues = GitSubmoduleEditor::new(p).status().unwrap();
627        assert!(!issues.is_empty()); assert_eq!(issues[0].status, "Dirty");
628    }
629
630    // ---- describe_issue ----
631    #[test] fn test_describe_issue_ahead_of_parent() { let (d, a) = describe_issue(&SubmoduleStatus::AheadOfParent); assert!(d.contains("领先")); assert!(a.contains("sync")); }
632    #[test] fn test_describe_issue_behind_remote() { let (d, a) = describe_issue(&SubmoduleStatus::BehindRemote); assert!(d.contains("落后")); assert!(a.contains("sync")); }
633    #[test] fn test_describe_issue_detached() { let (d, a) = describe_issue(&SubmoduleStatus::Detached); assert!(d.contains("游离")); assert!(a.contains("checkout")); }
634    #[test] fn test_describe_issue_dirty() { let (d, a) = describe_issue(&SubmoduleStatus::Dirty); assert!(d.contains("修改")); }
635    #[test] fn test_describe_issue_orphaned() { let (d, a) = describe_issue(&SubmoduleStatus::Orphaned); assert!(d.contains("不存在")); }
636    #[test] fn test_describe_issue_uninitialized() { let (d, a) = describe_issue(&SubmoduleStatus::Uninitialized); assert!(d.contains("初始化")); }
637    #[test] #[should_panic(expected = "unreachable")] fn test_describe_issue_clean_panics() { describe_issue(&SubmoduleStatus::Clean); }
638
639    // ---- edge case scan tests ----
640    #[test] fn test_scan_with_uninitialized_submodule() {
641        let tmp = tempfile::tempdir().unwrap(); let parent = tmp.path().join("parent");
642        std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init");
643        let sub = tmp.path().join("sub"); std::fs::create_dir_all(&sub).unwrap(); git_init(&sub); git_commit(&sub, "init");
644        Command::new("git").args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
645        Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
646        Command::new("git").args(["submodule", "deinit", "-f", "libs/sub"]).current_dir(&parent).output().unwrap();
647        assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].status, SubmoduleStatus::Uninitialized);
648    }
649
650    #[test] fn test_scan_with_detached_submodule() {
651        let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
652        let sm_path = parent.join("libs/sub");
653        let hash = String::from_utf8_lossy(&Command::new("git").args(["rev-parse", "HEAD"]).current_dir(&sm_path).output().unwrap().stdout).trim().to_string();
654        Command::new("git").args(["checkout", "--detach", &hash]).current_dir(&sm_path).output().unwrap();
655        assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].status, SubmoduleStatus::Detached);
656    }
657
658    #[test] fn test_scan_with_ahead_via_remote_unreachable() {
659        let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
660        let sm_path = parent.join("libs/sub");
661        std::fs::write(sm_path.join("new-file"), "content").unwrap();
662        Command::new("git").args(["add", "."]).current_dir(&sm_path).output().unwrap();
663        Command::new("git").args(["commit", "-m", "ahead commit"]).current_dir(&sm_path).output().unwrap();
664        Command::new("git").args(["remote", "remove", "origin"]).current_dir(&sm_path).output().unwrap();
665        let state = RepoState::scan(&parent).unwrap();
666        assert_eq!(state.submodules[0].status, SubmoduleStatus::AheadOfParent);
667    }
668
669    #[test] fn test_scan_with_subrepo_open_error() {
670        let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
671        let sm_git = parent.join("libs/sub/.git");
672        if sm_git.is_dir() { std::fs::remove_dir_all(&sm_git).unwrap(); } else { std::fs::remove_file(&sm_git).unwrap(); }
673        assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].local_head, CommitHash::default());
674    }
675
676    #[test] fn test_scan_with_behind_remote() {
677        let tmp = tempfile::tempdir().unwrap(); let parent = tmp.path().join("parent"); let sub = tmp.path().join("sub"); let bare = tmp.path().join("bare");
678        std::fs::create_dir_all(&bare).unwrap(); Command::new("git").args(["init", "--bare", &bare.to_string_lossy()]).current_dir(tmp.path()).output().unwrap();
679        Command::new("git").args(["clone", &bare.to_string_lossy(), &sub.to_string_lossy()]).current_dir(tmp.path()).output().unwrap();
680        git_init(&sub); git_commit(&sub, "init"); Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
681        std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init parent");
682        Command::new("git").args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
683        Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
684        git_commit(&sub, "remote ahead"); Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
685        Command::new("git").args(["fetch", "origin"]).current_dir(&parent.join("libs/sub")).output().unwrap();
686        assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].behind_count, 1);
687    }
688
689    #[test] fn test_scan_with_orphaned_submodule() {
690        let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
691        let sm_path = parent.join("libs/sub");
692        Command::new("git").args(["remote", "remove", "origin"]).current_dir(&sm_path).output().unwrap();
693        let ref_dir = parent.join(".git/modules/libs/sub/refs/remotes/origin");
694        std::fs::create_dir_all(&ref_dir).unwrap();
695        std::fs::write(ref_dir.join("main"), "1111111111111111111111111111111111111111\n").unwrap();
696        assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].status, SubmoduleStatus::Orphaned);
697    }
698
699    #[test] fn test_scan_with_ahead_of_parent_clean() {
700        let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
701        git_commit(&parent.join("libs/sub"), "ahead commit");
702        assert!(RepoState::scan(&parent).unwrap().submodules[0].ahead_count > 0);
703    }
704
705    #[test] fn test_orphaned_parse_oid_failure() {
706        let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
707        let ref_dir = parent.join(".git/modules/libs/sub/refs/remotes/origin");
708        if !ref_dir.exists() { std::fs::create_dir_all(&ref_dir).unwrap(); }
709        std::fs::write(ref_dir.join("main"), "not-a-valid-oid\n").unwrap();
710        assert!(!RepoState::scan(&parent).unwrap().submodules.is_empty());
711    }
712
713    #[test] fn test_ahead_of_parent_via_ahead_count() {
714        let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
715        let sm_path = parent.join("libs/sub");
716        Command::new("git").args(["remote", "remove", "origin"]).current_dir(&sm_path).output().unwrap();
717        std::fs::write(sm_path.join("new-file"), "content").unwrap();
718        Command::new("git").args(["add", "."]).current_dir(&sm_path).output().unwrap();
719        Command::new("git").args(["commit", "-m", "ahead"]).current_dir(&sm_path).output().unwrap();
720        let state = RepoState::scan(&parent).unwrap();
721        assert_eq!(state.submodules[0].ahead_count, 1);
722    }
723}