use anyhow::{Context, Result};
use clap::Parser;
use git2::{Commit, Repository};
use log::{debug, info, warn};
use std::path::PathBuf;
use std::process::Command;
#[derive(Parser, Debug)]
#[clap(author, version = env!("CARGO_PKG_VERSION"), about)]
struct Args {
#[clap(long, short = 'f')]
file: PathBuf,
#[clap(long, short = 'c')]
cmd: Option<String>,
#[clap(long, short = 'r', default_value = ".")]
repo_path: PathBuf,
#[clap(long, short = 'R', default_value = "true")]
restore: bool,
#[clap(long, short = 'v')]
verbose: bool,
#[clap(long, short = 'p')]
pytest: bool,
#[clap(long, short = 't')]
test: Option<String>,
}
fn main() -> Result<()> {
let args = Args::parse();
if args.cmd.is_none() && !args.pytest {
return Err(anyhow::anyhow!("Either --cmd or --pytest must be provided"));
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
if args.verbose { "debug" } else { "info" },
))
.init();
info!("Starting tracegit");
debug!("Arguments: {:?}", args);
let repo = Repository::open(&args.repo_path)
.with_context(|| format!("Failed to open repository at {:?}", args.repo_path))?;
let original_head = repo
.head()
.with_context(|| "Failed to get current HEAD")?;
let original_head_commit = original_head
.peel_to_commit()
.with_context(|| "Failed to get current HEAD commit")?;
info!("Current HEAD is at commit: {}", original_head_commit.id());
let mut revwalk = repo.revwalk().with_context(|| "Failed to create revision walker")?;
revwalk
.push_head()
.with_context(|| "Failed to push HEAD to revision walker")?;
let mut found_working_commit = false;
for oid_result in revwalk {
let oid = oid_result.with_context(|| "Failed to get commit OID")?;
let commit = repo
.find_commit(oid)
.with_context(|| format!("Failed to find commit {}", oid))?;
debug!("Checking commit: {} ({})", commit.id(), commit.summary().unwrap_or("No summary"));
let file_path_str = args.file.to_string_lossy().to_string();
let actual_file_path = if file_path_str.contains("::") {
PathBuf::from(file_path_str.split("::").next().unwrap())
} else {
args.file.clone()
};
let file_exists = repo.revparse_single(&format!("{}:{}", commit.id(), actual_file_path.display()))
.is_ok();
if !file_exists {
debug!("File {:?} does not exist in commit {}", actual_file_path, commit.id());
continue;
}
let effective_cmd = if args.pytest {
let test_path = if let Some(test) = &args.test {
format!("{}::{}", args.file.display(), test)
} else {
args.file.display().to_string()
};
format!("pytest {}", test_path)
} else if let Some(cmd) = &args.cmd {
cmd.clone()
} else {
unreachable!("Either --cmd or --pytest must be provided")
};
if check_commit(&repo, &commit, &effective_cmd, &args.file)? {
info!("Found working commit: {}", commit.id());
info!("Commit message: {}", commit.message().unwrap_or("No message"));
info!("Commit date: {}", commit.time().seconds());
found_working_commit = true;
break;
}
}
if args.restore {
info!("Restoring original HEAD");
restore_head(&repo, &original_head_commit)?;
}
if !found_working_commit {
warn!("No working commit found in the history");
}
Ok(())
}
fn check_commit(repo: &Repository, commit: &Commit, cmd: &str, file_path: &PathBuf) -> Result<bool> {
let tree = commit
.tree()
.with_context(|| format!("Failed to get tree for commit {}", commit.id()))?;
let obj = tree.as_object();
repo.checkout_tree(obj, None)
.with_context(|| format!("Failed to checkout tree for commit {}", commit.id()))?;
repo.set_head_detached(commit.id())
.with_context(|| format!("Failed to set HEAD to commit {}", commit.id()))?;
let effective_cmd = if cmd.starts_with("pytest ") {
cmd.to_string()
} else {
let file_str = file_path.to_string_lossy().to_string();
if cmd.contains(&file_str) {
cmd.to_string()
} else {
format!("{} {}", cmd, file_path.display())
}
};
debug!("Running command: {}", effective_cmd);
let output = Command::new("sh")
.arg("-c")
.arg(&effective_cmd)
.output()
.with_context(|| format!("Failed to execute command: {}", effective_cmd))?;
let success = output.status.success();
if success {
debug!("Command succeeded");
} else {
debug!(
"Command failed with exit code: {}",
output.status.code().unwrap_or(-1)
);
if !output.stderr.is_empty() {
debug!(
"Command stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
Ok(success)
}
fn restore_head(repo: &Repository, original_head: &Commit) -> Result<()> {
let tree = original_head
.tree()
.with_context(|| "Failed to get tree for original HEAD")?;
let obj = tree.as_object();
repo.checkout_tree(obj, None)
.with_context(|| "Failed to checkout tree for original HEAD")?;
repo.set_head_detached(original_head.id())
.with_context(|| "Failed to set HEAD to original commit")?;
Ok(())
}