638 lines · 22.3 KB · Rust
RawDownload
// Copyright 2024 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::io::Write as _;use std::path::Path;use std::process::Stdio; use clap_complete::ArgValueCompleter;use futures::AsyncReadExt as _;use futures::TryStreamExt as _;use itertools::Itertools as _;use jj_lib::backend::FileId;use jj_lib::commit::Commit;use jj_lib::fileset;use jj_lib::fileset::FilesetDiagnostics;use jj_lib::fileset::FilesetExpression;use jj_lib::fileset::FilesetParseContext;use jj_lib::fix::FileToFix;use jj_lib::fix::FixError;use jj_lib::fix::LineRange;use jj_lib::fix::ParallelFileFixer;use jj_lib::fix::RegionsToFormat;use jj_lib::fix::compute_changed_ranges;use jj_lib::fix::compute_file_line_count;use jj_lib::fix::fix_files;use jj_lib::matchers::Matcher;use jj_lib::repo::Repo as _;use jj_lib::repo_path::RepoPathUiConverter;use jj_lib::revset::RevsetStreamExt as _;use jj_lib::settings::UserSettings;use jj_lib::store::Store;use pollster::FutureExt as _;use tracing::instrument; use crate::cli_util::CommandHelper;use crate::cli_util::RevisionArg;use crate::cli_util::print_unmatched_explicit_paths;use crate::command_error::CommandError;use crate::command_error::config_error;use crate::command_error::print_parse_diagnostics;use crate::complete;use crate::config::CommandNameAndArgs;use crate::ui::Ui; /// Update files with formatting fixes or other changes////// The primary use case for this command is to apply the results of automatic/// code formatting tools to revisions that may not be properly formatted yet./// It can also be used to modify files with other tools like `sed` or `sort`.////// The modification made by `jj fix` can be reviewed by `jj op show -p`.////// ### How it works////// The changed files in the given revisions will be updated with any fixes/// determined by passing their file content through any external tools the user/// has configured for those files. Descendants will also be updated by passing/// their versions of the same files through the same tools, which will ensure/// that the fixes are not lost. This will never result in new conflicts. Files/// with existing conflicts will be updated on all sides of the conflict, which/// can potentially increase or decrease the number of conflict markers.////// ### Deduplication////// When fixing multiple commits, if the same file content appears at the same/// path in different commits, the tool is run only once and the result is/// reused. This means that tools used with `jj fix` must produce deterministic/// output.////// ### Configuration////// See `jj help -k config` chapter `Code formatting and other file content/// transformations` to understand how to configure your tools.////// ### Execution Example////// Let's consider the following configuration is set. We have two code/// formatters (`clang-format` and `black`), which apply to three different/// file extensions (`.cc`, `.h`, and `.py`):////// ```toml/// [fix.tools.clang-format]/// command = ["/usr/bin/clang-format", "--assume-filename=$path"]/// patterns = ["glob:'**/*.cc'",///             "glob:'**/*.h'"]////// [fix.tools.black]/// command = ["/usr/bin/black", "-", "--stdin-filename=$path"]/// patterns = ["glob:'**/*.py'"]/// ```////// Now, let's see what would happen to the following history, when executing/// `jj fix`.////// ```text/// C (mutable)/// |  Modifies file: foo.py/// |/// B @ (working copy - mutable)/// |  Modifies file: README.md/// |/// A (mutable)/// |  Modifies files: src/bar.cc and src/bar.h/// |/// X (immutable)/// ```////// By default, `jj fix` will modify revisions that matches the revset/// `reachable(@, mutable())` (see `jj help -k revsets`) which corresponds to/// the revisions `A`, `B` and `C` here.////// The following operations will then happen:////// - For revision `A`, content from this revision for files `src/bar.cc` and///   `src/bar.h` will each be provided to `clang-format` and the result output///   will be used to recreate revision `A` which we will call `A'`. All other///   files are untouched./// - For revision `B`, same thing happen for files `src/bar.cc` and `src/bar.h`///   Their content from revision `B` will go through `clang-format`. The file///   `README.md` as any other files, are untouched as no pattern matches it. We///   obtain revision `B'`./// - For revision `C`, `src/bar.cc` and `src/bar.h` goes through `clang-format`///   and file `foo.py` is fixed using `black`. Any other file is untouched. We///   obtain revision `C'`.////// ```text/// C (mutable)                    /-> C'/// |  src/bar.cc -> clang-format -|   |/// |  src/bar.h --> clang-format -|   |/// |  foo.py -----> black --------|   |/// |  * --------------------------/   |/// |                                  |/// B @ (working copy - mutable)   /-> B' @/// |  src/bar.cc -> clang-format -|   |/// |  src/bar.h --> clang-format -|   |/// |  * --------------------------|   |/// |                                  |/// A (mutable)                    /-> A'/// |  src/bar.cc -> clang-format -|   |/// |  src/bar.h --> clang-format -|   |/// |  * --------------------------/   |/// |                                  |/// X (immutable)                      X/// ```////// The revisions are now all correctly formatted according to the/// configuration.#[derive(clap::Args, Clone, Debug)]#[command(verbatim_doc_comment)]pub(crate) struct FixArgs {    /// Fix files in the specified revision(s) and their descendants. If no    /// revisions are specified, this defaults to the `revsets.fix` setting, or    /// `reachable(@, mutable())` if it is not set.    #[arg(long, short, value_name = "REVSETS")]    #[arg(add = ArgValueCompleter::new(complete::revset_expression_mutable))]    source: Vec<RevisionArg>,     /// Fix only these paths    #[arg(value_name = "FILESETS", value_hint = clap::ValueHint::AnyPath)]    paths: Vec<String>,     /// Fix unchanged files in addition to changed ones. If no paths are    /// specified, all files in the repo will be fixed.    #[arg(long)]    include_unchanged_files: bool,     /// Format all lines instead of only modified lines.    ///    /// If the formatter doesn't support formatting only modified lines, then    /// this option has no effect since the formatter always formats all lines.    #[arg(long, short)]    all_lines: bool,} #[instrument(skip_all)]pub(crate) async fn cmd_fix(    ui: &mut Ui,    command: &CommandHelper,    args: &FixArgs,) -> Result<(), CommandError> {    let mut workspace_command = command.workspace_helper(ui).await?;    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(),        &workspace_command.env().fileset_parse_context_for_config(),    )?;    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))?    } else {        workspace_command.parse_union_revsets(ui, &args.source)?    }    .resolve()?;    workspace_command        .check_rewritable_expr(&target_expr)        .await?;     let repo = workspace_command.repo();     let commits: Vec<Commit> = target_expr        .descendants()        .evaluate(repo.as_ref())?        .stream()        .commits(repo.store())        .try_collect()        .await?;     let commit_ids = commits        .iter()        .map(|commit| commit.id().clone())        .collect_vec();     let trees: Vec<_> = commits.iter().map(|commit| commit.tree()).collect();     let fileset_expression = workspace_command.parse_file_patterns(ui, &args.paths)?;    let matcher = fileset_expression.to_matcher();     let mut tx = workspace_command.start_transaction();    let mut parallel_fixer = ParallelFileFixer::new(|store, file_to_fix| {        fix_one_file(            ui,            &workspace_root,            &path_converter,            &tools_config,            store,            file_to_fix,            args.all_lines,        )        .block_on()    });     print_unmatched_explicit_paths(ui, tx.base_workspace_helper(), &fileset_expression, &trees)?;     let summary = fix_files(        commit_ids,        &matcher,        args.include_unchanged_files,        tx.repo_mut(),        &mut parallel_fixer,    )    .await?;    writeln!(        ui.status(),        "Fixed {} commits of {} checked.",        summary.num_fixed_commits,        summary.num_checked_commits    )?;    tx.finish(ui, format!("fixed {} commits", summary.num_fixed_commits))        .await} /// Invokes all matching tools (if any) to file_to_fix. If the content is/// successfully transformed the new content is written and the new FileId is/// returned. Returns None if the content is unchanged.////// The matching tools are invoked in order, with the result of one tool feeding/// into the next tool. Returns FixError if there is an error reading or writing/// the file. However, if a tool invocation fails for whatever reason, the tool/// is simply skipped and we proceed to invoke the next tool (this is/// indistinguishable from succeeding with no changes).////// TODO: Better error handling so we can tell the user what went wrong with/// each failed input.async fn fix_one_file(    ui: &Ui,    workspace_root: &Path,    path_converter: &RepoPathUiConverter,    tools_config: &ToolsConfig,    store: &Store,    file_to_fix: &FileToFix,    all_lines_arg: bool,) -> Result<Option<FileId>, FixError> {    let mut matching_tools = tools_config        .tools        .iter()        .filter(|tool_config| tool_config.matcher.matches(&file_to_fix.repo_path))        .peekable();     if matching_tools.peek().is_none() {        return Ok(None);    }     // The first matching tool gets its input from the committed file, and any    // subsequent matching tool gets its input from the previous matching tool's    // output.     // TODO: Consider adding a check for some max file size config or some global    // limit for both `old_content` and `base_content`.    let mut old_content = vec![];    let mut read = store        .read_file(&file_to_fix.repo_path, &file_to_fix.file_id)        .await?;    read.read_to_end(&mut old_content).await?;     // Do not run any tools on empty files.    if old_content.is_empty() {        return Ok(None);    }     // Load the base content from the file_to_fix (if exists) iff any tool needs it.    let base_content = if all_lines_arg {        None    } else {        match &file_to_fix.base_file_id {            Some(base_file_id) if matching_tools.clone().any(|t| t.line_range_arg.is_some()) => {                let mut content = vec![];                let mut read = store                    .read_file(&file_to_fix.repo_path, base_file_id)                    .await?;                read.read_to_end(&mut content).await?;                Some(content)            }            _ => None,        }    };     let new_content = matching_tools.fold(old_content.clone(), |prev_content, tool_config| {        let mut extra_args = Vec::new();         if let Some(line_range_arg) = &tool_config.line_range_arg {            let RegionsToFormat::LineRanges(ranges) =                compute_regions_to_format(base_content.as_deref(), &prev_content);            if ranges.is_empty() && !tool_config.run_tool_if_zero_line_ranges {                // Don't run the tool if there are no line ranges to format and the tool is                // configured to not run in that case.                return prev_content;            }             for range in ranges {                extra_args.push(                    line_range_arg                        .replace("$first", &range.first.to_string())                        .replace("$last", &range.last.to_string()),                );            }        }         match run_tool(            ui,            workspace_root,            path_converter,            &tool_config.command,            file_to_fix,            &prev_content,            &extra_args,        ) {            Ok(next_content) => next_content,            // TODO: Because the stderr is passed through, this isn't always failing            // silently, but it should do something better will the exit code, tool            // name, etc.            Err(()) => prev_content,        }    });     if new_content != old_content {        // TODO: send futures back over channel        let new_file_id = store            .write_file(&file_to_fix.repo_path, &mut new_content.as_slice())            .await?;        return Ok(Some(new_file_id));    }     Ok(None)} /// Computes the modified line ranges between the base and current file.pub fn compute_regions_to_format(    base_content: Option<&[u8]>,    current_content: &[u8],) -> RegionsToFormat {    if current_content.is_empty() {        // If the current file content is empty, then there are no regions to format.        RegionsToFormat::LineRanges(vec![])    } else if let Some(base) = base_content {        // If the base file content is available, then compute the modified line ranges        // to format between the base and current file.        compute_changed_ranges(base, current_content)    } else {        // Otherwise, format the entire file.        let line_count = compute_file_line_count(current_content);        RegionsToFormat::LineRanges(vec![LineRange {            first: 1,            last: line_count,        }])    }} /// Runs the `tool_command` to fix the given file content.////// The `old_content` is assumed to be that of the `file_to_fix`'s `FileId`, but/// this is not verified.////// Returns the new file content, whose value will be the same as `old_content`/// unless the command introduced changes. Returns `None` if there were any/// failures when starting, stopping, or communicating with the subprocess.fn run_tool(    ui: &Ui,    workspace_root: &Path,    path_converter: &RepoPathUiConverter,    tool_command: &CommandNameAndArgs,    file_to_fix: &FileToFix,    old_content: &[u8],    extra_args: &[String],) -> Result<Vec<u8>, ()> {    let mut vars: HashMap<&str, &str> = HashMap::new();    vars.insert("path", file_to_fix.repo_path.as_internal_file_string());    // TODO: workspace_root.to_str() returns None if the workspace path is not    // UTF-8, but we ignore that failure so `jj fix` still runs in that    // situation. Maybe we should do something like substituting bytes instead    // of strings so we can handle any Path here.    if let Some(root) = workspace_root.to_str() {        vars.insert("root", root);    }    let mut command = tool_command.to_command_with_variables(&vars);    if !extra_args.is_empty() {        command.args(extra_args);    }     tracing::debug!(?command, ?file_to_fix.repo_path, "spawning fix tool");    let Ok(mut child) = command        .current_dir(workspace_root)        .stdin(Stdio::piped())        .stdout(Stdio::piped())        .stderr(Stdio::piped())        .spawn()    else {        writeln!(            ui.warning_default(),            "Failed to start `{}`.",            tool_command.split_name(),        )        .ok();        return Err(());    };    let mut stdin = child.stdin.take().expect(        "The child process is created with piped stdin, and it's our first access to stdin.",    );    let output = std::thread::scope(|s| {        s.spawn(move || {            stdin.write_all(old_content).ok();        });        child.wait_with_output().or(Err(()))    })?;    tracing::debug!(?command, ?output.status, "fix tool exited:");    if !output.stderr.is_empty() {        let mut stderr = ui.stderr();        writeln!(            stderr,            "{}:",            path_converter.format_file_path(&file_to_fix.repo_path)        )        .ok();        stderr.write_all(&output.stderr).ok();        writeln!(stderr).ok();    }    if output.status.success() {        Ok(output.stdout)    } else {        writeln!(            ui.warning_default(),            "Fix tool `{}` exited with non-zero exit code for `{}`",            tool_command.split_name(),            path_converter.format_file_path(&file_to_fix.repo_path)        )        .ok();        Err(())    }} /// Represents an entry in the `fix.tools` config table.struct ToolConfig {    /// The command that will be run to fix a matching file.    command: CommandNameAndArgs,    /// The matcher that determines if this tool matches a file.    matcher: Box<dyn Matcher>,    /// Whether the tool is enabled    enabled: bool,    /// Arguments to pass changed line ranges to the tool.    /// The string is a template where `$first` and `$last` are replaced by the    /// 1-based first and last inclusive line numbers of the changed range.    /// For example, `"--lines=$first-$last"`.    line_range_arg: Option<String>,    /// Whether to run the tool invocation for this tool on files that had    /// zero line ranges to format in the revision being fixed.    ///    /// Defaults to `false`, meaning the tool is skipped for these files.

Lines 1–500 of 638.