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 <[email protected]>
Цей коміт міститься в:
GuoYuтаguoyucode authored and GitHub committed 2026-10-30 15:09:55 +08:00
1 джерело 9d1fe17894
коміт d9d1caa476
12 files changed
+206 -16

No files matched your search

+3 -2
Переглянути файл
@@ -249,12 +249,13 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> 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<PathBuf>,
destination: IgnoreDestination,
) -> IdeResult<()> {
) -> IdeResult<Vec<String>> {
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
}
+46 -5
Переглянути файл
@@ -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<String> {
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() {
+45
Переглянути файл
@@ -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();
+4 -1
Переглянути файл
@@ -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";
+1 -1
Переглянути файл
@@ -115,7 +115,7 @@ pub async fn vcs_add_to_ignore(
state: State<'_, AppState>,
paths: Vec<PathBuf>,
destination: IgnoreDestination,
) -> IdeResult<()> {
) -> IdeResult<Vec<String>> {
ide_host::ops::vcs::vcs_add_to_ignore(&state.host, window.label(), paths, destination).await
}
+1 -1
Переглянути файл
@@ -421,7 +421,7 @@ export const commands = {
vcsBranches: (repo: string | null) => typedError<Branch[], IdeError>(__TAURI_INVOKE("vcs_branches", { repo })),
vcsStashes: (repo: string | null) => typedError<Stash[], IdeError>(__TAURI_INVOKE("vcs_stashes", { repo })),
vcsStage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage", { paths })),
vcsAddToIgnore: (paths: string[], destination: IgnoreDestination) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_add_to_ignore", { paths, destination })),
vcsAddToIgnore: (paths: string[], destination: IgnoreDestination) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_add_to_ignore", { paths, destination })),
vcsUnstage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_unstage", { paths })),
vcsStagePatch: (patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage_patch", { patch })),
vcsDiscard: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_discard", { paths })),
+14 -2
Переглянути файл
@@ -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={
+31
Переглянути файл
@@ -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");
+27
Переглянути файл
@@ -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<unknown>) => 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<unknown>) => {
@@ -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();
});
});
+22
Переглянути файл
@@ -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<string>();
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<string>();
+11 -4
Переглянути файл
@@ -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 });
}
+1
Переглянути файл
@@ -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": "忽略空白",