Skip to main content

lit/commands/
rebase.rs

1use crate::core::{
2    find_repo_root, get_current_branch, read_head, write_ref, Commit, Object, ObjectHash, Tree,
3};
4use crate::response::RebaseResponse;
5use crate::storage::ObjectStore;
6use serde::{Deserialize, Serialize};
7use std::fs;
8
9/// Rebase todo entry for interactive rebase
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct RebaseTodoEntry {
12    pub action: String,
13    pub hash: String,
14    pub short_hash: String,
15    pub message: String,
16}
17
18pub fn execute(
19    base: String,
20    interactive: bool,
21    onto: Option<String>,
22    abort: bool,
23    cont: bool,
24) -> Result<RebaseResponse, crate::errors::LitError> {
25    let repo_root = find_repo_root()?;
26
27    if abort {
28        return rebase_abort(&repo_root);
29    }
30
31    if cont {
32        return rebase_continue(&repo_root);
33    }
34
35    if interactive {
36        return rebase_interactive(&repo_root, &base);
37    }
38
39    rebase_noninteractive(&repo_root, &base, onto)
40}
41
42fn rebase_noninteractive(
43    repo_root: &std::path::Path,
44    base: &str,
45    onto: Option<String>,
46) -> Result<RebaseResponse, crate::errors::LitError> {
47    let store = ObjectStore::new(repo_root);
48
49    let base_hash = resolve_rev(repo_root, base)?;
50    let head_hash = read_head(repo_root)?;
51    let current_branch = get_current_branch(repo_root)?;
52
53    let onto_hash = match &onto {
54        Some(o) => resolve_rev(repo_root, o)?,
55        None => base_hash.clone(),
56    };
57
58    // Collect commits to replay: HEAD back to (but not including) base
59    let commits_to_replay = collect_commits_since(&store, &head_hash, &base_hash)?;
60
61    if commits_to_replay.is_empty() {
62        return Ok(RebaseResponse {
63            rebased_commits: 0,
64            onto: onto_hash[..16.min(onto_hash.len())].to_string(),
65            branch: current_branch,
66            message: "Already up to date, nothing to rebase".to_string(),
67            todo: None,
68        });
69    }
70
71    // Save rebase state
72    save_rebase_state(repo_root, &current_branch, &head_hash, &onto_hash)?;
73
74    // Replay commits one by one onto the new base
75    let mut current_parent = onto_hash.clone();
76    let mut replayed = 0;
77
78    for (_commit_hash, commit) in commits_to_replay.iter().rev() {
79        let new_parent_obj = ObjectHash::from_hex(current_parent.clone());
80
81        // Apply this commit's changes on top of current_parent
82        let new_tree = apply_commit_onto(&store, commit, &new_parent_obj)?;
83
84        let new_commit = Commit::new(
85            new_tree,
86            vec![new_parent_obj],
87            commit.author.clone(),
88            commit.message.clone(),
89        );
90
91        let new_hash = store.write(&Object::Commit(new_commit))?;
92        current_parent = new_hash.to_string();
93        replayed += 1;
94    }
95
96    // Update branch ref
97    write_ref(
98        repo_root,
99        &format!("heads/{}", current_branch),
100        &current_parent,
101    )?;
102
103    // Clean up rebase state
104    cleanup_rebase_state(repo_root)?;
105
106    Ok(RebaseResponse {
107        rebased_commits: replayed,
108        onto: onto_hash[..16.min(onto_hash.len())].to_string(),
109        branch: current_branch,
110        message: format!(
111            "Successfully rebased {} commit(s) onto {}",
112            replayed,
113            &onto_hash[..16.min(onto_hash.len())]
114        ),
115        todo: None,
116    })
117}
118
119fn rebase_interactive(
120    repo_root: &std::path::Path,
121    base: &str,
122) -> Result<RebaseResponse, crate::errors::LitError> {
123    let store = ObjectStore::new(repo_root);
124
125    let base_hash = resolve_rev(repo_root, base)?;
126    let head_hash = read_head(repo_root)?;
127    let current_branch = get_current_branch(repo_root)?;
128
129    let commits = collect_commits_since(&store, &head_hash, &base_hash)?;
130
131    if commits.is_empty() {
132        return Ok(RebaseResponse {
133            rebased_commits: 0,
134            onto: base_hash[..16.min(base_hash.len())].to_string(),
135            branch: current_branch,
136            message: "Nothing to rebase".to_string(),
137            todo: None,
138        });
139    }
140
141    // Build interactive todo list (reversed so oldest first)
142    let todo: Vec<RebaseTodoEntry> = commits
143        .iter()
144        .rev()
145        .map(|(hash, commit)| RebaseTodoEntry {
146            action: "pick".to_string(),
147            hash: hash.clone(),
148            short_hash: hash[..16.min(hash.len())].to_string(),
149            message: commit.message.clone(),
150        })
151        .collect();
152
153    // Save the todo to disk for the agent to modify and then `--continue`
154    save_rebase_state(repo_root, &current_branch, &head_hash, &base_hash)?;
155    let todo_path = repo_root.join(".lit").join("rebase").join("todo.json");
156    let todo_json = serde_json::to_string_pretty(&todo)
157        .map_err(|e| format!("Failed to serialize rebase todo: {}", e))?;
158    fs::write(&todo_path, &todo_json).map_err(|e| format!("Failed to write rebase todo: {}", e))?;
159
160    Ok(RebaseResponse {
161        rebased_commits: 0,
162        onto: base_hash[..16.min(base_hash.len())].to_string(),
163        branch: current_branch,
164        message: format!(
165            "Interactive rebase started with {} commit(s). Edit .lit/rebase/todo.json and run `lit rebase --continue`",
166            todo.len()
167        ),
168        todo: Some(serde_json::to_value(&todo).unwrap_or_default()),
169    })
170}
171
172fn rebase_continue(repo_root: &std::path::Path) -> Result<RebaseResponse, crate::errors::LitError> {
173    let rebase_dir = repo_root.join(".lit").join("rebase");
174    if !rebase_dir.exists() {
175        return Err("No rebase in progress".into());
176    }
177
178    let store = ObjectStore::new(repo_root);
179
180    let branch = fs::read_to_string(rebase_dir.join("branch"))
181        .map_err(|e| format!("Failed to read rebase state: {}", e))?
182        .trim()
183        .to_string();
184
185    let onto_hash = fs::read_to_string(rebase_dir.join("onto"))
186        .map_err(|e| format!("Failed to read rebase state: {}", e))?
187        .trim()
188        .to_string();
189
190    let todo_path = rebase_dir.join("todo.json");
191    let todo_json =
192        fs::read_to_string(&todo_path).map_err(|e| format!("Failed to read rebase todo: {}", e))?;
193    let todo: Vec<RebaseTodoEntry> = serde_json::from_str(&todo_json)
194        .map_err(|e| format!("Failed to parse rebase todo: {}", e))?;
195
196    let mut current_parent = onto_hash.clone();
197    let mut replayed = 0;
198
199    for entry in &todo {
200        match entry.action.as_str() {
201            "pick" | "p" => {
202                let hash = ObjectHash::from_hex(entry.hash.clone());
203                let commit = match store.read(&hash)? {
204                    Object::Commit(c) => c,
205                    _ => return Err(format!("'{}' is not a commit", entry.hash).into()),
206                };
207
208                let parent_obj = ObjectHash::from_hex(current_parent.clone());
209                let new_tree = apply_commit_onto(&store, &commit, &parent_obj)?;
210
211                let new_commit = Commit::new(
212                    new_tree,
213                    vec![parent_obj],
214                    commit.author.clone(),
215                    commit.message.clone(),
216                );
217
218                let new_hash = store.write(&Object::Commit(new_commit))?;
219                current_parent = new_hash.to_string();
220                replayed += 1;
221            }
222            "drop" | "d" => {
223                // Skip this commit
224                continue;
225            }
226            "reword" | "r" => {
227                let hash = ObjectHash::from_hex(entry.hash.clone());
228                let commit = match store.read(&hash)? {
229                    Object::Commit(c) => c,
230                    _ => return Err(format!("'{}' is not a commit", entry.hash).into()),
231                };
232
233                let parent_obj = ObjectHash::from_hex(current_parent.clone());
234                let new_tree = apply_commit_onto(&store, &commit, &parent_obj)?;
235
236                // Use the message from the todo entry (agent may have modified it)
237                let new_commit = Commit::new(
238                    new_tree,
239                    vec![parent_obj],
240                    commit.author.clone(),
241                    entry.message.clone(),
242                );
243
244                let new_hash = store.write(&Object::Commit(new_commit))?;
245                current_parent = new_hash.to_string();
246                replayed += 1;
247            }
248            other => {
249                return Err(format!("Unknown rebase action: '{}'", other).into());
250            }
251        }
252    }
253
254    write_ref(repo_root, &format!("heads/{}", branch), &current_parent)?;
255
256    cleanup_rebase_state(repo_root)?;
257
258    Ok(RebaseResponse {
259        rebased_commits: replayed,
260        onto: onto_hash[..16.min(onto_hash.len())].to_string(),
261        branch,
262        message: format!("Successfully rebased {} commit(s)", replayed),
263        todo: None,
264    })
265}
266
267fn rebase_abort(repo_root: &std::path::Path) -> Result<RebaseResponse, crate::errors::LitError> {
268    let rebase_dir = repo_root.join(".lit").join("rebase");
269    if !rebase_dir.exists() {
270        return Err("No rebase in progress".into());
271    }
272
273    let branch = fs::read_to_string(rebase_dir.join("branch"))
274        .map_err(|e| format!("Failed to read rebase state: {}", e))?
275        .trim()
276        .to_string();
277
278    let orig_head = fs::read_to_string(rebase_dir.join("orig_head"))
279        .map_err(|e| format!("Failed to read rebase state: {}", e))?
280        .trim()
281        .to_string();
282
283    // Restore original HEAD
284    write_ref(repo_root, &format!("heads/{}", branch), &orig_head)?;
285
286    cleanup_rebase_state(repo_root)?;
287
288    Ok(RebaseResponse {
289        rebased_commits: 0,
290        onto: String::new(),
291        branch,
292        message: "Rebase aborted, HEAD restored to original position".to_string(),
293        todo: None,
294    })
295}
296
297fn collect_commits_since(
298    store: &ObjectStore,
299    head: &str,
300    base: &str,
301) -> Result<Vec<(String, Commit)>, String> {
302    let mut commits = Vec::new();
303    let mut current = head.to_string();
304
305    loop {
306        if current == base {
307            break;
308        }
309
310        let hash = ObjectHash::from_hex(current.clone());
311        let commit = match store.read(&hash)? {
312            Object::Commit(c) => c,
313            _ => return Err("Not a commit in history".into()),
314        };
315
316        let parent = commit.parents.first().map(|p| p.to_string());
317        commits.push((current, commit));
318
319        match parent {
320            Some(p) => current = p,
321            None => break,
322        }
323    }
324
325    Ok(commits)
326}
327
328fn apply_commit_onto(
329    store: &ObjectStore,
330    commit: &Commit,
331    new_parent: &ObjectHash,
332) -> Result<ObjectHash, crate::errors::LitError> {
333    // Get parent's tree (the new base)
334    let parent_commit = match store.read(new_parent)? {
335        Object::Commit(c) => c,
336        _ => return Err("Parent is not a commit".into()),
337    };
338
339    // Get the original parent's tree
340    let orig_parent_tree = if let Some(orig_parent) = commit.parents.first() {
341        match store.read(orig_parent)? {
342            Object::Commit(c) => load_tree_files(store, &c.tree)?,
343            _ => std::collections::HashMap::new(),
344        }
345    } else {
346        std::collections::HashMap::new()
347    };
348
349    let commit_tree_files = load_tree_files(store, &commit.tree)?;
350    let new_base_files = load_tree_files(store, &parent_commit.tree)?;
351
352    let mut result_tree = Tree::new();
353
354    // Start with new base, apply diff from orig_parent → commit
355    for (path, (hash, mode)) in &new_base_files {
356        let in_orig = orig_parent_tree.get(path);
357        let in_commit = commit_tree_files.get(path);
358
359        match (in_orig, in_commit) {
360            // File modified in commit → use commit version
361            (Some(ov), Some(cv)) if ov.0 != cv.0 => {
362                result_tree.add_entry(
363                    cv.1.clone(),
364                    path.clone(),
365                    ObjectHash::from_hex(cv.0.clone()),
366                    "blob".to_string(),
367                );
368            }
369            // File deleted in commit → skip
370            (Some(_), None) => continue,
371            // No change → keep new base version
372            _ => {
373                result_tree.add_entry(
374                    mode.clone(),
375                    path.clone(),
376                    ObjectHash::from_hex(hash.clone()),
377                    "blob".to_string(),
378                );
379            }
380        }
381    }
382
383    // Files added in commit
384    for (path, (hash, mode)) in &commit_tree_files {
385        if !orig_parent_tree.contains_key(path) && !new_base_files.contains_key(path) {
386            result_tree.add_entry(
387                mode.clone(),
388                path.clone(),
389                ObjectHash::from_hex(hash.clone()),
390                "blob".to_string(),
391            );
392        }
393    }
394
395    store.write(&Object::Tree(result_tree)).map_err(Into::into)
396}
397
398fn load_tree_files(
399    store: &ObjectStore,
400    tree_hash: &ObjectHash,
401) -> Result<std::collections::HashMap<String, (String, String)>, crate::errors::LitError> {
402    let tree = match store.read(tree_hash)? {
403        Object::Tree(t) => t,
404        _ => return Err("Not a tree".into()),
405    };
406    let mut files = std::collections::HashMap::new();
407    for entry in &tree.entries {
408        files.insert(
409            entry.name.clone(),
410            (entry.hash.to_string(), entry.mode.clone()),
411        );
412    }
413    Ok(files)
414}
415
416fn save_rebase_state(
417    repo_root: &std::path::Path,
418    branch: &str,
419    orig_head: &str,
420    onto: &str,
421) -> Result<(), crate::errors::LitError> {
422    let rebase_dir = repo_root.join(".lit").join("rebase");
423    fs::create_dir_all(&rebase_dir)
424        .map_err(|e| format!("Failed to create rebase directory: {}", e))?;
425    fs::write(rebase_dir.join("branch"), branch)
426        .map_err(|e| format!("Failed to save rebase state: {}", e))?;
427    fs::write(rebase_dir.join("orig_head"), orig_head)
428        .map_err(|e| format!("Failed to save rebase state: {}", e))?;
429    fs::write(rebase_dir.join("onto"), onto)
430        .map_err(|e| format!("Failed to save rebase state: {}", e))?;
431    Ok(())
432}
433
434fn cleanup_rebase_state(repo_root: &std::path::Path) -> Result<(), crate::errors::LitError> {
435    let rebase_dir = repo_root.join(".lit").join("rebase");
436    if rebase_dir.exists() {
437        fs::remove_dir_all(&rebase_dir)
438            .map_err(|e| format!("Failed to clean up rebase state: {}", e))?;
439    }
440    Ok(())
441}
442
443fn resolve_rev(
444    repo_root: &std::path::Path,
445    target: &str,
446) -> Result<String, crate::errors::LitError> {
447    if target.starts_with("HEAD~") || target.starts_with("HEAD^") {
448        let count: usize = target[5..].parse().unwrap_or(1);
449        let mut current = read_head(repo_root)?;
450        let store = ObjectStore::new(repo_root);
451        for _ in 0..count {
452            let hash = ObjectHash::from_hex(current);
453            let commit = match store.read(&hash)? {
454                Object::Commit(c) => c,
455                _ => return Err("Not a commit in history".into()),
456            };
457            current = commit
458                .parents
459                .first()
460                .ok_or("No parent commit")?
461                .to_string();
462        }
463        return Ok(current);
464    }
465    if target == "HEAD" {
466        return Ok(read_head(repo_root)?);
467    }
468    if let Ok(hash) = crate::core::read_ref(repo_root, &format!("heads/{}", target)) {
469        return Ok(hash);
470    }
471    if let Ok(hash) = crate::core::read_ref(repo_root, &format!("tags/{}", target)) {
472        return Ok(hash);
473    }
474    if target.len() >= 16 && target.chars().all(|c| c.is_ascii_hexdigit()) {
475        return Ok(target.to_string());
476    }
477    Err(format!("Cannot resolve '{}' to a commit", target).into())
478}