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