дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
9d1fe17894
коміт
d9d1caa476
12 files changed
+206
-16
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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": "忽略空白",
|
||||
|
||||
Reference in new issue
Заблокувати користувача