Skip to main content

seshat_cli/
db.rs

1//! Shared database path utilities used by both `scan` and `serve` commands.
2//!
3//! All Seshat databases live in `$XDG_DATA_HOME/seshat/repos/{project_name}.db`
4//! (typically `~/.local/share/seshat/repos/` on Linux/macOS).
5
6use 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
17/// Import ByteSlice for BStr::to_str() conversion.
18use gix::bstr::ByteSlice;
19
20/// Branch names that are never garbage-collected, regardless of git state.
21const PROTECTED_BRANCHES: &[&str] = &["main", "master"];
22
23/// Result of resolving what to serve — either an existing database or a
24/// project root that needs auto-scanning.
25pub(crate) enum ServeTarget {
26    /// An existing `.db` file was found — serve it normally (zero behavior change).
27    ExistingDb {
28        db_path: PathBuf,
29        project_root: PathBuf,
30    },
31    /// No `.db` file found — auto-scan the project root on startup.
32    AutoScan {
33        project_root: PathBuf,
34        db_path: PathBuf,
35    },
36}
37
38/// Resolved project information returned by the single shared resolver.
39///
40/// Used by every CLI command that needs to locate a project (scan, serve,
41/// review, status, decisions, uninstall, debug). Whether the DB file exists
42/// on disk is NOT checked here — the caller decides how to handle a missing
43/// database.
44///
45/// **Worktree-stable identity.** [`Self::project_name`] is derived from the
46/// git common-dir's basename (when available), so all worktrees of one git
47/// repository resolve to the same [`Self::db_path`]. [`Self::project_root`]
48/// remains the working tree directory the caller is operating on, so file
49/// reads still happen in the worktree the user is sitting in.
50pub struct ResolvedProject {
51    /// Working tree directory — where source files are read from.
52    /// For git worktrees, this is the worktree directory (NOT the main repo
53    /// root). For non-git directories, this is the canonicalised input path.
54    pub project_root: PathBuf,
55    /// Main repository root, where `.git` lives. Same for every worktree of
56    /// a single repository. `None` for non-git directories.
57    pub git_root: Option<PathBuf>,
58    /// Stable DB filename stem. Derived from `git_root.file_name()` when
59    /// available, otherwise from `project_root.file_name()`.
60    pub project_name: String,
61    /// Full DB path: `xdg_repos_dir / "{project_name}.db"`. May or may not
62    /// exist on disk.
63    pub db_path: PathBuf,
64}
65
66impl ResolvedProject {
67    /// Root used for git operations (gix open, tree-diff, ref resolution,
68    /// branch GC). Falls back to [`Self::project_root`] for non-git
69    /// directories so callers get a usable path either way.
70    pub fn sync_root(&self) -> &Path {
71        self.git_root.as_deref().unwrap_or(&self.project_root)
72    }
73}
74
75/// Walk up from `path` to the git common-dir parent, falling back to `path`
76/// itself when no git root is found.
77///
78/// Single helper for callers that have a plain `&Path` instead of a
79/// [`ResolvedProject`] (test fixtures, watcher callbacks, freshness gate
80/// helpers). Production CLI paths should prefer
81/// [`ResolvedProject::sync_root`] which avoids the redundant walk.
82pub fn sync_root_for(path: &Path) -> PathBuf {
83    find_git_root(path).unwrap_or_else(|| path.to_path_buf())
84}
85
86/// Current Unix timestamp in seconds (since epoch).
87pub(crate) fn unix_now() -> i64 {
88    chrono::Utc::now().timestamp()
89}
90
91/// Core project summary info loadable from any seshat database.
92///
93/// Used by both `serve` and `status` commands to avoid duplicating the
94/// same branch + file-count + convention-count queries.
95pub(crate) struct ProjectInfo {
96    /// Active branch.
97    pub branch: BranchId,
98    /// Number of indexed source files.
99    pub file_count: usize,
100    /// Number of convention nodes.
101    pub convention_count: usize,
102}
103
104/// Load core project summary info from a database.
105///
106/// Queries branch, file count, and convention count. Uses "main" as the
107/// default branch if no explicit branch has been set.
108pub(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
136/// Count files in a database for a given branch, ignoring `ir_schema_version`.
137///
138/// Unlike `load_project_info`, this query does **not** filter by the current
139/// `IR_SCHEMA_VERSION`, so it returns the correct count even when the database
140/// was scanned with an older schema version.
141pub(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
154/// Count convention nodes in a database for a given branch.
155pub(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
168/// Returns `true` when all rows in `files_ir` for the given branch already
169/// have the current `IR_SCHEMA_VERSION`, or the table is empty.
170///
171/// Used by the scan command to decide whether a submodule whose git commit
172/// hash hasn't changed still needs to be re-scanned (because the IR schema
173/// was bumped since the last scan).
174pub(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    // Count rows that are NOT on the current schema version.
179    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
191/// Get the XDG repos directory: `$XDG_DATA_HOME/seshat/repos/`.
192pub(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
201/// Extract project name from the last component of a path.
202///
203/// ```text
204/// ~/Projects/walt-chat-backend  → "walt-chat-backend"
205/// ~/Projects/walt-chat-backend/ → "walt-chat-backend"
206/// ```
207pub(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
213/// Resolve the database path for a submodule within a project.
214///
215/// Returns `$XDG_DATA_HOME/seshat/repos/{project_name}/{mount_path}.db`.
216/// Parent directories are created automatically via [`std::fs::create_dir_all`].
217///
218/// # Example
219///
220/// ```text
221/// resolve_submodule_db_path("my-app", "libs/shared")
222///   → ~/.local/share/seshat/repos/my-app/libs/shared.db
223/// ```
224pub(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    // Ensure parent directories exist (e.g., repos/my-app/libs/ for libs/shared.db).
234    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
244/// Maximum iterations for walk-up in find_git_root to prevent symlink cycles.
245const GIT_ROOT_MAX_ITERATIONS: u32 = 64;
246
247/// Walk up from `from` to find the nearest `.git` directory.
248///
249/// Handles git worktrees where `.git` is a file containing `gitdir: <path>`
250/// instead of a directory — resolves to the main repository root.
251///
252/// Returns the parent of `.git` (the repository root).
253/// Returns `None` if no `.git` is found before reaching the filesystem root
254/// or hitting the iteration limit (symlink cycle protection).
255pub 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                    // Normalize the resolved path (handle .. components).
277                    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                    // Walk up from resolved gitdir to find the main repo root
289                    // (which has HEAD or config).
290                    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 found directory is a .git directory, return its parent (the repo root).
295                                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    // Hit iteration limit — likely a symlink cycle.
319    tracing::warn!(
320        path = %from.display(),
321        "find_git_root reached iteration limit; possible symlink cycle"
322    );
323    None
324}
325
326/// Detect the current git branch for the given path.
327///
328/// Uses `get_current_branch` which resolves worktree `.git` files correctly,
329/// handles detached HEAD (returns short commit hash), and normalizes path
330/// components. Falls back to `"main"` on any error with a debug trace.
331pub 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
338/// Get the current git branch name for the repository containing `path`.
339///
340/// Reads the HEAD file directly, handling both normal repos and worktrees
341/// (where `.git` is a file with `gitdir:` prefix).
342///
343/// Returns `Some(branch_name)` when HEAD points to a branch reference
344/// (e.g., `refs/heads/main` → `"main"`).
345/// Returns `Some(commit_hash)` when HEAD is detached.
346/// Returns `None` when HEAD cannot be read.
347pub fn get_current_branch(path: &Path) -> Option<String> {
348    read_head_file(path)
349}
350
351/// Read the HEAD file directly and extract branch name (or commit hash for
352/// detached HEAD).
353///
354/// Handles both normal repos (`.git` is a directory) and worktrees
355/// (`.git` is a file containing `gitdir: <path>`).
356fn read_head_file(path: &Path) -> Option<String> {
357    let gitdir = resolve_gitdir(path)?;
358    read_head_in_gitdir(&gitdir)
359}
360
361/// Resolve the on-disk gitdir for `path`, following the worktree `.git`-file
362/// indirection when needed.
363fn 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
380/// Locate the `.git` directory or file, walking up from `path`.
381pub(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
413/// Read the HEAD file at `<gitdir>/HEAD` and parse it into either a branch
414/// name (`refs/heads/X` → `X`) or a commit hash (detached HEAD).
415///
416/// Single source of truth for HEAD parsing — used by [`read_head_file`]
417/// (which resolves the gitdir via worktree-aware indirection first).
418fn 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    // Detached HEAD — content is a commit hash. Accept both full (40-char)
428    // and abbreviated hashes (>= 7 chars).
429    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
437/// Discover local git branch names for the repository containing `path`.
438///
439/// Uses `gix` to walk all local branches under `refs/heads/`.
440/// Returns an empty vec if the path is not in a git repository or no branches exist.
441pub 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
464/// Check whether the given path is a valid git repository.
465///
466/// Returns `true` if `gix::open` succeeds, `false` otherwise.
467fn is_valid_git_repo(path: &Path) -> bool {
468    gix::open(path).is_ok()
469}
470
471/// Compare branches stored in the database against branches that exist in git.
472///
473/// Deletes branch snapshots from the database for branches that exist in the DB
474/// but no longer have a corresponding local git branch.
475///
476/// Safety rules:
477/// - Never deletes `main` or `master` branches
478/// - Never deletes the current branch (detected from git)
479///
480/// Returns the list of deleted branch names.
481pub fn gc_branch_snapshots(db: &Database, repo_path: &Path) -> Result<Vec<String>, CliError> {
482    let branch_repo = SqliteBranchRepository::new(db.connection().clone());
483
484    // Get branches stored in the database
485    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    // Validate that the path is a git repository
497    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    // Get current git branches
505    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    // Get current branch name
510    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        // Never GC protected branches
518        if PROTECTED_BRANCHES.contains(&name.as_str()) {
519            continue;
520        }
521
522        // Never GC current branch
523        if name == &current_branch {
524            continue;
525        }
526
527        // Only GC branches that don't exist in git anymore
528        if git_set.contains(name.as_str()) {
529            continue;
530        }
531
532        // Safe to delete
533        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
560/// List all `.db` files in the repos directory.
561///
562/// Returns `(path, project_name)` pairs sorted alphabetically by name.
563pub(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
599/// Try to read the `project_root` value from `repo_metadata` in the given DB.
600/// Returns `None` if the DB can't be opened, or the key doesn't exist.
601fn 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
613/// Build a [`ResolvedProject`] for a directory on disk.
614///
615/// Walks up to the git common-dir parent via [`find_git_root`]
616/// (worktree-aware) and uses ITS basename as the project name, so all
617/// worktrees of one repository resolve to the same DB. For non-git
618/// directories, falls back to the canonicalised input directory's basename.
619fn 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
634/// Build a [`ResolvedProject`] from a stored DB plus its scan-time
635/// `project_root` metadata. Used by name-based and auto-select fallbacks
636/// where the on-disk DB is the source of truth for "where this project
637/// lives". Re-derives `git_root` from the stored root so sync works even
638/// when the DB was originally scanned from a worktree directory.
639fn 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
659/// Common project resolution logic used by every CLI command.
660///
661/// The resolver walks to the git common-dir parent BEFORE deriving the
662/// project name, so all worktrees of a single repository share one DB. The
663/// caller decides whether a missing DB is an error.
664///
665/// `command_name` is used in error messages to identify the calling command.
666///
667/// Resolution priority:
668/// 1. Explicit argument that names an existing directory → resolve from it.
669/// 2. Explicit argument that names a known project (DB exists in repos_dir)
670///    → recover scan-time root from DB metadata.
671/// 3. No argument: derive from cwd. The DB is keyed by the cwd's git-root
672///    basename so worktrees collapse to one DB.
673/// 4. cwd is not in a git repo and has no DB → fall back to listing
674///    available projects (auto-select when exactly one exists).
675pub fn resolve_project(
676    explicit_path: Option<&Path>,
677    command_name: &str,
678) -> Result<ResolvedProject, CliError> {
679    // Priority 1: explicit argument.
680    if let Some(arg) = explicit_path {
681        // 1a. Existing directory — resolve from disk.
682        if arg.is_dir() {
683            return identity_from_dir(arg);
684        }
685
686        // 1b. Project NAME lookup — resolve via stored root in DB metadata.
687        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        // 1c. Maybe a path-like that doesn't exist; try its basename as a
696        //     project name (consistent with how scan would have stored it).
697        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        // 1d. Unknown — surface a hint to scan first.
705        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    // Priority 2: derive from cwd. Whether the DB exists or not, return
717    // the cwd-derived identity — the caller decides how to handle a missing
718    // DB (e.g. `scan` creates it, `review` errors with "No database found").
719    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    // Priority 3: cwd is unreadable (deleted-while-running, EACCES, …).
731    // Last-resort fallback — list whatever's in repos_dir and auto-select
732    // when there's exactly one. Otherwise surface a helpful list.
733    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
770/// Build the multi-line user-facing hint embedded in
771/// [`CliError::DangerousCwd`] errors. Lists three concrete next steps.
772///
773/// `seshat serve` accepts an optional positional `<repo>` argument (see
774/// `args.rs`), so the override hint shows the positional form rather than a
775/// `--repo` flag.
776pub(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
786/// Build the multi-line stderr warning emitted when the user passed an
787/// explicit positional `<repo>` pointing at a dangerous, non-git location.
788///
789/// The override is non-fatal: the user opted in by being explicit, so we
790/// only warn and continue.
791pub(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
802/// Pure decision: should `serve` refuse to run because `cwd` is dangerous and
803/// not inside a git repository?
804///
805/// Refuses only when `explicit_repo.is_none()`. When the user passed `--repo`,
806/// the caller should instead use [`check_repo_override_dangerous`] to decide
807/// whether to emit a warning.
808pub(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    // Even in a dangerous cwd, allow proceeding when there is a git
821    // repository at-or-above us — UNLESS the resolved git root IS itself
822    // a denylist entry (e.g. a stray `~/.git` from a dotfiles repo, where
823    // `find_git_root($HOME/scratch)` walks up and lands on `$HOME`). A
824    // legitimate project nested inside `$HOME` (e.g. `~/work/myproj`)
825    // resolves to a git root that is a *descendant* of the denylist
826    // entry, not equal to one — so it correctly stays allowed.
827    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
843/// Pure decision: when `--repo` was passed and the resolved `project_root`
844/// is on the dangerous denylist with no nearby git repository, return a
845/// warning string. Otherwise return `None`.
846///
847/// Mirrors the "dangerous && no-git" condition used by
848/// [`check_serve_dangerous_cwd`]; the difference is that this case is
849/// non-fatal — the user explicitly opted in via `--repo`.
850pub(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    // A real git repo at-or-above `project_root` means the user pointed
861    // their explicit `<repo>` at a project, not at a dangerous tree root —
862    // no warn needed. We require the resolved git root to NOT be exactly
863    // a denylist entry (mirrors the logic in [`check_serve_dangerous_cwd`]:
864    // a stray `.git` at `$HOME` does not retroactively make `$HOME` safe).
865    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
873/// Resolves what to serve — either an existing database or a project root that
874/// needs auto-scanning.
875///
876/// When no `.db` file is found, instead of erroring, this function determines
877/// the project root and returns `ServeTarget::AutoScan`. The caller can then
878/// create an empty DB and launch a background scan.
879///
880/// `additional_denylist_paths` extends the per-OS dangerous-cwd denylist used
881/// to gate auto-scan in unsafe locations (see [`check_serve_dangerous_cwd`]).
882pub(crate) fn resolve_serve_db_or_project_root(
883    explicit_repo: Option<&Path>,
884    additional_denylist_paths: &[String],
885) -> Result<ServeTarget, CliError> {
886    // Refuse early when invoked from a dangerous cwd with no nearby git repo.
887    //
888    // Fail closed if the cwd cannot be read (deleted-while-running, EACCES,
889    // etc.): silently skipping the guard would let a process with an
890    // unreadable cwd evade the entire P1 protection.
891    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    // Warn (but proceed) when an explicit `<repo>` arg points at a
907    // dangerous, non-git path. We use `tracing::warn!` rather than `eprintln!`
908    // so the warning flows through the normal logging pipeline (JSON
909    // subscribers, log aggregators, level filtering all work). The default
910    // tracing-subscriber writes WARN to stderr, so user-visible behaviour
911    // is unchanged for plain CLI invocations.
912    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        // "." has no file_name() component — falls back to "unknown"
961        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        // Non-db file should be ignored
992        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        // Empty DB (no rows in files_ir) → no stale rows → schema is current.
1015        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        // Insert a row via the normal upsert path (writes current IR_SCHEMA_VERSION).
1032        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        // Verify current schema is detected as current.
1038        assert!(submodule_ir_schema_is_current(&db, "main"));
1039
1040        // Now manually corrupt the ir_schema_version to simulate an old scan.
1041        {
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        // Should now report schema as stale.
1052        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        // Clean up the directories created by resolve_submodule_db_path.
1067        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        // The unified resolver canonicalises the input path so worktree
1079        // resolution is symlink-stable; tests must compare against the
1080        // canonical form (on macOS `/var/folders/...` → `/private/var/...`).
1081        let expected_root = std::fs::canonicalize(&project_dir).unwrap();
1082
1083        // Explicit directory with no existing DB → AutoScan.
1084        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        // Create a temp project directory and its DB in the real XDG repos dir.
1103        let repos_dir = xdg_repos_dir().expect("repos dir");
1104        // Make sure the XDG repos dir exists — on a fresh CI runner it may
1105        // not have been created yet.
1106        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        // The explicit repo arg is a path that doesn't exist as a directory,
1120        // so it's treated as a project name. With the DB existing, it should
1121        // return ExistingDb.
1122        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            // project_root should be read from repo_metadata, not db_path.parent()
1133            // Since the DB was just created empty, project_root defaults to repos_dir
1134            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        // Explicit directory path with no DB and no git → AutoScan with cwd.
1147        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        // Initialize a git repo with a specific branch
1166        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        // Create a DB in the XDG repos dir to make it ExistingDb
1176        let repos_dir = xdg_repos_dir().expect("repos dir");
1177        // Ensure the XDG repos dir exists on fresh CI runners.
1178        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        // Resolve — should be ExistingDb with project_root = the actual project dir
1184        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        // project_root should be the canonical project directory (worktree
1196        // resolution canonicalises the input).
1197        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        // detect_branch on the resolved project_root should return the actual branch
1202        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        // Create HEAD in main repo so walk-up can find it.
1212        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        // Create a worktree using git worktree command
1327        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        // Detach HEAD
1376        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        // Create a temp git repo with main and a feature branch.
1395        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        // Create a feature branch in git.
1425        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        // Create a DB with main, feature, and orphan branches.
1448        // First insert data for main to register it, then snapshot to others.
1449        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        // Insert a file to register the main branch
1460        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        // Snapshot main to feature and orphan-branch
1467        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        // Run GC — orphan-branch should be deleted, main and feature preserved.
1475        let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
1476        assert_eq!(deleted, vec!["orphan-branch"]);
1477
1478        // Verify remaining branches.
1479        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        // Create a temp git repo with NO commits (so no branches in git).
1489        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        // Create a DB with main and some-branch.
1499        // Current git branch is "main" (default after git init) even though
1500        // git has no branches yet. "main" should be preserved.
1501        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        // Insert data for main
1512        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        // Snapshot main to some-branch
1519        branch_repo
1520            .create_snapshot(&BranchId::from("main"), &BranchId::from("some-branch"))
1521            .expect("snapshot some-branch");
1522
1523        // Run GC — main should be preserved as current branch even though
1524        // git has no branches (get_git_branches returns empty).
1525        let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
1526        assert!(!deleted.contains(&"main".to_string()));
1527
1528        // Verify some-branch was deleted (it's not protected, not current, not in git).
1529        assert!(deleted.contains(&"some-branch".to_string()));
1530
1531        // Verify main is still there.
1532        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        // Create a temp git repo with no branches (just init).
1541        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        // Create a DB with main and some other branches.
1551        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        // Insert data for main
1562        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        // Snapshot main to some-branch
1569        branch_repo
1570            .create_snapshot(&BranchId::from("main"), &BranchId::from("some-branch"))
1571            .expect("snapshot some-branch");
1572
1573        // Run GC — main should NEVER be deleted.
1574        let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
1575        assert!(!deleted.contains(&"main".to_string()));
1576
1577        // Verify main is still there.
1578        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        // Create a temp git repo with no branches (just init).
1586        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        // Create a DB with master and some other branches.
1596        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        // Insert data for master
1607        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        // Snapshot master to some-branch
1614        branch_repo
1615            .create_snapshot(&BranchId::from("master"), &BranchId::from("some-branch"))
1616            .expect("snapshot some-branch");
1617
1618        // Run GC — master should NEVER be deleted.
1619        let deleted = gc_branch_snapshots(&db, &repo).expect("gc");
1620        assert!(!deleted.contains(&"master".to_string()));
1621
1622        // Verify master is still there.
1623        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        // Create a temp git repo with a feature branch.
1631        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        // Delete the feature branch in git so it doesn't exist in git anymore.
1666        std::process::Command::new("git")
1667            .args(["branch", "-D", "feature"])
1668            .current_dir(&repo)
1669            .output()
1670            .expect("git branch -D feature");
1671        // Checkout main so HEAD points to main.
1672        std::process::Command::new("git")
1673            .args(["checkout", "main"])
1674            .current_dir(&repo)
1675            .output()
1676            .expect("git checkout main");
1677
1678        // Create a DB with main and feature-branch.
1679        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        // Snapshot main to feature-branch
1696        branch_repo
1697            .create_snapshot(&BranchId::from("main"), &BranchId::from("feature-branch"))
1698            .expect("snapshot feature-branch");
1699
1700        // The current git branch is "main", so feature-branch should be deleted.
1701        // But we need to verify that the CURRENT branch (main) is preserved.
1702        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        // Create a temp git repo, make a commit, then detach HEAD.
1721        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        // Detach HEAD
1751        std::process::Command::new("git")
1752            .args(["checkout", "--detach", "HEAD"])
1753            .current_dir(&repo)
1754            .output()
1755            .expect("git checkout detach");
1756
1757        // Create a DB with main and some-branch.
1758        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        // In detached HEAD state, get_current_branch returns a commit hash.
1779        // main should still be preserved as a protected branch.
1780        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        // Create a temp git repo with only main.
1799        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        // Create a DB with main and multiple orphan branches.
1830        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        // Run GC — all orphans should be deleted, main preserved.
1857        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        // Create a test branch to base the worktree on.
1941        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    // ── unix_now / xdg_repos_dir / path resolvers ───────────────────
2074
2075    #[test]
2076    fn unix_now_returns_recent_timestamp() {
2077        // Sanity check: should be a positive value and >= a known baseline
2078        // (year 2025-01-01 UTC = 1735689600). We bumped well past that.
2079        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        // Non-git directory → project_name = file_name, db_path =
2099        // <repos_dir>/my-app.db. git_root is None.
2100        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        // Use a unique name to avoid colliding with user's real seshat data dir.
2113        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        // Parent dir must exist now (resolve_submodule_db_path creates it).
2117        let parent = result.parent().unwrap();
2118        assert!(parent.is_dir(), "parent dir should be created: {parent:?}");
2119        // Cleanup so we don't leak per-test directories under the user's data dir.
2120        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    // ── count_files_any_schema / count_conventions / load_project_info ──
2128
2129    #[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        // No git repo, no data — branch should default to "main".
2169        assert_eq!(info.branch.0, "main");
2170        assert_eq!(info.file_count, 0);
2171        assert_eq!(info.convention_count, 0);
2172    }
2173
2174    // ── read_head_in_gitdir ───────────────────────────────────────
2175
2176    #[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        // Refs outside refs/heads/ (e.g. tag refs) are not branches.
2212        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        // No HEAD file at all.
2227        assert!(read_head_in_gitdir(dir.path()).is_none());
2228    }
2229
2230    // ── find_git_dir ────────────────────────────────────────────────
2231
2232    #[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        // We can't actually walk up to / and find no .git in CI tempdirs,
2274        // but at least we can verify it doesn't panic.
2275        let _ = find_git_dir(&project);
2276    }
2277
2278    // ── gc_branch_snapshots ─────────────────────────────────────────
2279
2280    #[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    // ── dangerous-cwd guardrail (US-003) ────────────────────────────
2289
2290    /// `home`/`cwd` setup used to simulate "the user invoked seshat from
2291    /// inside `$HOME` (or a subdir) without a git repo nearby". A directory
2292    /// is created under the fake home and returned along with the home dir.
2293    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                // canonicalize for macOS where /var → /private/var etc.
2308                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        // cwd is dangerous (under fake home) but ALSO inside a git repo;
2328        // the gate should allow the caller to proceed (Ok(())).
2329        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        // A stray `.git` directory at $HOME (e.g. dotfiles repo) must not
2341        // retroactively make every $HOME subdir look "safe" — the guard
2342        // would otherwise be trivially bypassed by anyone with such a setup.
2343        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        // explicit_repo is Some, so the gate must not refuse — even if cwd is
2355        // both dangerous and not in a git repo. Caller is opting in.
2356        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        // explicit_repo=Some(dangerous-no-git): pure decision returns a warn.
2368        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        // cwd is dangerous AND --repo points at a path that has its own .git;
2383        // because the override-warn helper requires "no git root", we should
2384        // get None — i.e. proceed silently (PRD: "explicit_repo=Some(safe) →
2385        // proceeds with no warn", where 'safe' = a real project).
2386        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        // explicit_repo=None: the override-warning helper must always return
2399        // None (refusal handling is `check_serve_dangerous_cwd`'s job).
2400        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}