Skip to content

Jj workspace support#1677

Draft
martijnberger wants to merge 5 commits intoj178:masterfrom
martijnberger:jj-workspace-support
Draft

Jj workspace support#1677
martijnberger wants to merge 5 commits intoj178:masterfrom
martijnberger:jj-workspace-support

Conversation

@martijnberger
Copy link

@martijnberger martijnberger commented Feb 20, 2026

Motivation

I'm a happy jj (Jujutsu) user and I love prek. I just want the two to work together. Right now, prek fails in jj workspaces because:

  1. Secondary workspaces (created with jj workspace add) have no .git directory, only .jj/. Every git command fails with "fatal: not a git repository".
  2. Even in colocated workspaces, git diff --staged returns empty because jj doesn't use git's staging area. This makes the default prek run mode (which checks staged files) non-functional.

This PR makes prek detect jj workspaces transparently and do the right thing, with no configuration required.

Changes

New: crates/prek/src/jj.rs

A new module for all jj-related logic, mirroring git.rs patterns:

IS_JJ_WORKSPACE detects .jj/ by walking up from CWD. detect_jj_git_dir() resolves the backing .git directory from .jj/repo/store/git_target, handling both primary (colocated) and secondary workspaces. setup_git_env_for_jj() sets GIT_DIR and GIT_WORK_TREE env vars early in startup so all git commands work transparently. get_changed_files() uses jj diff --name-only to collect changed files in the working copy.

Modified files

main.rs calls setup_git_env_for_jj() before any GIT_DIR handling.

cli/run/run.rs disables stashing for jj workspaces (stashing, check_configs_staged(), has_unmerged_paths(), and WorkTreeKeeper::clean() are all git-index-specific).

cli/run/filter.rs uses jj::get_changed_files() instead of git::get_staged_files() when in a jj workspace.

git.rs short-circuits is_in_merge_conflict() for jj (jj handles conflicts differently).

Documentation

docs/faq.md new FAQ entry: "Does prek work with Jujutsu (jj)?"

docs/quickstart.md mentions jj working copy alongside git staging area.

docs/workspace.md updated repository boundary description to include .jj.

Test plan

  • 7 new unit tests covering find_jj_dir, colocated workspace resolution, and secondary workspace resolution
  • All 288 existing unit tests pass
  • Zero clippy warnings on changed files (including pedantic + nursery lints)
  • Manual test in a jj colocated workspace with prek run
  • Manual test in a jj secondary workspace with prek run --all-files
  • Verify existing git behavior is unaffected (IS_JJ_WORKSPACE = false when no .jj/ exists)

@martijnberger martijnberger requested a review from j178 as a code owner February 20, 2026 10:17
Copilot AI review requested due to automatic review settings February 20, 2026 10:17
@codecov
Copy link

codecov bot commented Feb 20, 2026

Codecov Report

❌ Patch coverage is 75.21127% with 88 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.44%. Comparing base (2c53121) to head (dd4eb52).
⚠️ Report is 30 commits behind head on master.

Files with missing lines Patch % Lines
crates/prek/src/jj.rs 66.66% 45 Missing ⚠️
crates/prek/src/repo.rs 78.65% 35 Missing ⚠️
...re_commit_hooks/check_executables_have_shebangs.rs 40.00% 6 Missing ⚠️
crates/prek/src/cli/install.rs 75.00% 1 Missing ⚠️
crates/prek/src/workspace.rs 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1677      +/-   ##
==========================================
- Coverage   91.69%   91.44%   -0.26%     
==========================================
  Files          98      100       +2     
  Lines       19999    20288     +289     
==========================================
+ Hits        18339    18552     +213     
- Misses       1660     1736      +76     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.


let files = git::get_staged_files(workspace_root).await?;
debug!("Staged files: {}", files.len());
let files = if *crate::jj::IS_JJ_WORKSPACE {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rest of the project does not reference crates this way.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as in the crate:: prefix ?

Comment on lines 378 to 382
if all_files {
let files = git::ls_files(git_root, workspace_root).await?;
debug!("All files in the workspace: {}", files.len());
return Ok(files);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jj file list is the jj equiv.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think I should take another pass and push this deeper into prek rather then patch the surface

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds support for Jujutsu (jj) workspaces to prek, enabling the tool to work seamlessly with both primary (colocated) and secondary jj workspaces. The implementation detects jj workspaces automatically and adapts prek's behavior to work with jj's different model (no staging area, different conflict handling).

Changes:

  • Adds new jj.rs module with workspace detection, git directory resolution, and changed files collection
  • Modifies startup sequence to set GIT_DIR and GIT_WORK_TREE environment variables for jj workspaces
  • Disables git-index-specific features (stashing, merge conflict detection) when in jj workspaces
  • Updates documentation to explain jj support

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
crates/prek/src/jj.rs New module implementing jj workspace detection, git directory resolution for colocated/secondary workspaces, and changed files collection via jj diff --name-only
crates/prek/src/main.rs Calls setup_git_env_for_jj() early in startup to configure environment variables before any git commands run
crates/prek/src/cli/run/run.rs Disables stashing for jj workspaces by adding !*crate::jj::IS_JJ_WORKSPACE to the should_stash condition
crates/prek/src/cli/run/filter.rs Uses jj::get_changed_files() instead of git::get_staged_files() when IS_JJ_WORKSPACE is true
crates/prek/src/git.rs Short-circuits is_in_merge_conflict() to return false for jj workspaces since jj handles conflicts differently
docs/faq.md Adds FAQ entry explaining jj support, automatic detection, and behavioral differences
docs/quickstart.md Updates prek run description to mention jj working copy alongside git staging area
docs/workspace.md Updates repository boundary description to include .jj directory

Comment on lines +26 to +32
/// Whether the current working directory is inside a jj workspace.
pub(crate) static IS_JJ_WORKSPACE: LazyLock<bool> = LazyLock::new(|| {
let Ok(cwd) = std::env::current_dir() else {
return false;
};
find_jj_dir(&cwd).is_some()
});
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IS_JJ_WORKSPACE LazyLock is initialized based on the current working directory at the time it's first accessed. If the working directory changes before this LazyLock is initialized (e.g., via the --cd option), the detection may be incorrect. However, looking at main.rs, setup_git_env_for_jj() is called before the --cd option is processed (line 207), so accessing IS_JJ_WORKSPACE later will use the original cwd. This is correct but fragile - consider documenting this dependency or restructuring to make it more explicit.

Copilot uses AI. Check for mistakes.
Comment on lines +158 to +265
#[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());
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no integration tests for jj workspace functionality. The PR only includes unit tests for the jj.rs module itself. Consider adding integration tests that verify end-to-end behavior, such as: running hooks in a simulated jj workspace, verifying that --all-files works correctly, and ensuring that stashing is properly disabled. Without integration tests, it's difficult to verify that all the pieces work together correctly.

Copilot uses AI. Check for mistakes.
Comment on lines +114 to +116
let workspace_root = find_jj_dir(&cwd)
.and_then(|jj_dir| jj_dir.parent().map(Path::to_path_buf))
.unwrap_or(cwd);
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On line 116, if find_jj_dir(&cwd) returns None (which shouldn't happen since we already validated it exists in detect_jj_git_dir()), or if .parent() returns None (which would be unusual for a .jj directory), the code falls back to using cwd as the workspace root. This fallback might lead to incorrect behavior where GIT_WORK_TREE points to a subdirectory instead of the actual workspace root. Consider whether this fallback is necessary or if it should be an error condition.

Suggested change
let workspace_root = find_jj_dir(&cwd)
.and_then(|jj_dir| jj_dir.parent().map(Path::to_path_buf))
.unwrap_or(cwd);
let Some(workspace_root) = find_jj_dir(&cwd)
.and_then(|jj_dir| jj_dir.parent().map(Path::to_path_buf))
else {
debug!(
"jj workspace detected, but failed to determine workspace root from cwd {}; not setting git env vars",
cwd.display()
);
return;
};

Copilot uses AI. Check for mistakes.
};

let git_target_file = repo_dir.join("store").join("git_target");
let git_target = std::fs::read_to_string(&git_target_file).ok()?;
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new jj.rs module uses std::fs directly instead of fs_err which is the standard throughout the codebase. Using fs_err provides better error messages with file paths included automatically. Replace all instances of std::fs::read_to_string, std::fs::create_dir, etc. with their fs_err:: equivalents.

Copilot uses AI. Check for mistakes.
Comment on lines +186 to +187
// Detect jj workspace and set GIT_DIR/GIT_WORK_TREE if needed.
jj::setup_git_env_for_jj();
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setup_git_env_for_jj function is called before logging is fully initialized. If there are issues in jj workspace detection, the debug messages on lines 118-122 will not be captured in the log file. Consider moving the call after logging is set up in main.rs, or document why early execution is required.

Copilot uses AI. Check for mistakes.
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()?;
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new jj.rs module uses std::fs directly instead of fs_err which is the standard throughout the codebase. Using fs_err provides better error messages with file paths included automatically. Replace all instances of std::fs::read_to_string, std::fs::create_dir, etc. with their fs_err:: equivalents.

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +73
let should_stash =
!all_files && files.is_empty() && directories.is_empty() && !*crate::jj::IS_JJ_WORKSPACE;
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The should_stash condition correctly excludes jj workspaces from stashing, but the code on lines 85-87 (not shown in diff) still calls workspace.check_configs_staged() when should_stash is true. Since this function uses git::files_not_staged() which checks git's staging area, it will work correctly for git repos but may need jj-specific handling in the future. Consider verifying this works as expected in jj workspaces where configs are modified.

Copilot uses AI. Check for mistakes.
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.
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states that prek "Uses jj diff --name-only instead of git diff --staged", but jj diff --name-only shows changes in the current changeset (working copy changes), not just the equivalent of staged files. This is conceptually different from git's staging area. Consider clarifying that jj operates on the working copy changeset, which includes all modified files, not just a subset like git's staging area.

Suggested change
- Uses `jj diff --name-only` instead of `git diff --staged` to collect changed files, since jj does not use Git's staging area.
- Uses `jj diff --name-only` to collect files changed in the current working-copy changeset (jj does not have a separate staging area like `git diff --staged`).

Copilot uses AI. Check for mistakes.
Comment on lines +391 to +393
let files = crate::jj::get_changed_files(workspace_root)
.await
.map_err(|e| anyhow::anyhow!(e))?;
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error from jj::get_changed_files() is converted to anyhow using map_err(|e| anyhow::anyhow!(e)). This is redundant since jj::Error already implements Display and std::error::Error (via thiserror), so it can be automatically converted using the ? operator or .context(). The current git code on line 397 uses ? directly without explicit error conversion.

Suggested change
let files = crate::jj::get_changed_files(workspace_root)
.await
.map_err(|e| anyhow::anyhow!(e))?;
let files = crate::jj::get_changed_files(workspace_root).await?;

Copilot uses AI. Check for mistakes.
let files = if *crate::jj::IS_JJ_WORKSPACE {
let files = crate::jj::get_changed_files(workspace_root)
.await
.map_err(|e| anyhow::anyhow!(e))?;
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When IS_JJ_WORKSPACE is true but the jj command is not available in PATH, calling get_changed_files() will fail with a "Failed to find jj" error. This could happen if someone checks out a jj workspace on a machine without jj installed. Consider providing a more helpful error message in this scenario, or falling back to a different behavior. The error handling in filter.rs (line 393) will show the raw error which may be confusing to users.

Suggested change
.map_err(|e| anyhow::anyhow!(e))?;
.map_err(|e| {
let msg = e.to_string();
if msg.contains("Failed to find jj") {
anyhow::anyhow!(
"Detected a jj workspace, but the `jj` command was not found in PATH.\n\
Install `jj` (https://github.com/martinvonz/jj), ensure it is on your PATH,\n\
or convert this workspace to use git instead.\n\
Underlying error: {msg}"
)
} else {
anyhow::anyhow!(e)
}
})?;

Copilot uses AI. Check for mistakes.
@prek-ci-bot
Copy link

prek-ci-bot bot commented Feb 20, 2026

📦 Cargo Bloat Comparison

Binary size change: +0.81% (24.7 MiB → 24.9 MiB)

Expand for cargo-bloat output

Head Branch Results

 File  .text     Size             Crate Name
 1.3%   2.7% 332.0KiB        aws_lc_sys aws_lc_0_38_0_aes_gcm_encrypt_avx512
 1.3%   2.7% 332.0KiB        aws_lc_sys aws_lc_0_38_0_aes_gcm_decrypt_avx512
 0.3%   0.7%  81.3KiB             prek? <prek::cli::Command as clap_builder::derive::Subcommand>::augment_subcommands
 0.3%   0.6%  77.6KiB              prek prek::languages::<impl prek::config::Language>::run::{{closure}}::{{closure}}
 0.3%   0.6%  69.8KiB              prek prek::languages::<impl prek::config::Language>::run::{{closure}}::{{closure}}
 0.2%   0.4%  51.0KiB annotate_snippets annotate_snippets::renderer::render::render
 0.2%   0.4%  50.6KiB              prek prek::languages::<impl prek::config::Language>::install::{{closure}}
 0.2%   0.4%  46.2KiB              prek prek::run::{{closure}}
 0.2%   0.3%  42.0KiB              prek prek::cli::run::run::run::{{closure}}
 0.1%   0.3%  32.0KiB             prek? <prek::cli::RunArgs as clap_builder::derive::Args>::augment_args
 0.1%   0.2%  28.0KiB        aws_lc_sys aws_lc_0_38_0_edwards25519_scalarmuldouble_alt
 0.1%   0.2%  27.8KiB      serde_saphyr saphyr_parser_bw::scanner::Scanner<T>::fetch_more_tokens
 0.1%   0.2%  27.5KiB        aws_lc_sys aws_lc_0_38_0_edwards25519_scalarmuldouble
 0.1%   0.2%  27.2KiB              prek prek::cli::run::filter::collect_files_from_args::{{closure}}
 0.1%   0.2%  25.8KiB              prek prek::cli::run::filter::collect_files_from_args::{{closure}}
 0.1%   0.2%  25.8KiB              prek prek::cli::try_repo::try_repo::{{closure}}
 0.1%   0.2%  24.9KiB             prek? <prek::config::_::<impl serde_core::de::Deserialize for prek::config::Config>::deserialize::__Visitor as serde_core::de::Visitor>::visit_map
 0.1%   0.2%  23.9KiB              prek prek::cli::run::filter::collect_files_from_args::{{closure}}
 0.1%   0.2%  23.2KiB              prek prek::hooks::meta_hooks::MetaHooks::run::{{closure}}
 0.1%   0.2%  22.4KiB      serde_saphyr saphyr_parser_bw::scanner::Scanner<T>::fetch_more_tokens
41.1%  85.8%  10.2MiB                   And 23423 smaller methods. Use -n N to show more.
47.8% 100.0%  11.9MiB                   .text section size, the file size is 24.9MiB

Base Branch Results

 File  .text     Size             Crate Name
 1.3%   2.7% 332.0KiB        aws_lc_sys aws_lc_0_38_0_aes_gcm_encrypt_avx512
 1.3%   2.7% 332.0KiB        aws_lc_sys aws_lc_0_38_0_aes_gcm_decrypt_avx512
 0.3%   0.7%  81.7KiB             prek? <prek::cli::Command as clap_builder::derive::Subcommand>::augment_subcommands
 0.3%   0.6%  77.6KiB              prek prek::languages::<impl prek::config::Language>::run::{{closure}}::{{closure}}
 0.3%   0.6%  69.8KiB              prek prek::languages::<impl prek::config::Language>::run::{{closure}}::{{closure}}
 0.2%   0.4%  51.0KiB annotate_snippets annotate_snippets::renderer::render::render
 0.2%   0.4%  50.6KiB              prek prek::languages::<impl prek::config::Language>::install::{{closure}}
 0.2%   0.4%  46.4KiB              prek prek::run::{{closure}}
 0.2%   0.3%  41.8KiB              prek prek::cli::run::run::run::{{closure}}
 0.1%   0.3%  32.0KiB             prek? <prek::cli::RunArgs as clap_builder::derive::Args>::augment_args
 0.1%   0.2%  28.0KiB        aws_lc_sys aws_lc_0_38_0_edwards25519_scalarmuldouble_alt
 0.1%   0.2%  27.8KiB      serde_saphyr saphyr_parser_bw::scanner::Scanner<T>::fetch_more_tokens
 0.1%   0.2%  27.5KiB        aws_lc_sys aws_lc_0_38_0_edwards25519_scalarmuldouble
 0.1%   0.2%  25.8KiB              prek prek::cli::try_repo::try_repo::{{closure}}
 0.1%   0.2%  24.9KiB             prek? <prek::config::_::<impl serde_core::de::Deserialize for prek::config::Config>::deserialize::__Visitor as serde_core::de::Visitor>::visit_map
 0.1%   0.2%  23.0KiB              prek prek::hooks::meta_hooks::MetaHooks::run::{{closure}}
 0.1%   0.2%  22.4KiB      serde_saphyr saphyr_parser_bw::scanner::Scanner<T>::fetch_more_tokens
 0.1%   0.2%  22.3KiB         [Unknown] Lp384_montjscalarmul_alt_p384_montjadd
 0.1%   0.2%  22.0KiB               std core::ptr::drop_in_place<prek::languages::<impl prek::config::Language>::install::{{closure}}>
 0.1%   0.2%  21.6KiB              prek prek::workspace::Project::init_hooks::{{closure}}
41.0%  85.8%  10.1MiB                   And 23260 smaller methods. Use -n N to show more.
47.8% 100.0%  11.8MiB                   .text section size, the file size is 24.7MiB

@j178 j178 marked this pull request as draft February 20, 2026 12:57
@j178
Copy link
Owner

j178 commented Feb 20, 2026

Thanks! I definitely want to support jj in prek. But it needs some careful design and thought—not just patching things into the git code path.

Let’s mark this as a draft for now. Once I have a better understanding of jj, I’ll be in a much better position to review and maintain this code properly.

@martijnberger
Copy link
Author

Thanks! I definitely want to support jj in prek. But it needs some careful design and thought—not just patching things into the git code path.

Agreed, ill take a proper pass at this tomorrow and do it by hand in the artisanal way

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)
- 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
- 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
@prek-ci-bot
Copy link

prek-ci-bot bot commented Mar 14, 2026

⚡️ Hyperfine Benchmarks

Summary: 0 regressions, 0 improvements above the 10% threshold.

Environment
  • OS: Linux 6.14.0-1017-azure
  • CPU: 4 cores
  • prek version: prek 0.3.5+22 (bf28e7f 2026-03-14)
  • Rust version: rustc 1.94.0 (4a4ef493e 2026-03-02)
  • Hyperfine version: hyperfine 1.20.0
CLI Commands

Benchmarking basic commands in the main repo:

prek --version

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base --version 2.4 ± 0.2 2.2 3.4 1.02 ± 0.10
prek-head --version 2.4 ± 0.1 2.2 3.4 1.00

prek list

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base list 8.9 ± 0.1 8.7 9.4 1.00
prek-head list 9.0 ± 0.3 8.7 11.9 1.00 ± 0.04

prek validate-config .pre-commit-config.yaml

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base validate-config .pre-commit-config.yaml 3.1 ± 0.3 3.0 5.3 1.03 ± 0.11
prek-head validate-config .pre-commit-config.yaml 3.0 ± 0.1 3.0 3.2 1.00

prek sample-config

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base sample-config 2.6 ± 0.0 2.5 2.7 1.00
prek-head sample-config 2.6 ± 0.1 2.6 2.9 1.01 ± 0.02
Cold vs Warm Runs

Comparing first run (cold) vs subsequent runs (warm cache):

prek run --all-files (cold - no cache)

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run --all-files 156.4 ± 6.8 150.5 174.4 1.04 ± 0.05
prek-head run --all-files 151.1 ± 2.0 149.1 155.9 1.00

prek run --all-files (warm - with cache)

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run --all-files 152.1 ± 2.5 148.3 156.6 1.00
prek-head run --all-files 152.3 ± 2.2 149.5 158.1 1.00 ± 0.02
Full Hook Suite

Running the builtin hook suite on the benchmark workspace:

prek run --all-files (full builtin hook suite)

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run --all-files 151.8 ± 2.2 148.7 156.6 1.00
prek-head run --all-files 153.8 ± 12.0 149.1 236.0 1.01 ± 0.08
Individual Hook Performance

Benchmarking each hook individually on the test repo:

prek run trailing-whitespace --all-files

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run trailing-whitespace --all-files 22.4 ± 0.4 21.6 23.3 1.00 ± 0.03
prek-head run trailing-whitespace --all-files 22.3 ± 0.4 21.7 23.3 1.00

prek run end-of-file-fixer --all-files

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run end-of-file-fixer --all-files 28.6 ± 1.9 26.6 33.1 1.00
prek-head run end-of-file-fixer --all-files 28.8 ± 2.3 26.0 34.4 1.01 ± 0.11

prek run check-json --all-files

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run check-json --all-files 13.0 ± 0.3 12.4 14.1 1.06 ± 0.04
prek-head run check-json --all-files 12.3 ± 0.3 11.9 13.1 1.00

prek run check-yaml --all-files

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run check-yaml --all-files 12.0 ± 0.2 11.8 12.5 1.00
prek-head run check-yaml --all-files 12.1 ± 0.2 11.8 12.6 1.00 ± 0.02

prek run check-toml --all-files

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run check-toml --all-files 12.2 ± 0.3 11.7 12.9 1.00 ± 0.03
prek-head run check-toml --all-files 12.2 ± 0.3 11.8 12.8 1.00

prek run check-xml --all-files

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run check-xml --all-files 12.1 ± 0.2 11.8 12.6 1.00
prek-head run check-xml --all-files 12.2 ± 0.3 11.8 13.0 1.01 ± 0.03
Installation Performance

Benchmarking hook installation (fast path hooks skip Python setup):

prek install-hooks (cold - no cache)

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base install-hooks 4.9 ± 0.1 4.9 5.0 1.00
prek-head install-hooks 4.9 ± 0.1 4.9 5.1 1.00 ± 0.02

prek install-hooks (warm - with cache)

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base install-hooks 4.8 ± 0.1 4.8 4.9 1.00
prek-head install-hooks 4.9 ± 0.1 4.7 5.0 1.01 ± 0.03
File Filtering/Scoping Performance

Testing different file selection modes:

prek run (staged files only)

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run 18.6 ± 0.1 18.4 18.9 1.00
prek-head run 18.7 ± 0.1 18.5 19.0 1.00 ± 0.01

prek run --files '*.json' (specific file type)

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run --files '*.json' 7.5 ± 0.1 7.4 8.0 1.01 ± 0.02
prek-head run --files '*.json' 7.5 ± 0.1 7.3 7.6 1.00
Workspace Discovery & Initialization

Benchmarking hook discovery and initialization overhead:

prek run --dry-run --all-files (measures init overhead)

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run --dry-run --all-files 12.6 ± 0.5 12.2 14.3 1.00 ± 0.04
prek-head run --dry-run --all-files 12.6 ± 0.2 12.3 13.2 1.00
Meta Hooks Performance

Benchmarking meta hooks separately:

prek run check-hooks-apply --all-files

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run check-hooks-apply --all-files 14.3 ± 0.1 14.1 14.5 1.09 ± 0.06
prek-head run check-hooks-apply --all-files 13.1 ± 0.7 12.5 14.4 1.00

prek run check-useless-excludes --all-files

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run check-useless-excludes --all-files 12.7 ± 0.1 12.5 12.8 1.00 ± 0.01
prek-head run check-useless-excludes --all-files 12.7 ± 0.1 12.5 12.9 1.00

prek run identity --all-files

Command Mean [ms] Min [ms] Max [ms] Relative
prek-base run identity --all-files 11.1 ± 0.1 10.9 11.3 1.00
prek-head run identity --all-files 11.2 ± 0.1 11.0 11.4 1.01 ± 0.01

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 6 comments.

Comment on lines +76 to +82
let git_dir = git_dir.canonicalize()?;

if git_dir.exists() {
Ok(Some(git_dir))
} else {
Ok(None)
}
Comment on lines 79 to 83
pub(crate) fn git_cmd(summary: &str) -> Result<Cmd, Error> {
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);

Comment on lines 974 to +979
/// 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(());
}

Comment on lines +41 to 48
mod jj;
mod languages;
mod printer;
mod process;
#[cfg(all(unix, feature = "profiler"))]
mod profiler;
mod repo;
#[cfg(unix)]
Comment on lines +85 to +88
fn run_in_non_colocated_jj_workspace() -> Result<()> {
let Some(mut init) = jj_cmd(".") else {
return Ok(());
};
Comment on lines +2109 to +2113
#[test]
fn check_case_conflict_in_non_colocated_jujutsu_workspace() -> Result<()> {
let Some(mut init) = jj_cmd(".") else {
return Ok(());
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants