1use std::path::PathBuf;
2
3#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
4pub struct CommitHash(pub String);
5
6impl std::fmt::Display for CommitHash {
7 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
8 write!(f, "{}", &self.0[..self.0.len().min(7)])
9 }
10}
11
12impl Default for CommitHash {
13 fn default() -> Self {
14 Self(String::from("0000000000000000000000000000000000000000"))
15 }
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
19pub enum SubmoduleStatus {
20 Clean,
21 AheadOfParent,
22 BehindRemote,
23 Detached,
24 Dirty,
25 Orphaned,
26 Uninitialized,
27}
28
29impl SubmoduleStatus {
30 pub fn priority(&self) -> u8 {
31 match self {
32 Self::Dirty => 0,
33 Self::Orphaned => 1,
34 Self::Detached => 2,
35 Self::Uninitialized => 3,
36 Self::BehindRemote => 4,
37 Self::AheadOfParent => 5,
38 Self::Clean => 6,
39 }
40 }
41}
42
43#[derive(Debug, Clone, serde::Serialize)]
44pub struct Submodule {
45 pub name: String,
46 pub path: PathBuf,
47 pub url: String,
48 pub tracked_branch: String,
49 pub parent_pointer: CommitHash,
50 pub local_head: CommitHash,
51 pub remote_head: CommitHash,
52 pub status: SubmoduleStatus,
53 pub ahead_count: usize,
54 pub behind_count: usize,
55 pub remote_unreachable: bool,
56}
57
58#[derive(Debug, Clone, serde::Serialize)]
59pub struct RepoState {
60 pub root_path: PathBuf,
61 pub submodules: Vec<Submodule>,
62 pub total: usize,
63 pub clean_count: usize,
64 pub needs_attention: Vec<String>,
65}
66
67impl RepoState {
68 pub fn scan(root: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
69 Self::scan_with_options(root, false)
70 }
71
72 pub fn scan_offline(root: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
73 Self::scan_with_options(root, true)
74 }
75
76 fn scan_with_options(root: &std::path::Path, offline: bool) -> Result<Self, Box<dyn std::error::Error>> {
77 let repo = match git2::Repository::open(root) {
78 Ok(r) => r,
79 Err(e) => return Err(format!("无法打开 Git 仓库 '{}': {}", root.display(), e).into()),
80 };
81 let gitmodules_path = root.join(".gitmodules");
82
83 let submodules = if gitmodules_path.exists() {
84 let mut git_submodules = repo.submodules()?;
85 git_submodules.sort_by(|a, b| a.name().cmp(&b.name()));
86 git_submodules
87 .iter()
88 .map(|sm| Self::scan_single_submodule(root, sm, &repo, offline))
89 .collect::<Result<Vec<_>, _>>()?
90 } else {
91 Vec::new()
92 };
93
94 let total = submodules.len();
95 let clean_count = submodules
96 .iter()
97 .filter(|s| s.status == SubmoduleStatus::Clean)
98 .count();
99 let needs_attention: Vec<String> = submodules
100 .iter()
101 .filter(|s| s.status != SubmoduleStatus::Clean)
102 .map(|s| s.name.clone())
103 .collect();
104
105 Ok(RepoState {
106 root_path: root.to_path_buf(),
107 submodules,
108 total,
109 clean_count,
110 needs_attention,
111 })
112 }
113
114 fn scan_single_submodule(
115 root: &std::path::Path,
116 sm: &git2::Submodule,
117 repo: &git2::Repository,
118 offline: bool,
119 ) -> Result<Submodule, Box<dyn std::error::Error>> {
120 let name = sm.name().unwrap_or("unknown").to_string();
121 let sm_path = sm.path();
122 let full_sm_path = root.join(sm_path);
123 let url = sm.url().unwrap_or("").to_string();
124 let branch = sm.branch().unwrap_or("main").to_string();
125
126 let raw_status = repo.submodule_status(&name, git2::SubmoduleIgnore::None)?;
127 let is_uninitialized = raw_status.is_wd_uninitialized();
128 let head_oid = sm.head_id().unwrap_or_else(git2::Oid::zero);
129 let parent_pointer = CommitHash(head_oid.to_string());
130
131 let (local_head, remote_head, is_detached, ahead_count, behind_count, is_orphaned, remote_unreachable) =
132 Self::scan_submodule_remote_state(&full_sm_path, &branch, &parent_pointer, is_uninitialized, offline);
133
134 let is_dirty = !is_uninitialized
135 && ahead_count == 0
136 && (raw_status.is_wd_modified()
137 || raw_status.is_index_modified()
138 || raw_status.is_wd_untracked());
139
140 let status = Self::determine_submodule_status(
141 is_uninitialized, is_dirty, is_detached, is_orphaned,
142 remote_unreachable, ahead_count, behind_count, &local_head, &parent_pointer,
143 );
144
145 Ok(Submodule {
146 name,
147 path: sm_path.to_path_buf(),
148 url,
149 tracked_branch: branch,
150 parent_pointer,
151 local_head,
152 remote_head,
153 status,
154 ahead_count,
155 behind_count,
156 remote_unreachable,
157 })
158 }
159
160 fn scan_submodule_remote_state(
161 full_sm_path: &std::path::Path, branch: &str, parent_pointer: &CommitHash, is_uninitialized: bool, offline: bool,
162 ) -> (CommitHash, CommitHash, bool, usize, usize, bool, bool) {
163 if is_uninitialized {
164 return Self::default_submodule_state();
165 }
166 let Ok(sub_repo) = git2::Repository::open(full_sm_path) else {
167 return Self::default_submodule_state();
168 };
169 let (local, detached) = Self::open_submodule_head(&sub_repo);
170 if !offline {
171 Self::fetch_submodule_remote(&sub_repo);
172 }
173 let (remote, unreachable) = Self::resolve_submodule_remote(&sub_repo, branch);
174 let (ahead, behind, orphaned) = Self::compute_submodule_diff(&sub_repo, &local, parent_pointer, &remote, unreachable);
175 (local, remote, detached, ahead, behind, orphaned, unreachable)
176 }
177
178 fn default_submodule_state() -> (CommitHash, CommitHash, bool, usize, usize, bool, bool) {
179 (CommitHash::default(), CommitHash::default(), false, 0, 0, false, false)
180 }
181
182 fn open_submodule_head(sub_repo: &git2::Repository) -> (CommitHash, bool) {
183 let local = sub_repo.head().ok().and_then(|r| r.target()).map(|o| CommitHash(o.to_string())).unwrap_or_default();
184 let detached = sub_repo.head().ok().map(|r| !r.is_branch()).unwrap_or(false);
185 (local, detached)
186 }
187
188 fn fetch_submodule_remote(sub_repo: &git2::Repository) {
189 if let Ok(mut sub_remote) = sub_repo.find_remote("origin") {
190 let mut fetch_opts = git2::FetchOptions::new();
191 fetch_opts.download_tags(git2::AutotagOption::None);
192 let mut callbacks = git2::RemoteCallbacks::new();
193 callbacks.transfer_progress(|_| true);
194 fetch_opts.remote_callbacks(callbacks);
195 let _ = sub_remote.fetch(&["+refs/heads/*:refs/remotes/origin/*"], Some(&mut fetch_opts), None);
196 }
197 }
198
199 fn resolve_submodule_remote(sub_repo: &git2::Repository, branch: &str) -> (CommitHash, bool) {
200 sub_repo.find_reference(&format!("refs/remotes/origin/{}", branch)).ok()
201 .and_then(|r| r.target())
202 .map(|o| (CommitHash(o.to_string()), false))
203 .unwrap_or_else(|| (CommitHash::default(), true))
204 }
205
206 fn compute_submodule_diff(
207 sub_repo: &git2::Repository, local: &CommitHash, parent_pointer: &CommitHash,
208 remote: &CommitHash, unreachable: bool,
209 ) -> (usize, usize, bool) {
210 let ahead = count_between_opt(sub_repo, parse_oid(parent_pointer), parse_oid(local));
211 let behind = if unreachable { 0 } else { count_between_opt(sub_repo, parse_oid(local), parse_oid(remote)) };
212 let orphaned = if !unreachable && remote != &CommitHash::default() && parent_pointer != remote {
213 let (p, r) = (parse_oid(parent_pointer), parse_oid(remote));
214 match (p, r) {
215 (Some(p_oid), Some(r_oid)) => sub_repo.merge_base(r_oid, p_oid).map(|base| base != p_oid).unwrap_or(true),
216 _ => false,
217 }
218 } else { false };
219 (ahead, behind, orphaned)
220 }
221
222 fn determine_submodule_status(
223 is_uninitialized: bool, is_dirty: bool, is_detached: bool, is_orphaned: bool,
224 remote_unreachable: bool, ahead_count: usize, behind_count: usize,
225 local_head: &CommitHash, parent_pointer: &CommitHash,
226 ) -> SubmoduleStatus {
227 if is_uninitialized { return SubmoduleStatus::Uninitialized; }
228 if is_dirty { return SubmoduleStatus::Dirty; }
229 if is_detached { return SubmoduleStatus::Detached; }
230 if is_orphaned && !remote_unreachable { return SubmoduleStatus::Orphaned; }
231 if (remote_unreachable && local_head != parent_pointer) || (ahead_count > 0 && behind_count == 0) {
232 return SubmoduleStatus::AheadOfParent;
233 }
234 if behind_count > 0 && !remote_unreachable { return SubmoduleStatus::BehindRemote; }
235 SubmoduleStatus::Clean
236 }
237
238 pub fn scan_all(root: &std::path::Path) -> Result<(Vec<Submodule>, AggregateStatus), Box<dyn std::error::Error>> {
239 let state = Self::scan(root)?;
240 let agg = AggregateStatus::from_submodules(&state.submodules);
241 Ok((state.submodules, agg))
242 }
243}
244
245#[derive(Debug, Clone, Default, serde::Serialize)]
246pub struct AggregateStatus {
247 pub total: usize,
248 pub clean: usize,
249 pub ahead_of_parent: usize,
250 pub behind_remote: usize,
251 pub detached: usize,
252 pub dirty: usize,
253 pub orphaned: usize,
254 pub uninitialized: usize,
255}
256
257impl AggregateStatus {
258 pub fn from_submodules(submodules: &[Submodule]) -> Self {
259 let mut clean = 0; let mut ahead = 0; let mut behind = 0;
260 let mut detached = 0; let mut dirty = 0; let mut orphaned = 0; let mut uninit = 0;
261 for sm in submodules {
262 match sm.status {
263 SubmoduleStatus::Clean => clean += 1,
264 SubmoduleStatus::AheadOfParent => ahead += 1,
265 SubmoduleStatus::BehindRemote => behind += 1,
266 SubmoduleStatus::Detached => detached += 1,
267 SubmoduleStatus::Dirty => dirty += 1,
268 SubmoduleStatus::Orphaned => orphaned += 1,
269 SubmoduleStatus::Uninitialized => uninit += 1,
270 }
271 }
272 AggregateStatus { total: submodules.len(), clean, ahead_of_parent: ahead, behind_remote: behind, detached, dirty, orphaned, uninitialized: uninit }
273 }
274}
275
276pub struct GitSubmoduleEditor {
279 root: PathBuf,
280 offline: bool,
281}
282
283impl GitSubmoduleEditor {
284 pub fn new(root: PathBuf) -> Self {
285 Self { root, offline: false }
286 }
287
288 pub fn set_offline(&mut self, offline: bool) {
289 self.offline = offline;
290 }
291
292 pub fn fetch_submodule(path: &std::path::Path) -> Result<(), ()> {
293 let has_remote = std::process::Command::new("git")
294 .args(["remote", "get-url", "origin"]).current_dir(path).output()
295 .map(|o| o.status.success()).unwrap_or(false);
296 if !has_remote { return Ok(()); }
297 std::process::Command::new("git").args(["fetch", "origin"]).current_dir(path).output()
298 .map(|o| if o.status.success() { Ok(()) } else { Err(()) }).unwrap_or(Err(()))
299 }
300
301 pub fn rebase_submodule(path: &std::path::Path) -> Result<(), String> {
302 if !path.exists() { return Ok(()); }
303 let branch = std::process::Command::new("git").args(["rev-parse", "--abbrev-ref", "HEAD"])
304 .current_dir(path).output().ok()
305 .and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) } else { None })
306 .unwrap_or_default();
307 if branch.is_empty() || branch == "HEAD" { return Ok(()); }
308 if !std::process::Command::new("git").args(["remote", "get-url", "origin"])
309 .current_dir(path).output().map(|o| o.status.success()).unwrap_or(false) { return Ok(()); }
310 let output = std::process::Command::new("git")
311 .args(["rebase", &format!("origin/{}", branch)])
312 .current_dir(path).output()
313 .map_err(|e| format!("git rebase 无法执行: {}", e))?;
314 if !output.status.success() {
315 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
316 if stderr.contains("up to date") || stderr.contains("up-to-date") {
317 return Ok(());
318 }
319 return Err(format!("rebase 冲突,需手动处理:解决冲突后 git rebase --continue,或 git rebase --abort 放弃\n{}", stderr));
320 }
321 Ok(())
322 }
323
324 pub fn push_submodule(path: &std::path::Path) -> Result<(), String> {
325 if !path.exists() { return Ok(()); }
326 let branch = std::process::Command::new("git").args(["rev-parse", "--abbrev-ref", "HEAD"])
327 .current_dir(path).output().ok()
328 .and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) } else { None })
329 .unwrap_or_default();
330 if branch.is_empty() || branch == "HEAD" { return Ok(()); }
331 if !std::process::Command::new("git").args(["remote", "get-url", "origin"])
332 .current_dir(path).output().map(|o| o.status.success()).unwrap_or(false) { return Ok(()); }
333 let tracking = format!("origin/{}", branch);
334 let ahead = std::process::Command::new("git").args(["rev-list", "--count", &format!("{}..{}", tracking, branch)])
335 .current_dir(path).output().ok()
336 .and_then(|o| if o.status.success() { String::from_utf8_lossy(&o.stdout).trim().parse::<i32>().ok() } else { None })
337 .unwrap_or(0);
338 if ahead <= 0 { return Ok(()); }
339 std::process::Command::new("git").args(["push", "origin", &branch]).current_dir(path).output()
340 .map(|o| if o.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&o.stderr).trim().to_string()) })
341 .unwrap_or_else(|e| Err(format!("git push 无法执行: {}", e)))
342 }
343
344 pub fn update_parent_pointer(repo: &git2::Repository, sm_path: &std::path::Path, name: &str) -> Result<(), Box<dyn std::error::Error>> {
345 let mut index = repo.index()?;
346 index.add_path(sm_path)?; index.write()?;
347 let tree_id = index.write_tree()?;
348 let tree = repo.find_tree(tree_id)?;
349 let head = repo.head()?;
350 let parent = head.peel_to_commit()?;
351 let signature = repo.signature()?;
352 repo.commit(Some("HEAD"), &signature, &signature, &format!("chore: 更新子模块 '{}' 指针", name), &tree, &[&parent])?;
353 Ok(())
354 }
355
356 pub fn push_parent(repo: &git2::Repository, root: &std::path::Path) -> Result<(), String> {
357 if !std::process::Command::new("git").args(["remote", "get-url", "origin"])
358 .current_dir(root).output().map(|o| o.status.success()).unwrap_or(false) { return Ok(()); }
359 let branch = repo.head().ok().and_then(|r| r.shorthand().map(|s| s.to_string())).unwrap_or_default();
360 if branch.is_empty() { return Err("无法检测当前分支".into()); }
361 std::process::Command::new("git").args(["push", "origin", &branch]).current_dir(root).output()
362 .map(|o| if o.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&o.stderr).trim().to_string()) })
363 .unwrap_or_else(|e| Err(format!("git push 无法执行: {}", e)))
364 }
365
366 pub fn revert_parent_commit(root: &std::path::Path) {
367 std::process::Command::new("git").args(["reset", "--hard", "HEAD~1"]).current_dir(root).output().ok();
368 }
369
370 pub fn root(&self) -> &std::path::Path {
371 &self.root
372 }
373
374 pub fn sync_to_parent(&self, name: &str) -> Result<(), Box<dyn std::error::Error>> {
375 let repo = git2::Repository::open(&self.root)?;
376 let sm = repo.find_submodule(name)?;
377 let sm_path = sm.path();
378 let full_sm_path = self.root.join(sm_path);
379
380 if full_sm_path.exists() {
381 Self::fetch_submodule(&full_sm_path).ok();
382 Self::rebase_submodule(&full_sm_path)?;
383 }
384 Self::push_submodule(&full_sm_path).map_err(|e| format!("子模块 push 失败: {}", e))?;
385 Self::update_parent_pointer(&repo, sm_path, name)?;
386 if let Err(e) = Self::push_parent(&repo, &self.root) {
387 Self::revert_parent_commit(&self.root);
388 return Err(format!("父仓库 push 失败 (已回滚提交): {}", e).into());
389 }
390 println!(" ✓ {}", name);
391 Ok(())
392 }
393
394 pub fn sync_all_to_parent(&self) -> Result<(), Box<dyn std::error::Error>> {
395 let repo = git2::Repository::open(&self.root)?;
396 let submodules = repo.submodules()?;
397 println!("同步 {} 个子模块", submodules.len());
398 for sm in submodules.iter() {
399 let name = sm.name().unwrap_or("unknown").to_string();
400 match self.sync_to_parent(&name) {
401 Ok(()) => {}
402 Err(e) => println!(" {:<35} ✗ 失败: {}", name, e),
403 }
404 }
405 Ok(())
406 }
407
408 pub fn status(&self) -> Result<Vec<HealthIssue>, Box<dyn std::error::Error>> {
409 let state = RepoState::scan(&self.root)?;
410 let mut issues = Vec::new();
411 for sm in &state.submodules {
412 if sm.status != SubmoduleStatus::Clean {
413 let (description, action) = describe_issue(&sm.status);
414 issues.push(HealthIssue {
415 submodule_name: sm.name.clone(),
416 status: format!("{:?}", sm.status),
417 description,
418 suggested_action: action,
419 });
420 }
421 }
422 Ok(issues)
423 }
424}
425
426fn parse_oid(h: &CommitHash) -> Option<git2::Oid> {
427 git2::Oid::from_str(&h.0).ok()
428}
429
430fn count_between_opt(repo: &git2::Repository, from: Option<git2::Oid>, to: Option<git2::Oid>) -> usize {
431 let (Some(from), Some(to)) = (from, to) else { return 0; };
432 if from == to { return 0; }
433 let mut walk = match repo.revwalk() { Ok(w) => w, Err(_) => return 0, };
434 if walk.push(to).is_err() || walk.hide(from).is_err() { return 0; }
435 walk.count()
436}
437
438#[derive(Debug, Clone)]
439pub struct HealthIssue {
440 pub submodule_name: String,
441 pub status: String,
442 pub description: String,
443 pub suggested_action: String,
444}
445
446fn describe_issue(status: &SubmoduleStatus) -> (String, String) {
447 match status {
448 SubmoduleStatus::AheadOfParent => ("本地领先于父仓库记录".into(), "运行 sync_to_parent 更新父仓库指针".into()),
449 SubmoduleStatus::BehindRemote => ("远程有更新,本地落后".into(), "运行 code sync 获取最新代码".into()),
450 SubmoduleStatus::Detached => ("处于游离 HEAD 状态".into(), "运行 checkout_branch 切换到跟踪分支".into()),
451 SubmoduleStatus::Dirty => ("有未提交的修改".into(), "提交或 stash 当前修改".into()),
452 SubmoduleStatus::Orphaned => ("父仓库记录的 commit 在远程已不存在".into(), "需手动干预".into()),
453 SubmoduleStatus::Uninitialized => ("尚未初始化".into(), "运行 init 初始化子模块".into()),
454 SubmoduleStatus::Clean => unreachable!(),
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use std::process::Command;
462
463 fn git_init(path: &std::path::Path) {
464 Command::new("git").args(["init", "-b", "main"]).current_dir(path).output().unwrap();
465 Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
466 Command::new("git").args(["config", "user.name", "Test"]).current_dir(path).output().unwrap();
467 }
468
469 fn git_commit(path: &std::path::Path, msg: &str) {
470 std::fs::write(path.join("file"), msg).unwrap();
471 Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
472 Command::new("git").args(["commit", "-m", msg]).current_dir(path).output().unwrap();
473 }
474
475 fn setup_repo_with_submodule(tmp: &std::path::Path) -> std::path::PathBuf {
476 let parent = tmp.join("parent");
477 let sub = tmp.join("sub");
478 std::fs::create_dir_all(&sub).unwrap(); git_init(&sub); git_commit(&sub, "init sub");
479 std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init parent");
480 Command::new("git").args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
481 Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
482 parent
483 }
484
485 #[test] fn test_status_priority_ordering() {
487 assert!(SubmoduleStatus::Dirty.priority() < SubmoduleStatus::Clean.priority());
488 assert!(SubmoduleStatus::Orphaned.priority() < SubmoduleStatus::BehindRemote.priority());
489 }
490 #[test] fn test_clean_is_lowest_priority() {
491 for s in &[SubmoduleStatus::Dirty, SubmoduleStatus::Orphaned, SubmoduleStatus::Detached, SubmoduleStatus::Uninitialized, SubmoduleStatus::BehindRemote, SubmoduleStatus::AheadOfParent] {
492 assert!(s.priority() < SubmoduleStatus::Clean.priority());
493 }
494 }
495 #[test] fn test_all_priorities_are_unique() {
496 let p: Vec<u8> = [SubmoduleStatus::Dirty, SubmoduleStatus::Orphaned, SubmoduleStatus::Detached, SubmoduleStatus::Uninitialized, SubmoduleStatus::BehindRemote, SubmoduleStatus::AheadOfParent, SubmoduleStatus::Clean].iter().map(|s| s.priority()).collect();
497 let mut s = p.clone(); s.sort(); s.dedup();
498 assert_eq!(p.len(), s.len());
499 }
500 #[test] fn test_status_debug_output() { assert_eq!(format!("{:?}", SubmoduleStatus::Clean), "Clean"); }
501 #[test] fn test_status_clone_eq() { assert_eq!(SubmoduleStatus::Dirty, SubmoduleStatus::Dirty); }
502
503 fn dh() -> CommitHash { CommitHash::default() }
505 fn h(s: &str) -> CommitHash { CommitHash(s.to_string()) }
506 #[test] fn test_determine_status_uninitialized() { assert_eq!(RepoState::determine_submodule_status(true, false, false, false, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Uninitialized); }
507 #[test] fn test_determine_status_dirty() { assert_eq!(RepoState::determine_submodule_status(false, true, false, false, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Dirty); }
508 #[test] fn test_determine_status_detached() { assert_eq!(RepoState::determine_submodule_status(false, false, true, false, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Detached); }
509 #[test] fn test_determine_status_orphaned() { assert_eq!(RepoState::determine_submodule_status(false, false, false, true, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Orphaned); }
510 #[test] fn test_determine_status_ahead_of_parent() {
511 assert_eq!(RepoState::determine_submodule_status(false, false, false, false, true, 0, 0, &h("abc"), &dh()), SubmoduleStatus::AheadOfParent);
512 assert_eq!(RepoState::determine_submodule_status(false, false, false, false, false, 5, 0, &dh(), &dh()), SubmoduleStatus::AheadOfParent);
513 assert_eq!(RepoState::determine_submodule_status(false, false, false, false, false, 5, 3, &dh(), &dh()), SubmoduleStatus::BehindRemote);
514 }
515 #[test] fn test_determine_status_behind_remote() {
516 assert_eq!(RepoState::determine_submodule_status(false, false, false, false, false, 0, 3, &dh(), &dh()), SubmoduleStatus::BehindRemote);
517 assert_eq!(RepoState::determine_submodule_status(false, false, false, false, true, 0, 3, &dh(), &dh()), SubmoduleStatus::Clean);
518 }
519 #[test] fn test_determine_status_clean() { assert_eq!(RepoState::determine_submodule_status(false, false, false, false, false, 0, 0, &dh(), &dh()), SubmoduleStatus::Clean); }
520
521 #[test] fn test_commit_hash_display_truncates() { assert_eq!(CommitHash("abcdef1234567890".to_string()).to_string(), "abcdef1"); }
523 #[test] fn test_commit_hash_display_short() { assert_eq!(CommitHash("abc".to_string()).to_string(), "abc"); }
524 #[test] fn test_commit_hash_display_empty() { assert_eq!(CommitHash(String::new()).to_string(), ""); }
525 #[test] fn test_commit_hash_equality() { assert_eq!(CommitHash("abc".to_string()), CommitHash("abc".to_string())); }
526 #[test] fn test_commit_hash_default() { assert_eq!(CommitHash::default().0, "0000000000000000000000000000000000000000"); }
527 #[test] fn test_commit_hash_clone() { let a = CommitHash("deadbeef".to_string()); assert_eq!(a, a.clone()); }
528
529 #[test] fn test_submodule_builder() {
531 let sm = Submodule { name: "test".into(), path: PathBuf::from("libs/test"), url: "https://example.com/test.git".into(), tracked_branch: "main".into(), parent_pointer: CommitHash("aaa".into()), local_head: CommitHash("bbb".into()), remote_head: CommitHash("ccc".into()), status: SubmoduleStatus::BehindRemote, ahead_count: 0, behind_count: 3, remote_unreachable: false };
532 assert_eq!(sm.name, "test");
533 }
534
535 #[test] fn test_aggregate_status_default() { assert_eq!(AggregateStatus::default().total, 0); }
537 #[test] fn test_aggregate_status_from_submodules() {
538 let sm = |s| Submodule { name: String::new(), path: PathBuf::new(), url: String::new(), tracked_branch: "main".into(), parent_pointer: CommitHash::default(), local_head: CommitHash::default(), remote_head: CommitHash::default(), status: s, ahead_count: 0, behind_count: 0, remote_unreachable: false };
539 let agg = AggregateStatus::from_submodules(&[sm(SubmoduleStatus::Clean), sm(SubmoduleStatus::Dirty), sm(SubmoduleStatus::Orphaned)]);
540 assert_eq!(agg.total, 3); assert_eq!(agg.clean, 1); assert_eq!(agg.dirty, 1); assert_eq!(agg.orphaned, 1);
541 }
542 #[test] fn test_aggregate_status_all_variants() {
543 let sm = |s| Submodule { name: String::new(), path: PathBuf::new(), url: String::new(), tracked_branch: "main".into(), parent_pointer: CommitHash::default(), local_head: CommitHash::default(), remote_head: CommitHash::default(), status: s, ahead_count: 0, behind_count: 0, remote_unreachable: false };
544 let agg = AggregateStatus::from_submodules(&[sm(SubmoduleStatus::Clean), sm(SubmoduleStatus::AheadOfParent), sm(SubmoduleStatus::BehindRemote), sm(SubmoduleStatus::Detached), sm(SubmoduleStatus::Dirty), sm(SubmoduleStatus::Orphaned), sm(SubmoduleStatus::Uninitialized)]);
545 assert_eq!(agg.total, 7); assert_eq!(agg.clean, 1);
546 }
547
548 #[test] fn test_parse_oid_valid() { assert!(parse_oid(&CommitHash("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0".into())).is_some()); }
550 #[test] fn test_parse_oid_invalid() { assert!(parse_oid(&CommitHash("not-a-hex-string".into())).is_none()); }
551 #[test] fn test_parse_oid_empty() { assert!(parse_oid(&CommitHash(String::new())).is_none()); }
552 #[test] fn test_count_between_opt_both_none() { let t = tempfile::tempdir().unwrap(); git_init(t.path()); let r = git2::Repository::open(t.path()).unwrap(); assert_eq!(count_between_opt(&r, None, None), 0); }
553 #[test] fn test_count_between_opt_equal_oids() { let t = tempfile::tempdir().unwrap(); git_init(t.path()); git_commit(t.path(), "c1"); let r = git2::Repository::open(t.path()).unwrap(); let h = r.head().unwrap().target().unwrap(); assert_eq!(count_between_opt(&r, Some(h), Some(h)), 0); }
554 #[test] fn test_count_between_opt_from_to() { let t = tempfile::tempdir().unwrap(); git_init(t.path()); git_commit(t.path(), "c1"); let r = git2::Repository::open(t.path()).unwrap(); let c1 = r.head().unwrap().target().unwrap(); git_commit(t.path(), "c2"); let c2 = r.head().unwrap().target().unwrap(); assert_eq!(count_between_opt(&r, Some(c1), Some(c2)), 1); }
555 #[test] fn test_count_between_opt_revwalk_fail() { let t = tempfile::tempdir().unwrap(); git_init(t.path()); let r = git2::Repository::open(t.path()).unwrap(); let o = git2::Oid::from_str("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0").ok(); assert_eq!(count_between_opt(&r, o, o), 0); }
556
557 #[test] fn test_scan_no_gitmodules() { assert!(RepoState::scan(&tempfile::tempdir().unwrap().path()).is_err()); }
559 #[test] fn test_scan_git_repo_but_no_submodules() { let t = tempfile::tempdir().unwrap(); git_init(t.path()); git_commit(t.path(), "initial"); assert_eq!(RepoState::scan(t.path()).unwrap().total, 0); }
560 #[test] fn test_scan_non_git_directory() { let t = tempfile::tempdir().unwrap(); std::fs::write(t.path().join(".gitmodules"), "").unwrap(); assert!(RepoState::scan(t.path()).is_err()); }
561 #[test] fn test_scan_with_submodule() { let t = tempfile::tempdir().unwrap(); let p = setup_repo_with_submodule(t.path()); let s = RepoState::scan(&p).unwrap(); assert_eq!(s.total, 1); assert_eq!(s.submodules[0].name, "libs/sub"); }
562 #[test] fn test_scan_all_no_gitmodules() { assert!(RepoState::scan_all(&tempfile::tempdir().unwrap().path()).is_err()); }
563 #[test] fn test_scan_all_with_submodule() { let t = tempfile::tempdir().unwrap(); let p = setup_repo_with_submodule(t.path()); let (subs, _) = RepoState::scan_all(&p).unwrap(); assert_eq!(subs.len(), 1); }
564 #[test] fn test_repo_state_empty() { let s = RepoState { root_path: PathBuf::from("/tmp"), submodules: vec![], total: 0, clean_count: 0, needs_attention: vec![] }; assert_eq!(s.total, 0); }
565
566 #[test] fn test_editor_new_and_root() { let e = GitSubmoduleEditor::new(PathBuf::from("/tmp")); assert_eq!(e.root(), std::path::Path::new("/tmp")); }
568 #[test] fn test_editor_sync_to_parent() { let t = tempfile::tempdir().unwrap(); let p = setup_repo_with_submodule(t.path()); assert!(GitSubmoduleEditor::new(p).sync_to_parent("libs/sub").is_ok()); }
569 #[test] fn test_editor_sync_to_parent_nonexistent() { let t = tempfile::tempdir().unwrap(); std::fs::create_dir_all(t.path().join(".git")).unwrap(); assert!(GitSubmoduleEditor::new(t.path().to_path_buf()).sync_to_parent("no-such-module").is_err()); }
570 #[test] fn test_editor_sync_all_to_parent() { let t = tempfile::tempdir().unwrap(); let p = setup_repo_with_submodule(t.path()); assert!(GitSubmoduleEditor::new(p).sync_all_to_parent().is_ok()); }
571 #[test] fn test_editor_sync_all_to_parent_no_submodules() { let t = tempfile::tempdir().unwrap(); git_init(t.path()); git_commit(t.path(), "initial"); assert!(GitSubmoduleEditor::new(t.path().to_path_buf()).sync_all_to_parent().is_ok()); }
572 #[test] fn test_editor_status() { let t = tempfile::tempdir().unwrap(); let p = setup_repo_with_submodule(t.path()); assert!(GitSubmoduleEditor::new(p).status().unwrap().is_empty()); }
573 #[test] fn test_editor_status_with_gitmodules_but_no_repo() { let t = tempfile::tempdir().unwrap(); std::fs::write(t.path().join(".gitmodules"), "").unwrap(); assert!(GitSubmoduleEditor::new(t.path().to_path_buf()).status().is_err()); }
574
575 #[test] fn test_editor_sync_with_remote_push() {
576 let tmp = tempfile::tempdir().unwrap();
577 let bare_sub = tmp.path().join("bare-sub");
578 let bare_parent = tmp.path().join("bare-parent");
579 for b in [&bare_sub, &bare_parent] { Command::new("git").args(["init", "--bare", &b.to_string_lossy()]).output().unwrap(); }
580 let sub = tmp.path().join("sub");
581 Command::new("git").args(["clone", &bare_sub.to_string_lossy(), &sub.to_string_lossy()]).current_dir(tmp.path()).output().unwrap();
582 git_init(&sub); git_commit(&sub, "init"); Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
583 let parent = tmp.path().join("parent");
584 std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init parent");
585 Command::new("git").args(["remote", "add", "origin", &bare_parent.to_string_lossy()]).current_dir(&parent).output().unwrap();
586 Command::new("git").args(["submodule", "add", &bare_sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
587 Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
588 git_commit(&sub, "ahead"); Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
589 Command::new("git").args(["fetch", "origin"]).current_dir(&parent.join("libs/sub")).output().unwrap();
590 assert!(GitSubmoduleEditor::new(parent).sync_to_parent("libs/sub").is_ok(), "sync failed");
591 }
592
593 #[test] fn test_editor_sync_rebase_catches_up() {
594 let tmp = tempfile::tempdir().unwrap();
595 let bare_sub = tmp.path().join("bare-sub");
596 let bare_parent = tmp.path().join("bare-parent");
597 for b in [&bare_sub, &bare_parent] { Command::new("git").args(["init", "--bare", &b.to_string_lossy()]).output().unwrap(); }
598 let sub = tmp.path().join("sub");
599 Command::new("git").args(["clone", &bare_sub.to_string_lossy(), &sub.to_string_lossy()]).current_dir(tmp.path()).output().unwrap();
600 git_init(&sub); git_commit(&sub, "init");
601 Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
602 let init_hash = String::from_utf8_lossy(&Command::new("git").args(["rev-parse", "HEAD"]).current_dir(&sub).output().unwrap().stdout).trim().to_string();
603 let parent = tmp.path().join("parent");
604 std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init parent");
605 Command::new("git").args(["remote", "add", "origin", &bare_parent.to_string_lossy()]).current_dir(&parent).output().unwrap();
606 Command::new("git").args(["submodule", "add", &bare_sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
607 Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
608 let sm_path = parent.join("libs/sub");
609 assert_eq!(
610 String::from_utf8_lossy(&Command::new("git").args(["rev-parse", "HEAD"]).current_dir(&sm_path).output().unwrap().stdout).trim().to_string(),
611 init_hash, "submodule starts at init"
612 );
613 git_commit(&sub, "remote ahead");
614 Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
615 let remote_hash = String::from_utf8_lossy(&Command::new("git").args(["rev-parse", "HEAD"]).current_dir(&sub).output().unwrap().stdout).trim().to_string();
616 assert!(GitSubmoduleEditor::new(parent).sync_to_parent("libs/sub").is_ok(), "sync failed");
617 assert_eq!(
618 String::from_utf8_lossy(&Command::new("git").args(["rev-parse", "HEAD"]).current_dir(&sm_path).output().unwrap().stdout).trim().to_string(),
619 remote_hash, "submodule caught up to remote after sync"
620 );
621 }
622
623 #[test] fn test_editor_status_with_dirty_submodule() {
624 let t = tempfile::tempdir().unwrap(); let p = setup_repo_with_submodule(t.path());
625 std::fs::write(p.join("libs/sub/new-file"), "content").unwrap();
626 let issues = GitSubmoduleEditor::new(p).status().unwrap();
627 assert!(!issues.is_empty()); assert_eq!(issues[0].status, "Dirty");
628 }
629
630 #[test] fn test_describe_issue_ahead_of_parent() { let (d, a) = describe_issue(&SubmoduleStatus::AheadOfParent); assert!(d.contains("领先")); assert!(a.contains("sync")); }
632 #[test] fn test_describe_issue_behind_remote() { let (d, a) = describe_issue(&SubmoduleStatus::BehindRemote); assert!(d.contains("落后")); assert!(a.contains("sync")); }
633 #[test] fn test_describe_issue_detached() { let (d, a) = describe_issue(&SubmoduleStatus::Detached); assert!(d.contains("游离")); assert!(a.contains("checkout")); }
634 #[test] fn test_describe_issue_dirty() { let (d, a) = describe_issue(&SubmoduleStatus::Dirty); assert!(d.contains("修改")); }
635 #[test] fn test_describe_issue_orphaned() { let (d, a) = describe_issue(&SubmoduleStatus::Orphaned); assert!(d.contains("不存在")); }
636 #[test] fn test_describe_issue_uninitialized() { let (d, a) = describe_issue(&SubmoduleStatus::Uninitialized); assert!(d.contains("初始化")); }
637 #[test] #[should_panic(expected = "unreachable")] fn test_describe_issue_clean_panics() { describe_issue(&SubmoduleStatus::Clean); }
638
639 #[test] fn test_scan_with_uninitialized_submodule() {
641 let tmp = tempfile::tempdir().unwrap(); let parent = tmp.path().join("parent");
642 std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init");
643 let sub = tmp.path().join("sub"); std::fs::create_dir_all(&sub).unwrap(); git_init(&sub); git_commit(&sub, "init");
644 Command::new("git").args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
645 Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
646 Command::new("git").args(["submodule", "deinit", "-f", "libs/sub"]).current_dir(&parent).output().unwrap();
647 assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].status, SubmoduleStatus::Uninitialized);
648 }
649
650 #[test] fn test_scan_with_detached_submodule() {
651 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
652 let sm_path = parent.join("libs/sub");
653 let hash = String::from_utf8_lossy(&Command::new("git").args(["rev-parse", "HEAD"]).current_dir(&sm_path).output().unwrap().stdout).trim().to_string();
654 Command::new("git").args(["checkout", "--detach", &hash]).current_dir(&sm_path).output().unwrap();
655 assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].status, SubmoduleStatus::Detached);
656 }
657
658 #[test] fn test_scan_with_ahead_via_remote_unreachable() {
659 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
660 let sm_path = parent.join("libs/sub");
661 std::fs::write(sm_path.join("new-file"), "content").unwrap();
662 Command::new("git").args(["add", "."]).current_dir(&sm_path).output().unwrap();
663 Command::new("git").args(["commit", "-m", "ahead commit"]).current_dir(&sm_path).output().unwrap();
664 Command::new("git").args(["remote", "remove", "origin"]).current_dir(&sm_path).output().unwrap();
665 let state = RepoState::scan(&parent).unwrap();
666 assert_eq!(state.submodules[0].status, SubmoduleStatus::AheadOfParent);
667 }
668
669 #[test] fn test_scan_with_subrepo_open_error() {
670 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
671 let sm_git = parent.join("libs/sub/.git");
672 if sm_git.is_dir() { std::fs::remove_dir_all(&sm_git).unwrap(); } else { std::fs::remove_file(&sm_git).unwrap(); }
673 assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].local_head, CommitHash::default());
674 }
675
676 #[test] fn test_scan_with_behind_remote() {
677 let tmp = tempfile::tempdir().unwrap(); let parent = tmp.path().join("parent"); let sub = tmp.path().join("sub"); let bare = tmp.path().join("bare");
678 std::fs::create_dir_all(&bare).unwrap(); Command::new("git").args(["init", "--bare", &bare.to_string_lossy()]).current_dir(tmp.path()).output().unwrap();
679 Command::new("git").args(["clone", &bare.to_string_lossy(), &sub.to_string_lossy()]).current_dir(tmp.path()).output().unwrap();
680 git_init(&sub); git_commit(&sub, "init"); Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
681 std::fs::create_dir_all(&parent).unwrap(); git_init(&parent); git_commit(&parent, "init parent");
682 Command::new("git").args(["submodule", "add", &sub.to_string_lossy(), "libs/sub"]).current_dir(&parent).output().unwrap();
683 Command::new("git").args(["commit", "-m", "add submodule"]).current_dir(&parent).output().unwrap();
684 git_commit(&sub, "remote ahead"); Command::new("git").args(["push", "origin", "main"]).current_dir(&sub).output().unwrap();
685 Command::new("git").args(["fetch", "origin"]).current_dir(&parent.join("libs/sub")).output().unwrap();
686 assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].behind_count, 1);
687 }
688
689 #[test] fn test_scan_with_orphaned_submodule() {
690 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
691 let sm_path = parent.join("libs/sub");
692 Command::new("git").args(["remote", "remove", "origin"]).current_dir(&sm_path).output().unwrap();
693 let ref_dir = parent.join(".git/modules/libs/sub/refs/remotes/origin");
694 std::fs::create_dir_all(&ref_dir).unwrap();
695 std::fs::write(ref_dir.join("main"), "1111111111111111111111111111111111111111\n").unwrap();
696 assert_eq!(RepoState::scan(&parent).unwrap().submodules[0].status, SubmoduleStatus::Orphaned);
697 }
698
699 #[test] fn test_scan_with_ahead_of_parent_clean() {
700 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
701 git_commit(&parent.join("libs/sub"), "ahead commit");
702 assert!(RepoState::scan(&parent).unwrap().submodules[0].ahead_count > 0);
703 }
704
705 #[test] fn test_orphaned_parse_oid_failure() {
706 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
707 let ref_dir = parent.join(".git/modules/libs/sub/refs/remotes/origin");
708 if !ref_dir.exists() { std::fs::create_dir_all(&ref_dir).unwrap(); }
709 std::fs::write(ref_dir.join("main"), "not-a-valid-oid\n").unwrap();
710 assert!(!RepoState::scan(&parent).unwrap().submodules.is_empty());
711 }
712
713 #[test] fn test_ahead_of_parent_via_ahead_count() {
714 let tmp = tempfile::tempdir().unwrap(); let parent = setup_repo_with_submodule(tmp.path());
715 let sm_path = parent.join("libs/sub");
716 Command::new("git").args(["remote", "remove", "origin"]).current_dir(&sm_path).output().unwrap();
717 std::fs::write(sm_path.join("new-file"), "content").unwrap();
718 Command::new("git").args(["add", "."]).current_dir(&sm_path).output().unwrap();
719 Command::new("git").args(["commit", "-m", "ahead"]).current_dir(&sm_path).output().unwrap();
720 let state = RepoState::scan(&parent).unwrap();
721 assert_eq!(state.submodules[0].ahead_count, 1);
722 }
723}