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 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 #[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 #[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 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 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 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 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 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}