488 lines · 20.3 KB · Rust
RawDownload
// 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. #[cfg(unix)]use std::os::unix::fs::PermissionsExt;#[cfg(unix)]use std::path::Path; use jj_lib::file_util::check_symlink_support;use jj_lib::file_util::symlink_file;#[cfg(unix)]use regex::Regex;use testutils::TestResult; 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"])} #[test]fn test_chmod_regular_conflict() {    let test_env = TestEnvironment::default();    test_env.run_jj_in(".", ["git", "init", "repo"]).success();    let work_dir = test_env.work_dir("repo");     create_commit_with_files(&work_dir, "base", &[], &[("file", "base\n")]);    create_commit_with_files(&work_dir, "n", &["base"], &[("file", "n\n")]);    create_commit_with_files(&work_dir, "x", &["base"], &[("file", "x\n")]);    // Test chmodding a file. The effect will be visible in the conflict below.    work_dir        .run_jj(["file", "chmod", "x", "file", "-r=x"])        .success();    create_commit_with_files(&work_dir, "conflict", &["x", "n"], &[]);     // Test the setup    insta::assert_snapshot!(get_log_output(&work_dir), @"    @    conflict    ├─╮    │ ○  n    ○ │  x    ├─╯    ○  base    ◆    [EOF]    ");    let output = work_dir.run_jj(["debug", "tree"]);    insta::assert_snapshot!(output, @r#"    file: Ok(Conflicted([Some(File { id: FileId("587be6b4c3f93f93c489c0111bba5596147a26cb"), executable: true, copy_id: CopyId("") }), Some(File { id: FileId("df967b96a579e45a18b8251732d16804b2e56a55"), executable: false, copy_id: CopyId("") }), Some(File { id: FileId("8ba3a16384aacc37d01564b28401755ce8053f51"), executable: false, copy_id: CopyId("") })]))    [EOF]    "#);    let output = work_dir.run_jj(["file", "show", "file"]);    insta::assert_snapshot!(output, @r#"    <<<<<<< conflict 1 of 1    %%%%%%% diff from: rlvkpnrz 1792382a "base"    \\\\\\\        to: royxmykx 02247291 "x"    -base    +x    +++++++ zsuskuln eb0ba805 "n"    n    >>>>>>> conflict 1 of 1 ends    [EOF]    "#);     // Test chmodding a conflict    work_dir.run_jj(["file", "chmod", "x", "file"]).success();    let output = work_dir.run_jj(["debug", "tree"]);    insta::assert_snapshot!(output, @r#"    file: Ok(Conflicted([Some(File { id: FileId("587be6b4c3f93f93c489c0111bba5596147a26cb"), executable: true, copy_id: CopyId("") }), Some(File { id: FileId("df967b96a579e45a18b8251732d16804b2e56a55"), executable: true, copy_id: CopyId("") }), Some(File { id: FileId("8ba3a16384aacc37d01564b28401755ce8053f51"), executable: true, copy_id: CopyId("") })]))    [EOF]    "#);    let output = work_dir.run_jj(["file", "show", "file"]);    insta::assert_snapshot!(output, @r#"    <<<<<<< conflict 1 of 1    %%%%%%% diff from: rlvkpnrz 1792382a "base"    \\\\\\\        to: royxmykx 02247291 "x"    -base    +x    +++++++ zsuskuln eb0ba805 "n"    n    >>>>>>> conflict 1 of 1 ends    [EOF]    "#);    work_dir.run_jj(["file", "chmod", "n", "file"]).success();    let output = work_dir.run_jj(["debug", "tree"]);    insta::assert_snapshot!(output, @r#"    file: Ok(Conflicted([Some(File { id: FileId("587be6b4c3f93f93c489c0111bba5596147a26cb"), executable: false, copy_id: CopyId("") }), Some(File { id: FileId("df967b96a579e45a18b8251732d16804b2e56a55"), executable: false, copy_id: CopyId("") }), Some(File { id: FileId("8ba3a16384aacc37d01564b28401755ce8053f51"), executable: false, copy_id: CopyId("") })]))    [EOF]    "#);    let output = work_dir.run_jj(["file", "show", "file"]);    insta::assert_snapshot!(output, @r#"    <<<<<<< conflict 1 of 1    %%%%%%% diff from: rlvkpnrz 1792382a "base"    \\\\\\\        to: royxmykx 02247291 "x"    -base    +x    +++++++ zsuskuln eb0ba805 "n"    n    >>>>>>> conflict 1 of 1 ends    [EOF]    "#);     // Unmatched paths should generate warnings    let output = work_dir.run_jj(["file", "chmod", "x", "nonexistent", "file"]);    insta::assert_snapshot!(output, @"    ------- stderr -------    Warning: No matching entries for paths: nonexistent    Working copy  (@) now at: yostqsxw a1b4dce4 conflict | (conflict) conflict    Parent commit (@-)      : royxmykx 02247291 x | x    Parent commit (@-)      : zsuskuln eb0ba805 n | n    Added 0 files, modified 1 files, removed 0 files    Warning: There are unresolved conflicts at these paths:    file    2-sided conflict including an executable    [EOF]    ");} #[test]fn test_chmod_nonfile() -> TestResult {    if !check_symlink_support()? {        eprintln!("Skipping test because symlink isn't supported");        return Ok(());    }     let test_env = TestEnvironment::default();    test_env.run_jj_in(".", ["git", "init", "repo"]).success();    let work_dir = test_env.work_dir("repo");     symlink_file("target", work_dir.root().join("symlink"))?;    let output = work_dir.run_jj(["show"]);    insta::assert_snapshot!(output, @"    Commit ID: 82976318a088d30054540d1a11ffb4c79fb5d47e    Change ID: qpvuntsmwlqtpsluzzsnyyzlmlwvmlnu    Author   : Test User <test.user@example.com> (2001-02-03 08:05:08)    Committer: Test User <test.user@example.com> (2001-02-03 08:05:08)         (no description set)     Added symlink symlink:            1: target    [EOF]    ");     let output = work_dir.run_jj(["file", "chmod", "n", "symlink"]);    insta::assert_snapshot!(output, @"    ------- stderr -------    Error: Found neither a file nor a conflict at 'symlink'.    [EOF]    [exit status: 1]    ");    Ok(())} // 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.run_jj_in(".", ["git", "init", "repo"]).success();    let work_dir = test_env.work_dir("repo");     create_commit_with_files(&work_dir, "base", &[], &[("file", "base\n")]);    create_commit_with_files(&work_dir, "file", &["base"], &[("file", "a\n")]);     create_commit_with_files(&work_dir, "deletion", &["base"], &[]);    work_dir.remove_file("file");     create_commit_with_files(&work_dir, "dir", &["base"], &[]);    work_dir.remove_file("file");    work_dir.create_dir("file");    // Without a placeholder file, `jj` ignores an empty directory    work_dir.write_file("file/placeholder", "");     // Create a file-dir conflict and a file-deletion conflict    create_commit_with_files(&work_dir, "file_dir", &["file", "dir"], &[]);    create_commit_with_files(&work_dir, "file_deletion", &["file", "deletion"], &[]);    insta::assert_snapshot!(get_log_output(&work_dir), @"    @    file_deletion    ├─╮    │ ○  deletion    │ │ ×  file_dir    ╭───┤    │ │ ○  dir    │ ├─╯    ○ │  file    ├─╯    ○  base    ◆    [EOF]    ");     // The file-dir conflict cannot be chmod-ed    let output = work_dir.run_jj(["debug", "tree", "-r=file_dir"]);    insta::assert_snapshot!(output, @r#"    file: Ok(Conflicted([Some(File { id: FileId("78981922613b2afb6025042ff6bd878ac1994e85"), executable: false, copy_id: CopyId("") }), Some(File { id: FileId("df967b96a579e45a18b8251732d16804b2e56a55"), executable: false, copy_id: CopyId("") }), Some(Tree(TreeId("133bb38fc4e4bf6b551f1f04db7e48f04cac2877")))]))    [EOF]    "#);    let output = work_dir.run_jj(["file", "show", "-r=file_dir", "file"]);    insta::assert_snapshot!(output, @r#"    Conflict:      Removing file with id df967b96a579e45a18b8251732d16804b2e56a55 (rlvkpnrz 1792382a "base")      Adding file with id 78981922613b2afb6025042ff6bd878ac1994e85 (zsuskuln bc9cdea1 "file")      Adding tree with id 133bb38fc4e4bf6b551f1f04db7e48f04cac2877 (vruxwmqv 223cb383 "dir")    [EOF]    "#);    let output = work_dir.run_jj(["file", "chmod", "x", "file", "-r=file_dir"]);    insta::assert_snapshot!(output, @"    ------- stderr -------    Error: Some of the sides of the conflict are not files at 'file'.    [EOF]    [exit status: 1]    ");     // The file_deletion conflict can be chmod-ed    let output = work_dir.run_jj(["debug", "tree", "-r=file_deletion"]);    insta::assert_snapshot!(output, @r#"    file: Ok(Conflicted([Some(File { id: FileId("78981922613b2afb6025042ff6bd878ac1994e85"), executable: false, copy_id: CopyId("") }), Some(File { id: FileId("df967b96a579e45a18b8251732d16804b2e56a55"), executable: false, copy_id: CopyId("") }), None]))    [EOF]    "#);    let output = work_dir.run_jj(["file", "show", "-r=file_deletion", "file"]);    insta::assert_snapshot!(output, @r#"    <<<<<<< conflict 1 of 1    +++++++ zsuskuln bc9cdea1 "file"    a    %%%%%%% diff from: rlvkpnrz 1792382a "base"    \\\\\\\        to: royxmykx d7d39332 "deletion"    -base    >>>>>>> conflict 1 of 1 ends    [EOF]    "#);    let output = work_dir.run_jj(["file", "chmod", "x", "file", "-r=file_deletion"]);    insta::assert_snapshot!(output, @"    ------- stderr -------    Working copy  (@) now at: kmkuslsw b468931e file_deletion | (conflict) file_deletion    Parent commit (@-)      : zsuskuln bc9cdea1 file | file    Parent commit (@-)      : royxmykx d7d39332 deletion | deletion    Added 0 files, modified 1 files, removed 0 files    Warning: There are unresolved conflicts at these paths:    file    2-sided conflict including 1 deletion and an executable    New conflicts appeared in 1 commits:      kmkuslsw b468931e file_deletion | (conflict) file_deletion    Hint: To resolve the conflicts, start by creating a commit on top of    the conflicted commit:      jj new kmkuslsw    Then use `jj resolve`, or edit the conflict markers in the file directly.    Once the conflicts are resolved, you can inspect the result with `jj diff`.    Then run `jj squash` to move the resolution into the conflicted commit.    [EOF]    ");    let output = work_dir.run_jj(["debug", "tree", "-r=file_deletion"]);    insta::assert_snapshot!(output, @r#"    file: Ok(Conflicted([Some(File { id: FileId("78981922613b2afb6025042ff6bd878ac1994e85"), executable: true, copy_id: CopyId("") }), Some(File { id: FileId("df967b96a579e45a18b8251732d16804b2e56a55"), executable: true, copy_id: CopyId("") }), None]))    [EOF]    "#);    let output = work_dir.run_jj(["file", "show", "-r=file_deletion", "file"]);    insta::assert_snapshot!(output, @r#"    <<<<<<< conflict 1 of 1    +++++++ zsuskuln bc9cdea1 "file"    a    %%%%%%% diff from: rlvkpnrz 1792382a "base"    \\\\\\\        to: royxmykx d7d39332 "deletion"    -base    >>>>>>> conflict 1 of 1 ends    [EOF]    "#);} #[cfg(unix)]#[test]fn test_chmod_exec_bit_settings() -> TestResult {    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")?;    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("c213f276df4f2ac12ede4d6e1b7f386311c02049195b5db39a476b1785fe052e21b816b82ad41a2833296c04e27eab5a981f1744fa511581e32d405e0f8370b2")    Current tree: MergedTree { tree_ids: Resolved(TreeId("6d5f482d15035cdd7733b1b551d1fead28d22592")), labels: Unlabeled, .. }    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("60ebc37d4d08925da7d4d50b21ebd4395dfd71180cd3694cc288bce2145e8d601ca38ecdf2ce1abbba7368d678f154688b0f0c855e874757bfb8ec2d54c46c18")    Current tree: MergedTree { tree_ids: Resolved(TreeId("5201dbafb66dc1b28b029a262e1b206f6f93df1e")), labels: Unlabeled, .. }    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("2b128dc13ea9967d7a233535a8ea1f70b2f9a5e713d17097507fe9f0d986c9852377aa578fdd49959bcd60692ebd4cfd6c6f02d8e86a67ce631bf39ecf48cc2c")    Current tree: MergedTree { tree_ids: Resolved(TreeId("6d5f482d15035cdd7733b1b551d1fead28d22592")), labels: Unlabeled, .. }    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("73ee76ab06de88c3a43feefd313883127562f28cacd52e41408b6a1769294731d7a7fd90313e6324db6e501ea6602c5c83cd647a4d750728889688b5dc7178a2")    Current tree: MergedTree { tree_ids: Resolved(TreeId("5201dbafb66dc1b28b029a262e1b206f6f93df1e")), labels: Unlabeled, .. }    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""#);    work_dir.write_file("file", "update the file so respect notices the new state\n");     assert_file_executable(path, false);    let output = work_dir.run_jj(["status"]);    insta::assert_snapshot!(output, @"    Working copy changes:    M file    Working copy  (@) : znkkpsqq 71681768 ignore | ignore    Parent commit (@-): rlvkpnrz 1792382a base | base    [EOF]    ");    let output = work_dir.run_jj(["file", "chmod", "x", "file"]);    insta::assert_snapshot!(output, @"    ------- stderr -------    Working copy  (@) now at: znkkpsqq ef0a25b6 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("8ab66b017c52976aaa389ea0a5f4f6fd6a0267525b44303a8882d6ed2d5ac507fd661fcde9f466efa51f09d82c826bbff2af236b68502cbb991877e609d4858b")    Current tree: MergedTree { tree_ids: Resolved(TreeId("5201dbafb66dc1b28b029a262e1b206f6f93df1e")), labels: Unlabeled, .. }    Normal { exec_bit: ExecBit(true) }             5 <timestamp> None "file"    [EOF]    "#);    Ok(())} #[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, @"    ------- 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""#);    work_dir.write_file("file", "update the file so respect notices the new state\n");     // This simultaneously snapshots and updates the executable bit.    let output = work_dir.run_jj(["file", "chmod", "x", "file"]);    insta::assert_snapshot!(output, @"    ------- stderr -------    Working copy  (@) now at: rlvkpnrz 96872a96 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);}