From abcf8dcbea5b0bba5b8a7b27fab5165b08a38814 Mon Sep 17 00:00:00 2001 From: Martijn Berger Date: Fri, 20 Feb 2026 10:53:19 +0100 Subject: [PATCH 1/5] Add jj (Jujutsu) workspace support Enable prek to work inside jj workspaces, including secondary workspaces created with `jj workspace add` where there is no `.git` directory. - Add new `jj` module that detects jj workspaces and resolves the backing git directory from `.jj/repo/store/git_target` - Set GIT_DIR/GIT_WORK_TREE env vars early in startup so all git commands work transparently in jj workspaces - Use `jj diff --name-only` instead of `git diff --staged` for file collection, since jj doesn't use git's staging area - Disable git-specific stashing/work-tree-cleaning for jj workspaces - Short-circuit merge conflict detection for jj (handles conflicts differently) --- crates/prek/src/cli/run/filter.rs | 13 ++- crates/prek/src/cli/run/run.rs | 3 +- crates/prek/src/git.rs | 3 + crates/prek/src/jj.rs | 153 ++++++++++++++++++++++++++++++ crates/prek/src/main.rs | 3 + 5 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 crates/prek/src/jj.rs diff --git a/crates/prek/src/cli/run/filter.rs b/crates/prek/src/cli/run/filter.rs index a2ace2557..bb5fa7942 100644 --- a/crates/prek/src/cli/run/filter.rs +++ b/crates/prek/src/cli/run/filter.rs @@ -387,8 +387,17 @@ async fn collect_files_from_args( return Ok(files); } - let files = git::get_staged_files(workspace_root).await?; - debug!("Staged files: {}", files.len()); + let files = if *crate::jj::IS_JJ_WORKSPACE { + let files = crate::jj::get_changed_files(workspace_root) + .await + .map_err(|e| anyhow::anyhow!(e))?; + debug!("jj changed files: {}", files.len()); + files + } else { + let files = git::get_staged_files(workspace_root).await?; + debug!("Staged files: {}", files.len()); + files + }; Ok(files) } diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index e08b66c8c..908a0a16d 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -69,7 +69,8 @@ pub(crate) async fn run( // Ensure we are in a git 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() && !*crate::jj::IS_JJ_WORKSPACE; // 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..e8d59dda4 100644 --- a/crates/prek/src/git.rs +++ b/crates/prek/src/git.rs @@ -249,6 +249,9 @@ pub(crate) async fn has_diff(rev: &str, path: &Path) -> Result { } pub(crate) async fn is_in_merge_conflict() -> Result { + if *crate::jj::IS_JJ_WORKSPACE { + return Ok(false); // jj handles conflicts differently + } let git_dir = get_git_dir().await?; Ok(git_dir.join("MERGE_HEAD").try_exists()? && git_dir.join("MERGE_MSG").try_exists()?) } diff --git a/crates/prek/src/jj.rs b/crates/prek/src/jj.rs new file mode 100644 index 000000000..770c553b2 --- /dev/null +++ b/crates/prek/src/jj.rs @@ -0,0 +1,153 @@ +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use tracing::debug; + +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 jj: {0}")] + JjNotFound(#[from] which::Error), + + #[error(transparent)] + Io(#[from] std::io::Error), +} + +pub(crate) static JJ: LazyLock> = + LazyLock::new(|| which::which("jj")); + +/// Detect if we're inside a jj workspace by walking up from CWD looking for a `.jj/` directory. +pub(crate) static IS_JJ_WORKSPACE: LazyLock = LazyLock::new(|| { + let Ok(cwd) = std::env::current_dir() else { + return false; + }; + find_jj_dir(&cwd).is_some() +}); + +/// Walk up from `start` looking for a directory containing `.jj/`. +fn find_jj_dir(start: &Path) -> Option { + let mut current = start.to_path_buf(); + loop { + let jj_dir = current.join(".jj"); + if jj_dir.is_dir() { + return Some(jj_dir); + } + if !current.pop() { + return None; + } + } +} + +/// Detect the backing git directory for a jj 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. +/// +/// The git store location is read from `/store/git_target`. +fn detect_jj_git_dir() -> Option { + let cwd = std::env::current_dir().ok()?; + let jj_dir = find_jj_dir(&cwd)?; + + let repo_dir_candidate = jj_dir.join("repo"); + let repo_dir = if repo_dir_candidate.is_file() { + // Secondary workspace: file contains the path to the main repo dir. + let content = std::fs::read_to_string(&repo_dir_candidate).ok()?; + let path = PathBuf::from(content.trim()); + if path.is_absolute() { + path + } else { + jj_dir.join(path) + } + } else if repo_dir_candidate.is_dir() { + repo_dir_candidate + } else { + return None; + }; + + let git_target_file = repo_dir.join("store").join("git_target"); + let git_target = std::fs::read_to_string(&git_target_file).ok()?; + 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) + }; + + // Canonicalize to resolve any `..` components. + let git_dir = git_dir.canonicalize().ok()?; + + if git_dir.exists() { + Some(git_dir) + } else { + None + } +} + +/// Set `GIT_DIR` and `GIT_WORK_TREE` environment variables for jj workspaces. +/// +/// This must be called early in startup, before any git commands are run. +/// If `GIT_DIR` is already set, we leave it alone (e.g., running from a git hook). +pub(crate) fn setup_git_env_for_jj() { + if std::env::var_os("GIT_DIR").is_some() { + return; + } + + let Some(git_dir) = detect_jj_git_dir() else { + return; + }; + + let cwd = match std::env::current_dir() { + Ok(cwd) => cwd, + Err(_) => return, + }; + + // Find the workspace root (the directory containing `.jj/`). + let workspace_root = find_jj_dir(&cwd) + .and_then(|jj_dir| jj_dir.parent().map(Path::to_path_buf)) + .unwrap_or(cwd); + + debug!( + "jj workspace detected, setting GIT_DIR={}, GIT_WORK_TREE={}", + git_dir.display(), + workspace_root.display() + ); + + unsafe { + std::env::set_var("GIT_DIR", &git_dir); + std::env::set_var("GIT_WORK_TREE", &workspace_root); + } +} + +pub(crate) fn jj_cmd(summary: &str) -> Result { + let cmd = Cmd::new(JJ.as_ref().map_err(|&e| Error::JjNotFound(e))?, summary); + Ok(cmd) +} + +/// Get the list of changed files in the current jj working copy. +/// +/// Uses `jj diff --name-only` which lists files changed in the current changeset. +pub(crate) async fn get_changed_files(root: &Path) -> Result, Error> { + let output = jj_cmd("jj diff")? + .current_dir(root) + .arg("diff") + .arg("--name-only") + .check(true) + .output() + .await?; + + let files = String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect(); + + Ok(files) +} diff --git a/crates/prek/src/main.rs b/crates/prek/src/main.rs index 189544b87..e7bb8a244 100644 --- a/crates/prek/src/main.rs +++ b/crates/prek/src/main.rs @@ -34,6 +34,7 @@ mod cli; mod config; mod fs; mod git; +mod jj; mod hook; mod hooks; mod http; @@ -193,6 +194,8 @@ async fn run(cli: Cli) -> Result { } } + // Detect jj workspace and set GIT_DIR/GIT_WORK_TREE if needed. + jj::setup_git_env_for_jj(); // If `GIT_DIR` is set, prek may be running from a git hook. // Git exports `GIT_DIR` but *not* `GIT_WORK_TREE`. Without `GIT_WORK_TREE`, git // treats the current working directory as the working tree. If prek changes the current From 8630b0bb683580d6c998b79b3096eb242e606458 Mon Sep 17 00:00:00 2001 From: Martijn Berger Date: Fri, 20 Feb 2026 11:03:13 +0100 Subject: [PATCH 2/5] Polish jj module: fix clippy warnings, align code style, add tests - Use EnvVars constants instead of raw string literals for GIT_DIR/GIT_WORK_TREE - Use EnvVars::is_set() instead of disallowed std::env::var_os() - Use let...else pattern per clippy::manual_let_else - Add #[instrument(level = "trace")] to get_changed_files() matching git.rs - Add doc comments to JJ static and jj_cmd() matching git.rs conventions - Import prek_consts::env_vars::EnvVars for consistency with rest of codebase - Add unit tests for find_jj_dir, colocated workspace, and secondary workspace resolution --- crates/prek/src/jj.rs | 128 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 120 insertions(+), 8 deletions(-) diff --git a/crates/prek/src/jj.rs b/crates/prek/src/jj.rs index 770c553b2..e285c445e 100644 --- a/crates/prek/src/jj.rs +++ b/crates/prek/src/jj.rs @@ -1,7 +1,8 @@ use std::path::{Path, PathBuf}; use std::sync::LazyLock; -use tracing::debug; +use prek_consts::env_vars::EnvVars; +use tracing::{debug, instrument}; use crate::process; use crate::process::Cmd; @@ -18,10 +19,11 @@ pub(crate) enum Error { Io(#[from] std::io::Error), } +/// Path to the `jj` executable, resolved via `PATH`. pub(crate) static JJ: LazyLock> = LazyLock::new(|| which::which("jj")); -/// Detect if we're inside a jj workspace by walking up from CWD looking for a `.jj/` directory. +/// Whether the current working directory is inside a jj workspace. pub(crate) static IS_JJ_WORKSPACE: LazyLock = LazyLock::new(|| { let Ok(cwd) = std::env::current_dir() else { return false; @@ -96,7 +98,7 @@ fn detect_jj_git_dir() -> Option { /// This must be called early in startup, before any git commands are run. /// If `GIT_DIR` is already set, we leave it alone (e.g., running from a git hook). pub(crate) fn setup_git_env_for_jj() { - if std::env::var_os("GIT_DIR").is_some() { + if EnvVars::is_set(EnvVars::GIT_DIR) { return; } @@ -104,9 +106,8 @@ pub(crate) fn setup_git_env_for_jj() { return; }; - let cwd = match std::env::current_dir() { - Ok(cwd) => cwd, - Err(_) => return, + let Ok(cwd) = std::env::current_dir() else { + return; }; // Find the workspace root (the directory containing `.jj/`). @@ -121,11 +122,12 @@ pub(crate) fn setup_git_env_for_jj() { ); unsafe { - std::env::set_var("GIT_DIR", &git_dir); - std::env::set_var("GIT_WORK_TREE", &workspace_root); + std::env::set_var(EnvVars::GIT_DIR, &git_dir); + std::env::set_var(EnvVars::GIT_WORK_TREE, &workspace_root); } } +/// Create a new `Cmd` for running jj. pub(crate) fn jj_cmd(summary: &str) -> Result { let cmd = Cmd::new(JJ.as_ref().map_err(|&e| Error::JjNotFound(e))?, summary); Ok(cmd) @@ -134,6 +136,7 @@ pub(crate) fn jj_cmd(summary: &str) -> Result { /// Get the list of changed files in the current jj working copy. /// /// Uses `jj diff --name-only` which lists files changed in the current changeset. +#[instrument(level = "trace")] pub(crate) async fn get_changed_files(root: &Path) -> Result, Error> { let output = jj_cmd("jj diff")? .current_dir(root) @@ -151,3 +154,112 @@ pub(crate) async fn get_changed_files(root: &Path) -> Result, Error Ok(files) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_jj_dir_returns_none_for_non_jj_directory() { + let dir = tempfile::tempdir().unwrap(); + assert!(find_jj_dir(dir.path()).is_none()); + } + + #[test] + fn find_jj_dir_finds_jj_in_current_directory() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".jj")).unwrap(); + let result = find_jj_dir(dir.path()); + assert_eq!(result, Some(dir.path().join(".jj"))); + } + + #[test] + fn find_jj_dir_finds_jj_in_parent_directory() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".jj")).unwrap(); + let child = dir.path().join("subdir"); + std::fs::create_dir(&child).unwrap(); + let result = find_jj_dir(&child); + assert_eq!(result, Some(dir.path().join(".jj"))); + } + + #[test] + fn detect_jj_git_dir_returns_none_without_jj_workspace() { + let dir = tempfile::tempdir().unwrap(); + // No .jj dir at all — detection should return None. + // We can't easily test this without changing CWD, so just verify + // the helper function returns None. + assert!(find_jj_dir(dir.path()).is_none()); + } + + #[test] + fn detect_jj_git_dir_returns_none_without_git_target() { + let dir = tempfile::tempdir().unwrap(); + let jj_dir = dir.path().join(".jj"); + let repo_dir = jj_dir.join("repo"); + let store_dir = repo_dir.join("store"); + std::fs::create_dir_all(&store_dir).unwrap(); + // No git_target file — should not resolve. + // detect_jj_git_dir() reads CWD, so we test the building blocks. + assert!(find_jj_dir(dir.path()).is_some()); + assert!(!store_dir.join("git_target").exists()); + } + + #[test] + fn detect_jj_git_dir_resolves_colocated_workspace() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + // Set up a colocated jj workspace structure: + // .jj/repo/store/git_target → "../../../.git" + // .git/ + let jj_dir = root.join(".jj"); + let store_dir = jj_dir.join("repo").join("store"); + std::fs::create_dir_all(&store_dir).unwrap(); + std::fs::write(store_dir.join("git_target"), "../../../.git").unwrap(); + let git_dir = root.join(".git"); + std::fs::create_dir(&git_dir).unwrap(); + + // Since detect_jj_git_dir uses std::env::current_dir, we test the + // resolution logic directly. + let repo_dir = jj_dir.join("repo"); + let git_target = std::fs::read_to_string(repo_dir.join("store").join("git_target")).unwrap(); + let resolved = repo_dir.join("store").join(git_target.trim()); + let resolved = resolved.canonicalize().unwrap(); + assert_eq!(resolved, git_dir.canonicalize().unwrap()); + } + + #[test] + fn detect_jj_git_dir_resolves_secondary_workspace() { + let dir = tempfile::tempdir().unwrap(); + let main_root = dir.path().join("main"); + let secondary_root = dir.path().join("secondary"); + + // Set up main workspace: + // main/.jj/repo/store/git_target → "../../../.git" + // main/.git/ + let main_jj = main_root.join(".jj"); + let main_store = main_jj.join("repo").join("store"); + std::fs::create_dir_all(&main_store).unwrap(); + std::fs::write(main_store.join("git_target"), "../../../.git").unwrap(); + let main_git = main_root.join(".git"); + std::fs::create_dir(&main_git).unwrap(); + + // Set up secondary workspace: + // secondary/.jj/repo → file pointing to main/.jj/repo (absolute path) + let secondary_jj = secondary_root.join(".jj"); + std::fs::create_dir_all(&secondary_jj).unwrap(); + let main_repo_abs = main_jj.join("repo").canonicalize().unwrap(); + std::fs::write(secondary_jj.join("repo"), main_repo_abs.to_str().unwrap()).unwrap(); + + // Verify secondary workspace resolves to the same git dir. + let repo_content = std::fs::read_to_string(secondary_jj.join("repo")).unwrap(); + let repo_dir = PathBuf::from(repo_content.trim()); + assert!(repo_dir.is_dir()); + + let git_target = std::fs::read_to_string(repo_dir.join("store").join("git_target")).unwrap(); + let resolved = repo_dir.join("store").join(git_target.trim()); + let resolved = resolved.canonicalize().unwrap(); + assert_eq!(resolved, main_git.canonicalize().unwrap()); + } +} From ca08373f16b8678a083afcfbc1c680ecb75c53c2 Mon Sep 17 00:00:00 2001 From: Martijn Berger Date: Fri, 20 Feb 2026 11:06:45 +0100 Subject: [PATCH 3/5] Document jj (Jujutsu) workspace support - Add FAQ entry explaining how prek detects and works with jj workspaces - Mention jj working copy in quickstart guide's "run hooks on demand" section - Update workspace discovery docs to reference .jj alongside .git as a repository boundary --- docs/faq.md | 12 ++++++++++++ docs/quickstart.md | 2 +- docs/workspace.md | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) 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 From 1ec9701591ea2faa823b4d2f7b4683e14818da9a Mon Sep 17 00:00:00 2001 From: Martijn Berger Date: Fri, 20 Feb 2026 11:21:15 +0100 Subject: [PATCH 4/5] Obviously did not run format --- crates/prek/src/jj.rs | 6 ++++-- crates/prek/src/main.rs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/prek/src/jj.rs b/crates/prek/src/jj.rs index e285c445e..e2942615e 100644 --- a/crates/prek/src/jj.rs +++ b/crates/prek/src/jj.rs @@ -223,7 +223,8 @@ mod tests { // Since detect_jj_git_dir uses std::env::current_dir, we test the // resolution logic directly. let repo_dir = jj_dir.join("repo"); - let git_target = std::fs::read_to_string(repo_dir.join("store").join("git_target")).unwrap(); + let git_target = + std::fs::read_to_string(repo_dir.join("store").join("git_target")).unwrap(); let resolved = repo_dir.join("store").join(git_target.trim()); let resolved = resolved.canonicalize().unwrap(); assert_eq!(resolved, git_dir.canonicalize().unwrap()); @@ -257,7 +258,8 @@ mod tests { let repo_dir = PathBuf::from(repo_content.trim()); assert!(repo_dir.is_dir()); - let git_target = std::fs::read_to_string(repo_dir.join("store").join("git_target")).unwrap(); + let git_target = + std::fs::read_to_string(repo_dir.join("store").join("git_target")).unwrap(); let resolved = repo_dir.join("store").join(git_target.trim()); let resolved = resolved.canonicalize().unwrap(); assert_eq!(resolved, main_git.canonicalize().unwrap()); diff --git a/crates/prek/src/main.rs b/crates/prek/src/main.rs index e7bb8a244..4b142f93d 100644 --- a/crates/prek/src/main.rs +++ b/crates/prek/src/main.rs @@ -34,11 +34,11 @@ mod cli; mod config; mod fs; mod git; -mod jj; mod hook; mod hooks; mod http; mod install_source; +mod jj; mod languages; mod printer; mod process; From dd4eb52be9ce380949178340221ed6e11f9353ab Mon Sep 17 00:00:00 2001 From: Martijn Berger Date: Sat, 14 Mar 2026 10:05:03 +0100 Subject: [PATCH 5/5] Refactor repository backend for Jujutsu support --- crates/prek/src/cli/hook_impl.rs | 4 +- crates/prek/src/cli/install.rs | 13 +- crates/prek/src/cli/run/filter.rs | 27 +- crates/prek/src/cli/run/run.rs | 9 +- crates/prek/src/git.rs | 21 +- .../pre_commit_hooks/check_case_conflict.rs | 6 +- .../check_executables_have_shebangs.rs | 72 +--- crates/prek/src/jj.rs | 303 +++++++++-------- crates/prek/src/main.rs | 3 +- crates/prek/src/process.rs | 31 ++ crates/prek/src/repo.rs | 317 ++++++++++++++++++ crates/prek/src/workspace.rs | 6 +- crates/prek/tests/builtin_hooks.rs | 55 +++ crates/prek/tests/run.rs | 53 ++- 14 files changed, 666 insertions(+), 254 deletions(-) create mode 100644 crates/prek/src/repo.rs 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 bb5fa7942..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,28 +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 = if *crate::jj::IS_JJ_WORKSPACE { - let files = crate::jj::get_changed_files(workspace_root) - .await - .map_err(|e| anyhow::anyhow!(e))?; - debug!("jj changed files: {}", files.len()); - files - } else { - let files = git::get_staged_files(workspace_root).await?; - debug!("Staged files: {}", files.len()); - files - }; + 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 908a0a16d..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,11 +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() && !*crate::jj::IS_JJ_WORKSPACE; + 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 e8d59dda4..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) } @@ -249,9 +257,6 @@ pub(crate) async fn has_diff(rev: &str, path: &Path) -> Result { } pub(crate) async fn is_in_merge_conflict() -> Result { - if *crate::jj::IS_JJ_WORKSPACE { - return Ok(false); // jj handles conflicts differently - } let git_dir = get_git_dir().await?; Ok(git_dir.join("MERGE_HEAD").try_exists()? && git_dir.join("MERGE_MSG").try_exists()?) } 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 index e2942615e..affe4706c 100644 --- a/crates/prek/src/jj.rs +++ b/crates/prek/src/jj.rs @@ -1,8 +1,7 @@ use std::path::{Path, PathBuf}; use std::sync::LazyLock; -use prek_consts::env_vars::EnvVars; -use tracing::{debug, instrument}; +use tracing::instrument; use crate::process; use crate::process::Cmd; @@ -12,7 +11,7 @@ pub(crate) enum Error { #[error(transparent)] Command(#[from] process::Error), - #[error("Failed to find jj: {0}")] + #[error("Failed to find Jujutsu (jj): {0}")] JjNotFound(#[from] which::Error), #[error(transparent)] @@ -23,21 +22,12 @@ pub(crate) enum Error { pub(crate) static JJ: LazyLock> = LazyLock::new(|| which::which("jj")); -/// Whether the current working directory is inside a jj workspace. -pub(crate) static IS_JJ_WORKSPACE: LazyLock = LazyLock::new(|| { - let Ok(cwd) = std::env::current_dir() else { - return false; - }; - find_jj_dir(&cwd).is_some() -}); - /// Walk up from `start` looking for a directory containing `.jj/`. -fn find_jj_dir(start: &Path) -> Option { +pub(crate) fn find_workspace_root(start: &Path) -> Option { let mut current = start.to_path_buf(); loop { - let jj_dir = current.join(".jj"); - if jj_dir.is_dir() { - return Some(jj_dir); + if current.join(".jj").is_dir() { + return Some(current); } if !current.pop() { return None; @@ -45,35 +35,36 @@ fn find_jj_dir(start: &Path) -> Option { } } -/// Detect the backing git directory for a jj workspace. +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. -/// -/// The git store location is read from `/store/git_target`. -fn detect_jj_git_dir() -> Option { - let cwd = std::env::current_dir().ok()?; - let jj_dir = find_jj_dir(&cwd)?; - - let repo_dir_candidate = jj_dir.join("repo"); - let repo_dir = if repo_dir_candidate.is_file() { - // Secondary workspace: file contains the path to the main repo dir. - let content = std::fs::read_to_string(&repo_dir_candidate).ok()?; - let path = PathBuf::from(content.trim()); - if path.is_absolute() { - path - } else { - jj_dir.join(path) - } - } else if repo_dir_candidate.is_dir() { - repo_dir_candidate - } else { - return None; +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 = std::fs::read_to_string(&git_target_file).ok()?; + let git_target = fs_err::read_to_string(&git_target_file)?; let git_target = git_target.trim(); let git_path = PathBuf::from(git_target); @@ -82,74 +73,116 @@ fn detect_jj_git_dir() -> Option { } else { repo_dir.join("store").join(git_path) }; - - // Canonicalize to resolve any `..` components. - let git_dir = git_dir.canonicalize().ok()?; + let git_dir = git_dir.canonicalize()?; if git_dir.exists() { - Some(git_dir) + Ok(Some(git_dir)) } else { - None + Ok(None) } } -/// Set `GIT_DIR` and `GIT_WORK_TREE` environment variables for jj workspaces. -/// -/// This must be called early in startup, before any git commands are run. -/// If `GIT_DIR` is already set, we leave it alone (e.g., running from a git hook). -pub(crate) fn setup_git_env_for_jj() { - if EnvVars::is_set(EnvVars::GIT_DIR) { - return; - } - - let Some(git_dir) = detect_jj_git_dir() else { - return; - }; - - let Ok(cwd) = std::env::current_dir() else { - return; - }; +/// 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) +} - // Find the workspace root (the directory containing `.jj/`). - let workspace_root = find_jj_dir(&cwd) - .and_then(|jj_dir| jj_dir.parent().map(Path::to_path_buf)) - .unwrap_or(cwd); +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) +} - debug!( - "jj workspace detected, setting GIT_DIR={}, GIT_WORK_TREE={}", - git_dir.display(), - workspace_root.display() - ); +fn parse_path_lines(output: &[u8]) -> Vec { + String::from_utf8_lossy(output) + .lines() + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect() +} - unsafe { - std::env::set_var(EnvVars::GIT_DIR, &git_dir); - std::env::set_var(EnvVars::GIT_WORK_TREE, &workspace_root); +/// 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); } -} -/// Create a new `Cmd` for running jj. -pub(crate) fn jj_cmd(summary: &str) -> Result { - let cmd = Cmd::new(JJ.as_ref().map_err(|&e| Error::JjNotFound(e))?, summary); - Ok(cmd) + let output = cmd.check(true).output().await?; + Ok(parse_path_lines(&output.stdout)) } -/// Get the list of changed files in the current jj working copy. -/// -/// Uses `jj diff --name-only` which lists files changed in the current changeset. +/// 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(|line| !line.is_empty()) - .map(PathBuf::from) + .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) @@ -160,108 +193,74 @@ mod tests { use super::*; #[test] - fn find_jj_dir_returns_none_for_non_jj_directory() { + fn find_workspace_root_returns_none_for_non_jj_directory() { let dir = tempfile::tempdir().unwrap(); - assert!(find_jj_dir(dir.path()).is_none()); + assert!(find_workspace_root(dir.path()).is_none()); } #[test] - fn find_jj_dir_finds_jj_in_current_directory() { + fn find_workspace_root_finds_current_directory() { let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir(dir.path().join(".jj")).unwrap(); - let result = find_jj_dir(dir.path()); - assert_eq!(result, Some(dir.path().join(".jj"))); + 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_jj_dir_finds_jj_in_parent_directory() { + fn find_workspace_root_finds_parent_directory() { let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir(dir.path().join(".jj")).unwrap(); + fs_err::create_dir(dir.path().join(".jj")).unwrap(); let child = dir.path().join("subdir"); - std::fs::create_dir(&child).unwrap(); - let result = find_jj_dir(&child); - assert_eq!(result, Some(dir.path().join(".jj"))); + fs_err::create_dir(&child).unwrap(); + let result = find_workspace_root(&child); + assert_eq!(result, Some(dir.path().to_path_buf())); } #[test] - fn detect_jj_git_dir_returns_none_without_jj_workspace() { + fn resolve_backing_git_dir_returns_none_without_repo_metadata() { let dir = tempfile::tempdir().unwrap(); - // No .jj dir at all — detection should return None. - // We can't easily test this without changing CWD, so just verify - // the helper function returns None. - assert!(find_jj_dir(dir.path()).is_none()); + fs_err::create_dir(dir.path().join(".jj")).unwrap(); + let result = resolve_backing_git_dir(dir.path()).unwrap(); + assert!(result.is_none()); } #[test] - fn detect_jj_git_dir_returns_none_without_git_target() { - let dir = tempfile::tempdir().unwrap(); - let jj_dir = dir.path().join(".jj"); - let repo_dir = jj_dir.join("repo"); - let store_dir = repo_dir.join("store"); - std::fs::create_dir_all(&store_dir).unwrap(); - // No git_target file — should not resolve. - // detect_jj_git_dir() reads CWD, so we test the building blocks. - assert!(find_jj_dir(dir.path()).is_some()); - assert!(!store_dir.join("git_target").exists()); - } - - #[test] - fn detect_jj_git_dir_resolves_colocated_workspace() { + fn resolve_backing_git_dir_resolves_colocated_workspace() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); - // Set up a colocated jj workspace structure: - // .jj/repo/store/git_target → "../../../.git" - // .git/ - let jj_dir = root.join(".jj"); - let store_dir = jj_dir.join("repo").join("store"); - std::fs::create_dir_all(&store_dir).unwrap(); - std::fs::write(store_dir.join("git_target"), "../../../.git").unwrap(); + 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"); - std::fs::create_dir(&git_dir).unwrap(); - - // Since detect_jj_git_dir uses std::env::current_dir, we test the - // resolution logic directly. - let repo_dir = jj_dir.join("repo"); - let git_target = - std::fs::read_to_string(repo_dir.join("store").join("git_target")).unwrap(); - let resolved = repo_dir.join("store").join(git_target.trim()); - let resolved = resolved.canonicalize().unwrap(); - assert_eq!(resolved, git_dir.canonicalize().unwrap()); + 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 detect_jj_git_dir_resolves_secondary_workspace() { + 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"); - // Set up main workspace: - // main/.jj/repo/store/git_target → "../../../.git" - // main/.git/ - let main_jj = main_root.join(".jj"); - let main_store = main_jj.join("repo").join("store"); - std::fs::create_dir_all(&main_store).unwrap(); - std::fs::write(main_store.join("git_target"), "../../../.git").unwrap(); + 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"); - std::fs::create_dir(&main_git).unwrap(); + fs_err::create_dir(&main_git).unwrap(); - // Set up secondary workspace: - // secondary/.jj/repo → file pointing to main/.jj/repo (absolute path) let secondary_jj = secondary_root.join(".jj"); - std::fs::create_dir_all(&secondary_jj).unwrap(); - let main_repo_abs = main_jj.join("repo").canonicalize().unwrap(); - std::fs::write(secondary_jj.join("repo"), main_repo_abs.to_str().unwrap()).unwrap(); - - // Verify secondary workspace resolves to the same git dir. - let repo_content = std::fs::read_to_string(secondary_jj.join("repo")).unwrap(); - let repo_dir = PathBuf::from(repo_content.trim()); - assert!(repo_dir.is_dir()); - - let git_target = - std::fs::read_to_string(repo_dir.join("store").join("git_target")).unwrap(); - let resolved = repo_dir.join("store").join(git_target.trim()); - let resolved = resolved.canonicalize().unwrap(); - assert_eq!(resolved, main_git.canonicalize().unwrap()); + 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 4b142f93d..4ebe1a4d1 100644 --- a/crates/prek/src/main.rs +++ b/crates/prek/src/main.rs @@ -44,6 +44,7 @@ mod printer; mod process; #[cfg(all(unix, feature = "profiler"))] mod profiler; +mod repo; #[cfg(unix)] mod resource_limit; mod run; @@ -194,8 +195,6 @@ async fn run(cli: Cli) -> Result { } } - // Detect jj workspace and set GIT_DIR/GIT_WORK_TREE if needed. - jj::setup_git_env_for_jj(); // If `GIT_DIR` is set, prek may be running from a git hook. // Git exports `GIT_DIR` but *not* `GIT_WORK_TREE`. Without `GIT_WORK_TREE`, git // treats the current working directory as the working tree. If prek changes the current 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