git_ui: Add Add to .git/info/exclude option to context menus (#57044)

This is an unsolicited contribution, I hope that's ok. The feature is
small and I didn't want to open a discussion just for this.

There was no way to write to `.git/info/exclude` from Zed, so I added
one.

The "Add to .gitignore" action in the project panel and git panel
context menus is now grouped under a "Git" submenu, with a new "Add to
.git/info/exclude" action next to it.

Both actions:

- skip the write if the pattern is already there
- make sure there's a trailing newline
- show an error toast if something goes wrong
- fail gracefully on remote repositories

In the git panel both entries are disabled unless the file is untracked,
same as the gitignore action was before.

The write logic is shared via a small private helper, covered by two
unit tests.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Added `Add to .git/info/exclude` option under a new `Git` submenu in
the project panel and git panel context menus, alongside the existing
`Add to .gitignore` action.

---------

Co-authored-by: Cole Miller <cole@zed.dev>
This commit is contained in:
Tom PlancheandCole Miller authored and GitHub committed 2026-06-08 19:37:22 +00:00
1 parent 5fa0f1787b
commit a80e7d2dc6
4 files changed
+206 -24

No files matched your search

+2
View File
@@ -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,
]
+45
View File
@@ -1480,6 +1480,44 @@ impl GitPanel {
});
}
fn add_to_git_info_exclude(
&mut self,
_: &git::AddToGitInfoExclude,
_window: &mut Window,
cx: &mut Context<Self>,
) {
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))
+107 -24
View File
@@ -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<Result<()>> {
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<usize>,
@@ -8974,6 +8989,33 @@ fn proto_to_commit_details(proto: &proto::GitCommitDetails) -> CommitDetails {
}
}
async fn append_pattern_to_ignore_file(
fs: Arc<dyn Fs>,
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<Oid, CommitData> {
@@ -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<dyn Fs> = 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<dyn Fs> = 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");
+52
View File
@@ -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<Self>,
) {
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))
})