#![deny(clippy::nursery, clippy::cargo)]
use args::*;
mod args;
use errors::*;
mod errors;
use git::*;
mod git;
use config::*;
mod config;
use hash::*;
mod hash;
use std::collections::HashSet;
use std::hash::BuildHasherDefault;
use std::io::{BufRead, BufReader, Write};
use std::process::{exit, Child, Command, Stdio};
use std::{env, io, str};
use structopt::StructOpt;
use ahash::RandomState;
use regex::Regex;
struct MenuCommand {
command: String,
args: Vec<String>,
}
impl MenuCommand {
fn new(command: String, args: Vec<String>) -> Self {
Self { command, args }
}
}
fn run(args: Args) -> Result<()> {
if let Some(SubCommand::Completions(completions)) = args.subcommand {
return args::gen_completions(&completions);
}
let hasher = RandomState::new();
let mut unique = HashSet::<u64, BuildHasherDefault<IdentityHasher>>::default();
let config = Config::load(&args)?;
let toplevel = git_toplevel().context("failed to get git toplevel path")?;
env::set_current_dir(&toplevel)?;
let mut staged_files = git_staged_files()?;
if staged_files.is_empty() {
writeln!(
io::stderr(),
"Changes not staged for commit\nUse git add -p to stage changed files"
)
.ok();
exit(1);
}
let range = git_rev_range(&config)?.ok_or_else(|| {
writeln!(io::stderr(), "No local commits found\nTry --all or set smash.range=all to list published commits").ok();
exit(1);
}).unwrap();
if git_rev_parse(&range).is_err() {
bail!("Ambiguous argument '{}': unknown revision", range)
}
if let Some(target) = config.commit {
match git_rev_parse(&target) {
Err(_) => bail!("Ambiguous argument '{}': unknown revision", target),
Ok(target) => {
git_commit_fixup(&target, config.fixup_mode)?;
if config.auto_rebase {
git_rebase(&target, config.interactive)?;
}
return Ok(());
}
}
}
let mut cmd_sk = match config.mode {
DisplayMode::List => None,
_ => Some(spawn_menu().context("failed to spawn menu command")?),
};
if config.recent > 0 {
for rev in git_rev_list(&range, config.recent)
.with_context(|| format!("failed to get rev-list for {}", range))?
{
if !unique.insert(hash(&hasher, &rev)) {
continue;
}
let target = format_target(&rev, &config.format, &config.source_label_recent)?;
if !process_target(&target, &config.mode, &mut cmd_sk) {
break;
}
}
}
if config.blame {
let commits_from_blame = match config.blame {
true => get_commits_from_blame(&staged_files, &range)?,
false => Vec::new(),
};
for rev in commits_from_blame {
if !unique.insert(hash(&hasher, &rev)) {
continue;
}
let target = format_target(&rev, &config.format, &config.source_label_blame)?;
if !process_target(&target, &config.mode, &mut cmd_sk) {
break;
}
}
}
if config.files {
let mut cmd_file_revs = spawn_file_revs(
&mut staged_files,
&config.format,
&range,
config.max_count,
&config.source_label_files,
)?;
let stdout = cmd_file_revs
.stdout
.as_mut()
.context("failed to acquire stdout from git log command")?;
let stdout_reader = BufReader::new(stdout);
let stdout_lines = stdout_reader.split(b'\n');
for target in stdout_lines {
let target = target.context("failed to read bytes from stream")?;
let target = String::from_utf8_lossy(&target);
let target = target.trim_end();
let mut target = target.splitn(2, ' ');
let target_hash = target.next().context("failed to extract target hash")?;
let target = target.next().context("failed to extract target")?;
if !unique.insert(hash(&hasher, &target_hash)) {
continue;
}
if !process_target(target, &config.mode, &mut cmd_sk) {
break;
}
}
cmd_file_revs.kill()?;
}
if let Some(cmd_sk) = cmd_sk {
let output = cmd_sk.wait_with_output()?;
let target = select_target(output.stdout.as_ref())?;
if target.is_empty() {
return Ok(());
}
if !is_valid_git_rev(&target)? {
bail!("Selected commit '{}' not found\nPossibly --format or smash.format doesn't return a hash", target);
}
if config.mode == DisplayMode::Select {
writeln!(io::stdout(), "{}", &target).ok();
return Ok(());
}
git_commit_fixup(&target, config.fixup_mode)?;
if config.auto_rebase {
git_rebase(&target, config.interactive)?;
}
}
Ok(())
}
fn process_target(target: &str, mode: &DisplayMode, cmd_sk: &mut Option<Child>) -> bool {
match mode {
DisplayMode::List => {
let mut stdout = io::stdout();
if writeln!(stdout, "{}", target).is_err() {
exit(0);
}
}
_ => {
if let Some(ref mut cmd_sk) = cmd_sk {
if let Some(ref mut stdin) = cmd_sk.stdin {
if writeln!(stdin, "{}", &target).is_err() {
return false;
}
}
}
}
}
true
}
fn get_commits_from_blame(staged_files: &[String], range: &str) -> Result<Vec<String>> {
let mut diff_args = vec![
"--no-pager",
"diff",
"--color=never",
"--unified=1",
"--no-prefix",
"--cached",
]
.into_iter()
.map(|e| e.to_string())
.collect::<Vec<_>>();
diff_args.push("--".to_string());
let mut staged_files = staged_files.to_owned();
diff_args.append(&mut staged_files);
let cmd_diff = Command::new("git")
.args(&diff_args)
.stdout(Stdio::piped())
.spawn()?;
let output = cmd_diff.wait_with_output()?;
let re_split = Regex::new(r"(?m)^diff ")?;
let re_file = Regex::new(r"(?m)^--- (.*)")?;
let re_chunk = Regex::new(r"(?m)^@@ -([0-9]*),([0-9]*) .*")?;
let diff = String::from_utf8_lossy(&output.stdout);
let mut commits: Vec<String> = Vec::new();
for split in re_split.split(&diff).skip(1) {
let file = re_file
.captures(split)
.context("failed to match file in chunk")?;
let file = file.get(1).context("failed to get file group")?.as_str();
if file == "/dev/null" {
continue;
}
let mut blame_args = vec![
"--no-pager".to_string(),
"blame".to_string(),
"--no-abbrev".to_string(),
"-s".to_string(),
];
for chunks in re_chunk.captures_iter(split) {
let offset = chunks
.get(1)
.context("failed to get offset group")?
.as_str();
let length = chunks
.get(2)
.context("failed to get length group")?
.as_str();
let location = format!("{},+{}", offset, length);
blame_args.push("-L".to_string());
blame_args.push(location);
}
blame_args.push(range.to_string());
blame_args.push("--".to_string());
blame_args.push(file.to_string());
let blame_output = Command::new("git")
.args(blame_args)
.stdout(Stdio::piped())
.output()?;
let blame_output = String::from_utf8_lossy(&blame_output.stdout);
let split_commits: Vec<_> = blame_output
.lines()
.filter_map(|e| e.split_whitespace().next())
.collect();
for hash in split_commits {
if hash.starts_with('^') {
continue;
}
commits.push(hash.into());
}
}
Ok(commits)
}
fn spawn_file_revs(
staged_files: &mut Vec<String>,
format: &str,
range: &str,
max_count: u32,
source_format: &str,
) -> Result<Child> {
let format = format.replace("%(smash:source)", source_format);
let mut file_revs_args = vec![
"--no-pager",
"log",
"--invert-grep",
"--extended-regexp",
"--grep",
"^(fixup|squash)! .*$",
format!("--format=%H {}", format).as_str(),
range,
]
.into_iter()
.map(|e| e.to_string())
.collect::<Vec<_>>();
if max_count > 0 {
file_revs_args.push(format!("-{}", max_count));
}
file_revs_args.push("--".to_string());
file_revs_args.append(staged_files);
Ok(Command::new("git")
.args(&file_revs_args)
.stdout(Stdio::piped())
.spawn()?)
}
fn format_target(commit: &str, format: &str, source_format: &str) -> Result<String> {
let format = format.replace("%(smash:source)", source_format);
let format = format!("--format={}", format);
let args = vec!["--no-pager", "log", "-1", &format, commit];
let output = Command::new("git")
.stdout(Stdio::piped())
.args(&args)
.output()?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn spawn_menu() -> Result<Child> {
let menu = resolve_menu_command()?;
Ok(Command::new(menu.command)
.args(menu.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?)
}
#[allow(unknown_lints, clippy::manual_split_once)]
fn select_target(line: &[u8]) -> Result<String> {
let cow = String::from_utf8_lossy(line);
Ok(cow
.split(' ')
.next()
.context("failed to split first part of the target")?
.into())
}
fn resolve_command(command: &str) -> Result<Option<String>> {
let output = Command::new("sh")
.stdout(Stdio::piped())
.args(vec!["-c", format!("command -v {}", &command).as_ref()])
.output()?;
if !output.status.success() {
return Ok(None);
}
Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_owned(),
))
}
fn resolve_menu_command() -> Result<MenuCommand> {
let fuzzy_args = vec![
"--ansi".to_string(),
"--preview".to_string(),
"git show --stat --patch --color {+1}".to_string(),
];
for cmd in &[("sk", &fuzzy_args), ("fzf", &fuzzy_args)] {
if let Some(bin) = resolve_command(cmd.0)? {
return Ok(MenuCommand::new(bin, cmd.1.to_owned()));
}
}
bail!("Can't find any supported fuzzy matcher or menu command\nPlease install skim, fzf or configure one with smash.menu");
}
fn main() {
let args = Args::from_args();
if let Err(err) = run(args) {
eprintln!("Error: {}", err);
for cause in err.chain().skip(1) {
eprintln!("{}", cause);
}
exit(1);
}
}