Move a bunch of git ops to use the git binary (#302)

This commit is contained in:
Gregory Schier
2025-11-17 15:22:39 -08:00
committed by GitHub
parent 84219571e8
commit 9c52652a5e
43 changed files with 1238 additions and 1176 deletions

View File

@@ -0,0 +1,16 @@
use crate::error::Result;
use crate::repository::open_repo;
use git2::IndexAddOption;
use log::info;
use std::path::Path;
pub(crate) fn git_add(dir: &Path, rela_path: &Path) -> Result<()> {
let repo = open_repo(dir)?;
let mut index = repo.index()?;
info!("Staging file {rela_path:?} to {dir:?}");
index.add_all(&[rela_path], IndexAddOption::DEFAULT, None)?;
index.write()?;
Ok(())
}

View File

@@ -0,0 +1,16 @@
use crate::error::Error::GitNotFound;
use crate::error::Result;
use std::path::Path;
use std::process::Command;
pub(crate) fn new_binary_command(dir: &Path) -> Result<Command> {
let status = Command::new("git").arg("--version").status();
if let Err(_) = status {
return Err(GitNotFound);
}
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir);
Ok(cmd)
}

View File

@@ -2,26 +2,12 @@ use crate::error::Error::GenericError;
use crate::error::Result;
use crate::merge::do_merge;
use crate::repository::open_repo;
use crate::util::{
bytes_to_string, get_branch_by_name, get_current_branch, get_default_remote_for_push_in_repo,
};
use crate::util::{bytes_to_string, get_branch_by_name, get_current_branch};
use git2::BranchType;
use git2::build::CheckoutBuilder;
use git2::{BranchType, Repository};
use log::info;
use std::path::Path;
pub(crate) fn branch_set_upstream_after_push(repo: &Repository, branch_name: &str) -> Result<()> {
let mut branch = repo.find_branch(branch_name, BranchType::Local)?;
if branch.upstream().is_err() {
let remote = get_default_remote_for_push_in_repo(repo)?;
let upstream_name = format!("{remote}/{branch_name}");
branch.set_upstream(Some(upstream_name.as_str()))?;
}
Ok(())
}
pub(crate) fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
if branch_name.starts_with("origin/") {
return git_checkout_remote_branch(dir, branch_name, force);
@@ -43,7 +29,11 @@ pub(crate) fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) ->
Ok(branch_name.to_string())
}
pub(crate) fn git_checkout_remote_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
pub(crate) fn git_checkout_remote_branch(
dir: &Path,
branch_name: &str,
force: bool,
) -> Result<String> {
let branch_name = branch_name.trim_start_matches("origin/");
let repo = open_repo(dir)?;
@@ -55,7 +45,7 @@ pub(crate) fn git_checkout_remote_branch(dir: &Path, branch_name: &str, force: b
let upstream_name = format!("origin/{}", branch_name);
new_branch.set_upstream(Some(&upstream_name))?;
return git_checkout_branch(dir, branch_name, force)
git_checkout_branch(dir, branch_name, force)
}
pub(crate) fn git_create_branch(dir: &Path, name: &str) -> Result<()> {

View File

@@ -1,76 +0,0 @@
use git2::{Cred, RemoteCallbacks};
use log::{debug, info};
use crate::util::find_ssh_key;
pub(crate) fn default_callbacks<'s>() -> RemoteCallbacks<'s> {
let mut callbacks = RemoteCallbacks::new();
let mut fail_next_call = false;
let mut tried_agent = false;
callbacks.credentials(move |url, username_from_url, allowed_types| {
if fail_next_call {
info!("Failed to get credentials for push");
return Err(git2::Error::from_str("Bad credentials."));
}
debug!("getting credentials {url} {username_from_url:?} {allowed_types:?}");
match (allowed_types.is_ssh_key(), username_from_url) {
(true, Some(username)) => {
if !tried_agent {
tried_agent = true;
return Cred::ssh_key_from_agent(username);
}
fail_next_call = true; // This is our last try
// If the agent failed, try using the default SSH key
if let Some(key) = find_ssh_key() {
Cred::ssh_key(username, None, key.as_path(), None)
} else {
Err(git2::Error::from_str(
"Bad credentials. Ensure your key was added using ssh-add",
))
}
}
(true, None) => Err(git2::Error::from_str("Couldn't get username from url")),
_ => {
return Err(git2::Error::from_str("https remotes are not (yet) supported"));
}
}
});
callbacks.push_transfer_progress(|current, total, bytes| {
debug!("progress: {}/{} ({} B)", current, total, bytes,);
});
callbacks.transfer_progress(|p| {
debug!("transfer: {}/{}", p.received_objects(), p.total_objects());
true
});
callbacks.pack_progress(|stage, current, total| {
debug!("packing: {:?} - {}/{}", stage, current, total);
});
callbacks.push_update_reference(|reference, msg| {
debug!("push_update_reference: '{}' {:?}", reference, msg);
Ok(())
});
callbacks.update_tips(|name, a, b| {
debug!("update tips: '{}' {} -> {}", name, a, b);
if a != b {
// let mut push_result = push_result.lock().unwrap();
// *push_result = PushResult::Success
}
true
});
callbacks.sideband_progress(|data| {
debug!("sideband transfer: '{}'", String::from_utf8_lossy(data).trim());
true
});
callbacks
}

View File

@@ -1,13 +1,19 @@
use crate::add::git_add;
use crate::branch::{git_checkout_branch, git_create_branch, git_delete_branch, git_merge_branch};
use crate::commit::git_commit;
use crate::credential::git_add_credential;
use crate::error::Result;
use crate::fetch::git_fetch_all;
use crate::git::{
git_add, git_commit, git_init, git_log, git_status, git_unstage, GitCommit, GitStatusSummary,
};
use crate::pull::{git_pull, PullResult};
use crate::push::{git_push, PushResult};
use crate::init::git_init;
use crate::log::{GitCommit, git_log};
use crate::pull::{PullResult, git_pull};
use crate::push::{PushResult, git_push};
use crate::remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};
use crate::status::{GitStatusSummary, git_status};
use crate::unstage::git_unstage;
use std::path::{Path, PathBuf};
use tauri::command;
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
#[command]
@@ -80,3 +86,28 @@ pub async fn unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
}
Ok(())
}
#[command]
pub async fn add_credential(
dir: &Path,
remote_url: &str,
username: &str,
password: &str,
) -> Result<()> {
git_add_credential(dir, remote_url, username, password).await
}
#[command]
pub async fn remotes(dir: &Path) -> Result<Vec<GitRemote>> {
git_remotes(dir)
}
#[command]
pub async fn add_remote(dir: &Path, name: &str, url: &str) -> Result<GitRemote> {
git_add_remote(dir, name, url)
}
#[command]
pub async fn rm_remote(dir: &Path, name: &str) -> Result<()> {
git_rm_remote(dir, name)
}

View File

@@ -0,0 +1,20 @@
use crate::binary::new_binary_command;
use crate::error::Error::GenericError;
use log::info;
use std::path::Path;
pub(crate) fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
let out = new_binary_command(dir)?.args(["commit", "--message", message]).output()?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = stdout + stderr;
if !out.status.success() {
return Err(GenericError(format!("Failed to commit: {}", combined)));
}
info!("Committed to {dir:?}");
Ok(())
}

View File

@@ -0,0 +1,47 @@
use crate::binary::new_binary_command;
use crate::error::Error::GenericError;
use crate::error::Result;
use std::io::Write;
use std::path::Path;
use std::process::Stdio;
use tauri::Url;
pub(crate) async fn git_add_credential(
dir: &Path,
remote_url: &str,
username: &str,
password: &str,
) -> Result<()> {
let url = Url::parse(remote_url)
.map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?;
let protocol = url.scheme();
let host = url.host_str().unwrap();
let path = Some(url.path());
let mut child = new_binary_command(dir)?
.args(["credential", "approve"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()?;
{
let stdin = child.stdin.as_mut().unwrap();
writeln!(stdin, "protocol={}", protocol)?;
writeln!(stdin, "host={}", host)?;
if let Some(path) = path {
if !path.is_empty() {
writeln!(stdin, "path={}", path.trim_start_matches('/'))?;
}
}
writeln!(stdin, "username={}", username)?;
writeln!(stdin, "password={}", password)?;
writeln!(stdin)?; // blank line terminator
}
let status = child.wait()?;
if !status.success() {
return Err(GenericError("Failed to approve git credential".to_string()));
}
Ok(())
}

View File

@@ -33,9 +33,18 @@ pub enum Error {
#[error("Git error: {0}")]
GenericError(String),
#[error("'git' not found. Please ensure it's installed and available in $PATH")]
GitNotFound,
#[error("Credentials required: {0}")]
CredentialsRequiredError(String),
#[error("No default remote found")]
NoDefaultRemoteFound,
#[error("No remotes found for repo")]
NoRemotesFound,
#[error("Merge failed due to conflicts")]
MergeConflicts,

View File

@@ -1,37 +1,20 @@
use crate::callbacks::default_callbacks;
use crate::binary::new_binary_command;
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::repository::open_repo;
use git2::{FetchOptions, ProxyOptions, Repository};
use std::path::Path;
pub(crate) fn git_fetch_all(dir: &Path) -> Result<()> {
let repo = open_repo(dir)?;
let remotes = repo.remotes()?.iter().flatten().map(String::from).collect::<Vec<_>>();
let out = new_binary_command(dir)?
.args(["fetch", "--all", "--prune", "--tags"])
.output()
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = stdout + stderr;
for (_idx, remote) in remotes.into_iter().enumerate() {
fetch_from_remote(&repo, &remote)?;
if !out.status.success() {
return Err(GenericError(format!("Failed to fetch: {}", combined)));
}
Ok(())
}
fn fetch_from_remote(repo: &Repository, remote: &str) -> Result<()> {
let mut remote = repo.find_remote(remote)?;
let mut options = FetchOptions::new();
let callbacks = default_callbacks();
options.prune(git2::FetchPrune::On);
let mut proxy = ProxyOptions::new();
proxy.auto();
options.proxy_options(proxy);
options.download_tags(git2::AutotagOption::All);
options.remote_callbacks(callbacks);
remote.fetch(&[] as &[&str], Some(&mut options), None)?;
// fetch tags (also removing remotely deleted ones)
remote.fetch(&["refs/tags/*:refs/tags/*"], Some(&mut options), None)?;
Ok(())
}

View File

@@ -1,673 +0,0 @@
use crate::error::Result;
use crate::repository::open_repo;
use crate::util::{local_branch_names, remote_branch_names};
use chrono::{DateTime, Utc};
use git2::IndexAddOption;
use log::{info, warn};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use ts_rs::TS;
use yaak_sync::models::SyncModel;
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitStatusSummary {
pub path: String,
pub head_ref: Option<String>,
pub head_ref_shorthand: Option<String>,
pub entries: Vec<GitStatusEntry>,
pub origins: Vec<String>,
pub local_branches: Vec<String>,
pub remote_branches: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitStatusEntry {
pub rela_path: String,
pub status: GitStatus,
pub staged: bool,
pub prev: Option<SyncModel>,
pub next: Option<SyncModel>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_git.ts")]
pub enum GitStatus {
Untracked,
Conflict,
Current,
Modified,
Removed,
Renamed,
TypeChange,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitCommit {
author: GitAuthor,
when: DateTime<Utc>,
message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitAuthor {
name: Option<String>,
email: Option<String>,
}
pub fn git_init(dir: &Path) -> Result<()> {
git2::Repository::init(dir)?;
let repo = open_repo(dir)?;
// Default to main instead of master, to align with
// the official Git and GitHub behavior
repo.set_head("refs/heads/main")?;
info!("Initialized {dir:?}");
Ok(())
}
pub fn git_add(dir: &Path, rela_path: &Path) -> Result<()> {
let repo = open_repo(dir)?;
let mut index = repo.index()?;
info!("Staging file {rela_path:?} to {dir:?}");
index.add_all(&[rela_path], IndexAddOption::DEFAULT, None)?;
index.write()?;
Ok(())
}
pub fn git_unstage(dir: &Path, rela_path: &Path) -> Result<()> {
let repo = open_repo(dir)?;
let head = match repo.head() {
Ok(h) => h,
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
info!("Unstaging file in empty branch {rela_path:?} to {dir:?}");
// Repo has no commits, so "unstage" means remove from index
let mut index = repo.index()?;
index.remove_path(rela_path)?;
index.write()?;
return Ok(());
}
Err(e) => return Err(e.into()),
};
// If repo has commits, update the index entry back to HEAD
info!("Unstaging file {rela_path:?} to {dir:?}");
let commit = head.peel_to_commit()?;
repo.reset_default(Some(commit.as_object()), &[rela_path])?;
Ok(())
}
pub fn git_commit(dir: &Path, message: &str) -> Result<()> {
let repo = open_repo(dir)?;
// Clear the in-memory index, add the paths, and write the tree for committing
let tree_oid = repo.index()?.write_tree()?;
let tree = repo.find_tree(tree_oid)?;
// Make the signature
let config = repo.config()?.snapshot()?;
let name = config.get_str("user.name").unwrap_or("Unknown");
let email = config.get_str("user.email")?;
let sig = git2::Signature::now(name, email)?;
// Get the current HEAD commit (if it exists)
let parent_commit = match repo.head() {
Ok(head) => Some(head.peel_to_commit()?),
Err(_) => None, // No parent if no HEAD exists (initial commit)
};
let parents = parent_commit.as_ref().map(|p| vec![p]).unwrap_or_default();
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, parents.as_slice())?;
info!("Committed to {dir:?}");
Ok(())
}
pub fn git_log(dir: &Path) -> Result<Vec<GitCommit>> {
let repo = open_repo(dir)?;
// Return empty if empty repo or no head (new repo)
if repo.is_empty()? || repo.head().is_err() {
return Ok(vec![]);
}
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(git2::Sort::TIME)?;
// Run git log
macro_rules! filter_try {
($e:expr) => {
match $e {
Ok(t) => t,
Err(_) => return None,
}
};
}
let log: Vec<GitCommit> = revwalk
.filter_map(|oid| {
let oid = filter_try!(oid);
let commit = filter_try!(repo.find_commit(oid));
let author = commit.author();
Some(GitCommit {
author: GitAuthor {
name: author.name().map(|s| s.to_string()),
email: author.email().map(|s| s.to_string()),
},
when: convert_git_time_to_date(author.when()),
message: commit.message().map(|m| m.to_string()),
})
})
.collect();
Ok(log)
}
pub fn git_status(dir: &Path) -> Result<GitStatusSummary> {
let repo = open_repo(dir)?;
let (head_tree, head_ref, head_ref_shorthand) = match repo.head() {
Ok(head) => {
let tree = head.peel_to_tree().ok();
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
let head_ref = head.name().map(|s| s.to_string());
(tree, head_ref, head_ref_shorthand)
}
Err(_) => {
// For "unborn" repos, reading from HEAD is the only way to get the branch name
// See https://github.com/starship/starship/pull/1336
let head_path = repo.path().join("HEAD");
let head_ref = fs::read_to_string(&head_path)
.ok()
.unwrap_or_default()
.lines()
.next()
.map(|s| s.trim_start_matches("ref:").trim().to_string());
let head_ref_shorthand =
head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
(None, head_ref, head_ref_shorthand)
}
};
let mut opts = git2::StatusOptions::new();
opts.include_ignored(false)
.include_untracked(true) // Include untracked
.recurse_untracked_dirs(true) // Show all untracked
.include_unmodified(true); // Include unchanged
// TODO: Support renames
let mut entries: Vec<GitStatusEntry> = Vec::new();
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
let rela_path = entry.path().unwrap().to_string();
let status = entry.status();
let index_status = match status {
// Note: order matters here, since we're checking a bitmap!
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
s => {
warn!("Unknown index status {s:?}");
continue;
}
};
let worktree_status = match status {
// Note: order matters here, since we're checking a bitmap!
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
s => {
warn!("Unknown worktree status {s:?}");
continue;
}
};
let status = if index_status == GitStatus::Current {
worktree_status.clone()
} else {
index_status.clone()
};
let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current
{
// No change, so can't be added
false
} else if index_status != GitStatus::Current {
true
} else {
false
};
// Get previous content from Git, if it's in there
let prev = match head_tree.clone() {
None => None,
Some(t) => match t.get_path(&Path::new(&rela_path)) {
Ok(entry) => {
let obj = entry.to_object(&repo)?;
let content = obj.as_blob().unwrap().content();
let name = Path::new(entry.name().unwrap_or_default());
SyncModel::from_bytes(content.into(), name)?.map(|m| m.0)
}
Err(_) => None,
},
};
let next = {
let full_path = repo.workdir().unwrap().join(rela_path.clone());
SyncModel::from_file(full_path.as_path())?.map(|m| m.0)
};
entries.push(GitStatusEntry {
status,
staged,
rela_path,
prev: prev.clone(),
next: next.clone(),
})
}
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
let local_branches = local_branch_names(&repo)?;
let remote_branches = remote_branch_names(&repo)?;
Ok(GitStatusSummary {
entries,
origins,
path: dir.to_string_lossy().to_string(),
head_ref,
head_ref_shorthand,
local_branches,
remote_branches,
})
}
#[cfg(test)]
fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime<Utc> {
DateTime::from_timestamp(0, 0).unwrap()
}
#[cfg(not(test))]
fn convert_git_time_to_date(git_time: git2::Time) -> DateTime<Utc> {
let timestamp = git_time.seconds();
DateTime::from_timestamp(timestamp, 0).unwrap()
}
// // Write a test
// #[cfg(test)]
// mod test {
// use crate::error::Error::GitRepoNotFound;
// use crate::error::Result;
// use crate::git::{
// git_add, git_commit, git_init, git_log, git_status, git_unstage, open_repo, GitStatus,
// GitStatusEntry,
// };
// use std::fs::{create_dir_all, remove_file, File};
// use std::io::Write;
// use std::path::{Path, PathBuf};
// use tempdir::TempDir;
//
// fn new_dir() -> PathBuf {
// let p = TempDir::new("yaak-git").unwrap().into_path();
// p
// }
//
// fn new_file(path: &Path, content: &str) {
// let parent = path.parent().unwrap();
// create_dir_all(parent).unwrap();
// File::create(path).unwrap().write_all(content.as_bytes()).unwrap();
// }
//
// #[tokio::test]
// async fn test_status_no_repo() {
// let dir = &new_dir();
// let result = git_status(dir).await;
// assert!(matches!(result, Err(GitRepoNotFound(_))));
// }
//
// #[test]
// fn test_open_repo() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
// open_repo(dir.as_path())?;
// Ok(())
// }
//
// #[test]
// fn test_open_repo_from_subdir() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// let sub_dir = dir.join("a").join("b");
// create_dir_all(sub_dir.as_path())?; // Create sub dir
//
// open_repo(sub_dir.as_path())?;
// Ok(())
// }
//
// #[tokio::test]
// async fn test_status() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// assert_eq!(git_status(dir).await?.entries, Vec::new());
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
// new_file(&dir.join("dir/baz.txt"), "baz");
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "dir/baz.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("baz".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("foo".to_string()),
// },
// ],
// );
// Ok(())
// }
//
// #[tokio::test]
// fn test_add() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
//
// git_add(dir, Path::new("foo.txt"))?;
//
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: true,
// prev: None,
// next: Some("foo".to_string()),
// },
// ],
// );
//
// new_file(&dir.join("foo.txt"), "foo foo");
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: true,
// prev: None,
// next: Some("foo foo".to_string()),
// },
// ],
// );
// Ok(())
// }
//
// #[tokio::test]
// fn test_unstage() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
//
// git_add(dir, Path::new("foo.txt"))?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: true,
// prev: None,
// next: Some("foo".to_string()),
// },
// ]
// );
//
// git_unstage(dir, Path::new("foo.txt"))?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("foo".to_string()),
// }
// ]
// );
//
// Ok(())
// }
//
// #[tokio::test]
// fn test_commit() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
//
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("foo".to_string()),
// },
// ]
// );
//
// git_add(dir, Path::new("foo.txt"))?;
// git_commit(dir, "This is my message")?;
//
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Current,
// staged: false,
// prev: Some("foo".to_string()),
// next: Some("foo".to_string()),
// },
// ]
// );
//
// new_file(&dir.join("foo.txt"), "foo foo");
// git_add(dir, Path::new("foo.txt"))?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Modified,
// staged: true,
// prev: Some("foo".to_string()),
// next: Some("foo foo".to_string()),
// },
// ]
// );
// Ok(())
// }
//
// #[tokio::test]
// async fn test_add_removed_file() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// let foo_path = &dir.join("foo.txt");
// let bar_path = &dir.join("bar.txt");
//
// new_file(foo_path, "foo");
// new_file(bar_path, "bar");
//
// git_add(dir, Path::new("foo.txt"))?;
// git_commit(dir, "Initial commit")?;
//
// remove_file(foo_path)?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Removed,
// staged: false,
// prev: Some("foo".to_string()),
// next: None,
// },
// ],
// );
//
// git_add(dir, Path::new("foo.txt"))?;
// assert_eq!(
// git_status(dir).await?.entries,
// vec![
// GitStatusEntry {
// rela_path: "bar.txt".to_string(),
// status: GitStatus::Added,
// staged: false,
// prev: None,
// next: Some("bar".to_string()),
// },
// GitStatusEntry {
// rela_path: "foo.txt".to_string(),
// status: GitStatus::Removed,
// staged: true,
// prev: Some("foo".to_string()),
// next: None,
// },
// ],
// );
// Ok(())
// }
//
// #[tokio::test]
// fn test_log_empty() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// let log = git_log(dir)?;
// assert_eq!(log.len(), 0);
// Ok(())
// }
//
// #[test]
// fn test_log() -> Result<()> {
// let dir = &new_dir();
// git_init(dir)?;
//
// new_file(&dir.join("foo.txt"), "foo");
// new_file(&dir.join("bar.txt"), "bar");
//
// git_add(dir, Path::new("foo.txt"))?;
// git_commit(dir, "This is my message")?;
//
// let log = git_log(dir)?;
// assert_eq!(log.len(), 1);
// assert_eq!(log.get(0).unwrap().message, Some("This is my message".to_string()));
// Ok(())
// }
// }

View File

@@ -0,0 +1,14 @@
use crate::error::Result;
use crate::repository::open_repo;
use log::info;
use std::path::Path;
pub(crate) fn git_init(dir: &Path) -> Result<()> {
git2::Repository::init(dir)?;
let repo = open_repo(dir)?;
// Default to main instead of master, to align with
// the official Git and GitHub behavior
repo.set_head("refs/heads/main")?;
info!("Initialized {dir:?}");
Ok(())
}

View File

@@ -1,26 +1,34 @@
use crate::commands::{add, branch, checkout, commit, delete_branch, fetch_all, initialize, log, merge_branch, pull, push, status, unstage};
use crate::commands::{add, add_credential, add_remote, branch, checkout, commit, delete_branch, fetch_all, initialize, log, merge_branch, pull, push, remotes, rm_remote, status, unstage};
use tauri::{
generate_handler,
Runtime, generate_handler,
plugin::{Builder, TauriPlugin},
Runtime,
};
mod add;
mod binary;
mod branch;
mod callbacks;
mod commands;
pub mod error;
mod commit;
mod credential;
mod fetch;
mod git;
mod init;
mod log;
mod merge;
mod pull;
mod push;
mod remotes;
mod repository;
mod status;
mod unstage;
mod util;
pub mod error;
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("yaak-git")
.invoke_handler(generate_handler![
add,
add_credential,
add_remote,
branch,
checkout,
commit,
@@ -31,8 +39,10 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
merge_branch,
pull,
push,
remotes,
rm_remote,
status,
unstage
unstage,
])
.build()
}

View File

@@ -0,0 +1,73 @@
use crate::repository::open_repo;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::Path;
use ts_rs::TS;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub(crate) struct GitCommit {
pub author: GitAuthor,
pub when: DateTime<Utc>,
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub(crate) struct GitAuthor {
pub name: Option<String>,
pub email: Option<String>,
}
pub(crate) fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
let repo = open_repo(dir)?;
// Return empty if empty repo or no head (new repo)
if repo.is_empty()? || repo.head().is_err() {
return Ok(vec![]);
}
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(git2::Sort::TIME)?;
// Run git log
macro_rules! filter_try {
($e:expr) => {
match $e {
Ok(t) => t,
Err(_) => return None,
}
};
}
let log: Vec<GitCommit> = revwalk
.filter_map(|oid| {
let oid = filter_try!(oid);
let commit = filter_try!(repo.find_commit(oid));
let author = commit.author();
Some(GitCommit {
author: GitAuthor {
name: author.name().map(|s| s.to_string()),
email: author.email().map(|s| s.to_string()),
},
when: convert_git_time_to_date(author.when()),
message: commit.message().map(|m| m.to_string()),
})
})
.collect();
Ok(log)
}
#[cfg(test)]
fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime<Utc> {
DateTime::from_timestamp(0, 0).unwrap()
}
#[cfg(not(test))]
fn convert_git_time_to_date(git_time: git2::Time) -> DateTime<Utc> {
let timestamp = git_time.seconds();
DateTime::from_timestamp(timestamp, 0).unwrap()
}

View File

@@ -1,54 +1,100 @@
use crate::callbacks::default_callbacks;
use crate::error::Error::NoActiveBranch;
use crate::binary::new_binary_command;
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::merge::do_merge;
use crate::repository::open_repo;
use crate::util::{bytes_to_string, get_current_branch};
use git2::{FetchOptions, ProxyOptions};
use log::debug;
use crate::util::{get_current_branch_name, get_default_remote_in_repo};
use log::info;
use serde::{Deserialize, Serialize};
use std::path::Path;
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_git.ts")]
pub(crate) struct PullResult {
received_bytes: usize,
received_objects: usize,
pub(crate) enum PullResult {
Success { message: String },
UpToDate,
NeedsCredentials { url: String, error: Option<String> },
}
pub(crate) fn git_pull(dir: &Path) -> Result<PullResult> {
let repo = open_repo(dir)?;
let branch_name = get_current_branch_name(&repo)?;
let remote = get_default_remote_in_repo(&repo)?;
let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?;
let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?;
let branch = get_current_branch(&repo)?.ok_or(NoActiveBranch)?;
let branch_ref = branch.get();
let branch_ref = bytes_to_string(branch_ref.name_bytes())?;
let out = new_binary_command(dir)?
.args(["pull", &remote_name, &branch_name])
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|e| GenericError(format!("failed to run git pull: {e}")))?;
let remote_name = repo.branch_upstream_remote(&branch_ref)?;
let remote_name = bytes_to_string(&remote_name)?;
debug!("Pulling from {remote_name}");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = stdout + stderr;
let mut remote = repo.find_remote(&remote_name)?;
info!("Pulled status={} {combined}", out.status);
let mut options = FetchOptions::new();
let callbacks = default_callbacks();
options.remote_callbacks(callbacks);
if combined.to_lowercase().contains("could not read") {
return Ok(PullResult::NeedsCredentials {
url: remote_url.to_string(),
error: None,
});
}
let mut proxy = ProxyOptions::new();
proxy.auto();
options.proxy_options(proxy);
if combined.to_lowercase().contains("unable to access") {
return Ok(PullResult::NeedsCredentials {
url: remote_url.to_string(),
error: Some(combined.to_string()),
});
}
remote.fetch(&[&branch_ref], Some(&mut options), None)?;
if !out.status.success() {
return Err(GenericError(format!("Failed to pull {combined}")));
}
let stats = remote.stats();
if combined.to_lowercase().contains("up to date") {
return Ok(PullResult::UpToDate);
}
let fetch_head = repo.find_reference("FETCH_HEAD")?;
let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
do_merge(&repo, &branch, &fetch_commit)?;
Ok(PullResult {
received_bytes: stats.received_bytes(),
received_objects: stats.received_objects(),
Ok(PullResult::Success {
message: format!("Pulled from {}/{}", remote_name, branch_name),
})
}
// pub(crate) fn git_pull_old(dir: &Path) -> Result<PullResult> {
// let repo = open_repo(dir)?;
//
// let branch = get_current_branch(&repo)?.ok_or(NoActiveBranch)?;
// let branch_ref = branch.get();
// let branch_ref = bytes_to_string(branch_ref.name_bytes())?;
//
// let remote_name = repo.branch_upstream_remote(&branch_ref)?;
// let remote_name = bytes_to_string(&remote_name)?;
// debug!("Pulling from {remote_name}");
//
// let mut remote = repo.find_remote(&remote_name)?;
//
// let mut options = FetchOptions::new();
// let callbacks = default_callbacks();
// options.remote_callbacks(callbacks);
//
// let mut proxy = ProxyOptions::new();
// proxy.auto();
// options.proxy_options(proxy);
//
// remote.fetch(&[&branch_ref], Some(&mut options), None)?;
//
// let stats = remote.stats();
//
// let fetch_head = repo.find_reference("FETCH_HEAD")?;
// let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
// do_merge(&repo, &branch, &fetch_commit)?;
//
// Ok(PullResult::Success {
// message: "Hello".to_string(),
// // received_bytes: stats.received_bytes(),
// // received_objects: stats.received_objects(),
// })
// }

View File

@@ -1,74 +1,64 @@
use crate::branch::branch_set_upstream_after_push;
use crate::callbacks::default_callbacks;
use crate::binary::new_binary_command;
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::repository::open_repo;
use git2::{ProxyOptions, PushOptions};
use crate::util::{get_current_branch_name, get_default_remote_for_push_in_repo};
use log::info;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Mutex;
use ts_rs::TS;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_git.ts")]
pub(crate) enum PushType {
Branch,
Tag,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_git.ts")]
pub(crate) enum PushResult {
Success,
NothingToPush,
Success { message: String },
UpToDate,
NeedsCredentials { url: String, error: Option<String> },
}
pub(crate) fn git_push(dir: &Path) -> Result<PushResult> {
let repo = open_repo(dir)?;
let head = repo.head()?;
let branch = head.shorthand().unwrap();
let mut remote = repo.find_remote("origin")?;
let branch_name = get_current_branch_name(&repo)?;
let remote = get_default_remote_for_push_in_repo(&repo)?;
let remote_name = remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?;
let remote_url = remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?;
let mut options = PushOptions::new();
options.packbuilder_parallelism(0);
let push_result = Mutex::new(PushResult::NothingToPush);
let mut callbacks = default_callbacks();
callbacks.push_transfer_progress(|_current, _total, _bytes| {
let mut push_result = push_result.lock().unwrap();
*push_result = PushResult::Success;
});
options.remote_callbacks(default_callbacks());
let out = new_binary_command(dir)?
.args(["push", &remote_name, &branch_name])
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map_err(|e| GenericError(format!("failed to run git push: {e}")))?;
let mut proxy = ProxyOptions::new();
proxy.auto();
options.proxy_options(proxy);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let combined = stdout + stderr;
// Push the current branch
let force = false;
let delete = false;
let branch_modifier = match (force, delete) {
(true, true) => "+:",
(false, true) => ":",
(true, false) => "+",
(false, false) => "",
};
info!("Pushed to repo status={} {combined}", out.status);
let ref_type = PushType::Branch;
if combined.to_lowercase().contains("could not read") {
return Ok(PushResult::NeedsCredentials {
url: remote_url.to_string(),
error: None,
});
}
let ref_type = match ref_type {
PushType::Branch => "heads",
PushType::Tag => "tags",
};
if combined.to_lowercase().contains("unable to access") {
return Ok(PushResult::NeedsCredentials {
url: remote_url.to_string(),
error: Some(combined.to_string()),
});
}
let refspec = format!("{branch_modifier}refs/{ref_type}/{branch}");
remote.push(&[refspec], Some(&mut options))?;
if combined.to_lowercase().contains("up-to-date") {
return Ok(PushResult::UpToDate);
}
branch_set_upstream_after_push(&repo, branch)?;
if !out.status.success() {
return Err(GenericError(format!("Failed to push {combined}")));
}
let push_result = push_result.lock().unwrap();
Ok(push_result.clone())
Ok(PushResult::Success {
message: format!("Pushed to {}/{}", remote_name, branch_name),
})
}

View File

@@ -0,0 +1,53 @@
use crate::error::Result;
use crate::repository::open_repo;
use log::warn;
use serde::{Deserialize, Serialize};
use std::path::Path;
use ts_rs::TS;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "gen_git.ts")]
pub(crate) struct GitRemote {
name: String,
url: Option<String>,
}
pub(crate) fn git_remotes(dir: &Path) -> Result<Vec<GitRemote>> {
let repo = open_repo(dir)?;
let mut remotes = Vec::new();
for remote in repo.remotes()?.into_iter() {
let name = match remote {
None => continue,
Some(name) => name,
};
let r = match repo.find_remote(name) {
Ok(r) => r,
Err(e) => {
warn!("Failed to get remote {name}: {e:?}");
continue;
}
};
remotes.push(GitRemote {
name: name.to_string(),
url: r.url().map(|u| u.to_string()),
});
}
Ok(remotes)
}
pub(crate) fn git_add_remote(dir: &Path, name: &str, url: &str) -> Result<GitRemote> {
let repo = open_repo(dir)?;
repo.remote(name, url)?;
Ok(GitRemote {
name: name.to_string(),
url: Some(url.to_string()),
})
}
pub(crate) fn git_rm_remote(dir: &Path, name: &str) -> Result<()> {
let repo = open_repo(dir)?;
repo.remote_delete(name)?;
Ok(())
}

View File

@@ -0,0 +1,172 @@
use crate::repository::open_repo;
use crate::util::{local_branch_names, remote_branch_names};
use log::warn;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use ts_rs::TS;
use yaak_sync::models::SyncModel;
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitStatusSummary {
pub path: String,
pub head_ref: Option<String>,
pub head_ref_shorthand: Option<String>,
pub entries: Vec<GitStatusEntry>,
pub origins: Vec<String>,
pub local_branches: Vec<String>,
pub remote_branches: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitStatusEntry {
pub rela_path: String,
pub status: GitStatus,
pub staged: bool,
pub prev: Option<SyncModel>,
pub next: Option<SyncModel>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_git.ts")]
pub enum GitStatus {
Untracked,
Conflict,
Current,
Modified,
Removed,
Renamed,
TypeChange,
}
pub(crate) fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
let repo = open_repo(dir)?;
let (head_tree, head_ref, head_ref_shorthand) = match repo.head() {
Ok(head) => {
let tree = head.peel_to_tree().ok();
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
let head_ref = head.name().map(|s| s.to_string());
(tree, head_ref, head_ref_shorthand)
}
Err(_) => {
// For "unborn" repos, reading from HEAD is the only way to get the branch name
// See https://github.com/starship/starship/pull/1336
let head_path = repo.path().join("HEAD");
let head_ref = fs::read_to_string(&head_path)
.ok()
.unwrap_or_default()
.lines()
.next()
.map(|s| s.trim_start_matches("ref:").trim().to_string());
let head_ref_shorthand =
head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
(None, head_ref, head_ref_shorthand)
}
};
let mut opts = git2::StatusOptions::new();
opts.include_ignored(false)
.include_untracked(true) // Include untracked
.recurse_untracked_dirs(true) // Show all untracked
.include_unmodified(true); // Include unchanged
// TODO: Support renames
let mut entries: Vec<GitStatusEntry> = Vec::new();
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
let rela_path = entry.path().unwrap().to_string();
let status = entry.status();
let index_status = match status {
// Note: order matters here, since we're checking a bitmap!
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
s => {
warn!("Unknown index status {s:?}");
continue;
}
};
let worktree_status = match status {
// Note: order matters here, since we're checking a bitmap!
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
s => {
warn!("Unknown worktree status {s:?}");
continue;
}
};
let status = if index_status == GitStatus::Current {
worktree_status.clone()
} else {
index_status.clone()
};
let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current
{
// No change, so can't be added
false
} else if index_status != GitStatus::Current {
true
} else {
false
};
// Get previous content from Git, if it's in there
let prev = match head_tree.clone() {
None => None,
Some(t) => match t.get_path(&Path::new(&rela_path)) {
Ok(entry) => {
let obj = entry.to_object(&repo)?;
let content = obj.as_blob().unwrap().content();
let name = Path::new(entry.name().unwrap_or_default());
SyncModel::from_bytes(content.into(), name)?.map(|m| m.0)
}
Err(_) => None,
},
};
let next = {
let full_path = repo.workdir().unwrap().join(rela_path.clone());
SyncModel::from_file(full_path.as_path())?.map(|m| m.0)
};
entries.push(GitStatusEntry {
status,
staged,
rela_path,
prev: prev.clone(),
next: next.clone(),
})
}
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
let local_branches = local_branch_names(&repo)?;
let remote_branches = remote_branch_names(&repo)?;
Ok(GitStatusSummary {
entries,
origins,
path: dir.to_string_lossy().to_string(),
head_ref,
head_ref_shorthand,
local_branches,
remote_branches,
})
}

View File

@@ -0,0 +1,28 @@
use std::path::Path;
use log::info;
use crate::repository::open_repo;
pub(crate) fn git_unstage(dir: &Path, rela_path: &Path) -> crate::error::Result<()> {
let repo = open_repo(dir)?;
let head = match repo.head() {
Ok(h) => h,
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
info!("Unstaging file in empty branch {rela_path:?} to {dir:?}");
// Repo has no commits, so "unstage" means remove from index
let mut index = repo.index()?;
index.remove_path(rela_path)?;
index.write()?;
return Ok(());
}
Err(e) => return Err(e.into()),
};
// If repo has commits, update the index entry back to HEAD
info!("Unstaging file {rela_path:?} to {dir:?}");
let commit = head.peel_to_commit()?;
repo.reset_default(Some(commit.as_object()), &[rela_path])?;
Ok(())
}

View File

@@ -1,29 +1,9 @@
use crate::error::Error::{GenericError, NoDefaultRemoteFound};
use crate::error::Result;
use git2::{Branch, BranchType, Repository};
use std::env;
use std::path::{Path, PathBuf};
use git2::{Branch, BranchType, Remote, Repository};
const DEFAULT_REMOTE_NAME: &str = "origin";
pub(crate) fn find_ssh_key() -> Option<PathBuf> {
let home_dir = env::var("HOME").ok()?;
let key_paths = [
format!("{}/.ssh/id_ed25519", home_dir),
format!("{}/.ssh/id_rsa", home_dir),
format!("{}/.ssh/id_ecdsa", home_dir),
format!("{}/.ssh/id_dsa", home_dir),
];
for key_path in key_paths.iter() {
let path = Path::new(key_path);
if path.exists() {
return Some(path.to_path_buf());
}
}
None
}
pub(crate) fn get_current_branch(repo: &Repository) -> Result<Option<Branch<'_>>> {
for b in repo.branches(None)? {
let branch = b?.0;
@@ -34,10 +14,18 @@ pub(crate) fn get_current_branch(repo: &Repository) -> Result<Option<Branch<'_>>
Ok(None)
}
pub(crate) fn get_current_branch_name(repo: &Repository) -> Result<String> {
Ok(get_current_branch(&repo)?
.ok_or(GenericError("Failed to get current branch".to_string()))?
.name()?
.ok_or(GenericError("Failed to get current branch name".to_string()))?
.to_string())
}
pub(crate) fn local_branch_names(repo: &Repository) -> Result<Vec<String>> {
let mut branches = Vec::new();
for branch in repo.branches(Some(BranchType::Local))? {
let branch = branch?.0;
let (branch, _) = branch?;
let name = branch.name_bytes()?;
let name = bytes_to_string(name)?;
branches.push(name);
@@ -48,9 +36,12 @@ pub(crate) fn local_branch_names(repo: &Repository) -> Result<Vec<String>> {
pub(crate) fn remote_branch_names(repo: &Repository) -> Result<Vec<String>> {
let mut branches = Vec::new();
for branch in repo.branches(Some(BranchType::Remote))? {
let branch = branch?.0;
let (branch, _) = branch?;
let name = branch.name_bytes()?;
let name = bytes_to_string(name)?;
if name.ends_with("/HEAD") {
continue;
}
branches.push(name);
}
Ok(branches)
@@ -64,7 +55,13 @@ pub(crate) fn bytes_to_string(bytes: &[u8]) -> Result<String> {
Ok(String::from_utf8(bytes.to_vec())?)
}
pub(crate) fn get_default_remote_for_push_in_repo(repo: &Repository) -> Result<String> {
pub(crate) fn get_default_remote_for_push_in_repo(repo: &'_ Repository) -> Result<Remote<'_>> {
let name = get_default_remote_name_for_push_in_repo(repo)?;
let remote = repo.find_remote(&name)?;
Ok(remote)
}
pub(crate) fn get_default_remote_name_for_push_in_repo(repo: &Repository) -> Result<String> {
let config = repo.config()?;
let branch = get_current_branch(repo)?;
@@ -89,12 +86,22 @@ pub(crate) fn get_default_remote_for_push_in_repo(repo: &Repository) -> Result<S
}
}
get_default_remote_in_repo(repo)
get_default_remote_name_in_repo(repo)
}
pub(crate) fn get_default_remote_in_repo(repo: &Repository) -> Result<String> {
pub(crate) fn get_default_remote_in_repo(repo: &'_ Repository) -> Result<Remote<'_>> {
let name = get_default_remote_name_in_repo(repo)?;
let remote = repo.find_remote(&name)?;
Ok(remote)
}
pub(crate) fn get_default_remote_name_in_repo(repo: &Repository) -> Result<String> {
let remotes = repo.remotes()?;
if remotes.is_empty() {
return Err(NoDefaultRemoteFound);
}
// if `origin` exists return that
let found_origin = remotes.iter().any(|r| r.is_some_and(|r| r == DEFAULT_REMOTE_NAME));
if found_origin {