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        // RepoState::scan() 内部对每个子模块执行 fetch,确保 remote_head 实时
158        let state = RepoState::scan(&self.root)?;
159        let mut issues = Vec::new();
160        for sm in &state.submodules {
161            if sm.status != SubmoduleStatus::Clean {
162                let (description, action) = describe_issue(&sm.status);
163                issues.push(HealthIssue {
164                    submodule_name: sm.name.clone(),
165                    status: sm.status.clone(),
166                    description,
167                    suggested_action: action,
168                });
169            }
170        }
171        Ok(issues)
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use std::process::Command;
179
180    fn git_init(repo_path: &std::path::Path) {
181        Command::new("git")
182            .args(["init"])
183            .current_dir(repo_path)
184            .output()
185            .unwrap();
186        Command::new("git")
187            .args(["config", "user.email", "test@test.com"])
188            .current_dir(repo_path)
189            .output()
190            .unwrap();
191        Command::new("git")
192            .args(["config", "user.name", "Test"])
193            .current_dir(repo_path)
194            .output()
195            .unwrap();
196    }
197
198    fn git_commit(repo_path: &std::path::Path, msg: &str) {
199        std::fs::write(repo_path.join("file"), msg).unwrap();
200        Command::new("git")
201            .args(["add", "."])
202            .current_dir(repo_path)
203            .output()
204            .unwrap();
205        Command::new("git")
206            .args(["commit", "-m", msg])
207            .current_dir(repo_path)
208            .output()
209            .unwrap();
210    }
211
212    fn setup_repo_with_submodule(tmp: &std::path::Path) -> std::path::PathBuf {
213        let parent = tmp.join("parent");
214        let sub = tmp.join("sub");
215        std::fs::create_dir_all(&sub).unwrap();
216        git_init(&sub);
217        git_commit(&sub, "init sub");
218        std::fs::create_dir_all(&parent).unwrap();
219        git_init(&parent);
220        git_commit(&parent, "init parent");
221        Command::new("git")
222            .args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"])
223            .current_dir(&parent)
224            .output()
225            .unwrap();
226        Command::new("git")
227            .args(["commit", "-m", "add submodule"])
228            .current_dir(&parent)
229            .output()
230            .unwrap();
231        parent
232    }
233
234    // ---- describe_issue ----
235
236    #[test]
237    fn test_describe_issue_ahead_of_parent() {
238        let (desc, action) = describe_issue(&SubmoduleStatus::AheadOfParent);
239        assert!(desc.contains("领先"));
240        assert!(action.contains("sync"));
241    }
242
243    #[test]
244    fn test_describe_issue_behind_remote() {
245        let (desc, action) = describe_issue(&SubmoduleStatus::BehindRemote);
246        assert!(desc.contains("落后"));
247        assert!(action.contains("update"));
248    }
249
250    #[test]
251    fn test_describe_issue_detached() {
252        let (desc, action) = describe_issue(&SubmoduleStatus::Detached);
253        assert!(desc.contains("游离"));
254        assert!(action.contains("checkout"));
255    }
256
257    #[test]
258    fn test_describe_issue_dirty() {
259        let (desc, action) = describe_issue(&SubmoduleStatus::Dirty);
260        assert!(desc.contains("修改"));
261        assert!(action.contains("提交") || action.contains("stash"));
262    }
263
264    #[test]
265    fn test_describe_issue_orphaned() {
266        let (desc, action) = describe_issue(&SubmoduleStatus::Orphaned);
267        assert!(desc.contains("不存在"));
268        assert!(action.contains("手动"));
269    }
270
271    #[test]
272    fn test_describe_issue_uninitialized() {
273        let (desc, action) = describe_issue(&SubmoduleStatus::Uninitialized);
274        assert!(desc.contains("初始化"));
275        assert!(action.contains("init"));
276    }
277
278    #[test]
279    #[should_panic(expected = "unreachable")]
280    fn test_describe_issue_clean_panics() {
281        describe_issue(&SubmoduleStatus::Clean);
282    }
283
284    // ---- GitSubmoduleEditor ----
285
286    #[test]
287    fn test_editor_new_and_root() {
288        let p = PathBuf::from("/tmp/test-editor");
289        let editor = GitSubmoduleEditor::new(p.clone());
290        assert_eq!(editor.root(), p);
291    }
292
293    #[test]
294    fn test_editor_sync_to_parent() {
295        let tmp = tempfile::tempdir().unwrap();
296        let parent = setup_repo_with_submodule(tmp.path());
297        let editor = GitSubmoduleEditor::new(parent);
298        let result = editor.sync_to_parent("libs/sub");
299        assert!(result.is_ok());
300    }
301
302    #[test]
303    fn test_editor_sync_to_parent_nonexistent() {
304        let tmp = tempfile::tempdir().unwrap();
305        std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
306        let editor = GitSubmoduleEditor::new(tmp.path().to_path_buf());
307        let result = editor.sync_to_parent("no-such-module");
308        assert!(result.is_err());
309    }
310
311    #[test]
312    fn test_editor_sync_all_to_parent() {
313        let tmp = tempfile::tempdir().unwrap();
314        let parent = setup_repo_with_submodule(tmp.path());
315        let editor = GitSubmoduleEditor::new(parent);
316        let result = editor.sync_all_to_parent();
317        assert!(result.is_ok());
318    }
319
320    #[test]
321    fn test_editor_sync_all_to_parent_no_submodules() {
322        let tmp = tempfile::tempdir().unwrap();
323        git_init(tmp.path());
324        git_commit(tmp.path(), "initial");
325        let editor = GitSubmoduleEditor::new(tmp.path().to_path_buf());
326        let result = editor.sync_all_to_parent();
327        assert!(result.is_ok());
328    }
329
330    #[test]
331    fn test_editor_retire_submodule() {
332        let tmp = tempfile::tempdir().unwrap();
333        let parent = setup_repo_with_submodule(tmp.path());
334        let editor = GitSubmoduleEditor::new(parent.clone());
335        let result = editor.retire_submodule("libs/sub");
336        assert!(result.is_ok());
337        // verify .gitmodules no longer has the submodule entry
338        let gitmodules = parent.join(".gitmodules");
339        assert!(!gitmodules.exists()
340            || !std::fs::read_to_string(&gitmodules)
341                .unwrap()
342                .contains("libs/sub"));
343    }
344
345    #[test]
346    fn test_editor_retire_submodule_nonexistent() {
347        let tmp = tempfile::tempdir().unwrap();
348        git_init(tmp.path());
349        git_commit(tmp.path(), "initial");
350        let editor = GitSubmoduleEditor::new(tmp.path().to_path_buf());
351        let result = editor.retire_submodule("no-such-module");
352        assert!(result.is_err());
353    }
354
355    #[test]
356    fn test_editor_status() {
357        let tmp = tempfile::tempdir().unwrap();
358        let parent = setup_repo_with_submodule(tmp.path());
359        let editor = GitSubmoduleEditor::new(parent);
360        let issues = editor.status().unwrap();
361        // initially the submodule should be clean
362        assert!(issues.is_empty());
363    }
364
365    #[test]
366    fn test_editor_status_with_gitmodules_but_no_repo() {
367        let tmp = tempfile::tempdir().unwrap();
368        std::fs::write(tmp.path().join(".gitmodules"), "").unwrap();
369        let editor = GitSubmoduleEditor::new(tmp.path().to_path_buf());
370        let result = editor.status();
371        assert!(result.is_err());
372    }
373
374    #[test]
375    fn test_editor_retire_with_multiple_submodules() {
376        let tmp = tempfile::tempdir().unwrap();
377        let parent = tmp.path().join("parent");
378        let sub1 = tmp.path().join("sub1");
379        let sub2 = tmp.path().join("sub2");
380        std::fs::create_dir_all(&sub1).unwrap();
381        git_init(&sub1);
382        git_commit(&sub1, "init");
383        std::fs::create_dir_all(&sub2).unwrap();
384        git_init(&sub2);
385        git_commit(&sub2, "init");
386        std::fs::create_dir_all(&parent).unwrap();
387        git_init(&parent);
388        git_commit(&parent, "init");
389        Command::new("git")
390            .args(["submodule", "add", &sub1.to_string_lossy(), "libs/sub1"])
391            .current_dir(&parent)
392            .output()
393            .unwrap();
394        Command::new("git")
395            .args(["submodule", "add", &sub2.to_string_lossy(), "libs/sub2"])
396            .current_dir(&parent)
397            .output()
398            .unwrap();
399        Command::new("git")
400            .args(["commit", "-m", "add submodules"])
401            .current_dir(&parent)
402            .output()
403            .unwrap();
404        let editor = GitSubmoduleEditor::new(parent.clone());
405        let result = editor.retire_submodule("libs/sub1");
406        assert!(result.is_ok());
407        let gitmodules = parent.join(".gitmodules");
408        let content = std::fs::read_to_string(&gitmodules).unwrap();
409        assert!(!content.contains("libs/sub1"));
410        assert!(content.contains("libs/sub2"));
411    }
412
413    #[test]
414    fn test_editor_sync_with_remote_push() {
415        let tmp = tempfile::tempdir().unwrap();
416        let bare = tmp.path().join("bare");
417        Command::new("git")
418            .args(["init", "--bare", &bare.to_string_lossy()])
419            .current_dir(tmp.path())
420            .output()
421            .unwrap();
422        let sub = tmp.path().join("sub");
423        Command::new("git")
424            .args(["clone", &bare.to_string_lossy(), &sub.to_string_lossy()])
425            .current_dir(tmp.path())
426            .output()
427            .unwrap();
428        git_init(&sub);
429        git_commit(&sub, "init");
430        Command::new("git")
431            .args(["push", "origin", "main"])
432            .current_dir(&sub)
433            .output()
434            .unwrap();
435        let parent = tmp.path().join("parent");
436        std::fs::create_dir_all(&parent).unwrap();
437        git_init(&parent);
438        git_commit(&parent, "init parent");
439        // Add a remote to parent so push can succeed
440        Command::new("git")
441            .args(["remote", "add", "origin", &bare.to_string_lossy()])
442            .current_dir(&parent)
443            .output()
444            .unwrap();
445        Command::new("git")
446            .args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"])
447            .current_dir(&parent)
448            .output()
449            .unwrap();
450        Command::new("git")
451            .args(["commit", "-m", "add submodule"])
452            .current_dir(&parent)
453            .output()
454            .unwrap();
455        // Commit in submodule to make it ahead
456        git_commit(&sub, "ahead");
457        Command::new("git")
458            .args(["push", "origin", "main"])
459            .current_dir(&sub)
460            .output()
461            .unwrap();
462        Command::new("git")
463            .args(["fetch", "origin"])
464            .current_dir(&parent.join("libs/sub"))
465            .output()
466            .unwrap();
467        let editor = GitSubmoduleEditor::new(parent);
468        // This should succeed: submodule push → parent commit → parent push
469        let result = editor.sync_to_parent("libs/sub");
470        assert!(result.is_ok());
471    }
472
473    #[test]
474    fn test_editor_status_with_dirty_submodule() {
475        let tmp = tempfile::tempdir().unwrap();
476        let parent = setup_repo_with_submodule(tmp.path());
477        let sm_path = parent.join("libs/sub");
478        std::fs::write(sm_path.join("new-file"), "content").unwrap();
479        let editor = GitSubmoduleEditor::new(parent);
480        let issues = editor.status().unwrap();
481        assert!(!issues.is_empty());
482        assert_eq!(issues[0].status, SubmoduleStatus::Dirty);
483    }
484}
485
486pub(crate) fn describe_issue(status: &SubmoduleStatus) -> (String, String) {
487    match status {
488        SubmoduleStatus::AheadOfParent => (
489            "本地领先于父仓库记录".into(),
490            "运行 sync_to_parent 更新父仓库指针".into(),
491        ),
492        SubmoduleStatus::BehindRemote => (
493            "远程有更新,本地落后".into(),
494            "运行 update 获取最新代码".into(),
495        ),
496        SubmoduleStatus::Detached => (
497            "处于游离 HEAD 状态".into(),
498            "运行 checkout_branch 切换到跟踪分支".into(),
499        ),
500        SubmoduleStatus::Dirty => ("有未提交的修改".into(), "提交或 stash 当前修改".into()),
501        SubmoduleStatus::Orphaned => (
502            "父仓库记录的 commit 在远程已不存在".into(),
503            "需手动干预".into(),
504        ),
505        SubmoduleStatus::Uninitialized => ("尚未初始化".into(), "运行 init 初始化子模块".into()),
506        SubmoduleStatus::Clean => unreachable!(),
507    }
508}