Merge branch 'new_featrues'

This commit is contained in:
Christoph Brandau
2026-07-20 23:05:00 +02:00
14 changed files with 882 additions and 80 deletions
+344 -15
View File
@@ -51,6 +51,14 @@ pub struct GitStatus {
pub clean: bool,
pub rebase_in_progress: bool,
pub cherry_pick_in_progress: bool,
pub merge_in_progress: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitRemote {
pub name: String,
pub fetch_url: String,
pub push_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -361,6 +369,34 @@ pub fn open_repository(path: String) -> Result<GitStatus, String> {
status_for_repo(&repo)
}
#[tauri::command]
pub fn init_repository(path: String, initial_branch: Option<String>) -> Result<GitStatus, String> {
let path = PathBuf::from(path.trim());
if path.as_os_str().is_empty() {
return Err("Repository path must not be empty.".to_string());
}
fs::create_dir_all(&path)
.map_err(|err| format!("Could not create repository folder: {err}"))?;
let branch = initial_branch.unwrap_or_else(|| "main".to_string());
let branch = branch.trim();
if branch.is_empty() {
return Err("Initial branch must not be empty.".to_string());
}
let output = git_command()
.arg("-C")
.arg(&path)
.args(["init", "-b", branch])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if !output.status.success() {
return Err(format!(
"Could not initialize repository: {}",
command_output_details(&output)
));
}
status_for_repo(&path)
}
#[tauri::command]
pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
let repo = resolve_repo(&path)?;
@@ -459,6 +495,125 @@ pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
branches_for_repo(&repo)
}
#[tauri::command]
pub fn list_remotes(path: String) -> Result<Vec<GitRemote>, String> {
log::info!(target: "gitty::remote", "list_remotes invoked: path={path:?}");
let repo = resolve_repo(&path)?;
let names = run_git(&repo, ["remote"])?;
Ok(String::from_utf8_lossy(&names)
.lines()
.filter_map(|line| {
let name = line.trim();
if name.is_empty() {
return None;
}
Some(GitRemote {
name: name.to_string(),
fetch_url: remote_url_for(&repo, name).unwrap_or_default(),
push_url: remote_push_url_for(&repo, name).unwrap_or_default(),
})
})
.collect())
}
#[tauri::command]
pub fn add_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
let repo = resolve_repo(&path)?;
let name = validate_remote_name(&repo, &name, false)?;
let url = validate_remote_url(&url)?;
run_git(&repo, ["remote", "add", name.as_str(), url.as_str()])?;
list_remotes(path)
}
#[tauri::command]
pub fn update_remote(path: String, name: String, url: String) -> Result<Vec<GitRemote>, String> {
let repo = resolve_repo(&path)?;
let name = validate_remote_name(&repo, &name, true)?;
let url = validate_remote_url(&url)?;
run_git(&repo, ["remote", "set-url", name.as_str(), url.as_str()])?;
list_remotes(path)
}
#[tauri::command]
pub fn remove_remote(path: String, name: String) -> Result<Vec<GitRemote>, String> {
log::info!(target: "gitty::remote", "remove_remote invoked: path={path:?}, name={name:?}");
let result = (|| {
let repo = resolve_repo(&path)?;
log::info!(target: "gitty::remote", "remove_remote resolved repository: {}", repo.display());
let name = validate_remote_name(&repo, &name, true)?;
log::info!(target: "gitty::remote", "remove_remote validated remote: {name}");
run_git(&repo, ["remote", "remove", name.as_str()])?;
log::info!(target: "gitty::remote", "remove_remote git command succeeded: {name}");
list_remotes(path)
})();
match &result {
Ok(remotes) => {
log::info!(target: "gitty::remote", "remove_remote completed; remaining={:?}", remotes.iter().map(|remote| remote.name.as_str()).collect::<Vec<_>>())
}
Err(error) => log::error!(target: "gitty::remote", "remove_remote failed: {error}"),
}
result
}
#[tauri::command]
pub fn set_branch_upstream(
path: String,
branch: String,
upstream: Option<String>,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = validate_existing_local_branch_name(&repo, &branch)?;
match upstream
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
Some(upstream) => {
if !ref_exists(&repo, &format!("refs/remotes/{upstream}"))? {
return Err(format!("Remote branch '{upstream}' was not found."));
}
run_git(
&repo,
[
"branch",
"--set-upstream-to",
upstream.as_str(),
branch.as_str(),
],
)?;
}
None => {
run_git(&repo, ["branch", "--unset-upstream", branch.as_str()])?;
}
}
status_for_repo(&repo)
}
#[tauri::command]
pub fn delete_remote_branch(
path: String,
remote: String,
branch: String,
) -> Result<GitStatus, String> {
log::info!(target: "gitty::remote", "delete_remote_branch invoked: path={path:?}, remote={remote:?}, branch={branch:?}");
let result = (|| {
let repo = resolve_repo(&path)?;
let remote = validate_remote_name(&repo, &remote, true)?;
let branch = branch.trim();
if branch.is_empty() || branch.starts_with('-') {
return Err("Invalid remote branch name.".to_string());
}
run_git(&repo, ["check-ref-format", "--branch", branch])?;
log::info!(target: "gitty::remote", "deleting remote branch with git push: {remote}/{branch}");
run_git(&repo, ["push", remote.as_str(), "--delete", branch])?;
log::info!(target: "gitty::remote", "remote branch deleted successfully: {remote}/{branch}");
status_for_repo(&repo)
})();
if let Err(error) = &result {
log::error!(target: "gitty::remote", "delete_remote_branch failed: {error}");
}
result
}
#[tauri::command]
pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
let repo = resolve_repo(&path)?;
@@ -1409,18 +1564,43 @@ pub async fn pull(
path: String,
username: Option<String>,
password: Option<String>,
strategy: Option<String>,
remote: Option<String>,
branch: Option<String>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"];
let strategy = strategy.as_deref().unwrap_or("merge");
let mut pull_args = vec![OsString::from("pull")];
match strategy {
"merge" => {
pull_args.extend([OsString::from("--no-rebase"), OsString::from("--no-edit")])
}
"rebase" => pull_args.push(OsString::from("--rebase")),
"ff-only" => pull_args.push(OsString::from("--ff-only")),
_ => return Err("Unknown pull strategy.".to_string()),
}
if let Some(remote) = remote
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
validate_remote_name(&repo, &remote, true)?;
pull_args.push(remote.into());
if let Some(branch) = branch
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
pull_args.push(branch.into());
}
}
let output = match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated_output(&repo, pull_args, u, p)?
run_git_authenticated_output(&repo, pull_args.clone(), u, p)?
}
_ => git_command()
.arg("-C")
.arg(&repo)
.args(pull_args)
.args(&pull_args)
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
};
@@ -1449,18 +1629,30 @@ pub async fn fetch(
path: String,
username: Option<String>,
password: Option<String>,
prune: Option<bool>,
remote: Option<String>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let fetch_args = ["fetch"];
let mut fetch_args = vec![OsString::from("fetch")];
if prune.unwrap_or(false) {
fetch_args.push(OsString::from("--prune"));
}
if let Some(remote) = remote
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
validate_remote_name(&repo, &remote, true)?;
fetch_args.push(remote.into());
}
let output = match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated_output(&repo, fetch_args, u, p)?
run_git_authenticated_output(&repo, fetch_args.clone(), u, p)?
}
_ => git_command()
.arg("-C")
.arg(&repo)
.args(fetch_args)
.args(&fetch_args)
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
};
@@ -1484,10 +1676,15 @@ pub async fn push(
path: String,
username: Option<String>,
password: Option<String>,
force_with_lease: Option<bool>,
remote: Option<String>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let push_args = push_args_for_repo(&repo)?;
let mut push_args = push_args_for_repo_to(&repo, remote.as_deref())?;
if force_with_lease.unwrap_or(false) {
push_args.insert(1, OsString::from("--force-with-lease"));
}
match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated(&repo, push_args, u, p)?;
@@ -1557,6 +1754,43 @@ fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
if url.is_empty() { None } else { Some(url) }
}
fn remote_push_url_for(repo: &Path, remote: &str) -> Option<String> {
let out = git_command()
.arg("-C")
.arg(repo)
.args(["remote", "get-url", "--push", remote])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if url.is_empty() { None } else { Some(url) }
}
fn validate_remote_url(url: &str) -> Result<String, String> {
let url = url.trim();
if url.is_empty() || url.starts_with('-') {
return Err("Remote URL must not be empty.".to_string());
}
Ok(url.to_string())
}
fn validate_remote_name(repo: &Path, name: &str, must_exist: bool) -> Result<String, String> {
let name = name.trim();
if name.is_empty() || name.starts_with('-') || name.chars().any(char::is_whitespace) {
return Err("Invalid remote name.".to_string());
}
let exists = remote_url_for(repo, name).is_some();
if must_exist && !exists {
return Err(format!("Remote '{name}' was not found."));
}
if !must_exist && exists {
return Err(format!("Remote '{name}' already exists."));
}
Ok(name.to_string())
}
fn upstream_remote_name(repo: &Path) -> Option<String> {
let branch = current_branch_name(repo).ok()?;
let out = git_command()
@@ -1609,7 +1843,25 @@ fn initial_push_remote_name(repo: &Path) -> Result<String, String> {
.ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string())
}
#[cfg(test)]
fn push_args_for_repo(repo: &Path) -> Result<Vec<OsString>, String> {
push_args_for_repo_to(repo, None)
}
fn push_args_for_repo_to(
repo: &Path,
requested_remote: Option<&str>,
) -> Result<Vec<OsString>, String> {
if let Some(remote) = requested_remote.map(str::trim).filter(|v| !v.is_empty()) {
let remote = validate_remote_name(repo, remote, true)?;
let branch = current_branch_name(repo)?;
return Ok(vec![
OsString::from("push"),
OsString::from("--set-upstream"),
remote.into(),
branch.into(),
]);
}
if branch_has_upstream(repo) {
return Ok(vec![OsString::from("push")]);
}
@@ -1669,7 +1921,11 @@ pub fn cred_delete(key: String) -> Result<(), String> {
}
#[tauri::command]
pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
pub async fn merge_branch(
path: String,
branch: String,
strategy: Option<String>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = branch.trim();
@@ -1677,10 +1933,19 @@ pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, Str
return Err("Branch name must not be empty.".to_string());
}
let mut args = vec!["merge", "--no-edit"];
match strategy.as_deref().unwrap_or("default") {
"default" => {}
"squash" => args.push("--squash"),
"ff-only" => args.push("--ff-only"),
"no-ff" => args.push("--no-ff"),
_ => return Err("Unknown merge strategy.".to_string()),
}
args.push(branch);
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["merge", "--no-edit", branch])
.args(args)
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
@@ -1712,6 +1977,52 @@ pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, Str
.map_err(|err| format!("Could not merge: {err}"))?
}
#[tauri::command]
pub fn merge_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !merge_in_progress(&repo) {
return Err("No merge is in progress.".to_string());
}
if has_unresolved_conflicts(&status_for_repo(&repo)?) {
return Err("Resolve all conflicts before continuing the merge.".to_string());
}
run_git(&repo, ["commit", "--no-edit"])?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn merge_abort(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !merge_in_progress(&repo) {
return Err("No merge is in progress.".to_string());
}
run_git(&repo, ["merge", "--abort"])?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn revert_commit(path: String, commit: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let commit = verify_commit(&repo, &commit)?;
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["revert", "--no-edit", commit.as_str()])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() {
return status_for_repo(&repo);
}
let status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&status) {
return Ok(status);
}
Err(format!(
"Revert failed: {}",
command_output_details(&output)
))
}
#[tauri::command]
pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
@@ -3118,6 +3429,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
files,
rebase_in_progress: rebase_in_progress(repo),
cherry_pick_in_progress: cherry_pick_in_progress(repo),
merge_in_progress: merge_in_progress(repo),
})
}
@@ -3129,6 +3441,10 @@ fn cherry_pick_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "CHERRY_PICK_HEAD")
}
fn merge_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "MERGE_HEAD")
}
fn git_path_exists(repo: &Path, name: &str) -> bool {
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
return false;
@@ -5633,9 +5949,16 @@ mod tests {
],
);
let status = pull(repo.path.to_string_lossy().to_string(), None, None)
.await
.unwrap();
let status = pull(
repo.path.to_string_lossy().to_string(),
None,
None,
None,
None,
None,
)
.await
.unwrap();
assert!(status.clean, "{:?}", status.files);
assert!(repo.path.join("remote.txt").exists());
@@ -5706,9 +6029,15 @@ mod tests {
["remote", "add", "origin", remote.path.to_str().unwrap()],
);
let status = push(repo.path.to_string_lossy().to_string(), None, None)
.await
.unwrap();
let status = push(
repo.path.to_string_lossy().to_string(),
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(
status.upstream.as_deref(),