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;