diff --git a/README.md b/README.md index d6f5e324a..cb53b5bea 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ cd /path/of/your/repo onefetch ``` +Native (non-colocated) Jujutsu repositories are also supported when `jj` is installed. + ## Customization Onefetch can be customized via [command-line arguments](https://github.com/o2sh/onefetch/wiki/command-line-options) to display exactly what you want, the way you want it: adjust the text styling, disable info lines, ignore files and directories, output in multiple formats (JSON, YAML), etc. diff --git a/src/info/git/mod.rs b/src/info/git/mod.rs index b1d7194dc..e767266ba 100644 --- a/src/info/git/mod.rs +++ b/src/info/git/mod.rs @@ -21,6 +21,8 @@ pub mod sig; pub fn traverse_commit_graph( repo: &gix::Repository, + head_id: ObjectId, + skip_head: bool, no_bots: Option, churn_pool_size: Option, no_merges: bool, @@ -36,7 +38,7 @@ pub fn traverse_commit_graph( let can_use_commit_graph = commit_graph.is_some(); let commit_iter = repo - .head_commit()? + .find_commit(head_id)? .id() .ancestors() .sorting(Sorting::ByCommitTime(CommitTimeOrder::NewestFirst)) @@ -59,6 +61,10 @@ pub fn traverse_commit_graph( for commit in commit_iter { let commit = commit?; { + if skip_head && commit.id == head_id { + continue; + } + if no_merges && commit.parent_ids.len() > 1 { continue; } diff --git a/src/info/head.rs b/src/info/head.rs index 3f837cdd2..20359289f 100644 --- a/src/info/head.rs +++ b/src/info/head.rs @@ -1,6 +1,7 @@ use crate::info::utils::info_field::InfoField; use anyhow::{Context, Result}; use gix::Repository; +use gix::prelude::ObjectIdExt; use serde::Serialize; #[derive(Serialize)] @@ -46,6 +47,12 @@ impl HeadInfo { let head_refs = get_head_refs(repo)?; Ok(Self { head_refs }) } + + pub fn from_id(repo: &Repository, head_id: gix::ObjectId) -> Result { + Ok(Self { + head_refs: HeadRefs::new(head_id.attach(repo).shorten()?.to_string(), Vec::new()), + }) + } } fn get_head_refs(repo: &Repository) -> Result { diff --git a/src/info/mod.rs b/src/info/mod.rs index b2db077a5..896974059 100644 --- a/src/info/mod.rs +++ b/src/info/mod.rs @@ -15,6 +15,7 @@ use self::license::LicenseInfo; use self::loc::LocInfo; use self::pending::PendingInfo; use self::project::ProjectInfo; +use self::repository::Repository as DiscoveredRepository; use self::size::SizeInfo; use self::title::Title; use self::url::UrlInfo; @@ -46,6 +47,7 @@ mod license; mod loc; mod pending; mod project; +mod repository; mod size; mod title; mod url; @@ -109,8 +111,14 @@ impl std::fmt::Display for Info { } pub fn build_info(cli_options: &CliOptions) -> Result { - let repo = gix::discover(&cli_options.input)?; - let repo_path = get_work_dir(&repo)?; + let discovered_repo = DiscoveredRepository::discover(&cli_options.input)?; + if discovered_repo.is_jujutsu() { + eprintln!( + "Jujutsu support is experimental: pending changes and size are not yet supported" + ); + } + let repo = discovered_repo.git(); + let repo_path = discovered_repo.work_dir().to_owned(); // Compute LOC in a separate thread so it runs in parallel with commit-graph traversal. let loc_by_language_sorted_handle = std::thread::spawn({ let globs_to_exclude = cli_options.info.exclude.clone(); @@ -127,19 +135,17 @@ pub fn build_info(cli_options: &CliOptions) -> Result { } }); let git_metrics = traverse_commit_graph( - &repo, + repo, + discovered_repo.head_id()?, + discovered_repo.is_jujutsu(), cli_options.info.no_bots.clone(), cli_options.info.churn_pool_size, cli_options.info.no_merges, ) .context("Failed to traverse Git commit history")?; let manifest = get_manifest(&repo_path)?; - let repo_url = get_repo_url( - &repo, - cli_options.info.hide_token, - cli_options.info.http_url, - ) - .context("Failed to determine repository URL")?; + let repo_url = get_repo_url(repo, cli_options.info.hide_token, cli_options.info.http_url) + .context("Failed to determine repository URL")?; let true_color = match cli_options.ascii.true_color { When::Always => true, When::Never => false, @@ -169,12 +175,12 @@ pub fn build_info(cli_options: &CliOptions) -> Result { let show_email = cli_options.info.email; Ok(InfoBuilder::new(cli_options) - .title(&repo, no_bold, &text_colors) - .project(&repo, &repo_url, manifest.as_ref(), number_separator)? + .title(repo, no_bold, &text_colors) + .project(repo, &repo_url, manifest.as_ref(), number_separator)? .description(manifest.as_ref()) - .head(&repo)? - .pending(&repo)? - .version(&repo, manifest.as_ref())? + .head(repo, discovered_repo.jujutsu_head())? + .pending(repo, discovered_repo.is_jujutsu())? + .version(repo, manifest.as_ref())? .created(&git_metrics, iso_time) .languages( loc_by_language.as_ref(), @@ -201,7 +207,7 @@ pub fn build_info(cli_options: &CliOptions) -> Result { number_separator, )? .loc(loc_by_language.as_ref(), number_separator) - .size(&repo, number_separator) + .size(repo, number_separator, discovered_repo.is_jujutsu()) .license(&repo_path, manifest.as_ref())? .build(cli_options, text_colors, dominant_language, ascii_colors)) } @@ -238,8 +244,9 @@ impl InfoBuilder { self } - fn pending(mut self, repo: &Repository) -> Result { - if !self.disabled_fields.contains(&InfoType::Pending) { + fn pending(mut self, repo: &Repository, is_jujutsu: bool) -> Result { + // Jujutsu records working-copy changes in @, and its bare Git store has no worktree status. + if !is_jujutsu && !self.disabled_fields.contains(&InfoType::Pending) { let pending = PendingInfo::new(repo)?; self.info_fields.push(Box::new(pending)); } @@ -268,9 +275,12 @@ impl InfoBuilder { Ok(self) } - fn head(mut self, repo: &Repository) -> Result { + fn head(mut self, repo: &Repository, jujutsu_head: Option) -> Result { if !self.disabled_fields.contains(&InfoType::Head) { - let head = HeadInfo::new(repo)?; + let head = match jujutsu_head { + Some(head_id) => HeadInfo::from_id(repo, head_id)?, + None => HeadInfo::new(repo)?, + }; self.info_fields.push(Box::new(head)); } Ok(self) @@ -284,8 +294,13 @@ impl InfoBuilder { Ok(self) } - fn size(mut self, repo: &Repository, number_separator: NumberSeparator) -> Self { - if !self.disabled_fields.contains(&InfoType::Size) { + fn size( + mut self, + repo: &Repository, + number_separator: NumberSeparator, + is_jujutsu: bool, + ) -> Self { + if !is_jujutsu && !self.disabled_fields.contains(&InfoType::Size) { let size = SizeInfo::new(repo, number_separator); self.info_fields.push(Box::new(size)); } diff --git a/src/info/repository.rs b/src/info/repository.rs new file mode 100644 index 000000000..190211206 --- /dev/null +++ b/src/info/repository.rs @@ -0,0 +1,135 @@ +use anyhow::{Context, Result, bail}; +use gix::ObjectId; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub struct Repository { + git: gix::Repository, + work_dir: PathBuf, + jujutsu_head: Option, +} + +impl Repository { + pub fn discover(input: &Path) -> Result { + if let Some(root) = find_jujutsu_root(input)? + && !root.join(".git").exists() + { + return Self::open_jujutsu(root); + } + + let git = gix::discover(input)?; + let work_dir = git + .workdir() + .context("please run onefetch inside of a non-bare git repository")? + .to_owned(); + + Ok(Self { + git, + work_dir, + jujutsu_head: None, + }) + } + + fn open_jujutsu(root: PathBuf) -> Result { + let git_dir = run_jj(&root, &["git", "root"]) + .context("Failed to locate the Git store backing the Jujutsu repository")?; + let git = gix::open(git_dir.trim()).context("Failed to open the Jujutsu Git store")?; + + let head = run_jj(&root, &["log", "-r", "@", "--no-graph", "-T", "commit_id"]) + .context("Failed to determine the Jujutsu working-copy commit")?; + let head_id = ObjectId::from_hex(head.trim().as_bytes()) + .context("Jujutsu returned an invalid working-copy commit ID")?; + + Ok(Self { + git, + work_dir: root, + jujutsu_head: Some(head_id), + }) + } + + pub fn git(&self) -> &gix::Repository { + &self.git + } + + pub fn work_dir(&self) -> &Path { + &self.work_dir + } + + pub fn head_id(&self) -> Result { + self.jujutsu_head.map_or_else( + || { + self.git + .head_id() + .context("Failed to retrieve HEAD ID") + .map(|head_id| head_id.detach()) + }, + Ok, + ) + } + + pub fn jujutsu_head(&self) -> Option { + self.jujutsu_head + } + + pub fn is_jujutsu(&self) -> bool { + self.jujutsu_head.is_some() + } +} + +fn find_jujutsu_root(input: &Path) -> Result> { + let input = input + .canonicalize() + .with_context(|| format!("Failed to resolve repository path '{}'.", input.display()))?; + if !input.is_dir() { + bail!("Repository path '{}' is not a directory", input.display()); + } + + Ok(input + .ancestors() + .find(|path| path.join(".jj").is_dir()) + .map(Path::to_owned)) +} + +fn run_jj(root: &Path, args: &[&str]) -> Result { + let output = Command::new("jj") + .args([ + "--ignore-working-copy", + "--no-pager", + "--color", + "never", + "-R", + ]) + .arg(root) + .args(args) + .output() + .context("Failed to execute `jj`; is Jujutsu installed?")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("`jj` exited with {}: {}", output.status, stderr.trim()); + } + + String::from_utf8(output.stdout).context("Jujutsu returned non-UTF-8 output") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_jujutsu_root_from_nested_directory() -> Result<()> { + let fixture = + std::env::temp_dir().join(format!("onefetch-jj-root-test-{}", std::process::id())); + let nested = fixture.join("a/b"); + let file = fixture.join("file"); + std::fs::create_dir_all(fixture.join(".jj"))?; + std::fs::create_dir_all(&nested)?; + std::fs::write(&file, b"")?; + + assert_eq!(find_jujutsu_root(&nested)?, Some(fixture.canonicalize()?)); + assert!(find_jujutsu_root(&file).is_err()); + + std::fs::remove_dir_all(fixture)?; + Ok(()) + } +}