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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ 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`.

* Updated the executable bit representation in the local working copy to allow
ignoring executable bit changes on Unix. By default we try to detect the
filesystem's behavior, but this can be overridden manually by setting
`working-copy.exec-bit-change = "respect" | "ignore"`.

### Fixed bugs

* `jj metaedit --author-timestamp` twice with the same value no longer
Expand Down
10 changes: 10 additions & 0 deletions cli/src/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,16 @@
"none"
],
"default": "none"
},
"exec-bit-change": {
"type": "string",
"description": "Whether to respect changes to executable bits on Unix. This is unused on Windows.",
"enum": [
"respect",
"ignore",
"auto"
],
"default": "auto"
}
}
},
Expand Down
2 changes: 2 additions & 0 deletions cli/src/merge_tools/diff_working_copies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use jj_lib::conflicts::ConflictMarkerStyle;
use jj_lib::fsmonitor::FsmonitorSettings;
use jj_lib::gitignore::GitIgnoreFile;
use jj_lib::local_working_copy::EolConversionMode;
use jj_lib::local_working_copy::ExecChangeSetting;
use jj_lib::local_working_copy::TreeState;
use jj_lib::local_working_copy::TreeStateError;
use jj_lib::local_working_copy::TreeStateSettings;
Expand Down Expand Up @@ -152,6 +153,7 @@ pub(crate) fn check_out_trees(
let tree_state_settings = TreeStateSettings {
conflict_marker_style,
eol_conversion_mode: EolConversionMode::None,
exec_change_setting: ExecChangeSetting::Auto,
fsmonitor_settings: FsmonitorSettings::None,
};
let mut state = TreeState::init(store.clone(), wc_path, state_dir, &tree_state_settings)?;
Expand Down
209 changes: 209 additions & 0 deletions cli/tests/test_file_chmod_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,39 @@
// See the License for the specific language governing permissions and
// limitations under the License.

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
use std::path::Path;

#[cfg(unix)]
use regex::Regex;

use crate::common::CommandOutput;
use crate::common::TestEnvironment;
use crate::common::TestWorkDir;
use crate::common::create_commit_with_files;

/// Assert that a file's executable bit matches the expected value.
#[cfg(unix)]
#[track_caller]
fn assert_file_executable(path: &Path, expected: bool) {
let perms = path.metadata().unwrap().permissions();
let actual = (perms.mode() & 0o100) == 0o100;
assert_eq!(actual, expected);
}

/// Set the executable bit of a file on the filesystem.
#[cfg(unix)]
#[track_caller]
pub fn set_file_executable(path: &Path, executable: bool) {
let prev_mode = path.metadata().unwrap().permissions().mode();
let is_executable = prev_mode & 0o100 != 0;
assert_ne!(executable, is_executable, "why are you calling this?");
let new_mode = if executable { 0o755 } else { 0o644 };
std::fs::set_permissions(path, PermissionsExt::from_mode(new_mode)).unwrap();
}

#[must_use]
fn get_log_output(work_dir: &TestWorkDir) -> CommandOutput {
work_dir.run_jj(["log", "-T", "bookmarks"])
Expand Down Expand Up @@ -228,3 +256,184 @@ fn test_chmod_file_dir_deletion_conflicts() {
[EOF]
");
}

#[cfg(unix)]
#[test]
fn test_chmod_exec_bit_settings() {
let test_env = TestEnvironment::default();
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
let work_dir = test_env.work_dir("repo");
let path = &work_dir.root().join("file");

// The timestamps in the `jj debug local-working-copy` output change, so we want
// to remove them before asserting the snapshot
let timestamp_regex = Regex::new(r"\b\d{10,}\b").unwrap();
let redact_timestamp = |output: String| {
let output = timestamp_regex.replace_all(&output, "<timestamp>");
output.into_owned()
};

// Load with an explicit "auto" value to test the deserialization.
test_env.add_config(r#"working-copy.exec-bit-change = "auto""#);
create_commit_with_files(&work_dir, "base", &[], &[("file", "base\n")]);

let output = work_dir.run_jj(["debug", "local-working-copy"]);
insta::assert_snapshot!(output.normalize_stdout_with(redact_timestamp), @r#"
Current operation: OperationId("8c58a72d1118aa7d8b1295949a7fa8c6fcda63a3c89813faf2b8ca599ceebf8adcfcbeb8f0bbb6439c86b47dd68b9cf85074c9e57214c3fb4b632e0c9e87ad65")
Current tree: MergedTreeId { tree_ids: Resolved(TreeId("6d5f482d15035cdd7733b1b551d1fead28d22592")) }
Normal { exec_bit: ExecBit(false) } 5 <timestamp> None "file"
[EOF]
"#); // in-repo: false, on-disk: false (1/4)

// 1. Start respecting the executable bit
test_env.add_config(r#"working-copy.exec-bit-change = "respect""#);
create_commit_with_files(&work_dir, "respect", &["base"], &[]);

set_file_executable(path, true);
let output = work_dir.run_jj(["debug", "local-working-copy"]);
insta::assert_snapshot!(output.normalize_stdout_with(redact_timestamp), @r#"
Current operation: OperationId("3a6a78820e6892164ed55680b92fa679fbb4d6acd4135c7413d1b815bedcd2c24c85ac8f4f96c96f76012f33d31ffbf50473b938feadf36fcd9c92997789aeca")
Current tree: MergedTreeId { tree_ids: Resolved(TreeId("5201dbafb66dc1b28b029a262e1b206f6f93df1e")) }
Normal { exec_bit: ExecBit(true) } 5 <timestamp> None "file"
[EOF]
"#); // in-repo: true, on-disk: true (2/4)

work_dir.run_jj(["file", "chmod", "n", "file"]).success();
assert_file_executable(path, false);

work_dir.run_jj(["file", "chmod", "x", "file"]).success();
assert_file_executable(path, true);

// 2. Now ignore the executable bit
create_commit_with_files(&work_dir, "ignore", &["base"], &[]);
test_env.add_config(r#"working-copy.exec-bit-change = "ignore""#);
set_file_executable(path, true);

// chmod should affect the repo state, but not the on-disk file.
work_dir.run_jj(["file", "chmod", "n", "file"]).success();
assert_file_executable(path, true);
let output = work_dir.run_jj(["debug", "local-working-copy"]);
insta::assert_snapshot!(output.normalize_stdout_with(redact_timestamp), @r#"
Current operation: OperationId("cab1801e16b54d5b413f638bdf74388520b51232c88db6b314ef64b054607ab82ae6ef0b1f707b52aa8d2131511f6f48f8ca52e465621ff38c442b0ec893f309")
Current tree: MergedTreeId { tree_ids: Resolved(TreeId("6d5f482d15035cdd7733b1b551d1fead28d22592")) }
Normal { exec_bit: ExecBit(true) } 5 <timestamp> None "file"
[EOF]
"#); // in-repo: false, on-disk: true (3/4)

set_file_executable(path, false);
work_dir.run_jj(["file", "chmod", "x", "file"]).success();
assert_file_executable(path, false);
let output = work_dir.run_jj(["debug", "local-working-copy"]);
insta::assert_snapshot!(output.normalize_stdout_with(redact_timestamp), @r#"
Current operation: OperationId("def8ce6211dcff6d2784d5309d36079c1cb6eeb70821ae144982c76d38ed76fedc8b84e4daddaac70f6a0aae1c301ff5b60e1baa6ac371dabd77cec3537d2c39")
Current tree: MergedTreeId { tree_ids: Resolved(TreeId("5201dbafb66dc1b28b029a262e1b206f6f93df1e")) }
Normal { exec_bit: ExecBit(false) } 5 <timestamp> None "file"
[EOF]
"#); // in-repo: true, on-disk: false (4/4) Yay! We've observed all possible states!

// 3. Go back to respecting the executable bit
test_env.add_config(r#"working-copy.exec-bit-change = "respect""#);
assert_file_executable(path, false);
let output = work_dir.run_jj(["status"]); // TODO: broken
insta::assert_snapshot!(output, @r#"
The working copy has no changes.
Working copy (@) : znkkpsqq edd97391 ignore | (empty) ignore
Parent commit (@-): rlvkpnrz 1792382a base | base
[EOF]
"#);
let output = work_dir.run_jj(["file", "chmod", "x", "file"]);
insta::assert_snapshot!(output, @r#"
------- stderr -------
Working copy (@) now at: znkkpsqq 8ea2b9a1 ignore | ignore
Parent commit (@-) : rlvkpnrz 1792382a base | base
Added 0 files, modified 1 files, removed 0 files
[EOF]
"#);
assert_file_executable(path, true);

work_dir.run_jj(["new", "base"]).success();
set_file_executable(path, true);
let output = work_dir.run_jj(["debug", "local-working-copy"]);
insta::assert_snapshot!(output.normalize_stdout_with(redact_timestamp), @r#"
Current operation: OperationId("7babf6123ae6644e9f8cc702464da6c4bfcbfa002afc8c0d77e301ff8971a0c485b1fdd4b6c59bcf5039db2e54126f9c84c03ede489a0c8bb42a3e7f94df6bd9")
Current tree: MergedTreeId { tree_ids: Resolved(TreeId("5201dbafb66dc1b28b029a262e1b206f6f93df1e")) }
Normal { exec_bit: ExecBit(true) } 5 <timestamp> None "file"
[EOF]
"#);
}

#[cfg(unix)]
#[test]
fn test_chmod_exec_bit_ignore() {
let test_env = TestEnvironment::default();
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
let work_dir = test_env.work_dir("repo");
let path = &work_dir.root().join("file");

test_env.add_config(r#"working-copy.exec-bit-change = "ignore""#);

create_commit_with_files(&work_dir, "base", &[], &[("file", "base\n")]);
assert_file_executable(path, false);

// 1. Reverting to "in-repo: true, on-disk: false" works.
create_commit_with_files(&work_dir, "repo-x-disk-n", &["base"], &[]);
work_dir.run_jj(["file", "chmod", "x", "file"]).success();
assert_file_executable(path, false);

// Commit, update the file, then reset the file.
work_dir.run_jj(["new"]).success();
work_dir.write_file(path, "something");
work_dir.run_jj(["abandon"]).success();
// The on-disk exec bit should remain false.
assert_file_executable(path, false);

// 2. Reverting to "in-repo: false, on-disk: true" works.
create_commit_with_files(&work_dir, "repo-n-disk-x", &["base"], &[]);
set_file_executable(path, true);
work_dir.run_jj(["file", "chmod", "n", "file"]).success();
assert_file_executable(path, true);

// Commit, update the file, then reset the file.
work_dir.run_jj(["new"]).success();
work_dir.write_file(path, "something");
work_dir.run_jj(["abandon"]).success();
// The on-disk exec bit should remain true.
assert_file_executable(path, true);
}

#[cfg(unix)]
#[test]
fn test_chmod_exec_bit_ignore_then_respect() {
let test_env = TestEnvironment::default();
test_env.run_jj_in(".", ["git", "init", "repo"]).success();
let work_dir = test_env.work_dir("repo");
let path = &work_dir.root().join("file");

// Start while ignoring executable bits.
test_env.add_config(r#"working-copy.exec-bit-change = "ignore""#);
create_commit_with_files(&work_dir, "base", &[], &[("file", "base\n")]);

// Set the in-repo executable bit to true.
let output = work_dir.run_jj(["file", "chmod", "x", "file"]);
insta::assert_snapshot!(output, @r#"
------- stderr -------
Working copy (@) now at: rlvkpnrz cb3f99cb base | base
Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set)
Added 0 files, modified 1 files, removed 0 files
[EOF]
"#);
assert_file_executable(path, false);

test_env.add_config(r#"working-copy.exec-bit-change = "respect""#);

// This simultaneously snapshots and updates the executable bit.
let output = work_dir.run_jj(["file", "chmod", "x", "file"]);
insta::assert_snapshot!(output, @r#"
------- stderr -------
Working copy (@) now at: rlvkpnrz f10cb843 base | base
Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set)
Added 0 files, modified 1 files, removed 0 files
[EOF]
"#);
assert_file_executable(path, true);
}
11 changes: 3 additions & 8 deletions cli/tests/test_working_copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,13 +350,8 @@ fn test_conflict_marker_length_stored_in_working_copy() {
// The timestamps in the `jj debug local-working-copy` output change, so we want
// to remove them before asserting the snapshot
let timestamp_regex = Regex::new(r"\b\d{10,}\b").unwrap();
// On Windows, executable is always `()`, but on Unix-like systems, it's `true`
// or `false`, so we want to remove it from the output as well
let executable_regex = Regex::new("executable: [^ ]+").unwrap();

let redact_output = |output: String| {
let output = timestamp_regex.replace_all(&output, "<timestamp>");
let output = executable_regex.replace_all(&output, "<executable>");
output.into_owned()
};

Expand All @@ -365,7 +360,7 @@ fn test_conflict_marker_length_stored_in_working_copy() {
insta::assert_snapshot!(output.normalize_stdout_with(redact_output), @r#"
Current operation: OperationId("da3b34243efe5ea04830cd2211b5be79444fbc2ef23681361fd2f551ebb86772bff21695da95b72388306e028bf04c6d76db10bf4cbd3a08eb34bf744c8900c7")
Current tree: MergedTreeId { tree_ids: Conflicted([TreeId("381273b50cf73f8c81b3f1502ee89e9bbd6c1518"), TreeId("771f3d31c4588ea40a8864b2a981749888e596c2"), TreeId("f56b8223da0dab22b03b8323ced4946329aeb4e0")]) }
Normal { <executable> } 249 <timestamp> Some(MaterializedConflictData { conflict_marker_len: 11 }) "file"
Normal { exec_bit: ExecBit(false) } 249 <timestamp> Some(MaterializedConflictData { conflict_marker_len: 11 }) "file"
[EOF]
"#);

Expand Down Expand Up @@ -428,7 +423,7 @@ fn test_conflict_marker_length_stored_in_working_copy() {
insta::assert_snapshot!(output.normalize_stdout_with(redact_output), @r#"
Current operation: OperationId("3de33bbfe3a9df8a052cc243aeedac6a3240d6115cb88f2779a1b6f1289288c6e78153875e48e41c17c098418f681bc872c54743e76b9e210f08533c50fc5a26")
Current tree: MergedTreeId { tree_ids: Conflicted([TreeId("381273b50cf73f8c81b3f1502ee89e9bbd6c1518"), TreeId("771f3d31c4588ea40a8864b2a981749888e596c2"), TreeId("3329c18c95f7b7a55c278c2259e9c4ce711fae59")]) }
Normal { <executable> } 289 <timestamp> Some(MaterializedConflictData { conflict_marker_len: 11 }) "file"
Normal { exec_bit: ExecBit(false) } 289 <timestamp> Some(MaterializedConflictData { conflict_marker_len: 11 }) "file"
[EOF]
"#);

Expand Down Expand Up @@ -463,7 +458,7 @@ fn test_conflict_marker_length_stored_in_working_copy() {
insta::assert_snapshot!(output.normalize_stdout_with(redact_output), @r#"
Current operation: OperationId("2676b66a8d17cf7913d2260285abe6f3ca4c8dc8f3fdfb3f54a4d566c9199670f80123a7174b553ff67c13c20c6827cde2429847a7949c19bc52f2397139e4c9")
Current tree: MergedTreeId { tree_ids: Resolved(TreeId("6120567b3cb2472d549753ed3e4b84183d52a650")) }
Normal { <executable> } 130 <timestamp> None "file"
Normal { exec_bit: ExecBit(false) } 130 <timestamp> None "file"
[EOF]
"#);
}
33 changes: 33 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1674,6 +1674,39 @@ large repositories, this may cause `watchman` to fail and commands like
`jj status` to take longer than expected. If you experience this run
`jj debug watchman status` and tune your `inotify` limits.

## Respect or ignore executable bit permission changes

Whether to respect or ignore changes to the executable bit for files on Unix.
This option is unused on Windows.

`working-copy.exec-bit-change = "respect" | "ignore" | "auto" (default)`

On Unix systems, files have a permission bit for whether they are executable as
scripts or binary code. Jujutsu stores this state in the repository and will
update it for files as you operate on a repository. If you set your working
copy to a commit where a file is recorded as executable or not, `jj` will
adjust the permission of that file. If you change a file's executable bit
through the filesystem, `jj` will record that change when taking a snapshot.

Setting this to `"ignore"` will make Jujutsu ignore the executable bit on the
filesystem when updating the state for the repository. In addition, `jj` will
not attempt to modify a file's executable bit and will add new files as
"not executable." This is already the behavior on Windows, and having the
option to enable this behavior is useful for Unix users dual-booting Windows,
Windows users accessing files from WSL, or anyone experimenting with other
filesystem configurations.

On Unix if `"auto"` is set (the default), `jj` will try to detect whether the
filesystem supports changing executable bits to choose between `"respect"` or
`"ignore"`. On errors, we assume `"respect"`, except if permission was denied
which will assume `"ignore"`.

On Windows, files have no executable bit so this option is unused.

You can always use `jj file chmod` to update the recorded executable bit for a
file manually. If this option is `"respect"`, `jj` will also attempt to
propagate that change to the filesystem.

## Snapshot settings

### Paths to automatically track
Expand Down
1 change: 1 addition & 0 deletions lib/src/config/misc.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ name = ""

[working-copy]
eol-conversion = "none"
exec-bit-change = "auto"
Loading
Loading