feat(git): add ignore and untrack paths commands

The changes introduce server-side commands to manage gitignore
 rules and to untrack paths without deleting local files.
 A new GitIgnoreKind enum and helper functions normalize targets
 and build proper ignore patterns, and UI code was wired to use
 these commands.

- add_to_gitignore command and related helpers
- untrack_paths command to remove paths from the index
- UI wiring to expose ignore and untrack actions in explorer
This commit is contained in:
Christoph Brandau
2026-08-18 13:09:33 +02:00
parent a4936ba790
commit e4697c74b6
11 changed files with 608 additions and 53 deletions
+308
View File
@@ -4,6 +4,7 @@ use std::{
env,
ffi::{OsStr, OsString},
fs,
io::Write,
path::{Path, PathBuf},
process::{Command, Output, Stdio},
sync::{
@@ -32,6 +33,14 @@ pub enum FileStatusKind {
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GitIgnoreKind {
File,
Extension,
Folder,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitFileStatus {
pub path: String,
@@ -2029,6 +2038,30 @@ pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus
.map_err(|err| format!("Could not unstage files: {err}"))?
}
#[tauri::command]
pub async fn add_to_gitignore(
path: String,
target: String,
kind: GitIgnoreKind,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
add_to_gitignore_for_repo(&repo, &target, kind)
})
.await
.map_err(|err| format!("Could not update .gitignore: {err}"))?
}
#[tauri::command]
pub async fn untrack_paths(path: String, targets: Vec<String>) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
untrack_paths_for_repo(&repo, &targets)
})
.await
.map_err(|err| format!("Could not stop tracking paths: {err}"))?
}
#[tauri::command]
pub async fn restore_files(
path: String,
@@ -6308,6 +6341,164 @@ fn validate_files(files: &[String]) -> Result<(), String> {
Ok(())
}
fn normalize_gitignore_target(target: &str) -> Result<String, String> {
if target.is_empty() || target.trim().is_empty() {
return Err("Ignore target must not be empty.".to_string());
}
if target
.chars()
.any(|character| matches!(character, '\0' | '\r' | '\n'))
{
return Err("Ignore target contains an unsupported control character.".to_string());
}
let normalized = target.replace('\\', "/");
if normalized.starts_with('/') || normalized.as_bytes().get(1) == Some(&b':') {
return Err("Ignore target must be relative to the repository.".to_string());
}
let segments: Vec<&str> = normalized.trim_end_matches('/').split('/').collect();
if segments.is_empty()
|| segments
.iter()
.any(|segment| segment.is_empty() || *segment == "." || *segment == "..")
{
return Err("Ignore target must be a normalized repository path.".to_string());
}
if segments[0].eq_ignore_ascii_case(".git") {
return Err("The repository metadata directory cannot be ignored.".to_string());
}
Ok(segments.join("/"))
}
fn escape_gitignore_literal(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
if matches!(
character,
'\\' | '*' | '?' | '[' | ']' | '#' | '!' | ' ' | '\t'
) {
escaped.push('\\');
}
escaped.push(character);
}
escaped
}
fn gitignore_extension(target: &str) -> Option<&str> {
let name = target.rsplit('/').next()?;
let separator = name.rfind('.')?;
(separator > 0 && separator < name.len() - 1).then(|| &name[separator + 1..])
}
fn gitignore_pattern(target: &str, kind: GitIgnoreKind) -> Result<String, String> {
match kind {
GitIgnoreKind::File => Ok(format!("/{}", escape_gitignore_literal(target))),
GitIgnoreKind::Folder => Ok(format!("/{}/", escape_gitignore_literal(target))),
GitIgnoreKind::Extension => {
let extension = gitignore_extension(target)
.ok_or_else(|| "The selected file has no extension to ignore.".to_string())?;
Ok(format!("*.{}", escape_gitignore_literal(extension)))
}
}
}
fn gitignore_target_matches(kind: GitIgnoreKind, target: &str, candidate: &str) -> bool {
match kind {
GitIgnoreKind::File => candidate == target,
GitIgnoreKind::Folder => candidate
.strip_prefix(target)
.is_some_and(|remainder| remainder.starts_with('/')),
GitIgnoreKind::Extension => gitignore_extension(target)
.zip(gitignore_extension(candidate))
.is_some_and(|(selected, current)| selected == current),
}
}
fn append_gitignore_pattern(repo: &Path, pattern: &str) -> Result<bool, String> {
let gitignore = repo.join(".gitignore");
match fs::symlink_metadata(&gitignore) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err("Refusing to update a symlinked .gitignore file.".to_string());
}
Ok(metadata) if metadata.is_dir() => {
return Err(".gitignore is a directory, not a file.".to_string());
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("Could not inspect .gitignore: {error}")),
}
let existing = match fs::read(&gitignore) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(format!("Could not read .gitignore: {error}")),
};
let text = std::str::from_utf8(&existing)
.map_err(|_| ".gitignore is not valid UTF-8 and cannot be updated safely.".to_string())?;
if text
.lines()
.any(|line| line.trim_end_matches('\r') == pattern)
{
return Ok(false);
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&gitignore)
.map_err(|error| format!("Could not open .gitignore: {error}"))?;
if !existing.is_empty() && !existing.ends_with(b"\n") {
file.write_all(b"\n")
.map_err(|error| format!("Could not update .gitignore: {error}"))?;
}
file.write_all(pattern.as_bytes())
.and_then(|_| file.write_all(b"\n"))
.map_err(|error| format!("Could not update .gitignore: {error}"))?;
Ok(true)
}
fn add_to_gitignore_for_repo(
repo: &Path,
target: &str,
kind: GitIgnoreKind,
) -> Result<GitStatus, String> {
let target = normalize_gitignore_target(target)?;
let pattern = gitignore_pattern(&target, kind)?;
let status = status_for_repo(repo)?;
let staged_additions: Vec<String> = status
.files
.iter()
.filter(|file| {
file.staged == Some(FileStatusKind::Added)
&& file.old_path.is_none()
&& gitignore_target_matches(kind, &target, &file.path)
})
.map(|file| file.path.clone())
.collect();
append_gitignore_pattern(repo, &pattern)?;
if !staged_additions.is_empty() {
unstage_selected_files(repo, &status.files, &staged_additions)?;
}
status_for_repo(repo)
}
fn untrack_paths_for_repo(repo: &Path, targets: &[String]) -> Result<GitStatus, String> {
validate_files(targets)?;
if !targets.is_empty() {
// --cached keeps every working-tree file in place. Force only permits
// index removal when the staged/worktree content differs from HEAD.
run_git_with_paths(
repo,
&["rm", "-r", "--cached", "--force", "--ignore-unmatch"],
targets,
)?;
}
status_for_repo(repo)
}
fn write_temp_patch(patch: &str) -> Result<PathBuf, String> {
let counter = CANCELLABLE_GIT_OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
@@ -8970,4 +9161,121 @@ mod tests {
let duplicate = r#"{"summary":"Bad plan","groups":[{"message":"feat: one","reason":"","files":["src/app.ts"]},{"message":"test: two","reason":"","files":["src/app.ts"]}]}"#;
assert!(parse_ai_commit_plan(duplicate, &files).is_err());
}
#[test]
fn gitignore_file_rule_escapes_literal_path_characters() {
let repo = init_temp_repo("gitignore_literal_path");
fs::create_dir_all(repo.path.join("generated files"))
.expect("test directory should be created");
let target = "generated files/[draft]!.log";
fs::write(repo.path.join(target), "temporary\n").expect("test file should be written");
add_to_gitignore_for_repo(&repo.path, target, GitIgnoreKind::File)
.expect("file should be ignored");
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"/generated\\ files/\\[draft\\]\\!.log\n"
);
assert_eq!(
git_output_test(&repo.path, ["check-ignore", target]),
target
);
}
#[test]
fn gitignore_extension_rule_unstages_all_matching_new_files_without_duplicates() {
let repo = init_temp_repo("gitignore_extension");
fs::create_dir_all(repo.path.join("build")).expect("build directory should be created");
fs::write(repo.path.join("build/result.log"), "result\n")
.expect("nested log should be written");
fs::write(repo.path.join("debug.log"), "debug\n").expect("root log should be written");
run_git_test(&repo.path, ["add", "build/result.log", "debug.log"]);
let status =
add_to_gitignore_for_repo(&repo.path, "build/result.log", GitIgnoreKind::Extension)
.expect("extension should be ignored");
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"*.log\n"
);
assert!(status.files.iter().all(|file| !file.path.ends_with(".log")));
assert!(git_output_test(&repo.path, ["ls-files"]).is_empty());
assert_eq!(
git_output_test(
&repo.path,
["check-ignore", "build/result.log", "debug.log"]
),
"build/result.log\ndebug.log"
);
add_to_gitignore_for_repo(&repo.path, "build/result.log", GitIgnoreKind::Extension)
.expect("existing rule should be accepted");
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"*.log\n"
);
}
#[test]
fn gitignore_folder_rule_only_unstages_files_below_the_selected_folder() {
let repo = init_temp_repo("gitignore_folder");
fs::create_dir_all(repo.path.join("cache/nested"))
.expect("cache directory should be created");
fs::create_dir_all(repo.path.join("cache-old"))
.expect("similarly named directory should be created");
fs::write(repo.path.join("cache/nested/result.tmp"), "cached\n")
.expect("cached file should be written");
fs::write(repo.path.join("cache-old/keep.tmp"), "keep\n")
.expect("kept file should be written");
run_git_test(
&repo.path,
["add", "cache/nested/result.tmp", "cache-old/keep.tmp"],
);
add_to_gitignore_for_repo(&repo.path, "cache", GitIgnoreKind::Folder)
.expect("folder should be ignored");
assert_eq!(
fs::read_to_string(repo.path.join(".gitignore")).expect(".gitignore should exist"),
"/cache/\n"
);
assert_eq!(
git_output_test(&repo.path, ["ls-files"]),
"cache-old/keep.tmp"
);
assert_eq!(
git_output_test(&repo.path, ["check-ignore", "cache/nested/result.tmp"]),
"cache/nested/result.tmp"
);
}
#[test]
fn untrack_paths_keep_worktree_files_and_only_remove_the_selected_scope() {
let repo = init_temp_repo("untrack_paths");
fs::create_dir_all(repo.path.join("generated/nested"))
.expect("tracked directory should be created");
fs::write(repo.path.join("generated/nested/output.bin"), "original\n")
.expect("tracked file should be written");
fs::write(repo.path.join("keep.txt"), "keep\n").expect("kept file should be written");
run_git_test(&repo.path, ["add", "."]);
run_git_test(&repo.path, ["commit", "-m", "initial"]);
fs::write(
repo.path.join("generated/nested/output.bin"),
"local change\n",
)
.expect("tracked file should be modified");
let targets = vec!["generated".to_string()];
untrack_paths_for_repo(&repo.path, &targets).expect("folder should be untracked");
assert_eq!(
fs::read_to_string(repo.path.join("generated/nested/output.bin"))
.expect("working-tree file should remain"),
"local change\n"
);
assert_eq!(git_output_test(&repo.path, ["ls-files"]), "keep.txt");
assert!(repo.path.join("keep.txt").is_file());
}
}
+5 -3
View File
@@ -10,8 +10,8 @@ use external_tools::{
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
};
use git::{
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit,
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
@@ -30,7 +30,7 @@ use git::{
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
update_remote,
untrack_paths, update_remote,
};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@@ -254,6 +254,8 @@ async fn main() {
cherry_pick_abort,
stage_files,
unstage_files,
add_to_gitignore,
untrack_paths,
stash_push,
stash_apply,
stash_pop,