1use 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 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 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 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 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 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 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 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 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") .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 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 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}