Skip to main content

lit/commands/
status.rs

1use crate::core::{find_repo_root, get_current_branch};
2use crate::response::StatusResponse;
3use crate::storage::Index;
4use std::collections::HashSet;
5use std::fs;
6use std::path::Path;
7use walkdir::WalkDir;
8
9/// Maximum files to visit during the untracked-file walk before stopping.
10const MAX_WALK_FILES: usize = 50_000;
11
12pub fn execute() -> Result<StatusResponse, crate::errors::LitError> {
13    let repo_root = find_repo_root()?;
14    let index = Index::load(&repo_root)?;
15
16    let branch = get_current_branch(&repo_root).ok();
17    let ignore_dirs = load_ignore_dirs(&repo_root);
18
19    // Phase 1: Check index entries for modifications (no filesystem walk needed)
20    let staged_set: HashSet<String> = index.entries.keys().cloned().collect();
21    let mut modified = Vec::new();
22    for file in &staged_set {
23        if is_modified(&repo_root, file, &index)? {
24            modified.push(file.clone());
25        }
26    }
27
28    // Phase 2: Walk for untracked files.
29    // Walk from CWD if it's inside repo_root (scopes the search to what the
30    // user is actually working on), otherwise fall back to repo_root.
31    let cwd = std::env::current_dir().unwrap_or_else(|_| repo_root.clone());
32    let walk_root = if cwd.starts_with(&repo_root) {
33        &cwd
34    } else {
35        &repo_root
36    };
37
38    let mut untracked = Vec::new();
39    let mut visited: usize = 0;
40    for entry in WalkDir::new(walk_root)
41        .into_iter()
42        .filter_entry(|e| !should_skip_entry(e, &ignore_dirs))
43    {
44        let entry = match entry {
45            Ok(e) => e,
46            Err(_) => continue,
47        };
48
49        if entry.file_type().is_file() {
50            visited += 1;
51            if visited > MAX_WALK_FILES {
52                break;
53            }
54            if let Ok(rel_path) = entry.path().strip_prefix(&repo_root) {
55                let path_str = rel_path.to_string_lossy().replace('\\', "/");
56                if !staged_set.contains(&path_str) {
57                    untracked.push(path_str);
58                }
59            }
60        }
61    }
62
63    modified.sort();
64    untracked.sort();
65    let mut staged: Vec<String> = staged_set.into_iter().collect();
66    staged.sort();
67
68    let clean = staged.is_empty() && modified.is_empty() && untracked.is_empty();
69
70    let head = crate::core::read_head(&repo_root).ok();
71
72    Ok(StatusResponse {
73        branch,
74        head,
75        staged,
76        modified,
77        untracked,
78        clean,
79    })
80}
81
82/// Load directory names to ignore from built-in defaults + .litignore file.
83fn load_ignore_dirs(repo_root: &Path) -> HashSet<String> {
84    let mut dirs: HashSet<String> = [
85        // VCS / build
86        "target",
87        "node_modules",
88        "venv",
89        "__pycache__",
90        "dist",
91        "out",
92        "bin",
93        "obj",
94        // Toolchain caches
95        ".cargo",
96        ".rustup",
97        ".npm",
98        ".nvm",
99        ".pyenv",
100        ".conda",
101        ".local",
102        ".cache",
103        ".thumbnails",
104        // OS user-profile directories (home-dir-as-repo safety)
105        "AppData",
106        "Application Data",
107        "Library",
108        "Caches",
109        // Cloud sync
110        "OneDrive",
111        "Dropbox",
112        "Google Drive",
113        // Windows user folders
114        "Documents",
115        "Downloads",
116        "Desktop",
117        "Music",
118        "Pictures",
119        "Videos",
120        "Contacts",
121        "Favorites",
122        "Links",
123        "Saved Games",
124        "Searches",
125        "3D Objects",
126        "scoop",
127    ]
128    .iter()
129    .map(|s| s.to_string())
130    .collect();
131
132    // Read .litignore (one directory name per line, # for comments)
133    let ignore_path = repo_root.join(".litignore");
134    if let Ok(content) = fs::read_to_string(&ignore_path) {
135        for line in content.lines() {
136            let line = line.trim().trim_end_matches('/');
137            if !line.is_empty() && !line.starts_with('#') {
138                dirs.insert(line.to_string());
139            }
140        }
141    }
142
143    dirs
144}
145
146/// Decide whether a walkdir entry should be pruned.
147fn should_skip_entry(entry: &walkdir::DirEntry, ignore_dirs: &HashSet<String>) -> bool {
148    let name = entry.file_name().to_string_lossy();
149
150    // Always skip hidden (dot-prefixed)
151    if name.starts_with('.') {
152        return true;
153    }
154
155    // Skip directories by name
156    entry.file_type().is_dir() && ignore_dirs.contains(name.as_ref())
157}
158
159fn is_modified(
160    repo_root: &Path,
161    file: &str,
162    index: &Index,
163) -> Result<bool, crate::errors::LitError> {
164    let file_path = repo_root.join(file);
165
166    if let Some(entry) = index.entries.get(file) {
167        // Fast path: if file was deleted, it's modified
168        if !file_path.exists() {
169            return Ok(true);
170        }
171
172        let current_content =
173            fs::read(&file_path).map_err(|e| format!("Failed to read file {}: {}", file, e))?;
174
175        use crate::core::{Blob, Object};
176        let blob = Blob::new(current_content);
177        let object = Object::Blob(blob);
178        let current_hash = object.hash();
179
180        Ok(current_hash.to_string() != entry.hash)
181    } else {
182        Ok(false)
183    }
184}