use super::*; use std::collections::BTreeMap; #[cfg(unix)] use std::os::unix::ffi::OsStringExt; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum GitChangeKind { Added, Modified, Deleted, } impl GitChangeKind { pub(super) fn marker(self) -> &'static str { match self { Self::Added => "A", Self::Modified => "M", Self::Deleted => "D", } } } #[derive(Clone, Debug)] pub(super) struct GitFile { pub(super) path: PathBuf, pub(super) display_path: String, pub(super) staged_kind: Option, pub(super) worktree_kind: Option, } impl GitFile { pub(super) fn kind(&self) -> GitChangeKind { self.worktree_kind.or(self.staged_kind).unwrap() } } #[derive(Clone, Debug)] pub(super) struct GitWorktree { pub(super) root: PathBuf, pub(super) files: Vec, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum GitDiffLineKind { Section, Header, Hunk, Context, Addition, Deletion, } #[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct GitDiffLine { pub(super) kind: GitDiffLineKind, pub(super) old_number: Option, pub(super) new_number: Option, pub(super) text: String, } #[derive(Clone, Debug, Eq, PartialEq)] pub(super) enum GitDiffRow { Header(GitDiffLine), Pair { old: Option, new: Option, }, } #[derive(Clone, Debug)] pub(super) struct GitDiff { pub(super) path: String, pub(super) lines: Vec, } impl GitDiff { pub(super) fn split_rows(&self) -> Vec { let mut rows = Vec::new(); let mut index = 0; while index < self.lines.len() { let line = &self.lines[index]; match line.kind { GitDiffLineKind::Section | GitDiffLineKind::Header | GitDiffLineKind::Hunk => { rows.push(GitDiffRow::Header(line.clone())); index += 1; } GitDiffLineKind::Context => { rows.push(GitDiffRow::Pair { old: Some(line.clone()), new: Some(line.clone()), }); index += 1; } GitDiffLineKind::Deletion => { let deletion_start = index; while index < self.lines.len() && self.lines[index].kind == GitDiffLineKind::Deletion { index += 1; } let addition_start = index; while index < self.lines.len() && self.lines[index].kind == GitDiffLineKind::Addition { index += 1; } let deletions = &self.lines[deletion_start..addition_start]; let additions = &self.lines[addition_start..index]; for offset in 0..deletions.len().max(additions.len()) { rows.push(GitDiffRow::Pair { old: deletions.get(offset).cloned(), new: additions.get(offset).cloned(), }); } } GitDiffLineKind::Addition => { rows.push(GitDiffRow::Pair { old: None, new: Some(line.clone()), }); index += 1; } } } rows } } pub(super) struct ActiveGitOperation { pub(super) project_id: i32, pub(super) label: String, receiver: mpsc::Receiver>, clear_commit_on_success: bool, } impl App { pub(super) fn refresh_git_worktree(&mut self, project_id: i32) { let Some(path) = self .projects .iter() .find(|item| item.project.id == project_id) .map(|item| PathBuf::from(&item.project.path)) else { self.git_worktrees.remove(&project_id); return; }; match read_worktree(&path) { Ok(worktree) => { self.git_worktrees.insert(project_id, worktree); if self.git_selected_project == Some(project_id) { let files = &self.git_worktrees[&project_id].files; self.git_selected_files .retain(|path| files.iter().any(|file| file.path == *path)); } } Err(error) => { self.git_worktrees.remove(&project_id); if self.git_states.contains_key(&project_id) { self.error = Some(format!("Could not read Git status: {error}")); } } } if self.selected_project == Some(project_id) { self.last_git_scan = Instant::now(); } } pub(super) fn open_git_diff(&mut self, path: &Path) { let Some(project_id) = self.selected_project else { return; }; let Some(worktree) = self.git_worktrees.get(&project_id) else { return; }; let Some(file) = worktree.files.iter().find(|file| file.path == path) else { self.error = Some("The selected Git file is no longer available.".into()); return; }; match load_diff(&worktree.root, file, &self.config.git) { Ok(diff) => { self.git_diff = Some(diff); self.git_diff_layout = self.config.git.diff_layout; self.error = None; } Err(error) => self.error = Some(error), } } pub(super) fn toggle_git_file(&mut self, path: PathBuf) { let Some(project_id) = self.selected_project else { return; }; if !self .git_worktrees .get(&project_id) .is_some_and(|worktree| worktree.files.iter().any(|file| file.path == path)) { return; } if self.git_selected_project != Some(project_id) { self.git_selected_files.clear(); self.git_selected_project = Some(project_id); } if !self.git_selected_files.remove(&path) { self.git_selected_files.insert(path); } } pub(super) fn stage_selected_git_files(&mut self) { let Some((project_id, root, paths)) = self.git_selection(|file| file.worktree_kind.is_some()) else { self.error = Some("Select at least one file to stage.".into()); return; }; self.start_git_operation(project_id, "Staging files", false, move || { stage_paths(&root, &paths) }); } pub(super) fn unstage_selected_git_files(&mut self) { let Some((project_id, root, paths)) = self.git_selection(|file| file.staged_kind.is_some()) else { self.error = Some("Select at least one file to unstage.".into()); return; }; self.start_git_operation(project_id, "Unstaging files", false, move || { unstage_paths(&root, &paths) }); } pub(super) fn commit_git(&mut self) { let message = self.git_commit_message.trim().to_owned(); if message.is_empty() { self.error = Some("Enter a commit message.".into()); return; } let Some(project_id) = self.selected_project else { return; }; let Some(worktree) = self.git_worktrees.get(&project_id) else { self.error = Some("The selected project is not a Git worktree.".into()); return; }; if self.project_has_active_chat(project_id) { self.error = Some("Stop active chats before committing worktree changes.".into()); return; } let root = worktree.root.clone(); let selected = self.selected_git_paths(project_id); if !selected.is_empty() { self.start_git_operation(project_id, "Committing selected files", true, move || { stage_paths(&root, &selected)?; commit_paths(&root, &message, &selected) }); } else if worktree.files.iter().any(|file| file.staged_kind.is_some()) { self.start_git_operation(project_id, "Committing staged changes", true, move || { commit_index(&root, &message) }); } else if worktree.files.is_empty() { self.error = Some("There are no changes to commit.".into()); } else { self.git_commit_all_confirmation = true; } } pub(super) fn confirm_commit_all_git(&mut self) { let message = self.git_commit_message.trim().to_owned(); let Some(project_id) = self.selected_project else { return; }; let Some(worktree) = self.git_worktrees.get(&project_id) else { return; }; let root = worktree.root.clone(); self.git_commit_all_confirmation = false; self.start_git_operation(project_id, "Committing all changes", true, move || { stage_all(&root)?; commit_index(&root, &message) }); } pub(super) fn start_git_remote(&mut self, action: &'static str) { let Some(project_id) = self.selected_project else { return; }; let Some(worktree) = self.git_worktrees.get(&project_id) else { self.error = Some("The selected project is not a Git worktree.".into()); return; }; if action == "pull" && self.project_has_active_chat(project_id) { self.error = Some("Stop active chats before pulling worktree changes.".into()); return; } let root = worktree.root.clone(); let label = match action { "fetch" => "Fetching from origin", "pull" => "Pulling from origin", "push" => "Pushing to origin", _ => return, }; let branch = self .git_states .get(&project_id) .and_then(|state| state.current.clone()); if action != "fetch" && branch.is_none() { self.error = Some("Attach HEAD to a local branch before pulling or pushing.".into()); return; } self.start_git_operation(project_id, label, false, move || { run_git_remote(&root, action, branch.as_deref()) }); } pub(super) fn poll_git_operation(&mut self) { let result = self.git_operation .as_ref() .and_then(|operation| match operation.receiver.try_recv() { Ok(result) => Some(result), Err(TryRecvError::Empty) => None, Err(TryRecvError::Disconnected) => { Some(Err("The Git operation stopped unexpectedly.".into())) } }); let Some(result) = result else { return; }; let operation = self.git_operation.take().unwrap(); match result { Ok(()) => { if operation.clear_commit_on_success { self.git_commit_message.clear(); self.git_selected_files.clear(); } self.error = None; } Err(error) => self.error = Some(error), } self.refresh_git_worktree(operation.project_id); if self.selected_project == Some(operation.project_id) { self.refresh_git_state(); } } fn git_selection( &self, include: impl Fn(&GitFile) -> bool, ) -> Option<(i32, PathBuf, Vec)> { let project_id = self.selected_project?; let worktree = self.git_worktrees.get(&project_id)?; let paths = self.selected_git_paths_matching(project_id, include); (!paths.is_empty()).then(|| (project_id, worktree.root.clone(), paths)) } fn selected_git_paths(&self, project_id: i32) -> Vec { self.selected_git_paths_matching(project_id, |_| true) } fn selected_git_paths_matching( &self, project_id: i32, include: impl Fn(&GitFile) -> bool, ) -> Vec { if self.git_selected_project != Some(project_id) { return Vec::new(); } self.git_worktrees .get(&project_id) .map(|worktree| { worktree .files .iter() .filter(|file| self.git_selected_files.contains(&file.path) && include(file)) .map(|file| file.path.clone()) .collect() }) .unwrap_or_default() } fn start_git_operation( &mut self, project_id: i32, label: &str, clear_commit_on_success: bool, operation: impl FnOnce() -> Result<(), String> + Send + 'static, ) { if self.git_operation.is_some() { self.error = Some("Another Git operation is still running.".into()); return; } if self.project_has_active_chat(project_id) && !matches!(label, "Fetching from origin" | "Pushing to origin") { self.error = Some("Stop active chats before changing Git state.".into()); return; } let (sender, receiver) = mpsc::channel(); if let Err(error) = thread::Builder::new() .name("ds4-git".into()) .spawn(move || { let _ = sender.send(operation()); }) { self.error = Some(format!("Could not start the Git operation: {error}")); return; } self.git_operation = Some(ActiveGitOperation { project_id, label: label.into(), receiver, clear_commit_on_success, }); self.error = None; } } fn read_worktree(project_path: &Path) -> Result { let repository = git2::Repository::discover(project_path).map_err(|error| error.to_string())?; let root = repository .workdir() .ok_or_else(|| "bare repositories do not have a worktree".to_owned())? .to_path_buf(); let mut options = git2::StatusOptions::new(); options.include_untracked(true).recurse_untracked_dirs(true); let mut files = BTreeMap::::new(); let statuses = repository .statuses(Some(&mut options)) .map_err(|error| error.to_string())?; for entry in statuses.iter() { #[cfg(unix)] let path = PathBuf::from(std::ffi::OsString::from_vec(entry.path_bytes().to_vec())); #[cfg(not(unix))] let path = PathBuf::from(String::from_utf8_lossy(entry.path_bytes()).into_owned()); let display_path = path.to_string_lossy().into_owned(); let file = files.entry(path.clone()).or_insert_with(|| GitFile { path, display_path, staged_kind: None, worktree_kind: None, }); let status = entry.status(); if status.intersects(git2::Status::INDEX_NEW) { file.staged_kind = Some(GitChangeKind::Added); } else if status.intersects(git2::Status::INDEX_DELETED) { file.staged_kind = Some(GitChangeKind::Deleted); } else if status.intersects( git2::Status::INDEX_MODIFIED | git2::Status::INDEX_RENAMED | git2::Status::INDEX_TYPECHANGE, ) { file.staged_kind = Some(GitChangeKind::Modified); } if status.intersects(git2::Status::WT_NEW) { file.worktree_kind = Some(GitChangeKind::Added); } else if status.intersects(git2::Status::WT_DELETED) { file.worktree_kind = Some(GitChangeKind::Deleted); } else if status.intersects( git2::Status::WT_MODIFIED | git2::Status::WT_RENAMED | git2::Status::WT_TYPECHANGE, ) { file.worktree_kind = Some(GitChangeKind::Modified); } if status.intersects(git2::Status::CONFLICTED) { file.staged_kind = Some(GitChangeKind::Modified); file.worktree_kind = Some(GitChangeKind::Modified); } } files.retain(|_, file| file.staged_kind.is_some() || file.worktree_kind.is_some()); Ok(GitWorktree { root, files: files.into_values().collect(), }) } fn load_diff(root: &Path, file: &GitFile, settings: &GitConfig) -> Result { let repository = git2::Repository::discover(root).map_err(|error| error.to_string())?; let index = repository.index().map_err(|error| error.to_string())?; let mut lines = Vec::new(); if file.staged_kind.is_some() { let head_tree = repository .head() .ok() .and_then(|head| head.peel_to_tree().ok()); let mut options = diff_options(file, settings, false); let diff = repository .diff_tree_to_index(head_tree.as_ref(), Some(&index), Some(&mut options)) .map_err(|error| error.to_string())?; lines.push(diff_section("Staged changes")); lines.extend(render_diff(&diff)?); } if file.worktree_kind.is_some() { let mut options = diff_options(file, settings, true); let diff = repository .diff_index_to_workdir(Some(&index), Some(&mut options)) .map_err(|error| error.to_string())?; lines.push(diff_section("Worktree changes")); lines.extend(render_diff(&diff)?); } if lines.is_empty() { lines.push(diff_message("No textual diff is available.")); } Ok(GitDiff { path: file.display_path.clone(), lines, }) } fn diff_options(file: &GitFile, settings: &GitConfig, worktree: bool) -> git2::DiffOptions { let mut options = git2::DiffOptions::new(); options .pathspec(&file.path) .context_lines(settings.context_lines) .interhunk_lines(settings.interhunk_lines) .indent_heuristic(settings.indent_heuristic) .ignore_blank_lines(settings.ignore_blank_lines); match settings.diff_algorithm { GitDiffAlgorithm::Default => {} GitDiffAlgorithm::Patience => { options.patience(true); } GitDiffAlgorithm::Minimal => { options.minimal(true); } } match settings.whitespace { GitDiffWhitespace::ShowAll => {} GitDiffWhitespace::IgnoreAll => { options.ignore_whitespace(true); } GitDiffWhitespace::IgnoreChanges => { options.ignore_whitespace_change(true); } GitDiffWhitespace::IgnoreEndOfLine => { options.ignore_whitespace_eol(true); } } if worktree { options .include_untracked(true) .recurse_untracked_dirs(true) .show_untracked_content(true); } options } fn render_diff(diff: &git2::Diff<'_>) -> Result, String> { let mut output = Vec::new(); diff.print(git2::DiffFormat::Patch, |_, _, line| { if matches!( line.origin_value(), git2::DiffLineType::Context | git2::DiffLineType::Addition | git2::DiffLineType::Deletion ) { output.push(line.origin() as u8); } output.extend_from_slice(line.content()); true }) .map_err(|error| error.to_string())?; let lines = parse_unified_diff(&String::from_utf8_lossy(&output)); Ok(if lines.is_empty() { vec![diff_message("No textual diff is available.")] } else { lines }) } fn diff_section(text: &str) -> GitDiffLine { GitDiffLine { kind: GitDiffLineKind::Section, old_number: None, new_number: None, text: text.into(), } } fn diff_message(text: &str) -> GitDiffLine { GitDiffLine { kind: GitDiffLineKind::Header, old_number: None, new_number: None, text: text.into(), } } fn parse_unified_diff(diff: &str) -> Vec { let mut old_number = None; let mut new_number = None; diff.lines() .map(|line| { let (kind, old, new) = if line.starts_with("@@") { let mut parts = line.split_whitespace(); let _ = parts.next(); old_number = parts.next().and_then(diff_range_start); new_number = parts.next().and_then(diff_range_start); (GitDiffLineKind::Hunk, None, None) } else if line.starts_with("---") || line.starts_with("+++") { (GitDiffLineKind::Header, None, None) } else if line.starts_with('-') { let current = old_number; old_number = old_number.map(|number| number.saturating_add(1)); (GitDiffLineKind::Deletion, current, None) } else if line.starts_with('+') { let current = new_number; new_number = new_number.map(|number| number.saturating_add(1)); (GitDiffLineKind::Addition, None, current) } else if line.starts_with(' ') { let current_old = old_number; let current_new = new_number; old_number = old_number.map(|number| number.saturating_add(1)); new_number = new_number.map(|number| number.saturating_add(1)); (GitDiffLineKind::Context, current_old, current_new) } else { (GitDiffLineKind::Header, None, None) }; GitDiffLine { kind, old_number: old, new_number: new, text: line.to_owned(), } }) .collect() } fn diff_range_start(range: &str) -> Option { range .get(1..)? .split_once(',') .map_or(range.get(1..), |(start, _)| Some(start))? .parse() .ok() } fn stage_paths(root: &Path, paths: &[PathBuf]) -> Result<(), String> { let repository = git2::Repository::discover(root).map_err(git_error("stage files"))?; let mut index = repository .index() .map_err(git_error("open the Git index"))?; for path in paths { match root.join(path).symlink_metadata() { Ok(_) => index.add_path(path).map_err(git_error("stage files"))?, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { index.remove_path(path).map_err(git_error("stage files"))? } Err(error) => return Err(format!("Could not stage files: {error}")), } } index.write().map_err(git_error("write the Git index")) } fn stage_all(root: &Path) -> Result<(), String> { let repository = git2::Repository::discover(root).map_err(git_error("stage files"))?; let mut index = repository .index() .map_err(git_error("open the Git index"))?; index .add_all(["*"], git2::IndexAddOption::DEFAULT, None) .map_err(git_error("stage files"))?; index.write().map_err(git_error("write the Git index")) } fn unstage_paths(root: &Path, paths: &[PathBuf]) -> Result<(), String> { let repository = git2::Repository::discover(root).map_err(git_error("unstage files"))?; let head = repository .head() .ok() .and_then(|head| head.peel_to_commit().ok()); repository .reset_default(head.as_ref().map(|commit| commit.as_object()), paths.iter()) .map_err(git_error("unstage files")) } fn commit_paths(root: &Path, message: &str, paths: &[PathBuf]) -> Result<(), String> { let repository = git2::Repository::discover(root).map_err(git_error("commit selected files"))?; let index = repository .index() .map_err(git_error("open the Git index"))?; let mut commit_index = git2::Index::new().map_err(git_error("create a Git index"))?; if let Ok(head) = repository.head().and_then(|head| head.peel_to_commit()) { commit_index .read_tree(&head.tree().map_err(git_error("read the HEAD tree"))?) .map_err(git_error("read the HEAD tree"))?; } for path in paths { if let Some(entry) = index.get_path(path, 0) { commit_index .add(&entry) .map_err(git_error("prepare the commit"))?; } else { commit_index .remove_path(path) .map_err(git_error("prepare the commit"))?; } } let tree = commit_index .write_tree_to(&repository) .map_err(git_error("write the commit tree"))?; let commit = create_commit(&repository, message, tree)?; let commit = repository .find_object(commit, None) .map_err(git_error("read the new commit"))?; repository .reset_default(Some(&commit), paths.iter()) .map_err(git_error("refresh the Git index")) } fn commit_index(root: &Path, message: &str) -> Result<(), String> { let repository = git2::Repository::discover(root).map_err(git_error("commit changes"))?; let tree = repository .index() .and_then(|mut index| index.write_tree()) .map_err(git_error("write the commit tree"))?; create_commit(&repository, message, tree).map(|_| ()) } fn create_commit( repository: &git2::Repository, message: &str, tree: git2::Oid, ) -> Result { let signature = repository .signature() .map_err(git_error("read Git identity"))?; let tree = repository .find_tree(tree) .map_err(git_error("read the commit tree"))?; let parent = repository .head() .ok() .and_then(|head| head.peel_to_commit().ok()); let parents = parent.iter().collect::>(); repository .commit( Some("HEAD"), &signature, &signature, message, &tree, &parents, ) .map_err(git_error("commit changes")) } fn run_git_remote(root: &Path, action: &str, branch: Option<&str>) -> Result<(), String> { let repository = git2::Repository::discover(root).map_err(|error| format!("Could not {action}: {error}"))?; let mut remote = repository .find_remote("origin") .map_err(|error| format!("Could not {action}: {error}"))?; match action { "fetch" => { let mut options = git2::FetchOptions::new(); options.remote_callbacks(remote_callbacks(&repository)?); remote .fetch(&[] as &[&str], Some(&mut options), None) .map_err(git_error("fetch")) } "pull" => { let branch = branch.ok_or_else(|| "The current branch is unavailable.".to_owned())?; let mut options = git2::FetchOptions::new(); options.remote_callbacks(remote_callbacks(&repository)?); remote .fetch(&[branch], Some(&mut options), None) .map_err(git_error("pull"))?; merge_fetch_head(&repository, branch) } "push" => { let branch = branch.ok_or_else(|| "The current branch is unavailable.".to_owned())?; let refspec = format!("refs/heads/{branch}:refs/heads/{branch}"); let mut options = git2::PushOptions::new(); options.remote_callbacks(remote_callbacks(&repository)?); remote .push(&[&refspec], Some(&mut options)) .map_err(git_error("push")) } _ => Err("The Git remote action is unavailable.".into()), } } fn merge_fetch_head(repository: &git2::Repository, branch: &str) -> Result<(), String> { let fetch_head = repository .find_reference("FETCH_HEAD") .and_then(|reference| repository.reference_to_annotated_commit(&reference)) .map_err(git_error("read the fetched branch"))?; let (analysis, _) = repository .merge_analysis(&[&fetch_head]) .map_err(git_error("analyze the pull"))?; if analysis.is_up_to_date() { return Ok(()); } if analysis.is_fast_forward() || analysis.is_unborn() { let object = repository .find_object(fetch_head.id(), None) .map_err(git_error("read the fetched commit"))?; repository .checkout_tree(&object, Some(git2::build::CheckoutBuilder::new().safe())) .map_err(git_error("check out the fetched commit"))?; let reference = format!("refs/heads/{branch}"); repository .reference(&reference, fetch_head.id(), true, "pull: fast-forward") .and_then(|_| repository.set_head(&reference)) .map_err(git_error("fast-forward the current branch"))?; return Ok(()); } if !analysis.is_normal() { return Err("Could not pull: the fetched branch cannot be merged.".into()); } let local = repository .head() .and_then(|head| head.peel_to_commit()) .map_err(git_error("read the current commit"))?; repository .merge( &[&fetch_head], None, Some(git2::build::CheckoutBuilder::new().safe()), ) .map_err(git_error("merge the fetched branch"))?; let mut index = repository .index() .map_err(git_error("read the merge index"))?; if index.has_conflicts() { return Err("Could not pull: the merge has conflicts.".into()); } let tree = index .write_tree() .and_then(|id| repository.find_tree(id)) .map_err(git_error("write the merge tree"))?; let remote = repository .find_commit(fetch_head.id()) .map_err(git_error("read the fetched commit"))?; let signature = repository .signature() .map_err(git_error("read Git identity"))?; repository .commit( Some("HEAD"), &signature, &signature, &format!("Merge remote-tracking branch 'origin/{branch}'"), &tree, &[&local, &remote], ) .map_err(git_error("commit the merge"))?; repository .cleanup_state() .map_err(git_error("finish the pull")) } fn remote_callbacks( repository: &git2::Repository, ) -> Result, String> { let config = repository .config() .map_err(git_error("read Git configuration"))?; let mut callbacks = git2::RemoteCallbacks::new(); callbacks.credentials(move |url, username, allowed| { if allowed.contains(git2::CredentialType::SSH_KEY) { return git2::Cred::ssh_key_from_agent(username.unwrap_or("git")); } if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) { return git2::Cred::credential_helper(&config, url, username); } if allowed.contains(git2::CredentialType::USERNAME) { return git2::Cred::username(username.unwrap_or("git")); } if allowed.contains(git2::CredentialType::DEFAULT) { return git2::Cred::default(); } Err(git2::Error::from_str( "no supported Git credentials are available", )) }); Ok(callbacks) } fn git_error(action: &'static str) -> impl FnOnce(git2::Error) -> String { move |error| format!("Could not {action}: {error}") } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); #[test] fn libgit2_status_merges_staged_and_worktree_changes() { let directory = repository_fixture(); std::fs::write(directory.join("added.txt"), "added\n").unwrap(); git2::Repository::open(&directory) .unwrap() .config() .unwrap() .set_bool("status.showUntrackedFiles", false) .unwrap(); std::fs::write(directory.join("changed.txt"), "changed\n").unwrap(); stage_paths(&directory, &["changed.txt".into()]).unwrap(); std::fs::write(directory.join("changed.txt"), "changed again\n").unwrap(); std::fs::remove_file(directory.join("deleted.txt")).unwrap(); let worktree = read_worktree(&directory).unwrap(); let added = worktree .files .iter() .find(|file| file.display_path == "added.txt") .unwrap(); assert_eq!(added.worktree_kind, Some(GitChangeKind::Added)); let changed = worktree .files .iter() .find(|file| file.display_path == "changed.txt") .unwrap(); assert_eq!(changed.staged_kind, Some(GitChangeKind::Modified)); assert_eq!(changed.worktree_kind, Some(GitChangeKind::Modified)); let diff = load_diff(&worktree.root, changed, &GitConfig::default()).unwrap(); assert_eq!( diff.lines .iter() .filter(|line| line.kind == GitDiffLineKind::Section) .count(), 2 ); assert!( diff.lines .iter() .any(|line| line.kind == GitDiffLineKind::Addition) ); assert!( diff.lines .iter() .any(|line| line.kind == GitDiffLineKind::Deletion) ); let deleted = worktree .files .iter() .find(|file| file.display_path == "deleted.txt") .unwrap(); assert_eq!(deleted.worktree_kind, Some(GitChangeKind::Deleted)); let added_path = PathBuf::from("added.txt"); stage_paths(&directory, std::slice::from_ref(&added_path)).unwrap(); let worktree = read_worktree(&directory).unwrap(); let added = worktree .files .iter() .find(|file| file.path == added_path) .unwrap(); assert_eq!(added.staged_kind, Some(GitChangeKind::Added)); unstage_paths(&directory, std::slice::from_ref(&added_path)).unwrap(); let worktree = read_worktree(&directory).unwrap(); let added = worktree .files .iter() .find(|file| file.path == added_path) .unwrap(); assert_eq!(added.staged_kind, None); assert_eq!(added.worktree_kind, Some(GitChangeKind::Added)); stage_paths(&directory, std::slice::from_ref(&added_path)).unwrap(); commit_paths( &directory, "Commit selected file", std::slice::from_ref(&added_path), ) .unwrap(); let worktree = read_worktree(&directory).unwrap(); assert!(!worktree.files.iter().any(|file| file.path == added_path)); assert_eq!( worktree .files .iter() .find(|file| file.display_path == "changed.txt") .unwrap() .staged_kind, Some(GitChangeKind::Modified) ); std::fs::remove_dir_all(directory).unwrap(); } #[test] fn unstage_works_before_the_first_commit() { let directory = empty_repository_fixture(); std::fs::write(directory.join("new.txt"), "new\n").unwrap(); let path = PathBuf::from("new.txt"); stage_paths(&directory, std::slice::from_ref(&path)).unwrap(); unstage_paths(&directory, std::slice::from_ref(&path)).unwrap(); let worktree = read_worktree(&directory).unwrap(); let file = worktree .files .iter() .find(|file| file.path == path) .unwrap(); assert_eq!(file.staged_kind, None); assert_eq!(file.worktree_kind, Some(GitChangeKind::Added)); std::fs::remove_dir_all(directory).unwrap(); } #[test] fn origin_fetch_pull_and_push_use_the_current_branch() { let local = repository_fixture(); let remote = local.with_extension("remote.git"); let collaborator = local.with_extension("collaborator"); git2::Repository::init_bare(&remote).unwrap(); git2::Repository::open(&local) .unwrap() .remote("origin", remote.to_str().unwrap()) .unwrap(); run_git_remote(&local, "push", Some("main")).unwrap(); git2::Repository::open_bare(&remote) .unwrap() .set_head("refs/heads/main") .unwrap(); let cloned = git2::build::RepoBuilder::new() .clone(remote.to_str().unwrap(), &collaborator) .unwrap(); configure_test_repository(&cloned); drop(cloned); std::fs::write(collaborator.join("remote.txt"), "from origin\n").unwrap(); stage_paths(&collaborator, &["remote.txt".into()]).unwrap(); commit_index(&collaborator, "Remote change").unwrap(); run_git_remote(&collaborator, "push", Some("main")).unwrap(); run_git_remote(&local, "fetch", None).unwrap(); run_git_remote(&local, "pull", Some("main")).unwrap(); assert_eq!( std::fs::read_to_string(local.join("remote.txt")).unwrap(), "from origin\n" ); std::fs::write(local.join("local.txt"), "to origin\n").unwrap(); stage_paths(&local, &["local.txt".into()]).unwrap(); commit_index(&local, "Local change").unwrap(); run_git_remote(&local, "push", Some("main")).unwrap(); let content = { let bare = git2::Repository::open_bare(&remote).unwrap(); let commit = bare .find_branch("main", git2::BranchType::Local) .unwrap() .get() .peel_to_commit() .unwrap(); let tree = commit.tree().unwrap(); let entry = tree.get_path(Path::new("local.txt")).unwrap(); bare.find_blob(entry.id()).unwrap().content().to_vec() }; assert_eq!(content, b"to origin\n"); std::fs::remove_dir_all(local).unwrap(); std::fs::remove_dir_all(remote).unwrap(); std::fs::remove_dir_all(collaborator).unwrap(); } #[test] fn unified_diff_parser_aligns_replacements_for_split_view() { let lines = parse_unified_diff( "diff --git a/file b/file\n--- a/file\n+++ b/file\n@@ -2,2 +2,2 @@\n same\n-old\n+new\n", ); assert_eq!(lines[4].old_number, Some(2)); assert_eq!(lines[4].new_number, Some(2)); assert_eq!(lines[5].old_number, Some(3)); assert_eq!(lines[6].new_number, Some(3)); let rows = GitDiff { path: "file".into(), lines, } .split_rows(); assert!(rows.iter().any(|row| matches!( row, GitDiffRow::Pair { old: Some(old), new: Some(new), } if old.kind == GitDiffLineKind::Deletion && new.kind == GitDiffLineKind::Addition ))); } #[test] fn configured_git_diff_options_change_the_rendered_patch() { let directory = empty_repository_fixture(); std::fs::write( directory.join("changed.txt"), "first\nsecond\nthird\nfourth\nfifth\n", ) .unwrap(); stage_paths(&directory, &["changed.txt".into()]).unwrap(); commit_index(&directory, "Initial").unwrap(); std::fs::write( directory.join("changed.txt"), "first\nsecond changed\nthird\nfourth changed\nfifth\n", ) .unwrap(); let worktree = read_worktree(&directory).unwrap(); let changed = worktree .files .iter() .find(|file| file.display_path == "changed.txt") .unwrap(); let settings = GitConfig { diff_layout: GitDiffLayout::Unified, diff_algorithm: GitDiffAlgorithm::Patience, context_lines: 0, interhunk_lines: 0, indent_heuristic: true, whitespace: GitDiffWhitespace::ShowAll, ignore_blank_lines: false, }; let diff = load_diff(&worktree.root, changed, &settings).unwrap(); assert_eq!( diff.lines .iter() .filter(|line| line.kind == GitDiffLineKind::Context) .count(), 0 ); assert_eq!( diff.lines .iter() .filter(|line| line.kind == GitDiffLineKind::Hunk) .count(), 2 ); std::fs::remove_dir_all(directory).unwrap(); } fn repository_fixture() -> PathBuf { let directory = empty_repository_fixture(); std::fs::write(directory.join("changed.txt"), "original\n").unwrap(); std::fs::write(directory.join("deleted.txt"), "original\n").unwrap(); stage_paths(&directory, &["changed.txt".into(), "deleted.txt".into()]).unwrap(); commit_index(&directory, "Initial").unwrap(); directory } fn empty_repository_fixture() -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let directory = std::env::temp_dir().join(format!( "ds4-server-git-pane-{}-{nonce}-{}", std::process::id(), NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) )); std::fs::create_dir(&directory).unwrap(); let mut options = git2::RepositoryInitOptions::new(); options.initial_head("main"); let repository = git2::Repository::init_opts(&directory, &options).unwrap(); configure_test_repository(&repository); directory } fn configure_test_repository(repository: &git2::Repository) { let mut config = repository.config().unwrap(); config.set_str("user.name", "DS4Server Test").unwrap(); config .set_str("user.email", "test@ds4server.invalid") .unwrap(); } }