Skip to main content

qtcloud_devops_cli/commands/
code.rs

1use std::path::{Path, PathBuf};
2
3use crate::commands::{HealthIssue, SubmoduleEditor};
4use crate::model::code::{RepoState, SubmoduleStatus};
5
6pub struct GitSubmoduleEditor {
7    root: PathBuf,
8    offline: bool,
9}
10
11impl GitSubmoduleEditor {
12    pub fn new(root: PathBuf) -> Self {
13        Self { root, offline: false }
14    }
15
16    pub fn set_offline(&mut self, offline: bool) {
17        self.offline = offline;
18    }
19}
20
21impl SubmoduleEditor for GitSubmoduleEditor {
22    fn root(&self) -> &Path {
23        &self.root
24    }
25
26    fn sync_to_parent(&self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
27        let repo = git2::Repository::open(&self.root)?;
28        let sm = repo.find_submodule(name)?;
29        let sm_path = sm.path();
30        let full_sm_path = self.root.join(sm_path);
31
32        let mut parts: Vec<&str> = Vec::new();
33
34        // 1. 推送子模块自身的 commit 到子模块的 remote
35        if full_sm_path.exists() {
36            let output = std::process::Command::new("git")
37                .args(["push", "origin"])
38                .current_dir(&full_sm_path)
39                .output()
40                .map_err(|e| format!("无法在子模块内执行 git push: {}", e))?;
41            if !output.status.success() {
42                let stderr = String::from_utf8_lossy(&output.stderr);
43                parts.push("✗ push");
44                eprintln!("  {}  push 失败: {}", name, stderr.trim());
45            } else {
46                parts.push("✓ push");
47            }
48        }
49
50        // 2. 更新父仓库的子模块指针
51        let mut index = repo.index()?;
52        index.add_path(sm_path)?;
53        index.write()?;
54
55        let tree_id = index.write_tree()?;
56        let tree = repo.find_tree(tree_id)?;
57        let head = repo.head()?;
58        let parent = head.peel_to_commit()?;
59        let signature = git2::Signature::now("kse", "kse@local")?;
60        repo.commit(
61            Some("HEAD"),
62            &signature,
63            &signature,
64            &format!("chore: 更新子模块 '{}' 指针", name),
65            &tree,
66            &[&parent],
67        )?;
68        parts.push("sync");
69
70        // 3. 推送父仓库到 origin
71        let branch = repo
72            .head()
73            .ok()
74            .and_then(|r| r.shorthand().map(|s| s.to_string()))
75            .unwrap_or_default();
76        if !branch.is_empty() {
77            let output = std::process::Command::new("git")
78                .args(["push", "origin", &branch])
79                .current_dir(&self.root)
80                .output()
81                .map_err(|e| format!("无法执行 git push: {}", e))?;
82            if !output.status.success() {
83                let stderr = String::from_utf8_lossy(&output.stderr);
84                parts.push("✗ push-parent");
85                eprintln!("  {}  push-parent 失败: {}", name, stderr.trim());
86            } else {
87                parts.push("✓ push-parent");
88            }
89        }
90
91        println!("  {:<35} {}", name, parts.join(" · "));
92        Ok(())
93    }
94
95    fn sync_all_to_parent(&self) -> Result<(), Box<dyn std::error::Error>> {
96        let repo = git2::Repository::open(&self.root)?;
97        let submodules = repo.submodules()?;
98        println!("同步 {} 个子模块", submodules.len());
99        for sm in submodules.iter() {
100            let name = sm.name().unwrap_or("unknown").to_string();
101            match self.sync_to_parent(&name) {
102                Ok(()) => {}
103                Err(e) => println!("  {:<35} ✗ 失败: {}", name, e),
104            }
105        }
106        Ok(())
107    }
108
109    fn retire_submodule(&self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
110        let repo = git2::Repository::open(&self.root)?;
111        let sm = repo.find_submodule(name)?;
112        let sm_path = sm.path().to_path_buf();
113
114        let result = std::process::Command::new("git")
115            .args(["submodule", "deinit", "-f", name])
116            .current_dir(&self.root)
117            .output();
118        match result {
119            Err(e) => eprintln!("警告: git submodule deinit 无法执行: {} (继续处理)", e),
120            Ok(out) if !out.status.success() => {
121                let stderr = String::from_utf8_lossy(&out.stderr);
122                eprintln!("警告: git submodule deinit 失败: {} (继续处理)", stderr.trim());
123            }
124            _ => {}
125        }
126
127        let gitmodules_path = self.root.join(".gitmodules");
128        if gitmodules_path.exists() {
129            let content = std::fs::read_to_string(&gitmodules_path)?;
130            let mut new_content = String::new();
131            let mut skip = false;
132            let in_submodule_alt = format!("[submodule \"{}\"]", name);
133            for line in content.lines() {
134                if line.trim() == in_submodule_alt {
135                    skip = true;
136                    continue;
137                }
138                if skip && line.trim_start().starts_with('[') {
139                    skip = false;
140                }
141                if !skip {
142                    new_content.push_str(line);
143                    new_content.push('\n');
144                }
145            }
146            std::fs::write(&gitmodules_path, new_content)?;
147        }
148        let mut index = repo.index()?;
149        index.remove_path(&sm_path)?;
150        index.write()?;
151
152        println!("已退役子模块 '{}'", name);
153        Ok(())
154    }
155
156    fn status(&self) -> Result<Vec<HealthIssue>, Box<dyn std::error::Error>> {
157        // 先 fetch 远程更新(除非 --offline),确保 remote_head 是实时状态
158        if !self.offline {
159            if let Ok(repo) = git2::Repository::open(&self.root) {
160                if let Ok(mut remote) = repo.find_remote("origin") {
161                    let mut fetch_opts = git2::FetchOptions::new();
162                    fetch_opts.download_tags(git2::AutotagOption::None);
163                    let mut callbacks = git2::RemoteCallbacks::new();
164                    callbacks.transfer_progress(|_| true);
165                    fetch_opts.remote_callbacks(callbacks);
166                    let _ = remote.fetch(&["+refs/heads/*:refs/remotes/origin/*"], Some(&mut fetch_opts), None);
167                }
168            }
169        }
170        let state = RepoState::scan(&self.root)?;
171        let mut issues = Vec::new();
172        for sm in &state.submodules {
173            if sm.status != SubmoduleStatus::Clean {
174                let (description, action) = describe_issue(&sm.status);
175                issues.push(HealthIssue {
176                    submodule_name: sm.name.clone(),
177                    status: sm.status.clone(),
178                    description,
179                    suggested_action: action,
180                });
181            }
182        }
183        Ok(issues)
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use std::process::Command;
191
192    fn git_init(repo_path: &std::path::Path) {
193        Command::new("git")
194            .args(["init"])
195            .current_dir(repo_path)
196            .output()
197            .unwrap();
198        Command::new("git")
199            .args(["config", "user.email", "test@test.com"])
200            .current_dir(repo_path)
201            .output()
202            .unwrap();
203        Command::new("git")
204            .args(["config", "user.name", "Test"])
205            .current_dir(repo_path)
206            .output()
207            .unwrap();
208    }
209
210    fn git_commit(repo_path: &std::path::Path, msg: &str) {
211        std::fs::write(repo_path.join("file"), msg).unwrap();
212        Command::new("git")
213            .args(["add", "."])
214            .current_dir(repo_path)
215            .output()
216            .unwrap();
217        Command::new("git")
218            .args(["commit", "-m", msg])
219            .current_dir(repo_path)
220            .output()
221            .unwrap();
222    }
223
224    fn setup_repo_with_submodule(tmp: &std::path::Path) -> std::path::PathBuf {
225        let parent = tmp.join("parent");
226        let sub = tmp.join("sub");
227        std::fs::create_dir_all(&sub).unwrap();
228        git_init(&sub);
229        git_commit(&sub, "init sub");
230        std::fs::create_dir_all(&parent).unwrap();
231        git_init(&parent);
232        git_commit(&parent, "init parent");
233        Command::new("git")
234            .args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"])
235            .current_dir(&parent)
236            .output()
237            .unwrap();
238        Command::new("git")
239            .args(["commit", "-m", "add submodule"])
240            .current_dir(&parent)
241            .output()
242            .unwrap();
243        parent
244    }
245
246    // ---- describe_issue ----
247
248    #[test]
249    fn test_describe_issue_ahead_of_parent() {
250        let (desc, action) = describe_issue(&SubmoduleStatus::AheadOfParent);
251        assert!(desc.contains("领先"));
252        assert!(action.contains("sync"));
253    }
254
255    #[test]
256    fn test_describe_issue_behind_remote() {
257        let (desc, action) = describe_issue(&SubmoduleStatus::BehindRemote);
258        assert!(desc.contains("落后"));
259        assert!(action.contains("update"));
260    }
261
262    #[test]
263    fn test_describe_issue_detached() {
264        let (desc, action) = describe_issue(&SubmoduleStatus::Detached);
265        assert!(desc.contains("游离"));
266        assert!(action.contains("checkout"));
267    }
268
269    #[test]
270    fn test_describe_issue_dirty() {
271        let (desc, action) = describe_issue(&SubmoduleStatus::Dirty);
272        assert!(desc.contains("修改"));
273        assert!(action.contains("提交") || action.contains("stash"));
274    }
275
276    #[test]
277    fn test_describe_issue_orphaned() {
278        let (desc, action) = describe_issue(&SubmoduleStatus::Orphaned);
279        assert!(desc.contains("不存在"));
280        assert!(action.contains("手动"));
281    }
282
283    #[test]
284    fn test_describe_issue_uninitialized() {
285        let (desc, action) = describe_issue(&SubmoduleStatus::Uninitialized);
286        assert!(desc.contains("初始化"));
287        assert!(action.contains("init"));
288    }
289
290    #[test]
291    #[should_panic(expected = "unreachable")]
292    fn test_describe_issue_clean_panics() {
293        describe_issue(&SubmoduleStatus::Clean);
294    }
295
296    // ---- GitSubmoduleEditor ----
297
298    #[test]
299    fn test_editor_new_and_root() {
300        let p = PathBuf::from("/tmp/test-editor");
301        let editor = GitSubmoduleEditor::new(p.clone());
302        assert_eq!(editor.root(), p);
303    }
304
305    #[test]
306    fn test_editor_sync_to_parent() {
307        let tmp = tempfile::tempdir().unwrap();
308        let parent = setup_repo_with_submodule(tmp.path());
309        let editor = GitSubmoduleEditor::new(parent);
310        let result = editor.sync_to_parent("libs/sub");
311        assert!(result.is_ok());
312    }
313
314    #[test]
315    fn test_editor_sync_to_parent_nonexistent() {
316        let tmp = tempfile::tempdir().unwrap();
317        std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
318        let editor = GitSubmoduleEditor::new(tmp.path().to_path_buf());
319        let result = editor.sync_to_parent("no-such-module");
320        assert!(result.is_err());
321    }
322
323    #[test]
324    fn test_editor_sync_all_to_parent() {
325        let tmp = tempfile::tempdir().unwrap();
326        let parent = setup_repo_with_submodule(tmp.path());
327        let editor = GitSubmoduleEditor::new(parent);
328        let result = editor.sync_all_to_parent();
329        assert!(result.is_ok());
330    }
331
332    #[test]
333    fn test_editor_sync_all_to_parent_no_submodules() {
334        let tmp = tempfile::tempdir().unwrap();
335        git_init(tmp.path());
336        git_commit(tmp.path(), "initial");
337        let editor = GitSubmoduleEditor::new(tmp.path().to_path_buf());
338        let result = editor.sync_all_to_parent();
339        assert!(result.is_ok());
340    }
341
342    #[test]
343    fn test_editor_retire_submodule() {
344        let tmp = tempfile::tempdir().unwrap();
345        let parent = setup_repo_with_submodule(tmp.path());
346        let editor = GitSubmoduleEditor::new(parent.clone());
347        let result = editor.retire_submodule("libs/sub");
348        assert!(result.is_ok());
349        // verify .gitmodules no longer has the submodule entry
350        let gitmodules = parent.join(".gitmodules");
351        assert!(!gitmodules.exists()
352            || !std::fs::read_to_string(&gitmodules)
353                .unwrap()
354                .contains("libs/sub"));
355    }
356
357    #[test]
358    fn test_editor_retire_submodule_nonexistent() {
359        let tmp = tempfile::tempdir().unwrap();
360        git_init(tmp.path());
361        git_commit(tmp.path(), "initial");
362        let editor = GitSubmoduleEditor::new(tmp.path().to_path_buf());
363        let result = editor.retire_submodule("no-such-module");
364        assert!(result.is_err());
365    }
366
367    #[test]
368    fn test_editor_status() {
369        let tmp = tempfile::tempdir().unwrap();
370        let parent = setup_repo_with_submodule(tmp.path());
371        let editor = GitSubmoduleEditor::new(parent);
372        let issues = editor.status().unwrap();
373        // initially the submodule should be clean
374        assert!(issues.is_empty());
375    }
376
377    #[test]
378    fn test_editor_status_with_gitmodules_but_no_repo() {
379        let tmp = tempfile::tempdir().unwrap();
380        std::fs::write(tmp.path().join(".gitmodules"), "").unwrap();
381        let editor = GitSubmoduleEditor::new(tmp.path().to_path_buf());
382        let result = editor.status();
383        assert!(result.is_err());
384    }
385
386    #[test]
387    fn test_editor_retire_with_multiple_submodules() {
388        let tmp = tempfile::tempdir().unwrap();
389        let parent = tmp.path().join("parent");
390        let sub1 = tmp.path().join("sub1");
391        let sub2 = tmp.path().join("sub2");
392        std::fs::create_dir_all(&sub1).unwrap();
393        git_init(&sub1);
394        git_commit(&sub1, "init");
395        std::fs::create_dir_all(&sub2).unwrap();
396        git_init(&sub2);
397        git_commit(&sub2, "init");
398        std::fs::create_dir_all(&parent).unwrap();
399        git_init(&parent);
400        git_commit(&parent, "init");
401        Command::new("git")
402            .args(["submodule", "add", &sub1.to_string_lossy(), "libs/sub1"])
403            .current_dir(&parent)
404            .output()
405            .unwrap();
406        Command::new("git")
407            .args(["submodule", "add", &sub2.to_string_lossy(), "libs/sub2"])
408            .current_dir(&parent)
409            .output()
410            .unwrap();
411        Command::new("git")
412            .args(["commit", "-m", "add submodules"])
413            .current_dir(&parent)
414            .output()
415            .unwrap();
416        let editor = GitSubmoduleEditor::new(parent.clone());
417        let result = editor.retire_submodule("libs/sub1");
418        assert!(result.is_ok());
419        let gitmodules = parent.join(".gitmodules");
420        let content = std::fs::read_to_string(&gitmodules).unwrap();
421        assert!(!content.contains("libs/sub1"));
422        assert!(content.contains("libs/sub2"));
423    }
424
425    #[test]
426    fn test_editor_sync_with_remote_push() {
427        let tmp = tempfile::tempdir().unwrap();
428        let bare = tmp.path().join("bare");
429        Command::new("git")
430            .args(["init", "--bare", &bare.to_string_lossy()])
431            .current_dir(tmp.path())
432            .output()
433            .unwrap();
434        let sub = tmp.path().join("sub");
435        Command::new("git")
436            .args(["clone", &bare.to_string_lossy(), &sub.to_string_lossy()])
437            .current_dir(tmp.path())
438            .output()
439            .unwrap();
440        git_init(&sub);
441        git_commit(&sub, "init");
442        Command::new("git")
443            .args(["push", "origin", "main"])
444            .current_dir(&sub)
445            .output()
446            .unwrap();
447        let parent = tmp.path().join("parent");
448        std::fs::create_dir_all(&parent).unwrap();
449        git_init(&parent);
450        git_commit(&parent, "init parent");
451        // Add a remote to parent so push can succeed
452        Command::new("git")
453            .args(["remote", "add", "origin", &bare.to_string_lossy()])
454            .current_dir(&parent)
455            .output()
456            .unwrap();
457        Command::new("git")
458            .args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"])
459            .current_dir(&parent)
460            .output()
461            .unwrap();
462        Command::new("git")
463            .args(["commit", "-m", "add submodule"])
464            .current_dir(&parent)
465            .output()
466            .unwrap();
467        // Commit in submodule to make it ahead
468        git_commit(&sub, "ahead");
469        Command::new("git")
470            .args(["push", "origin", "main"])
471            .current_dir(&sub)
472            .output()
473            .unwrap();
474        Command::new("git")
475            .args(["fetch", "origin"])
476            .current_dir(&parent.join("libs/sub"))
477            .output()
478            .unwrap();
479        let editor = GitSubmoduleEditor::new(parent);
480        // This should succeed: submodule push → parent commit → parent push
481        let result = editor.sync_to_parent("libs/sub");
482        assert!(result.is_ok());
483    }
484
485    #[test]
486    fn test_editor_status_with_dirty_submodule() {
487        let tmp = tempfile::tempdir().unwrap();
488        let parent = setup_repo_with_submodule(tmp.path());
489        let sm_path = parent.join("libs/sub");
490        std::fs::write(sm_path.join("new-file"), "content").unwrap();
491        let editor = GitSubmoduleEditor::new(parent);
492        let issues = editor.status().unwrap();
493        assert!(!issues.is_empty());
494        assert_eq!(issues[0].status, SubmoduleStatus::Dirty);
495    }
496}
497
498pub(crate) fn describe_issue(status: &SubmoduleStatus) -> (String, String) {
499    match status {
500        SubmoduleStatus::AheadOfParent => (
501            "本地领先于父仓库记录".into(),
502            "运行 sync_to_parent 更新父仓库指针".into(),
503        ),
504        SubmoduleStatus::BehindRemote => (
505            "远程有更新,本地落后".into(),
506            "运行 update 获取最新代码".into(),
507        ),
508        SubmoduleStatus::Detached => (
509            "处于游离 HEAD 状态".into(),
510            "运行 checkout_branch 切换到跟踪分支".into(),
511        ),
512        SubmoduleStatus::Dirty => ("有未提交的修改".into(), "提交或 stash 当前修改".into()),
513        SubmoduleStatus::Orphaned => (
514            "父仓库记录的 commit 在远程已不存在".into(),
515            "需手动干预".into(),
516        ),
517        SubmoduleStatus::Uninitialized => ("尚未初始化".into(), "运行 init 初始化子模块".into()),
518        SubmoduleStatus::Clean => unreachable!(),
519    }
520}