Skip to main content

lit/commands/
resolve.rs

1use crate::core::merge::MergeStrategy;
2use crate::core::{find_repo_root, get_current_branch, write_ref, Commit, Object, ObjectHash};
3use crate::response::ResolveResponse;
4use crate::storage::{Index, ObjectStore};
5use std::fs;
6use std::str::FromStr;
7
8/// Resolve merge conflicts programmatically
9///
10/// Modes:
11///   - `lit resolve <file> --strategy=ours` — take our version
12///   - `lit resolve <file> --strategy=theirs` — take their version
13///   - `lit resolve --all --strategy=ours` — resolve all with strategy
14///   - `lit resolve --continue` — finalize merge after manual resolution
15pub fn execute(
16    file: Option<String>,
17    strategy: Option<String>,
18    all: bool,
19    finish: bool,
20) -> Result<ResolveResponse, crate::errors::LitError> {
21    let repo_root = find_repo_root()?;
22    let store = ObjectStore::new(&repo_root);
23
24    let merge_dir = repo_root.join(".lit").join("merge");
25    if !merge_dir.exists() {
26        return Err("No merge in progress. Nothing to resolve.".into());
27    }
28
29    if finish {
30        return finalize_merge(&repo_root, &store);
31    }
32
33    let strategy = match &strategy {
34        Some(s) => MergeStrategy::from_str(s)?,
35        None => return Err("--strategy is required for resolve (ours or theirs)".into()),
36    };
37
38    // Read conflict state
39    let conflicts_path = merge_dir.join("conflicts.json");
40    let conflicts_data = fs::read_to_string(&conflicts_path)
41        .map_err(|e| format!("Failed to read conflict state: {}", e))?;
42    let file_results: Vec<crate::core::merge::FileMergeResult> =
43        serde_json::from_str(&conflicts_data)
44            .map_err(|e| format!("Failed to parse conflict state: {}", e))?;
45
46    let merge_head_str = fs::read_to_string(merge_dir.join("MERGE_HEAD"))
47        .map_err(|e| format!("Failed to read MERGE_HEAD: {}", e))?
48        .trim()
49        .to_string();
50    let merge_head = ObjectHash::from_hex(merge_head_str);
51
52    let head_hash_str = crate::core::read_head(&repo_root)?;
53    let head_hash = ObjectHash::from_hex(head_hash_str);
54
55    // Get trees for ours/theirs
56    let ours_tree = get_commit_tree(&store, &head_hash)?;
57    let theirs_tree = get_commit_tree(&store, &merge_head)?;
58    let ours_files = crate::core::diff::collect_tree_files(&ours_tree, &store, "")?;
59    let theirs_files = crate::core::diff::collect_tree_files(&theirs_tree, &store, "")?;
60
61    let ours_map: std::collections::HashMap<String, ObjectHash> = ours_files.into_iter().collect();
62    let theirs_map: std::collections::HashMap<String, ObjectHash> =
63        theirs_files.into_iter().collect();
64
65    let mut resolved_files = Vec::new();
66    let mut index = Index::load(&repo_root)?;
67
68    let conflicting: Vec<&crate::core::merge::FileMergeResult> = file_results
69        .iter()
70        .filter(|r| r.status == crate::core::merge::FileMergeStatus::Conflict)
71        .collect();
72
73    for conflict in &conflicting {
74        let should_resolve = all || file.as_deref() == Some(&conflict.path);
75        if !should_resolve {
76            continue;
77        }
78
79        let chosen_hash = match strategy {
80            MergeStrategy::Ours => ours_map.get(&conflict.path),
81            MergeStrategy::Theirs => theirs_map.get(&conflict.path),
82            MergeStrategy::Recursive => {
83                return Err(
84                    "Cannot use 'recursive' strategy for resolve. Use 'ours' or 'theirs'.".into(),
85                );
86            }
87        };
88
89        if let Some(hash) = chosen_hash {
90            // Update index with resolved version
91            let entry = crate::storage::index::IndexEntry {
92                path: conflict.path.clone(),
93                hash: hash.to_string(),
94                mode: "100644".to_string(),
95            };
96            index.entries.insert(conflict.path.clone(), entry);
97
98            // Write file to working tree
99            if let Ok(Object::Blob(blob)) = store.read(hash) {
100                let file_path = repo_root.join(&conflict.path);
101                if let Some(parent) = file_path.parent() {
102                    let _ = fs::create_dir_all(parent);
103                }
104                fs::write(&file_path, &blob.content)
105                    .map_err(|e| format!("Failed to write {}: {}", conflict.path, e))?;
106            }
107
108            resolved_files.push(conflict.path.clone());
109        }
110    }
111
112    if resolved_files.is_empty() {
113        return Err(if let Some(f) = &file {
114            format!("No conflict found for file '{}'", f).into()
115        } else {
116            "No conflicts to resolve".into()
117        });
118    }
119
120    // Save updated index
121    index.save(&repo_root)?;
122
123    // Update conflict state — remove resolved conflicts
124    let remaining: Vec<&crate::core::merge::FileMergeResult> = file_results
125        .iter()
126        .filter(|r| {
127            r.status == crate::core::merge::FileMergeStatus::Conflict
128                && !resolved_files.contains(&r.path)
129        })
130        .collect();
131
132    if remaining.is_empty() {
133        // All conflicts resolved — clean up and offer to finalize
134        let _ = fs::remove_file(&conflicts_path);
135        Ok(ResolveResponse {
136            resolved_files,
137            remaining_conflicts: 0,
138            merge_complete: false,
139            message: "All conflicts resolved. Run 'lit resolve --continue' to finalize the merge."
140                .to_string(),
141        })
142    } else {
143        // Write updated conflict state
144        let updated_data = serde_json::to_string_pretty(&remaining)
145            .map_err(|e| format!("Failed to serialize: {}", e))?;
146        fs::write(&conflicts_path, updated_data)
147            .map_err(|e| format!("Failed to update conflict state: {}", e))?;
148
149        Ok(ResolveResponse {
150            resolved_files,
151            remaining_conflicts: remaining.len(),
152            merge_complete: false,
153            message: format!("{} conflict(s) remaining", remaining.len()),
154        })
155    }
156}
157
158/// Finalize a merge after all conflicts are resolved
159fn finalize_merge(
160    repo_root: &std::path::Path,
161    store: &ObjectStore,
162) -> Result<ResolveResponse, crate::errors::LitError> {
163    let merge_dir = repo_root.join(".lit").join("merge");
164
165    // Check no remaining conflicts
166    let conflicts_path = merge_dir.join("conflicts.json");
167    if conflicts_path.exists() {
168        let data = fs::read_to_string(&conflicts_path)
169            .map_err(|e| format!("Failed to read conflicts: {}", e))?;
170        let remaining: Vec<crate::core::merge::FileMergeResult> =
171            serde_json::from_str(&data).unwrap_or_default();
172        let conflict_count = remaining
173            .iter()
174            .filter(|r| r.status == crate::core::merge::FileMergeStatus::Conflict)
175            .count();
176        if conflict_count > 0 {
177            return Err(format!(
178                "{} unresolved conflict(s) remain. Resolve them first.",
179                conflict_count
180            )
181            .into());
182        }
183    }
184
185    // Read MERGE_HEAD
186    let merge_head_str = fs::read_to_string(merge_dir.join("MERGE_HEAD"))
187        .map_err(|e| format!("Failed to read MERGE_HEAD: {}", e))?
188        .trim()
189        .to_string();
190    let merge_head = ObjectHash::from_hex(merge_head_str);
191
192    let head_hash_str = crate::core::read_head(repo_root)?;
193    let head_hash = ObjectHash::from_hex(head_hash_str);
194
195    // Build tree from current index
196    let index = Index::load(repo_root)?;
197    let tree_hash = build_tree_from_index(&index, store)?;
198
199    // Create merge commit
200    let author = std::env::var("USER")
201        .or_else(|_| std::env::var("USERNAME"))
202        .unwrap_or_else(|_| "Unknown".to_string());
203
204    let commit = Commit::new(
205        tree_hash,
206        vec![head_hash, merge_head],
207        author,
208        "Merge commit (conflicts resolved)".to_string(),
209    );
210
211    let commit_obj = Object::Commit(commit);
212    let commit_hash = store.write(&commit_obj)?;
213
214    // Update branch ref
215    let current_branch = get_current_branch(repo_root).unwrap_or_else(|_| "main".to_string());
216    write_ref(
217        repo_root,
218        &format!("heads/{}", current_branch),
219        commit_hash.as_str(),
220    )?;
221
222    // Clean up merge state
223    let _ = fs::remove_dir_all(&merge_dir);
224
225    Ok(ResolveResponse {
226        resolved_files: vec![],
227        remaining_conflicts: 0,
228        merge_complete: true,
229        message: format!("Merge complete: {}", commit_hash.short()),
230    })
231}
232
233fn get_commit_tree(
234    store: &ObjectStore,
235    commit_hash: &ObjectHash,
236) -> Result<crate::core::Tree, crate::errors::LitError> {
237    let commit = match store.read(commit_hash)? {
238        Object::Commit(c) => c,
239        _ => return Err(format!("Expected commit object for {}", commit_hash).into()),
240    };
241    match store.read(&commit.tree)? {
242        Object::Tree(t) => Ok(t),
243        _ => Err(format!("Expected tree object for {}", commit.tree).into()),
244    }
245}
246
247/// Build tree from index (same logic as commit command)
248fn build_tree_from_index(
249    index: &Index,
250    store: &ObjectStore,
251) -> Result<ObjectHash, crate::errors::LitError> {
252    use crate::core::Tree;
253    use std::collections::HashMap;
254
255    let mut tree_map: HashMap<String, Vec<(String, String, String)>> = HashMap::new();
256
257    for entry in index.sorted_entries() {
258        let parts: Vec<&str> = entry.path.split('/').collect();
259        if parts.len() == 1 {
260            tree_map.entry("".to_string()).or_default().push((
261                parts[0].to_string(),
262                entry.hash.clone(),
263                entry.mode.clone(),
264            ));
265        } else {
266            let dir = parts[0].to_string();
267            tree_map.entry(dir).or_default().push((
268                parts[1..].join("/"),
269                entry.hash.clone(),
270                entry.mode.clone(),
271            ));
272        }
273    }
274
275    let mut root_tree = Tree::new();
276
277    if let Some(root_files) = tree_map.get("") {
278        for (name, hash, mode) in root_files {
279            root_tree.add_entry(
280                mode.clone(),
281                name.clone(),
282                ObjectHash::from_hex(hash.clone()),
283                "blob".to_string(),
284            );
285        }
286    }
287
288    for dir in tree_map.keys() {
289        if !dir.is_empty() {
290            let mut subtree = Tree::new();
291            if let Some(files) = tree_map.get(dir) {
292                for (name, hash, mode) in files {
293                    subtree.add_entry(
294                        mode.clone(),
295                        name.clone(),
296                        ObjectHash::from_hex(hash.clone()),
297                        "blob".to_string(),
298                    );
299                }
300            }
301            let subtree_object = Object::Tree(subtree);
302            let subtree_hash = store.write(&subtree_object)?;
303            root_tree.add_entry(
304                "040000".to_string(),
305                dir.clone(),
306                subtree_hash,
307                "tree".to_string(),
308            );
309        }
310    }
311
312    let tree_object = Object::Tree(root_tree);
313    store.write(&tree_object).map_err(Into::into)
314}