From d9d1caa47675fd0a0c2bad7e3172e567c311a08a Mon Sep 17 00:00:00 2001 From: GuoYu Date: Fri, 30 Oct 2026 15:09:55 +0800 Subject: [PATCH] feat(vcs): add Add to .gitignore to the commit changes menu (#371) Unversioned files and folders in the Commit tool window's changes menu offer Add to .gitignore / Add to .git/info/exclude, the same action as the Project view's Git submenu. vcs_add_to_ignore returns the ignore files it changed and the IDE opens the nearest one after writing. A selected symlink or Windows junction is ignored by name without a trailing slash; symlinked ancestors are still refused, and already-ignored paths are left unchanged. Co-authored-by: guoyucode <29471644+guoyucode@users.noreply.github.com> --- crates/ide-host/src/ops/vcs/commands.rs | 5 ++- crates/ide-vcs/src/ignore.rs | 51 +++++++++++++++++++--- crates/ide-vcs/tests/ignore.rs | 45 +++++++++++++++++++ e2e/gitIgnore.spec.ts | 5 ++- src-tauri/src/commands/vcs.rs | 2 +- src/bindings.ts | 2 +- src/features/vcs/CommitView.tsx | 16 ++++++- src/features/vcs/__tests__/changes.test.ts | 31 +++++++++++++ src/features/vcs/__tests__/ignore.test.ts | 27 ++++++++++++ src/features/vcs/changes.ts | 22 ++++++++++ src/features/vcs/ignore.ts | 15 +++++-- src/platform/locales/text/zh.json | 1 + 12 files changed, 206 insertions(+), 16 deletions(-) diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 0c639cd1d..3255b2043 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -249,12 +249,13 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec) -> IdeRes } /// Project-host operation (also registered with the remote dispatcher). +/// Returns the ignore files that were updated, so the UI can show them. pub async fn vcs_add_to_ignore( host: &Host, window: &str, paths: Vec, destination: IgnoreDestination, -) -> IdeResult<()> { +) -> IdeResult> { let project = host.project(window)?; blocking(move || { for path in &paths { @@ -270,7 +271,7 @@ pub async fn vcs_add_to_ignore( watcher.reload_ignore_rules(); } project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() }); - result.map(|_| ()) + result.map(|files| files.into_iter().map(|p| p.to_string_lossy().into_owned()).collect()) }) .await } diff --git a/crates/ide-vcs/src/ignore.rs b/crates/ide-vcs/src/ignore.rs index 1634c27cf..fadb9cd3c 100644 --- a/crates/ide-vcs/src/ignore.rs +++ b/crates/ide-vcs/src/ignore.rs @@ -33,17 +33,20 @@ pub fn add_to_ignore(paths: &[PathBuf], destination: IgnoreDestination) -> IdeRe if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir)) { return Err(IdeError::invalid("Select an absolute path without '..'")); } - no_symlinks(path)?; let meta = fs::symlink_metadata(path)?; - if !meta.is_file() && !meta.is_dir() { + // A symlink or junction is one entry to Git: ignore its name, not its target, + // and never trail it with '/' (which matches directories only). + let link = is_link(&meta); + if !link && !meta.is_file() && !meta.is_dir() { return Err(IdeError::invalid("Only regular files and directories can be ignored")); } + let directory = !link && meta.is_dir(); // A selected nested repository directory is an entry of its enclosing repository. let start = path.parent().ok_or_else(|| IdeError::invalid("Cannot ignore a filesystem root"))?; let repo = Repo::discover(start)?.ok_or_else(|| IdeError::invalid("Selection is not in a Git repository"))?; let rel = path.strip_prefix(repo.root()).map_err(|_| IdeError::invalid("Selection is outside its repository"))?; - let literal = pattern(rel, meta.is_dir())?; + let literal = pattern(rel, directory)?; if rel.components().any(|c| c.as_os_str() == ".git") { return Err(IdeError::invalid("Git administration files cannot be ignored")); } @@ -60,10 +63,14 @@ pub fn add_to_ignore(paths: &[PathBuf], destination: IgnoreDestination) -> IdeRe let literal_query = format!("./{rel_str}"); let ignored = cli::run_raw(repo.root(), &["check-ignore", "-q", "--", &literal_query], None)?; match ignored.code { + // Already ignored: unchanged, and nothing needs to be written or opened. Some(0) => continue, Some(1) => {} _ => return Err(IdeError::invalid(ignored.stdout_str() + &String::from_utf8_lossy(&ignored.stderr))), } + // Only paths that are about to be written are held to the stricter rule: a + // symlinked ancestor can put the rule or the ignore file in another repository. + no_symlinked_ancestors(path)?; verify.push((repo.root().to_path_buf(), rel_str.to_owned())); let (file, rule, base) = match destination { IgnoreDestination::LocalExclude => { @@ -89,7 +96,7 @@ pub fn add_to_ignore(paths: &[PathBuf], destination: IgnoreDestination) -> IdeRe .take_while(|p| p.starts_with(repo.root())) .find(|p| p.join(".gitignore").symlink_metadata().is_ok()) .unwrap_or(repo.root()); - (base.join(".gitignore"), pattern(path.strip_prefix(base).unwrap(), meta.is_dir())?, base) + (base.join(".gitignore"), pattern(path.strip_prefix(base).unwrap(), directory)?, base) } }; no_symlinks(file.parent().unwrap())?; @@ -97,7 +104,7 @@ pub fn add_to_ignore(paths: &[PathBuf], destination: IgnoreDestination) -> IdeRe edits.entry(file).or_default().push(IgnoreRule { pattern: rule, relative: path.strip_prefix(base).unwrap().to_path_buf(), - directory: meta.is_dir(), + directory, }); } let mut changed = Vec::new(); @@ -153,6 +160,40 @@ fn pattern(path: &Path, directory: bool) -> IdeResult { Ok(rule) } +/// A symlink, or a Windows reparse point such as a junction, is a single entry for Git. +fn is_link(meta: &fs::Metadata) -> bool { + if meta.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + // Junctions and mount points are reparse points that `file_type` may report as directories. + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + } + #[cfg(not(windows))] + { + false + } +} + +/// The selected entry itself may be a link (its name is all that gets ignored); +/// an ancestor may not: the rule would describe a path Git does not see. +fn no_symlinked_ancestors(path: &Path) -> IdeResult<()> { + for part in path.ancestors().skip(1) { + match fs::symlink_metadata(part) { + Ok(m) if m.file_type().is_symlink() => { + return Err(IdeError::invalid(format!("Refusing symlink path: {}", part.display()))); + } + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + Ok(()) +} + /// Refuse symlinked ancestors too: writing through them could affect another repository. fn no_symlinks(path: &Path) -> IdeResult<()> { for part in path.ancestors() { diff --git a/crates/ide-vcs/tests/ignore.rs b/crates/ide-vcs/tests/ignore.rs index 1291974f4..dadf921d4 100644 --- a/crates/ide-vcs/tests/ignore.rs +++ b/crates/ide-vcs/tests/ignore.rs @@ -234,6 +234,51 @@ fn backslash_is_literal_newlines_and_symlinks_are_rejected() { assert_eq!(std::fs::read(fx.root.join(".gitignore")).unwrap(), original); } +#[cfg(unix)] +#[test] +fn symlink_leaf_is_ignored_by_name_without_a_directory_slash() { + use std::os::unix::fs::symlink; + let fx = Fixture::new(); + let target = fx.root.join("real"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("a.txt"), "x").unwrap(); + let link = fx.root.join("link-dir"); + symlink(&target, &link).unwrap(); + add_to_ignore(std::slice::from_ref(&link), Gitignore).unwrap(); + assert_eq!(std::fs::read_to_string(fx.root.join(".gitignore")).unwrap(), "/link-dir\n"); + fx.ignored("link-dir"); + // Already covered: nothing is written and no link is refused. + add_to_ignore(&[link], Gitignore).unwrap(); + assert_eq!(std::fs::read_to_string(fx.root.join(".gitignore")).unwrap(), "/link-dir\n"); +} + +#[cfg(windows)] +#[test] +fn junction_leaf_is_ignored_by_name_without_a_directory_slash() { + let fx = Fixture::new(); + let target = fx.root.join("real"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("a.txt"), "x").unwrap(); + let link = fx.root.join("_exp"); + let created = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&link) + .arg(&target) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + assert!(created.success(), "mklink /J failed"); + add_to_ignore(std::slice::from_ref(&link), Gitignore).unwrap(); + assert_eq!(std::fs::read_to_string(fx.root.join(".gitignore")).unwrap(), "/_exp\n"); + fx.ignored("_exp"); + // Already covered by a directory rule, as in project trees that ignore every `_*` directory. + std::fs::write(fx.root.join(".gitignore"), "_*/\n").unwrap(); + add_to_ignore(&[link], Gitignore).unwrap(); + assert_eq!(std::fs::read_to_string(fx.root.join(".gitignore")).unwrap(), "_*/\n"); + fx.ignored("_exp"); +} + #[test] fn overridden_local_rule_is_not_duplicated_and_explains_precedence() { let fx = Fixture::new(); diff --git a/e2e/gitIgnore.spec.ts b/e2e/gitIgnore.spec.ts index c6423aa58..ed45e61a5 100644 --- a/e2e/gitIgnore.spec.ts +++ b/e2e/gitIgnore.spec.ts @@ -20,7 +20,10 @@ for (const destination of ["gitignore", "localExclude"] as const) { throw new Error("Wrong ignore destination or paths"); written = true; fs.ignored.add(path); - return { status: "ok", data: null }; + return { + status: "ok", + data: [destination === "gitignore" ? `${root}/.gitignore` : `${root}/.git/info/exclude`], + }; }; commands.vcsStatus = async () => { document.body.dataset.ignoreStatusRefresh = written ? "after-write" : "before-write"; diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index d8a6084cd..091fa5fd8 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -115,7 +115,7 @@ pub async fn vcs_add_to_ignore( state: State<'_, AppState>, paths: Vec, destination: IgnoreDestination, -) -> IdeResult<()> { +) -> IdeResult> { ide_host::ops::vcs::vcs_add_to_ignore(&state.host, window.label(), paths, destination).await } diff --git a/src/bindings.ts b/src/bindings.ts index 5c2a2d97d..dd16653d7 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -421,7 +421,7 @@ export const commands = { vcsBranches: (repo: string | null) => typedError(__TAURI_INVOKE("vcs_branches", { repo })), vcsStashes: (repo: string | null) => typedError(__TAURI_INVOKE("vcs_stashes", { repo })), vcsStage: (paths: string[]) => typedError(__TAURI_INVOKE("vcs_stage", { paths })), - vcsAddToIgnore: (paths: string[], destination: IgnoreDestination) => typedError(__TAURI_INVOKE("vcs_add_to_ignore", { paths, destination })), + vcsAddToIgnore: (paths: string[], destination: IgnoreDestination) => typedError(__TAURI_INVOKE("vcs_add_to_ignore", { paths, destination })), vcsUnstage: (paths: string[]) => typedError(__TAURI_INVOKE("vcs_unstage", { paths })), vcsStagePatch: (patch: string) => typedError(__TAURI_INVOKE("vcs_stage_patch", { patch })), vcsDiscard: (paths: string[]) => typedError(__TAURI_INVOKE("vcs_discard", { paths })), diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index ccbe0c788..c650b44db 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -36,6 +36,7 @@ import { buildChangeTree, checkOf, filesOf, + ignoreTargets, isPartiallyStaged, planCommit, toggledCheck, @@ -194,11 +195,12 @@ function listMenu(id: string, active: boolean, paths: string[]): MenuItem[] { ]; } -function nodeMenu(nodes: ChangeNode[]): MenuItem[] { +function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuItem[] { const files = filesOf(nodes); const paths = files.map((f) => f.path); const first = nodes.find((n) => n.type === "file"); const untracked = files.filter((f) => f.unstaged === "untracked"); + const toIgnore = ignoreTargets(nodes, repos); const conflicted = files.filter((f) => f.unstaged === "conflicted"); const items: MenuItem[] = [ { kind: "action", action: "vcs.showDiff", arg: first?.path }, @@ -217,6 +219,16 @@ function nodeMenu(nodes: ChangeNode[]): MenuItem[] { ...(untracked.length ? [{ kind: "action" as const, action: "vcs.addToVcs", arg: untracked.map((f) => f.path) }] : []), + ...(toIgnore.length + ? [ + { + kind: "action" as const, + action: "vcs.ignoreShared", + arg: toIgnore, + label: "Add to .gitignore", + }, + ] + : []), { label: "Stage", icon: "plus", @@ -478,7 +490,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { return; } const nodes = [...sel].map((k) => byKey.get(k)).filter((n): n is ChangeNode => !!n); - showContextMenu(e, nodeMenu(nodes.length ? nodes : [node])); + showContextMenu(e, nodeMenu(nodes.length ? nodes : [node], repos)); }} aria-label="Changes" empty={ diff --git a/src/features/vcs/__tests__/changes.test.ts b/src/features/vcs/__tests__/changes.test.ts index 6503942b0..0b64b6147 100644 --- a/src/features/vcs/__tests__/changes.test.ts +++ b/src/features/vcs/__tests__/changes.test.ts @@ -4,6 +4,7 @@ import { aggregateCheck, buildChangeTree, commitRepos, + ignoreTargets, initialCheck, hunkIncluded, planCommit, @@ -115,6 +116,36 @@ describe("several repositories", () => { }); }); +describe("ignore targets", () => { + const group = (roots: ChangeNode[], id: string) => roots.find((n) => n.group === id)!; + + it("takes unversioned files only, not tracked changes", () => { + const tree = buildChangeTree(status, "/r", true); + expect(ignoreTargets([group(tree, "changes")], [])).toEqual([]); + expect(ignoreTargets([group(tree, "unversioned")], [])).toEqual(["/r/notes.txt"]); + }); + + it("uses one rule for a directory whose files are all unversioned", () => { + const tree = buildChangeTree( + [ + fs("build/out.log", null, "untracked"), + fs("build/tmp/a.tmp", null, "untracked"), + fs("notes.txt", null, "untracked"), + ], + "/r", + true, + ); + expect(ignoreTargets([group(tree, "unversioned")], [])).toEqual(["/r/build", "/r/notes.txt"]); + }); + + it("lists the files of every repository instead of a repository root", () => { + const nested = [...status, fs("libs/core/new.txt", null, "untracked")]; + const repos = ["/r", "/r/libs/core"]; + const tree = buildChangeTree(nested, "/r", false, undefined, repos); + expect(ignoreTargets([group(tree, "unversioned")], repos)).toEqual(["/r/notes.txt", "/r/libs/core/new.txt"]); + }); +}); + describe("commit inclusion", () => { it("defaults: tracked changes included, partial files keep their staged part, unversioned excluded", () => { expect(initialCheck(status[0]!)).toBe("all"); diff --git a/src/features/vcs/__tests__/ignore.test.ts b/src/features/vcs/__tests__/ignore.test.ts index 2b075ab84..1a73dee41 100644 --- a/src/features/vcs/__tests__/ignore.test.ts +++ b/src/features/vcs/__tests__/ignore.test.ts @@ -4,12 +4,18 @@ const mocks = vi.hoisted(() => ({ reload: vi.fn(), refresh: vi.fn(), failure: vi.fn(), + open: vi.fn(), + notify: vi.fn(), })); vi.mock("@/platform/ipc", () => ({ commands: { vcsAddToIgnore: mocks.write }, call: async (result: Promise) => result, })); vi.mock("@/features/project-tree/store", () => ({ reloadAll: mocks.reload })); +vi.mock("@/workbench/contributions", () => ({ + editorService: () => ({ openFile: mocks.open }), + notifications: () => ({ notify: mocks.notify }), +})); vi.mock("../store", () => ({ refreshNow: mocks.refresh })); vi.mock("../ops", () => ({ runOp: async (_title: string, run: () => Promise) => { @@ -48,4 +54,25 @@ describe("ignore action", () => { await addToIgnore([], "gitignore"); expect(mocks.write).not.toHaveBeenCalled(); }); + it("opens the .gitignore the rules were written to, which may be a nested one", async () => { + mocks.write.mockResolvedValue(["/p/pkg/.gitignore"]); + await addToIgnore(["/p/pkg/a.txt"], "gitignore"); + expect(mocks.open).toHaveBeenCalledWith("/p/pkg/.gitignore", { focus: true }); + }); + it("opens nothing for the local exclude file", async () => { + mocks.write.mockResolvedValue(["/p/.git/info/exclude"]); + await addToIgnore(["/p/a.txt"], "localExclude"); + expect(mocks.open).not.toHaveBeenCalled(); + }); + it("reports an already ignored selection instead of opening an unchanged file", async () => { + mocks.write.mockResolvedValue([]); + await addToIgnore(["/p/_exp"], "gitignore"); + expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Already ignored" })); + expect(mocks.open).not.toHaveBeenCalled(); + }); + it("stays quiet after a failure", async () => { + mocks.write.mockRejectedValue(new Error("Refusing symlink path")); + await addToIgnore(["/p/_exp"], "gitignore"); + expect(mocks.notify).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/vcs/changes.ts b/src/features/vcs/changes.ts index 9bf342bb7..73627c73c 100644 --- a/src/features/vcs/changes.ts +++ b/src/features/vcs/changes.ts @@ -355,6 +355,28 @@ export function toggledCheck(current: CheckState): CheckState { return current === "none" ? "all" : "none"; } +/** + * Paths of the selection to add to an ignore file: unversioned files, and a directory + * whose files are all unversioned (one rule for it instead of a rule per file). + */ +export function ignoreTargets(nodes: readonly ChangeNode[], repos: readonly string[]): string[] { + const roots = new Set(repos.map(stripSlash)); + const out = new Set(); + const add = (n: ChangeNode) => { + const untracked = n.files.filter((f) => f.unstaged === "untracked"); + if (!untracked.length) return; + // A repository node's path is the root itself, which cannot be ignored. + if (n.type === "dir" && n.path && n.files.length === untracked.length && !roots.has(stripSlash(n.path))) { + out.add(stripSlash(n.path)); + return; + } + if (n.children?.length) n.children.forEach(add); + else for (const f of untracked) out.add(stripSlash(f.path)); + }; + nodes.forEach(add); + return [...out]; +} + /** File paths (absolute) under the given tree nodes. */ export function filesOf(nodes: readonly ChangeNode[]): FileStatus[] { const seen = new Set(); diff --git a/src/features/vcs/ignore.ts b/src/features/vcs/ignore.ts index 7a86ce52e..54f420a9b 100644 --- a/src/features/vcs/ignore.ts +++ b/src/features/vcs/ignore.ts @@ -2,18 +2,25 @@ import type { IgnoreDestination } from "@/bindings"; import { call, commands } from "@/platform/ipc"; import { reloadAll } from "@/features/project-tree/store"; +import { editorService, notifications } from "@/workbench/contributions"; import { runOp } from "./ops"; import { refreshNow } from "./store"; export async function addToIgnore(paths: string[], destination: IgnoreDestination) { if (!paths.length) return; - await runOp( + const changed = await runOp( destination === "gitignore" ? "Add to .gitignore" : "Add to .git/info/exclude", - async () => { - await call(commands.vcsAddToIgnore(paths, destination)); - }, + () => call(commands.vcsAddToIgnore(paths, destination)), { refresh: false }, ); // Also refresh after a partial failure in a multi-repository selection. await Promise.all([refreshNow(), reloadAll()]); + if (changed && !changed.length) { + // Existing rules already cover the selection (e.g. a `_*/` directory pattern). + notifications().notify({ group: "vcs", level: "info", title: "Already ignored" }); + return; + } + // Show the rule where it was written: the nearest .gitignore may be a nested one. + const file = changed?.[0]; + if (destination === "gitignore" && file) await editorService().openFile(file, { focus: true }); } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index a2fef34ea..074f9e1af 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -937,6 +937,7 @@ "Installed {name} {version}": "已安装 {name} {version}", "Installing {name}": "正在安装 {name}", "IPC round trip": "IPC 往返", + "Already ignored": "已被忽略", "Idle seconds": "空闲秒数", "Ignore files and folders": "忽略文件和文件夹", "Ignore whitespace": "忽略空白",