diff --git a/CHANGELOG.md b/CHANGELOG.md index 86caabe84..8f50e3797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 referenced in revsets. Such branches exist in colocated repos or if you use `jj git export`. +* The new `jj chmod` command allows setting or removing the executable bit on + paths. Unlike the POSIX `chmod`, it works on Windows, on conflicted files, and + on arbitrary revisions. Bits other than the executable bit are not planned to + be supported. + ### Fixed bugs * Modify/delete conflicts now include context lines diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 338f17a37..e288dd2f1 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -33,6 +33,7 @@ use indexmap::{IndexMap, IndexSet}; use itertools::Itertools; use jujutsu_lib::backend::{CommitId, ObjectId, TreeValue}; use jujutsu_lib::commit::Commit; +use jujutsu_lib::conflicts::Conflict; use jujutsu_lib::dag_walk::topo_order_reverse; use jujutsu_lib::git_backend::GitBackend; use jujutsu_lib::matchers::EverythingMatcher; @@ -76,6 +77,7 @@ enum Commands { #[command(alias = "print")] Cat(CatArgs), Checkout(CheckoutArgs), + Chmod(ChmodArgs), Commit(CommitArgs), #[command(subcommand)] Config(ConfigSubcommand), @@ -610,6 +612,32 @@ struct UnsquashArgs { interactive: bool, } +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)] +enum ChmodMode { + /// Make a path non-executable (alias: normal) + // We use short names for enum values so that errors say that the possible values are `n, x`. + #[value(name = "n", alias("normal"))] + Normal, + /// Make a path executable (alias: executable) + #[value(name = "x", alias("executable"))] + Executable, +} + +/// Sets or removes the executable bit for paths in the repo +/// +/// Unlike the POSIX `chmod`, `jj chmod` also works on Windows, on conflicted +/// files, and on arbitrary revisions. +#[derive(clap::Args, Clone, Debug)] +struct ChmodArgs { + mode: ChmodMode, + /// The revision to update + #[arg(long, short, default_value = "@")] + revision: RevisionArg, + /// Paths to change the executable bit for + #[arg(required = true, value_hint = clap::ValueHint::AnyPath)] + paths: Vec, +} + /// Resolve a conflicted file with an external merge tool /// /// Only conflicts that can be resolved with a 3-way merge are supported. See @@ -2469,6 +2497,95 @@ aborted. Ok(()) } +fn cmd_chmod(ui: &mut Ui, command: &CommandHelper, args: &ChmodArgs) -> Result<(), CommandError> { + let executable_bit = match args.mode { + ChmodMode::Executable => true, + ChmodMode::Normal => false, + }; + + let mut workspace_command = command.workspace_helper(ui)?; + let repo_paths: Vec<_> = args + .paths + .iter() + .map(|path| workspace_command.parse_file_path(path)) + .try_collect()?; + let commit = workspace_command.resolve_single_rev(&args.revision)?; + workspace_command.check_rewritable(&commit)?; + + let mut tx = workspace_command.start_transaction(&format!( + "make paths {} in commit {}", + if executable_bit { + "executable" + } else { + "non-executable" + }, + commit.id().hex(), + )); + let tree = commit.tree(); + let store = tree.store(); + let mut tree_builder = store.tree_builder(tree.id().clone()); + for repo_path in repo_paths { + let user_error_with_path = |msg: &str| { + user_error(format!( + "{msg} at '{}'.", + tx.base_workspace_helper().format_file_path(&repo_path) + )) + }; + let new_tree_value = match tree.path_value(&repo_path) { + None => return Err(user_error_with_path("No such path")), + Some(TreeValue::File { id, executable: _ }) => TreeValue::File { + id, + executable: executable_bit, + }, + Some(TreeValue::Conflict(id)) => { + let conflict = tree.store().read_conflict(&repo_path, &id)?; + let (new_removes, _) = chmod_conflict_sides(conflict.removes(), executable_bit); + let (new_adds, all_files) = chmod_conflict_sides(conflict.adds(), executable_bit); + if !all_files { + return Err(user_error_with_path( + "None of the sides of the conflict are files", + )); + } + let new_conflict_id = + store.write_conflict(&repo_path, &Conflict::new(new_removes, new_adds))?; + TreeValue::Conflict(new_conflict_id) + } + Some(_) => return Err(user_error_with_path("Found neither a file nor a conflict")), + }; + tree_builder.set(repo_path, new_tree_value); + } + + tx.mut_repo() + .rewrite_commit(command.settings(), &commit) + .set_tree(tree_builder.write_tree()) + .write()?; + tx.finish(ui) +} + +fn chmod_conflict_sides( + sides: &[Option], + executable_bit: bool, +) -> (Vec>, bool) { + let mut all_files = true; + let result = sides + .iter() + .map(|side| { + side.as_ref().map(|value| match value { + TreeValue::File { id, executable: _ } => TreeValue::File { + id: id.clone(), + executable: executable_bit, + }, + TreeValue::Conflict(_) => panic!("Conflict sides must not themselves be conflicts"), + value => { + all_files = false; + value.clone() + } + }) + }) + .collect(); + (result, all_files) +} + fn cmd_resolve( ui: &mut Ui, command: &CommandHelper, @@ -3458,6 +3575,7 @@ pub fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result<(), Co Commands::Operation(sub_args) => operation::cmd_operation(ui, command_helper, sub_args), Commands::Workspace(sub_args) => cmd_workspace(ui, command_helper, sub_args), Commands::Sparse(sub_args) => cmd_sparse(ui, command_helper, sub_args), + Commands::Chmod(sub_args) => cmd_chmod(ui, command_helper, sub_args), Commands::Git(sub_args) => git::cmd_git(ui, command_helper, sub_args), Commands::Util(sub_args) => cmd_util(ui, command_helper, sub_args), #[cfg(feature = "bench")] diff --git a/tests/test_chmod_command.rs b/tests/test_chmod_command.rs new file mode 100644 index 000000000..8fc5be042 --- /dev/null +++ b/tests/test_chmod_command.rs @@ -0,0 +1,202 @@ +// Copyright 2023 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::path::Path; + +use crate::common::TestEnvironment; + +pub mod common; + +fn create_commit( + test_env: &TestEnvironment, + repo_path: &Path, + name: &str, + parents: &[&str], + files: &[(&str, &str)], +) { + if parents.is_empty() { + test_env.jj_cmd_success(repo_path, &["new", "root", "-m", name]); + } else { + let mut args = vec!["new", "-m", name]; + args.extend(parents); + test_env.jj_cmd_success(repo_path, &args); + } + for (name, content) in files { + std::fs::write(repo_path.join(name), content).unwrap(); + } + test_env.jj_cmd_success(repo_path, &["branch", "create", name]); +} + +fn get_log_output(test_env: &TestEnvironment, repo_path: &Path) -> String { + test_env.jj_cmd_success(repo_path, &["log", "-T", "branches"]) +} + +#[test] +fn test_chmod_regular_conflict() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + + create_commit(&test_env, &repo_path, "base", &[], &[("file", "base\n")]); + create_commit(&test_env, &repo_path, "n", &["base"], &[("file", "n\n")]); + create_commit(&test_env, &repo_path, "x", &["base"], &[("file", "x\n")]); + // Test chmodding a file. The effect will be visible in the conflict below. + test_env.jj_cmd_success(&repo_path, &["chmod", "x", "file", "-r=x"]); + create_commit(&test_env, &repo_path, "conflict", &["x", "n"], &[]); + + // Test the setup + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ conflict + ├─╮ + ◉ │ x + │ ◉ n + ├─╯ + ◉ base + ◉ + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["cat", "file"]); + insta::assert_snapshot!(stdout, + @r###" + Conflict: + Removing file with id df967b96a579e45a18b8251732d16804b2e56a55 + Adding executable file with id 587be6b4c3f93f93c489c0111bba5596147a26cb + Adding file with id 8ba3a16384aacc37d01564b28401755ce8053f51 + "###); + + // Test chmodding a conflict + test_env.jj_cmd_success(&repo_path, &["chmod", "x", "file"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["cat", "file"]); + insta::assert_snapshot!(stdout, + @r###" + Conflict: + Removing executable file with id df967b96a579e45a18b8251732d16804b2e56a55 + Adding executable file with id 587be6b4c3f93f93c489c0111bba5596147a26cb + Adding executable file with id 8ba3a16384aacc37d01564b28401755ce8053f51 + "###); + test_env.jj_cmd_success(&repo_path, &["chmod", "n", "file"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["cat", "file"]); + insta::assert_snapshot!(stdout, + @r###" + <<<<<<< + %%%%%%% + -base + +x + +++++++ + n + >>>>>>> + "###); + + // An error prevents `chmod` from making any changes. + // In this case, the failure with `nonexistent` prevents any changes to `file`. + let stderr = test_env.jj_cmd_failure(&repo_path, &["chmod", "x", "nonexistent", "file"]); + insta::assert_snapshot!(stderr, @r###" + Error: No such path at 'nonexistent'. + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["cat", "file"]); + insta::assert_snapshot!(stdout, + @r###" + <<<<<<< + %%%%%%% + -base + +x + +++++++ + n + >>>>>>> + "###); +} + +// TODO: Test demonstrating that conflicts whose *base* is not a file are +// chmod-dable + +#[test] +fn test_chmod_file_dir_deletion_conflicts() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + + create_commit(&test_env, &repo_path, "base", &[], &[("file", "base\n")]); + create_commit(&test_env, &repo_path, "file", &["base"], &[("file", "a\n")]); + + create_commit(&test_env, &repo_path, "deletion", &["base"], &[]); + std::fs::remove_file(repo_path.join("file")).unwrap(); + + create_commit(&test_env, &repo_path, "dir", &["base"], &[]); + std::fs::remove_file(repo_path.join("file")).unwrap(); + std::fs::create_dir(repo_path.join("file")).unwrap(); + // Without a placeholder file, `jj` ignores an empty directory + std::fs::write(repo_path.join("file").join("placeholder"), "").unwrap(); + + // Create a file-dir conflict and a file-deletion conflict + create_commit(&test_env, &repo_path, "file_dir", &["file", "dir"], &[]); + create_commit( + &test_env, + &repo_path, + "file_deletion", + &["file", "deletion"], + &[], + ); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ file_deletion + ├─╮ + │ │ ◉ file_dir + │ ╭─┤ + │ │ ◉ dir + ◉ │ │ deletion + ├───╯ + │ ◉ file + ├─╯ + ◉ base + ◉ + "###); + + // The file-dir conflict cannot be chmod-ed + let stdout = test_env.jj_cmd_success(&repo_path, &["cat", "-r=file_dir", "file"]); + insta::assert_snapshot!(stdout, + @r###" + Conflict: + Removing file with id df967b96a579e45a18b8251732d16804b2e56a55 + Adding file with id 78981922613b2afb6025042ff6bd878ac1994e85 + Adding tree with id 133bb38fc4e4bf6b551f1f04db7e48f04cac2877 + "###); + let stderr = test_env.jj_cmd_failure(&repo_path, &["chmod", "x", "file", "-r=file_dir"]); + insta::assert_snapshot!(stderr, @r###" + Error: None of the sides of the conflict are files at 'file'. + "###); + + // The file_deletion conflict can be chmod-ed + let stdout = test_env.jj_cmd_success(&repo_path, &["cat", "-r=file_deletion", "file"]); + insta::assert_snapshot!(stdout, + @r###" + <<<<<<< + +++++++ + a + %%%%%%% + -base + >>>>>>> + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["chmod", "x", "file", "-r=file_deletion"]); + insta::assert_snapshot!(stdout, @r###" + Working copy now at: 85942d954f00 file_deletion + Parent commit : c51c9c55bad4 file + Parent commit : 6b18b3c1578d deletion + Added 0 files, modified 1 files, removed 0 files + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["cat", "-r=file_deletion", "file"]); + insta::assert_snapshot!(stdout, + @r###" + Conflict: + Removing executable file with id df967b96a579e45a18b8251732d16804b2e56a55 + Adding executable file with id 78981922613b2afb6025042ff6bd878ac1994e85 + "###); +}