1use std::path::{Path, PathBuf};
7
8use rusqlite::params;
9use seshat_core::BranchId;
10use seshat_storage::{
11 BranchRepository, Database, FileIRRepository, IR_SCHEMA_VERSION, NodeRepository,
12 SqliteBranchRepository, SqliteFileIRRepository, SqliteNodeRepository,
13};
14
15use crate::error::CliError;
16
17use gix::bstr::ByteSlice;
19
20const PROTECTED_BRANCHES: &[&str] = &["main", "master"];
22
23pub(crate) enum ServeTarget {
26 ExistingDb {
28 db_path: PathBuf,
29 project_root: PathBuf,
30 },
31 AutoScan {
33 project_root: PathBuf,
34 db_path: PathBuf,
35 },
36}
37
38pub struct ResolvedProject {
51 pub project_root: PathBuf,
55 pub git_root: Option<PathBuf>,
58 pub project_name: String,
61 pub db_path: PathBuf,
64}
65
66impl ResolvedProject {
67 pub fn sync_root(&self) -> &Path {
71 self.git_root.as_deref().unwrap_or(&self.project_root)
72 }
73}
74
75pub fn sync_root_for(path: &Path) -> PathBuf {
83 find_git_root(path).unwrap_or_else(|| path.to_path_buf())
84}
85
86pub(crate) fn unix_now() -> i64 {
88 chrono::Utc::now().timestamp()
89}
90
91pub(crate) struct ProjectInfo {
96 pub branch: BranchId,
98 pub file_count: usize,
100 pub convention_count: usize,
102}
103
104pub(crate) fn load_project_info(db: &Database) -> ProjectInfo {
109 let conn = db.connection().clone();
110
111 let branch_repo = SqliteBranchRepository::new(conn.clone());
112 let branch = branch_repo.get_current_branch().unwrap_or_else(|_| {
113 tracing::debug!("Could not detect git branch from DB, defaulting to 'main'");
114 BranchId::from("main")
115 });
116
117 let file_repo = SqliteFileIRRepository::new(conn.clone());
118 let file_count = file_repo
119 .get_file_hashes_by_branch(&branch)
120 .map(|h| h.len())
121 .unwrap_or(0);
122
123 let node_repo = SqliteNodeRepository::new(conn);
124 let convention_count = node_repo
125 .find_by_branch(&branch)
126 .map(|nodes| nodes.len())
127 .unwrap_or(0);
128
129 ProjectInfo {
130 branch,
131 file_count,
132 convention_count,
133 }
134}
135
136pub(crate) fn count_files_any_schema(db: &Database, branch_id: &str) -> usize {
142 let conn = db.connection().clone();
143 let Ok(guard) = conn.lock() else { return 0 };
144 guard
145 .query_row(
146 "SELECT COUNT(*) FROM files_ir WHERE branch_id = ?1",
147 params![branch_id],
148 |row| row.get::<_, i64>(0),
149 )
150 .map(|n| n as usize)
151 .unwrap_or(0)
152}
153
154pub(crate) fn count_conventions(db: &Database, branch_id: &str) -> usize {
156 let conn = db.connection().clone();
157 let Ok(guard) = conn.lock() else { return 0 };
158 guard
159 .query_row(
160 "SELECT COUNT(*) FROM nodes WHERE branch_id = ?1",
161 params![branch_id],
162 |row| row.get::<_, i64>(0),
163 )
164 .map(|n| n as usize)
165 .unwrap_or(0)
166}
167
168pub(crate) fn submodule_ir_schema_is_current(db: &Database, branch_id: &str) -> bool {
175 let conn = db.connection().clone();
176 let Ok(guard) = conn.lock() else { return true };
177
178 let stale_count: i64 = guard
180 .query_row(
181 "SELECT COUNT(*) FROM files_ir
182 WHERE branch_id = ?1 AND ir_schema_version != ?2",
183 params![branch_id, i64::from(IR_SCHEMA_VERSION)],
184 |row| row.get(0),
185 )
186 .unwrap_or(0);
187
188 stale_count == 0
189}
190
191pub(crate) fn xdg_repos_dir() -> Result<PathBuf, CliError> {
193 let data_dir = dirs::data_dir().ok_or_else(|| CliError::CommandFailed {
194 command: "seshat".to_owned(),
195 reason: "could not determine XDG data directory".to_owned(),
196 })?;
197
198 Ok(data_dir.join("seshat").join("repos"))
199}
200
201pub(crate) fn project_name(path: &Path) -> String {
208 path.file_name()
209 .map(|n| n.to_string_lossy().to_string())
210 .unwrap_or_else(|| "unknown".to_owned())
211}
212
213pub(crate) fn resolve_submodule_db_path(
225 project_name: &str,
226 mount_path: &str,
227) -> Result<PathBuf, CliError> {
228 let repos_dir = xdg_repos_dir()?;
229 let db_path = repos_dir
230 .join(project_name)
231 .join(format!("{mount_path}.db"));
232
233 if let Some(parent) = db_path.parent() {
235 std::fs::create_dir_all(parent).map_err(|e| CliError::CommandFailed {
236 command: "scan".to_owned(),
237 reason: format!("failed to create submodule database directory: {e}"),
238 })?;
239 }
240
241 Ok(db_path)
242}
243
244const GIT_ROOT_MAX_ITERATIONS: u32 = 64;
246
247pub fn find_git_root(from: &Path) -> Option<PathBuf> {
256 let mut current = if from.is_absolute() {
257 from.to_path_buf()
258 } else {
259 std::env::current_dir().ok()?.join(from)
260 };
261
262 for _ in 0..GIT_ROOT_MAX_ITERATIONS {
263 let git_path = current.join(".git");
264 if git_path.is_dir() {
265 return Some(current);
266 }
267 if git_path.is_file() {
268 if let Ok(content) = std::fs::read_to_string(&git_path) {
269 if let Some(gitdir) = content.strip_prefix("gitdir: ") {
270 let gitdir_path = PathBuf::from(gitdir.trim());
271 let raw_resolved = if gitdir_path.is_absolute() {
272 gitdir_path
273 } else {
274 git_path.parent()?.join(gitdir_path)
275 };
276 let mut normalized = PathBuf::new();
278 for component in raw_resolved.components() {
279 match component {
280 std::path::Component::ParentDir => {
281 normalized.pop();
282 }
283 _ => {
284 normalized.push(component);
285 }
286 }
287 }
288 let mut candidate = normalized.clone();
291 for _ in 0..GIT_ROOT_MAX_ITERATIONS {
292 if let Some(parent) = candidate.parent() {
293 if parent.join("HEAD").exists() || parent.join("config").exists() {
294 if parent.file_name().map(|n| n == ".git").unwrap_or(false) {
296 return parent
297 .parent()
298 .map(PathBuf::from)
299 .or(Some(parent.to_path_buf()));
300 }
301 return Some(parent.to_path_buf());
302 }
303 if !candidate.pop() {
304 break;
305 }
306 } else {
307 break;
308 }
309 }
310 }
311 }
312 }
313 if !current.pop() {
314 return None;
315 }
316 }
317
318 tracing::warn!(
320 path = %from.display(),
321 "find_git_root reached iteration limit; possible symlink cycle"
322 );
323 None
324}
325
326pub fn detect_branch(path: &Path) -> String {
332 get_current_branch(path).unwrap_or_else(|| {
333 tracing::debug!(path = %path.display(), "Could not detect git branch, defaulting to 'main'");
334 "main".to_string()
335 })
336}
337
338pub fn get_current_branch(path: &Path) -> Option<String> {
348 read_head_file(path)
349}
350
351fn read_head_file(path: &Path) -> Option<String> {
357 let gitdir = resolve_gitdir(path)?;
358 read_head_in_gitdir(&gitdir)
359}
360
361fn resolve_gitdir(path: &Path) -> Option<PathBuf> {
364 let git_dir = find_git_dir(path)?;
365 match git_dir {
366 GitDir::Dir(dir) => Some(dir),
367 GitDir::File(file) => {
368 let content = std::fs::read_to_string(&file).ok()?;
369 let gitdir = content.strip_prefix("gitdir: ")?.trim();
370 let gitdir_path = PathBuf::from(gitdir);
371 if gitdir_path.is_absolute() {
372 Some(gitdir_path)
373 } else {
374 Some(file.parent()?.join(gitdir_path))
375 }
376 }
377 }
378}
379
380pub(crate) enum GitDir {
382 Dir(PathBuf),
383 File(PathBuf),
384}
385
386pub(crate) fn find_git_dir(path: &Path) -> Option<GitDir> {
387 let mut current = if path.is_absolute() {
388 path.to_path_buf()
389 } else {
390 std::env::current_dir().ok()?.join(path)
391 };
392
393 for _ in 0..GIT_ROOT_MAX_ITERATIONS {
394 let git_path = current.join(".git");
395 if git_path.is_dir() {
396 return Some(GitDir::Dir(git_path));
397 }
398 if git_path.is_file() {
399 return Some(GitDir::File(git_path));
400 }
401 if !current.pop() {
402 return None;
403 }
404 }
405
406 tracing::warn!(
407 path = %path.display(),
408 "find_git_dir reached iteration limit; possible symlink cycle"
409 );
410 None
411}
412
413fn read_head_in_gitdir(gitdir: &Path) -> Option<String> {
419 let content = std::fs::read_to_string(gitdir.join("HEAD")).ok()?;
420
421 if let Some(rest) = content.strip_prefix("ref: ") {
422 if let Some(branch) = rest.trim().strip_prefix("refs/heads/") {
423 return Some(branch.to_string());
424 }
425 }
426
427 let trimmed = content.trim();
430 if trimmed.len() >= 7 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
431 return Some(trimmed.to_string());
432 }
433
434 None
435}
436
437pub fn get_git_branches(path: &Path) -> Vec<String> {
442 let repo = match gix::open(path) {
443 Ok(r) => r,
444 Err(_) => return Vec::new(),
445 };
446
447 let mut branches = Vec::new();
448
449 if let Ok(all_refs) = repo.references() {
450 if let Ok(mut local_branches) = all_refs.local_branches() {
451 while let Some(Ok(entry)) = local_branches.next() {
452 let full_name = entry.name().as_bstr();
453 let name_str = full_name.to_str().unwrap_or("");
454 if let Some(short_name) = name_str.strip_prefix("refs/heads/") {
455 branches.push(short_name.to_string());
456 }
457 }
458 }
459 }
460
461 branches
462}
463
464fn is_valid_git_repo(path: &Path) -> bool {
468 gix::open(path).is_ok()
469}
470
471pub fn gc_branch_snapshots(db: &Database, repo_path: &Path) -> Result<Vec<String>, CliError> {
482 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
483
484 let db_branches = branch_repo
486 .list_branches()
487 .map_err(|e| CliError::CommandFailed {
488 command: "gc_branch_snapshots".to_owned(),
489 reason: format!("failed to list branches from database: {e}"),
490 })?;
491
492 if db_branches.is_empty() {
493 return Ok(Vec::new());
494 }
495
496 if !is_valid_git_repo(repo_path) {
498 tracing::warn!(
499 repo_path = %repo_path.display(),
500 "repo_path is not a valid git repository; skipping git branch comparison"
501 );
502 }
503
504 let git_branches = get_git_branches(repo_path);
506 let git_set: std::collections::HashSet<&str> =
507 git_branches.iter().map(|s| s.as_str()).collect();
508
509 let current_branch = get_current_branch(repo_path).unwrap_or_default();
511
512 let mut deleted = Vec::new();
513
514 for branch_id in &db_branches {
515 let name = &branch_id.0;
516
517 if PROTECTED_BRANCHES.contains(&name.as_str()) {
519 continue;
520 }
521
522 if name == ¤t_branch {
524 continue;
525 }
526
527 if git_set.contains(name.as_str()) {
529 continue;
530 }
531
532 tracing::info!(
534 branch = %name,
535 current_branch = %current_branch,
536 "Deleting orphan branch snapshot"
537 );
538
539 branch_repo
540 .delete_branch(branch_id)
541 .map_err(|e| CliError::CommandFailed {
542 command: "gc_branch_snapshots".to_owned(),
543 reason: format!("failed to delete branch '{name}': {e}"),
544 })?;
545
546 deleted.push(name.clone());
547 }
548
549 if !deleted.is_empty() {
550 tracing::info!(
551 deleted_count = deleted.len(),
552 deleted_branches = ?deleted,
553 "Branch snapshot garbage collection complete"
554 );
555 }
556
557 Ok(deleted)
558}
559
560pub(crate) fn list_available_projects(
564 repos_dir: &Path,
565) -> Result<Vec<(PathBuf, String)>, CliError> {
566 if !repos_dir.is_dir() {
567 return Ok(Vec::new());
568 }
569
570 let entries = std::fs::read_dir(repos_dir).map_err(|e| CliError::CommandFailed {
571 command: "seshat".to_owned(),
572 reason: format!("failed to read repos directory: {e}"),
573 })?;
574
575 let mut projects: Vec<(PathBuf, String)> = Vec::new();
576
577 for entry in entries {
578 let entry = entry.map_err(|e| CliError::CommandFailed {
579 command: "seshat".to_owned(),
580 reason: format!("failed to read directory entry: {e}"),
581 })?;
582
583 let path = entry.path();
584 if path.extension().is_some_and(|ext| ext == "db") {
585 let name = path
586 .file_stem()
587 .map(|s| s.to_string_lossy().to_string())
588 .unwrap_or_default();
589 if !name.is_empty() {
590 projects.push((path, name));
591 }
592 }
593 }
594
595 projects.sort_by(|a, b| a.1.cmp(&b.1));
596 Ok(projects)
597}
598
599fn read_project_root_from_db(db_path: &Path) -> Option<PathBuf> {
602 use seshat_storage::{Database, RepoMetadataRepository, SqliteRepoMetadataRepository};
603
604 let db = Database::open(db_path).ok()?;
605 let meta_repo = SqliteRepoMetadataRepository::new(db.connection().clone());
606 let root_str = match meta_repo.get("project_root") {
607 Ok(Some(s)) => s,
608 _ => return None,
609 };
610 Some(PathBuf::from(root_str))
611}
612
613fn identity_from_dir(input: &Path) -> Result<ResolvedProject, CliError> {
620 let canonical = input.canonicalize().unwrap_or_else(|_| input.to_path_buf());
621 let git_root = find_git_root(&canonical);
622 let name_source = git_root.as_deref().unwrap_or(&canonical);
623 let project_name = project_name(name_source);
624 let repos_dir = xdg_repos_dir()?;
625 let db_path = repos_dir.join(format!("{project_name}.db"));
626 Ok(ResolvedProject {
627 project_root: canonical,
628 git_root,
629 project_name,
630 db_path,
631 })
632}
633
634fn identity_from_db(
640 project_name: String,
641 db_path: PathBuf,
642 stored_root: Option<PathBuf>,
643) -> ResolvedProject {
644 let project_root = stored_root.unwrap_or_else(|| {
645 db_path
646 .parent()
647 .map(PathBuf::from)
648 .unwrap_or_else(|| PathBuf::from("."))
649 });
650 let git_root = find_git_root(&project_root);
651 ResolvedProject {
652 project_root,
653 git_root,
654 project_name,
655 db_path,
656 }
657}
658
659pub fn resolve_project(
676 explicit_path: Option<&Path>,
677 command_name: &str,
678) -> Result<ResolvedProject, CliError> {
679 if let Some(arg) = explicit_path {
681 if arg.is_dir() {
683 return identity_from_dir(arg);
684 }
685
686 let repos_dir = xdg_repos_dir()?;
688 let name = arg.to_string_lossy().to_string();
689 let by_name = repos_dir.join(format!("{name}.db"));
690 if by_name.is_file() {
691 let stored = read_project_root_from_db(&by_name);
692 return Ok(identity_from_db(name, by_name, stored));
693 }
694
695 let name_from_path = project_name(arg);
698 let by_path_name = repos_dir.join(format!("{name_from_path}.db"));
699 if by_path_name.is_file() {
700 let stored = read_project_root_from_db(&by_path_name);
701 return Ok(identity_from_db(name_from_path, by_path_name, stored));
702 }
703
704 return Err(CliError::CommandFailed {
706 command: command_name.to_owned(),
707 reason: format!(
708 "project '{}' has not been found.\n\
709 hint: run `seshat scan {}` first",
710 name,
711 arg.display()
712 ),
713 });
714 }
715
716 if let Ok(cwd) = std::env::current_dir() {
720 let identity = identity_from_dir(&cwd)?;
721 if identity.db_path.is_file() {
722 tracing::info!(
723 project = %identity.project_name,
724 "Auto-detected project from cwd"
725 );
726 }
727 return Ok(identity);
728 }
729
730 let repos_dir = xdg_repos_dir()?;
734 let projects = list_available_projects(&repos_dir)?;
735 match projects.len() {
736 0 => Err(CliError::CommandFailed {
737 command: command_name.to_owned(),
738 reason: "no scanned projects found.\n\
739 hint: run `seshat scan <path>` first to index a project"
740 .to_string(),
741 }),
742 1 => {
743 let (path, name) = &projects[0];
744 tracing::info!(project = %name, "Auto-selected only available project");
745 let stored = read_project_root_from_db(path);
746 Ok(identity_from_db(name.clone(), path.clone(), stored))
747 }
748 _ => {
749 let project_list = projects
750 .iter()
751 .map(|(_, name)| format!(" ‣ {name}"))
752 .collect::<Vec<_>>()
753 .join("\n");
754
755 Err(CliError::CommandFailed {
756 command: command_name.to_owned(),
757 reason: format!(
758 "could not determine which project to use.\n\n\
759 Available scanned projects:\n\
760 {project_list}\n\n\
761 hint: run from the project directory, or specify:\n\
762 \x20 seshat <command> <project-name>\n\
763 \x20 seshat <command> <path-to-project>"
764 ),
765 })
766 }
767 }
768}
769
770pub(crate) fn build_dangerous_cwd_hint() -> String {
777 concat!(
778 "Suggestions:\n",
779 " • Change to a real project directory: cd /path/to/your/project\n",
780 " • Index a specific path: seshat scan /path/to/project\n",
781 " • Bypass this guardrail by passing the path explicitly: seshat serve /path/to/project",
782 )
783 .to_owned()
784}
785
786pub(crate) fn build_repo_override_warning(project_root: &Path) -> String {
792 format!(
793 concat!(
794 "⚠️ Serving from a dangerous location: {}\n",
795 " This path is on the dangerous-cwd denylist (e.g. $HOME, ~/Library, /, drive roots).\n",
796 " Proceeding because an explicit repo path was passed. Watch memory usage on large trees.",
797 ),
798 project_root.display()
799 )
800}
801
802pub(crate) fn check_serve_dangerous_cwd(
809 explicit_repo: Option<&Path>,
810 additional: &[String],
811 cwd: &Path,
812 home: Option<&Path>,
813) -> Result<(), CliError> {
814 if explicit_repo.is_some() {
815 return Ok(());
816 }
817 if !crate::dangerous_path::is_dangerous_cwd_with_home(cwd, additional, home) {
818 return Ok(());
819 }
820 if let Some(git_root) = find_git_root(cwd) {
828 if !crate::dangerous_path::is_exact_denylist_entry(&git_root, additional, home) {
829 return Ok(());
830 }
831 tracing::warn!(
832 cwd = %cwd.display(),
833 git_root = %git_root.display(),
834 "found .git exactly at a denylist root; ignoring it for guard purposes"
835 );
836 }
837 Err(CliError::DangerousCwd {
838 path: cwd.to_path_buf(),
839 hint: build_dangerous_cwd_hint(),
840 })
841}
842
843pub(crate) fn check_repo_override_dangerous(
851 explicit_repo: Option<&Path>,
852 additional: &[String],
853 project_root: &Path,
854 home: Option<&Path>,
855) -> Option<String> {
856 explicit_repo?;
857 if !crate::dangerous_path::is_dangerous_cwd_with_home(project_root, additional, home) {
858 return None;
859 }
860 if let Some(git_root) = find_git_root(project_root) {
866 if !crate::dangerous_path::is_exact_denylist_entry(&git_root, additional, home) {
867 return None;
868 }
869 }
870 Some(build_repo_override_warning(project_root))
871}
872
873pub(crate) fn resolve_serve_db_or_project_root(
883 explicit_repo: Option<&Path>,
884 additional_denylist_paths: &[String],
885) -> Result<ServeTarget, CliError> {
886 if explicit_repo.is_none() {
892 let cwd = std::env::current_dir().map_err(|e| CliError::IoWithPath {
893 message: format!("could not read current working directory: {e}"),
894 path: PathBuf::from("."),
895 })?;
896 check_serve_dangerous_cwd(
897 explicit_repo,
898 additional_denylist_paths,
899 &cwd,
900 dirs::home_dir().as_deref(),
901 )?;
902 }
903
904 let resolved = resolve_project(explicit_repo, "serve")?;
905
906 if let Some(warning) = check_repo_override_dangerous(
913 explicit_repo,
914 additional_denylist_paths,
915 &resolved.project_root,
916 dirs::home_dir().as_deref(),
917 ) {
918 tracing::warn!("{warning}");
919 }
920
921 if resolved.db_path.exists() {
922 Ok(ServeTarget::ExistingDb {
923 db_path: resolved.db_path,
924 project_root: resolved.project_root,
925 })
926 } else {
927 Ok(ServeTarget::AutoScan {
928 project_root: resolved.project_root,
929 db_path: resolved.db_path,
930 })
931 }
932}
933
934#[cfg(test)]
935mod tests {
936 use super::*;
937 use std::fs;
938
939 struct CleanupDir(PathBuf);
940 impl Drop for CleanupDir {
941 fn drop(&mut self) {
942 let _ = fs::remove_dir_all(&self.0);
943 }
944 }
945
946 fn setup_repos_dir() -> (tempfile::TempDir, PathBuf) {
947 let tmp = tempfile::tempdir().expect("create temp dir");
948 let repos = tmp.path().join("seshat").join("repos");
949 fs::create_dir_all(&repos).expect("create repos dir");
950 (tmp, repos)
951 }
952
953 #[test]
954 fn project_name_extracts_last_component() {
955 assert_eq!(
956 project_name(Path::new("/Users/me/Projects/my-app")),
957 "my-app"
958 );
959 assert_eq!(project_name(Path::new("my-app")), "my-app");
960 assert_eq!(project_name(Path::new(".")), "unknown");
962 }
963
964 #[test]
965 fn find_git_root_finds_parent_with_dotgit() {
966 let tmp = tempfile::tempdir().expect("create temp dir");
967 let project = tmp.path().join("my-project");
968 let subdir = project.join("src").join("api");
969 fs::create_dir_all(&subdir).expect("create subdirs");
970 fs::create_dir(project.join(".git")).expect("create .git");
971
972 let root = find_git_root(&subdir);
973 assert_eq!(root, Some(project));
974 }
975
976 #[test]
977 fn find_git_root_returns_none_without_dotgit() {
978 let tmp = tempfile::tempdir().expect("create temp dir");
979 let subdir = tmp.path().join("no-git").join("src");
980 fs::create_dir_all(&subdir).expect("create subdirs");
981
982 assert!(find_git_root(&subdir).is_none());
983 }
984
985 #[test]
986 fn list_available_projects_returns_sorted() {
987 let (_tmp, repos) = setup_repos_dir();
988 fs::write(repos.join("zebra.db"), "").unwrap();
989 fs::write(repos.join("alpha.db"), "").unwrap();
990 fs::write(repos.join("middle.db"), "").unwrap();
991 fs::write(repos.join("notes.txt"), "").unwrap();
993
994 let projects = list_available_projects(&repos).unwrap();
995 let names: Vec<&str> = projects.iter().map(|(_, n)| n.as_str()).collect();
996 assert_eq!(names, vec!["alpha", "middle", "zebra"]);
997 }
998
999 #[test]
1000 fn list_available_projects_empty_dir() {
1001 let (_tmp, repos) = setup_repos_dir();
1002 let projects = list_available_projects(&repos).unwrap();
1003 assert!(projects.is_empty());
1004 }
1005
1006 #[test]
1007 fn list_available_projects_nonexistent_dir() {
1008 let projects = list_available_projects(Path::new("/nonexistent/path")).unwrap();
1009 assert!(projects.is_empty());
1010 }
1011
1012 #[test]
1013 fn submodule_ir_schema_is_current_empty_db_returns_true() {
1014 let tmp = tempfile::tempdir().expect("create temp dir");
1016 let db_path = tmp.path().join("sub.db");
1017 let db = Database::open(&db_path).expect("open");
1018 assert!(submodule_ir_schema_is_current(&db, "main"));
1019 }
1020
1021 #[test]
1022 fn submodule_ir_schema_is_current_detects_stale_rows() {
1023 use seshat_core::test_helpers::make_project_file;
1024 use seshat_storage::{FileIRRepository, SqliteFileIRRepository};
1025
1026 let tmp = tempfile::tempdir().expect("create temp dir");
1027 let db_path = tmp.path().join("sub.db");
1028 let db = Database::open(&db_path).expect("open");
1029
1030 let branch = BranchId::from("main");
1031 let file = make_project_file(seshat_core::Language::Rust);
1033 SqliteFileIRRepository::new(db.connection().clone())
1034 .upsert(&branch, &file, None)
1035 .expect("upsert");
1036
1037 assert!(submodule_ir_schema_is_current(&db, "main"));
1039
1040 {
1042 let guard = db.connection().lock().expect("lock");
1043 guard
1044 .execute(
1045 "UPDATE files_ir SET ir_schema_version = 0 WHERE branch_id = 'main'",
1046 [],
1047 )
1048 .expect("update");
1049 }
1050
1051 assert!(!submodule_ir_schema_is_current(&db, "main"));
1053 }
1054
1055 #[test]
1056 fn resolve_submodule_db_path_creates_parent_dirs() {
1057 let project = "db-test-submod-nested";
1058 let result = resolve_submodule_db_path(project, "libs/shared");
1059 assert!(result.is_ok());
1060 let path = result.unwrap();
1061 assert!(
1062 path.ends_with(format!("{project}/libs/shared.db")),
1063 "Expected path ending with {project}/libs/shared.db, got: {}",
1064 path.display()
1065 );
1066 if let Ok(repos) = xdg_repos_dir() {
1068 let _ = fs::remove_dir_all(repos.join(project));
1069 }
1070 }
1071
1072 #[test]
1073 fn resolve_serve_db_or_project_root_returns_auto_scan_when_no_db() {
1074 let tmp_dir = tempfile::tempdir().expect("create temp dir");
1075 let project_dir = tmp_dir.path().join("new-project");
1076 fs::create_dir_all(&project_dir).unwrap();
1077
1078 let expected_root = std::fs::canonicalize(&project_dir).unwrap();
1082
1083 let result = resolve_serve_db_or_project_root(Some(&project_dir), &[]);
1085 assert!(result.is_ok());
1086 match result.unwrap() {
1087 ServeTarget::AutoScan {
1088 project_root,
1089 db_path,
1090 } => {
1091 assert_eq!(project_root, expected_root);
1092 assert!(db_path.to_string_lossy().ends_with("new-project.db"));
1093 }
1094 ServeTarget::ExistingDb { .. } => {
1095 panic!("Expected AutoScan, got ExistingDb");
1096 }
1097 }
1098 }
1099
1100 #[test]
1101 fn resolve_serve_db_or_project_root_returns_existing_db_when_present() {
1102 let repos_dir = xdg_repos_dir().expect("repos dir");
1104 fs::create_dir_all(&repos_dir).expect("create repos dir");
1107 let _cleanup = CleanupDir(repos_dir.join("_test_serve_existing"));
1108
1109 let project_name = "_test_serve_existing";
1110 let db_path = repos_dir.join(format!("{project_name}.db"));
1111 fs::write(&db_path, "").unwrap();
1112
1113 let project_dir = tempfile::tempdir().expect("temp dir");
1114
1115 let result = resolve_serve_db_or_project_root(
1116 Some(project_dir.path().join(project_name).as_path()),
1117 &[],
1118 );
1119 if let Ok(ServeTarget::ExistingDb {
1123 db_path: resolved,
1124 project_root,
1125 }) = result
1126 {
1127 assert!(
1128 resolved
1129 .to_string_lossy()
1130 .ends_with("_test_serve_existing.db")
1131 );
1132 assert_eq!(project_root, repos_dir);
1135 }
1136 }
1137
1138 #[test]
1139 fn resolve_serve_db_or_project_root_uses_cwd_when_no_git() {
1140 let tmp_dir = tempfile::tempdir().expect("create temp dir");
1141 let project_dir = tmp_dir.path().join("no-git-project");
1142 fs::create_dir_all(&project_dir).unwrap();
1143
1144 let expected_root = std::fs::canonicalize(&project_dir).unwrap();
1145
1146 let result = resolve_serve_db_or_project_root(Some(&project_dir), &[]);
1148 assert!(result.is_ok());
1149 match result.unwrap() {
1150 ServeTarget::AutoScan { project_root, .. } => {
1151 assert_eq!(project_root, expected_root);
1152 }
1153 ServeTarget::ExistingDb { .. } => {
1154 panic!("Expected AutoScan, got ExistingDb");
1155 }
1156 }
1157 }
1158
1159 #[test]
1160 fn existing_db_project_root_is_used_for_branch_detection() {
1161 let tmp_dir = tempfile::tempdir().expect("create temp dir");
1162 let project_dir = tmp_dir.path().join("my-project");
1163 fs::create_dir_all(&project_dir).unwrap();
1164
1165 let git_output = std::process::Command::new("git")
1167 .arg("init")
1168 .arg("-b")
1169 .arg("feature-x")
1170 .current_dir(&project_dir)
1171 .output()
1172 .expect("git init");
1173 assert!(git_output.status.success(), "git init failed");
1174
1175 let repos_dir = xdg_repos_dir().expect("repos dir");
1177 fs::create_dir_all(&repos_dir).expect("create repos dir");
1179 let db_path = repos_dir.join("my-project.db");
1180 let _cleanup = CleanupDir(db_path.clone());
1181 fs::write(&db_path, "").unwrap();
1182
1183 let result = resolve_serve_db_or_project_root(Some(&project_dir), &[]);
1185 assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
1186
1187 let (resolved_root, db_file) = match result.unwrap() {
1188 ServeTarget::ExistingDb {
1189 project_root,
1190 db_path,
1191 } => (project_root, db_path),
1192 _ => panic!("Expected ExistingDb"),
1193 };
1194
1195 let expected_root = std::fs::canonicalize(&project_dir).unwrap();
1198 assert_eq!(resolved_root, expected_root);
1199 assert!(db_file.to_string_lossy().ends_with("my-project.db"));
1200
1201 let branch = detect_branch(&resolved_root);
1203 assert_eq!(branch.as_str(), "feature-x");
1204 }
1205
1206 #[test]
1207 fn find_git_root_handles_worktree_gitfile() {
1208 let tmp = tempfile::tempdir().expect("create temp dir");
1209 let main_project = tmp.path().join("main-repo");
1210 fs::create_dir_all(&main_project).expect("create main project");
1211 fs::write(main_project.join("HEAD"), "ref: refs/heads/main").expect("write HEAD");
1213
1214 let worktree = tmp.path().join("worktree");
1215 fs::create_dir_all(&worktree).expect("create worktree");
1216
1217 let main_git = main_project.join(".git");
1218 let rel = main_git.strip_prefix(worktree.parent().unwrap()).unwrap();
1219 let gitdir_rel = PathBuf::from("../").join(rel);
1220 let gitdir_content = format!("gitdir: {}\n", gitdir_rel.display());
1221 fs::write(worktree.join(".git"), gitdir_content).expect("write .git file");
1222
1223 let result = find_git_root(&worktree);
1224 assert_eq!(result, Some(main_project));
1225 }
1226
1227 #[test]
1228 fn find_git_root_handles_nested_worktree() {
1229 let tmp = tempfile::tempdir().expect("create temp dir");
1230 let main_project = tmp.path().join("main-project");
1231 fs::create_dir_all(&main_project).expect("create main project");
1232 fs::create_dir(main_project.join(".git")).expect("create .git dir");
1233
1234 let worktree = main_project.join("worktree");
1235 fs::create_dir_all(&worktree).expect("create worktree");
1236
1237 let rel = main_project
1238 .strip_prefix(worktree.parent().unwrap())
1239 .unwrap();
1240 let gitdir_content = format!("gitdir: {}\n", rel.display());
1241 fs::write(worktree.join(".git"), gitdir_content).expect("write .git file");
1242
1243 let subdir = worktree.join("src").join("api");
1244 fs::create_dir_all(&subdir).expect("create subdir");
1245
1246 let root = find_git_root(&subdir);
1247 assert_eq!(root, Some(main_project));
1248 }
1249
1250 #[test]
1251 fn get_current_branch_from_git_repo() {
1252 let dir = tempfile::tempdir().expect("tempdir");
1253 let repo = dir.path().join("test-repo");
1254 fs::create_dir_all(&repo).expect("create repo");
1255
1256 std::process::Command::new("git")
1257 .args(["init", "-b", "main"])
1258 .current_dir(&repo)
1259 .output()
1260 .expect("git init");
1261
1262 std::process::Command::new("git")
1263 .args(["config", "user.email", "test@test.com"])
1264 .current_dir(&repo)
1265 .output()
1266 .expect("git config email");
1267
1268 std::process::Command::new("git")
1269 .args(["config", "user.name", "Test User"])
1270 .current_dir(&repo)
1271 .output()
1272 .expect("git config name");
1273
1274 fs::write(repo.join("README.md"), "# Test").expect("write file");
1275 std::process::Command::new("git")
1276 .args(["add", "."])
1277 .current_dir(&repo)
1278 .output()
1279 .expect("git add");
1280 std::process::Command::new("git")
1281 .args(["commit", "-m", "initial"])
1282 .current_dir(&repo)
1283 .output()
1284 .expect("git commit");
1285
1286 let branch = get_current_branch(&repo);
1287 assert_eq!(branch, Some("main".to_string()));
1288 }
1289
1290 #[test]
1291 fn get_current_branch_worktree() {
1292 let dir = tempfile::tempdir().expect("tempdir");
1293 let main_repo = dir.path().join("main-repo");
1294 fs::create_dir_all(&main_repo).expect("create main repo");
1295
1296 std::process::Command::new("git")
1297 .args(["init", "-b", "main"])
1298 .current_dir(&main_repo)
1299 .output()
1300 .expect("git init");
1301
1302 std::process::Command::new("git")
1303 .args(["config", "user.email", "test@test.com"])
1304 .current_dir(&main_repo)
1305 .output()
1306 .expect("git config email");
1307
1308 std::process::Command::new("git")
1309 .args(["config", "user.name", "Test User"])
1310 .current_dir(&main_repo)
1311 .output()
1312 .expect("git config name");
1313
1314 fs::write(main_repo.join("README.md"), "# Main").expect("write");
1315 std::process::Command::new("git")
1316 .args(["add", "."])
1317 .current_dir(&main_repo)
1318 .output()
1319 .expect("git add");
1320 std::process::Command::new("git")
1321 .args(["commit", "-m", "initial"])
1322 .current_dir(&main_repo)
1323 .output()
1324 .expect("git commit");
1325
1326 let worktree = main_repo.join("worktree");
1328 let status = std::process::Command::new("git")
1329 .args(["worktree", "add", "../worktree"])
1330 .current_dir(&main_repo)
1331 .status()
1332 .expect("git worktree add");
1333 assert!(status.success(), "git worktree add failed");
1334
1335 let branch = get_current_branch(&worktree);
1336 assert_eq!(branch, Some("main".to_string()));
1337 }
1338
1339 #[test]
1340 fn get_current_branch_detached_head() {
1341 let dir = tempfile::tempdir().expect("tempdir");
1342 let repo = dir.path().join("test-repo");
1343 fs::create_dir_all(&repo).expect("create repo");
1344
1345 std::process::Command::new("git")
1346 .args(["init", "-b", "main"])
1347 .current_dir(&repo)
1348 .output()
1349 .expect("git init");
1350
1351 std::process::Command::new("git")
1352 .args(["config", "user.email", "test@test.com"])
1353 .current_dir(&repo)
1354 .output()
1355 .expect("git config email");
1356
1357 std::process::Command::new("git")
1358 .args(["config", "user.name", "Test User"])
1359 .current_dir(&repo)
1360 .output()
1361 .expect("git config name");
1362
1363 fs::write(repo.join("file.txt"), "content").expect("write");
1364 std::process::Command::new("git")
1365 .args(["add", "."])
1366 .current_dir(&repo)
1367 .output()
1368 .expect("git add");
1369 std::process::Command::new("git")
1370 .args(["commit", "-m", "initial"])
1371 .current_dir(&repo)
1372 .output()
1373 .expect("git commit");
1374
1375 std::process::Command::new("git")
1377 .args(["checkout", "--detach", "HEAD"])
1378 .current_dir(&repo)
1379 .output()
1380 .expect("git checkout detach");
1381
1382 let branch = get_current_branch(&repo);
1383 assert!(
1384 branch
1385 .as_deref()
1386 .is_some_and(|b| b.len() == 40 && b.chars().all(|c| c.is_ascii_hexdigit())),
1387 "detached HEAD should return commit hash, got: {:?}",
1388 branch
1389 );
1390 }
1391
1392 #[test]
1393 fn gc_deletes_orphan_branches() {
1394 let git_dir = tempfile::tempdir().expect("tempdir");
1396 let repo = git_dir.path().join("test-repo");
1397 fs::create_dir_all(&repo).expect("create repo");
1398 std::process::Command::new("git")
1399 .args(["init", "-b", "main"])
1400 .current_dir(&repo)
1401 .output()
1402 .expect("git init");
1403 std::process::Command::new("git")
1404 .args(["config", "user.email", "test@test.com"])
1405 .current_dir(&repo)
1406 .output()
1407 .expect("git config email");
1408 std::process::Command::new("git")
1409 .args(["config", "user.name", "Test User"])
1410 .current_dir(&repo)
1411 .output()
1412 .expect("git config name");
1413 fs::write(repo.join("README.md"), "# Test").expect("write file");
1414 std::process::Command::new("git")
1415 .args(["add", "."])
1416 .current_dir(&repo)
1417 .output()
1418 .expect("git add");
1419 std::process::Command::new("git")
1420 .args(["commit", "-m", "initial"])
1421 .current_dir(&repo)
1422 .output()
1423 .expect("git commit");
1424 std::process::Command::new("git")
1426 .args(["checkout", "-b", "feature"])
1427 .current_dir(&repo)
1428 .output()
1429 .expect("git checkout feature");
1430 fs::write(repo.join("feature.txt"), "feat").expect("write");
1431 std::process::Command::new("git")
1432 .args(["add", "."])
1433 .current_dir(&repo)
1434 .output()
1435 .expect("git add");
1436 std::process::Command::new("git")
1437 .args(["commit", "-m", "feature"])
1438 .current_dir(&repo)
1439 .output()
1440 .expect("git commit");
1441 std::process::Command::new("git")
1442 .args(["checkout", "main"])
1443 .current_dir(&repo)
1444 .output()
1445 .expect("git checkout main");
1446
1447 let db_dir = tempfile::tempdir().expect("tempdir");
1450 let db_path = db_dir.path().join("test.db");
1451 let db = Database::open(&db_path).expect("open db");
1452 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
1453 let file_repo = SqliteFileIRRepository::new(db.connection().clone());
1454
1455 branch_repo
1456 .switch_branch(&BranchId::from("main"))
1457 .expect("switch to main");
1458
1459 use seshat_core::test_helpers::make_project_file;
1461 let file = make_project_file(seshat_core::Language::Rust);
1462 file_repo
1463 .upsert(&BranchId::from("main"), &file, None)
1464 .expect("upsert file");
1465
1466 branch_repo
1468 .create_snapshot(&BranchId::from("main"), &BranchId::from("feature"))
1469 .expect("snapshot feature");
1470 branch_repo
1471 .create_snapshot(&BranchId::from("main"), &BranchId::from("orphan-branch"))
1472 .expect("snapshot orphan");
1473
1474 let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
1476 assert_eq!(deleted, vec!["orphan-branch"]);
1477
1478 let remaining = branch_repo.list_branches().expect("list branches");
1480 let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
1481 assert!(names.contains(&"main"));
1482 assert!(names.contains(&"feature"));
1483 assert!(!names.contains(&"orphan-branch"));
1484 }
1485
1486 #[test]
1487 fn gc_preserves_current_branch() {
1488 let git_dir = tempfile::tempdir().expect("tempdir");
1490 let repo = git_dir.path().join("test-repo");
1491 fs::create_dir_all(&repo).expect("create repo");
1492 std::process::Command::new("git")
1493 .args(["init", "-b", "main"])
1494 .current_dir(&repo)
1495 .output()
1496 .expect("git init");
1497
1498 let db_dir = tempfile::tempdir().expect("tempdir");
1502 let db_path = db_dir.path().join("test.db");
1503 let db = Database::open(&db_path).expect("open db");
1504 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
1505 let file_repo = SqliteFileIRRepository::new(db.connection().clone());
1506
1507 branch_repo
1508 .switch_branch(&BranchId::from("main"))
1509 .expect("switch to main");
1510
1511 use seshat_core::test_helpers::make_project_file;
1513 let file = make_project_file(seshat_core::Language::Rust);
1514 file_repo
1515 .upsert(&BranchId::from("main"), &file, None)
1516 .expect("upsert file");
1517
1518 branch_repo
1520 .create_snapshot(&BranchId::from("main"), &BranchId::from("some-branch"))
1521 .expect("snapshot some-branch");
1522
1523 let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
1526 assert!(!deleted.contains(&"main".to_string()));
1527
1528 assert!(deleted.contains(&"some-branch".to_string()));
1530
1531 let remaining = branch_repo.list_branches().expect("list branches");
1533 let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
1534 assert!(names.contains(&"main"));
1535 assert!(!names.contains(&"some-branch"));
1536 }
1537
1538 #[test]
1539 fn gc_preserves_main() {
1540 let git_dir = tempfile::tempdir().expect("tempdir");
1542 let repo = git_dir.path().join("test-repo");
1543 fs::create_dir_all(&repo).expect("create repo");
1544 std::process::Command::new("git")
1545 .args(["init", "-b", "main"])
1546 .current_dir(&repo)
1547 .output()
1548 .expect("git init");
1549
1550 let db_dir = tempfile::tempdir().expect("tempdir");
1552 let db_path = db_dir.path().join("test.db");
1553 let db = Database::open(&db_path).expect("open db");
1554 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
1555 let file_repo = SqliteFileIRRepository::new(db.connection().clone());
1556
1557 branch_repo
1558 .switch_branch(&BranchId::from("main"))
1559 .expect("switch to main");
1560
1561 use seshat_core::test_helpers::make_project_file;
1563 let file = make_project_file(seshat_core::Language::Rust);
1564 file_repo
1565 .upsert(&BranchId::from("main"), &file, None)
1566 .expect("upsert file");
1567
1568 branch_repo
1570 .create_snapshot(&BranchId::from("main"), &BranchId::from("some-branch"))
1571 .expect("snapshot some-branch");
1572
1573 let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
1575 assert!(!deleted.contains(&"main".to_string()));
1576
1577 let remaining = branch_repo.list_branches().expect("list branches");
1579 let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
1580 assert!(names.contains(&"main"));
1581 }
1582
1583 #[test]
1584 fn gc_preserves_master() {
1585 let git_dir = tempfile::tempdir().expect("tempdir");
1587 let repo = git_dir.path().join("test-repo");
1588 fs::create_dir_all(&repo).expect("create repo");
1589 std::process::Command::new("git")
1590 .args(["init", "-b", "main"])
1591 .current_dir(&repo)
1592 .output()
1593 .expect("git init");
1594
1595 let db_dir = tempfile::tempdir().expect("tempdir");
1597 let db_path = db_dir.path().join("test.db");
1598 let db = Database::open(&db_path).expect("open db");
1599 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
1600 let file_repo = SqliteFileIRRepository::new(db.connection().clone());
1601
1602 branch_repo
1603 .switch_branch(&BranchId::from("master"))
1604 .expect("switch to master");
1605
1606 use seshat_core::test_helpers::make_project_file;
1608 let file = make_project_file(seshat_core::Language::Rust);
1609 file_repo
1610 .upsert(&BranchId::from("master"), &file, None)
1611 .expect("upsert file");
1612
1613 branch_repo
1615 .create_snapshot(&BranchId::from("master"), &BranchId::from("some-branch"))
1616 .expect("snapshot some-branch");
1617
1618 let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
1620 assert!(!deleted.contains(&"master".to_string()));
1621
1622 let remaining = branch_repo.list_branches().expect("list branches");
1624 let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
1625 assert!(names.contains(&"master"));
1626 }
1627
1628 #[test]
1629 fn gc_preserves_current_branch_not_in_git() {
1630 let git_dir = tempfile::tempdir().expect("tempdir");
1632 let repo = git_dir.path().join("test-repo");
1633 fs::create_dir_all(&repo).expect("create repo");
1634 std::process::Command::new("git")
1635 .args(["init", "-b", "main"])
1636 .current_dir(&repo)
1637 .output()
1638 .expect("git init");
1639 std::process::Command::new("git")
1640 .args(["config", "user.email", "test@test.com"])
1641 .current_dir(&repo)
1642 .output()
1643 .expect("git config email");
1644 std::process::Command::new("git")
1645 .args(["config", "user.name", "Test User"])
1646 .current_dir(&repo)
1647 .output()
1648 .expect("git config name");
1649 fs::write(repo.join("README.md"), "# Test").expect("write file");
1650 std::process::Command::new("git")
1651 .args(["add", "."])
1652 .current_dir(&repo)
1653 .output()
1654 .expect("git add");
1655 std::process::Command::new("git")
1656 .args(["commit", "-m", "initial"])
1657 .current_dir(&repo)
1658 .output()
1659 .expect("git commit");
1660 std::process::Command::new("git")
1661 .args(["checkout", "-b", "feature"])
1662 .current_dir(&repo)
1663 .output()
1664 .expect("git checkout feature");
1665 std::process::Command::new("git")
1667 .args(["branch", "-D", "feature"])
1668 .current_dir(&repo)
1669 .output()
1670 .expect("git branch -D feature");
1671 std::process::Command::new("git")
1673 .args(["checkout", "main"])
1674 .current_dir(&repo)
1675 .output()
1676 .expect("git checkout main");
1677
1678 let db_dir = tempfile::tempdir().expect("tempdir");
1680 let db_path = db_dir.path().join("test.db");
1681 let db = Database::open(&db_path).expect("open db");
1682 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
1683 let file_repo = SqliteFileIRRepository::new(db.connection().clone());
1684
1685 branch_repo
1686 .switch_branch(&BranchId::from("main"))
1687 .expect("switch to main");
1688
1689 use seshat_core::test_helpers::make_project_file;
1690 let file = make_project_file(seshat_core::Language::Rust);
1691 file_repo
1692 .upsert(&BranchId::from("main"), &file, None)
1693 .expect("upsert file");
1694
1695 branch_repo
1697 .create_snapshot(&BranchId::from("main"), &BranchId::from("feature-branch"))
1698 .expect("snapshot feature-branch");
1699
1700 let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
1703 assert!(
1704 !deleted.contains(&"main".to_string()),
1705 "main should be preserved as current branch"
1706 );
1707 assert!(
1708 deleted.contains(&"feature-branch".to_string()),
1709 "feature-branch should be deleted (not current, not in git, not protected)"
1710 );
1711
1712 let remaining = branch_repo.list_branches().expect("list branches");
1713 let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
1714 assert!(names.contains(&"main"));
1715 assert!(!names.contains(&"feature-branch"));
1716 }
1717
1718 #[test]
1719 fn gc_handles_detached_head() {
1720 let git_dir = tempfile::tempdir().expect("tempdir");
1722 let repo = git_dir.path().join("test-repo");
1723 fs::create_dir_all(&repo).expect("create repo");
1724 std::process::Command::new("git")
1725 .args(["init", "-b", "main"])
1726 .current_dir(&repo)
1727 .output()
1728 .expect("git init");
1729 std::process::Command::new("git")
1730 .args(["config", "user.email", "test@test.com"])
1731 .current_dir(&repo)
1732 .output()
1733 .expect("git config email");
1734 std::process::Command::new("git")
1735 .args(["config", "user.name", "Test User"])
1736 .current_dir(&repo)
1737 .output()
1738 .expect("git config name");
1739 fs::write(repo.join("README.md"), "# Test").expect("write file");
1740 std::process::Command::new("git")
1741 .args(["add", "."])
1742 .current_dir(&repo)
1743 .output()
1744 .expect("git add");
1745 std::process::Command::new("git")
1746 .args(["commit", "-m", "initial"])
1747 .current_dir(&repo)
1748 .output()
1749 .expect("git commit");
1750 std::process::Command::new("git")
1752 .args(["checkout", "--detach", "HEAD"])
1753 .current_dir(&repo)
1754 .output()
1755 .expect("git checkout detach");
1756
1757 let db_dir = tempfile::tempdir().expect("tempdir");
1759 let db_path = db_dir.path().join("test.db");
1760 let db = Database::open(&db_path).expect("open db");
1761 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
1762 let file_repo = SqliteFileIRRepository::new(db.connection().clone());
1763
1764 branch_repo
1765 .switch_branch(&BranchId::from("main"))
1766 .expect("switch to main");
1767
1768 use seshat_core::test_helpers::make_project_file;
1769 let file = make_project_file(seshat_core::Language::Rust);
1770 file_repo
1771 .upsert(&BranchId::from("main"), &file, None)
1772 .expect("upsert file");
1773
1774 branch_repo
1775 .create_snapshot(&BranchId::from("main"), &BranchId::from("some-branch"))
1776 .expect("snapshot some-branch");
1777
1778 let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
1781 assert!(
1782 !deleted.contains(&"main".to_string()),
1783 "main should be preserved even in detached HEAD"
1784 );
1785 assert!(
1786 deleted.contains(&"some-branch".to_string()),
1787 "some-branch should be deleted"
1788 );
1789
1790 let remaining = branch_repo.list_branches().expect("list branches");
1791 let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
1792 assert!(names.contains(&"main"));
1793 assert!(!names.contains(&"some-branch"));
1794 }
1795
1796 #[test]
1797 fn gc_deletes_all_orphans() {
1798 let git_dir = tempfile::tempdir().expect("tempdir");
1800 let repo = git_dir.path().join("test-repo");
1801 fs::create_dir_all(&repo).expect("create repo");
1802 std::process::Command::new("git")
1803 .args(["init", "-b", "main"])
1804 .current_dir(&repo)
1805 .output()
1806 .expect("git init");
1807 std::process::Command::new("git")
1808 .args(["config", "user.email", "test@test.com"])
1809 .current_dir(&repo)
1810 .output()
1811 .expect("git config email");
1812 std::process::Command::new("git")
1813 .args(["config", "user.name", "Test User"])
1814 .current_dir(&repo)
1815 .output()
1816 .expect("git config name");
1817 fs::write(repo.join("README.md"), "# Test").expect("write file");
1818 std::process::Command::new("git")
1819 .args(["add", "."])
1820 .current_dir(&repo)
1821 .output()
1822 .expect("git add");
1823 std::process::Command::new("git")
1824 .args(["commit", "-m", "initial"])
1825 .current_dir(&repo)
1826 .output()
1827 .expect("git commit");
1828
1829 let db_dir = tempfile::tempdir().expect("tempdir");
1831 let db_path = db_dir.path().join("test.db");
1832 let db = Database::open(&db_path).expect("open db");
1833 let branch_repo = SqliteBranchRepository::new(db.connection().clone());
1834 let file_repo = SqliteFileIRRepository::new(db.connection().clone());
1835
1836 branch_repo
1837 .switch_branch(&BranchId::from("main"))
1838 .expect("switch to main");
1839
1840 use seshat_core::test_helpers::make_project_file;
1841 let file = make_project_file(seshat_core::Language::Rust);
1842 file_repo
1843 .upsert(&BranchId::from("main"), &file, None)
1844 .expect("upsert file");
1845
1846 branch_repo
1847 .create_snapshot(&BranchId::from("main"), &BranchId::from("orphan-1"))
1848 .expect("snapshot orphan-1");
1849 branch_repo
1850 .create_snapshot(&BranchId::from("main"), &BranchId::from("orphan-2"))
1851 .expect("snapshot orphan-2");
1852 branch_repo
1853 .create_snapshot(&BranchId::from("main"), &BranchId::from("orphan-3"))
1854 .expect("snapshot orphan-3");
1855
1856 let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
1858 assert_eq!(deleted.len(), 3, "should delete all 3 orphans");
1859 assert!(deleted.contains(&"orphan-1".to_string()));
1860 assert!(deleted.contains(&"orphan-2".to_string()));
1861 assert!(deleted.contains(&"orphan-3".to_string()));
1862 assert!(!deleted.contains(&"main".to_string()));
1863
1864 let remaining = branch_repo.list_branches().expect("list branches");
1865 let names: Vec<&str> = remaining.iter().map(|b| b.0.as_str()).collect();
1866 assert_eq!(names.len(), 1);
1867 assert!(names.contains(&"main"));
1868 assert!(!names.contains(&"orphan-1"));
1869 assert!(!names.contains(&"orphan-2"));
1870 assert!(!names.contains(&"orphan-3"));
1871 }
1872
1873 #[test]
1874 fn detect_branch_normal_repo() {
1875 let dir = tempfile::tempdir().expect("tempdir");
1876 let repo = dir.path().join("test-repo");
1877 fs::create_dir_all(&repo).expect("create repo");
1878 std::process::Command::new("git")
1879 .args(["init", "-b", "main"])
1880 .current_dir(&repo)
1881 .output()
1882 .expect("git init");
1883 std::process::Command::new("git")
1884 .args(["config", "user.email", "test@test.com"])
1885 .current_dir(&repo)
1886 .output()
1887 .expect("git config email");
1888 std::process::Command::new("git")
1889 .args(["config", "user.name", "Test User"])
1890 .current_dir(&repo)
1891 .output()
1892 .expect("git config name");
1893 fs::write(repo.join("README.md"), "# Test").expect("write file");
1894 std::process::Command::new("git")
1895 .args(["add", "."])
1896 .current_dir(&repo)
1897 .output()
1898 .expect("git add");
1899 std::process::Command::new("git")
1900 .args(["commit", "-m", "initial"])
1901 .current_dir(&repo)
1902 .output()
1903 .expect("git commit");
1904
1905 let branch = detect_branch(&repo);
1906 assert_eq!(branch, "main");
1907 }
1908
1909 #[test]
1910 fn detect_branch_worktree_file() {
1911 let dir = tempfile::tempdir().expect("tempdir");
1912 let main_repo = dir.path().join("main-repo");
1913 fs::create_dir_all(&main_repo).expect("create main repo");
1914 std::process::Command::new("git")
1915 .args(["init", "-b", "main"])
1916 .current_dir(&main_repo)
1917 .output()
1918 .expect("git init");
1919 std::process::Command::new("git")
1920 .args(["config", "user.email", "test@test.com"])
1921 .current_dir(&main_repo)
1922 .output()
1923 .expect("git config email");
1924 std::process::Command::new("git")
1925 .args(["config", "user.name", "Test User"])
1926 .current_dir(&main_repo)
1927 .output()
1928 .expect("git config name");
1929 fs::write(main_repo.join("README.md"), "# Main").expect("write");
1930 std::process::Command::new("git")
1931 .args(["add", "."])
1932 .current_dir(&main_repo)
1933 .output()
1934 .expect("git add");
1935 std::process::Command::new("git")
1936 .args(["commit", "-m", "initial"])
1937 .current_dir(&main_repo)
1938 .output()
1939 .expect("git commit");
1940 std::process::Command::new("git")
1942 .args(["branch", "wt-test-branch-1"])
1943 .current_dir(&main_repo)
1944 .output()
1945 .expect("git branch wt-test-branch-1");
1946
1947 let worktree = dir.path().join("wt-on-test");
1948 let status = std::process::Command::new("git")
1949 .args([
1950 "worktree",
1951 "add",
1952 worktree.to_str().unwrap(),
1953 "wt-test-branch-1",
1954 ])
1955 .current_dir(&main_repo)
1956 .status()
1957 .expect("git worktree add wt-test-branch-1");
1958 assert!(status.success(), "git worktree add wt-test-branch-1 failed");
1959
1960 let branch = detect_branch(&worktree);
1961 assert_eq!(branch, "wt-test-branch-1");
1962 }
1963
1964 #[test]
1965 fn detect_branch_worktree_nested() {
1966 let dir = tempfile::tempdir().expect("tempdir");
1967 let main_repo = dir.path().join("main-repo");
1968 fs::create_dir_all(&main_repo).expect("create main repo");
1969 std::process::Command::new("git")
1970 .args(["init", "-b", "main"])
1971 .current_dir(&main_repo)
1972 .output()
1973 .expect("git init");
1974 std::process::Command::new("git")
1975 .args(["config", "user.email", "test@test.com"])
1976 .current_dir(&main_repo)
1977 .output()
1978 .expect("git config email");
1979 std::process::Command::new("git")
1980 .args(["config", "user.name", "Test User"])
1981 .current_dir(&main_repo)
1982 .output()
1983 .expect("git config name");
1984 fs::write(main_repo.join("README.md"), "# Main").expect("write");
1985 std::process::Command::new("git")
1986 .args(["add", "."])
1987 .current_dir(&main_repo)
1988 .output()
1989 .expect("git add");
1990 std::process::Command::new("git")
1991 .args(["commit", "-m", "initial"])
1992 .current_dir(&main_repo)
1993 .output()
1994 .expect("git commit");
1995 std::process::Command::new("git")
1996 .args(["branch", "wt-test-branch-2"])
1997 .current_dir(&main_repo)
1998 .output()
1999 .expect("git branch wt-test-branch-2");
2000
2001 let worktree = dir.path().join("wt-nested-on-test");
2002 let status = std::process::Command::new("git")
2003 .args([
2004 "worktree",
2005 "add",
2006 worktree.to_str().unwrap(),
2007 "wt-test-branch-2",
2008 ])
2009 .current_dir(&main_repo)
2010 .status()
2011 .expect("git worktree add wt-test-branch-2");
2012 assert!(status.success(), "git worktree add wt-test-branch-2 failed");
2013
2014 let subdir = worktree.join("src").join("api");
2015 fs::create_dir_all(&subdir).expect("create subdir");
2016
2017 let branch = detect_branch(&subdir);
2018 assert_eq!(branch, "wt-test-branch-2");
2019 }
2020
2021 #[test]
2022 fn detect_branch_detached_head() {
2023 let dir = tempfile::tempdir().expect("tempdir");
2024 let repo = dir.path().join("test-repo");
2025 fs::create_dir_all(&repo).expect("create repo");
2026 std::process::Command::new("git")
2027 .args(["init", "-b", "main"])
2028 .current_dir(&repo)
2029 .output()
2030 .expect("git init");
2031 std::process::Command::new("git")
2032 .args(["config", "user.email", "test@test.com"])
2033 .current_dir(&repo)
2034 .output()
2035 .expect("git config email");
2036 std::process::Command::new("git")
2037 .args(["config", "user.name", "Test User"])
2038 .current_dir(&repo)
2039 .output()
2040 .expect("git config name");
2041 fs::write(repo.join("file.txt"), "content").expect("write");
2042 std::process::Command::new("git")
2043 .args(["add", "."])
2044 .current_dir(&repo)
2045 .output()
2046 .expect("git add");
2047 std::process::Command::new("git")
2048 .args(["commit", "-m", "initial"])
2049 .current_dir(&repo)
2050 .output()
2051 .expect("git commit");
2052 std::process::Command::new("git")
2053 .args(["checkout", "--detach", "HEAD"])
2054 .current_dir(&repo)
2055 .output()
2056 .expect("git checkout detach");
2057
2058 let branch = detect_branch(&repo);
2059 assert_eq!(branch.len(), 40);
2060 assert!(branch.chars().all(|c| c.is_ascii_hexdigit()));
2061 }
2062
2063 #[test]
2064 fn detect_branch_no_git() {
2065 let dir = tempfile::tempdir().expect("tempdir");
2066 let no_git = dir.path().join("no-git-project");
2067 fs::create_dir_all(&no_git).expect("create dir");
2068
2069 let branch = detect_branch(&no_git);
2070 assert_eq!(branch, "main");
2071 }
2072
2073 #[test]
2076 fn unix_now_returns_recent_timestamp() {
2077 let now = unix_now();
2080 assert!(
2081 now > 1_735_689_600,
2082 "expected post-2025 unix time, got {now}"
2083 );
2084 }
2085
2086 #[test]
2087 fn xdg_repos_dir_path_shape() {
2088 let dir = xdg_repos_dir().expect("should resolve");
2089 assert!(dir.ends_with("repos"));
2090 assert!(dir.parent().unwrap().ends_with("seshat"));
2091 }
2092
2093 #[test]
2094 fn resolved_project_uses_project_filename_for_non_git_dir() {
2095 let dir = tempfile::tempdir().unwrap();
2096 let project = dir.path().join("my-app");
2097 fs::create_dir_all(&project).unwrap();
2098 let resolved = resolve_project(Some(&project), "test").expect("resolve");
2101 assert_eq!(resolved.project_name, "my-app");
2102 assert_eq!(
2103 resolved.db_path.file_name().unwrap().to_string_lossy(),
2104 "my-app.db"
2105 );
2106 assert!(resolved.db_path.parent().unwrap().ends_with("repos"));
2107 assert!(resolved.git_root.is_none());
2108 }
2109
2110 #[test]
2111 fn resolve_submodule_db_path_creates_parent_and_uses_mount() {
2112 let unique = format!("seshat-test-{}", unix_now());
2114 let result = resolve_submodule_db_path(&unique, "libs/shared").expect("resolve");
2115 assert!(result.ends_with("libs/shared.db"));
2116 let parent = result.parent().unwrap();
2118 assert!(parent.is_dir(), "parent dir should be created: {parent:?}");
2119 if let Some(repos) = parent.parent() {
2121 if repos.file_name().and_then(|s| s.to_str()) == Some(&unique) {
2122 let _ = fs::remove_dir_all(repos);
2123 }
2124 }
2125 }
2126
2127 #[test]
2130 fn count_files_any_schema_empty_db_returns_zero() {
2131 let dir = tempfile::tempdir().unwrap();
2132 let db = Database::open(dir.path().join("c.db")).unwrap();
2133 assert_eq!(count_files_any_schema(&db, "main"), 0);
2134 }
2135
2136 #[test]
2137 fn count_conventions_empty_db_returns_zero() {
2138 let dir = tempfile::tempdir().unwrap();
2139 let db = Database::open(dir.path().join("c.db")).unwrap();
2140 assert_eq!(count_conventions(&db, "main"), 0);
2141 }
2142
2143 #[test]
2144 fn count_conventions_seeded_returns_count() {
2145 let dir = tempfile::tempdir().unwrap();
2146 let db = Database::open(dir.path().join("c.db")).unwrap();
2147 {
2148 let g = db.connection().lock().unwrap();
2149 for desc in &["a", "b", "c"] {
2150 g.execute(
2151 "INSERT INTO nodes (branch_id, nature, weight, confidence,
2152 adoption_count, total_count, description, ext_data)
2153 VALUES ('main', 'convention', 'strong', 0.9, 1, 1, ?1, NULL)",
2154 params![*desc],
2155 )
2156 .unwrap();
2157 }
2158 }
2159 assert_eq!(count_conventions(&db, "main"), 3);
2160 assert_eq!(count_conventions(&db, "other"), 0);
2161 }
2162
2163 #[test]
2164 fn load_project_info_defaults_for_empty_db() {
2165 let dir = tempfile::tempdir().unwrap();
2166 let db = Database::open(dir.path().join("c.db")).unwrap();
2167 let info = load_project_info(&db);
2168 assert_eq!(info.branch.0, "main");
2170 assert_eq!(info.file_count, 0);
2171 assert_eq!(info.convention_count, 0);
2172 }
2173
2174 #[test]
2177 fn read_head_in_gitdir_ref_form() {
2178 let dir = tempfile::tempdir().unwrap();
2179 let gitdir = dir.path();
2180 fs::write(gitdir.join("HEAD"), "ref: refs/heads/feature/my-branch\n").unwrap();
2181 let result = read_head_in_gitdir(gitdir);
2182 assert_eq!(result.as_deref(), Some("feature/my-branch"));
2183 }
2184
2185 #[test]
2186 fn read_head_in_gitdir_detached_full_hash() {
2187 let dir = tempfile::tempdir().unwrap();
2188 fs::write(
2189 dir.path().join("HEAD"),
2190 "0123456789abcdef0123456789abcdef01234567\n",
2191 )
2192 .unwrap();
2193 let result = read_head_in_gitdir(dir.path());
2194 assert_eq!(
2195 result.as_deref(),
2196 Some("0123456789abcdef0123456789abcdef01234567")
2197 );
2198 }
2199
2200 #[test]
2201 fn read_head_in_gitdir_detached_abbreviated_hash() {
2202 let dir = tempfile::tempdir().unwrap();
2203 fs::write(dir.path().join("HEAD"), "deadbee\n").unwrap();
2204 let result = read_head_in_gitdir(dir.path());
2205 assert_eq!(result.as_deref(), Some("deadbee"));
2206 }
2207
2208 #[test]
2209 fn read_head_in_gitdir_unknown_ref_namespace_returns_none() {
2210 let dir = tempfile::tempdir().unwrap();
2211 fs::write(dir.path().join("HEAD"), "ref: refs/tags/v1.0\n").unwrap();
2213 assert!(read_head_in_gitdir(dir.path()).is_none());
2214 }
2215
2216 #[test]
2217 fn read_head_in_gitdir_garbage_returns_none() {
2218 let dir = tempfile::tempdir().unwrap();
2219 fs::write(dir.path().join("HEAD"), "not a hash and not a ref").unwrap();
2220 assert!(read_head_in_gitdir(dir.path()).is_none());
2221 }
2222
2223 #[test]
2224 fn read_head_in_gitdir_missing_file_returns_none() {
2225 let dir = tempfile::tempdir().unwrap();
2226 assert!(read_head_in_gitdir(dir.path()).is_none());
2228 }
2229
2230 #[test]
2233 fn find_git_dir_returns_dir_variant_when_dotgit_is_directory() {
2234 let dir = tempfile::tempdir().unwrap();
2235 let project = dir.path().join("p");
2236 fs::create_dir_all(project.join(".git").join("subdir")).unwrap();
2237 match find_git_dir(&project) {
2238 Some(GitDir::Dir(p)) => assert!(p.ends_with(".git")),
2239 Some(GitDir::File(_)) => panic!("expected GitDir::Dir, got File"),
2240 None => panic!("expected GitDir::Dir, got None"),
2241 }
2242 }
2243
2244 #[test]
2245 fn find_git_dir_returns_file_variant_when_dotgit_is_file() {
2246 let dir = tempfile::tempdir().unwrap();
2247 let worktree = dir.path().join("wt");
2248 fs::create_dir_all(&worktree).unwrap();
2249 fs::write(worktree.join(".git"), "gitdir: /tmp/some-elsewhere").unwrap();
2250 match find_git_dir(&worktree) {
2251 Some(GitDir::File(p)) => assert!(p.ends_with(".git")),
2252 Some(GitDir::Dir(_)) => panic!("expected GitDir::File, got Dir"),
2253 None => panic!("expected GitDir::File, got None"),
2254 }
2255 }
2256
2257 #[test]
2258 fn find_git_dir_walks_up_from_subdir() {
2259 let dir = tempfile::tempdir().unwrap();
2260 let project = dir.path().join("p");
2261 let nested = project.join("a").join("b");
2262 fs::create_dir_all(&nested).unwrap();
2263 fs::create_dir_all(project.join(".git")).unwrap();
2264 let result = find_git_dir(&nested);
2265 assert!(matches!(result, Some(GitDir::Dir(_))));
2266 }
2267
2268 #[test]
2269 fn find_git_dir_returns_none_when_no_dotgit() {
2270 let dir = tempfile::tempdir().unwrap();
2271 let project = dir.path().join("no-git");
2272 fs::create_dir_all(&project).unwrap();
2273 let _ = find_git_dir(&project);
2276 }
2277
2278 #[test]
2281 fn gc_branch_snapshots_empty_db_returns_empty() {
2282 let dir = tempfile::tempdir().unwrap();
2283 let db = Database::open(dir.path().join("c.db")).unwrap();
2284 let deleted = gc_branch_snapshots(&db, dir.path()).unwrap();
2285 assert!(deleted.is_empty());
2286 }
2287
2288 fn fake_home_with_subdir(name: &str) -> (tempfile::TempDir, PathBuf, PathBuf) {
2294 let tmp = tempfile::tempdir().expect("create temp dir");
2295 let home = tmp.path().to_path_buf();
2296 let cwd = home.join(name);
2297 fs::create_dir_all(&cwd).expect("create cwd subdir");
2298 (tmp, home, cwd)
2299 }
2300
2301 #[test]
2302 fn check_serve_dangerous_cwd_refuses_when_in_home_with_no_git() {
2303 let (_tmp, home, cwd) = fake_home_with_subdir("scratchpad");
2304 let result = check_serve_dangerous_cwd(None, &[], &cwd, Some(&home));
2305 match result {
2306 Err(CliError::DangerousCwd { path, hint }) => {
2307 let expected = std::fs::canonicalize(&cwd).unwrap_or(cwd.clone());
2309 let got = std::fs::canonicalize(&path).unwrap_or(path.clone());
2310 assert_eq!(got, expected, "path should reflect offending cwd");
2311 assert!(
2312 hint.contains("seshat scan"),
2313 "hint missing scan suggestion: {hint}"
2314 );
2315 assert!(
2316 hint.contains("seshat serve /"),
2317 "hint missing positional-repo override suggestion: {hint}"
2318 );
2319 assert!(hint.contains("cd "), "hint missing cd suggestion: {hint}");
2320 }
2321 other => panic!("expected DangerousCwd, got {other:?}"),
2322 }
2323 }
2324
2325 #[test]
2326 fn check_serve_dangerous_cwd_proceeds_when_inside_git_repo() {
2327 let (_tmp, home, cwd) = fake_home_with_subdir("real-project");
2330 fs::create_dir(cwd.join(".git")).expect("create .git dir");
2331 let result = check_serve_dangerous_cwd(None, &[], &cwd, Some(&home));
2332 assert!(
2333 result.is_ok(),
2334 "expected Ok when cwd is inside a git repo, got {result:?}"
2335 );
2336 }
2337
2338 #[test]
2339 fn check_serve_dangerous_cwd_refuses_when_stray_git_lives_at_dangerous_root() {
2340 let (_tmp, home, cwd) = fake_home_with_subdir("scratchpad");
2344 fs::create_dir(home.join(".git")).expect("create stray .git at home");
2345 let result = check_serve_dangerous_cwd(None, &[], &cwd, Some(&home));
2346 match result {
2347 Err(CliError::DangerousCwd { .. }) => {}
2348 other => panic!("expected DangerousCwd despite stray ~/.git, got {other:?}"),
2349 }
2350 }
2351
2352 #[test]
2353 fn check_serve_dangerous_cwd_skipped_when_explicit_repo_provided() {
2354 let (_tmp, home, cwd) = fake_home_with_subdir("scratchpad");
2357 let safe_repo = PathBuf::from("/totally/unrelated/path");
2358 let result = check_serve_dangerous_cwd(Some(&safe_repo), &[], &cwd, Some(&home));
2359 assert!(
2360 result.is_ok(),
2361 "explicit --repo must bypass the cwd gate, got {result:?}"
2362 );
2363 }
2364
2365 #[test]
2366 fn check_repo_override_dangerous_returns_warn_for_dangerous_path_no_git() {
2367 let (_tmp, home, project_root) = fake_home_with_subdir("inside-home");
2369 let warn =
2370 check_repo_override_dangerous(Some(&project_root), &[], &project_root, Some(&home));
2371 let msg = warn.expect("expected warn message for dangerous explicit repo");
2372 assert!(msg.contains("⚠️"), "warn message missing ⚠️ prefix: {msg}");
2373 assert!(
2374 msg.contains("explicit repo path"),
2375 "warn message must explain the explicit-repo override: {msg}"
2376 );
2377 assert!(msg.lines().count() >= 2, "warn must be multi-line: {msg}");
2378 }
2379
2380 #[test]
2381 fn check_repo_override_dangerous_silent_when_project_root_is_git_repo() {
2382 let (_tmp, home, project_root) = fake_home_with_subdir("real-project");
2387 fs::create_dir(project_root.join(".git")).expect("create .git");
2388 let warn =
2389 check_repo_override_dangerous(Some(&project_root), &[], &project_root, Some(&home));
2390 assert!(
2391 warn.is_none(),
2392 "git-rooted --repo path must not warn, got {warn:?}"
2393 );
2394 }
2395
2396 #[test]
2397 fn check_repo_override_dangerous_skipped_when_no_explicit_repo() {
2398 let (_tmp, home, project_root) = fake_home_with_subdir("inside-home");
2401 let warn = check_repo_override_dangerous(None, &[], &project_root, Some(&home));
2402 assert!(warn.is_none(), "no explicit_repo → no override warn");
2403 }
2404}