Viewing:
use std::fs::{metadata, read_dir}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use anyhow::{bail, Result}; use chrono::{DateTime, NaiveDateTime, Utc}; use clap::{Parser, Subcommand}; use git2::{BlameHunk, ErrorCode, Repository, Time}; use substring::Substring; use terminal_size::{terminal_size, Height, Width}; use std::println as d; /// A CLI tool for code age #[derive(Debug, Parser)] #[command(name = "age")] #[command(about = "view history of your git project", long_about = None)] struct Cli { #[command(subcommand)] command: Commands, } const BLAME_ID_WIDTH: usize = 16; #[derive(Debug, Subcommand)] enum Commands { /// Views a file or folder #[command(arg_required_else_help = true)] View { /// The file or folder to view file: String, }, } fn display_time(time: Time) -> String { let dt: DateTime<Utc> = DateTime::from_local( NaiveDateTime::from_timestamp_opt(time.seconds(), 0).unwrap(), Utc, ); dt.to_rfc2822() } fn resolve_filepath(path: &String) -> Result<PathBuf> { match PathBuf::from(&path).canonicalize() { Ok(x) => Ok(x), Err(_) => bail!("oh no could not resolve filepath"), } } fn resolve_relative_filepath(path: &String, parent: &String) -> Result<String> { let file_path = resolve_filepath(&path) .unwrap() .into_os_string() .into_string() .unwrap() .replace(parent, ""); return Ok(file_path); } fn find_git_root(path: &String) -> Option<(Repository, String)> { if let Ok(repo) = Repository::open(&path) { return Some((repo, path.clone())); } let mut pathage = match resolve_filepath(path) { Ok(p) => p, _ => return None, }; if pathage.parent().is_none() { pathage = PathBuf::from(format!("./{}", path)) } while pathage.pop() { if let Ok(repo) = Repository::open(pathage.as_os_str()) { return Some(( repo, pathage.into_os_string().into_string().expect("git repo root of string that we know matches a filesystem entry is not a valid string"), )); } } return None; } fn main() -> Result<()> { let args = Cli::parse(); let size = terminal_size(); let mut terminal_width: usize = 80; if let Some((Width(w), Height(_))) = size { terminal_width = w as usize; } else { eprintln!( "Unable to get terminal size, defaulting width to {} characters", terminal_width ); } match args.command { Commands::View { file } => { let file_check = metadata(&file); match file_check { Ok(met) => { let (repo, repo_path) = match find_git_root(&file) { Some((x, y)) => (x, y), None => { bail!("{} is not within a git repo", file); } }; let mut file_path = resolve_filepath(&file) .unwrap() .into_os_string() .into_string() .unwrap() .replace(&repo_path, ""); file_path = file_path.trim_start_matches('/').to_string(); if met.is_dir() { let mut paths = read_dir(&file)? .map(|x| { x.expect("asdf") .path() .into_os_string() .into_string() .unwrap() }) .collect::<Vec<String>>(); paths.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase())); for path in paths { let mut path_from_git_root = resolve_relative_filepath(&path, &repo_path)?; path_from_git_root = path_from_git_root.trim_start_matches('/').to_string(); let blame = repo.blame_file(Path::new(&path_from_git_root), None); match blame { Ok(blame) => { let latest_hunk = blame .iter() .reduce(|acc: BlameHunk, curr| { curr.final_commit_id(); let curr_when = curr.final_signature().when(); if acc.final_signature().when() > curr_when { acc } else { curr } }) .unwrap(); let signature = latest_hunk.final_signature(); let most_recent_commit = repo.find_commit(latest_hunk.final_commit_id())?; let name = signature .name() .unwrap_or(signature.email().unwrap_or("n/@")); let summary = most_recent_commit.summary().unwrap(); d!( "{:.40} | {} | {:.7} | {:<7.7} | {:#?}", path_from_git_root, summary, latest_hunk.final_commit_id(), name, display_time(latest_hunk.final_signature().when()), ); } Err(err) => match err.code() { ErrorCode::NotFound => continue, code => { d!( "{}:{} could not get info {:#?}", path_from_git_root, path, code ) } }, }; } return Ok(()); } else if met.is_file() { let blame = repo .blame_file(Path::new(&file_path), None) .expect("unable to read blame for file"); let commit_id = "HEAD".to_string(); let spec = format!("{}:{}", commit_id, file_path); let object = repo.revparse_single(&spec[..]).unwrap(); let blob = repo.find_blob(object.id()).unwrap(); let reader = BufReader::new(blob.content()); for (i, line) in reader.lines().enumerate() { if let (Ok(line), Some(hunk)) = (line, blame.get_line(i + 1)) { let signature = hunk.final_signature(); let name = signature .name() .unwrap_or(signature.email().unwrap_or("n/@")); println!( "{:.7} {:<7.7}|{}", hunk.final_commit_id(), name, line.substring(0, terminal_width - BLAME_ID_WIDTH), ); } } return Ok(()); } return Ok(()); } _ => { eprintln!("could not find file {}", file); return Ok(()); } } } } }