qtcloud_devops_cli/commands/
code.rs1use 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 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 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 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 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 #[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 #[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 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 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 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 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 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}