diff --git a/crates/prek/src/cli/hook_impl.rs b/crates/prek/src/cli/hook_impl.rs index c82cd5d76..4d78201f2 100644 --- a/crates/prek/src/cli/hook_impl.rs +++ b/crates/prek/src/cli/hook_impl.rs @@ -15,10 +15,10 @@ use prek_consts::env_vars::EnvVars; use crate::cli::{self, ExitStatus, RunArgs}; use crate::config::HookType; use crate::fs::CWD; -use crate::git::GIT_ROOT; use crate::languages::resolve_command; use crate::printer::Printer; use crate::process::Cmd; +use crate::repo; use crate::store::Store; use crate::workspace; use crate::workspace::Project; @@ -92,7 +92,7 @@ pub(crate) async fn hook_impl( }; } Ok(project) => { - if project.path() != GIT_ROOT.as_ref()? { + if project.path() != repo::root()? { writeln!( printer.stdout(), "Running in workspace: `{}`", diff --git a/crates/prek/src/cli/install.rs b/crates/prek/src/cli/install.rs index bc2c8210e..2e5e76c94 100644 --- a/crates/prek/src/cli/install.rs +++ b/crates/prek/src/cli/install.rs @@ -15,8 +15,9 @@ use crate::cli::run::{SelectorSource, Selectors}; use crate::cli::{ExitStatus, HookType}; use crate::config::load_config; use crate::fs::{CWD, Simplified}; -use crate::git::{GIT_ROOT, git_cmd}; +use crate::git::git_cmd; use crate::printer::Printer; +use crate::repo; use crate::store::Store; use crate::workspace::{Error as WorkspaceError, Project, Workspace}; use crate::{git, warn_user}; @@ -64,14 +65,16 @@ pub(crate) async fn install( let hooks_path = if let Some(dir) = git_dir { dir.join("hooks") } else { - git::get_git_common_dir().await?.join("hooks") + repo::hooks_dir().await? }; fs_err::create_dir_all(&hooks_path)?; let selectors = if let Some(project) = &project { Some(Selectors::load(&includes, &skips, project.path())?) } else if !includes.is_empty() || !skips.is_empty() { - anyhow::bail!("Cannot use `--include` or `--skip` outside of a git repository"); + anyhow::bail!( + "Cannot use `--include` or `--skip` outside of a Git or Jujutsu (jj) repository" + ); } else { None }; @@ -264,7 +267,7 @@ fn install_hook_script( write!(hint, " with specified config `{}`", config.display().cyan())?; } else if let Some(project) = project { - let git_root = GIT_ROOT.as_ref()?; + let git_root = repo::root()?; let project_path = project.path(); let relative_path = project_path.strip_prefix(git_root).unwrap_or(project_path); if !relative_path.as_os_str().is_empty() { @@ -387,7 +390,7 @@ pub(crate) async fn uninstall( printer: Printer, ) -> Result { let project = Project::discover(config.as_deref(), &CWD).ok(); - let hooks_path = git::get_git_common_dir().await?.join("hooks"); + let hooks_path = repo::hooks_dir().await?; for hook_type in get_hook_types(hook_types, project.as_ref(), config.as_deref()) { let hook_path = hooks_path.join(hook_type.as_ref()); diff --git a/crates/prek/src/cli/run/filter.rs b/crates/prek/src/cli/run/filter.rs index a2ace2557..1a8465615 100644 --- a/crates/prek/src/cli/run/filter.rs +++ b/crates/prek/src/cli/run/filter.rs @@ -13,7 +13,7 @@ use crate::config::{FilePattern, Stage}; use crate::git::GIT_ROOT; use crate::hook::Hook; use crate::workspace::Project; -use crate::{fs, git, warn_user}; +use crate::{fs, repo, warn_user}; /// Filter filenames by include/exclude patterns. pub(crate) struct FilenameFilter<'a> { @@ -317,7 +317,7 @@ async fn collect_files_from_args( } if let (Some(from_ref), Some(to_ref)) = (from_ref, to_ref) { - let files = git::get_changed_files(&from_ref, &to_ref, workspace_root).await?; + let files = repo::changed_files_between(&from_ref, &to_ref, workspace_root).await?; debug!( "Files changed between {} and {}: {}", from_ref, @@ -364,7 +364,7 @@ async fn collect_files_from_args( for dir in directories { let dir = adjust_relative_path(&dir, git_root)?; - let dir_files = git::ls_files(git_root, &dir).await?; + let dir_files = repo::ls_files(git_root, &dir).await?; for file in dir_files { let file = fs::normalize_path(file); exists.insert(file); @@ -376,19 +376,21 @@ async fn collect_files_from_args( } if all_files { - let files = git::ls_files(git_root, workspace_root).await?; + let files = repo::ls_files(git_root, workspace_root).await?; debug!("All files in the workspace: {}", files.len()); return Ok(files); } - if git::is_in_merge_conflict().await? { - let files = git::get_conflicted_files(workspace_root).await?; + if let Some(files) = repo::conflicted_files(workspace_root).await? { debug!("Conflicted files: {}", files.len()); return Ok(files); } - let files = git::get_staged_files(workspace_root).await?; - debug!("Staged files: {}", files.len()); + let files = repo::default_files(workspace_root).await?; + debug!( + "Default files selected from repository backend: {}", + files.len() + ); Ok(files) } diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index e08b66c8c..d7deb7b96 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -26,6 +26,7 @@ use crate::fs::CWD; use crate::git::GIT_ROOT; use crate::hook::{Hook, InstallInfo, InstalledHook, Repo}; use crate::printer::Printer; +use crate::repo; use crate::run::{CONCURRENCY, USE_COLOR}; use crate::store::Store; use crate::workspace::{Project, Workspace}; @@ -66,10 +67,13 @@ pub(crate) async fn run( return Ok(ExitStatus::Success); } - // Ensure we are in a git repository. + // Ensure we are in a supported repository. LazyLock::force(&GIT_ROOT).as_ref()?; - let should_stash = !all_files && files.is_empty() && directories.is_empty(); + let should_stash = !all_files + && files.is_empty() + && directories.is_empty() + && repo::should_stash_by_default_run(); // Check if we have unresolved merge conflict files and fail fast. if should_stash && git::has_unmerged_paths().await? { diff --git a/crates/prek/src/git.rs b/crates/prek/src/git.rs index bde476bdc..c38067bdc 100644 --- a/crates/prek/src/git.rs +++ b/crates/prek/src/git.rs @@ -28,16 +28,23 @@ pub(crate) enum Error { #[error(transparent)] UTF8(#[from] Utf8Error), + + #[error("{0}")] + Message(String), } pub(crate) static GIT: LazyLock> = LazyLock::new(|| which::which("git")); -pub(crate) static GIT_ROOT: LazyLock> = LazyLock::new(|| { - get_root().inspect(|root| { - debug!("Git root: {}", root.display()); - }) -}); +pub(crate) static GIT_ROOT: LazyLock> = + LazyLock::new(|| match crate::repo::REPO_CONTEXT.as_ref() { + Ok(repo) => { + let root = repo.root().to_path_buf(); + debug!("Repository root: {}", root.display()); + Ok(root) + } + Err(err) => Err(Error::Message(err.to_string())), + }); /// Remove some `GIT_` environment variables exposed by `git`. /// @@ -72,6 +79,7 @@ pub(crate) static GIT_ENV_TO_REMOVE: LazyLock> = LazyLock: pub(crate) fn git_cmd(summary: &str) -> Result { let mut cmd = Cmd::new(GIT.as_ref().map_err(|&e| Error::GitNotFound(e))?, summary); cmd.arg("-c").arg("core.useBuiltinFSMonitor=false"); + crate::repo::apply_git_env(&mut cmd); Ok(cmd) } diff --git a/crates/prek/src/hooks/pre_commit_hooks/check_case_conflict.rs b/crates/prek/src/hooks/pre_commit_hooks/check_case_conflict.rs index 6a58cc425..a4e373a11 100644 --- a/crates/prek/src/hooks/pre_commit_hooks/check_case_conflict.rs +++ b/crates/prek/src/hooks/pre_commit_hooks/check_case_conflict.rs @@ -5,8 +5,8 @@ use anyhow::Result; use rustc_hash::FxHashMap; use rustc_hash::FxHashSet; -use crate::git; use crate::hook::Hook; +use crate::repo; pub(crate) async fn check_case_conflict( hook: &Hook, @@ -15,14 +15,14 @@ pub(crate) async fn check_case_conflict( let work_dir = hook.work_dir(); // Get all files in the repo. - let repo_files = git::ls_files(work_dir, Path::new(".")).await?; + let repo_files = repo::ls_files(work_dir, Path::new(".")).await?; let mut repo_files_with_dirs: FxHashSet<&Path> = FxHashSet::default(); for path in &repo_files { insert_path_and_parents(&mut repo_files_with_dirs, path); } // Get relevant files (filenames + added files) and include their parent directories. - let added = git::get_added_files(work_dir).await?; + let added = repo::added_files(work_dir).await?; let mut relevant_files_with_dirs: FxHashSet<&Path> = FxHashSet::default(); for filename in filenames { insert_path_and_parents(&mut relevant_files_with_dirs, filename); diff --git a/crates/prek/src/hooks/pre_commit_hooks/check_executables_have_shebangs.rs b/crates/prek/src/hooks/pre_commit_hooks/check_executables_have_shebangs.rs index 48f1ab4c8..5497b9919 100644 --- a/crates/prek/src/hooks/pre_commit_hooks/check_executables_have_shebangs.rs +++ b/crates/prek/src/hooks/pre_commit_hooks/check_executables_have_shebangs.rs @@ -2,27 +2,18 @@ use std::path::Path; use futures::StreamExt; use owo_colors::OwoColorize; -use rustc_hash::FxHashSet; use tokio::io::AsyncReadExt; -use crate::git; use crate::hook::Hook; use crate::hooks::run_concurrent_file_checks; +use crate::repo; use crate::run::CONCURRENCY; pub(crate) async fn check_executables_have_shebangs( hook: &Hook, filenames: &[&Path], ) -> Result<(i32, Vec), anyhow::Error> { - let stdout = git::git_cmd("get file file mode")? - .arg("config") - .arg("core.fileMode") - .check(true) - .output() - .await? - .stdout; - - let tracks_executable_bit = std::str::from_utf8(&stdout)?.trim() != "false"; + let tracks_executable_bit = repo::tracks_executable_bit().await?; let file_base = hook.project().relative_path(); let (code, output) = if tracks_executable_bit { @@ -77,56 +68,17 @@ async fn git_check_shebangs( file_base: &Path, filenames: &[&Path], ) -> Result<(i32, Vec), anyhow::Error> { - let filenames: FxHashSet<_> = filenames.iter().collect(); - - let output = git::git_cmd("git ls-files")? - .arg("ls-files") - // Show staged contents' mode bits, object name and stage number in the output. - .arg("--stage") - .arg("-z") - .arg("--") - .arg(if file_base.as_os_str().is_empty() { - Path::new(".") - } else { - file_base - }) - .check(true) - .output() - .await?; - - let entries = output.stdout.split(|&b| b == b'\0').filter_map(|entry| { - let entry = str::from_utf8(entry).ok()?; - if entry.is_empty() { - return None; - } - - let mut parts = entry.split('\t'); - let metadata = parts.next()?; - let file_name = parts.next()?; - let file_name = Path::new(file_name); - if !filenames.contains(&file_name) { - return None; - } - - let mode_str = metadata.split_whitespace().next()?; - let mode_bits = u32::from_str_radix(mode_str, 8).ok()?; - let is_executable = (mode_bits & 0o111) != 0; - Some((file_name, is_executable)) - }); - - let mut tasks = futures::stream::iter(entries) - .map(async |(file_name, is_executable)| { - if is_executable { - let has_shebang = file_has_shebang(file_name).await?; - if has_shebang { - anyhow::Ok((0, Vec::new())) - } else { - let stripped = file_name.strip_prefix(file_base).unwrap_or(file_name); - let msg = print_shebang_warning(stripped); - Ok((1, msg.into_bytes())) - } + let executable_files = repo::executable_files(file_base, filenames).await?; + + let mut tasks = futures::stream::iter(executable_files) + .map(async |file_name| { + let full_path = file_base.join(&file_name); + let has_shebang = file_has_shebang(&full_path).await?; + if has_shebang { + anyhow::Ok((0, Vec::new())) } else { - Ok((0, Vec::new())) + let msg = print_shebang_warning(&file_name); + Ok((1, msg.into_bytes())) } }) .buffered(*CONCURRENCY); diff --git a/crates/prek/src/jj.rs b/crates/prek/src/jj.rs new file mode 100644 index 000000000..affe4706c --- /dev/null +++ b/crates/prek/src/jj.rs @@ -0,0 +1,266 @@ +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use tracing::instrument; + +use crate::process; +use crate::process::Cmd; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum Error { + #[error(transparent)] + Command(#[from] process::Error), + + #[error("Failed to find Jujutsu (jj): {0}")] + JjNotFound(#[from] which::Error), + + #[error(transparent)] + Io(#[from] std::io::Error), +} + +/// Path to the `jj` executable, resolved via `PATH`. +pub(crate) static JJ: LazyLock> = + LazyLock::new(|| which::which("jj")); + +/// Walk up from `start` looking for a directory containing `.jj/`. +pub(crate) fn find_workspace_root(start: &Path) -> Option { + let mut current = start.to_path_buf(); + loop { + if current.join(".jj").is_dir() { + return Some(current); + } + if !current.pop() { + return None; + } + } +} + +fn resolve_repo_dir(workspace_root: &Path) -> Result, Error> { + let repo_dir_candidate = workspace_root.join(".jj").join("repo"); + if repo_dir_candidate.is_file() { + let content = fs_err::read_to_string(&repo_dir_candidate)?; + let path = PathBuf::from(content.trim()); + let repo_dir = if path.is_absolute() { + path + } else { + workspace_root.join(".jj").join(path) + }; + return Ok(Some(repo_dir)); + } + if repo_dir_candidate.is_dir() { + return Ok(Some(repo_dir_candidate)); + } + Ok(None) +} + +/// Resolve the backing Git directory for a Jujutsu workspace. +/// +/// For a primary (colocated) workspace, `.jj/repo` is a directory. +/// For a secondary workspace (created with `jj workspace add`), `.jj/repo` is a file +/// containing the absolute path to the main repo's `.jj/repo` directory. +pub(crate) fn resolve_backing_git_dir(workspace_root: &Path) -> Result, Error> { + let Some(repo_dir) = resolve_repo_dir(workspace_root)? else { + return Ok(None); + }; + + let git_target_file = repo_dir.join("store").join("git_target"); + let git_target = fs_err::read_to_string(&git_target_file)?; + let git_target = git_target.trim(); + + let git_path = PathBuf::from(git_target); + let git_dir = if git_path.is_absolute() { + git_path + } else { + repo_dir.join("store").join(git_path) + }; + let git_dir = git_dir.canonicalize()?; + + if git_dir.exists() { + Ok(Some(git_dir)) + } else { + Ok(None) + } +} + +/// Create a new `Cmd` for running Jujutsu. +pub(crate) fn jj_cmd(summary: &str) -> Result { + let cmd = Cmd::new(JJ.as_ref().map_err(|&e| Error::JjNotFound(e))?, summary); + Ok(cmd) +} + +fn relative_to_cwd<'a>(cwd: &'a Path, path: &'a Path) -> &'a Path { + path.strip_prefix(cwd) + .ok() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or(path) +} + +fn parse_path_lines(output: &[u8]) -> Vec { + String::from_utf8_lossy(output) + .lines() + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect() +} + +/// List tracked files in a Jujutsu workspace revision. +#[instrument(level = "trace")] +pub(crate) async fn ls_files(cwd: &Path, path: &Path) -> Result, Error> { + let relative = relative_to_cwd(cwd, path); + let mut cmd = jj_cmd("jj file list")?; + cmd.current_dir(cwd).arg("file").arg("list"); + if !relative.as_os_str().is_empty() && relative != Path::new(".") { + cmd.arg(relative); + } + + let output = cmd.check(true).output().await?; + Ok(parse_path_lines(&output.stdout)) +} + +/// Get the list of changed files in the current Jujutsu working copy. +#[instrument(level = "trace")] +pub(crate) async fn get_changed_files(root: &Path) -> Result, Error> { + let output = jj_cmd("jj diff")? + .current_dir(root) + .arg("diff") + .arg("-r") + .arg("@") + .arg("--name-only") + .check(true) + .output() + .await?; + Ok(parse_path_lines(&output.stdout)) +} + +/// Get the list of changed files between two Jujutsu revisions. +#[instrument(level = "trace")] +pub(crate) async fn get_changed_files_between( + old: &str, + new: &str, + root: &Path, +) -> Result, Error> { + let output = jj_cmd("jj diff")? + .current_dir(root) + .arg("diff") + .arg("--from") + .arg(old) + .arg("--to") + .arg(new) + .arg("--name-only") + .check(true) + .output() + .await?; + Ok(parse_path_lines(&output.stdout)) +} + +/// Get conflicted files in the current Jujutsu working copy. +#[instrument(level = "trace")] +pub(crate) async fn get_conflicted_files(root: &Path) -> Result, Error> { + let output = jj_cmd("jj diff")? + .current_dir(root) + .arg("diff") + .arg("-r") + .arg("@") + .arg("--types") + .check(true) + .output() + .await?; + + let files = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + + let mut parts = line.splitn(2, char::is_whitespace); + let status = parts.next()?; + let path = parts.next()?.trim_start(); + if status.contains('C') && !path.is_empty() { + Some(PathBuf::from(path)) + } else { + None + } + }) + .collect(); + + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_workspace_root_returns_none_for_non_jj_directory() { + let dir = tempfile::tempdir().unwrap(); + assert!(find_workspace_root(dir.path()).is_none()); + } + + #[test] + fn find_workspace_root_finds_current_directory() { + let dir = tempfile::tempdir().unwrap(); + fs_err::create_dir(dir.path().join(".jj")).unwrap(); + let result = find_workspace_root(dir.path()); + assert_eq!(result, Some(dir.path().to_path_buf())); + } + + #[test] + fn find_workspace_root_finds_parent_directory() { + let dir = tempfile::tempdir().unwrap(); + fs_err::create_dir(dir.path().join(".jj")).unwrap(); + let child = dir.path().join("subdir"); + fs_err::create_dir(&child).unwrap(); + let result = find_workspace_root(&child); + assert_eq!(result, Some(dir.path().to_path_buf())); + } + + #[test] + fn resolve_backing_git_dir_returns_none_without_repo_metadata() { + let dir = tempfile::tempdir().unwrap(); + fs_err::create_dir(dir.path().join(".jj")).unwrap(); + let result = resolve_backing_git_dir(dir.path()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn resolve_backing_git_dir_resolves_colocated_workspace() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + let store_dir = root.join(".jj").join("repo").join("store"); + fs_err::create_dir_all(&store_dir).unwrap(); + fs_err::write(store_dir.join("git_target"), "../../../.git").unwrap(); + let git_dir = root.join(".git"); + fs_err::create_dir(&git_dir).unwrap(); + + let resolved = resolve_backing_git_dir(root).unwrap(); + assert_eq!(resolved, Some(git_dir.canonicalize().unwrap())); + } + + #[test] + fn resolve_backing_git_dir_resolves_secondary_workspace() { + let dir = tempfile::tempdir().unwrap(); + let main_root = dir.path().join("main"); + let secondary_root = dir.path().join("secondary"); + + let main_store = main_root.join(".jj").join("repo").join("store"); + fs_err::create_dir_all(&main_store).unwrap(); + fs_err::write(main_store.join("git_target"), "../../../.git").unwrap(); + let main_git = main_root.join(".git"); + fs_err::create_dir(&main_git).unwrap(); + + let secondary_jj = secondary_root.join(".jj"); + fs_err::create_dir_all(&secondary_jj).unwrap(); + let main_repo_abs = main_root.join(".jj").join("repo").canonicalize().unwrap(); + fs_err::write( + secondary_jj.join("repo"), + main_repo_abs.to_string_lossy().as_ref(), + ) + .unwrap(); + + let resolved = resolve_backing_git_dir(&secondary_root).unwrap(); + assert_eq!(resolved, Some(main_git.canonicalize().unwrap())); + } +} diff --git a/crates/prek/src/main.rs b/crates/prek/src/main.rs index 189544b87..4ebe1a4d1 100644 --- a/crates/prek/src/main.rs +++ b/crates/prek/src/main.rs @@ -38,11 +38,13 @@ mod hook; mod hooks; mod http; mod install_source; +mod jj; mod languages; mod printer; mod process; #[cfg(all(unix, feature = "profiler"))] mod profiler; +mod repo; #[cfg(unix)] mod resource_limit; mod run; diff --git a/crates/prek/src/process.rs b/crates/prek/src/process.rs index c428817ea..d3ffc3082 100644 --- a/crates/prek/src/process.rs +++ b/crates/prek/src/process.rs @@ -408,6 +408,12 @@ impl Cmd { /// Remove some git-specific environment variables to make git commands isolated. pub fn remove_git_envs(&mut self) -> &mut Self { + // `git_cmd()` may have already injected repo-specific Git env vars for the current + // repository context. Commands that operate on a temporary repo must clear those + // explicit overrides as well, not just inherited process env vars. + self.inner.env_remove(EnvVars::GIT_DIR); + self.inner.env_remove(EnvVars::GIT_WORK_TREE); + for (key, _) in crate::git::GIT_ENV_TO_REMOVE.iter() { self.inner.env_remove(key); } @@ -530,8 +536,33 @@ impl Display for Cmd { #[cfg(all(test, not(windows)))] mod tests { + use std::collections::BTreeMap; + + use prek_consts::env_vars::EnvVars; + use super::Cmd; + #[test] + fn remove_git_envs_clears_repo_context_overrides() { + let mut cmd = Cmd::new("/bin/sh", "remove git envs test"); + cmd.env(EnvVars::GIT_DIR, "/tmp/repo/.git") + .env(EnvVars::GIT_WORK_TREE, "/tmp/repo") + .remove_git_envs(); + + let envs = cmd + .get_envs() + .map(|(key, value)| { + ( + key.to_string_lossy().into_owned(), + value.map(|v| v.to_string_lossy().into_owned()), + ) + }) + .collect::>(); + + assert_eq!(envs.get(EnvVars::GIT_DIR), Some(&None)); + assert_eq!(envs.get(EnvVars::GIT_WORK_TREE), Some(&None)); + } + #[tokio::test] async fn pty_output_captures_trailing_output_after_fast_exit() { for _ in 0..20 { diff --git a/crates/prek/src/repo.rs b/crates/prek/src/repo.rs new file mode 100644 index 000000000..91d678e8c --- /dev/null +++ b/crates/prek/src/repo.rs @@ -0,0 +1,317 @@ +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use anyhow::{Context, Result}; +use prek_consts::env_vars::EnvVars; +use rustc_hash::FxHashSet; +use tracing::debug; + +use crate::git; +use crate::jj; +use crate::process::Cmd; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RepoKind { + Git, + Jujutsu, +} + +#[derive(Debug)] +pub(crate) struct RepoContext { + kind: RepoKind, + root: PathBuf, + backing_git_dir: Option, +} + +impl RepoContext { + /// Detect the repository model for the current working directory once at startup. + /// + /// The intent here is to give the rest of the codebase a single answer to + /// "what kind of repo am I in?" after `--cd` and other cwd changes have already + /// taken effect. Jujutsu is checked first because a Jujutsu workspace may not be + /// discoverable via plain Git root detection from the current directory. + fn detect_current() -> Result { + let cwd = std::env::current_dir().context("Failed to get current directory")?; + if let Some(root) = jj::find_workspace_root(&cwd) { + let git_dir = jj::resolve_backing_git_dir(&root) + .context("Failed to resolve backing Git directory for Jujutsu workspace")? + .context( + "Detected a Jujutsu workspace, but could not resolve its backing Git directory", + )?; + debug!( + root = %root.display(), + git_dir = %git_dir.display(), + "Detected Jujutsu workspace", + ); + return Ok(Self { + kind: RepoKind::Jujutsu, + root, + backing_git_dir: Some(git_dir), + }); + } + + let root = git::get_root() + .map_err(anyhow::Error::new) + .map_err(|err| anyhow::anyhow!("Not inside a Git or Jujutsu repository: {err}"))?; + debug!(root = %root.display(), "Detected Git repository"); + + Ok(Self { + kind: RepoKind::Git, + root, + backing_git_dir: None, + }) + } + + pub(crate) fn kind(&self) -> RepoKind { + self.kind + } + + pub(crate) fn root(&self) -> &Path { + &self.root + } + + /// Apply per-command Git environment needed to talk to the backing Git repo. + /// + /// This is intentionally scoped to the specific command being built. We do not + /// mutate process-wide `GIT_DIR` / `GIT_WORK_TREE`, because that would make + /// Jujutsu support an ambient global side effect rather than an explicit repo + /// backend behavior. + fn apply_git_env(&self, cmd: &mut Cmd) { + if let Some(git_dir) = &self.backing_git_dir { + cmd.env(EnvVars::GIT_DIR, git_dir); + cmd.env(EnvVars::GIT_WORK_TREE, &self.root); + } + } +} + +pub(crate) static REPO_CONTEXT: LazyLock> = + LazyLock::new(RepoContext::detect_current); + +/// Access the cached repository context with a normal `Result` API. +/// +/// `LazyLock>` is convenient for one-time detection, but most callers +/// want error propagation instead of reasoning about the lazy container directly. +fn current() -> Result<&'static RepoContext> { + match REPO_CONTEXT.as_ref() { + Ok(repo) => Ok(repo), + Err(err) => Err(anyhow::anyhow!("{err}")), + } +} + +/// Apply repository-specific Git environment to a single command if needed. +/// +/// For plain Git repositories this is a no-op. For Jujutsu workspaces this points +/// Git commands at the backing Git store so the rest of prek can keep using a +/// small number of Git-backed primitives without leaking that setup everywhere. +pub(crate) fn apply_git_env(cmd: &mut Cmd) { + if let Ok(repo) = REPO_CONTEXT.as_ref() { + repo.apply_git_env(cmd); + } +} + +/// Return the repository root that prek should treat as the workspace boundary. +/// +/// This is the Jujutsu workspace root for Jujutsu repos and the Git root for +/// plain Git repos. Callers should prefer this instead of reaching into Git/JJ +/// discovery directly. +pub(crate) fn root() -> Result<&'static Path> { + Ok(current()?.root()) +} + +/// Return the hooks directory for the active repository backend. +/// +/// Jujutsu still uses the backing Git hooks directory, but that detail stays +/// behind this function so install and hook entrypoints do not need to know it. +pub(crate) async fn hooks_dir() -> Result { + Ok(git::get_git_common_dir().await?.join("hooks")) +} + +/// Whether `prek run` should preserve Git's stash/clean-worktree behavior by default. +/// +/// Git's default mode is index-driven, so stashing protects unstaged changes from +/// bleeding into hook execution. Jujutsu's default mode is working-copy based, so +/// that Git-specific hygiene step does not apply. +pub(crate) fn should_stash_by_default_run() -> bool { + current() + .map(|repo| repo.kind() == RepoKind::Git) + .unwrap_or(true) +} + +/// Whether config files must be staged before they are considered authoritative. +/// +/// This is a Git-specific rule because prek historically reads config from the +/// staged snapshot. Jujutsu has no staging area, so enforcing that rule there +/// would be both confusing and wrong. +pub(crate) fn requires_staged_configs() -> bool { + current() + .map(|repo| repo.kind() == RepoKind::Git) + .unwrap_or(true) +} + +/// Return files that should be treated as newly introduced for hook logic. +/// +/// In Git this maps to added files in the index. In Jujutsu there is no staging +/// area, so the closest useful intent is "files changed in the current working +/// copy changeset". +pub(crate) async fn added_files(workspace_root: &Path) -> Result> { + match current()?.kind() { + RepoKind::Git => git::get_added_files(workspace_root) + .await + .map_err(Into::into), + RepoKind::Jujutsu => jj::get_changed_files(workspace_root) + .await + .map_err(Into::into), + } +} + +/// Return the default file set for `prek run` when the user did not specify one. +/// +/// The goal is to preserve each VCS's natural workflow: +/// Git uses staged files, while Jujutsu uses the current working-copy changeset. +pub(crate) async fn default_files(workspace_root: &Path) -> Result> { + match current()?.kind() { + RepoKind::Git => git::get_staged_files(workspace_root) + .await + .map_err(Into::into), + RepoKind::Jujutsu => jj::get_changed_files(workspace_root) + .await + .map_err(Into::into), + } +} + +/// Return files changed between two user-supplied revisions. +/// +/// The caller does not need to care whether those revision strings are Git refs or +/// Jujutsu revsets/bookmarks; each backend interprets them using its own native +/// revision syntax. +pub(crate) async fn changed_files_between( + old: &str, + new: &str, + workspace_root: &Path, +) -> Result> { + match current()?.kind() { + RepoKind::Git => git::get_changed_files(old, new, workspace_root) + .await + .map_err(Into::into), + RepoKind::Jujutsu => jj::get_changed_files_between(old, new, workspace_root) + .await + .map_err(Into::into), + } +} + +/// List tracked files under `path` using the active repository backend. +/// +/// This keeps callers focused on "which files belong to the repo here?" rather +/// than the mechanics of `git ls-files` versus the Jujutsu equivalent. +pub(crate) async fn ls_files(cwd: &Path, path: &Path) -> Result> { + match current()?.kind() { + RepoKind::Git => git::ls_files(cwd, path).await.map_err(Into::into), + RepoKind::Jujutsu => jj::ls_files(cwd, path).await.map_err(Into::into), + } +} + +/// Return conflicted files if the current repo backend reports a conflict state. +/// +/// Git exposes a repo-wide merge-conflict mode, while Jujutsu exposes conflicted +/// paths in the working copy. This helper normalizes both into "Some(files)" or +/// `None` so higher-level run logic can stay backend-agnostic. +pub(crate) async fn conflicted_files(workspace_root: &Path) -> Result>> { + match current()?.kind() { + RepoKind::Git => { + if git::is_in_merge_conflict().await? { + Ok(Some(git::get_conflicted_files(workspace_root).await?)) + } else { + Ok(None) + } + } + RepoKind::Jujutsu => { + let files = jj::get_conflicted_files(workspace_root).await?; + if files.is_empty() { + Ok(None) + } else { + Ok(Some(files)) + } + } + } +} + +/// Report whether the backing Git repository stores executable-bit metadata. +/// +/// The executable shebang hook only makes sense when the repository tracks mode +/// bits. Even in a Jujutsu workspace, that metadata still comes from the backing +/// Git store. +pub(crate) async fn tracks_executable_bit() -> Result { + let stdout = git::git_cmd("get file file mode")? + .arg("config") + .arg("core.fileMode") + .check(true) + .output() + .await? + .stdout; + Ok(std::str::from_utf8(&stdout)?.trim() != "false") +} + +/// Return the subset of `filenames` that are marked executable in repo metadata. +/// +/// This is intentionally repo metadata, not a filesystem stat call. We care about +/// which files the VCS records as executable because that is what hooks validate +/// and what collaborators will observe after commit/checkout. +pub(crate) async fn executable_files( + file_base: &Path, + filenames: &[&Path], +) -> Result> { + let filenames: FxHashSet<_> = filenames.iter().copied().collect(); + + let output = git::git_cmd("git ls-files")? + .arg("ls-files") + .arg("--stage") + .arg("-z") + .arg("--") + .arg(if file_base.as_os_str().is_empty() { + Path::new(".") + } else { + file_base + }) + .check(true) + .output() + .await?; + + let mut executable_files = Vec::new(); + for entry in output.stdout.split(|&b| b == b'\0') { + let entry = std::str::from_utf8(entry)?; + if entry.is_empty() { + continue; + } + + let mut parts = entry.split('\t'); + let Some(metadata) = parts.next() else { + continue; + }; + let file_name = match parts.next() { + Some(file_name) => Path::new(file_name), + None => continue, + }; + if !filenames.contains(file_name) { + continue; + } + + let Some(mode_str) = metadata.split_whitespace().next() else { + continue; + }; + let Ok(mode_bits) = u32::from_str_radix(mode_str, 8) else { + continue; + }; + if (mode_bits & 0o111) == 0 { + continue; + } + + executable_files.push( + file_name + .strip_prefix(file_base) + .unwrap_or(file_name) + .to_path_buf(), + ); + } + + Ok(executable_files) +} diff --git a/crates/prek/src/workspace.rs b/crates/prek/src/workspace.rs index f0a9175b7..9c6871a8d 100644 --- a/crates/prek/src/workspace.rs +++ b/crates/prek/src/workspace.rs @@ -22,7 +22,7 @@ use crate::git::GIT_ROOT; use crate::hook::HookSpec; use crate::hook::{self, Hook, HookBuilder, Repo}; use crate::store::{CacheBucket, Store}; -use crate::{git, store, warn_user}; +use crate::{git, repo, store, warn_user}; #[derive(Error, Debug)] pub(crate) enum Error { @@ -973,6 +973,10 @@ impl Workspace { /// Check if all configuration files are staged in git. pub(crate) async fn check_configs_staged(&self) -> Result<()> { + if !repo::requires_staged_configs() { + return Ok(()); + } + let config_files = self .projects .iter() diff --git a/crates/prek/tests/builtin_hooks.rs b/crates/prek/tests/builtin_hooks.rs index c4d3fcec9..8dd8f5576 100644 --- a/crates/prek/tests/builtin_hooks.rs +++ b/crates/prek/tests/builtin_hooks.rs @@ -2,8 +2,11 @@ use prek_consts::env_vars::EnvVars; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Command; use anyhow::Result; +use assert_cmd::assert::OutputAssertExt; use assert_fs::prelude::*; use insta::assert_snapshot; use prek_consts::PRE_COMMIT_CONFIG_YAML; @@ -12,6 +15,13 @@ use crate::common::{TestContext, cmd_snapshot}; mod common; +fn jj_cmd(dir: impl AsRef) -> Option { + let jj = which::which("jj").ok()?; + let mut cmd = Command::new(jj); + cmd.current_dir(dir); + Some(cmd) +} + /// Tests that `repo: builtin` hooks doesn't create hook env. #[test] fn builtin_hooks_not_create_env() { @@ -2096,6 +2106,51 @@ fn check_case_conflict_directory() -> Result<()> { Ok(()) } +#[test] +fn check_case_conflict_in_non_colocated_jujutsu_workspace() -> Result<()> { + let Some(mut init) = jj_cmd(".") else { + return Ok(()); + }; + let context = TestContext::new(); + + if !is_case_sensitive_filesystem(&context)? { + return Ok(()); + } + + init.current_dir(context.work_dir()) + .args(["git", "init", "--no-colocate"]) + .assert() + .success(); + + let cwd = context.work_dir(); + cwd.child("src/foo.txt").write_str("existing file")?; + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: builtin + hooks: + - id: check-case-conflict + "}); + + cwd.child("src/FOO.txt").write_str("conflicting case")?; + + cmd_snapshot!(context.filters(), context.run(), @r#" + success: false + exit_code: 1 + ----- stdout ----- + check for case conflicts.................................................Failed + - hook id: check-case-conflict + - exit code: 1 + + Case-insensitivity conflict found: src/FOO.txt + Case-insensitivity conflict found: src/foo.txt + + ----- stderr ----- + "#); + + Ok(()) +} + #[test] fn check_case_conflict_among_new_files() -> Result<()> { let context = TestContext::new(); diff --git a/crates/prek/tests/run.rs b/crates/prek/tests/run.rs index 5e5112b65..6410c75cb 100644 --- a/crates/prek/tests/run.rs +++ b/crates/prek/tests/run.rs @@ -1,4 +1,5 @@ use std::path::Path; +use std::process::Command; use anyhow::Result; use assert_cmd::assert::OutputAssertExt; @@ -12,6 +13,13 @@ use crate::common::{TestContext, cmd_snapshot, git_cmd}; mod common; +fn jj_cmd(dir: impl AsRef) -> Option { + let jj = which::which("jj").ok()?; + let mut cmd = Command::new(jj); + cmd.current_dir(dir); + Some(cmd) +} + #[test] fn run_basic() -> Result<()> { let context = TestContext::new(); @@ -73,6 +81,49 @@ fn run_basic() -> Result<()> { Ok(()) } +#[test] +fn run_in_non_colocated_jj_workspace() -> Result<()> { + let Some(mut init) = jj_cmd(".") else { + return Ok(()); + }; + let context = TestContext::new(); + + init.current_dir(context.work_dir()) + .args(["git", "init", "--no-colocate"]) + .assert() + .success(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: echo-files + name: echo-files + entry: python3 -c "import sys; print('ARGS:' + ' '.join(sys.argv[1:]))" + language: system + files: \.txt$ + verbose: true + "#}); + + context.work_dir().child("file.txt").write_str("hello")?; + context.work_dir().child("ignored.md").write_str("nope")?; + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + echo-files...............................................................Passed + - hook id: echo-files + - duration: [TIME] + + ARGS:file.txt + + ----- stderr ----- + "); + + Ok(()) +} + #[test] fn run_glob_patterns_with_multiple_hooks() -> Result<()> { let context = TestContext::new(); @@ -142,7 +193,7 @@ fn run_in_non_git_repo() { ----- stdout ----- ----- stderr ----- - error: Command `get git root` exited with an error: + error: Not inside a Git or Jujutsu repository: Command `get git root` exited with an error: [status] exit status: 128 diff --git a/docs/faq.md b/docs/faq.md index df4754031..a29420249 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -25,6 +25,18 @@ Running `prek install` installs the first type: it writes the Git shim so that G Adding `--prepare-hooks` tells prek to do that **and** proactively create the environments and caches required by the hooks that prek manages. That way, the next time Git invokes prek through the shim, the managed hooks are ready to run without additional setup. The older `--install-hooks` spelling remains as an alias. +## Does prek work with Jujutsu (jj)? + +Yes. prek detects [Jujutsu](https://jj-vcs.github.io/jj/) workspaces automatically, including secondary workspaces created with `jj workspace add`. No extra configuration is needed. + +When running inside a jj workspace, prek: + +- Resolves the backing Git directory from `.jj/repo/store/git_target`, so all internal git commands work even when there is no `.git` directory. +- Uses `jj diff --name-only` instead of `git diff --staged` to collect changed files, since jj does not use Git's staging area. +- Disables git-index stashing, which is not applicable to jj. + +The `--all-files`, `--files`, and `--from-ref`/`--to-ref` modes work the same way as in a regular git repository. + ## How do I use hooks from private repositories? prek supports cloning hooks from private repositories that require authentication. diff --git a/docs/quickstart.md b/docs/quickstart.md index 3fb2c1927..973d12875 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -59,7 +59,7 @@ Once you’re happy with your setup, you can stage the config file with `git add ### 2. Run hooks on demand -Use `prek run` to execute all configured hooks on the files in your current git staging area: +Use `prek run` to execute all configured hooks on the files in your current git staging area (or jj working copy, if you use [Jujutsu](https://jj-vcs.github.io/jj/)): ```bash prek run diff --git a/docs/workspace.md b/docs/workspace.md index 896237ebe..cde430736 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -19,7 +19,7 @@ When you run `prek run` without the `--config` option, `prek` automatically disc 2. **Discover all projects**: From the workspace root, `prek` recursively searches all subdirectories for additional `.pre-commit-config.yaml` files. Each one becomes a separate project. -3. **Git repository boundary**: The search stops at the git repository root (`.git` directory) to avoid including unrelated projects. +3. **Repository boundary**: The search stops at the repository root (`.git` or `.jj` directory) to avoid including unrelated projects. !!! note