Skip to main content

lit/commands/
cherry_pick.rs

1use crate::core::{
2    find_repo_root, get_current_branch, read_head, write_ref, Commit, Object, ObjectHash, Tree,
3};
4use crate::response::CherryPickResponse;
5use crate::storage::ObjectStore;
6
7pub fn execute(target: String) -> Result<CherryPickResponse, crate::errors::LitError> {
8    let repo_root = find_repo_root()?;
9    let store = ObjectStore::new(&repo_root);
10
11    // Resolve target to commit
12    let commit_hash = resolve_rev(&repo_root, &target)?;
13    let hash_obj = ObjectHash::from_hex(commit_hash.clone());
14
15    let commit = match store.read(&hash_obj)? {
16        Object::Commit(c) => c,
17        _ => return Err(format!("'{}' is not a commit", target).into()),
18    };
19
20    // Get parent tree of the commit being cherry-picked
21    let parent_hash = commit
22        .parents
23        .first()
24        .ok_or("Cannot cherry-pick a root commit")?;
25    let parent_commit = match store.read(parent_hash)? {
26        Object::Commit(c) => c,
27        _ => return Err("Parent is not a commit".into()),
28    };
29
30    // Get current HEAD
31    let head_hash_str = read_head(&repo_root)?;
32    let head_hash = ObjectHash::from_hex(head_hash_str.clone());
33    let head_commit = match store.read(&head_hash)? {
34        Object::Commit(c) => c,
35        _ => return Err("HEAD is not a commit".into()),
36    };
37
38    // Apply the diff (parent → commit) onto HEAD
39    let parent_files = load_tree_files(&store, &parent_commit.tree)?;
40    let commit_files = load_tree_files(&store, &commit.tree)?;
41    let head_files = load_tree_files(&store, &head_commit.tree)?;
42
43    let mut new_tree = Tree::new();
44    let mut changed_files = Vec::new();
45
46    // Start with HEAD tree
47    for (path, (hash, mode)) in &head_files {
48        let in_parent = parent_files.get(path);
49        let in_commit = commit_files.get(path);
50
51        match (in_parent, in_commit) {
52            // File modified by the cherry-picked commit
53            (Some(pv), Some(cv)) if pv.0 != cv.0 => {
54                changed_files.push(path.clone());
55                new_tree.add_entry(
56                    cv.1.clone(),
57                    path.clone(),
58                    ObjectHash::from_hex(cv.0.clone()),
59                    "blob".to_string(),
60                );
61            }
62            // File deleted by the cherry-picked commit
63            (Some(_), None) => {
64                changed_files.push(path.clone());
65                continue; // Don't include in new tree
66            }
67            // File not changed by cherry-picked commit → keep HEAD version
68            _ => {
69                new_tree.add_entry(
70                    mode.clone(),
71                    path.clone(),
72                    ObjectHash::from_hex(hash.clone()),
73                    "blob".to_string(),
74                );
75            }
76        }
77    }
78
79    // Files added by the cherry-picked commit
80    for (path, (hash, mode)) in &commit_files {
81        if !parent_files.contains_key(path) && !head_files.contains_key(path) {
82            changed_files.push(path.clone());
83            new_tree.add_entry(
84                mode.clone(),
85                path.clone(),
86                ObjectHash::from_hex(hash.clone()),
87                "blob".to_string(),
88            );
89        }
90    }
91
92    let tree_hash = store.write(&Object::Tree(new_tree))?;
93
94    let author = std::env::var("USER")
95        .or_else(|_| std::env::var("USERNAME"))
96        .unwrap_or_else(|_| "Unknown".to_string());
97
98    let new_commit = Commit::new(
99        tree_hash,
100        vec![ObjectHash::from_hex(head_hash_str)],
101        author,
102        commit.message.clone(),
103    );
104
105    let new_hash = store.write(&Object::Commit(new_commit))?;
106
107    let branch = get_current_branch(&repo_root).unwrap_or_else(|_| "main".to_string());
108    write_ref(&repo_root, &format!("heads/{}", branch), new_hash.as_str())?;
109
110    Ok(CherryPickResponse {
111        source_commit: commit_hash[..16.min(commit_hash.len())].to_string(),
112        new_commit: new_hash.short(),
113        files_changed: changed_files.len(),
114        message: format!(
115            "Cherry-picked {} as {}",
116            &commit_hash[..16.min(commit_hash.len())],
117            new_hash.short()
118        ),
119    })
120}
121
122fn load_tree_files(
123    store: &ObjectStore,
124    tree_hash: &ObjectHash,
125) -> Result<std::collections::HashMap<String, (String, String)>, String> {
126    let tree = match store.read(tree_hash)? {
127        Object::Tree(t) => t,
128        _ => return Err("Not a tree".into()),
129    };
130    let mut files = std::collections::HashMap::new();
131    for entry in &tree.entries {
132        files.insert(
133            entry.name.clone(),
134            (entry.hash.to_string(), entry.mode.clone()),
135        );
136    }
137    Ok(files)
138}
139
140fn resolve_rev(
141    repo_root: &std::path::Path,
142    target: &str,
143) -> Result<String, crate::errors::LitError> {
144    if target.starts_with("HEAD~") || target.starts_with("HEAD^") {
145        let count: usize = target[5..].parse().unwrap_or(1);
146        let mut current = read_head(repo_root)?;
147        let store = ObjectStore::new(repo_root);
148        for _ in 0..count {
149            let hash = ObjectHash::from_hex(current);
150            let commit = match store.read(&hash)? {
151                Object::Commit(c) => c,
152                _ => return Err("Not a commit in history".into()),
153            };
154            current = commit
155                .parents
156                .first()
157                .ok_or("No parent commit")?
158                .to_string();
159        }
160        return Ok(current);
161    }
162    if target == "HEAD" {
163        return Ok(read_head(repo_root)?);
164    }
165    if let Ok(hash) = crate::core::read_ref(repo_root, &format!("heads/{}", target)) {
166        return Ok(hash);
167    }
168    if let Ok(hash) = crate::core::read_ref(repo_root, &format!("tags/{}", target)) {
169        return Ok(hash);
170    }
171    if target.len() >= 16 && target.chars().all(|c| c.is_ascii_hexdigit()) {
172        return Ok(target.to_string());
173    }
174    Err(format!("Cannot resolve '{}' to a commit", target).into())
175}