Skip to main content

xei_core/
rebase.rs

1//! Interactive rebase planner — edit pick/squash/fix/drop then run `git rebase -i`.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use crate::git_ops::{self, CommitSummary};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum RebaseAction {
10    Pick,
11    Reword,
12    Edit,
13    Squash,
14    Fixup,
15    Drop,
16}
17
18impl RebaseAction {
19    pub fn label(self) -> &'static str {
20        match self {
21            RebaseAction::Pick => "pick",
22            RebaseAction::Reword => "reword",
23            RebaseAction::Edit => "edit",
24            RebaseAction::Squash => "squash",
25            RebaseAction::Fixup => "fixup",
26            RebaseAction::Drop => "drop",
27        }
28    }
29
30    pub fn short(self) -> char {
31        match self {
32            RebaseAction::Pick => 'p',
33            RebaseAction::Reword => 'r',
34            RebaseAction::Edit => 'e',
35            RebaseAction::Squash => 's',
36            RebaseAction::Fixup => 'f',
37            RebaseAction::Drop => 'd',
38        }
39    }
40
41    pub fn cycle(self) -> Self {
42        match self {
43            RebaseAction::Pick => RebaseAction::Reword,
44            RebaseAction::Reword => RebaseAction::Edit,
45            RebaseAction::Edit => RebaseAction::Squash,
46            RebaseAction::Squash => RebaseAction::Fixup,
47            RebaseAction::Fixup => RebaseAction::Drop,
48            RebaseAction::Drop => RebaseAction::Pick,
49        }
50    }
51
52    pub fn from_char(c: char) -> Option<Self> {
53        match c {
54            'p' | 'P' => Some(RebaseAction::Pick),
55            'r' | 'R' => Some(RebaseAction::Reword),
56            'e' | 'E' => Some(RebaseAction::Edit),
57            's' | 'S' => Some(RebaseAction::Squash),
58            'f' | 'F' => Some(RebaseAction::Fixup),
59            'd' | 'D' => Some(RebaseAction::Drop),
60            _ => None,
61        }
62    }
63}
64
65#[derive(Debug, Clone)]
66pub struct RebaseEntry {
67    pub hash: String,
68    pub short: String,
69    pub subject: String,
70    pub action: RebaseAction,
71}
72
73#[derive(Debug, Clone)]
74pub struct RebaseState {
75    pub open: bool,
76    pub root: PathBuf,
77    /// Oldest first (rebase todo order).
78    pub entries: Vec<RebaseEntry>,
79    pub selected: usize,
80    pub message: String,
81    pub last_result: Option<String>,
82}
83
84impl Default for RebaseState {
85    fn default() -> Self {
86        Self {
87            open: false,
88            root: PathBuf::new(),
89            entries: Vec::new(),
90            selected: 0,
91            message: String::new(),
92            last_result: None,
93        }
94    }
95}
96
97impl RebaseState {
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    pub fn close(&mut self) {
103        self.open = false;
104        self.entries.clear();
105        self.message.clear();
106    }
107
108    /// Open planner for the last `count` commits (newest first in log → reverse for todo).
109    pub fn open_for(&mut self, root: &Path, count: usize) -> Result<(), String> {
110        let n = count.clamp(2, 50);
111        let commits = git_ops::list_commits(root, n, false)?;
112        if commits.len() < 2 {
113            return Err("Need at least 2 commits to rebase".into());
114        }
115        // list_commits is newest-first; rebase todo wants oldest-first
116        let mut entries: Vec<RebaseEntry> = commits
117            .into_iter()
118            .take(n)
119            .map(|c: CommitSummary| RebaseEntry {
120                hash: c.hash,
121                short: c.short,
122                subject: c.subject,
123                action: RebaseAction::Pick,
124            })
125            .collect();
126        entries.reverse();
127        self.root = root.to_path_buf();
128        self.entries = entries;
129        self.selected = 0;
130        self.open = true;
131        self.last_result = None;
132        self.message = format!(
133            "Interactive rebase · {} commits · Tab cycle · Enter run · Esc cancel",
134            self.entries.len()
135        );
136        Ok(())
137    }
138
139    pub fn move_sel(&mut self, delta: isize) {
140        if self.entries.is_empty() {
141            return;
142        }
143        let n = self.entries.len() as isize;
144        let cur = self.selected as isize + delta;
145        self.selected = cur.rem_euclid(n) as usize;
146    }
147
148    pub fn cycle_action(&mut self) {
149        if let Some(e) = self.entries.get_mut(self.selected) {
150            e.action = e.action.cycle();
151        }
152    }
153
154    pub fn set_action(&mut self, action: RebaseAction) {
155        if let Some(e) = self.entries.get_mut(self.selected) {
156            e.action = action;
157        }
158    }
159
160    /// Move selected entry up/down in the todo list.
161    pub fn move_entry(&mut self, delta: isize) {
162        if self.entries.len() < 2 {
163            return;
164        }
165        let i = self.selected;
166        let j = (i as isize + delta).clamp(0, (self.entries.len() - 1) as isize) as usize;
167        if i != j {
168            self.entries.swap(i, j);
169            self.selected = j;
170        }
171    }
172
173    fn todo_text(&self) -> String {
174        let mut out = String::new();
175        for e in &self.entries {
176            if e.action == RebaseAction::Drop {
177                continue;
178            }
179            out.push_str(&format!(
180                "{} {} {}\n",
181                e.action.label(),
182                e.hash,
183                e.subject
184            ));
185        }
186        out
187    }
188
189    /// Execute `git rebase -i` with our sequence via a temporary sequence editor script.
190    pub fn run(&mut self) -> Result<String, String> {
191        if self.entries.is_empty() {
192            return Err("Empty rebase plan".into());
193        }
194        if self
195            .entries
196            .iter()
197            .all(|e| e.action == RebaseAction::Drop)
198        {
199            return Err("All commits marked drop — nothing to do".into());
200        }
201
202        let todo = self.todo_text();
203        if todo.trim().is_empty() {
204            return Err("Rebase todo is empty".into());
205        }
206
207        let tmp_dir = std::env::temp_dir().join(format!("xei-rebase-{}", std::process::id()));
208        std::fs::create_dir_all(&tmp_dir).map_err(|e| e.to_string())?;
209        let todo_path = tmp_dir.join("git-rebase-todo");
210        let script_path = tmp_dir.join("seq-editor.sh");
211        std::fs::write(&todo_path, &todo).map_err(|e| e.to_string())?;
212
213        // Sequence editor: copy our todo over the path git passes as $1
214        let script = format!(
215            "#!/bin/sh\ncp '{}' \"$1\"\n",
216            todo_path.display()
217        );
218        std::fs::write(&script_path, script).map_err(|e| e.to_string())?;
219        #[cfg(unix)]
220        {
221            use std::os::unix::fs::PermissionsExt;
222            let mut perms = std::fs::metadata(&script_path)
223                .map_err(|e| e.to_string())?
224                .permissions();
225            perms.set_mode(0o755);
226            std::fs::set_permissions(&script_path, perms).map_err(|e| e.to_string())?;
227        }
228
229        // Rebase onto parent of oldest kept commit
230        let base = self
231            .entries
232            .iter()
233            .find(|e| e.action != RebaseAction::Drop)
234            .map(|e| e.hash.clone())
235            .ok_or_else(|| "No commits to rebase".to_string())?;
236
237        // `git rebase -i <base>^` replays commits after base's parent
238        let onto = format!("{base}^");
239        let output = Command::new("git")
240            .args(["rebase", "-i", &onto])
241            .current_dir(&self.root)
242            .env("GIT_SEQUENCE_EDITOR", &script_path)
243            .env("GIT_EDITOR", "true") // skip reword/edit body prompts if any
244            .output()
245            .map_err(|e| format!("git rebase failed to start: {e}"))?;
246
247        let stdout = String::from_utf8_lossy(&output.stdout);
248        let stderr = String::from_utf8_lossy(&output.stderr);
249        let combined = format!("{stdout}{stderr}").trim().to_string();
250
251        // Cleanup temp (best-effort)
252        let _ = std::fs::remove_dir_all(&tmp_dir);
253
254        if output.status.success() {
255            let msg = if combined.is_empty() {
256                format!("✓ Rebase done ({} commits)", self.entries.len())
257            } else {
258                combined.lines().next().unwrap_or("✓ Rebase done").to_string()
259            };
260            self.last_result = Some(msg.clone());
261            self.open = false;
262            Ok(msg)
263        } else {
264            // Conflict or other failure — leave open so user can abort
265            let msg = if combined.is_empty() {
266                "Rebase failed — try :rebase-abort".into()
267            } else {
268                format!("Rebase issue: {}", combined.lines().next().unwrap_or("failed"))
269            };
270            self.last_result = Some(msg.clone());
271            Err(msg)
272        }
273    }
274}
275
276pub fn rebase_abort(root: &Path) -> Result<String, String> {
277    git_ops::run_git(root, &["rebase", "--abort"]).map(|s| {
278        let t = s.trim();
279        if t.is_empty() {
280            "Rebase aborted".into()
281        } else {
282            t.to_string()
283        }
284    })
285}
286
287pub fn rebase_continue(root: &Path) -> Result<String, String> {
288    git_ops::run_git(root, &["rebase", "--continue"]).map(|s| {
289        let t = s.trim();
290        if t.is_empty() {
291            "Rebase continued".into()
292        } else {
293            t.to_string()
294        }
295    })
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn cycle_actions() {
304        let mut a = RebaseAction::Pick;
305        for _ in 0..6 {
306            a = a.cycle();
307        }
308        assert_eq!(a, RebaseAction::Pick);
309    }
310
311    #[test]
312    fn todo_skips_drop() {
313        let mut s = RebaseState::new();
314        s.entries = vec![
315            RebaseEntry {
316                hash: "aaa".into(),
317                short: "aaa".into(),
318                subject: "one".into(),
319                action: RebaseAction::Pick,
320            },
321            RebaseEntry {
322                hash: "bbb".into(),
323                short: "bbb".into(),
324                subject: "two".into(),
325                action: RebaseAction::Drop,
326            },
327            RebaseEntry {
328                hash: "ccc".into(),
329                short: "ccc".into(),
330                subject: "three".into(),
331                action: RebaseAction::Squash,
332            },
333        ];
334        let t = s.todo_text();
335        assert!(t.contains("pick aaa"));
336        assert!(!t.contains("bbb"));
337        assert!(t.contains("squash ccc"));
338    }
339}