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