From f7b8beaad477fff7e9c913b0a3bfc53acd58c587 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 10 Jul 2026 22:49:08 +0200 Subject: [PATCH] feat(git): add interactive rebase and reflog recovery features This update significantly expands Git functionality by implementing support for advanced workflows, including interactive rebasing and recovering lost commits via the reflog. New logic handles preparing the necessary environment files (todo lists and reword queues) required by Git's internal editors. The frontend components are also updated to expose these new capabilities to the user interface. - Implements full planning and execution flow for interactive rebase - Adds functionality to list and restore commits using the reflog history - Updates Rust backend commands to support advanced git operations --- feature-qa.html | 1 + src-tauri/src/git.rs | 532 +++++++++++++++++- src-tauri/src/main.rs | 31 +- src/App.svelte | 155 ++++- src/app.css | 131 +++++ src/feature-qa.ts | 27 + src/lib/TitleBar.svelte | 26 +- .../components/InteractiveRebaseDialog.svelte | 148 +++++ src/lib/components/ReflogDialog.svelte | 80 +++ src/lib/git.ts | 23 + src/lib/types.ts | 25 + 11 files changed, 1168 insertions(+), 11 deletions(-) create mode 100644 feature-qa.html create mode 100644 src/feature-qa.ts create mode 100644 src/lib/components/InteractiveRebaseDialog.svelte create mode 100644 src/lib/components/ReflogDialog.svelte diff --git a/feature-qa.html b/feature-qa.html new file mode 100644 index 0000000..11cb091 --- /dev/null +++ b/feature-qa.html @@ -0,0 +1 @@ +Git features QA
diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 6d881ea..59c03a7 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1,7 +1,9 @@ use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, BTreeSet}, + env, ffi::{OsStr, OsString}, + fs, path::{Path, PathBuf}, process::{Command, Output, Stdio}, sync::{ @@ -170,6 +172,108 @@ pub struct GitSearchHit { pub matches_added: u32, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RebaseCommit { + pub hash: String, + pub short_hash: String, + pub summary: String, + pub author_name: String, + pub date: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RebaseAction { + Pick, + Reword, + Squash, + Fixup, + Drop, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RebasePlanItem { + pub hash: String, + pub action: RebaseAction, + pub message: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ReflogEntry { + pub hash: String, + pub short_hash: String, + pub selector: String, + pub action: String, + pub author_name: String, + pub date: String, +} + +const SEQUENCE_EDITOR_PLAN_ENV: &str = "GITTY_SEQUENCE_EDITOR_PLAN"; +const COMMIT_EDITOR_QUEUE_ENV: &str = "GITTY_COMMIT_EDITOR_QUEUE"; + +pub fn run_sequence_editor_if_requested() -> Option> { + let executable = env::current_exe().ok()?; + let executable_name = executable.file_name()?.to_string_lossy(); + let target = env::args_os() + .nth(1) + .ok_or_else(|| "Git did not provide a sequence-editor target path.".to_string()); + if executable_name.contains("gitty-sequence-editor") { + let plan = env::var_os(SEQUENCE_EDITOR_PLAN_ENV)?; + return Some(target.and_then(|target| { + fs::copy(PathBuf::from(plan), PathBuf::from(target)) + .map(|_| ()) + .map_err(|err| format!("Could not write the interactive rebase plan: {err}")) + })); + } + if executable_name.contains("gitty-commit-editor") { + let queue = env::var_os(COMMIT_EDITOR_QUEUE_ENV)?; + return Some( + target.and_then(|target| apply_reword_message(Path::new(&queue), Path::new(&target))), + ); + } + None +} + +fn apply_reword_message(queue_path: &Path, message_path: &Path) -> Result<(), String> { + let queue = fs::read_to_string(queue_path) + .map_err(|err| format!("Could not read the reword queue: {err}"))?; + if queue.is_empty() { + return Ok(()); + } + let current = fs::read_to_string(message_path) + .map_err(|err| format!("Could not read the commit message: {err}"))?; + let current_subject = current + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with('#')) + .unwrap_or(""); + + let mut remaining = Vec::new(); + let mut replacement = None; + for record in queue.split('\x1e').filter(|record| !record.is_empty()) { + let Some((old, new)) = record.split_once('\x1f') else { + return Err("The reword queue is malformed.".to_string()); + }; + if replacement.is_none() && old == current_subject { + replacement = Some(new.to_string()); + } else { + remaining.push(record); + } + } + + if let Some(message) = replacement { + fs::write(message_path, format!("{message}\n")) + .map_err(|err| format!("Could not update the commit message: {err}"))?; + let mut next_queue = remaining.join("\x1e"); + if !next_queue.is_empty() { + next_queue.push('\x1e'); + } + fs::write(queue_path, next_queue) + .map_err(|err| format!("Could not update the reword queue: {err}"))?; + } + Ok(()) +} + #[derive(Debug, Default, Clone, PartialEq, Eq)] struct BranchInfo { current_branch: Option, @@ -838,7 +942,11 @@ pub async fn unstage_files(path: String, files: Vec) -> Result, staged: bool) -> Result { +pub async fn restore_files( + path: String, + files: Vec, + staged: bool, +) -> Result { tauri::async_runtime::spawn_blocking(move || -> Result { let repo = resolve_repo(&path)?; validate_files(&files)?; @@ -1481,6 +1589,97 @@ pub async fn rebase_branch(path: String, branch: String) -> Result Result, String> { + let repo = resolve_repo(&path)?; + interactive_rebase_commits_for_repo(&repo, &base) +} + +#[tauri::command] +pub async fn start_interactive_rebase( + path: String, + base: String, + plan: Vec, +) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let repo = resolve_repo(&path)?; + let status = status_for_repo(&repo)?; + if !status.clean { + return Err( + "Commit or stash working tree changes before starting an interactive rebase." + .to_string(), + ); + } + if status.rebase_in_progress || status.cherry_pick_in_progress { + return Err( + "Finish the current Git operation before starting an interactive rebase." + .to_string(), + ); + } + + let base_hash = verify_commit(&repo, &base)?; + ensure_ancestor( + &repo, + &base_hash, + "The selected base must be an ancestor of HEAD.", + )?; + let available = interactive_rebase_commits_for_repo(&repo, &base_hash)?; + validate_rebase_plan(&available, &plan)?; + + let todo = build_rebase_todo(&available, &plan)?; + let reword_queue = build_reword_queue(&available, &plan)?; + let git_dir = git_dir_for_repo(&repo)?; + let todo_path = git_dir.join("gitty-interactive-rebase-todo"); + let reword_queue_path = git_dir.join("gitty-interactive-rebase-messages"); + fs::write(&todo_path, todo) + .map_err(|err| format!("Could not prepare interactive rebase plan: {err}"))?; + fs::write(&reword_queue_path, reword_queue) + .map_err(|err| format!("Could not prepare reword messages: {err}"))?; + + let sequence_helper_name = if cfg!(windows) { + format!(".gitty-sequence-editor-{}.exe", std::process::id()) + } else { + format!(".gitty-sequence-editor-{}", std::process::id()) + }; + let commit_helper_name = if cfg!(windows) { + format!(".gitty-commit-editor-{}.exe", std::process::id()) + } else { + format!(".gitty-commit-editor-{}", std::process::id()) + }; + let sequence_helper_path = repo.join(&sequence_helper_name); + let commit_helper_path = repo.join(&commit_helper_name); + let current_exe = env::current_exe() + .map_err(|err| format!("Could not locate the Gitty executable: {err}"))?; + fs::copy(¤t_exe, &sequence_helper_path) + .and_then(|_| fs::copy(¤t_exe, &commit_helper_path)) + .map_err(|err| format!("Could not prepare interactive rebase helpers: {err}"))?; + + let sequence_editor_command = format!("./{sequence_helper_name}"); + let commit_editor_command = format!("./{commit_helper_name}"); + let output = git_command() + .arg("-C") + .arg(&repo) + .args(["rebase", "-i", base_hash.as_str()]) + .env("GIT_SEQUENCE_EDITOR", sequence_editor_command) + .env(SEQUENCE_EDITOR_PLAN_ENV, &todo_path) + .env("GIT_EDITOR", commit_editor_command) + .env(COMMIT_EDITOR_QUEUE_ENV, &reword_queue_path) + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}")); + + let _ = fs::remove_file(&todo_path); + let _ = fs::remove_file(&reword_queue_path); + let _ = fs::remove_file(&sequence_helper_path); + let _ = fs::remove_file(&commit_helper_path); + rebase_status_or_error(&repo, output?, "Interactive rebase failed", true) + }) + .await + .map_err(|err| format!("Could not run interactive rebase: {err}"))? +} + #[tauri::command] pub fn rebase_continue(path: String) -> Result { let repo = resolve_repo(&path)?; @@ -1510,6 +1709,232 @@ pub fn rebase_abort(path: String) -> Result { status_for_repo(&repo) } +#[tauri::command] +pub fn list_reflog(path: String, limit: Option) -> Result, String> { + let repo = resolve_repo(&path)?; + let limit = limit.unwrap_or(250).clamp(1, 1_000).to_string(); + let output = run_git( + &repo, + [ + "reflog", + "show", + "--date=iso-strict", + "--format=%H%x1f%h%x1f%gD%x1f%gs%x1f%an%x1f%aI%x1e", + "-n", + limit.as_str(), + ], + )?; + parse_reflog(&output) +} + +#[tauri::command] +pub fn restore_reflog_entry( + path: String, + commit: String, + branch: String, +) -> Result { + let repo = resolve_repo(&path)?; + let status = status_for_repo(&repo)?; + if !status.clean { + return Err( + "Commit or stash working tree changes before restoring from the reflog.".to_string(), + ); + } + let commit_hash = verify_commit(&repo, &commit)?; + let branch = validate_new_branch_name(&repo, &branch)?; + run_git( + &repo, + ["checkout", "-b", branch.as_str(), commit_hash.as_str()], + )?; + status_for_repo(&repo) +} + +fn interactive_rebase_commits_for_repo( + repo: &Path, + base: &str, +) -> Result, String> { + let base_hash = verify_commit(repo, base)?; + ensure_ancestor( + repo, + &base_hash, + "The selected base must be an ancestor of HEAD.", + )?; + + let range = format!("{base_hash}..HEAD"); + let merges = run_git(repo, ["rev-list", "--merges", range.as_str()])?; + if !String::from_utf8_lossy(&merges).trim().is_empty() { + return Err("Interactive rebase currently supports linear commit ranges only. Choose a base after the last merge commit.".to_string()); + } + + let output = run_git( + repo, + [ + "log", + "--reverse", + "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1e", + range.as_str(), + ], + )?; + + let mut commits = Vec::new(); + for raw in output.split(|byte| *byte == 0x1e) { + let record = String::from_utf8_lossy(raw); + let record = record.trim_matches(['\r', '\n', ' ']); + if record.is_empty() { + continue; + } + let fields = record.split('\x1f').collect::>(); + if fields.len() != 5 { + return Err("Git returned an unexpected interactive rebase record.".to_string()); + } + commits.push(RebaseCommit { + hash: fields[0].to_string(), + short_hash: fields[1].to_string(), + summary: fields[2].to_string(), + author_name: fields[3].to_string(), + date: fields[4].to_string(), + }); + } + Ok(commits) +} + +fn ensure_ancestor(repo: &Path, commit: &str, message: &str) -> Result<(), String> { + let output = git_command() + .arg("-C") + .arg(repo) + .args(["merge-base", "--is-ancestor", commit, "HEAD"]) + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + if output.status.success() { + Ok(()) + } else { + Err(message.to_string()) + } +} + +fn validate_rebase_plan(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result<(), String> { + if commits.is_empty() { + return Err("There are no commits to rebase onto the selected base.".to_string()); + } + if commits.len() != plan.len() { + return Err("The rebase plan must include every commit exactly once.".to_string()); + } + + let expected = commits + .iter() + .map(|item| item.hash.as_str()) + .collect::>(); + let actual = plan + .iter() + .map(|item| item.hash.as_str()) + .collect::>(); + if actual.len() != plan.len() || actual != expected { + return Err( + "The rebase plan contains missing, duplicate, or unexpected commits.".to_string(), + ); + } + + let mut has_kept_commit = false; + for item in plan { + match item.action { + RebaseAction::Drop => {} + RebaseAction::Squash | RebaseAction::Fixup if !has_kept_commit => { + return Err("Squash and fixup need an earlier picked commit.".to_string()); + } + RebaseAction::Reword => { + let message = item.message.as_deref().unwrap_or("").trim(); + if message.is_empty() || message.contains(['\r', '\n']) { + return Err("Reword messages must be a single non-empty line.".to_string()); + } + has_kept_commit = true; + } + _ => has_kept_commit = true, + } + } + if !has_kept_commit { + return Err("Keep at least one commit in the interactive rebase plan.".to_string()); + } + Ok(()) +} + +fn build_rebase_todo(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result { + let by_hash = commits + .iter() + .map(|commit| (commit.hash.as_str(), commit)) + .collect::>(); + let mut todo = String::new(); + for item in plan { + let commit = by_hash + .get(item.hash.as_str()) + .ok_or_else(|| "The rebase plan references an unknown commit.".to_string())?; + let summary = commit.summary.replace(['\r', '\n'], " "); + match item.action { + RebaseAction::Pick => todo.push_str(&format!("pick {} {}\n", item.hash, summary)), + RebaseAction::Reword => todo.push_str(&format!("reword {} {}\n", item.hash, summary)), + RebaseAction::Squash => todo.push_str(&format!("squash {} {}\n", item.hash, summary)), + RebaseAction::Fixup => todo.push_str(&format!("fixup {} {}\n", item.hash, summary)), + RebaseAction::Drop => todo.push_str(&format!("drop {} {}\n", item.hash, summary)), + } + } + Ok(todo) +} + +fn build_reword_queue(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result { + let by_hash = commits + .iter() + .map(|commit| (commit.hash.as_str(), commit)) + .collect::>(); + let mut queue = String::new(); + for item in plan + .iter() + .filter(|item| item.action == RebaseAction::Reword) + { + let commit = by_hash + .get(item.hash.as_str()) + .ok_or_else(|| "The rebase plan references an unknown commit.".to_string())?; + let message = item.message.as_deref().unwrap_or("").trim(); + queue.push_str(&commit.summary.replace(['\r', '\n'], " ")); + queue.push('\x1f'); + queue.push_str(message); + queue.push('\x1e'); + } + Ok(queue) +} + +fn git_dir_for_repo(repo: &Path) -> Result { + let output = run_git(repo, ["rev-parse", "--absolute-git-dir"])?; + let path = String::from_utf8_lossy(&output).trim().to_string(); + if path.is_empty() { + Err("Git could not determine its metadata directory.".to_string()) + } else { + Ok(PathBuf::from(path)) + } +} + +fn parse_reflog(output: &[u8]) -> Result, String> { + let mut entries = Vec::new(); + for raw in output.split(|byte| *byte == 0x1e) { + let record = String::from_utf8_lossy(raw); + let record = record.trim_matches(['\r', '\n', ' ']); + if record.is_empty() { + continue; + } + let fields = record.split('\x1f').collect::>(); + if fields.len() != 6 { + return Err("Git returned an unexpected reflog record.".to_string()); + } + entries.push(ReflogEntry { + hash: fields[0].to_string(), + short_hash: fields[1].to_string(), + selector: fields[2].to_string(), + action: fields[3].to_string(), + author_name: fields[4].to_string(), + date: fields[5].to_string(), + }); + } + Ok(entries) +} + fn rebase_status_or_error( repo: &Path, output: Output, @@ -4582,6 +5007,111 @@ mod tests { assert_eq!(branch.behind, 0); } + #[test] + fn parses_reflog_records() { + let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fHEAD@{0}\x1fcommit: Add feature\x1fAda\x1f2026-07-10T12:00:00+02:00\x1e"; + let entries = parse_reflog(raw).expect("reflog should parse"); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].selector, "HEAD@{0}"); + assert_eq!(entries[0].action, "commit: Add feature"); + } + + #[test] + fn commit_editor_applies_only_the_matching_reword_message() { + let temp = temp_dir("commit_editor"); + let queue = temp.path.join("queue"); + let message = temp.path.join("COMMIT_EDITMSG"); + fs::write( + &queue, + "first commit\x1frenamed first\x1esecond commit\x1frenamed second\x1e", + ) + .expect("queue should be written"); + fs::write( + &message, + "first commit\n\n# Please enter the commit message\n", + ) + .expect("message should be written"); + + apply_reword_message(&queue, &message).expect("message should be applied"); + + assert_eq!(fs::read_to_string(&message).unwrap(), "renamed first\n"); + assert_eq!( + fs::read_to_string(&queue).unwrap(), + "second commit\x1frenamed second\x1e" + ); + } + + #[test] + fn interactive_rebase_builds_a_valid_reword_and_squash_plan() { + let repo = init_temp_repo("interactive_rebase"); + commit_initial_file(&repo.path); + let base = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + + fs::write(repo.path.join("first.txt"), "first\n").expect("first file should be written"); + run_git_test(&repo.path, ["add", "first.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "first commit"]); + fs::write(repo.path.join("second.txt"), "second\n").expect("second file should be written"); + run_git_test(&repo.path, ["add", "second.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "second commit"]); + + let commits = interactive_rebase_commits_for_repo(&repo.path, &base) + .expect("rebase commits should load"); + assert_eq!(commits.len(), 2); + let plan = vec![ + RebasePlanItem { + hash: commits[0].hash.clone(), + action: RebaseAction::Reword, + message: Some("combined feature".to_string()), + }, + RebasePlanItem { + hash: commits[1].hash.clone(), + action: RebaseAction::Squash, + message: None, + }, + ]; + + validate_rebase_plan(&commits, &plan).expect("plan should be valid"); + let todo = build_rebase_todo(&commits, &plan).expect("todo should build"); + + assert!(todo.contains(&format!("reword {} first commit", commits[0].hash))); + assert!(todo.contains(&format!("squash {} second commit", commits[1].hash))); + assert_eq!( + build_reword_queue(&commits, &plan).expect("queue should build"), + "first commit\x1fcombined feature\x1e" + ); + } + + #[test] + fn reflog_restore_creates_a_recovery_branch_without_resetting_existing_branch() { + let repo = init_temp_repo("reflog_restore"); + commit_initial_file(&repo.path); + let initial = git_output_test(&repo.path, ["rev-parse", "HEAD"]); + let original_branch = git_output_test(&repo.path, ["branch", "--show-current"]); + + fs::write(repo.path.join("later.txt"), "later\n").expect("later file should be written"); + run_git_test(&repo.path, ["add", "later.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "later"]); + + let entries = list_reflog(repo.path.to_string_lossy().to_string(), Some(20)) + .expect("reflog should load"); + assert!(entries.len() >= 2); + + let status = restore_reflog_entry( + repo.path.to_string_lossy().to_string(), + initial.clone(), + "recovery/initial".to_string(), + ) + .expect("recovery branch should be created"); + + assert_eq!(status.current_branch.as_deref(), Some("recovery/initial")); + assert_eq!(git_output_test(&repo.path, ["rev-parse", "HEAD"]), initial); + assert!( + ref_exists(&repo.path, &format!("refs/heads/{original_branch}")) + .expect("original branch should still exist") + ); + } + #[test] fn parses_commit_history_records() { let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fAda Lovelace\x1fada@example.com\x1f2026-06-26T12:34:56+02:00\x1fHEAD -> main, tag: v1\x1f2222222222222222222222222222222222222222 3333333333333333333333333333333333333333\x1fAdd history panel\x1e"; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9b3fbc6..cd62789 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -12,14 +12,16 @@ use git::{ compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save, delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits, - list_file_history, list_repository_files, list_stashes, list_tags, merge_branch, - open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull, - push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch, - resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, - restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop, - stash_push, undo_last_commit, unstage_files, + list_file_history, list_interactive_rebase_commits, list_reflog, list_repository_files, + list_stashes, list_tags, merge_branch, open_repo_in_explorer, open_repository, + open_repository_bundle, open_repository_file, pull, push, push_tag, read_conflict, + rebase_abort, rebase_branch, rebase_continue, rename_branch, resolve_conflict, + resolve_conflict_side, restore_file_from_commit, restore_files, restore_reflog_entry, + restore_to_commit, run_sequence_editor_if_requested, search_code_introductions, stage_files, + start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, + unstage_files, }; -use tauri::{Manager, AppHandle}; +use tauri::Manager; #[tauri::command] fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> { @@ -42,10 +44,19 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> { #[tokio::main] async fn main() { + if let Some(result) = run_sequence_editor_if_requested() { + if let Err(error) = result { + eprintln!("{error}"); + std::process::exit(1); + } + return; + } + tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _, _| { #[cfg(desktop)] - let _ = app.get_webview_window("main") + let _ = app + .get_webview_window("main") .expect("no main window") .set_focus(); })) @@ -107,6 +118,10 @@ async fn main() { rebase_branch, rebase_continue, rebase_abort, + list_interactive_rebase_commits, + start_interactive_rebase, + list_reflog, + restore_reflog_entry, list_repository_files, open_repository_bundle, list_file_history, diff --git a/src/App.svelte b/src/App.svelte index e871891..bf6d18a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -22,9 +22,11 @@ import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte"; import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte"; import HistoryPanel from "./lib/components/HistoryPanel.svelte"; + import InteractiveRebaseDialog from "./lib/components/InteractiveRebaseDialog.svelte"; import LinePatchDialog from "./lib/components/LinePatchDialog.svelte"; import NewBranchDialog from "./lib/components/NewBranchDialog.svelte"; import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte"; + import ReflogDialog from "./lib/components/ReflogDialog.svelte"; import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte"; import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import StashPanel from "./lib/components/StashPanel.svelte"; @@ -62,6 +64,8 @@ listTags, listCommits, listFileHistory, + listInteractiveRebaseCommits, + listReflog, listRepositoryFiles, mergeBranch, openRepoInExplorer, @@ -83,9 +87,11 @@ resolveConflict, resolveConflictSide, restoreFileFromCommit, + restoreReflogEntry, restoreFiles, restoreToCommit, searchCodeIntroductions, + startInteractiveRebase, setSyncBadge, stageFiles, stashApply, @@ -119,6 +125,9 @@ LocalModelOption, PatchApplyAction, PreparedResolution, + RebaseCommit, + RebasePlanItem, + ReflogEntry, StoredCredential, } from "./lib/types"; @@ -254,6 +263,15 @@ let deleteBranchForce = false; let compareSelectOpen = false; let compareDialogOpen = false; + let interactiveRebaseOpen = false; + let interactiveRebaseBase = ""; + let interactiveRebaseCommits: RebaseCommit[] = []; + let interactiveRebaseLoading = false; + let interactiveRebaseError = ""; + let reflogOpen = false; + let reflogEntries: ReflogEntry[] = []; + let reflogLoading = false; + let reflogError = ""; let selectedDiffPath = ""; let diffHighlightQuery = ""; let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null; @@ -640,7 +658,7 @@ } async function autoRefreshTick() { - if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return; + if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || newBranchCommit || globalSearchOpen) return; const path = activeRepoPath; autoRefreshInFlight = true; try { @@ -1566,6 +1584,13 @@ comparison = null; compareSelectOpen = false; compareDialogOpen = false; + interactiveRebaseOpen = false; + interactiveRebaseBase = ""; + interactiveRebaseCommits = []; + interactiveRebaseError = ""; + reflogOpen = false; + reflogEntries = []; + reflogError = ""; selectedDiffPath = ""; pendingRestoreFile = null; newBranchCommit = null; @@ -2236,6 +2261,99 @@ }); } + function preferredInteractiveRebaseBase(): string { + const candidates = [status?.upstream, "origin/main", "main", "origin/master", "master"] + .filter((value): value is string => Boolean(value) && value !== status?.current_branch); + for (const candidate of candidates) { + if (branches.some((branch) => branch.name === candidate)) return candidate; + } + return branches.find((branch) => !branch.current)?.name ?? ""; + } + + async function loadInteractiveRebaseRange(base: string) { + interactiveRebaseBase = base; + interactiveRebaseCommits = []; + interactiveRebaseError = ""; + if (!activeRepoPath || !base) return; + interactiveRebaseLoading = true; + try { + const result = await listInteractiveRebaseCommits(activeRepoPath, base); + if (interactiveRebaseBase === base) interactiveRebaseCommits = result; + } catch (error) { + if (interactiveRebaseBase === base) interactiveRebaseError = errorToMessage(error); + } finally { + if (interactiveRebaseBase === base) interactiveRebaseLoading = false; + } + } + + function openInteractiveRebase() { + if (!hasRepository || rebaseInProgress || cherryPickInProgress || isBusy) return; + interactiveRebaseOpen = true; + const base = preferredInteractiveRebaseBase(); + void loadInteractiveRebaseRange(base); + trackEvent("interactive_rebase_opened"); + } + + async function runInteractiveRebase(plan: RebasePlanItem[]) { + if (!activeRepoPath || !interactiveRebaseBase || isBusy) return; + interactiveRebaseError = ""; + await runOperation("Starting interactive rebase", async () => { + applyStatus(await startInteractiveRebase(activeRepoPath, interactiveRebaseBase, plan)); + interactiveRebaseOpen = false; + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + trackEvent("interactive_rebase_started", { commits: plan.length }); + }); + if (interactiveRebaseOpen && errorMessage) interactiveRebaseError = errorMessage; + } + + async function openReflog() { + if (!hasRepository || isBusy) return; + reflogOpen = true; + reflogEntries = []; + reflogError = ""; + reflogLoading = true; + try { + reflogEntries = await listReflog(activeRepoPath, 300); + trackEvent("reflog_opened", { entries: reflogEntries.length }); + } catch (error) { + reflogError = errorToMessage(error); + } finally { + reflogLoading = false; + } + } + + async function previewReflogEntry(entry: ReflogEntry) { + if (!activeRepoPath || isBusy) return; + await runOperation("Previewing reflog entry", async () => { + const result = await compareCommits(activeRepoPath, entry.hash, "HEAD"); + comparison = result; + selectedDiffPath = result.files[0]?.path ?? ""; + diffHighlightQuery = ""; + pendingRestoreFile = null; + reflogOpen = false; + compareDialogOpen = true; + trackEvent("reflog_previewed", { files: result.files.length }); + }); + } + + async function recoverReflogEntry(entry: ReflogEntry, branch: string) { + if (!activeRepoPath || !branch.trim() || isBusy) return; + reflogError = ""; + await runOperation("Restoring reflog entry", async () => { + applyStatus(await restoreReflogEntry(activeRepoPath, entry.hash, branch.trim())); + reflogOpen = false; + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + trackEvent("reflog_recovered"); + }); + if (reflogOpen && errorMessage) reflogError = errorMessage; + } + async function createNewTag(name: string, message: string) { const trimmed = name.trim(); if (!activeRepoPath || !trimmed) return; @@ -3323,6 +3441,8 @@ else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null; else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog(); + else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false; + else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false; else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false; else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog(); } @@ -3360,6 +3480,8 @@ onRefresh={refreshRepo} onSearch={openGlobalSearchDialog} onCompare={openCompareSelect} + onInteractiveRebase={openInteractiveRebase} + onReflog={openReflog} onOpenInExplorer={openActiveRepoInExplorer} onToggleAutoRefresh={toggleAutoRefresh} onOpenSettings={() => { appSettingsOpen = true; }} @@ -4079,6 +4201,37 @@ /> {/if} + +{#if interactiveRebaseOpen} + { if (!isBusy) interactiveRebaseOpen = false; }} + /> +{/if} + +{#if reflogOpen} + { if (!isBusy) reflogOpen = false; }} + /> +{/if} + {#if compareSelectOpen} span { font-weight: 700; } + .rebase-base-bar label strong { color: var(--color-ink); font-family: var(--font-mono); } + .rebase-base-bar p { margin: 0 0 4px; color: var(--color-ink-faint); font-size: 12px; line-height: 1.45; } + .rebase-plan { + display: grid; + align-content: start; + min-height: 0; + padding: 8px; + overflow: auto; + background: var(--code-surface); + } + .rebase-plan-row { + display: grid; + grid-template-columns: auto 112px 64px minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 48px; + padding: 6px 8px; + border: 1px solid transparent; + border-bottom-color: var(--color-border-subtle); + background: var(--code-surface); + } + .rebase-plan-row:hover { border-color: var(--color-border-subtle); background: var(--code-hover-bg); } + .rebase-plan-row.drop { opacity: 0.58; background: var(--code-delete-bg); } + .rebase-order-actions { display: inline-flex; gap: 3px; } + .rebase-order-actions button { + width: 25px; + min-height: 25px; + padding: 0; + border-radius: 5px; + background: var(--code-surface-subtle); + } + .rebase-action { height: 30px; font-family: var(--font-mono); font-weight: 800; } + .rebase-action.pick { color: var(--code-add-strong); } + .rebase-action.reword { color: var(--code-hunk-text); } + .rebase-action.squash, .rebase-action.fixup { color: #96620f; } + .rebase-action.drop { color: var(--code-delete-strong); } + .rebase-plan-row > code { color: var(--color-accent); font-family: var(--font-mono); font-size: 11px; font-weight: 800; } + .rebase-commit-copy { display: grid; gap: 3px; min-width: 0; } + .rebase-commit-copy strong { overflow: hidden; color: var(--color-ink); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; } + .rebase-commit-copy span { color: var(--color-ink-faint); font-size: 10.5px; } + .rebase-commit-copy input { height: 30px; font-family: var(--font-mono); font-size: 12px; } + .rebase-warning { + display: flex; + align-items: center; + gap: 7px; + margin: 8px 12px 0; + padding: 8px 10px; + border: 1px solid rgba(224,160,64,0.25); + border-radius: 7px; + color: #b87914; + background: rgba(224,160,64,0.08); + font-size: 12px; + } + .rebase-warning.error { border-color: rgba(232,96,96,0.28); color: var(--code-delete-text); background: var(--code-delete-bg); } + .rebase-footer-actions { display: flex; gap: 8px; } + + .reflog-body { display: grid; grid-template-columns: minmax(340px, 0.8fr) minmax(0, 1.2fr); min-height: 0; overflow: hidden; } + .reflog-list-pane { display: grid; grid-template-rows: auto minmax(0, 1fr); min-width: 0; min-height: 0; border-right: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); } + .reflog-search { position: relative; display: flex; align-items: center; padding: 10px; border-bottom: 1px solid var(--color-border-subtle); } + .reflog-search svg { position: absolute; left: 21px; color: var(--color-ink-faint); } + .reflog-search input { padding-left: 34px; } + .reflog-list { display: grid; align-content: start; min-height: 0; padding: 7px; overflow: auto; } + .reflog-list > button { + display: grid; + justify-content: stretch; + gap: 4px; + width: 100%; + min-height: 70px; + padding: 8px 10px; + border-color: transparent; + border-bottom-color: var(--color-border-subtle); + border-radius: 6px; + background: transparent; + text-align: left; + } + .reflog-list > button:hover:not(:disabled) { background: var(--color-surface-hover); } + .reflog-list > button.active { border-color: rgba(49,95,214,0.3); background: rgba(49,95,214,0.09); } + .reflog-list > button strong { overflow: hidden; color: var(--color-ink); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } + .reflog-row-top, .reflog-row-bottom { display: flex; align-items: center; justify-content: space-between; min-width: 0; gap: 8px; color: var(--color-ink-faint); font-size: 10.5px; } + .reflog-row-top code { color: var(--color-accent); font-weight: 800; } + .reflog-row-bottom code { color: var(--color-ink-dim); } + .reflog-detail { display: grid; align-content: start; gap: 14px; min-width: 0; padding: 18px; overflow: auto; } + .reflog-detail-head { display: flex; align-items: center; gap: 10px; } + .reflog-detail-head > svg { color: var(--color-accent); } + .reflog-detail-head h3 { margin: 2px 0 0; color: var(--color-ink); font-size: 17px; } + .reflog-detail dl { display: grid; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-border-subtle); } + .reflog-detail dl > div { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 10px; padding: 9px 11px; background: var(--color-surface-raised); } + .reflog-detail dt { color: var(--color-ink-faint); font-size: 11px; font-weight: 800; text-transform: uppercase; } + .reflog-detail dd { min-width: 0; margin: 0; overflow: hidden; color: var(--color-ink-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } + .reflog-detail dd code { font-family: var(--font-mono); } + .reflog-preview { justify-self: start; } + .reflog-recovery-card { display: grid; gap: 12px; padding: 14px; border: 1px solid rgba(78,202,118,0.24); border-radius: 10px; background: rgba(78,202,118,0.07); } + .reflog-recovery-title { display: flex; align-items: flex-start; gap: 9px; } + .reflog-recovery-title > svg { flex: 0 0 auto; color: var(--code-add-strong); } + .reflog-recovery-title div { display: grid; gap: 3px; } + .reflog-recovery-title strong { color: var(--color-ink); font-size: 13px; } + .reflog-recovery-title span { color: var(--color-ink-faint); font-size: 11px; line-height: 1.4; } + .reflog-recovery-card label { display: grid; gap: 5px; color: var(--color-ink-faint); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em; } + .reflog-recovery-card label > div { position: relative; display: flex; align-items: center; } + .reflog-recovery-card label svg { position: absolute; left: 10px; color: var(--color-accent); } + .reflog-recovery-card label input { padding-left: 33px; font-family: var(--font-mono); text-transform: none; letter-spacing: 0; } + .reflog-recovery-card .btn-primary { justify-self: start; } .new-branch-dialog { display: block; width: min(520px, calc(100vw - 32px)); @@ -5127,6 +5254,10 @@ .file-search-hit { grid-template-columns: auto minmax(0, 1fr); align-items: start; } .file-search-hit .status-badge, .file-search-action { grid-column: 2; justify-self: start; } + .rebase-base-bar { grid-template-columns: 1fr; } + .rebase-plan-row { grid-template-columns: auto 96px 54px minmax(180px, 1fr); } + .reflog-body { grid-template-columns: 1fr; grid-template-rows: minmax(220px, 0.8fr) minmax(0, 1.2fr); } + .reflog-list-pane { border-right: none; border-bottom: 1px solid var(--color-border-subtle); } .dialog-files { border-right: none; border-bottom: 1px solid var(--color-border-subtle); } .branch-actions { flex-direction: row; justify-content: flex-start; } .tb-action-label { display: none; } diff --git a/src/feature-qa.ts b/src/feature-qa.ts new file mode 100644 index 0000000..37701a2 --- /dev/null +++ b/src/feature-qa.ts @@ -0,0 +1,27 @@ +import { mount } from "svelte"; +import "./app.css"; +import InteractiveRebaseDialog from "./lib/components/InteractiveRebaseDialog.svelte"; +import ReflogDialog from "./lib/components/ReflogDialog.svelte"; + +const target = document.getElementById("qa")!; +const branches = [ + { name: "features/rewrite-history", current: true, remote: false }, + { name: "main", current: false, remote: false }, + { name: "origin/main", current: false, remote: true }, +]; +const commits = [ + { hash: "1111111111111111111111111111111111111111", short_hash: "1111111", summary: "Add reflog backend", author_name: "Ada", date: "2026-07-10T09:10:00+02:00" }, + { hash: "2222222222222222222222222222222222222222", short_hash: "2222222", summary: "Build interactive rebase dialog", author_name: "Linus", date: "2026-07-10T10:20:00+02:00" }, + { hash: "3333333333333333333333333333333333333333", short_hash: "3333333", summary: "Polish recovery workflow", author_name: "Grace", date: "2026-07-10T11:30:00+02:00" }, +]; +const entries = [ + { hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", short_hash: "aaaaaaa", selector: "HEAD@{0}", action: "commit: Add recovery workflow", author_name: "Ada", date: "2026-07-10T12:00:00+02:00" }, + { hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", short_hash: "bbbbbbb", selector: "HEAD@{1}", action: "rebase (finish): returning to refs/heads/feature", author_name: "Ada", date: "2026-07-10T11:00:00+02:00" }, + { hash: "cccccccccccccccccccccccccccccccccccccccc", short_hash: "ccccccc", selector: "HEAD@{2}", action: "checkout: moving from main to feature", author_name: "Ada", date: "2026-07-10T10:00:00+02:00" }, +]; + +if (location.hash === "#reflog") { + mount(ReflogDialog, { target, props: { entries, currentHash: entries[0].hash, isLoading: false, isBusy: false, operation: "", error: "", onPreview: () => {}, onRestore: (_entry, branch) => { document.title = `Recovered ${branch}`; }, onClose: () => {} } }); +} else { + mount(InteractiveRebaseDialog, { target, props: { branches, currentBranch: branches[0].name, base: "main", commits, isLoading: false, isBusy: false, operation: "", error: "", onBaseChange: () => {}, onStart: (plan) => { document.title = `Rebase ${plan.length} commits`; }, onClose: () => {} } }); +} diff --git a/src/lib/TitleBar.svelte b/src/lib/TitleBar.svelte index 32eff55..393e112 100644 --- a/src/lib/TitleBar.svelte +++ b/src/lib/TitleBar.svelte @@ -2,7 +2,7 @@ import { onDestroy, onMount } from "svelte"; import { getVersion } from "@tauri-apps/api/app"; import { getCurrentWindow } from "@tauri-apps/api/window"; - import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte"; + import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, History, ListRestart, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte"; import iconUrl from "../../src-tauri/icons/icon.png"; export let branch: string = ""; @@ -20,6 +20,8 @@ export let onRefresh: () => void = () => {}; export let onSearch: () => void = () => {}; export let onCompare: () => void = () => {}; + export let onInteractiveRebase: () => void = () => {}; + export let onReflog: () => void = () => {}; export let onOpenInExplorer: () => void = () => {}; export let onToggleAutoRefresh: () => void = () => {}; export let onOpenSettings: () => void = () => {}; @@ -138,6 +140,28 @@ Compare + + + + + + +
+
+ +

Oldest commit first. Reorder commits, then choose how each one should be replayed.

+
+ + {#if error} +
+ {/if} + + {#if isLoading} +
+ {:else if !base} +
Select the branch or commit that should become the new base.
+ {:else if rows.length === 0} +
No linear commits are available above this base.
+ {:else} +
+ {#each rows as row, index (row.hash)} +
+
+ + +
+ + {row.short_hash} +
+ {#if row.action === "reword"} + updateMessage(index, (event.target as HTMLInputElement).value)} disabled={isBusy} aria-label={`New message for ${row.short_hash}`} maxlength="240" /> + {:else} + {row.summary} + {/if} + {row.author_name} · {new Date(row.date).toLocaleString()} +
+
+ {/each} +
+ {/if} + + {#if invalidSquash} +
+ {:else if invalidReword} +
+ {/if} +
+ +
+ {keptCount} of {rows.length} commits kept + +
+ + diff --git a/src/lib/components/ReflogDialog.svelte b/src/lib/components/ReflogDialog.svelte new file mode 100644 index 0000000..764fe4c --- /dev/null +++ b/src/lib/components/ReflogDialog.svelte @@ -0,0 +1,80 @@ + + + diff --git a/src/lib/git.ts b/src/lib/git.ts index 56e7ee4..6bf434c 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -10,6 +10,9 @@ import type { GitCommit, GitCommitComparison, GitRepositoryFile, + RebaseCommit, + RebasePlanItem, + ReflogEntry, GitSearchHit, GitStash, GitStatus, @@ -306,6 +309,26 @@ export function rebaseAbort(path: string): Promise { return invoke("rebase_abort", { path }); } +export function listInteractiveRebaseCommits(path: string, base: string): Promise { + return invoke("list_interactive_rebase_commits", { path, base }); +} + +export function startInteractiveRebase( + path: string, + base: string, + plan: RebasePlanItem[], +): Promise { + return invoke("start_interactive_rebase", { path, base, plan }); +} + +export function listReflog(path: string, limit = 250): Promise { + return invoke("list_reflog", { path, limit }); +} + +export function restoreReflogEntry(path: string, commit: string, branch: string): Promise { + return invoke("restore_reflog_entry", { path, commit, branch }); +} + export function listRepositoryFiles(path: string): Promise { return invoke("list_repository_files", { path }); } diff --git a/src/lib/types.ts b/src/lib/types.ts index b761c75..0b9a25e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -198,6 +198,31 @@ export interface GitBlameResult { lines: GitBlameLine[]; } +export type RebaseAction = "pick" | "reword" | "squash" | "fixup" | "drop"; + +export interface RebaseCommit { + hash: string; + short_hash: string; + summary: string; + author_name: string; + date: string; +} + +export interface RebasePlanItem { + hash: string; + action: RebaseAction; + message: string | null; +} + +export interface ReflogEntry { + hash: string; + short_hash: string; + selector: string; + action: string; + author_name: string; + date: string; +} + export interface StoredCredential { username: string; password: string;