add auto-updater and git workflow enhancements
publish / publish-tauri (, windows-latest) (release) Failing after 2m52s
publish / publish-tauri (, windows-latest) (release) Failing after 2m52s
This change introduces a comprehensive auto-update mechanism for the application and significant improvements to the integrated Git client experience. Key features include: - **Automated Release Workflow:** A new Gitea Actions workflow (`app_builder.yaml`) and a Python `cicd_tool` are added to automatically build, sign, and upload release artifacts to S3-compatible storage (MinIO) upon a new release. This also generates the `latest.json` file required by the updater. - **Tauri Updater Integration:** The `tauri-plugin-updater` is integrated into the application, enabling it to check for and apply updates seamlessly. - **Robust Git Push Handling:** The application now intelligently handles non-fast-forward push failures by prompting the user to perform a pull/merge operation before re-attempting the push. - **Enhanced File Comparison:** A new `compare_file_to_parent` command is introduced, allowing detailed file diffs against a commit's direct parent, including the "empty tree" for initial commits. - **Explorer Panel Improvements:** "Expand All" and "Collapse All" functionality is added to the file explorer for better navigation.
This commit is contained in:
+228
-18
@@ -3,7 +3,7 @@ use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
ffi::{OsStr, OsString},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
process::{Command, Output, Stdio},
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
@@ -139,6 +139,7 @@ enum CheckoutPlan {
|
||||
}
|
||||
|
||||
const FULL_FILE_DIFF_CONTEXT: &str = "--unified=1000000";
|
||||
const EMPTY_TREE_HASH: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
||||
const SEARCH_CANCELLED_MESSAGE: &str = "Suche wurde abgebrochen.";
|
||||
static CANCELLABLE_GIT_OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
@@ -336,15 +337,35 @@ pub fn pull(
|
||||
password: Option<String>,
|
||||
) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
match (username.as_deref(), password.as_deref()) {
|
||||
let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"];
|
||||
let output = match (username.as_deref(), password.as_deref()) {
|
||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||
run_git_authenticated(&repo, ["pull", "--ff-only"], u, p)?;
|
||||
}
|
||||
_ => {
|
||||
run_git(&repo, ["pull", "--ff-only"])?;
|
||||
run_git_authenticated_output(&repo, pull_args, u, p)?
|
||||
}
|
||||
_ => Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(pull_args)
|
||||
.output()
|
||||
.map_err(|err| {
|
||||
format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}")
|
||||
})?,
|
||||
};
|
||||
|
||||
if output.status.success() {
|
||||
return status_for_repo(&repo);
|
||||
}
|
||||
status_for_repo(&repo)
|
||||
|
||||
let status = status_for_repo(&repo)?;
|
||||
if has_unresolved_conflicts(&status) {
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
let details = command_output_details(&output);
|
||||
if is_auth_error(&details) {
|
||||
return Err(format!("AUTH_FAILED:{details}"));
|
||||
}
|
||||
Err(format!("Git-Befehl fehlgeschlagen: {details}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -949,6 +970,78 @@ pub fn compare_file_to_head(
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn compare_file_to_parent(
|
||||
path: String,
|
||||
commit: String,
|
||||
file: String,
|
||||
old_file: Option<String>,
|
||||
) -> Result<GitCommitComparison, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let mut pathspecs = Vec::new();
|
||||
if let Some(old_file) = old_file.filter(|value| !value.trim().is_empty() && value != &file) {
|
||||
pathspecs.push(old_file);
|
||||
}
|
||||
pathspecs.push(file);
|
||||
validate_files(&pathspecs)?;
|
||||
|
||||
let commit_hash = verify_commit(&repo, &commit)?;
|
||||
let parents = commit_parents(&repo, &commit_hash)?;
|
||||
let (from_hash, from_short) = if let Some(parent) = parents.first() {
|
||||
(parent.clone(), short_hash(parent))
|
||||
} else {
|
||||
(EMPTY_TREE_HASH.to_string(), "empty tree".to_string())
|
||||
};
|
||||
|
||||
let name_status = run_git_with_paths(
|
||||
&repo,
|
||||
&[
|
||||
"diff",
|
||||
"--name-status",
|
||||
"-M",
|
||||
"-z",
|
||||
from_hash.as_str(),
|
||||
commit_hash.as_str(),
|
||||
],
|
||||
&pathspecs,
|
||||
)?;
|
||||
let numstat = run_git_with_paths(
|
||||
&repo,
|
||||
&[
|
||||
"diff",
|
||||
"--numstat",
|
||||
"-M",
|
||||
"-z",
|
||||
from_hash.as_str(),
|
||||
commit_hash.as_str(),
|
||||
],
|
||||
&pathspecs,
|
||||
)?;
|
||||
let patch_output = run_git_with_paths(
|
||||
&repo,
|
||||
&[
|
||||
"diff",
|
||||
"-M",
|
||||
FULL_FILE_DIFF_CONTEXT,
|
||||
from_hash.as_str(),
|
||||
commit_hash.as_str(),
|
||||
],
|
||||
&pathspecs,
|
||||
)?;
|
||||
|
||||
let files = parse_diff_files(&name_status, &numstat)?;
|
||||
let patch = String::from_utf8_lossy(&patch_output).to_string();
|
||||
|
||||
Ok(GitCommitComparison {
|
||||
from_short,
|
||||
to_short: short_hash(&commit_hash),
|
||||
from_hash,
|
||||
to_hash: commit_hash,
|
||||
files,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_conflict(path: String, file: String) -> Result<ConflictFile, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -1926,6 +2019,29 @@ fn run_git_authenticated<I, S>(
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<Vec<u8>, String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
let output = run_git_authenticated_output(repo, args, username, password)?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(output.stdout);
|
||||
}
|
||||
|
||||
let details = command_output_details(&output);
|
||||
if is_auth_error(&details) {
|
||||
return Err(format!("AUTH_FAILED:{details}"));
|
||||
}
|
||||
Err(format!("Git-Befehl fehlgeschlagen: {details}"))
|
||||
}
|
||||
|
||||
fn run_git_authenticated_output<I, S>(
|
||||
repo: &Path,
|
||||
args: I,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<Output, String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
@@ -1944,26 +2060,19 @@ where
|
||||
.map_err(|err| format!("Git konnte nicht gestartet werden: {err}"));
|
||||
|
||||
let _ = std::fs::remove_file(&askpass);
|
||||
result
|
||||
}
|
||||
|
||||
let output = result?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(output.stdout);
|
||||
}
|
||||
|
||||
fn command_output_details(output: &Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let details = if !stderr.trim().is_empty() {
|
||||
if !stderr.trim().is_empty() {
|
||||
stderr.trim().to_string()
|
||||
} else if !stdout.trim().is_empty() {
|
||||
stdout.trim().to_string()
|
||||
} else {
|
||||
"Unbekannter Fehler".to_string()
|
||||
};
|
||||
if is_auth_error(&details) {
|
||||
return Err(format!("AUTH_FAILED:{details}"));
|
||||
}
|
||||
Err(format!("Git-Befehl fehlgeschlagen: {details}"))
|
||||
}
|
||||
|
||||
/// Heuristic: did git fail because the credentials were rejected/expired,
|
||||
@@ -2744,6 +2853,107 @@ mod tests {
|
||||
assert!(!comparison.patch.contains("other file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_file_to_parent_reports_selected_file_change_in_commit() {
|
||||
let repo = init_temp_repo("compare_file_to_parent");
|
||||
commit_initial_file(&repo.path);
|
||||
let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||
|
||||
fs::write(repo.path.join("old.txt"), "original\nsecond line\n")
|
||||
.expect("tracked file should change");
|
||||
fs::write(repo.path.join("other.txt"), "other file\n")
|
||||
.expect("other file should be written");
|
||||
run_git_test(&repo.path, ["add", "old.txt", "other.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "second"]);
|
||||
let second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||
|
||||
let comparison = compare_file_to_parent(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
second_commit.clone(),
|
||||
"old.txt".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(comparison.from_hash, first_commit);
|
||||
assert_eq!(comparison.to_hash, second_commit);
|
||||
assert_eq!(comparison.files.len(), 1);
|
||||
assert_eq!(comparison.files[0].path, "old.txt");
|
||||
assert!(comparison.patch.contains("second line"));
|
||||
assert!(!comparison.patch.contains("other file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_file_to_parent_uses_empty_tree_for_initial_commit() {
|
||||
let repo = init_temp_repo("compare_file_to_parent_initial");
|
||||
commit_initial_file(&repo.path);
|
||||
let initial_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||
|
||||
let comparison = compare_file_to_parent(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
initial_commit.clone(),
|
||||
"old.txt".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(comparison.from_hash, EMPTY_TREE_HASH);
|
||||
assert_eq!(comparison.from_short, "empty tree");
|
||||
assert_eq!(comparison.to_hash, initial_commit);
|
||||
assert_eq!(comparison.files.len(), 1);
|
||||
assert_eq!(comparison.files[0].path, "old.txt");
|
||||
assert!(comparison.patch.contains("+original"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(
|
||||
windows,
|
||||
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error"
|
||||
)]
|
||||
fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() {
|
||||
let repo = init_temp_repo("pull_diverged");
|
||||
commit_initial_file(&repo.path);
|
||||
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
|
||||
run_git_test(&repo.path, ["checkout", "-q", "-b", "remote-change"]);
|
||||
fs::write(repo.path.join("remote.txt"), "remote change\n")
|
||||
.expect("remote file should be written");
|
||||
run_git_test(&repo.path, ["add", "remote.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "remote change"]);
|
||||
|
||||
run_git_test(&repo.path, ["checkout", "-q", base_branch.as_str()]);
|
||||
fs::write(repo.path.join("local.txt"), "local change\n")
|
||||
.expect("local file should be written");
|
||||
run_git_test(&repo.path, ["add", "local.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "local change"]);
|
||||
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
|
||||
run_git_test(
|
||||
&repo.path,
|
||||
["config", &format!("branch.{base_branch}.remote"), "origin"],
|
||||
);
|
||||
run_git_test(
|
||||
&repo.path,
|
||||
[
|
||||
"config",
|
||||
&format!("branch.{base_branch}.merge"),
|
||||
"refs/heads/remote-change",
|
||||
],
|
||||
);
|
||||
|
||||
let status = pull(repo.path.to_string_lossy().to_string(), None, None).unwrap();
|
||||
|
||||
assert!(status.clean, "{:?}", status.files);
|
||||
assert!(repo.path.join("remote.txt").exists());
|
||||
assert!(repo.path.join("local.txt").exists());
|
||||
|
||||
let parent_count =
|
||||
git_output_test(&repo.path, ["rev-list", "--parents", "-n", "1", "HEAD"])
|
||||
.split_whitespace()
|
||||
.count()
|
||||
- 1;
|
||||
assert_eq!(parent_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_and_resolve_conflict_round_trip() {
|
||||
let repo = init_temp_repo("resolve_conflict");
|
||||
|
||||
@@ -4,15 +4,17 @@ mod git;
|
||||
|
||||
use git::{
|
||||
cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head,
|
||||
cred_delete, cred_load, cred_save, diff_file_against_working_tree, get_remote_url, get_status,
|
||||
list_branches, list_commits, list_file_history, list_repository_files, merge_branch,
|
||||
open_repository, pull, push, read_conflict, resolve_conflict, resolve_conflict_side,
|
||||
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions,
|
||||
stage_files, unstage_files, SearchCancellationState,
|
||||
compare_file_to_parent, cred_delete, cred_load, cred_save, diff_file_against_working_tree,
|
||||
get_remote_url, get_status, list_branches, list_commits, list_file_history,
|
||||
list_repository_files, merge_branch, open_repository, pull, push, read_conflict,
|
||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
||||
restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
||||
SearchCancellationState,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(SearchCancellationState::default())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -34,6 +36,7 @@ fn main() {
|
||||
list_file_history,
|
||||
compare_commits,
|
||||
compare_file_to_head,
|
||||
compare_file_to_parent,
|
||||
diff_file_against_working_tree,
|
||||
search_code_introductions,
|
||||
cancel_code_search,
|
||||
|
||||
Reference in New Issue
Block a user