Skip to content

Conversation

isuffix
Copy link
Contributor

@isuffix isuffix commented Sep 11, 2025

Let's try this again!

This is a remaking of #4478 that builds off of the many smaller PRs I've been making the past month.

One key difference from the approach in #4478 is how the third commit starts out by preserving the in_repo executable bit on all platforms, which makes the later commit adding on_disk for Unix easier to reason about.

Closes #3949

It took me a while to come to the final representation of the executable bit, but I think it's correct for what we want.

I find the symmetry between the from_repo and from_disk methods particularly convincing: if we're respecting the executable bit, then on_disk and in_repo always update to match each other. If we're ignoring the executable bit, then the two values are allowed to diverge, and instead we set them to their previous values (if known).

Checklist

If applicable:

  • I have updated CHANGELOG.md
  • I have updated the documentation (README.md, docs/, demos/)
  • I have updated the config schema (cli/src/config-schema.json)
  • I have added/updated tests to cover my changes

@isuffix isuffix requested a review from a team as a code owner September 11, 2025 06:50
@isuffix isuffix force-pushed the exec-bit branch 3 times, most recently from 0e64109 to 8a078c3 Compare September 11, 2025 07:06
@isuffix
Copy link
Contributor Author

isuffix commented Sep 11, 2025

Random: this has the exact same set of digits as the previous PR, 4478 vs. 7484.

@isuffix isuffix force-pushed the exec-bit branch 2 times, most recently from 45925e9 to a92ecbc Compare September 16, 2025 19:41
@isuffix
Copy link
Contributor Author

isuffix commented Sep 25, 2025

Rebased off main and added a new test in test_file_chmod_command.rs in the last commit

@isuffix
Copy link
Contributor Author

isuffix commented Oct 13, 2025

I've removed the in-repo field from the ExecBit type and redone the last three commits. This is ready for another review.

@isuffix isuffix requested a review from yuja October 13, 2025 22:09
Copy link
Contributor

@PhilipMetzger PhilipMetzger left a comment

Choose a reason for hiding this comment

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

LG

@isuffix
Copy link
Contributor Author

isuffix commented Oct 14, 2025

Rebased off main and fixed an out-of-date comment in the third commit.

@isuffix isuffix force-pushed the exec-bit branch 4 times, most recently from 137abe9 to 65d5a7a Compare October 15, 2025 20:22
@martinvonz
Copy link
Member

I haven't looked at this yet but it would be nice to have better descriptions for commits 3-5. They don't say much about the motivation or whether there are observable differences.

@isuffix isuffix force-pushed the exec-bit branch 2 times, most recently from 69be4fa to 777c885 Compare October 17, 2025 20:10
Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

The following commits do not follow our format for subject lines:

  • 76261c5: (draft) flaky test fixes

Commits should have a subject line following the format <topic>: <description>. Please review the commit guidelines for more information.

@github-actions github-actions bot dismissed their stale review October 17, 2025 21:15

All commits are now correctly formatted. Thank you for your contribution!

Copy link
Contributor

@yuja yuja left a comment

Choose a reason for hiding this comment

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

Looks generally good to me, thanks for working this feature.

Comment on lines 155 to 159
/// On Windows, we instead always set this to `false` because when we write
/// files, we always create them anew, so the executable bit will be false
/// even if shared with a Unix machine.
fn new_from_repo(in_repo: bool) -> Self {
Self(if cfg!(unix) { in_repo } else { false })
Copy link
Contributor

Choose a reason for hiding this comment

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

I thought this could be worked around by set_executable() or its caller, but I'm not against the current implementation either. The difference would only matter in reset() where we don't touch on-disk files at all.

fn set_executable(exec_bit: ExecBit, disk_path: &Path) -> Result<ExecBit, io::Error> {
    ....
    // Windows
    ExecBit(false)
}

let real_exec_bit = set_executable(desired_exec_bit, disk_path)?;
Ok(FileState::for_file(real_exec_bit, ...)

…iables

The main reason for this change is that we now give variables different names
based on their types. This helps avoid confusion and makes intent clearer.
However, the type name `FileExecutableFlag` doesn't have a good shortening
(`file_exec_flag` is annoyingly long), so I also renamed the type to something
shorter, which makes the code more legible: easier to mentally parse and
quicker to type.

I removed `File` from the name both for length and because it doesn't really
help distinguish from the executable field in `TreeValue` (because that field
is nested under `TreeValue::File`). Instead, in the upcoming commits I update
comments to consistently use the terms 'on-disk' and 'in-repo' to respectively
refer to the fields in the `FileState` and `TreeValue` structs, which I find
is better for keeping the difference clear in my head.

I went with `Bit` in the new name just because I'm already changing it and I
prefer `exec_bit` slightly over `exec_flag` as the variable name.
@isuffix
Copy link
Contributor Author

isuffix commented Oct 22, 2025

I've found an issue with the PR design that needs a fix, and I'd like to get a confirmation on the direction before I commit to changing anything.

If we have the commands:

jj config set --repo working-copy.exec-bit-change ignore
touch file
jj file chmod x file
# on-disk file is not executable
jj config set --repo working-copy.exec-bit-change respect
jj file chmod x file
# on-disk file should be executable

The snapshot from the last command might not update the backend state, making the chmod not write anything because it thinks the file is already executable and there isn't anything to write.

I did have this pattern inside a larger test case (and have re-added it as a distinct test), but I didn't realize it was an issue sooner because it would only happen occasionally in CI and I couldn't reproduce it on my machine. So I changed the original test slightly (which fixed CI) and I thought it was either fine or an issue with CI to report later. But it turns out the changed test actually can reproduce the issue on my machine sometimes, I just got incredibly unlucky the first few times I tried.

Now that I can reproduce the issue on my machine, I've done a deep-dive and found the problem.

Why does this happen, and why only rarely?

The following file state cleanliness check should always set clean to false when the final command does its snapshot, but it only usually sets clean to false:

// lib/src/local_working_copy.rs:1600
let clean = new_file_state.is_clean(current_file_state)
    && current_file_state.mtime < self.tree_state.own_mtime;

I didn't think about this part of the code much because I don't change it in this PR, but I eventually realized it was the culprit. The first line here is always true (it should be false), so the reason clean is only usually false is because the modification time comparison on the second line will usually be false, but can be true based on chance (and is less likely on a machine that isn't overworked or running many things, which is why I've had difficulty reproducing it).

Since we want clean to always be false, the real issue is with the first line, so why is that true?

Because we only store the on-disk executable bit in the file states we're comparing, and the on-disk file never changed.

What's the fix?

When the configuration goes from ignore to respect, we need to be able to recognize a difference between the in-repo and on-disk executable bits when comparing file states.

One way would be to start reading the executable bit from the backend TreeValue before we compare file states. However, this would have to be read and checked for every file, and my understanding of the point of the file state cleanliness check is to avoid reading data from the backend in the first place.

The other way would be to store both the on-disk and in-repo bits in FileState (and in the file state cache) as I originally did in this PR in the ExecBit struct but was advised to change.

I would like to go back to the original design of the ExecBit struct, but I'd like confirmation first before I commit to changing anything.

@yuja
Copy link
Contributor

yuja commented Oct 23, 2025

jj config set --repo working-copy.exec-bit-change ignore
touch file
jj file chmod x file
# on-disk file is not executable
jj config set --repo working-copy.exec-bit-change respect
jj file chmod x file
# on-disk file should be executable

This is common problem in EOL handling and watchman, and I personally don't think we need a special support. User should check out all files if they change exec-bit-change mode.

If we need a workaround, we can record the previous working-copy.exec-bit-change mode in tree_state file. If the mode changes, cached file states cannot be trusted, so all working-copy files will be snapshotted. In the example above, the second jj file chmod x file will notice that the working-copy file isn't an executable, so it will first snapshot the current state and update it to executable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Annoying executable reporting on NTFS drives in Linux

4 participants