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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user