diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index e826e5c0c3..c61f819ea2 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -107,6 +107,8 @@ actions!( ViewCommit, /// Adds a file to .gitignore. AddToGitignore, + /// Adds a file to the repository's .git/info/exclude. + AddToGitInfoExclude, /// Copies the current branch name to the clipboard. CopyBranchName, ] diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 160c3698dd..062385d6dc 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1480,6 +1480,44 @@ impl GitPanel { }); } + fn add_to_git_info_exclude( + &mut self, + _: &git::AddToGitInfoExclude, + _window: &mut Window, + cx: &mut Context, + ) { + maybe!({ + let list_entry = self.entries.get(self.selected_entry?)?.clone(); + let entry = list_entry.status_entry()?.to_owned(); + + if !entry.status.is_created() { + return Some(()); + } + + let active_repository = self.active_repository.clone()?; + let workspace = self.workspace.clone(); + let repo_path = entry.repo_path; + + let receiver = active_repository.update(cx, |repo, _| { + repo.add_path_to_git_info_exclude(&repo_path, false) + }); + + cx.spawn(async move |_, cx| { + if let Err(e) = receiver.await? { + if let Some(workspace) = workspace.upgrade() { + cx.update(|cx| { + show_error_toast(workspace, "add to .git/info/exclude", e, cx); + }); + } + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + Some(()) + }); + } + fn revert_entry( &mut self, entry: &GitStatusEntry, @@ -5946,11 +5984,17 @@ impl GitPanel { .context(self.focus_handle.clone()) .action(stage_title, ToggleStaged.boxed_clone()) .action(restore_title, git::RestoreFile::default().boxed_clone()) + .separator() .action_disabled_when( !is_created, "Add to .gitignore", git::AddToGitignore.boxed_clone(), ) + .action_disabled_when( + !is_created, + "Add to .git/info/exclude", + git::AddToGitInfoExclude.boxed_clone(), + ) .separator() .action("Open Diff", menu::Confirm.boxed_clone()) .action("Open Diff (File)", menu::SecondaryConfirm.boxed_clone()) @@ -6670,6 +6714,7 @@ impl Render for GitPanel { .on_action(cx.listener(Self::restore_tracked_files)) .on_action(cx.listener(Self::revert_selected)) .on_action(cx.listener(Self::add_to_gitignore)) + .on_action(cx.listener(Self::add_to_git_info_exclude)) .on_action(cx.listener(Self::clean_all)) .on_action(cx.listener(Self::generate_commit_message_action)) .on_action(cx.listener(Self::stash_all)) diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 359b0f8a99..70266e2138 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -6421,29 +6421,10 @@ impl Repository { move |git_repo, _cx| async move { match git_repo { RepositoryState::Local(LocalRepositoryState { fs, .. }) => { - let gitignore_path = work_dir.join(".gitignore"); - - let existing_content = fs.load(&gitignore_path).await.unwrap_or_default(); - - if existing_content - .lines() - .any(|line| line.trim() == file_path_str) - { - return Ok(()); - } - - let new_content = if existing_content.is_empty() { - format!("{}\n", file_path_str) - } else if existing_content.ends_with('\n') { - format!("{}{}\n", existing_content, file_path_str) - } else { - format!("{}\n{}\n", existing_content, file_path_str) - }; - - fs.save( - &gitignore_path, - &text::Rope::from(new_content.as_str()), - text::LineEnding::Unix, + append_pattern_to_ignore_file( + fs, + work_dir.join(".gitignore"), + file_path_str, ) .await } @@ -6455,6 +6436,40 @@ impl Repository { ) } + pub fn add_path_to_git_info_exclude( + &mut self, + repo_path: &RepoPath, + is_dir: bool, + ) -> oneshot::Receiver> { + let repository_dir = self.snapshot.repository_dir_abs_path.clone(); + let path_display = repo_path.as_ref().display(PathStyle::Posix); + let file_path_str = if is_dir { + format!("{}/", path_display) + } else { + path_display.to_string() + }; + + self.send_job( + "add_path_to_git_info_exclude", + None, + move |git_repo, _cx| async move { + match git_repo { + RepositoryState::Local(LocalRepositoryState { fs, .. }) => { + append_pattern_to_ignore_file( + fs, + repository_dir.join(git::REPO_EXCLUDE), + file_path_str, + ) + .await + } + RepositoryState::Remote(_) => Err(anyhow::anyhow!( + "Cannot modify .git/info/exclude on remote repository" + )), + } + }, + ) + } + pub fn stash_drop( &mut self, index: Option, @@ -8974,6 +8989,33 @@ fn proto_to_commit_details(proto: &proto::GitCommitDetails) -> CommitDetails { } } +async fn append_pattern_to_ignore_file( + fs: Arc, + file_path: PathBuf, + pattern: String, +) -> Result<()> { + let existing_content = fs.load(&file_path).await.unwrap_or_default(); + + if existing_content.lines().any(|line| line.trim() == pattern) { + return Ok(()); + } + + let new_content = if existing_content.is_empty() { + format!("{}\n", pattern) + } else if existing_content.ends_with('\n') { + format!("{}{}\n", existing_content, pattern) + } else { + format!("{}\n{}\n", existing_content, pattern) + }; + + fs.save( + &file_path, + &text::Rope::from(new_content.as_str()), + text::LineEnding::Unix, + ) + .await +} + #[cfg(any(test, feature = "test-support"))] impl Repository { pub fn loaded_commit_data_for_test(&self) -> HashMap { @@ -8991,7 +9033,7 @@ impl Repository { mod tests { use super::*; use crate::Project; - use fs::FakeFs; + use fs::{FakeFs, Fs}; use git::repository::{RepoPath, repo_path}; use gpui::TestAppContext; use gpui::proptest::prelude::*; @@ -9084,6 +9126,47 @@ mod tests { }); } + #[gpui::test] + async fn test_append_pattern_to_ignore_file_creates_and_deduplicates(cx: &mut TestAppContext) { + let fs: Arc = FakeFs::new(cx.executor()); + let path = PathBuf::from("/root/.gitignore"); + + // Appending to a non-existent file creates it with a trailing newline. + super::append_pattern_to_ignore_file(fs.clone(), path.clone(), "build/".to_string()) + .await + .unwrap(); + assert_eq!(fs.load(&path).await.unwrap(), "build/\n"); + + // Appending the same pattern again is a no-op (deduplication). + super::append_pattern_to_ignore_file(fs.clone(), path.clone(), "build/".to_string()) + .await + .unwrap(); + assert_eq!(fs.load(&path).await.unwrap(), "build/\n"); + + // Appending a distinct pattern adds it with a trailing newline. + super::append_pattern_to_ignore_file(fs.clone(), path.clone(), "target/".to_string()) + .await + .unwrap(); + assert_eq!(fs.load(&path).await.unwrap(), "build/\ntarget/\n"); + } + + #[gpui::test] + async fn test_append_pattern_adds_newline_before_pattern_when_missing(cx: &mut TestAppContext) { + let fs: Arc = FakeFs::new(cx.executor()); + let path = PathBuf::from("/root/.gitignore"); + + // Pre-populate the file without a trailing newline. + fs.save(&path, &text::Rope::from("*.log"), text::LineEnding::Unix) + .await + .unwrap(); + + // The new pattern must be written on its own line. + super::append_pattern_to_ignore_file(fs.clone(), path.clone(), "build/".to_string()) + .await + .unwrap(); + assert_eq!(fs.load(&path).await.unwrap(), "*.log\nbuild/\n"); + } + #[test] fn test_new_worktree_path_uses_posix_style_for_remote_paths() { let work_dir = Path::new("/home/user/dev/lsp-tests"); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index eece41c9af..7d531c989b 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1188,6 +1188,10 @@ impl ProjectPanel { ) }) .action("Add to .gitignore", Box::new(git::AddToGitignore)) + .action( + "Add to .git/info/exclude", + Box::new(git::AddToGitInfoExclude), + ) .when(has_history, |menu| { menu.action("View History", Box::new(git::FileHistory)) }) @@ -2356,6 +2360,53 @@ impl ProjectPanel { }); } + fn add_to_git_info_exclude( + &mut self, + _: &git::AddToGitInfoExclude, + _window: &mut Window, + cx: &mut Context, + ) { + maybe!({ + let selection = self.selection?; + let (_, entry) = self.selected_sub_entry(cx)?; + let is_dir = entry.is_dir(); + let project = self.project.read(cx); + + let project_path = project.path_for_entry(selection.entry_id, cx)?; + + let git_store = project.git_store(); + let (repository, repo_path) = git_store + .read(cx) + .repository_and_path_for_project_path(&project_path, cx)?; + + let workspace = self.workspace.clone(); + let receiver = repository.update(cx, |repo, _| { + repo.add_path_to_git_info_exclude(&repo_path, is_dir) + }); + + cx.spawn(async move |_, cx| { + if let Err(e) = receiver.await? { + if let Some(workspace) = workspace.upgrade() { + cx.update(|cx| { + let message = format!("Failed to add to .git/info/exclude: {}", e); + let toast = StatusToast::new(message, cx, |this, _| { + this.icon(Icon::new(IconName::XCircle).color(Color::Error)) + .dismiss_button(true) + }); + workspace.update(cx, |workspace, cx| { + workspace.toggle_status_toast(toast, cx); + }); + }); + } + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + Some(()) + }); + } + fn remove( &mut self, trash: bool, @@ -6735,6 +6786,7 @@ impl Render for ProjectPanel { .on_action(cx.listener(Self::duplicate)) .on_action(cx.listener(Self::restore_file)) .on_action(cx.listener(Self::add_to_gitignore)) + .on_action(cx.listener(Self::add_to_git_info_exclude)) .when(!project.is_remote(), |el| { el.on_action(cx.listener(Self::trash)) })