Skip to main content

qtcloud_devops_cli/git/
editor.rs

1use crate::git::scan::*;
2use crate::git::types::*;
3use std::path::{Path, PathBuf};
4
5// ===== git operations =====
6
7pub struct GitSubmoduleEditor {
8    root: PathBuf,
9    offline: bool,
10}
11
12impl GitSubmoduleEditor {
13    pub fn new(root: PathBuf) -> Self {
14        Self {
15            root,
16            offline: false,
17        }
18    }
19
20    pub fn set_offline(&mut self, offline: bool) {
21        self.offline = offline;
22    }
23
24    /// 检测是否有远端。优先用 gix,回退到 CLI。
25    fn has_remote(path: &Path) -> bool {
26        if let Ok(repo) = git2::Repository::open(path) {
27            repo.find_remote("origin").is_ok()
28        } else {
29            false
30        }
31    }
32
33    /// 获取当前 branch 名。
34    fn branch_name(path: &Path) -> Option<String> {
35        std::process::Command::new("git")
36            .args(["rev-parse", "--abbrev-ref", "HEAD"])
37            .current_dir(path)
38            .output()
39            .ok()
40            .and_then(|o| {
41                if o.status.success() {
42                    let b = String::from_utf8_lossy(&o.stdout).trim().to_string();
43                    if b != "HEAD" && !b.is_empty() {
44                        Some(b)
45                    } else {
46                        None
47                    }
48                } else {
49                    None
50                }
51            })
52    }
53
54    pub fn fetch_submodule(path: &Path) -> Result<(), ()> {
55        let repo = match git2::Repository::open(path) {
56            Ok(r) => r,
57            Err(_) => return Ok(()),
58        };
59        let mut remote = match repo.find_remote("origin") {
60            Ok(r) => r,
61            Err(_) => return Ok(()),
62        };
63        remote
64            .fetch(&[] as &[&str], None, None)
65            .map_err(|_| ())
66            .ok();
67        Ok(())
68    }
69
70    pub fn rebase_submodule(path: &Path) -> Result<(), String> {
71        if !path.exists() {
72            return Ok(());
73        }
74        let branch = Self::branch_name(path).unwrap_or_default();
75        if branch.is_empty() || branch == "HEAD" {
76            return Ok(());
77        }
78        if !Self::has_remote(path) {
79            return Ok(());
80        }
81        let output = std::process::Command::new("git")
82            .args(["rebase", &format!("origin/{}", branch)])
83            .current_dir(path)
84            .output()
85            .map_err(|e| format!("git rebase 无法执行: {}", e))?;
86        if !output.status.success() {
87            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
88            if stderr.contains("up to date") || stderr.contains("up-to-date") {
89                return Ok(());
90            }
91            return Err(format!(
92                "rebase 冲突,需手动处理:解决冲突后 git rebase --continue,或 git rebase --abort 放弃\n{}",
93                stderr
94            ));
95        }
96        Ok(())
97    }
98
99    pub fn push_submodule(path: &Path) -> Result<(), String> {
100        if !path.exists() {
101            return Ok(());
102        }
103        let branch = Self::branch_name(path).unwrap_or_default();
104        if branch.is_empty() || branch == "HEAD" {
105            return Ok(());
106        }
107        if !Self::has_remote(path) {
108            return Ok(());
109        }
110        let tracking = format!("origin/{}", branch);
111        let ahead = std::process::Command::new("git")
112            .args(["rev-list", "--count", &format!("{}..{}", tracking, branch)])
113            .current_dir(path)
114            .output()
115            .ok()
116            .and_then(|o| {
117                if o.status.success() {
118                    String::from_utf8_lossy(&o.stdout)
119                        .trim()
120                        .parse::<i32>()
121                        .ok()
122                } else {
123                    None
124                }
125            })
126            .unwrap_or(0);
127        if ahead <= 0 {
128            return Ok(());
129        }
130        std::process::Command::new("git")
131            .args(["push", "origin", &branch])
132            .current_dir(path)
133            .output()
134            .map(|o| {
135                if o.status.success() {
136                    Ok(())
137                } else {
138                    Err(String::from_utf8_lossy(&o.stderr).trim().to_string())
139                }
140            })
141            .unwrap_or_else(|e| Err(format!("git push 无法执行: {}", e)))
142    }
143
144    /// 用 git2 更新父仓库的子模块指针并提交。
145    pub fn update_parent_pointer(
146        root: &Path,
147        sm_path: &Path,
148        name: &str,
149    ) -> Result<(), Box<dyn std::error::Error>> {
150        let repo = git2::Repository::open(root)?;
151        // index.add_path + write_tree + commit
152        let mut index = repo.index()?;
153        index.add_path(sm_path)?;
154        index.write()?;
155        let tree_id = index.write_tree()?;
156        let tree = repo.find_tree(tree_id)?;
157        let parent = repo.head()?.peel_to_commit()?;
158        let sig = repo.signature()?;
159        match repo.commit(
160            Some("HEAD"),
161            &sig,
162            &sig,
163            &format!("chore: 更新子模块 '{}' 指针", name),
164            &tree,
165            &[&parent],
166        ) {
167            Ok(_) => Ok(()),
168            Err(e) => {
169                let msg = e.message();
170                if msg.contains("nothing to commit") || msg.contains("no changes") {
171                    Ok(())
172                } else {
173                    Err(Box::new(e))
174                }
175            }
176        }
177    }
178
179    pub fn push_parent(root: &Path) -> Result<(), String> {
180        if !Self::has_remote(root) {
181            return Ok(());
182        }
183        let branch = Self::branch_name(root).unwrap_or_default();
184        if branch.is_empty() || branch == "HEAD" {
185            return Err("无法检测当前分支".into());
186        }
187        std::process::Command::new("git")
188            .args(["push", "origin", &branch])
189            .current_dir(root)
190            .output()
191            .map(|o| {
192                if o.status.success() {
193                    Ok(())
194                } else {
195                    Err(String::from_utf8_lossy(&o.stderr).trim().to_string())
196                }
197            })
198            .unwrap_or_else(|e| Err(format!("git push 无法执行: {}", e)))
199    }
200
201    /// 用 git2 回滚最近一次提交(`git reset --hard HEAD~1`)。
202    pub fn revert_parent_commit(root: &Path) {
203        if let Ok(repo) = git2::Repository::open(root) {
204            if let Ok(head) = repo.find_reference("HEAD") {
205                if let Some(target) = head.target() {
206                    if let Ok(commit) = repo.find_commit(target) {
207                        if let Ok(parent) = commit.parent(0) {
208                            repo.reset(parent.as_object(), git2::ResetType::Hard, None)
209                                .ok();
210                        }
211                    }
212                }
213            }
214        }
215    }
216
217    pub fn root(&self) -> &Path {
218        &self.root
219    }
220
221    fn find_sm_path(&self, name: &str) -> Option<PathBuf> {
222        let entries = parse_gitmodules(&self.root);
223        entries
224            .into_iter()
225            .find(|(n, _, _, _)| n == name)
226            .map(|(_, p, _, _)| p)
227    }
228
229    pub fn sync_to_parent(&self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
230        let sm_path = self
231            .find_sm_path(name)
232            .ok_or_else(|| format!(".gitmodules 中未找到子模块 '{}'", name))?;
233        let full_sm_path = self.root.join(&sm_path);
234
235        if full_sm_path.exists() {
236            Self::fetch_submodule(&full_sm_path).ok();
237            Self::rebase_submodule(&full_sm_path)?;
238        }
239        Self::push_submodule(&full_sm_path).map_err(|e| format!("子模块 push 失败: {}", e))?;
240        Self::update_parent_pointer(&self.root, &sm_path, name)?;
241        if let Err(e) = Self::push_parent(&self.root) {
242            Self::revert_parent_commit(&self.root);
243            return Err(format!("父仓库 push 失败 (已回滚提交): {}", e).into());
244        }
245        println!("  ✓ {}", name);
246        Ok(())
247    }
248
249    pub fn sync_all_to_parent(&self) -> Result<(), Box<dyn std::error::Error>> {
250        let entries = parse_gitmodules(&self.root);
251        let names: Vec<String> = entries.into_iter().map(|(n, _, _, _)| n).collect();
252        println!("同步 {} 个子模块", names.len());
253        for name in &names {
254            match self.sync_to_parent(name) {
255                Ok(()) => {}
256                Err(e) => println!("  {:<35} ✗ 失败: {}", name, e),
257            }
258        }
259        Ok(())
260    }
261
262    pub fn status(&self) -> Result<Vec<HealthIssue>, Box<dyn std::error::Error>> {
263        let state = RepoState::scan(&self.root)?;
264        let mut issues = Vec::new();
265        for sm in &state.submodules {
266            if sm.status != SubmoduleStatus::Clean {
267                let (description, action) = describe_issue(&sm.status);
268                issues.push(HealthIssue {
269                    submodule_name: sm.name.clone(),
270                    status: format!("{:?}", sm.status),
271                    description,
272                    suggested_action: action,
273                });
274            }
275        }
276        Ok(issues)
277    }
278}
279#[cfg(test)]
280mod tests {
281    fn git_init(path: &std::path::Path) {
282        let repo = git2::Repository::init(path).unwrap();
283        let mut cfg = repo.config().unwrap();
284        cfg.set_str("user.email", "t@t").unwrap();
285        cfg.set_str("user.name", "t").unwrap();
286    }
287
288    fn git_commit(path: &std::path::Path, msg: &str) {
289        std::fs::write(path.join("f"), msg).unwrap();
290        let repo = git2::Repository::open(path).unwrap();
291        let mut index = repo.index().unwrap();
292        index.add_path(std::path::Path::new("f")).unwrap();
293        index.write().unwrap();
294        let tree_id = index.write_tree().unwrap();
295        let tree = repo.find_tree(tree_id).unwrap();
296        let sig = repo.signature().unwrap();
297        let parent = repo.head().and_then(|h| h.peel_to_commit()).ok();
298        let parents: Vec<&git2::Commit> = parent.iter().collect();
299        repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parents)
300            .unwrap();
301    }
302
303    use super::*;
304    use std::process::Command;
305
306    fn setup_repo_with_submodule(tmp: &Path) -> PathBuf {
307        let parent = tmp.join("parent");
308        let sub = tmp.join("sub");
309        std::fs::create_dir_all(&sub).unwrap();
310        git_init(&sub);
311        git_commit(&sub, "init sub");
312        std::fs::create_dir_all(&parent).unwrap();
313        git_init(&parent);
314        git_commit(&parent, "init parent");
315        Command::new("git")
316            .args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"])
317            .current_dir(&parent)
318            .output()
319            .unwrap();
320        Command::new("git")
321            .args(["commit", "-m", "add submodule"])
322            .current_dir(&parent)
323            .output()
324            .unwrap();
325        parent
326    }
327
328    // ---- GitSubmoduleEditor ----
329    #[test]
330    fn test_editor_new_and_root() {
331        let e = GitSubmoduleEditor::new(PathBuf::from("/tmp"));
332        assert_eq!(e.root(), std::path::Path::new("/tmp"));
333    }
334    #[test]
335    fn test_editor_sync_to_parent() {
336        let t = tempfile::tempdir().unwrap();
337        let p = setup_repo_with_submodule(t.path());
338        assert!(
339            GitSubmoduleEditor::new(p)
340                .sync_to_parent("libs/sub")
341                .is_ok(),
342            "sync_to_parent failed"
343        );
344    }
345    #[test]
346    fn test_editor_sync_to_parent_nonexistent() {
347        let t = tempfile::tempdir().unwrap();
348        std::fs::create_dir_all(t.path().join(".git")).unwrap();
349        assert!(GitSubmoduleEditor::new(t.path().to_path_buf())
350            .sync_to_parent("no-such-module")
351            .is_err());
352    }
353    #[test]
354    fn test_editor_sync_all_to_parent() {
355        let t = tempfile::tempdir().unwrap();
356        let p = setup_repo_with_submodule(t.path());
357        assert!(GitSubmoduleEditor::new(p).sync_all_to_parent().is_ok());
358    }
359    #[test]
360    fn test_editor_sync_all_to_parent_no_submodules() {
361        let t = tempfile::tempdir().unwrap();
362        git_init(t.path());
363        git_commit(t.path(), "initial");
364        assert!(GitSubmoduleEditor::new(t.path().to_path_buf())
365            .sync_all_to_parent()
366            .is_ok());
367    }
368    #[test]
369    fn test_editor_status() {
370        let t = tempfile::tempdir().unwrap();
371        let p = setup_repo_with_submodule(t.path());
372        assert!(GitSubmoduleEditor::new(p).status().unwrap().is_empty());
373    }
374    #[test]
375    fn test_editor_status_with_gitmodules_but_no_repo() {
376        let t = tempfile::tempdir().unwrap();
377        std::fs::write(t.path().join(".gitmodules"), "").unwrap();
378        assert!(GitSubmoduleEditor::new(t.path().to_path_buf())
379            .status()
380            .is_err());
381    }
382
383    #[test]
384    fn test_editor_sync_with_remote_push() {
385        let tmp = tempfile::tempdir().unwrap();
386        let bare_sub = tmp.path().join("bare-sub");
387        let bare_parent = tmp.path().join("bare-parent");
388        for b in [&bare_sub, &bare_parent] {
389            Command::new("git")
390                .args(["init", "--bare", &b.to_string_lossy()])
391                .output()
392                .unwrap();
393        }
394        let sub = tmp.path().join("sub");
395        Command::new("git")
396            .args(["clone", &bare_sub.to_string_lossy(), &sub.to_string_lossy()])
397            .current_dir(tmp.path())
398            .output()
399            .unwrap();
400        git_init(&sub);
401        git_commit(&sub, "init");
402        Command::new("git")
403            .args(["push", "origin", "main"])
404            .current_dir(&sub)
405            .output()
406            .unwrap();
407        let parent = tmp.path().join("parent");
408        std::fs::create_dir_all(&parent).unwrap();
409        git_init(&parent);
410        git_commit(&parent, "init parent");
411        Command::new("git")
412            .args(["remote", "add", "origin", &bare_parent.to_string_lossy()])
413            .current_dir(&parent)
414            .output()
415            .unwrap();
416        Command::new("git")
417            .args(["submodule", "add", &bare_sub.to_string_lossy(), "libs/sub"])
418            .current_dir(&parent)
419            .output()
420            .unwrap();
421        Command::new("git")
422            .args(["commit", "-m", "add submodule"])
423            .current_dir(&parent)
424            .output()
425            .unwrap();
426        git_commit(&sub, "ahead");
427        Command::new("git")
428            .args(["push", "origin", "main"])
429            .current_dir(&sub)
430            .output()
431            .unwrap();
432        Command::new("git")
433            .args(["fetch", "origin"])
434            .current_dir(&parent.join("libs/sub"))
435            .output()
436            .unwrap();
437        let result = GitSubmoduleEditor::new(parent).sync_to_parent("libs/sub");
438        assert!(result.is_ok(), "sync failed: {:?}", result.err());
439    }
440
441    #[test]
442    fn test_editor_sync_rebase_catches_up() {
443        let tmp = tempfile::tempdir().unwrap();
444        let bare_sub = tmp.path().join("bare-sub");
445        let bare_parent = tmp.path().join("bare-parent");
446        for b in [&bare_sub, &bare_parent] {
447            Command::new("git")
448                .args(["init", "--bare", &b.to_string_lossy()])
449                .output()
450                .unwrap();
451        }
452        let sub = tmp.path().join("sub");
453        Command::new("git")
454            .args(["clone", &bare_sub.to_string_lossy(), &sub.to_string_lossy()])
455            .current_dir(tmp.path())
456            .output()
457            .unwrap();
458        git_init(&sub);
459        git_commit(&sub, "init");
460        Command::new("git")
461            .args(["push", "origin", "main"])
462            .current_dir(&sub)
463            .output()
464            .unwrap();
465        let init_hash = String::from_utf8_lossy(
466            &Command::new("git")
467                .args(["rev-parse", "HEAD"])
468                .current_dir(&sub)
469                .output()
470                .unwrap()
471                .stdout,
472        )
473        .trim()
474        .to_string();
475        let parent = tmp.path().join("parent");
476        std::fs::create_dir_all(&parent).unwrap();
477        git_init(&parent);
478        git_commit(&parent, "init parent");
479        Command::new("git")
480            .args(["remote", "add", "origin", &bare_parent.to_string_lossy()])
481            .current_dir(&parent)
482            .output()
483            .unwrap();
484        Command::new("git")
485            .args(["submodule", "add", &bare_sub.to_string_lossy(), "libs/sub"])
486            .current_dir(&parent)
487            .output()
488            .unwrap();
489        Command::new("git")
490            .args(["commit", "-m", "add submodule"])
491            .current_dir(&parent)
492            .output()
493            .unwrap();
494        let sm_path = parent.join("libs/sub");
495        assert_eq!(
496            String::from_utf8_lossy(
497                &Command::new("git")
498                    .args(["rev-parse", "HEAD"])
499                    .current_dir(&sm_path)
500                    .output()
501                    .unwrap()
502                    .stdout
503            )
504            .trim()
505            .to_string(),
506            init_hash,
507            "submodule starts at init"
508        );
509        git_commit(&sub, "remote ahead");
510        Command::new("git")
511            .args(["push", "origin", "main"])
512            .current_dir(&sub)
513            .output()
514            .unwrap();
515        let remote_hash = String::from_utf8_lossy(
516            &Command::new("git")
517                .args(["rev-parse", "HEAD"])
518                .current_dir(&sub)
519                .output()
520                .unwrap()
521                .stdout,
522        )
523        .trim()
524        .to_string();
525        assert!(
526            GitSubmoduleEditor::new(parent)
527                .sync_to_parent("libs/sub")
528                .is_ok(),
529            "sync failed"
530        );
531        assert_eq!(
532            String::from_utf8_lossy(
533                &Command::new("git")
534                    .args(["rev-parse", "HEAD"])
535                    .current_dir(&sm_path)
536                    .output()
537                    .unwrap()
538                    .stdout
539            )
540            .trim()
541            .to_string(),
542            remote_hash,
543            "submodule caught up to remote after sync"
544        );
545    }
546
547    #[test]
548    fn test_editor_status_with_dirty_submodule() {
549        let t = tempfile::tempdir().unwrap();
550        let p = setup_repo_with_submodule(t.path());
551        std::fs::write(p.join("libs/sub/new-file"), "content").unwrap();
552        let issues = GitSubmoduleEditor::new(p).status().unwrap();
553        assert!(!issues.is_empty());
554        assert_eq!(issues[0].status, "Dirty");
555    }
556}