diff --git a/Cargo.lock b/Cargo.lock index 38b7a6939878fad9bfa259ee03189e018ef507c9..3c19e1b79bc859e77c99b992f4f5617894da82df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6406,6 +6406,7 @@ dependencies = [ "git", "gpui", "ignore", + "is_executable", "libc", "log", "notify 8.2.0", @@ -8436,6 +8437,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_executable" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 15093b3a5b5e18ce0ddca1e9c23350e1ac46d66e..52063eeddcc3aa74adae33f3a78c74ecb6b6f04c 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -33,6 +33,7 @@ tempfile.workspace = true text.workspace = true time.workspace = true util.workspace = true +is_executable = "1.0.5" [target.'cfg(target_os = "macos")'.dependencies] fsevent.workspace = true diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index c9a41243aa641318026db208d78a64429cfeb1ab..febef94d8cd8c5f10c27dd5c62e8076fb5fb784d 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -138,6 +138,7 @@ impl GitRepository for FakeGitRepository { path: RepoPath, content: Option, _env: Arc>, + _is_executable: bool, ) -> BoxFuture<'_, anyhow::Result<()>> { self.with_state_async(true, move |state| { if let Some(message) = &state.simulated_index_write_error_message { diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 93192ecd2bd2449dafa622a69045be6811a43cf7..5a6e4bdfdba48af25342d4d1ecfafd1d4ce0709b 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -32,6 +32,7 @@ use std::mem::MaybeUninit; use async_tar::Archive; use futures::{AsyncRead, Stream, StreamExt, future::BoxFuture}; use git::repository::{GitRepository, RealGitRepository}; +use is_executable::IsExecutable; use rope::Rope; use serde::{Deserialize, Serialize}; use smol::io::AsyncWriteExt; @@ -208,6 +209,7 @@ pub struct Metadata { pub is_dir: bool, pub len: u64, pub is_fifo: bool, + pub is_executable: bool, } /// Filesystem modification time. The purpose of this newtype is to discourage use of operations @@ -895,6 +897,12 @@ impl Fs for RealFs { #[cfg(unix)] let is_fifo = metadata.file_type().is_fifo(); + let path_buf = path.to_path_buf(); + let is_executable = self + .executor + .spawn(async move { path_buf.is_executable() }) + .await; + Ok(Some(Metadata { inode, mtime: MTime(metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH)), @@ -902,6 +910,7 @@ impl Fs for RealFs { is_symlink, is_dir: metadata.file_type().is_dir(), is_fifo, + is_executable, })) } @@ -2602,6 +2611,7 @@ impl Fs for FakeFs { is_dir: false, is_symlink, is_fifo: false, + is_executable: false, }, FakeFsEntry::Dir { inode, mtime, len, .. @@ -2612,6 +2622,7 @@ impl Fs for FakeFs { is_dir: true, is_symlink, is_fifo: false, + is_executable: false, }, FakeFsEntry::Symlink { .. } => unreachable!(), })) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 9beb3d838382d9267afdb081211647139f85b75e..03b29eb4a7a28ddc13bdbfb23422f98baa82ae36 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -400,6 +400,7 @@ pub trait GitRepository: Send + Sync { path: RepoPath, content: Option, env: Arc>, + is_executable: bool, ) -> BoxFuture<'_, anyhow::Result<()>>; /// Returns the URL of the remote with the given name. @@ -987,12 +988,15 @@ impl GitRepository for RealGitRepository { path: RepoPath, content: Option, env: Arc>, + is_executable: bool, ) -> BoxFuture<'_, anyhow::Result<()>> { let working_directory = self.working_directory(); let git_binary_path = self.any_git_binary_path.clone(); self.executor .spawn(async move { let working_directory = working_directory?; + let mode = if is_executable { "100755" } else { "100644" }; + if let Some(content) = content { let mut child = new_smol_command(&git_binary_path) .current_dir(&working_directory) @@ -1013,7 +1017,7 @@ impl GitRepository for RealGitRepository { let output = new_smol_command(&git_binary_path) .current_dir(&working_directory) .envs(env.iter()) - .args(["update-index", "--add", "--cacheinfo", "100644", sha]) + .args(["update-index", "--add", "--cacheinfo", mode, sha]) .arg(path.as_unix_str()) .output() .await?; diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index bde9261fa28b8ed0d6c6a79fd02b90177e52a98e..8b83fa48e9b61a7200a001f4d42227b1c2302874 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -301,6 +301,7 @@ impl std::ops::Deref for Repository { #[derive(Clone)] pub enum RepositoryState { Local { + fs: Arc, backend: Arc, environment: Arc>, }, @@ -4288,6 +4289,7 @@ impl Repository { RepositoryState::Local { backend, environment, + .. } => backend.run_hook(hook, environment.clone()).await, RepositoryState::Remote { project_id, client } => { client @@ -4580,6 +4582,7 @@ impl Repository { let id = self.id; let this = cx.weak_entity(); let git_store = self.git_store.clone(); + let abs_path = self.snapshot.repo_path_to_abs_path(&path); self.send_keyed_job( Some(GitJobKey::WriteIndex(vec![path.clone()])), None, @@ -4588,14 +4591,21 @@ impl Repository { "start updating index text for buffer {}", path.as_unix_str() ); + match git_repo { RepositoryState::Local { + fs, backend, environment, .. } => { + let executable = match fs.metadata(&abs_path).await { + Ok(Some(meta)) => meta.is_executable, + Ok(None) => false, + Err(_err) => false, + }; backend - .set_index_text(path.clone(), content, environment.clone()) + .set_index_text(path.clone(), content, environment.clone(), executable) .await?; } RepositoryState::Remote { project_id, client } => { @@ -5164,6 +5174,7 @@ impl Repository { cx: &mut Context, ) -> mpsc::UnboundedSender { let (job_tx, mut job_rx) = mpsc::unbounded::(); + let fs_cloned = fs.clone(); cx.spawn(async move |_, cx| { let environment = project_environment @@ -5195,8 +5206,8 @@ impl Repository { backend.clone(), ); } - let state = RepositoryState::Local { + fs: fs_cloned, backend, environment: Arc::new(environment), }; diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index d42859de5d5491d4a5388d311266e22962889f35..1cbaf950e818956f55cb52eed997c1e3819ced34 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -8174,6 +8174,91 @@ async fn test_single_file_diffs(cx: &mut gpui::TestAppContext) { }); } +// TODO: Should we test this on Windows also? +#[gpui::test] +#[cfg(not(windows))] +async fn test_staging_hunk_preserve_executable_permission(cx: &mut gpui::TestAppContext) { + use std::os::unix::fs::PermissionsExt; + init_test(cx); + cx.executor().allow_parking(); + let committed_contents = "bar\n"; + let file_contents = "baz\n"; + let root = TempTree::new(json!({ + "project": { + "foo": committed_contents + }, + })); + + let work_dir = root.path().join("project"); + let file_path = work_dir.join("foo"); + let repo = git_init(work_dir.as_path()); + let mut perms = std::fs::metadata(&file_path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&file_path, perms).unwrap(); + git_add("foo", &repo); + git_commit("Initial commit", &repo); + std::fs::write(&file_path, file_contents).unwrap(); + + let project = Project::test( + Arc::new(RealFs::new(None, cx.executor())), + [root.path()], + cx, + ) + .await; + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(file_path.as_path(), cx) + }) + .await + .unwrap(); + + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + + let uncommitted_diff = project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + }) + .await + .unwrap(); + + uncommitted_diff.update(cx, |diff, cx| { + let hunks = diff.hunks(&snapshot, cx).collect::>(); + diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx); + }); + + cx.run_until_parked(); + + let output = smol::process::Command::new("git") + .current_dir(&work_dir) + .args(["diff", "--staged"]) + .output() + .await + .unwrap(); + + let staged_diff = String::from_utf8_lossy(&output.stdout); + + assert!( + !staged_diff.contains("new mode 100644"), + "Staging should not change file mode from 755 to 644.\ngit diff --staged:\n{}", + staged_diff + ); + + let output = smol::process::Command::new("git") + .current_dir(&work_dir) + .args(["ls-files", "-s"]) + .output() + .await + .unwrap(); + let index_contents = String::from_utf8_lossy(&output.stdout); + + assert!( + index_contents.contains("100755"), + "Index should show file as executable (100755).\ngit ls-files -s:\n{}", + index_contents + ); +} + #[gpui::test] async fn test_repository_and_path_for_project_path( background_executor: BackgroundExecutor,