feat(git): expand core git repository management features

This update significantly expands the available Git functionality by adding robust support for initializing repositories, managing remotes, and improving complex workflow operations like merging and reverting commits. New API endpoints are exposed across the backend and frontend to handle remote setup, branch tracking, and conflict resolution workflows.

- Added full remote management capabilities (add, update, remove).
- Implemented advanced merge strategies and commit reversion logic.
- Introduced a dedicated UI component for synchronization settings.
This commit is contained in:
Christoph Brandau
2026-07-13 23:24:36 +02:00
parent 35310bec6d
commit 7800f0fb24
11 changed files with 688 additions and 48 deletions
+20 -3
View File
@@ -15,6 +15,9 @@ interface GitStatus {
behind: number; behind: number;
files: GitFileStatus[]; files: GitFileStatus[];
clean: boolean; clean: boolean;
rebase_in_progress: boolean;
cherry_pick_in_progress: boolean;
merge_in_progress: boolean;
} }
interface GitFileStatus { interface GitFileStatus {
@@ -56,19 +59,33 @@ interface GitRepositoryFile {
## Commands ## Commands
The command list below includes the repository-management and synchronization API. The TypeScript wrappers in `src/lib/git.ts` are the authoritative full list.
- `open_repository(path: string): Promise<GitStatus>` - `open_repository(path: string): Promise<GitStatus>`
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
- `clone_repository(...): Promise<RepositoryBundle>`
- `get_status(path: string): Promise<GitStatus>` - `get_status(path: string): Promise<GitStatus>`
- `list_branches(path: string): Promise<GitBranch[]>` - `list_branches(path: string): Promise<GitBranch[]>`
- `list_remotes(path: string): Promise<GitRemote[]>`
- `add_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
- `update_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
- `remove_remote(path: string, name: string): Promise<GitRemote[]>`
- `set_branch_upstream(path: string, branch: string, upstream?: string): Promise<GitStatus>`
- `delete_remote_branch(path: string, remote: string, branch: string): Promise<GitStatus>`
- `checkout_branch(path: string, branch: string): Promise<GitStatus>` - `checkout_branch(path: string, branch: string): Promise<GitStatus>`
- `stage_files(path: string, files: string[]): Promise<GitStatus>` - `stage_files(path: string, files: string[]): Promise<GitStatus>`
- `unstage_files(path: string, files: string[]): Promise<GitStatus>` - `unstage_files(path: string, files: string[]): Promise<GitStatus>`
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>` - `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
- `commit(path: string, message: string): Promise<GitStatus>` - `commit(path: string, message: string): Promise<GitStatus>`
- `pull(path: string): Promise<GitStatus>` - `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
- `push(path: string): Promise<GitStatus>` - `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
- `push(path: string, forceWithLease?: boolean, remote?: string): Promise<GitStatus>`
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>` - `list_commits(path: string, limit?: number): Promise<GitCommit[]>`
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>` - `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path) - `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
- `merge_branch(path: string, branch: string): Promise<GitStatus>` - `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>`
- `merge_continue(path: string): Promise<GitStatus>`
- `merge_abort(path: string): Promise<GitStatus>`
- `revert_commit(path: string, commit: string): Promise<GitStatus>`
- `list_repository_files(path: string): Promise<GitRepositoryFile[]>` - `list_repository_files(path: string): Promise<GitRepositoryFile[]>`
- `list_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path) - `list_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path)
+321 -15
View File
@@ -51,6 +51,14 @@ pub struct GitStatus {
pub clean: bool, pub clean: bool,
pub rebase_in_progress: bool, pub rebase_in_progress: bool,
pub cherry_pick_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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -357,6 +365,34 @@ pub fn open_repository(path: String) -> Result<GitStatus, String> {
status_for_repo(&repo) 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] #[tauri::command]
pub fn open_repo_in_explorer(path: String) -> Result<(), String> { pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -455,6 +491,102 @@ pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
branches_for_repo(&repo) branches_for_repo(&repo)
} }
#[tauri::command]
pub fn list_remotes(path: String) -> Result<Vec<GitRemote>, String> {
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> {
let repo = resolve_repo(&path)?;
let name = validate_remote_name(&repo, &name, true)?;
run_git(&repo, ["remote", "remove", name.as_str()])?;
list_remotes(path)
}
#[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> {
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])?;
run_git(&repo, ["push", remote.as_str(), "--delete", branch])?;
status_for_repo(&repo)
}
#[tauri::command] #[tauri::command]
pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> { pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -1405,18 +1537,43 @@ pub async fn pull(
path: String, path: String,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
strategy: Option<String>,
remote: Option<String>,
branch: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> { tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; 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()) { let output = match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { (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() _ => git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(pull_args) .args(&pull_args)
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?, .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
}; };
@@ -1445,18 +1602,30 @@ pub async fn fetch(
path: String, path: String,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
prune: Option<bool>,
remote: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> { tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; 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()) { let output = match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { (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() _ => git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(fetch_args) .args(&fetch_args)
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?, .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
}; };
@@ -1480,10 +1649,15 @@ pub async fn push(
path: String, path: String,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
force_with_lease: Option<bool>,
remote: Option<String>,
) -> Result<GitStatus, String> { ) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> { tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; 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()) { match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => { (Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated(&repo, push_args, u, p)?; run_git_authenticated(&repo, push_args, u, p)?;
@@ -1553,6 +1727,43 @@ fn remote_url_for(repo: &Path, remote: &str) -> Option<String> {
if url.is_empty() { None } else { Some(url) } 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> { fn upstream_remote_name(repo: &Path) -> Option<String> {
let branch = current_branch_name(repo).ok()?; let branch = current_branch_name(repo).ok()?;
let out = git_command() let out = git_command()
@@ -1605,7 +1816,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()) .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> { 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) { if branch_has_upstream(repo) {
return Ok(vec![OsString::from("push")]); return Ok(vec![OsString::from("push")]);
} }
@@ -1665,7 +1894,11 @@ pub fn cred_delete(key: String) -> Result<(), String> {
} }
#[tauri::command] #[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> { tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let branch = branch.trim(); let branch = branch.trim();
@@ -1673,10 +1906,19 @@ pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, Str
return Err("Branch name must not be empty.".to_string()); 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() let output = git_command()
.arg("-C") .arg("-C")
.arg(&repo) .arg(&repo)
.args(["merge", "--no-edit", branch]) .args(args)
.output() .output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
@@ -1708,6 +1950,52 @@ pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, Str
.map_err(|err| format!("Could not merge: {err}"))? .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] #[tauri::command]
pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> { pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> { tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
@@ -3114,6 +3402,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
files, files,
rebase_in_progress: rebase_in_progress(repo), rebase_in_progress: rebase_in_progress(repo),
cherry_pick_in_progress: cherry_pick_in_progress(repo), cherry_pick_in_progress: cherry_pick_in_progress(repo),
merge_in_progress: merge_in_progress(repo),
}) })
} }
@@ -3125,6 +3414,10 @@ fn cherry_pick_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "CHERRY_PICK_HEAD") 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 { fn git_path_exists(repo: &Path, name: &str) -> bool {
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else { let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
return false; return false;
@@ -5626,9 +5919,16 @@ mod tests {
], ],
); );
let status = pull(repo.path.to_string_lossy().to_string(), None, None) let status = pull(
.await repo.path.to_string_lossy().to_string(),
.unwrap(); None,
None,
None,
None,
None,
)
.await
.unwrap();
assert!(status.clean, "{:?}", status.files); assert!(status.clean, "{:?}", status.files);
assert!(repo.path.join("remote.txt").exists()); assert!(repo.path.join("remote.txt").exists());
@@ -5699,9 +5999,15 @@ mod tests {
["remote", "add", "origin", remote.path.to_str().unwrap()], ["remote", "add", "origin", remote.path.to_str().unwrap()],
); );
let status = push(repo.path.to_string_lossy().to_string(), None, None) let status = push(
.await repo.path.to_string_lossy().to_string(),
.unwrap(); None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!( assert_eq!(
status.upstream.as_deref(), status.upstream.as_deref(),
+22 -10
View File
@@ -5,21 +5,23 @@ mod git;
use badge::set_sync_badge; use badge::set_sync_badge;
use git::{ use git::{
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search, SearchCancellationState, add_remote, amend_commit, apply_file_patch, cancel_code_search,
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit, cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load, cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits, commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits,
compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete, compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete,
cred_load, cred_save, delete_branch, delete_tag, diff_file_against_working_tree, fetch, cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
get_file_blame, get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
list_commits, list_file_history, list_interactive_rebase_commits, list_reflog, get_status, init_repository, last_commit_message, list_branches, list_commits,
list_repository_files, list_stashes, list_tags, merge_branch, open_repo_in_explorer, list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
open_repository, open_repository_bundle, open_repository_file, pull, push, push_tag, list_repository_files, list_stashes, list_tags, merge_abort, merge_branch, merge_continue,
read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch, resolve_conflict, open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
resolve_conflict_side, restore_file_from_commit, restore_files, restore_reflog_entry, push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote,
restore_to_commit, run_sequence_editor_if_requested, search_code_introductions, stage_files, rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
restore_files, restore_reflog_entry, restore_to_commit, revert_commit,
run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream, stage_files,
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
unstage_files, unstage_files, update_remote,
}; };
use tauri::Manager; use tauri::Manager;
@@ -82,11 +84,18 @@ async fn main() {
builder builder
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
open_repository, open_repository,
init_repository,
clone_repository, clone_repository,
open_repo_in_explorer, open_repo_in_explorer,
open_repository_file, open_repository_file,
get_status, get_status,
list_branches, list_branches,
list_remotes,
add_remote,
update_remote,
remove_remote,
set_branch_upstream,
delete_remote_branch,
list_stashes, list_stashes,
checkout_branch, checkout_branch,
create_branch, create_branch,
@@ -124,6 +133,9 @@ async fn main() {
restore_to_commit, restore_to_commit,
restore_file_from_commit, restore_file_from_commit,
merge_branch, merge_branch,
merge_continue,
merge_abort,
revert_commit,
rebase_branch, rebase_branch,
rebase_continue, rebase_continue,
rebase_abort, rebase_abort,
+137 -5
View File
@@ -36,10 +36,12 @@
import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StashPanel from "./lib/components/StashPanel.svelte"; import StashPanel from "./lib/components/StashPanel.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte"; import StatusPanel from "./lib/components/StatusPanel.svelte";
import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte"; import UpdateToast from "./lib/components/UpdateToast.svelte";
import { import {
amendCommit, amendCommit,
addRemote,
checkoutBranch, checkoutBranch,
cherryPickAbort, cherryPickAbort,
cherryPickCommit, cherryPickCommit,
@@ -59,6 +61,8 @@
createTag, createTag,
deleteBranch, deleteBranch,
deleteTag, deleteTag,
deleteRemoteBranch,
initRepository,
diffFileAgainstWorkingTree, diffFileAgainstWorkingTree,
compareFileToParent, compareFileToParent,
fetchRemote, fetchRemote,
@@ -66,6 +70,7 @@
getStatus, getStatus,
lastCommitMessage, lastCommitMessage,
listBranches, listBranches,
listRemotes,
listStashes, listStashes,
listTags, listTags,
listCommits, listCommits,
@@ -74,12 +79,18 @@
listReflog, listReflog,
listRepositoryFiles, listRepositoryFiles,
mergeBranch, mergeBranch,
mergeAbort,
mergeContinue,
openRepoInExplorer, openRepoInExplorer,
openRepositoryFile, openRepositoryFile,
openRepositoryBundle, openRepositoryBundle,
pull, pull,
push, push,
pushTag, pushTag,
removeRemote,
revertCommit,
setBranchUpstream,
updateRemote,
renameBranch, renameBranch,
rebaseAbort, rebaseAbort,
rebaseBranch, rebaseBranch,
@@ -126,6 +137,8 @@
GitDiffFile, GitDiffFile,
GitFileStatus, GitFileStatus,
GitRepositoryFile, GitRepositoryFile,
GitRemote,
PullStrategy,
GitSearchHit, GitSearchHit,
GitStash, GitStash,
GitStatus, GitStatus,
@@ -230,6 +243,12 @@
let repoStatusCache: Record<string, RepoTab> = {}; let repoStatusCache: Record<string, RepoTab> = {};
let repoSearch = ""; let repoSearch = "";
let cloneDialogOpen = false; let cloneDialogOpen = false;
let pullStrategy: PullStrategy = (localStorage.getItem("gitlite.pullStrategy") as PullStrategy) || "merge";
let selectedRemote = localStorage.getItem("gitlite.selectedRemote") || "";
let remoteActionForceWithLease = false;
let remoteActionPrune = false;
let syncSettingsOpen = false;
let syncSettingsRemotes: GitRemote[] = [];
let cloneDialogError = ""; let cloneDialogError = "";
let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined; let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined;
let pendingClone: CloneRequest | null = null; let pendingClone: CloneRequest | null = null;
@@ -392,6 +411,7 @@
$: hasConflicts = conflictedFiles.length > 0; $: hasConflicts = conflictedFiles.length > 0;
$: rebaseInProgress = status?.rebase_in_progress ?? false; $: rebaseInProgress = status?.rebase_in_progress ?? false;
$: cherryPickInProgress = status?.cherry_pick_in_progress ?? false; $: cherryPickInProgress = status?.cherry_pick_in_progress ?? false;
$: mergeInProgress = status?.merge_in_progress ?? false;
// Amending/undoing is only offered while the last commit hasn't reached a // Amending/undoing is only offered while the last commit hasn't reached a
// remote yet: no upstream at all, or the branch is still ahead of it. // remote yet: no upstream at all, or the branch is still ahead of it.
$: canAmend = hasRepository && commits.length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress $: canAmend = hasRepository && commits.length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress
@@ -2314,8 +2334,11 @@
async function merge(branch: GitBranchInfo) { async function merge(branch: GitBranchInfo) {
if (!activeRepoPath || branch.current) return; if (!activeRepoPath || branch.current) return;
const strategy = (window.prompt("Merge strategy: default, squash, ff-only, or no-ff", "default") ?? "").trim();
if (!strategy) return;
if (!["default", "squash", "ff-only", "no-ff"].includes(strategy)) { errorMessage = "Unknown merge strategy."; return; }
await runOperation(`Merging ${branch.name}`, async () => { await runOperation(`Merging ${branch.name}`, async () => {
applyStatus(await mergeBranch(activeRepoPath, branch.name)); applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy as import("./lib/types").MergeStrategy));
await refreshBranchList(activeRepoPath); await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
@@ -2618,7 +2641,7 @@
) { ) {
errorMessage = ""; errorMessage = "";
await runOperation("Pulling", async () => { await runOperation("Pulling", async () => {
applyStatus(await pull(activeRepoPath, username, password)); applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
await refreshBranchList(activeRepoPath); await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
@@ -2639,7 +2662,8 @@
) { ) {
errorMessage = ""; errorMessage = "";
await runOperation("Fetching", async () => { await runOperation("Fetching", async () => {
applyStatus(await fetchRemote(activeRepoPath, username, password)); applyStatus(await fetchRemote(activeRepoPath, username, password, remoteActionPrune, selectedRemote || undefined));
remoteActionPrune = false;
await refreshRefsAndCommitGraph(activeRepoPath); await refreshRefsAndCommitGraph(activeRepoPath);
trackEvent("repository_fetched", { trackEvent("repository_fetched", {
from_stored_credential: fromStore ? 1 : 0, from_stored_credential: fromStore ? 1 : 0,
@@ -2658,7 +2682,8 @@
) { ) {
errorMessage = ""; errorMessage = "";
await runOperation("Pushing", async () => { await runOperation("Pushing", async () => {
applyStatus(await push(activeRepoPath, username, password)); applyStatus(await push(activeRepoPath, username, password, remoteActionForceWithLease, selectedRemote || undefined));
remoteActionForceWithLease = false;
await refreshBranchList(activeRepoPath); await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshFileHistory(activeRepoPath); await refreshFileHistory(activeRepoPath);
@@ -2782,6 +2807,78 @@
await startRemoteAction("push"); await startRemoteAction("push");
} }
async function deleteTrackedRemoteBranch(branch: GitBranchInfo) {
if (!activeRepoPath || !branch.remote) return;
const slash = branch.name.indexOf("/");
if (slash < 1) { errorMessage = "Could not determine remote name."; return; }
const remote = branch.name.slice(0, slash); const remoteBranch = branch.name.slice(slash + 1);
if (!window.confirm(`Delete '${remoteBranch}' from remote '${remote}'?`)) return;
await runOperation("Deleting remote branch", async () => { applyStatus(await deleteRemoteBranch(activeRepoPath, remote, remoteBranch)); await refreshRefsAndCommitGraph(activeRepoPath); });
}
async function initializeRepository() {
const selected = await openDialog({ directory: true, multiple: false, title: "Choose an empty or existing folder" });
if (typeof selected !== "string") return;
const branch = window.prompt("Initial branch name", "main")?.trim(); if (!branch) return;
await runOperation("Initializing repository", async () => { await initRepository(selected, branch); await openRepo(selected); });
}
async function revertHistoryCommit(commit: GitCommit) {
if (!activeRepoPath || !window.confirm(`Revert commit ${commit.short_hash} (${commit.summary}) with a new commit?`)) return;
await runOperation(`Reverting ${commit.short_hash}`, async () => {
applyStatus(await revertCommit(activeRepoPath, commit.hash));
await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); await refreshFileHistory(activeRepoPath);
});
}
async function continueMerge() {
if (!activeRepoPath) return;
await runOperation("Continuing merge", async () => { applyStatus(await mergeContinue(activeRepoPath)); await refreshCommitHistory(activeRepoPath); });
}
async function abortMerge() {
if (!activeRepoPath || !window.confirm("Abort the current merge and restore the pre-merge state?")) return;
await runOperation("Aborting merge", async () => { applyStatus(await mergeAbort(activeRepoPath)); await refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); });
}
async function fetchPruneRepo() {
remoteActionPrune = true;
await startRemoteAction("fetch");
}
async function forcePushRepo() {
if (!window.confirm("Push the current branch with --force-with-lease? This is intended for a branch whose history you rebased.")) return;
remoteActionForceWithLease = true;
await startRemoteAction("push");
}
async function openSyncOptions() {
if (!activeRepoPath) return;
try {
syncSettingsRemotes = await listRemotes(activeRepoPath);
syncSettingsOpen = true;
} catch (error) { errorMessage = errorToMessage(error); }
}
async function saveSyncSettings(strategy: PullStrategy, remote: string, upstream: string) {
if (!activeRepoPath || !status?.current_branch) return;
await runOperation("Saving sync settings", async () => {
pullStrategy = strategy; selectedRemote = remote;
localStorage.setItem("gitlite.pullStrategy", strategy); localStorage.setItem("gitlite.selectedRemote", remote);
applyStatus(await setBranchUpstream(activeRepoPath, status!.current_branch!, upstream || undefined));
await refreshBranchList(activeRepoPath); syncSettingsOpen = false;
});
}
async function addSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await addRemote(activeRepoPath, name, url); await refreshBranchList(activeRepoPath); }
async function updateSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await updateRemote(activeRepoPath, name, url); }
async function removeSyncRemote(name: string) {
if (!activeRepoPath || !window.confirm(`Remove remote '${name}'? Local commits and branches are kept.`)) return;
syncSettingsRemotes = await removeRemote(activeRepoPath, name);
if (selectedRemote === name) selectedRemote = "";
await refreshBranchList(activeRepoPath);
}
async function saveStash(message: string, includeUntracked: boolean) { async function saveStash(message: string, includeUntracked: boolean) {
if (!activeRepoPath || changedFiles.length === 0) return; if (!activeRepoPath || changedFiles.length === 0) return;
const stashedFiles = changedFiles.length; const stashedFiles = changedFiles.length;
@@ -3626,6 +3723,9 @@
onInteractiveRebase={openInteractiveRebase} onInteractiveRebase={openInteractiveRebase}
onReflog={openReflog} onReflog={openReflog}
onOpenInExplorer={openActiveRepoInExplorer} onOpenInExplorer={openActiveRepoInExplorer}
onFetchPrune={fetchPruneRepo}
onForcePush={forcePushRepo}
onSyncOptions={openSyncOptions}
/> />
{/if} {/if}
@@ -3671,7 +3771,15 @@
</section> </section>
{/if} {/if}
{#if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress} {#if workspaceActive && mergeInProgress}
<section class="notice conflict" role="status">
<GitMerge size={17} aria-hidden="true" />
<span>{hasConflicts ? "Merge in progress. Resolve all conflicts, then continue." : "Merge is ready to be completed."}</span>
{#if hasConflicts}<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>{/if}
<button type="button" onclick={continueMerge} disabled={isBusy || hasConflicts}>Continue</button>
<button type="button" onclick={abortMerge} disabled={isBusy}>Abort</button>
</section>
{:else if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
<section class="notice conflict" role="alert"> <section class="notice conflict" role="alert">
<GitMerge size={17} aria-hidden="true" /> <GitMerge size={17} aria-hidden="true" />
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span> <span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
@@ -3729,6 +3837,9 @@
<Download size={15} aria-hidden="true" /> <Download size={15} aria-hidden="true" />
Clone Clone
</button> </button>
<button class="btn-secondary" type="button" onclick={initializeRepository} disabled={isBusy}>
<Plus size={15} aria-hidden="true" /> Init
</button>
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}> <button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" /> <FolderOpen size={15} aria-hidden="true" />
Browse Browse
@@ -3904,6 +4015,7 @@
onCreateBranch={createNewBranch} onCreateBranch={createNewBranch}
onRenameBranch={renameLocalBranch} onRenameBranch={renameLocalBranch}
onDeleteBranch={deleteLocalBranch} onDeleteBranch={deleteLocalBranch}
onDeleteRemoteBranch={deleteTrackedRemoteBranch}
onCreateTag={createNewTag} onCreateTag={createNewTag}
onDeleteTag={deleteLocalTag} onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag} onPushTag={pushLocalTag}
@@ -4115,6 +4227,7 @@
onPreviewCommitFile={previewCommitFileFromHistory} onPreviewCommitFile={previewCommitFileFromHistory}
onCreateBranchFromCommit={openNewBranchDialog} onCreateBranchFromCommit={openNewBranchDialog}
onCherryPickCommit={cherryPickFromCommit} onCherryPickCommit={cherryPickFromCommit}
onRevertCommit={revertHistoryCommit}
onToggleCommitFiles={(hash) => { onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes); const next = new Set(expandedCommitHashes);
if (next.has(hash)) next.delete(hash); else next.add(hash); if (next.has(hash)) next.delete(hash); else next.add(hash);
@@ -4384,6 +4497,25 @@
/> />
{/if} {/if}
<!-- Clone repository dialog -->
{#if syncSettingsOpen}
<SyncSettingsDialog
remotes={syncSettingsRemotes}
remoteBranches={remoteBranches.map((branch) => branch.name)}
currentBranch={status?.current_branch ?? ""}
currentUpstream={status?.upstream ?? ""}
strategy={pullStrategy}
{selectedRemote}
{isBusy}
language={appLanguage}
onSaveSync={saveSyncSettings}
onAddRemote={addSyncRemote}
onUpdateRemote={updateSyncRemote}
onRemoveRemote={removeSyncRemote}
onClose={() => { if (!isBusy) syncSettingsOpen = false; }}
/>
{/if}
<!-- Clone repository dialog --> <!-- Clone repository dialog -->
{#if cloneDialogOpen} {#if cloneDialogOpen}
<CloneRepositoryDialog <CloneRepositoryDialog
+40
View File
@@ -6011,6 +6011,46 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.ai-review-finding-body > p { margin: 6px 0 8px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; } .ai-review-finding-body > p { margin: 6px 0 8px; color: var(--color-ink-muted); font-size: 12px; line-height: 1.5; }
.ai-review-location { display: flex; align-items: center; gap: 5px; color: var(--color-primary); } .ai-review-location { display: flex; align-items: center; gap: 5px; color: var(--color-primary); }
.ai-review-location code { overflow-wrap: anywhere; font: 10.5px/1.4 var(--font-mono); } .ai-review-location code { overflow-wrap: anywhere; font: 10.5px/1.4 var(--font-mono); }
/* Sync settings: one place for pull behavior, upstream and remote connections. */
.sync-settings-dialog { width: min(820px, calc(100vw - 32px)); max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-border); border-radius: 14px; background: var(--app-dialog-bg); box-shadow: 0 28px 80px rgba(0,0,0,.48); }
.sync-settings-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.sync-settings-title { display: flex; align-items: center; gap: 12px; }
.sync-settings-title h2 { margin: 2px 0 0; color: var(--color-ink); font-size: 19px; }
.sync-settings-icon { display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid rgba(90,140,248,.28); border-radius: 10px; color: var(--color-accent); background: rgba(90,140,248,.1); }
.sync-settings-body { display: grid; gap: 12px; min-height: 0; padding: 14px; overflow: auto; }
.sync-settings-card { padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 11px; background: var(--color-surface-raised); }
.sync-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 13px; }
.sync-card-heading h3 { margin: 0; color: var(--color-ink); font-size: 13px; }
.sync-card-heading p { margin: 4px 0 0; color: var(--color-ink-faint); font-size: 11px; }
.count-pill { min-width: 24px; padding: 3px 7px; border-radius: 999px; color: var(--color-ink-muted); background: var(--color-surface-hover); font: 700 10px var(--font-mono); text-align: center; }
.strategy-options { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
.strategy-options label { position: relative; display: grid; grid-template-columns: 18px 1fr; gap: 8px; min-height: 94px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 9px; color: var(--color-ink-muted); background: var(--app-input-bg); cursor: pointer; }
.strategy-options label.active { border-color: rgba(90,140,248,.65); box-shadow: inset 0 0 0 1px rgba(90,140,248,.18); background: rgba(90,140,248,.08); }
.strategy-options input { margin-top: 2px; accent-color: var(--color-accent); }
.strategy-options span { display: grid; align-content: start; gap: 5px; }
.strategy-options strong { color: var(--color-ink); font-size: 12px; }
.strategy-options small, .sync-fields small { color: var(--color-ink-faint); font-size: 10px; line-height: 1.45; }
.sync-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-border-subtle); }
.sync-fields label { display: grid; gap: 6px; color: var(--color-ink-muted); font-size: 11px; font-weight: 700; }
.sync-fields select, .remote-add input, .remote-edit input { width: 100%; height: 35px; border: 1px solid var(--color-border-input); border-radius: 7px; color: var(--color-ink); background: var(--app-input-bg); font-size: 11px; }
.sync-fields select { padding: 0 9px; }
.remote-list { display: grid; gap: 5px; }
.remote-row { display: grid; grid-template-columns: 28px minmax(0,1fr) auto auto; align-items: center; gap: 7px; min-height: 48px; padding: 6px 7px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--app-input-bg); }
.remote-mark { display: grid; place-items: center; color: var(--color-accent); }
.remote-main { display: grid; gap: 3px; min-width: 0; padding: 0; border: 0; color: var(--color-ink); background: transparent; text-align: left; }
.remote-main strong, .remote-edit strong { font-size: 11.5px; }
.remote-main span { overflow: hidden; color: var(--color-ink-faint); font: 10px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
.remote-edit { display: grid; grid-template-columns: 80px minmax(0,1fr); align-items: center; gap: 8px; }
.remote-edit input, .remote-add input { padding: 0 9px; }
.remote-delete { display: grid; place-items: center; width: 30px; height: 30px; border: 0; border-radius: 6px; color: #e86060; background: transparent; }
.remote-delete:hover { background: rgba(235,87,87,.1); }
.remote-empty { margin: 4px 0 10px; color: var(--color-ink-faint); font-size: 11px; }
.remote-add { display: grid; grid-template-columns: 120px minmax(180px,1fr) auto; gap: 7px; margin-top: 9px; padding-top: 10px; border-top: 1px solid var(--color-border-subtle); }
.sync-settings-footer { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 13px 20px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.sync-settings-footer p { max-width: 440px; margin: 0; color: var(--color-ink-faint); font-size: 9.5px; line-height: 1.4; }
.sync-settings-footer > div { display: flex; gap: 8px; }
@media (max-width: 720px) { .strategy-options, .sync-fields { grid-template-columns: 1fr; } .remote-add { grid-template-columns: 1fr; } .sync-settings-footer { align-items: stretch; flex-direction: column; } .sync-settings-footer > div { justify-content: flex-end; } }
.ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); } .ai-review-suggestion { display: grid; gap: 3px; margin-top: 9px; padding: 8px 9px; border-radius: 5px; background: var(--color-surface-dim); }
.ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; } .ai-review-suggestion strong { color: var(--color-ink-dim); font-size: 9.5px; text-transform: uppercase; letter-spacing: .05em; }
.ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; } .ai-review-suggestion span { color: var(--color-ink-muted); font-size: 11.5px; line-height: 1.45; }
+18 -3
View File
@@ -11,6 +11,7 @@
RefreshCw, RefreshCw,
Search, Search,
Upload, Upload,
Settings2,
} from "@lucide/svelte"; } from "@lucide/svelte";
export let hasRepository: boolean = false; export let hasRepository: boolean = false;
@@ -28,8 +29,12 @@
export let onInteractiveRebase: () => void = () => {}; export let onInteractiveRebase: () => void = () => {};
export let onReflog: () => void = () => {}; export let onReflog: () => void = () => {};
export let onOpenInExplorer: () => void = () => {}; export let onOpenInExplorer: () => void = () => {};
export let onFetchPrune: () => void = () => {};
export let onForcePush: () => void = () => {};
export let onSyncOptions: () => void = () => {};
let historyOpen = false; let historyOpen = false;
let syncOpen = false;
let toolbarElement: HTMLDivElement; let toolbarElement: HTMLDivElement;
$: isGerman = language === "de"; $: isGerman = language === "de";
@@ -40,9 +45,7 @@
} }
function handleWindowClick(event: MouseEvent) { function handleWindowClick(event: MouseEvent) {
if (historyOpen && toolbarElement && !toolbarElement.contains(event.target as Node)) { if (toolbarElement && !toolbarElement.contains(event.target as Node)) { historyOpen = false; syncOpen = false; }
historyOpen = false;
}
} }
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
@@ -108,6 +111,18 @@
<span class="repo-action-label">Push</span> <span class="repo-action-label">Push</span>
{#if ahead > 0}<span class="repo-action-count ahead">{ahead}</span>{/if} {#if ahead > 0}<span class="repo-action-count ahead">{ahead}</span>{/if}
</button> </button>
<div class="repo-history-wrap">
<button class="repo-action" type="button" onclick={() => { syncOpen = !syncOpen; historyOpen = false; }} disabled={!hasRepository || isBusy} aria-label={isGerman ? "Sync-Optionen" : "Sync options"} aria-haspopup="menu">
<ChevronDown size={14} aria-hidden="true" />
</button>
{#if syncOpen}
<div class="repo-history-menu" role="menu">
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onFetchPrune(); }}><CloudDownload size={15} /><span><strong>Fetch + Prune</strong><small>{isGerman ? "Veraltete Remote-Branches entfernen" : "Remove stale remote branches"}</small></span></button>
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onForcePush(); }}><Upload size={15} /><span><strong>Force with lease</strong><small>{isGerman ? "Sicheres Pushen nach Rebase" : "Safe push after rebase"}</small></span></button>
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onSyncOptions(); }}><Settings2 size={15} /><span><strong>{isGerman ? "Remotes & Strategien" : "Remotes & strategies"}</strong><small>{isGerman ? "Upstream, Pull und Remote verwalten" : "Manage upstream, pull and remotes"}</small></span></button>
</div>
{/if}
</div>
</div> </div>
</div> </div>
+6 -4
View File
@@ -54,6 +54,7 @@
onCreateBranch: (branchName: string) => void | Promise<void>; onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>; onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>; onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onCreateTag: (name: string, message: string) => void | Promise<void>; onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>; onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>; onPushTag: (tag: GitTag) => void | Promise<void>;
@@ -74,6 +75,7 @@
onCreateBranch = () => {}, onCreateBranch = () => {},
onRenameBranch = () => {}, onRenameBranch = () => {},
onDeleteBranch = () => {}, onDeleteBranch = () => {},
onDeleteRemoteBranch = () => {},
onCreateTag = () => {}, onCreateTag = () => {},
onDeleteTag = () => {}, onDeleteTag = () => {},
onPushTag = () => {}, onPushTag = () => {},
@@ -266,7 +268,7 @@
const branch = contextBranch; const branch = contextBranch;
if (!branch || branch.current || branch.remote || isBusy) return; if (!branch || branch.current || branch.remote || isBusy) return;
closeBranchContextMenu(); closeBranchContextMenu();
await onDeleteBranch(branch); if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
} }
async function checkoutContextBranch() { async function checkoutContextBranch() {
@@ -669,11 +671,11 @@
type="button" type="button"
role="menuitem" role="menuitem"
onclick={deleteContextBranch} onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current || contextBranch.remote} disabled={isBusy || contextBranch.current}
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Remote branch cannot be deleted here" : "Delete local branch"} title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Delete remote branch" : "Delete local branch"}
> >
<Trash2 size={14} aria-hidden="true" /> <Trash2 size={14} aria-hidden="true" />
Delete {contextBranch.remote ? "Delete remote" : "Delete"}
</button> </button>
</div> </div>
{/if} {/if}
+12
View File
@@ -42,6 +42,7 @@
onToggleCommitFiles: (hash: string) => void; onToggleCommitFiles: (hash: string) => void;
onCreateBranchFromCommit: (commit: GitCommit) => void; onCreateBranchFromCommit: (commit: GitCommit) => void;
onCherryPickCommit: (commit: GitCommit) => void; onCherryPickCommit: (commit: GitCommit) => void;
onRevertCommit: (commit: GitCommit) => void;
} }
let { let {
@@ -58,6 +59,7 @@
onToggleCommitFiles = () => {}, onToggleCommitFiles = () => {},
onCreateBranchFromCommit = () => {}, onCreateBranchFromCommit = () => {},
onCherryPickCommit = () => {}, onCherryPickCommit = () => {},
onRevertCommit = () => {},
}: Props = $props(); }: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set()); let hiddenGraphBranches = $state<Set<string>>(new Set());
@@ -406,6 +408,13 @@
await onCherryPickCommit(commit); await onCherryPickCommit(commit);
} }
async function revertContextCommit() {
const commit = contextCommit;
if (!commit || isBusy) return;
closeCommitContextMenu();
await onRevertCommit(commit);
}
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
closeCommitContextMenu(); closeCommitContextMenu();
@@ -718,6 +727,9 @@
<Cherry size={14} aria-hidden="true" /> <Cherry size={14} aria-hidden="true" />
Cherry-pick Cherry-pick
</button> </button>
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title="Create a new commit that reverses this commit">
<RotateCcw size={14} aria-hidden="true" /> Revert
</button>
</div> </div>
{/if} {/if}
</section> </section>
@@ -0,0 +1,82 @@
<script lang="ts">
import { Cloud, GitBranch, Plus, Save, Trash2, X } from "@lucide/svelte";
import type { GitRemote, PullStrategy } from "../types";
export let remotes: GitRemote[] = [];
export let remoteBranches: string[] = [];
export let currentBranch = "";
export let currentUpstream = "";
export let strategy: PullStrategy = "merge";
export let selectedRemote = "";
export let isBusy = false;
export let language: "en" | "de" = "en";
export let onSaveSync: (strategy: PullStrategy, remote: string, upstream: string) => void | Promise<void> = () => {};
export let onAddRemote: (name: string, url: string) => void | Promise<void> = () => {};
export let onUpdateRemote: (name: string, url: string) => void | Promise<void> = () => {};
export let onRemoveRemote: (name: string) => void | Promise<void> = () => {};
export let onClose: () => void = () => {};
let draftStrategy = strategy;
let draftRemote = selectedRemote;
let draftUpstream = currentUpstream;
let newName = "origin";
let newUrl = "";
let editingName = "";
let editingUrl = "";
$: de = language === "de";
function beginEdit(remote: GitRemote) { editingName = remote.name; editingUrl = remote.fetch_url; }
function cancelEdit() { editingName = ""; editingUrl = ""; }
async function add() { if (!newName.trim() || !newUrl.trim()) return; await onAddRemote(newName.trim(), newUrl.trim()); newName = "origin"; newUrl = ""; }
async function update() { if (!editingName || !editingUrl.trim()) return; await onUpdateRemote(editingName, editingUrl.trim()); cancelEdit(); }
</script>
<div class="dialog-backdrop" role="presentation">
<div class="sync-settings-dialog" role="dialog" aria-modal="true" aria-labelledby="sync-settings-title">
<header class="sync-settings-head">
<div class="sync-settings-title">
<span class="sync-settings-icon"><Cloud size={18} aria-hidden="true" /></span>
<div><span class="eyebrow">Git sync</span><h2 id="sync-settings-title">{de ? "Synchronisierung & Remotes" : "Sync & remotes"}</h2></div>
</div>
<button class="dialog-icon-button" type="button" onclick={onClose} disabled={isBusy} aria-label={de ? "Schließen" : "Close"}><X size={17} /></button>
</header>
<div class="sync-settings-body">
<section class="sync-settings-card">
<div class="sync-card-heading"><div><h3>{de ? "Pull-Verhalten" : "Pull behavior"}</h3><p>{de ? "Legt fest, wie entfernte Änderungen in deinen aktuellen Branch übernommen werden." : "Controls how remote changes are integrated into your current branch."}</p></div></div>
<div class="strategy-options">
<label class:active={draftStrategy === "merge"}><input type="radio" bind:group={draftStrategy} value="merge" /><span><strong>Merge</strong><small>{de ? "Erstellt bei getrennten Verläufen einen Merge-Commit. Sicher und leicht nachvollziehbar." : "Creates a merge commit for diverged history. Safe and easy to follow."}</small></span></label>
<label class:active={draftStrategy === "rebase"}><input type="radio" bind:group={draftStrategy} value="rebase" /><span><strong>Rebase</strong><small>{de ? "Setzt deine lokalen Commits auf die Remote-Änderungen. Ergibt eine lineare Historie." : "Replays your local commits on remote changes for a linear history."}</small></span></label>
<label class:active={draftStrategy === "ff-only"}><input type="radio" bind:group={draftStrategy} value="ff-only" /><span><strong>Fast-forward only</strong><small>{de ? "Pull stoppt, sobald ein Merge nötig wäre. Verändert die Historie nie automatisch." : "Stops when a merge would be required. Never combines diverged history automatically."}</small></span></label>
</div>
<div class="sync-fields">
<label><span>{de ? "Remote für Sync" : "Remote used for sync"}</span><select bind:value={draftRemote}><option value="">{de ? "Automatisch wählen" : "Choose automatically"}</option>{#each remotes as remote}<option value={remote.name}>{remote.name}</option>{/each}</select><small>{de ? "Ein Remote ist die gespeicherte Verbindung zu einem Server-Repository." : "A remote is a saved connection to a repository on a server."}</small></label>
<label><span>{de ? `Upstream für ${currentBranch || "aktuellen Branch"}` : `Upstream for ${currentBranch || "current branch"}`}</span><select bind:value={draftUpstream}><option value="">{de ? "Kein Upstream" : "No upstream"}</option>{#each remoteBranches as branch}<option value={branch}>{branch}</option>{/each}</select><small>{de ? "Der Upstream ist der Remote-Branch, mit dem Pull, Push und Ahead/Behind verglichen werden." : "The upstream is the remote branch used by Pull, Push, and Ahead/Behind."}</small></label>
</div>
</section>
<section class="sync-settings-card">
<div class="sync-card-heading"><div><h3>Remotes</h3><p>{de ? "Server-Verbindungen dieses Repositorys verwalten." : "Manage this repository's server connections."}</p></div><span class="count-pill">{remotes.length}</span></div>
<div class="remote-list">
{#each remotes as remote (remote.name)}
<div class="remote-row">
<span class="remote-mark"><GitBranch size={15} /></span>
{#if editingName === remote.name}
<div class="remote-edit"><strong>{remote.name}</strong><input bind:value={editingUrl} aria-label={`URL for ${remote.name}`} /></div>
<button class="btn-sm" type="button" onclick={update} disabled={isBusy || !editingUrl.trim()}><Save size={13} /> {de ? "Speichern" : "Save"}</button>
<button class="btn-sm" type="button" onclick={cancelEdit} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button>
{:else}
<button class="remote-main" type="button" onclick={() => beginEdit(remote)} disabled={isBusy}><strong>{remote.name}</strong><span>{remote.fetch_url}</span></button>
<button class="remote-delete" type="button" onclick={() => onRemoveRemote(remote.name)} disabled={isBusy} aria-label={`${de ? "Remote löschen" : "Remove remote"} ${remote.name}`}><Trash2 size={14} /></button>
{/if}
</div>
{:else}<p class="remote-empty">{de ? "Noch kein Remote eingerichtet." : "No remote configured yet."}</p>{/each}
</div>
<div class="remote-add"><input bind:value={newName} placeholder={de ? "Name, z. B. origin" : "Name, e.g. origin"} aria-label="Remote name" /><input bind:value={newUrl} placeholder="https://… or git@…" aria-label="Remote URL" /><button class="btn-secondary" type="button" onclick={add} disabled={isBusy || !newName.trim() || !newUrl.trim()}><Plus size={14} /> {de ? "Hinzufügen" : "Add remote"}</button></div>
</section>
</div>
<footer class="sync-settings-footer"><p>{de ? "Fetch + Prune entfernt veraltete Remote-Verweise. Force with lease ist ein geschütztes Force-Push nach einem Rebase." : "Fetch + Prune removes stale remote references. Force with lease is a protected force-push after a rebase."}</p><div><button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button><button class="btn-primary" type="button" onclick={() => onSaveSync(draftStrategy, draftRemote, draftUpstream)} disabled={isBusy}><Save size={14} /> {de ? "Einstellungen speichern" : "Save settings"}</button></div></footer>
</div>
</div>
+25 -8
View File
@@ -11,6 +11,9 @@ import type {
GitCommit, GitCommit,
GitCommitComparison, GitCommitComparison,
GitRepositoryFile, GitRepositoryFile,
GitRemote,
MergeStrategy,
PullStrategy,
RebaseCommit, RebaseCommit,
RebasePlanItem, RebasePlanItem,
ReflogEntry, ReflogEntry,
@@ -28,6 +31,10 @@ export function openRepository(path: string): Promise<GitStatus> {
return invoke<GitStatus>("open_repository", { path }); return invoke<GitStatus>("open_repository", { path });
} }
export function initRepository(path: string, initialBranch = "main"): Promise<GitStatus> {
return invoke<GitStatus>("init_repository", { path, initialBranch });
}
export function openRepoInExplorer(path: string): Promise<void> { export function openRepoInExplorer(path: string): Promise<void> {
return invoke<void>("open_repo_in_explorer", { path }); return invoke<void>("open_repo_in_explorer", { path });
} }
@@ -72,6 +79,13 @@ export function listBranches(path: string): Promise<GitBranch[]> {
return invoke<GitBranch[]>("list_branches", { path }); return invoke<GitBranch[]>("list_branches", { path });
} }
export function listRemotes(path: string): Promise<GitRemote[]> { return invoke("list_remotes", { path }); }
export function addRemote(path: string, name: string, url: string): Promise<GitRemote[]> { return invoke("add_remote", { path, name, url }); }
export function updateRemote(path: string, name: string, url: string): Promise<GitRemote[]> { return invoke("update_remote", { path, name, url }); }
export function removeRemote(path: string, name: string): Promise<GitRemote[]> { return invoke("remove_remote", { path, name }); }
export function setBranchUpstream(path: string, branch: string, upstream?: string): Promise<GitStatus> { return invoke("set_branch_upstream", { path, branch, upstream: upstream || null }); }
export function deleteRemoteBranch(path: string, remote: string, branch: string): Promise<GitStatus> { return invoke("delete_remote_branch", { path, remote, branch }); }
export function listStashes(path: string): Promise<GitStash[]> { export function listStashes(path: string): Promise<GitStash[]> {
return invoke<GitStash[]>("list_stashes", { path }); return invoke<GitStash[]>("list_stashes", { path });
} }
@@ -255,16 +269,16 @@ export function commitAiReview(path: string, options: CommitAiGenerateOptions):
}); });
} }
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> { export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise<GitStatus> {
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null }); return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null });
} }
export function fetchRemote(path: string, username?: string, password?: string): Promise<GitStatus> { export function fetchRemote(path: string, username?: string, password?: string, prune = false, remote?: string): Promise<GitStatus> {
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null }); return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null, prune, remote: remote || null });
} }
export function push(path: string, username?: string, password?: string): Promise<GitStatus> { export function push(path: string, username?: string, password?: string, forceWithLease = false, remote?: string): Promise<GitStatus> {
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null }); return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null, forceWithLease, remote: remote || null });
} }
export function getRemoteUrl(path: string): Promise<string | null> { export function getRemoteUrl(path: string): Promise<string | null> {
@@ -304,9 +318,12 @@ export function restoreFileFromCommit(
return invoke<GitStatus>("restore_file_from_commit", { path, commit, file }); return invoke<GitStatus>("restore_file_from_commit", { path, commit, file });
} }
export function mergeBranch(path: string, branch: string): Promise<GitStatus> { export function mergeBranch(path: string, branch: string, strategy: MergeStrategy = "default"): Promise<GitStatus> {
return invoke<GitStatus>("merge_branch", { path, branch }); return invoke<GitStatus>("merge_branch", { path, branch, strategy });
} }
export function mergeContinue(path: string): Promise<GitStatus> { return invoke("merge_continue", { path }); }
export function mergeAbort(path: string): Promise<GitStatus> { return invoke("merge_abort", { path }); }
export function revertCommit(path: string, commit: string): Promise<GitStatus> { return invoke("revert_commit", { path, commit }); }
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> { export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_branch", { path, branch }); return invoke<GitStatus>("rebase_branch", { path, branch });
+5
View File
@@ -68,8 +68,13 @@ export interface GitStatus {
clean: boolean; clean: boolean;
rebase_in_progress: boolean; rebase_in_progress: boolean;
cherry_pick_in_progress: boolean; cherry_pick_in_progress: boolean;
merge_in_progress: boolean;
} }
export interface GitRemote { name: string; fetch_url: string; push_url: string; }
export type PullStrategy = "merge" | "rebase" | "ff-only";
export type MergeStrategy = "default" | "squash" | "ff-only" | "no-ff";
export interface GitFileStatus { export interface GitFileStatus {
path: string; path: string;
old_path: string | null; old_path: string | null;