Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
colocation state (`status`) and to convert a non-colocated git repo into
a colocated repo (`enable`) and vice-versa `disable`.

* Added pre-upload hooks to jj. For now, this is limited in scope to a hook
named fix, and only works for `jj gerrit upload`.
Toggled via the config flag `hooks.pre-upload.fix.enabled`.

### Fixed bugs

* `jj metaedit --author-timestamp` twice with the same value no longer
Expand Down
35 changes: 28 additions & 7 deletions cli/src/commands/fix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use jj_lib::fileset::FilesetDiagnostics;
use jj_lib::fileset::FilesetExpression;
use jj_lib::fix::FileToFix;
use jj_lib::fix::FixError;
use jj_lib::fix::FixSummary;
use jj_lib::fix::ParallelFileFixer;
use jj_lib::fix::fix_files;
use jj_lib::matchers::Matcher;
Expand All @@ -38,6 +39,7 @@ use tracing::instrument;

use crate::cli_util::CommandHelper;
use crate::cli_util::RevisionArg;
use crate::cli_util::WorkspaceCommandTransaction;
use crate::command_error::CommandError;
use crate::command_error::config_error;
use crate::command_error::print_parse_diagnostics;
Expand Down Expand Up @@ -131,9 +133,6 @@ pub(crate) fn cmd_fix(
args: &FixArgs,
) -> Result<(), CommandError> {
let mut workspace_command = command.workspace_helper(ui)?;
let workspace_root = workspace_command.workspace_root().to_owned();
let path_converter = workspace_command.path_converter().to_owned();
let tools_config = get_tools_config(ui, workspace_command.settings())?;
let target_expr = if args.source.is_empty() {
let revs = workspace_command.settings().get_string("revsets.fix")?;
workspace_command.parse_revset(ui, &RevisionArg::from(revs))?
Expand All @@ -151,6 +150,28 @@ pub(crate) fn cmd_fix(
.to_matcher();

let mut tx = workspace_command.start_transaction();
let summary = fix_revisions(
ui,
&mut tx,
&root_commits,
&matcher,
args.include_unchanged_files,
)?;
tx.finish(ui, format!("fixed {} commits", summary.num_fixed_commits))?;
Ok(())
}

pub(crate) fn fix_revisions(
ui: &mut Ui,
tx: &mut WorkspaceCommandTransaction,
root_commits: &[CommitId],
matcher: &dyn Matcher,
include_unchanged_files: bool,
) -> Result<FixSummary, CommandError> {
let workspace_command = tx.base_workspace_helper();
let workspace_root = workspace_command.workspace_root().to_owned();
let path_converter = workspace_command.path_converter().to_owned();
let tools_config = get_tools_config(ui, workspace_command.settings())?;
let mut parallel_fixer = ParallelFileFixer::new(|store, file_to_fix| {
fix_one_file(
ui,
Expand All @@ -163,9 +184,9 @@ pub(crate) fn cmd_fix(
.block_on()
});
let summary = fix_files(
root_commits,
&matcher,
args.include_unchanged_files,
root_commits.to_vec(),
matcher,
include_unchanged_files,
tx.repo_mut(),
&mut parallel_fixer,
)
Expand All @@ -176,7 +197,7 @@ pub(crate) fn cmd_fix(
summary.num_fixed_commits,
summary.num_checked_commits
)?;
tx.finish(ui, format!("fixed {} commits", summary.num_fixed_commits))
Ok(summary)
}

/// Invokes all matching tools (if any) to file_to_fix. If the content is
Expand Down
20 changes: 18 additions & 2 deletions cli/src/commands/gerrit/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ use crate::command_error::user_error;
use crate::command_error::user_error_with_hint;
use crate::command_error::user_error_with_message;
use crate::git_util::with_remote_git_callbacks;
use crate::hooks::apply_rewrites;
use crate::hooks::run_pre_upload_hooks;
use crate::ui::Ui;

/// Upload changes to Gerrit for code review, or update existing changes.
Expand Down Expand Up @@ -171,7 +173,7 @@ pub fn cmd_gerrit_upload(
.parse_union_revsets(ui, &args.revisions)?
.resolve()?;
workspace_command.check_rewritable_expr(&target_expr)?;
let revisions: Vec<_> = target_expr
let mut revisions: Vec<_> = target_expr
.evaluate(workspace_command.repo().as_ref())?
.iter()
.try_collect()?;
Expand All @@ -185,7 +187,7 @@ pub fn cmd_gerrit_upload(
// has a Change-ID.
// We make an assumption here that all immutable commits already have a
// Change-ID.
let to_upload: Vec<Commit> = workspace_command
let mut to_upload: Vec<_> = workspace_command
.attach_revset_evaluator(
workspace_command
.env()
Expand All @@ -195,6 +197,20 @@ pub fn cmd_gerrit_upload(
.evaluate_to_commits()?
.try_collect()?;

{
let ids: Vec<_> = to_upload.iter().map(|c| c.id().clone()).collect();
let rewrites = run_pre_upload_hooks(ui, command, &mut workspace_command, &ids)?;
// Apply rewrites to all variables containing commit IDs.
if !rewrites.is_empty() {
revisions = apply_rewrites(&rewrites, revisions);
let store = workspace_command.repo().store();
to_upload = apply_rewrites(&rewrites, ids)
.into_iter()
.map(|id| store.get_commit(&id))
.try_collect()?;
}
}

// Note: This transaction is intentionally never finished. This way, the
// Change-Id is never part of the commit description in jj.
// This avoids scenarios where you have many commits with the same
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ mod duplicate;
mod edit;
mod evolog;
mod file;
mod fix;
pub(crate) mod fix;
#[cfg(feature = "git")]
mod gerrit;
#[cfg(feature = "git")]
Expand Down
26 changes: 26 additions & 0 deletions cli/src/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,32 @@
}
}
},
"hooks": {
"type": "object",
"description": "Settings for jj hooks",
"properties": {
"pre-upload": {
"type": "object",
"additionalProperties": {
"type": "object",
"description": "Each pre-upload tool will run once, and be passed all commits to be uploaded in reverse jj log order (topological)",
"properties": {
"enabled": {
"type": "boolean",
"description": "Disables this tool if set to false",
"default": true
},
"order": {
"type": "integer",
"description": "Hooks are run in ascending order on (order, name)",
"default": 0
}
}
},
"description": "Settings for pre-upload hooks"
}
}
},
"--when": {
"type": "object",
"description": "Conditions restriction the application of the configuration",
Expand Down
139 changes: 139 additions & 0 deletions cli/src/hooks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright 2025 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use std::collections::HashMap;
use std::collections::HashSet;

use itertools::Itertools as _;
use jj_lib::backend::CommitId;
use jj_lib::fileset::FilesetExpression;
use jj_lib::settings::UserSettings;

use crate::cli_util::CommandHelper;
use crate::cli_util::WorkspaceCommandHelper;
use crate::command_error::CommandError;
use crate::command_error::user_error;
use crate::commands::fix::fix_revisions;
use crate::ui::Ui;

/// A hook in the `hooks.pre-upload` config table.
enum PreUploadToolConfig {
FixTool,
}

/// Parses the `hooks.pre-upload` config table.
fn pre_upload_tools(settings: &UserSettings) -> Result<Vec<PreUploadToolConfig>, CommandError> {
// Simplifies deserialization of the config values while building a ToolConfig.
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
struct RawPreUploadToolConfig {
enabled: bool,
}

let mut tools: Vec<_> = vec![];
for name in settings
.table_keys("hooks.pre-upload")
// Sort keys so errors are deterministic.
.sorted()
{
let tool: RawPreUploadToolConfig = settings.get(["hooks", "pre-upload", name])?;
if tool.enabled {
tools.push(if name == "fix" {
PreUploadToolConfig::FixTool
} else {
return Err(user_error(
"Generic pre-upload hooks are currently unsupported. Only fix is supported \
for now",
));
});
}
}
Ok(tools)
}

/// Triggered every time a user runs something that semantically approximates
/// an "upload".
///
/// Currently, this triggers on `jj gerrit upload`. Other forges which
/// implement custom upload scripts should also call this.
///
/// This should ideally work for `jj git push` too, but doing so has
/// consequences. `git push` can be used to upload to code review, but it can
/// do many other things as well. We need to ensure the UX works well before
/// adding it to `git push`.
Comment on lines +68 to +74
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we should advertise this as "general hook" support. Since it is the only support we'll ever have

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I intend to add support for general hooks in the future. This is why I'm working on repo-managed config. Having that will allow us to do this in a secure manner.

Copy link
Contributor

Choose a reason for hiding this comment

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

I intend to add support for general hooks in the future.

Then can you please outline your plans somewhere, preferably on the issue since it has some major implications and may be a step away from what the project communicated thus far.

///
/// This function may create transactions that rewrite commits, so is not
/// allowed to be called while a transaction is ongoing.
/// It returns a mapping of rewrites, and users are expected to update any
/// references to point at the new revision.
pub(crate) fn run_pre_upload_hooks(
ui: &mut Ui,
command: &CommandHelper,
workspace_command: &mut WorkspaceCommandHelper,
commit_ids: &[CommitId],
) -> Result<HashMap<CommitId, Vec<CommitId>>, CommandError> {
// Rewrites are a many-to-many relationship.
let mut rewrites = HashMap::<CommitId, Vec<CommitId>>::new();
let mut current_commits = commit_ids.to_vec();
for tool in pre_upload_tools(command.settings())? {
let next_rewrites: HashMap<CommitId, Vec<CommitId>> = match tool {
PreUploadToolConfig::FixTool => {
let mut tx = workspace_command.start_transaction();
let summary = fix_revisions(
ui,
&mut tx,
&current_commits,
&FilesetExpression::all().to_matcher(),
false,
)?;
tx.finish(ui, format!("fixed {} commits", summary.num_fixed_commits))?;
summary
.rewrites
.into_iter()
.map(|(k, v)| (k, vec![v]))
.collect()
}
};

current_commits = apply_rewrites(&next_rewrites, current_commits);

// Apply transitive rewrites.
for v in rewrites.values_mut() {
*v = apply_rewrites(&next_rewrites, v.clone());
}

for (from, to) in next_rewrites {
rewrites.insert(from, to);
}
}
Ok(rewrites)
}

pub(crate) fn apply_rewrites(
rewrites: &HashMap<CommitId, Vec<CommitId>>,
commits: Vec<CommitId>,
) -> Vec<CommitId> {
let rewritten: Vec<_> = commits
.into_iter()
.flat_map(|c| rewrites.get(&c).cloned().unwrap_or(vec![c]))
.collect();
let mut filtered = vec![];
let mut seen = HashSet::new();
for commit in &rewritten {
if seen.insert(commit) {
filtered.push(commit.clone());
}
}
filtered
}
1 change: 1 addition & 0 deletions cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub mod git_util {
}
}
pub mod graphlog;
pub mod hooks;
pub mod merge_tools;
pub mod movement_util;
pub mod operation_templater;
Expand Down
Loading