use crate::{Res, error::Error};
use git2::Repository;
use std::{
ffi::{OsStr, OsString},
fs,
path::{Path, PathBuf},
process::{Command, Stdio},
};
const CAPTURE_EDITOR: &str = r#"sh -c 'cat -- "$1" > "$GITU_REBASE_TODO"; exit 1' --"#;
const APPLY_EDITOR: &str = r#"sh -c 'cat -- "$GITU_REBASE_TODO" > "$1"' --"#;
const TODO_VAR: &str = "GITU_REBASE_TODO";
const TODO_FILE: &str = "gitu-rebase-todo";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TodoAction {
Pick,
Reword,
Edit,
Squash,
Fixup,
Drop,
}
impl TodoAction {
pub(crate) fn keyword(&self) -> &'static str {
match self {
TodoAction::Pick => "pick",
TodoAction::Reword => "reword",
TodoAction::Edit => "edit",
TodoAction::Squash => "squash",
TodoAction::Fixup => "fixup",
TodoAction::Drop => "drop",
}
}
fn parse(word: &str) -> Option<Self> {
match word {
"p" | "pick" => Some(TodoAction::Pick),
"r" | "reword" => Some(TodoAction::Reword),
"e" | "edit" => Some(TodoAction::Edit),
"s" | "squash" => Some(TodoAction::Squash),
"f" | "fixup" => Some(TodoAction::Fixup),
"d" | "drop" => Some(TodoAction::Drop),
_ => None,
}
}
}
#[derive(Debug)]
pub(crate) enum TodoEntry {
Commit { action: TodoAction, oid: String },
Other(String),
}
#[derive(Debug)]
pub(crate) struct RebaseTodo {
pub entries: Vec<TodoEntry>,
source: Source,
}
#[derive(Debug)]
enum Source {
Captured {
base: OsString,
args: Vec<OsString>,
todo_file: PathBuf,
workdir: PathBuf,
},
Editing(PathBuf),
}
impl RebaseTodo {
pub(crate) fn capture(repo: &Repository, base: &OsStr, args: &[OsString]) -> Res<Self> {
let workdir = repo.workdir().ok_or(Error::NoRepoWorkdir)?.to_path_buf();
let todo_file = repo.path().join(TODO_FILE);
let output = Command::new("git")
.args(["rebase", "-i"])
.args(args)
.arg(base)
.env("GIT_SEQUENCE_EDITOR", CAPTURE_EDITOR)
.env(TODO_VAR, &todo_file)
.current_dir(&workdir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(Error::SpawnCmd)?;
let Ok(text) = fs::read_to_string(&todo_file) else {
return Err(Error::CmdBadExit(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
output.status.code(),
));
};
let _ = fs::remove_file(&todo_file);
Ok(Self {
entries: read_entries(repo, &text),
source: Source::Captured {
base: base.to_os_string(),
args: args.to_vec(),
todo_file,
workdir,
},
})
}
pub(crate) fn read(repo: &Repository, path: &Path) -> Res<Self> {
let text = fs::read_to_string(path).map_err(Error::ReadRebaseTodo)?;
Ok(Self {
entries: read_entries(repo, &text),
source: Source::Editing(path.to_path_buf()),
})
}
pub(crate) fn revs(&self) -> Vec<String> {
let commits = self.entries.iter().filter_map(|entry| match entry {
TodoEntry::Commit { oid, .. } => Some(oid.clone()),
TodoEntry::Other(_) => None,
});
let mut revs: Vec<String> = commits.collect();
if !revs.is_empty() {
revs.insert(0, "--no-walk=unsorted".to_string());
}
revs
}
pub(crate) fn start(&self) -> Res<Option<Command>> {
match &self.source {
Source::Captured { todo_file, .. } => {
fs::write(todo_file, self.text()).map_err(Error::WriteRebaseTodo)?;
Ok(Some(self.apply_cmd(todo_file)))
}
Source::Editing(path) => {
fs::write(path, self.text()).map_err(Error::WriteRebaseTodo)?;
Ok(None)
}
}
}
fn apply_cmd(&self, todo_file: &Path) -> Command {
let Source::Captured {
base,
args,
workdir,
..
} = &self.source
else {
unreachable!("only a captured list is applied by running git")
};
let mut cmd = Command::new("git");
cmd.args(["rebase", "-i"])
.args(args)
.arg(base)
.env("GIT_SEQUENCE_EDITOR", APPLY_EDITOR)
.env(TODO_VAR, todo_file)
.current_dir(workdir);
cmd
}
pub(crate) fn is_editing(&self) -> bool {
matches!(self.source, Source::Editing(_))
}
pub(crate) fn discard_file(&self) {
if let Source::Captured { todo_file, .. } = &self.source {
let _ = fs::remove_file(todo_file);
}
}
fn text(&self) -> String {
self.entries
.iter()
.rev()
.map(|entry| match entry {
TodoEntry::Commit { action, oid } => format!("{} {oid}\n", action.keyword()),
TodoEntry::Other(line) => format!("{line}\n"),
})
.collect()
}
pub(crate) fn move_entry(&mut self, index: usize, offset: isize) -> usize {
let to = index.saturating_add_signed(offset);
if index >= self.entries.len() || to >= self.entries.len() {
return index;
}
let entry = self.entries.remove(index);
self.entries.insert(to, entry);
to
}
pub(crate) fn set_action(&mut self, index: usize, action: TodoAction) {
if let Some(TodoEntry::Commit {
action: current, ..
}) = self.entries.get_mut(index)
{
*current = action;
}
}
}
fn read_entries(repo: &Repository, text: &str) -> Vec<TodoEntry> {
let mut entries = parse(repo, text);
entries.reverse();
entries
}
fn parse(repo: &Repository, text: &str) -> Vec<TodoEntry> {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| {
let mut words = line.split_whitespace();
let action = words.next().and_then(TodoAction::parse);
let oid = words
.next()
.and_then(|rev| repo.revparse_single(rev).ok())
.map(|object| object.id().to_string());
match (action, oid) {
(Some(action), Some(oid)) => TodoEntry::Commit { action, oid },
_ => TodoEntry::Other(line.to_string()),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn todo(oids: &[&str]) -> RebaseTodo {
RebaseTodo {
source: Source::Editing(PathBuf::new()),
entries: oids
.iter()
.map(|oid| TodoEntry::Commit {
action: TodoAction::Pick,
oid: oid.to_string(),
})
.collect(),
}
}
fn oids(todo: &RebaseTodo) -> Vec<&str> {
todo.entries
.iter()
.map(|entry| match entry {
TodoEntry::Commit { oid, .. } => oid.as_str(),
TodoEntry::Other(line) => line.as_str(),
})
.collect()
}
#[test]
fn move_entry_shifts_and_reports_where_it_landed() {
let mut todo = todo(&["a", "b", "c"]);
assert_eq!(todo.move_entry(0, 1), 1);
assert_eq!(oids(&todo), ["b", "a", "c"]);
assert_eq!(todo.move_entry(1, -1), 0);
assert_eq!(oids(&todo), ["a", "b", "c"]);
}
#[test]
fn move_entry_stops_at_the_ends() {
let mut todo = todo(&["a", "b"]);
assert_eq!(todo.move_entry(0, -1), 0);
assert_eq!(todo.move_entry(1, 1), 1);
assert_eq!(oids(&todo), ["a", "b"]);
}
#[test]
fn text_is_one_instruction_per_entry_oldest_first() {
let mut todo = todo(&["aaa", "bbb"]);
todo.set_action(1, TodoAction::Fixup);
todo.entries.push(TodoEntry::Other("exec make test".into()));
assert_eq!(todo.text(), "exec make test\nfixup bbb\npick aaa\n");
}
#[test]
fn set_action_leaves_non_commit_entries_alone() {
let mut todo = todo(&[]);
todo.entries.push(TodoEntry::Other("break".into()));
todo.set_action(0, TodoAction::Drop);
assert_eq!(todo.text(), "break\n");
}
}