| @@ -1,44 +1,59 @@ |
| 1 | use anyhow::{anyhow, bail}; | 1 | use anyhow::{anyhow, bail}; |
| | 2 | use clap::Clap; |
| 2 | use git_hash::{bstr::ByteSlice, ObjectId}; | 3 | use git_hash::{bstr::ByteSlice, ObjectId}; |
| 3 | use git_object::bstr::BString; | 4 | use git_object::bstr::BString; |
| 4 | use git_odb::find::FindExt; | 5 | use git_odb::find::FindExt; |
| 5 | use git_traverse::commit; | 6 | use git_traverse::commit; |
| 6 | use itertools::Itertools; | 7 | use itertools::Itertools; |
| 7 | use rayon::prelude::*; | 8 | use rayon::prelude::*; |
| 8 | use std::{path::PathBuf, time::Instant}; | 9 | use std::{ |
| | 10 | ffi::{OsStr, OsString}, |
| | 11 | path::PathBuf, |
| | 12 | time::Instant, |
| | 13 | }; |
| | 14 | |
| | 15 | fn is_repo_inner(dir: &OsStr) -> anyhow::Result<()> { |
| | 16 | let p = PathBuf::from(dir).join(".git").canonicalize()?; |
| | 17 | if p.extension().unwrap_or_default() == "git" |
| | 18 | || p.file_name().unwrap_or_default() == ".git" |
| | 19 | || p.join("HEAD").is_file() |
| | 20 | { |
| | 21 | Ok(()) |
| | 22 | } else { |
| | 23 | Err(anyhow!( |
| | 24 | "Path '{}' needs to be a directory containing '.git/'", |
| | 25 | p.display() |
| | 26 | )) |
| | 27 | } |
| | 28 | } |
| | 29 | |
| | 30 | fn is_repo(dir: &OsStr) -> Result<(), String> { |
| | 31 | is_repo_inner(dir).map_err(|err| err.to_string()) |
| | 32 | } |
| | 33 | |
| | 34 | #[derive(Clap)] |
| | 35 | #[clap(name = "git-hours", about = "Estimate hours worked basted on a commit history", version = clap::crate_version!())] |
| | 36 | struct Opts { |
| | 37 | /// The directory containing a '.git/' folder. |
| | 38 | #[clap(parse(from_os_str))] |
| | 39 | #[clap(validator_os = is_repo)] |
| | 40 | working_dir: PathBuf, |
| | 41 | /// The name of the ref like 'main' or 'master' at which to start iterating the commit graph. |
| | 42 | refname: OsString, |
| | 43 | /// Omit personally identifyable information, leaving only the summary. |
| | 44 | #[clap(short = 'p' long)] |
| | 45 | omit_pii: bool, |
| | 46 | } |
| 9 | | 47 | |
| 10 | fn main() -> anyhow::Result<()> { | 48 | fn main() -> anyhow::Result<()> { |
| 11 | let mut args = std::env::args(); | 49 | let opts: Opts = Opts::parse(); |
| 12 | let repo_git_dir = args | 50 | let repo_git_dir = opts.working_dir.join(".git"); |
| 13 | .nth(1) | 51 | let commit_id = ObjectId::from_hex( |
| 14 | .ok_or_else(|| anyhow!("First argument is the .git directory to work in")) | 52 | &std::fs::read(repo_git_dir.join("refs").join("heads").join(opts.refname))? |
| 15 | .and_then(|p| { | 53 | .as_bstr() |
| 16 | let p = PathBuf::from(p).join(".git").canonicalize()?; | 54 | .trim(), |
| 17 | if p.extension().unwrap_or_default() == "git" | 55 | )?; |
| 18 | || p.file_name().unwrap_or_default() == ".git" | 56 | |
| 19 | || p.join("HEAD").is_file() | | |
| 20 | { | | |
| 21 | Ok(p) | | |
| 22 | } else { | | |
| 23 | Err(anyhow!( | | |
| 24 | "Path '{}' needs to be a directory containing '.git/'", | | |
| 25 | p.display() | | |
| 26 | )) | | |
| 27 | } | | |
| 28 | })?; | | |
| 29 | let commit_id = args | | |
| 30 | .next() | | |
| 31 | .ok_or_else(|| { | | |
| 32 | anyhow!("Second argument is the name of the branch from which to start iteration, like 'main' or 'master'") | | |
| 33 | }) | | |
| 34 | .and_then(|name| { | | |
| 35 | ObjectId::from_hex( | | |
| 36 | &std::fs::read(repo_git_dir.join("refs").join("heads").join(name))? | | |
| 37 | .as_bstr() | | |
| 38 | .trim(), | | |
| 39 | ) | | |
| 40 | .map_err(Into::into) | | |
| 41 | })?; | | |
| 42 | let repo_objects_dir = { | 57 | let repo_objects_dir = { |
| 43 | let mut d = repo_git_dir; | 58 | let mut d = repo_git_dir; |
| 44 | d.push("objects"); | 59 | d.push("objects"); |