Skip to main content

lit/commands/
diff.rs

1use crate::core::diff::{collect_tree_files, diff_blobs, diff_trees, DiffStat, FileDiff};
2use crate::core::{find_repo_root, Object, ObjectHash};
3use crate::response::DiffResponse;
4use crate::storage::{Index, ObjectStore};
5use std::collections::HashMap;
6use std::fs;
7
8/// Execute the diff command
9///
10/// Modes:
11///   - No args: working tree vs index (unstaged changes)
12///   - --staged: index vs HEAD (staged changes)
13///   - Two refs: commit-to-commit or branch-to-branch
14pub fn execute(
15    staged: bool,
16    stat: bool,
17    word_diff: bool,
18    ref1: Option<String>,
19    ref2: Option<String>,
20) -> Result<DiffResponse, crate::errors::LitError> {
21    let repo_root = find_repo_root()?;
22    let store = ObjectStore::new(&repo_root);
23
24    let file_diffs = if let Some(r1) = ref1 {
25        let r2 = ref2.unwrap_or_else(|| "HEAD".to_string());
26        // Commit-to-commit or branch-to-branch diff
27        diff_refs(&repo_root, &store, &r1, &r2)?
28    } else if staged {
29        // Index vs HEAD
30        diff_staged(&repo_root, &store)?
31    } else {
32        // Working tree vs index
33        diff_working(&repo_root, &store)?
34    };
35
36    let stats: Vec<DiffStat> = file_diffs
37        .iter()
38        .map(|d| DiffStat {
39            path: d.path.clone(),
40            additions: d.additions,
41            deletions: d.deletions,
42            status: d.status,
43        })
44        .collect();
45
46    let total_additions: usize = stats.iter().map(|s| s.additions).sum();
47    let total_deletions: usize = stats.iter().map(|s| s.deletions).sum();
48    let files_changed = file_diffs.len();
49
50    Ok(DiffResponse {
51        files: file_diffs,
52        stats,
53        stat_only: stat,
54        word_diff,
55        files_changed,
56        total_additions,
57        total_deletions,
58    })
59}
60
61/// Diff working tree against index (unstaged changes)
62fn diff_working(
63    repo_root: &std::path::Path,
64    _store: &ObjectStore,
65) -> Result<Vec<FileDiff>, crate::errors::LitError> {
66    let index = Index::load(repo_root)?;
67    let mut diffs = Vec::new();
68
69    for (path, entry) in &index.entries {
70        let file_path = repo_root.join(path);
71
72        if !file_path.exists() {
73            // File deleted from working tree
74            let old_content = read_blob_by_hash_str(_store, &entry.hash)?;
75            diffs.push(diff_blobs(
76                path,
77                Some(&old_content),
78                None,
79                Some(entry.hash.clone()),
80                None,
81            ));
82            continue;
83        }
84
85        let current_content =
86            fs::read(&file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
87
88        let old_content = read_blob_by_hash_str(_store, &entry.hash)?;
89
90        if current_content != old_content {
91            diffs.push(diff_blobs(
92                path,
93                Some(&old_content),
94                Some(&current_content),
95                Some(entry.hash.clone()),
96                None,
97            ));
98        }
99    }
100
101    diffs.sort_by(|a, b| a.path.cmp(&b.path));
102    Ok(diffs)
103}
104
105/// Diff index (staged changes) against HEAD
106fn diff_staged(
107    repo_root: &std::path::Path,
108    store: &ObjectStore,
109) -> Result<Vec<FileDiff>, crate::errors::LitError> {
110    let index = Index::load(repo_root)?;
111
112    // Get HEAD tree files
113    let head_files = get_head_tree_files(repo_root, store).unwrap_or_default();
114
115    let mut diffs = Vec::new();
116
117    // Files in index
118    for (path, entry) in &index.entries {
119        let new_content = read_blob_by_hash_str(store, &entry.hash)?;
120
121        if let Some(old_hash) = head_files.get(path) {
122            // File exists in HEAD — check if changed
123            let old_hash_str = old_hash.to_string();
124            if entry.hash != old_hash_str {
125                let old_content = read_blob_by_hash_str(store, &old_hash_str)?;
126                diffs.push(diff_blobs(
127                    path,
128                    Some(&old_content),
129                    Some(&new_content),
130                    Some(old_hash_str),
131                    Some(entry.hash.clone()),
132                ));
133            }
134        } else {
135            // New file
136            diffs.push(diff_blobs(
137                path,
138                None,
139                Some(&new_content),
140                None,
141                Some(entry.hash.clone()),
142            ));
143        }
144    }
145
146    // Files in HEAD but not in index (would be deletions if we tracked that)
147    // Note: lit's current index model only tracks staged files, not deletions
148
149    diffs.sort_by(|a, b| a.path.cmp(&b.path));
150    Ok(diffs)
151}
152
153/// Diff two refs (commits or branches)
154fn diff_refs(
155    repo_root: &std::path::Path,
156    store: &ObjectStore,
157    ref1: &str,
158    ref2: &str,
159) -> Result<Vec<FileDiff>, crate::errors::LitError> {
160    let hash1 = resolve_ref(repo_root, ref1)?;
161    let hash2 = resolve_ref(repo_root, ref2)?;
162
163    let tree1 = get_commit_tree(store, &hash1)?;
164    let tree2 = get_commit_tree(store, &hash2)?;
165
166    diff_trees(&tree1, &tree2, store).map_err(Into::into)
167}
168
169/// Resolve a ref string to an ObjectHash — supports branch names, HEAD, and raw hashes
170fn resolve_ref(
171    repo_root: &std::path::Path,
172    reference: &str,
173) -> Result<ObjectHash, crate::errors::LitError> {
174    if reference == "HEAD" {
175        let head = crate::core::read_head(repo_root)?;
176        return Ok(ObjectHash::from_hex(head));
177    }
178
179    // Try as branch ref
180    if let Ok(hash) = crate::core::read_ref(repo_root, &format!("heads/{}", reference)) {
181        return Ok(ObjectHash::from_hex(hash));
182    }
183
184    // Try as tag ref
185    if let Ok(hash) = crate::core::read_ref(repo_root, &format!("tags/{}", reference)) {
186        return Ok(ObjectHash::from_hex(hash));
187    }
188
189    // Try as raw hash
190    Ok(ObjectHash::from_hex(reference.to_string()))
191}
192
193/// Get the tree object from a commit
194fn get_commit_tree(
195    store: &ObjectStore,
196    commit_hash: &ObjectHash,
197) -> Result<crate::core::Tree, crate::errors::LitError> {
198    let commit = match store.read(commit_hash)? {
199        Object::Commit(c) => c,
200        _ => return Err(format!("Expected commit object for {}", commit_hash).into()),
201    };
202
203    match store.read(&commit.tree)? {
204        Object::Tree(t) => Ok(t),
205        _ => Err(format!("Expected tree object for {}", commit.tree).into()),
206    }
207}
208
209/// Get all files from HEAD's tree
210fn get_head_tree_files(
211    repo_root: &std::path::Path,
212    store: &ObjectStore,
213) -> Result<HashMap<String, ObjectHash>, crate::errors::LitError> {
214    let head_hash = crate::core::read_head(repo_root)?;
215    let commit_hash = ObjectHash::from_hex(head_hash);
216    let tree = get_commit_tree(store, &commit_hash)?;
217    let files = collect_tree_files(&tree, store, "")
218        .map_err(|e: String| -> crate::errors::LitError { e.into() })?;
219    Ok(files.into_iter().collect())
220}
221
222/// Read a blob by its hash string
223fn read_blob_by_hash_str(
224    store: &ObjectStore,
225    hash: &str,
226) -> Result<Vec<u8>, crate::errors::LitError> {
227    let obj_hash = ObjectHash::from_hex(hash.to_string());
228    match store.read(&obj_hash)? {
229        Object::Blob(b) => Ok(b.content),
230        _ => Err(format!("Expected blob object for hash {}", hash).into()),
231    }
232}