1use crate::git::scan::*;
2use crate::git::types::*;
3use std::path::{Path, PathBuf};
4
5pub 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 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 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 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 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 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 #[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}