diff --git a/Cargo.lock b/Cargo.lock index aaac98c..103d770 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1166,6 +1166,7 @@ dependencies = [ "cc", "diesel", "diesel_migrations", + "git2", "gix", "iced", "image", @@ -1761,6 +1762,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.13.1", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + [[package]] name = "gix" version = "0.86.0" @@ -3474,6 +3490,20 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libgit2-sys" +version = "0.18.7+1.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -3512,6 +3542,20 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libssh2-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + [[package]] name = "libxdo" version = "0.6.0" @@ -3537,6 +3581,18 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "lilt" version = "0.8.1" @@ -4208,6 +4264,34 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 86ab345..e497ed3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ cc = "1.3.0" diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "64-column-tables"] } diesel_migrations = "2.3.2" gix = { version = "0.86.0", default-features = false, features = ["max-performance-safe", "sha1", "status"] } +git2 = { version = "0.20.4", features = ["vendored-libgit2", "vendored-openssl"] } iced = { version = "0.14.0", features = ["advanced", "highlighter", "image-without-codecs", "markdown", "svg", "tokio"] } image = { version = "0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] } memmap2 = "0.9.11" diff --git a/src/app/git.rs b/src/app/git.rs index fd9b389..9a885eb 100644 --- a/src/app/git.rs +++ b/src/app/git.rs @@ -1,6 +1,5 @@ use super::*; use std::collections::BTreeMap; -use std::process::{Command, Output}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum GitChangeKind { @@ -255,12 +254,12 @@ impl App { let selected = self.selected_git_paths(project_id); if !selected.is_empty() { self.start_git_operation(project_id, "Committing selected files", true, move || { - run_git_paths(&root, &["add", "--"], &selected)?; - run_git_commit_paths(&root, &message, &selected) + 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 || { - run_git_args(&root, &["commit", "-m", &message]).map(|_| ()) + commit_index(&root, &message) }); } else if worktree.files.is_empty() { self.error = Some("There are no changes to commit.".into()); @@ -280,8 +279,8 @@ impl App { let root = worktree.root.clone(); self.git_commit_all_confirmation = false; self.start_git_operation(project_id, "Committing all changes", true, move || { - run_git_args(&root, &["add", "--all"])?; - run_git_args(&root, &["commit", "-m", &message]).map(|_| ()) + stage_all(&root)?; + commit_index(&root, &message) }); } @@ -667,105 +666,253 @@ fn diff_range_start(range: &str) -> Option { .ok() } -fn git_has_head(root: &Path) -> bool { - Command::new("git") - .arg("-C") - .arg(root) - .args(["rev-parse", "--verify", "HEAD"]) - .output() - .is_ok_and(|output| output.status.success()) +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_paths(root: &Path, paths: &[PathBuf]) -> Result<(), String> { - run_git_paths(root, &["add", "--"], paths).map(|_| ()) +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> { - if git_has_head(root) { - run_git_paths(root, &["restore", "--staged", "--"], paths)?; - } else { - run_git_paths( - root, - &["rm", "--cached", "--quiet", "--ignore-unmatch", "--"], - paths, - )?; + 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"))?; } - Ok(()) + 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 run_git_commit_paths(root: &Path, message: &str, paths: &[PathBuf]) -> Result<(), String> { - let mut command = git_command(root); - command.args(["commit", "-m", message, "--"]); - command.args(paths); - finish_git(command.output(), "commit selected files").map(|_| ()) +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 run_git_paths(root: &Path, arguments: &[&str], paths: &[PathBuf]) -> Result { - let mut command = git_command(root); - command.args(arguments); - command.args(paths); - finish_git( - command.output(), - arguments.first().copied().unwrap_or("run Git"), - ) -} - -fn run_git_args(root: &Path, arguments: &[&str]) -> Result { - let mut command = git_command(root); - command.args(arguments); - finish_git( - command.output(), - arguments.first().copied().unwrap_or("run Git"), - ) +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 mut command = git_command(root); + 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" => { - command.args(["fetch", "origin"]); + 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" => { - command.args([ - "pull", - "--no-rebase", - "origin", - branch.ok_or_else(|| "The current branch is unavailable.".to_owned())?, - ]); + 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" => { - command.args([ - "push", - "origin", - &format!( - "HEAD:{}", - branch.ok_or_else(|| "The current branch is unavailable.".to_owned())? - ), - ]); + 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")) } - _ => return Err("The Git remote action is unavailable.".into()), + _ => Err("The Git remote action is unavailable.".into()), } - finish_git(command.output(), action).map(|_| ()) } -fn git_command(root: &Path) -> Command { - let mut command = Command::new("git"); - command.arg("-C").arg(root).env("GIT_TERMINAL_PROMPT", "0"); - command +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 finish_git(output: std::io::Result, action: &str) -> Result { - let output = output.map_err(|error| format!("Could not {action}: {error}"))?; - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } else { - let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); - Err(if detail.is_empty() { - format!("Could not {action}.") - } else { - format!("Could not {action}: {detail}") - }) - } +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)] @@ -780,9 +927,14 @@ mod tests { fn gitoxide_status_merges_staged_and_worktree_changes() { let directory = repository_fixture(); std::fs::write(directory.join("added.txt"), "added\n").unwrap(); - run_git_args(&directory, &["config", "status.showUntrackedFiles", "no"]).unwrap(); + git2::Repository::open(&directory) + .unwrap() + .config() + .unwrap() + .set_bool("status.showUntrackedFiles", false) + .unwrap(); std::fs::write(directory.join("changed.txt"), "changed\n").unwrap(); - run_git_args(&directory, &["add", "changed.txt"]).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(); @@ -846,7 +998,7 @@ mod tests { assert_eq!(added.worktree_kind, Some(GitChangeKind::Added)); stage_paths(&directory, std::slice::from_ref(&added_path)).unwrap(); - run_git_commit_paths( + commit_paths( &directory, "Commit selected file", std::slice::from_ref(&added_path), @@ -892,36 +1044,25 @@ mod tests { let local = repository_fixture(); let remote = local.with_extension("remote.git"); let collaborator = local.with_extension("collaborator"); - let output = Command::new("git") - .args(["init", "--bare"]) - .arg(&remote) - .output() + git2::Repository::init_bare(&remote).unwrap(); + git2::Repository::open(&local) + .unwrap() + .remote("origin", remote.to_str().unwrap()) .unwrap(); - assert!(output.status.success()); - run_git_args( - &local, - &["remote", "add", "origin", remote.to_str().unwrap()], - ) - .unwrap(); run_git_remote(&local, "push", Some("main")).unwrap(); - run_git_args(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]).unwrap(); - - let output = Command::new("git") - .arg("clone") - .arg(&remote) - .arg(&collaborator) - .output() + git2::Repository::open_bare(&remote) + .unwrap() + .set_head("refs/heads/main") .unwrap(); - assert!(output.status.success()); - run_git_args(&collaborator, &["config", "user.name", "DS4Server Test"]).unwrap(); - run_git_args( - &collaborator, - &["config", "user.email", "test@ds4server.invalid"], - ) - .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(); - run_git_args(&collaborator, &["add", "remote.txt"]).unwrap(); - run_git_args(&collaborator, &["commit", "-m", "Remote change"]).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(); @@ -931,15 +1072,22 @@ mod tests { "from origin\n" ); std::fs::write(local.join("local.txt"), "to origin\n").unwrap(); - run_git_args(&local, &["add", "local.txt"]).unwrap(); - run_git_args(&local, &["commit", "-m", "Local change"]).unwrap(); + stage_paths(&local, &["local.txt".into()]).unwrap(); + commit_index(&local, "Local change").unwrap(); run_git_remote(&local, "push", Some("main")).unwrap(); - assert_eq!( - run_git_args(&remote, &["show", "main:local.txt"]) + let content = { + let bare = git2::Repository::open_bare(&remote).unwrap(); + let commit = bare + .find_branch("main", git2::BranchType::Local) .unwrap() - .trim(), - "to origin" - ); + .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(); @@ -974,8 +1122,8 @@ mod tests { 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(); - run_git_args(&directory, &["add", "changed.txt", "deleted.txt"]).unwrap(); - run_git_args(&directory, &["commit", "-m", "Initial"]).unwrap(); + stage_paths(&directory, &["changed.txt".into(), "deleted.txt".into()]).unwrap(); + commit_index(&directory, "Initial").unwrap(); directory } @@ -990,13 +1138,18 @@ mod tests { NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) )); std::fs::create_dir(&directory).unwrap(); - run_git_args(&directory, &["init", "-b", "main"]).unwrap(); - run_git_args(&directory, &["config", "user.name", "DS4Server Test"]).unwrap(); - run_git_args( - &directory, - &["config", "user.email", "test@ds4server.invalid"], - ) - .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(); + } } diff --git a/src/app/projects.rs b/src/app/projects.rs index f83892d..00cf799 100644 --- a/src/app/projects.rs +++ b/src/app/projects.rs @@ -368,48 +368,31 @@ impl App { } fn read_git_state(path: &Path) -> Option { - let path = path.to_str()?; - let branches = std::process::Command::new("git") - .args([ - "-C", - path, - "for-each-ref", - "--format=%(refname:short)", - "refs/heads", - ]) - .output() + let repository = gix::discover(path).ok()?; + let mut branches = repository + .references() + .ok()? + .local_branches() + .ok()? + .map(|reference| { + reference + .map(|reference| String::from_utf8_lossy(reference.name().shorten()).into_owned()) + }) + .collect::, _>>() .ok()?; - if !branches.status.success() { - return None; - } - let mut branches = String::from_utf8_lossy(&branches.stdout) - .lines() - .map(str::trim) - .filter(|branch| !branch.is_empty()) - .map(str::to_owned) - .collect::>(); branches.sort(); branches.dedup(); - let current = std::process::Command::new("git") - .args(["-C", path, "symbolic-ref", "--quiet", "--short", "HEAD"]) - .output() - .ok() - .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned()) - .filter(|branch| !branch.is_empty()); + let current = repository + .head_name() + .ok()? + .map(|name| String::from_utf8_lossy(name.shorten()).into_owned()); let label = current.clone().unwrap_or_else(|| { - std::process::Command::new("git") - .args(["-C", path, "rev-parse", "--short", "HEAD"]) - .output() + repository + .head_id() .ok() - .filter(|output| output.status.success()) - .map(|output| { - format!( - "detached @ {}", - String::from_utf8_lossy(&output.stdout).trim() - ) - }) + .and_then(|id| id.shorten().ok()) + .map(|id| format!("detached @ {id}")) .unwrap_or_else(|| "No branch".to_owned()) }); Some(GitState { @@ -420,22 +403,25 @@ fn read_git_state(path: &Path) -> Option { } fn git_switch(path: &Path, branch: &str) -> Result<(), String> { - let output = std::process::Command::new("git") - .arg("-C") - .arg(path) - .args(["switch", "--", branch]) - .output() - .map_err(|error| format!("Could not run Git: {error}"))?; - if output.status.success() { - Ok(()) - } else { - let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); - Err(if detail.is_empty() { - "Could not switch Git branches.".into() - } else { - format!("Could not switch Git branches: {detail}") - }) + let repository = git2::Repository::discover(path) + .map_err(|error| format!("Could not switch Git branches: {error}"))?; + let reference = format!("refs/heads/{branch}"); + let previous = repository + .find_reference("HEAD") + .ok() + .map(|head| (head.symbolic_target().map(str::to_owned), head.target())); + repository + .set_head(&reference) + .map_err(|error| format!("Could not switch Git branches: {error}"))?; + if let Err(error) = repository.checkout_head(Some(git2::build::CheckoutBuilder::new().safe())) { + if let Some((Some(reference), _)) = &previous { + let _ = repository.set_head(reference); + } else if let Some((_, Some(target))) = previous { + let _ = repository.set_head_detached(target); + } + return Err(format!("Could not switch Git branches: {error}")); } + Ok(()) } pub(super) fn draft_title(projects: &[ProjectWithSessions], project_id: i32) -> String { @@ -450,7 +436,6 @@ pub(super) fn draft_title(projects: &[ProjectWithSessions], project_id: i32) -> mod tests { use super::*; use crate::database::{Project, Session}; - use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; #[test] @@ -541,37 +526,30 @@ mod tests { std::process::id() )); std::fs::create_dir(&directory).unwrap(); - for arguments in [ - &["init", "-b", "main"][..], - &["config", "user.name", "DS4Server Test"], - &["config", "user.email", "test@ds4server.invalid"], - ] { - assert!( - Command::new("git") - .arg("-C") - .arg(&directory) - .args(arguments) - .status() - .unwrap() - .success() - ); - } + let mut options = git2::RepositoryInitOptions::new(); + options.initial_head("main"); + let repository = git2::Repository::init_opts(&directory, &options).unwrap(); + let mut config = repository.config().unwrap(); + config.set_str("user.name", "DS4Server Test").unwrap(); + config + .set_str("user.email", "test@ds4server.invalid") + .unwrap(); + drop(config); std::fs::write(directory.join("tracked.txt"), "test").unwrap(); - for arguments in [ - &["add", "tracked.txt"][..], - &["commit", "-m", "Initial"], - &["branch", "feature"], - ] { - assert!( - Command::new("git") - .arg("-C") - .arg(&directory) - .args(arguments) - .status() - .unwrap() - .success() - ); - } + let mut index = repository.index().unwrap(); + index.add_path(Path::new("tracked.txt")).unwrap(); + let tree_id = index.write_tree().unwrap(); + index.write().unwrap(); + let tree = repository.find_tree(tree_id).unwrap(); + let signature = repository.signature().unwrap(); + let commit = repository + .commit(Some("HEAD"), &signature, &signature, "Initial", &tree, &[]) + .unwrap(); + let commit = repository.find_commit(commit).unwrap(); + repository.branch("feature", &commit, false).unwrap(); + drop(commit); + drop(tree); + drop(repository); let state = read_git_state(&directory).unwrap(); assert_eq!(state.current.as_deref(), Some("main")); diff --git a/src/dev_brain.rs b/src/dev_brain.rs index 64fc09a..752a0f1 100644 --- a/src/dev_brain.rs +++ b/src/dev_brain.rs @@ -7,7 +7,6 @@ use sha2::{Digest, Sha256}; use std::collections::{BTreeSet, HashMap, HashSet}; use std::fs; use std::path::{Component, Path, PathBuf}; -use std::process::Command; use std::sync::RwLock; use std::time::{SystemTime, UNIX_EPOCH}; use time::OffsetDateTime; @@ -1113,35 +1112,34 @@ fn git_revision_matches(root: &Path, revision: &str, current: &str) -> bool { { return false; } - Command::new("git") - .args(["-C"]) - .arg(root) - .args(["rev-parse", "--verify"]) - .arg(format!("{revision}^{{commit}}")) - .output() + git2::Repository::discover(root) .ok() - .filter(|output| output.status.success()) - .is_some_and(|output| String::from_utf8_lossy(&output.stdout).trim() == current) + .is_some_and(|repository| { + repository + .revparse_single(revision) + .ok() + .and_then(|object| object.peel_to_commit().ok()) + .is_some_and(|commit| commit.id().to_string() == current) + }) } fn git_state(root: &Path) -> Option<(String, bool)> { - let revision = Command::new("git") - .args(["-C"]) - .arg(root) - .args(["rev-parse", "HEAD"]) - .output() - .ok() - .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())?; - let status = Command::new("git") - .args(["-C"]) - .arg(root) - .args(["status", "--porcelain"]) - .output() - .ok()?; + let repository = git2::Repository::discover(root).ok()?; + let revision = repository + .head() + .ok()? + .peel_to_commit() + .ok()? + .id() + .to_string(); + let mut options = git2::StatusOptions::new(); + options + .include_untracked(true) + .recurse_untracked_dirs(true) + .include_ignored(false); Some(( revision, - status.status.success() && status.stdout.is_empty(), + repository.statuses(Some(&mut options)).ok()?.is_empty(), )) } @@ -1586,29 +1584,18 @@ mod tests { #[test] fn clean_full_and_short_git_revisions_become_stale_when_the_worktree_changes() { let fixture = Fixture::new(); - for arguments in [ - vec!["init"], - vec!["add", "source.rs"], - vec![ - "-c", - "user.name=DS4Server", - "-c", - "user.email=ds4@example.invalid", - "commit", - "-m", - "Initial", - ], - ] { - assert!( - Command::new("git") - .arg("-C") - .arg(&fixture.project) - .args(arguments) - .status() - .unwrap() - .success() - ); - } + let repository = git2::Repository::init(&fixture.project).unwrap(); + let mut index = repository.index().unwrap(); + index.add_path(Path::new("source.rs")).unwrap(); + let tree_id = index.write_tree().unwrap(); + index.write().unwrap(); + let tree = repository.find_tree(tree_id).unwrap(); + let signature = git2::Signature::now("DS4Server", "ds4@example.invalid").unwrap(); + repository + .commit(Some("HEAD"), &signature, &signature, "Initial", &tree, &[]) + .unwrap(); + drop(tree); + drop(repository); let revision = git_state(&fixture.project).unwrap().0; assert!(git_revision_matches( &fixture.project,