diff --git a/.restyled.yaml b/.restyled.yaml index 9ccfcfb..0edc6d7 100644 --- a/.restyled.yaml +++ b/.restyled.yaml @@ -2,4 +2,6 @@ restylers: - pyment: enabled: false + - rustfmt: + arguments: ["--edition=2024"] - "*" diff --git a/BUILD.bazel b/BUILD.bazel index de96957..ba35e9c 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -10,7 +10,6 @@ haskell_library( "src/GitHub/Types/Base/*.hs", "src/GitHub/Types/Base*.hs", ]), - ghcopts = ["-j4"], src_strip_prefix = "src", tags = [ "haskell", @@ -34,7 +33,6 @@ haskell_library( "src/GitHub/Types/Events/*.hs", "src/GitHub/Types/Event*.hs", ]), - ghcopts = ["-j4"], src_strip_prefix = "src", tags = [ "haskell", @@ -101,9 +99,7 @@ haskell_library( hspec_test( name = "testsuite", - size = "small", args = [ - "-j4", "+RTS", "-N4", ], diff --git a/stack.yaml b/stack.yaml index 7f01328..759dcf2 100644 --- a/stack.yaml +++ b/stack.yaml @@ -1,6 +1,7 @@ --- packages: [.] -resolver: lts-21.9 +resolver: lts-21.25 extra-deps: + - Diff-1.0.2 - suspend-0.2.0.0 - timers-0.2.0.4 diff --git a/tools/check-workflows.hs b/tools/check-workflows.hs index 657be20..604432c 100644 --- a/tools/check-workflows.hs +++ b/tools/check-workflows.hs @@ -78,6 +78,6 @@ showDiff a b = Text.pack . PP.render . toDoc $ diff where toDoc = Diff.prettyContextDiff (PP.text "payload") (PP.text "value") - (PP.text . Text.unpack) + (\(Diff.Numbered _ t) -> PP.text . Text.unpack $ t) diff = Diff.getContextDiff linesOfContext (Text.lines a) (Text.lines b) - linesOfContext = 3 + linesOfContext = Just 3 diff --git a/tools/gitui/BUILD.bazel b/tools/gitui/BUILD.bazel new file mode 100644 index 0000000..f913f7b --- /dev/null +++ b/tools/gitui/BUILD.bazel @@ -0,0 +1,100 @@ +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_clippy", "rust_library", "rust_test") + +rust_library( + name = "gitui_lib", + srcs = [ + "src/diff_utils.rs", + "src/engine/executor.rs", + "src/engine/git.rs", + "src/engine/mod.rs", + "src/engine/planner.rs", + "src/engine/topology.rs", + "src/engine/transaction.rs", + "src/engine/types.rs", + "src/lib.rs", + "src/patch_utils.rs", + "src/runtime.rs", + "src/split_state.rs", + "src/state/actions.rs", + "src/state/input.rs", + "src/state/mod.rs", + "src/state/reducer.rs", + "src/state/types.rs", + "src/testing.rs", + "src/topology/mod.rs", + "src/topology/virtual_layer.rs", + "src/ui/common.rs", + "src/ui/main_view.rs", + "src/ui/mod.rs", + "src/ui/preview_view.rs", + "src/ui/prompt_view.rs", + "src/ui/split_view.rs", + ], + crate_name = "gitui", + edition = "2024", + visibility = ["//visibility:public"], + deps = [ + "@crates//:anyhow", + "@crates//:crossterm", + "@crates//:git2", + "@crates//:indexmap", + "@crates//:itertools", + "@crates//:petgraph", + "@crates//:ratatui", + "@crates//:tempfile", + "@crates//:tokio", + "@crates//:unicode-segmentation", + ], +) + +rust_binary( + name = "gitui", + srcs = ["src/main.rs"], + edition = "2024", + rustc_flags = ["-Clink-arg=-fuse-ld=bfd"], + deps = [ + ":gitui_lib", + "@crates//:anyhow", + "@crates//:clap", + "@crates//:tokio", + ], +) + +TEST_SRCS = glob(["test/*.rs"]) + +[ + rust_test( + name = test_file.replace("test/", "").replace(".rs", ""), + size = "small", + srcs = [test_file], + data = glob(["test/snapshots/**"]), + edition = "2024", + rustc_flags = ["-Clink-arg=-fuse-ld=bfd"], + deps = [ + ":gitui_lib", + "@crates//:anyhow", + "@crates//:crossterm", + "@crates//:git2", + "@crates//:insta", + "@crates//:petgraph", + "@crates//:proptest", + "@crates//:ratatui", + "@crates//:regex", + "@crates//:tempfile", + "@crates//:tokio", + ], + ) + for test_file in TEST_SRCS +] + +rust_clippy( + name = "clippy", + testonly = True, + deps = [ + ":gitui", + ":gitui_lib", + ] + [ + ":" + src.replace("test/", "").replace(".rs", "") + for src in TEST_SRCS + ], +) diff --git a/tools/gitui/README.md b/tools/gitui/README.md new file mode 100644 index 0000000..86177a0 --- /dev/null +++ b/tools/gitui/README.md @@ -0,0 +1,111 @@ +# Git Stack Manager (gitui) + +A simple Git TUI for managing branch stacks and complex branch trees. + +## Overview + +This tool is designed to simplify the management of "stacked" branches, where +multiple feature branches are built on top of each other. It provides a visual +representation of the branch hierarchy and allows for easy restructuring of +entire subtrees. + +## Key Features + +- **Visual Branch Tree:** Automatically detects and displays the relationship + between local and remote branches. +- **Interactive Move:** "Grab" a branch and move it to a new parent. The tool + handles the rebase of the entire subtree. +- **Predictive Conflict Detection:** Highlights potential merge conflicts + *while* you are moving a branch, before any action is taken. +- **Heuristic Repair (`u`):** Automatically detects when a branch has drifted + from its true parent (e.g. after a remote rebase) and allows you to + "converge" it back with a single keypress. +- **Split Branch (`x`):** Interactively decompose a single commit into + multiple sequential branches by selecting specific hunks. +- **Remote Visibility:** Toggle between local-only and tracking views for + `origin` and `upstream` remotes. +- **Submit Workflow:** Plan and execute branch submissions to `upstream` with + automatic sync to `origin`. +- **Localize Remotes:** Easily create local tracking branches from remote + branches by simply moving them in the tree. +- **Branch Management:** Directly push (`p`), delete (`d`), reset (`r`), + rename (`R`), or amend (`m`/`M`) branches from the TUI. + +## Shortcuts + +### Navigation + +- `j` / `Down`: Move selection down. +- `k` / `Up`: Move selection up. +- `a`: Toggle showing remote branches from `origin` and `upstream`. + +### Manipulation + +- `Space`: Grab or drop a branch. While grabbed, use `j`/`k` to select a new + parent, or `h` to move to root. +- `p`: Toggle pending push (for local branches with ahead commits). +- `s`: Toggle pending submit (push to `upstream`, delete from `origin`, merge + to `master`). +- `x`: Enter Split Branch mode (only available if 1 commit ahead of parent). +- `u`: Converge diverged branch (move to heuristic parent). +- `d`: Toggle pending delete. +- `r`: Toggle pending reset to upstream (or rebase onto upstream if + ahead/behind). +- `m`: Toggle pending amend (amends current staged changes into the selected + branch). +- `M`: Toggle pending amend with message update. +- `R`: Rename the selected branch. +- `f`: Toggle pending localize (for remote branches) or fetch (for root). + +### Execution + +- `v`: Enter Preview mode to see planned operations and predicted conflicts. +- `c`: Execute all pending operations. +- `Esc`: Cancel current grab or quit the current mode. +- `q`: Quit. + +## CLI Usage + +```bash +# Start the TUI in the current directory +gitui + +# Start in a specific directory +gitui --path /path/to/repo + +# Print the current tree and exit +gitui --tree + +# Print the tree including remote branches +gitui --tree --all + +# Show the submission plan for a branch and exit +gitui --submit branch-name + +# Show the plan to fix a diverged branch (converge) +gitui --converge branch-name + +# Show the plan to sync a branch with its upstream +gitui --sync branch-name +``` + +## Development + +This tool is built with: + +- **Language:** Rust +- **UI:** [Ratatui](https://ratatui.rs/) +- **Git Engine:** [git2-rs](https://github.com/rust-lang/git2-rs) +- **Build System:** Bazel + +### Building + +```bash +bazel build //hs-github-tools/tools/gitui:gitui +``` + +### Testing + +```bash +bazel test //hs-github-tools/tools/gitui/... +``` diff --git a/tools/gitui/src/diff_utils.rs b/tools/gitui/src/diff_utils.rs new file mode 100644 index 0000000..57dd444 --- /dev/null +++ b/tools/gitui/src/diff_utils.rs @@ -0,0 +1,135 @@ +use git2::{Diff, DiffFormat, DiffLineType}; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum LineType { + #[default] + Context, + Addition, + Deletion, + Header, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DiffLine { + pub content: String, + pub line_type: LineType, + pub old_lineno: Option, + pub new_lineno: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Hunk { + pub header: String, + pub lines: Vec, + pub old_start: u32, + pub old_lines: u32, + pub new_start: u32, + pub new_lines: u32, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FileDiff { + pub path: String, + pub hunks: Vec, +} + +pub fn parse_diff(diff: &Diff) -> anyhow::Result> { + let mut file_diffs = Vec::new(); + let mut current_file: Option = None; + let mut current_hunk: Option = None; + + diff.print(DiffFormat::Patch, |delta, hunk, line| { + let path_str = delta + .new_file() + .path() + .and_then(|p| p.to_str()) + .unwrap_or(""); + + let is_different_file = match ¤t_file { + Some(f) => f.path != path_str, + None => true, + }; + + if is_different_file { + if let Some(h) = current_hunk.take() + && let Some(ref mut f) = current_file + { + f.hunks.push(h); + } + if let Some(f) = current_file.take() { + file_diffs.push(f); + } + current_file = Some(FileDiff { + path: path_str.to_string(), + hunks: Vec::new(), + }); + } + + if let Some(h) = hunk { + let is_different_hunk = match ¤t_hunk { + Some(curr) => { + curr.old_start != h.old_start() + || curr.old_lines != h.old_lines() + || curr.new_start != h.new_start() + || curr.new_lines != h.new_lines() + } + None => true, + }; + + if is_different_hunk { + if let Some(h_val) = current_hunk.take() + && let Some(ref mut f) = current_file + { + f.hunks.push(h_val); + } + + let header = std::str::from_utf8(h.header()) + .unwrap_or("") + .trim() + .to_string(); + + current_hunk = Some(Hunk { + header, + lines: Vec::new(), + old_start: h.old_start(), + old_lines: h.old_lines(), + new_start: h.new_start(), + new_lines: h.new_lines(), + }); + } + } + + let line_type = match line.origin_value() { + DiffLineType::Context => LineType::Context, + DiffLineType::Addition => LineType::Addition, + DiffLineType::Deletion => LineType::Deletion, + _ => LineType::Header, + }; + + if line_type != LineType::Header + && let Some(ref mut h_val) = current_hunk + { + h_val.lines.push(DiffLine { + content: std::str::from_utf8(line.content()) + .unwrap_or("") + .to_string(), + line_type, + old_lineno: line.old_lineno(), + new_lineno: line.new_lineno(), + }); + } + + true + })?; + + if let Some(h) = current_hunk + && let Some(ref mut f) = current_file + { + f.hunks.push(h); + } + if let Some(f) = current_file { + file_diffs.push(f); + } + + Ok(file_diffs) +} diff --git a/tools/gitui/src/engine/executor.rs b/tools/gitui/src/engine/executor.rs new file mode 100644 index 0000000..c35c8b4 --- /dev/null +++ b/tools/gitui/src/engine/executor.rs @@ -0,0 +1,76 @@ +use crate::engine::git::Git; +use crate::engine::types::Operation; +use std::path::Path; +use std::process::Command; + +pub trait Executor { + fn execute(&self, git: &dyn Git, op: &Operation, path: &Path) -> anyhow::Result; +} + +pub struct ShellExecutor { + pub interactive: bool, +} + +impl ShellExecutor { + pub fn new(interactive: bool) -> Self { + Self { interactive } + } +} + +impl Executor for ShellExecutor { + fn execute(&self, git: &dyn Git, op: &Operation, path: &Path) -> anyhow::Result { + match op { + Operation::Split { + branch, + parent, + data, + } => { + println!("Splitting {}...", branch); + git.split_branch(branch, parent, data)?; + Ok(true) + } + Operation::Rebase { + branch, + onto, + upstream: _, + predicted_conflict: _, + } => { + println!("Rebasing {} onto {}...", branch, onto); + let cmds = op.commands(); + for cmd in cmds { + if !git.run_command(&cmd)? { + if self.interactive { + drop_to_shell(path)?; + } else { + anyhow::bail!( + "Rebase failed for {}. Manual resolution required.", + branch + ); + } + } + } + Ok(true) + } + _ => { + println!("Executing: {}", op); + let cmds = op.commands(); + for cmd in cmds { + if !git.run_command(&cmd)? { + anyhow::bail!("Command failed: git {}", cmd.join(" ")); + } + } + Ok(true) + } + } + } +} + +fn drop_to_shell>(path: P) -> anyhow::Result<()> { + println!("\nRebase conflict! Dropping to shell."); + println!("Resolve conflicts (git add, etc.) and exit the shell to continue."); + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string()); + Command::new(shell).current_dir(path).status()?; + + Ok(()) +} diff --git a/tools/gitui/src/engine/git.rs b/tools/gitui/src/engine/git.rs new file mode 100644 index 0000000..8109192 --- /dev/null +++ b/tools/gitui/src/engine/git.rs @@ -0,0 +1,931 @@ +use crate::diff_utils; +use crate::engine::types::{BranchInfo, CommitInfo, SplitData}; +use crate::patch_utils; +use crate::topology::HistoryContext; +use git2::{BranchType, Oid, Repository}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +pub trait Git: Send + Sync { + fn get_branches( + &self, + progress: Option<&dyn Fn(String, f64)>, + ) -> anyhow::Result<(Vec, HistoryContext)>; + fn get_current_branch(&self) -> anyhow::Result; + fn check_conflict(&self, branch_name: &str, new_parent_name: &str) -> anyhow::Result; + fn check_conflict_between(&self, oid_a: Oid, oid_b: Oid) -> anyhow::Result; + fn is_descendant(&self, ancestor: Oid, descendant: Oid) -> anyhow::Result; + fn rebase(&self, branch: &str, onto: &str) -> anyhow::Result; + fn checkout(&self, branch: &str) -> anyhow::Result<()>; + fn push(&self, branch: &str) -> anyhow::Result; + fn reset_to_upstream(&self, branch: &str) -> anyhow::Result; + fn delete_branch(&self, branch: &str) -> anyhow::Result; + fn run_command(&self, args: &[String]) -> anyhow::Result; + fn is_dirty(&self) -> anyhow::Result; + fn find_parent_for_oid( + &self, + oid: Oid, + exclude_name: &str, + branches: &HashMap, + ) -> Option; + fn get_commit_log(&self, branch: &str) -> anyhow::Result>; + fn get_diff(&self, branch: &str, parent: &str) -> anyhow::Result>; + fn split_branch(&self, branch: &str, parent: &str, data: &SplitData) -> anyhow::Result<()>; +} + +pub struct RealGit { + pub repo: std::sync::Mutex, + pub path: PathBuf, + descendant_cache: std::sync::Mutex>, + branch_tips_cache: std::sync::Mutex>>, +} + +pub(crate) struct RawBranchData { + pub name: String, + pub oid: Oid, + pub is_local: bool, + pub is_remote: bool, + pub upstream_oid: Option, + pub upstream_name: Option, +} + +impl RealGit { + pub fn new>(path: P) -> anyhow::Result { + let repo = Repository::discover(path.as_ref())?; + Ok(Self { + repo: std::sync::Mutex::new(repo), + path: path.as_ref().to_path_buf(), + descendant_cache: std::sync::Mutex::new(HashMap::new()), + branch_tips_cache: std::sync::Mutex::new(None), + }) + } + + fn git_cmd(&self) -> std::process::Command { + let mut cmd = std::process::Command::new("git"); + cmd.arg("-C") + .arg(&self.path) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE"); + cmd + } + + fn repo(&self) -> anyhow::Result> { + self.repo + .lock() + .map_err(|_| anyhow::anyhow!("Repository mutex poisoned")) + } + + fn detect_heuristic_parent( + &self, + repo: &Repository, + oid: Oid, + branch_name: &str, + parent_oid: Option, + summary_to_branch: &HashMap, + branch_to_oid: &HashMap, + ) -> (Option, Option) { + let mut heuristic_parent = None; + let mut heuristic_upstream_oid = None; + + if let Ok(mut walk) = repo.revwalk() { + let _ = walk.push(oid); + let _ = walk.set_sorting(git2::Sort::TOPOLOGICAL); + if let Some(p_oid) = parent_oid { + let _ = walk.hide(p_oid); + } + for commit_oid in walk.flatten() { + if let Ok(commit) = repo.find_commit(commit_oid) + && let Some(summary) = commit.summary() + && let Some(other_branch) = summary_to_branch.get(summary) + && other_branch != branch_name + { + // Find OID of other_branch + let other_oid = branch_to_oid.get(other_branch); + + if Some(&commit_oid) != other_oid || commit_oid != oid { + heuristic_parent = Some(other_branch.clone()); + heuristic_upstream_oid = Some(commit_oid); + break; + } + } + } + } + (heuristic_parent, heuristic_upstream_oid) + } + + fn calculate_ahead_behind( + &self, + repo: &Repository, + oid: Oid, + other_oid: Option, + is_local: bool, + ) -> (usize, usize) { + if let (Some(uoid), true) = (other_oid, is_local) + && let Ok((a, b)) = repo.graph_ahead_behind(oid, uoid) + { + (a, b) + } else { + (0, 0) + } + } + + pub(crate) fn collect_raw_branch_data(&self) -> anyhow::Result> { + let mut branch_data = Vec::new(); + let repo = self.repo()?; + + let local_branches = repo.branches(Some(BranchType::Local))?; + for branch_res in local_branches { + let (branch, _) = branch_res?; + let name = branch + .name()? + .ok_or_else(|| anyhow::anyhow!("Invalid branch name"))? + .to_string(); + let oid = branch.get().peel_to_commit()?.id(); + let mut upstream_name = None; + let mut upstream_oid = None; + if let Ok(upstream) = branch.upstream() + && let Some(uname) = upstream.name()?.map(|s| s.to_string()) + { + upstream_name = Some(uname); + upstream_oid = upstream.get().peel_to_commit().ok().map(|c| c.id()); + } + branch_data.push(RawBranchData { + name, + oid, + is_local: true, + is_remote: false, + upstream_oid, + upstream_name, + }); + } + + let mut branch_names_set: HashSet = + branch_data.iter().map(|b| b.name.clone()).collect(); + let remote_branches = repo.branches(Some(BranchType::Remote))?; + for branch_res in remote_branches { + let (branch, _) = branch_res?; + let name = branch + .name()? + .ok_or_else(|| anyhow::anyhow!("Invalid branch name"))? + .to_string(); + + if name.contains("/HEAD") + || (!name.starts_with("origin/") && !name.starts_with("upstream/")) + { + continue; + } + + if branch_names_set.contains(&name) { + continue; + } + + let oid = branch.get().peel_to_commit()?.id(); + branch_data.push(RawBranchData { + name: name.clone(), + oid, + is_local: false, + is_remote: true, + upstream_oid: None, + upstream_name: None, + }); + branch_names_set.insert(name); + } + let branch_tips = branch_data + .iter() + .map(|b| (b.name.clone(), b.oid)) + .collect(); + *self + .branch_tips_cache + .lock() + .map_err(|_| anyhow::anyhow!("Branch tips cache mutex poisoned"))? = Some(branch_tips); + + Ok(branch_data) + } + + pub(crate) fn get_representative_map( + &self, + branch_data: &[RawBranchData], + ) -> HashMap { + let mut representative_map: HashMap = HashMap::new(); + for b in branch_data { + representative_map + .entry(b.oid) + .and_modify(|(current_best_name, current_best_is_local)| { + let is_better = if b.is_local && !*current_best_is_local { + true + } else if !b.is_local && *current_best_is_local { + false + } else { + is_better_name(&b.name, current_best_name) + }; + + if is_better { + *current_best_name = b.name.clone(); + *current_best_is_local = b.is_local; + } + }) + .or_insert_with(|| (b.name.clone(), b.is_local)); + } + representative_map + .into_iter() + .map(|(oid, (name, _))| (oid, name)) + .collect() + } + + pub(crate) fn get_topological_parents( + &self, + representative_map: &HashMap, + progress: Option<&dyn Fn(String, f64)>, + ) -> anyhow::Result>> { + let repo = self.repo()?; + let mut oid_to_parent_oid: HashMap> = HashMap::new(); + let unique_oids: Vec = representative_map.keys().cloned().collect(); + + // Optimization: Instead of N walks, we do 1 walk. + // We track "active searches". + // active_branches: Map> + // If a branch index is in the set for CommitOID, it means that branch + // is looking for a parent, and has reached CommitOID. + + let mut active_branches: HashMap> = HashMap::new(); + let mut walk = repo.revwalk()?; + + if let Some(p) = progress { + p("Analyzing branch topology...".to_string(), 0.3); + } + + for (i, &oid) in unique_oids.iter().enumerate() { + walk.push(oid)?; + active_branches.entry(oid).or_default().insert(i); + } + + walk.set_sorting(git2::Sort::TOPOLOGICAL)?; + + for commit_oid_res in walk { + let commit_oid = commit_oid_res?; + + if let Some(indices) = active_branches.remove(&commit_oid) { + if indices.is_empty() { + continue; + } + + // Check if the current commit is itself a branch tip (a potential parent) + let is_tip = representative_map.contains_key(&commit_oid); + + // Get parents to propagate search + let commit = repo.find_commit(commit_oid)?; + let parents: Vec = commit.parent_ids().collect(); + + for idx in indices { + let branch_oid = unique_oids[idx]; + + // If we hit a tip, and it's NOT the branch itself, we found the parent. + if is_tip && branch_oid != commit_oid { + oid_to_parent_oid.insert(branch_oid, Some(commit_oid)); + } else { + // Otherwise, propagate the search to parents + if parents.is_empty() { + // Reached root, no parent found + oid_to_parent_oid.insert(branch_oid, None); + } else { + for &p_oid in &parents { + active_branches.entry(p_oid).or_default().insert(idx); + } + } + } + } + } + + if active_branches.is_empty() { + break; + } + } + + Ok(oid_to_parent_oid) + } +} + +pub fn is_better_name(other: &str, current: &str) -> bool { + if other == current { + return false; + } + match (other, current) { + ("master", _) => true, + (_, "master") => false, + ("main", _) => true, + (_, "main") => false, + _ => { + if other.starts_with("upstream/") && current.starts_with("origin/") { + return true; + } + if other.starts_with("origin/") && current.starts_with("upstream/") { + return false; + } + other < current + } + } +} + +pub fn is_better_heuristic_parent(new: &str, existing: &str) -> bool { + if new == existing { + return false; + } + + let new_is_main = new == "master" || new == "main"; + let existing_is_main = existing == "master" || existing == "main"; + + if !new_is_main && existing_is_main { + return true; + } + if new_is_main && !existing_is_main { + return false; + } + + let new_is_remote = new.starts_with("origin/") || new.starts_with("upstream/"); + let existing_is_remote = existing.starts_with("origin/") || existing.starts_with("upstream/"); + + if !new_is_remote && existing_is_remote { + return true; + } + if new_is_remote && !existing_is_remote { + return false; + } + + new < existing +} + +impl Git for RealGit { + fn find_parent_for_oid( + &self, + oid: Oid, + exclude_name: &str, + branch_map: &HashMap, + ) -> Option { + let mut best_parent: Option = None; + let mut best_base: Option = None; + + for (other_name, other_oid) in branch_map { + if exclude_name == other_name { + continue; + } + + if oid == *other_oid && !is_better_name(other_name, exclude_name) { + continue; + } + + // Avoid deadlock: check_merged_status locks Repo -> Cache. + // We must not hold Cache lock while acquiring Repo lock. + let is_descendant = if oid == *other_oid { + true + } else { + let key = (oid, *other_oid); + if let Some(&val) = self.descendant_cache.lock().unwrap().get(&key) { + val + } else { + let val = self + .repo + .lock() + .unwrap() + .graph_descendant_of(oid, *other_oid) + .unwrap_or(false); + self.descendant_cache.lock().unwrap().insert(key, val); + val + } + }; + + if is_descendant { + let is_better = if let Some(current_best_oid) = best_base { + if *other_oid == current_best_oid { + let best_parent_name = best_parent.as_ref().expect("Invariant violated"); + is_better_name(other_name, best_parent_name) + } else { + let key = (*other_oid, current_best_oid); + if let Some(&val) = self.descendant_cache.lock().unwrap().get(&key) { + val + } else { + let val = self + .repo + .lock() + .unwrap() + .graph_descendant_of(*other_oid, current_best_oid) + .unwrap_or(false); + self.descendant_cache.lock().unwrap().insert(key, val); + val + } + } + } else { + true + }; + + if is_better { + best_base = Some(*other_oid); + best_parent = Some(other_name.clone()); + } + } + } + best_parent + } + + fn get_branches( + &self, + progress: Option<&dyn Fn(String, f64)>, + ) -> anyhow::Result<(Vec, HistoryContext)> { + if let Some(p) = progress { + p("Collecting branch data...".to_string(), 0.1); + } + let branch_data = self.collect_raw_branch_data()?; + + if let Some(p) = progress { + p("Mapping branches...".to_string(), 0.2); + } + let representative_map = self.get_representative_map(&branch_data); + + if let Some(p) = progress { + p("Calculating topology...".to_string(), 0.3); + } + let oid_to_parent_oid = self.get_topological_parents(&representative_map, progress)?; + + let mut history = HistoryContext::new(); + history.oid_to_ancestor = oid_to_parent_oid.clone(); + history.oid_to_visible_branch = representative_map.clone(); + + let repo = self.repo()?; + let main_oids: Vec = ["master", "main"] + .iter() + .filter_map(|name| { + branch_data + .iter() + .find(|b| &b.name == name && b.is_local) + .map(|b| b.oid) + }) + .collect(); + + let mut remote_aliases: HashMap> = HashMap::new(); + let mut filtered_branch_data = Vec::new(); + for b in branch_data { + let best_rep = representative_map + .get(&b.oid) + .ok_or_else(|| anyhow::anyhow!("Failed to find representative for OID"))?; + if &b.name != best_rep && b.is_remote { + remote_aliases + .entry(best_rep.clone()) + .or_default() + .push(b.name.clone()); + } else { + filtered_branch_data.push(b); + } + } + + let mut summary_to_branch: HashMap = HashMap::new(); + for b in &filtered_branch_data { + if let Ok(commit) = repo.find_commit(b.oid) + && let Some(summary) = commit.summary() + { + summary_to_branch + .entry(summary.to_string()) + .and_modify(|existing| { + if is_better_heuristic_parent(&b.name, existing) { + *existing = b.name.clone(); + } + }) + .or_insert_with(|| b.name.clone()); + } + } + + let mut branch_to_oid: HashMap = HashMap::new(); + for b in &filtered_branch_data { + branch_to_oid.insert(b.name.clone(), b.oid); + } + + // Pre-calculate merge status + let mut merge_status_map: HashMap = HashMap::new(); + { + // Recursive helper to resolve status + fn get_status( + repo: &Repository, + oid: Oid, + main_oids: &[Oid], + oid_to_parent_oid: &HashMap>, + descendant_cache: &mut HashMap<(Oid, Oid), bool>, + cache: &mut HashMap, + ) -> (bool, bool) { + if let Some(&res) = cache.get(&oid) { + return res; + } + + // If this is one of the main branches, it is merged and not ahead (relative to itself) + if main_oids.contains(&oid) { + let res = (true, false); + cache.insert(oid, res); + return res; + } + + let parent_opt = oid_to_parent_oid.get(&oid).copied().flatten(); + + let (p_merged, p_ahead) = if let Some(p_oid) = parent_opt { + get_status( + repo, + p_oid, + main_oids, + oid_to_parent_oid, + descendant_cache, + cache, + ) + } else { + (false, false) // Default for root (will force check) + }; + + // Optimization 1: If parent is NOT merged, child cannot be merged. + let is_merged = if !p_merged && parent_opt.is_some() { + false + } else { + // Standard check + let mut merged = false; + for &m_oid in main_oids { + if m_oid == oid { + merged = true; + break; + } + let is_desc = *descendant_cache.entry((m_oid, oid)).or_insert_with(|| { + repo.graph_descendant_of(m_oid, oid).unwrap_or(false) + }); + if is_desc { + merged = true; + break; + } + } + merged + }; + + // Optimization 2: If parent IS ahead, child MUST be ahead. + let is_ahead = if p_ahead { + true + } else { + let mut ahead = false; + for &m_oid in main_oids { + if m_oid == oid { + continue; + } + let is_desc = *descendant_cache.entry((oid, m_oid)).or_insert_with(|| { + repo.graph_descendant_of(oid, m_oid).unwrap_or(false) + }); + if is_desc { + ahead = true; + break; + } + } + ahead + }; + + let res = (is_merged, is_ahead); + cache.insert(oid, res); + res + } + + let mut descendant_cache_guard = self.descendant_cache.lock().unwrap(); + for b in &filtered_branch_data { + get_status( + &repo, + b.oid, + &main_oids, + &oid_to_parent_oid, + &mut descendant_cache_guard, + &mut merge_status_map, + ); + } + } + + let mut branches = Vec::new(); + let total = filtered_branch_data.len(); + for (i, b) in filtered_branch_data.into_iter().enumerate() { + if let Some(p) = progress { + p( + format!("Processing branch {}/{}: {}", i + 1, total, b.name), + 0.4 + (i as f64 / total as f64) * 0.5, + ); + } + let best_rep = representative_map + .get(&b.oid) + .ok_or_else(|| anyhow::anyhow!("Missing representative for OID"))?; + let parent = if &b.name != best_rep { + Some(best_rep.clone()) + } else { + oid_to_parent_oid + .get(&b.oid) + .and_then(|p_oid| p_oid.as_ref()) + .map(|p_oid| { + representative_map + .get(p_oid) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Missing representative for parent OID")) + }) + .transpose()? + }; + + let mut aliases = remote_aliases.remove(&b.name).unwrap_or_default(); + aliases.sort(); + + let p_oid = oid_to_parent_oid.get(&b.oid).and_then(|o| *o); + let (heuristic_parent, heuristic_upstream_oid) = self.detect_heuristic_parent( + &repo, + b.oid, + &b.name, + p_oid, + &summary_to_branch, + &branch_to_oid, + ); + + let (ahead, behind) = + self.calculate_ahead_behind(&repo, b.oid, b.upstream_oid, b.is_local); + + // Optimization: Look up parent OID from map instead of revparse + let parent_oid_val = parent + .as_ref() + .and_then(|p_name| branch_to_oid.get(p_name)) + .copied(); + + let (parent_ahead, _) = + self.calculate_ahead_behind(&repo, b.oid, parent_oid_val, b.is_local); + + let (mut is_merged, is_ahead) = merge_status_map + .get(&b.oid) + .copied() + .unwrap_or((false, false)); + + if ["master", "main"].contains(&b.name.as_str()) { + is_merged = false; + } + + branches.push(BranchInfo { + name: b.name, + oid: b.oid, + upstream_oid: b.upstream_oid, + original_parent: parent, + children: Vec::new(), + ahead, + behind, + parent_ahead, + upstream: b.upstream_name, + is_merged, + is_ahead, + is_local: b.is_local, + is_remote: b.is_remote, + aliases, + heuristic_parent, + heuristic_upstream_oid, + }); + } + + if let Some(p) = progress { + p("Finalizing branch list...".to_string(), 0.9); + } + + branches.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok((branches, history)) + } + + fn get_current_branch(&self) -> anyhow::Result { + let repo = self.repo()?; + let head = repo.head()?; + if head.is_branch() { + let name = head + .shorthand() + .ok_or_else(|| anyhow::anyhow!("Invalid branch name"))?; + Ok(name.to_string()) + } else { + anyhow::bail!("Not on a branch") + } + } + + fn check_conflict(&self, branch_name: &str, new_parent_name: &str) -> anyhow::Result { + let repo = self.repo()?; + let branch_oid = repo.revparse_single(branch_name)?.peel_to_commit()?.id(); + let parent_oid = repo + .revparse_single(new_parent_name)? + .peel_to_commit()? + .id(); + drop(repo); + self.check_conflict_between(branch_oid, parent_oid) + } + + fn check_conflict_between(&self, branch_oid: Oid, parent_oid: Oid) -> anyhow::Result { + let repo = self.repo()?; + let our_commit = repo.find_commit(branch_oid)?; + let their_commit = repo.find_commit(parent_oid)?; + + let index = repo.merge_commits(&their_commit, &our_commit, None)?; + Ok(index.has_conflicts()) + } + + fn is_descendant(&self, ancestor: Oid, descendant: Oid) -> anyhow::Result { + let repo = self.repo()?; + Ok(repo.graph_descendant_of(descendant, ancestor)?) + } + + fn rebase(&self, branch: &str, onto: &str) -> anyhow::Result { + let status = self.git_cmd().args(["rebase", onto, branch]).status()?; + Ok(status.success()) + } + + fn checkout(&self, branch: &str) -> anyhow::Result<()> { + let status = self.git_cmd().args(["checkout", branch]).status()?; + if status.success() { + Ok(()) + } else { + anyhow::bail!("Checkout failed") + } + } + + fn push(&self, branch: &str) -> anyhow::Result { + let status = self + .git_cmd() + .args(["push", "--force-with-lease", "origin", branch]) + .status()?; + Ok(status.success()) + } + + fn delete_branch(&self, branch: &str) -> anyhow::Result { + let status = self.git_cmd().args(["branch", "-d", branch]).status()?; + Ok(status.success()) + } + + fn run_command(&self, args: &[String]) -> anyhow::Result { + let status = self.git_cmd().args(args).status()?; + Ok(status.success()) + } + + fn is_dirty(&self) -> anyhow::Result { + let repo = self.repo()?; + let mut status_options = git2::StatusOptions::new(); + status_options.include_untracked(false); + status_options.update_index(true); + status_options.exclude_submodules(true); + let statuses = repo.statuses(Some(&mut status_options))?; + + Ok(!statuses.is_empty()) + } + + fn reset_to_upstream(&self, branch: &str) -> anyhow::Result { + self.checkout(branch)?; + let status = self.git_cmd().args(["reset", "--hard", "@{u}"]).status()?; + Ok(status.success()) + } + + fn get_commit_log(&self, branch: &str) -> anyhow::Result> { + let start_oid = { + let repo = self.repo()?; + repo.revparse_single(branch)?.peel_to_commit()?.id() + }; + + let branch_tips = { + let mut cache = self + .branch_tips_cache + .lock() + .map_err(|_| anyhow::anyhow!("Branch tips cache mutex poisoned"))?; + if cache.is_none() { + drop(cache); + let _ = self.collect_raw_branch_data(); + cache = self + .branch_tips_cache + .lock() + .map_err(|_| anyhow::anyhow!("Branch tips cache mutex poisoned"))?; + } + cache + .clone() + .ok_or_else(|| anyhow::anyhow!("Failed to populate branch tips cache"))? + }; + + let repo = self.repo()?; + let mut walk = repo.revwalk()?; + walk.push(start_oid)?; + walk.set_sorting(git2::Sort::TOPOLOGICAL)?; + + for (other_name, other_oid) in branch_tips { + if other_oid == start_oid { + let other_shorthand = other_name.rsplit('/').next().unwrap_or(&other_name); + let branch_shorthand = branch.rsplit('/').next().unwrap_or(branch); + if other_shorthand != branch_shorthand { + let _ = walk.hide(other_oid); + } + } else { + let is_descendant = *self + .descendant_cache + .lock() + .map_err(|_| anyhow::anyhow!("Descendant cache mutex poisoned"))? + .entry((other_oid, start_oid)) + .or_insert_with(|| { + repo.graph_descendant_of(other_oid, start_oid) + .unwrap_or(false) + }); + + if !is_descendant { + let _ = walk.hide(other_oid); + } + } + } + + let mut logs = Vec::new(); + for commit_oid_res in walk { + let commit_oid = commit_oid_res?; + + let commit = repo.find_commit(commit_oid)?; + let summary = commit.summary().unwrap_or("").to_string(); + let author = commit.author().name().unwrap_or("").to_string(); + let short_id = commit.id().to_string()[..7].to_string(); + logs.push(CommitInfo { + id: short_id, + summary, + author, + }); + + if logs.len() >= 10 { + break; + } + } + Ok(logs) + } + + fn get_diff(&self, branch: &str, parent: &str) -> anyhow::Result> { + let repo = self.repo()?; + let branch_oid = repo.revparse_single(branch)?.peel_to_commit()?.id(); + let parent_oid = repo.revparse_single(parent)?.peel_to_commit()?.id(); + let branch_tree = repo.find_commit(branch_oid)?.tree()?; + let parent_tree = repo.find_commit(parent_oid)?.tree()?; + let mut diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&branch_tree), None)?; + let mut find_opts = git2::DiffFindOptions::new(); + find_opts.renames(true); + find_opts.remove_unmodified(true); + diff.find_similar(Some(&mut find_opts))?; + diff_utils::parse_diff(&diff) + } + + fn split_branch(&self, branch: &str, parent: &str, data: &SplitData) -> anyhow::Result<()> { + let repo = self.repo()?; + + let branch_obj = repo.revparse_single(branch)?; + let branch_commit = branch_obj.peel_to_commit()?; + let parent_obj = repo.revparse_single(parent)?; + let mut current_parent_commit = parent_obj.peel_to_commit()?; + + let mut diff = repo.diff_tree_to_tree( + Some(¤t_parent_commit.tree()?), + Some(&branch_commit.tree()?), + None, + )?; + let mut find_opts = git2::DiffFindOptions::new(); + find_opts.renames(true); + find_opts.remove_unmodified(true); + diff.find_similar(Some(&mut find_opts))?; + + let signature = repo.signature()?; + + for part in &data.parts { + let new_tree_oid = patch_utils::apply_selected_hunks_to_tree( + &repo, + ¤t_parent_commit.tree()?, + &diff, + &part.selected_hunks, + )?; + + let new_tree = repo.find_tree(new_tree_oid)?; + + let new_commit_oid = repo.commit( + None, + &signature, + &signature, + &part.commit_message, + &new_tree, + &[¤t_parent_commit], + )?; + + let new_commit = repo.find_commit(new_commit_oid)?; + repo.reference( + &format!("refs/heads/{}", part.name), + new_commit_oid, + true, + "split part", + )?; + + current_parent_commit = new_commit; + } + + let final_commit_oid = repo.commit( + None, + &signature, + &signature, + branch_commit.message().unwrap_or(""), + &branch_commit.tree()?, + &[¤t_parent_commit], + )?; + + repo.reference( + &format!("refs/heads/{}", branch), + final_commit_oid, + true, + "split branch", + )?; + + Ok(()) + } +} diff --git a/tools/gitui/src/engine/mod.rs b/tools/gitui/src/engine/mod.rs new file mode 100644 index 0000000..f5ab845 --- /dev/null +++ b/tools/gitui/src/engine/mod.rs @@ -0,0 +1,15 @@ +pub mod executor; +pub mod git; +pub mod planner; +pub mod topology; +pub mod transaction; +pub mod types; + +pub use executor::{Executor, ShellExecutor}; +pub use git::{Git, RealGit, is_better_name}; +pub use planner::{calculate_plan, predict_conflicts}; +pub use topology::{apply_move, build_topology, flatten_branches, is_descendant}; +pub use types::*; + +// Re-exports from other modules for convenience +pub use crate::topology::{HistoryContext, Intent, VirtualTopology}; diff --git a/tools/gitui/src/engine/planner.rs b/tools/gitui/src/engine/planner.rs new file mode 100644 index 0000000..68ffeac --- /dev/null +++ b/tools/gitui/src/engine/planner.rs @@ -0,0 +1,449 @@ +use crate::engine::git::Git; +use crate::engine::topology::flatten_branches; +use crate::engine::types::{ + BranchInfo, BranchIntent, Operation, RepositorySnapshot, get_local_name, +}; +use git2::Oid; +use std::collections::{HashMap, HashSet}; + +pub fn calculate_plan( + snapshot: &RepositorySnapshot, + intents: &HashMap, +) -> anyhow::Result> { + let branch_map: HashMap = snapshot + .branches + .iter() + .map(|b| (b.name.clone(), b)) + .collect(); + + let flattened = flatten_branches(&snapshot.branches, intents, &snapshot.history, true)?; + + let mut ctx = PlanContext { + snapshot, + intents, + branch_map, + flattened, + plan: Vec::new(), + rebased_or_reset_branches: HashSet::new(), + effectively_dirty: snapshot.is_dirty, + submitted_branches: HashSet::new(), + }; + + ctx.calculate()?; + Ok(ctx.plan) +} + +struct PlanContext<'a> { + snapshot: &'a RepositorySnapshot, + intents: &'a HashMap, + branch_map: HashMap, + flattened: Vec<(String, usize)>, + plan: Vec, + rebased_or_reset_branches: HashSet, + effectively_dirty: bool, + submitted_branches: HashSet, +} + +impl<'a> PlanContext<'a> { + fn calculate(&mut self) -> anyhow::Result<()> { + self.handle_amends_and_splits()?; + self.handle_submits()?; + self.handle_localizations()?; + self.handle_resets_and_rebases()?; + self.handle_post_ops()?; + Ok(()) + } + + fn handle_amends_and_splits(&mut self) -> anyhow::Result<()> { + for (branch_name, _) in &self.flattened { + if let Some(branch_info) = self.branch_map.get(branch_name) { + let intent = self.intents.get(branch_name).cloned().unwrap_or_default(); + + if intent.pending_amend { + self.plan.push(Operation::Amend { + message: intent.pending_amend_message.clone(), + }); + self.rebased_or_reset_branches.insert(branch_name.clone()); + self.effectively_dirty = false; + } + + if let Some(split_data) = &intent.pending_split { + let parent = intent + .parent + .as_ref() + .cloned() + .flatten() + .or_else(|| branch_info.original_parent.clone()) + .unwrap_or_else(|| "master".to_string()); + + self.plan.push(Operation::Split { + branch: branch_name.clone(), + parent, + data: split_data.clone(), + }); + self.rebased_or_reset_branches.insert(branch_name.clone()); + for part in &split_data.parts { + self.rebased_or_reset_branches.insert(part.name.clone()); + } + } + } + } + Ok(()) + } + + fn handle_submits(&mut self) -> anyhow::Result<()> { + let main_branch_name = self + .snapshot + .branches + .iter() + .find(|b| b.name == "master" || b.name == "main") + .map(|b| b.name.clone()) + .unwrap_or_else(|| "master".to_string()); + + for b in &self.snapshot.branches { + let intent = self.intents.get(&b.name).cloned().unwrap_or_default(); + if intent.pending_submit && !self.effectively_dirty { + self.plan.push(Operation::Submit { + branch: b.name.clone(), + target: main_branch_name.clone(), + }); + self.rebased_or_reset_branches + .insert(main_branch_name.clone()); + self.plan.push(Operation::Delete { + branch: b.name.clone(), + }); + self.submitted_branches.insert(b.name.clone()); + } + } + Ok(()) + } + + fn handle_localizations(&mut self) -> anyhow::Result<()> { + for (branch_name, _) in &self.flattened { + if let Some(branch_info) = self.branch_map.get(branch_name) { + let intent = self.intents.get(branch_name).cloned().unwrap_or_default(); + + let parent = intent + .parent + .as_ref() + .cloned() + .flatten() + .or_else(|| branch_info.original_parent.clone()); + + let needs_localization = intent.pending_localize + || (branch_info.is_remote && parent != branch_info.original_parent); + + if needs_localization && !self.effectively_dirty { + let local_name = get_local_name(branch_name); + if !self.branch_map.contains_key(local_name) || intent.pending_localize { + self.plan.push(Operation::Localize { + branch: branch_name.clone(), + }); + } + } + } + } + Ok(()) + } + + fn handle_resets_and_rebases(&mut self) -> anyhow::Result<()> { + for (branch_name, _) in &self.flattened { + if self.submitted_branches.contains(branch_name) { + continue; + } + + if let Some(branch_info) = self.branch_map.get(branch_name) { + let intent = self.intents.get(branch_name).cloned().unwrap_or_default(); + + if intent.pending_amend || intent.pending_split.is_some() { + continue; + } + + let effective_name = if branch_info.is_remote { + get_local_name(branch_name).to_string() + } else { + branch_name.clone() + }; + + let mut acted = false; + + // Handle Reset/Sync to Upstream + if intent.pending_reset && !self.effectively_dirty { + let is_main = branch_name == "master" || branch_name == "main"; + if is_main + && let Some(upstream_info) = + self.branch_map.get(&format!("upstream/{}", branch_name)) + && upstream_info.oid != branch_info.oid + { + self.plan.push(Operation::Sync { + branch: branch_name.clone(), + onto: format!("upstream/{}", branch_name), + predicted_conflict: None, + }); + self.rebased_or_reset_branches.insert(branch_name.clone()); + acted = true; + } + + if !acted { + let is_noop = branch_info.upstream_oid == Some(branch_info.oid); + + if !is_noop { + if let Some(upstream_name) = &branch_info.upstream { + let upstream_oid = get_rebase_upstream( + branch_info, + &self.branch_map, + upstream_name, + ); + self.plan.push(Operation::Rebase { + branch: effective_name.clone(), + onto: upstream_name.clone(), + upstream: upstream_oid, + predicted_conflict: None, + }); + } else { + self.plan.push(Operation::Reset { + branch: effective_name.clone(), + }); + } + self.rebased_or_reset_branches.insert(branch_name.clone()); + acted = true; + } + } + } + + // Handle Rebase onto Parent + if !acted && !self.effectively_dirty { + let parent = intent + .parent + .as_ref() + .cloned() + .flatten() + .or_else(|| branch_info.original_parent.clone()); + + let parent_changed = parent != branch_info.original_parent; + let parent_rebased = parent + .as_ref() + .is_some_and(|p| self.rebased_or_reset_branches.contains(p)); + + let upstream_oid_str = parent + .as_ref() + .and_then(|p| get_rebase_upstream(branch_info, &self.branch_map, p)); + + let current_topo_parent = self + .snapshot + .history + .oid_to_ancestor + .get(&branch_info.oid) + .and_then(|o| *o); + + let upstream_differs = if let (Some(u_str), Some(current_p_oid)) = + (&upstream_oid_str, current_topo_parent) + { + if let Ok(u_oid) = Oid::from_str(u_str) { + u_oid != current_p_oid + } else { + false + } + } else { + false + }; + + if (parent_changed || parent_rebased || upstream_differs) + && let Some(new_parent_name) = parent + { + let mut is_noop = false; + let p_oid = resolve_oid(&new_parent_name, &self.branch_map, branch_info); + + if !parent_rebased + && let Some(oid) = p_oid + && (oid == branch_info.oid || current_topo_parent == Some(oid)) + { + // Already parented under the head of the target branch. + is_noop = true; + } + + if !is_noop { + self.plan.push(Operation::Rebase { + branch: effective_name, + onto: new_parent_name, + upstream: upstream_oid_str, + predicted_conflict: None, + }); + self.rebased_or_reset_branches.insert(branch_name.clone()); + } + } + } + } + } + Ok(()) + } + + fn handle_post_ops(&mut self) -> anyhow::Result<()> { + for b in &self.snapshot.branches { + let intent = self.intents.get(&b.name).cloned().unwrap_or_default(); + if intent.pending_push { + self.plan.push(Operation::Push { + branch: b.name.clone(), + }); + } + if intent.pending_delete { + self.plan.push(Operation::Delete { + branch: b.name.clone(), + }); + } + } + + for (branch_name, _) in &self.flattened { + if let Some(new_name) = self + .intents + .get(branch_name) + .and_then(|i| i.pending_rename.as_ref()) + { + self.plan.push(Operation::Rename { + branch: branch_name.clone(), + new_name: new_name.clone(), + }); + } + } + Ok(()) + } +} + +pub fn predict_conflicts( + plan: &mut [Operation], + git: &dyn Git, + branches: &[BranchInfo], + progress: Option<&dyn Fn(usize, Option)>, +) { + let mut future_oids: HashMap = + branches.iter().map(|b| (b.name.clone(), b.oid)).collect(); + + for (i, op) in plan.iter_mut().enumerate() { + match op { + Operation::Submit { branch, target } => { + if let Some(branch_oid) = future_oids.get(branch).copied() { + future_oids.insert(target.clone(), branch_oid); + } + if let Some(p) = progress { + p(i, None); + } + } + Operation::Rebase { + branch, + onto, + upstream: _, + predicted_conflict, + } => { + let (branch_oid, onto_oid) = ( + future_oids.get(branch).copied(), + future_oids.get(onto).copied(), + ); + let res = if let (Some(b_oid), Some(o_oid)) = (branch_oid, onto_oid) { + git.check_conflict_between(b_oid, o_oid).ok() + } else { + None + }; + *predicted_conflict = res; + + if let Some(o_oid) = onto_oid { + future_oids.insert(branch.clone(), o_oid); + } + + if let Some(p) = progress { + p(i, res); + } + } + Operation::Sync { + branch, + onto, + predicted_conflict, + } => { + let (branch_oid, onto_oid) = ( + future_oids.get(branch).copied(), + future_oids.get(onto).copied(), + ); + let res = if let (Some(b_oid), Some(o_oid)) = (branch_oid, onto_oid) { + git.is_descendant(b_oid, o_oid).map(|is_desc| !is_desc).ok() + } else { + None + }; + *predicted_conflict = res; + + if let Some(o_oid) = onto_oid { + future_oids.insert(branch.clone(), o_oid); + } + + if let Some(p) = progress { + p(i, res); + } + } + Operation::Reset { branch } => { + if let Some(b_info) = branches.iter().find(|b| b.name == *branch) + && let Some(u_oid) = b_info.upstream_oid + { + future_oids.insert(branch.clone(), u_oid); + } + if let Some(p) = progress { + p(i, None); + } + } + Operation::Localize { branch } => { + if let Some(branch_oid) = future_oids.get(branch).copied() { + let local_name = crate::engine::types::get_local_name(branch); + future_oids.insert(local_name.to_string(), branch_oid); + } + if let Some(p) = progress { + p(i, None); + } + } + Operation::Rename { branch, new_name } => { + if let Some(branch_oid) = future_oids.get(branch).copied() { + future_oids.insert(new_name.clone(), branch_oid); + } + if let Some(p) = progress { + p(i, None); + } + } + _ => { + if let Some(p) = progress { + p(i, None); + } + } + } + } +} + +fn resolve_oid( + name: &str, + branch_map: &HashMap, + context_branch: &BranchInfo, +) -> Option { + if let Some(info) = branch_map.get(name) { + Some(info.oid) + } else if let Ok(oid) = Oid::from_str(name) { + Some(oid) + } else if Some(name) == context_branch.upstream.as_deref() { + context_branch.upstream_oid + } else { + None + } +} + +fn get_rebase_upstream( + branch_info: &BranchInfo, + branch_map: &HashMap, + onto_name: &str, +) -> Option { + if let (Some(h_parent), Some(h_oid)) = ( + &branch_info.heuristic_parent, + branch_info.heuristic_upstream_oid, + ) && h_parent == onto_name + { + return Some(h_oid.to_string()); + } + + branch_info + .original_parent + .as_ref() + .and_then(|orig| resolve_oid(orig, branch_map, branch_info).map(|oid| oid.to_string())) +} diff --git a/tools/gitui/src/engine/topology.rs b/tools/gitui/src/engine/topology.rs new file mode 100644 index 0000000..6405985 --- /dev/null +++ b/tools/gitui/src/engine/topology.rs @@ -0,0 +1,177 @@ +use crate::engine::types::{BranchInfo, BranchIntent}; +use crate::topology::{HistoryContext, Intent, VirtualTopology}; +use git2::Oid; +use std::collections::HashMap; + +pub fn build_topology( + branches: &[BranchInfo], + intents: &HashMap, + history: &HistoryContext, + show_remote: bool, +) -> anyhow::Result { + let mut topo = VirtualTopology::new(); + + for b in branches { + if !show_remote && b.is_remote { + continue; + } + topo.add_branch(&b.name, b.oid); + } + + for b in branches { + if !show_remote && b.is_remote { + continue; + } + let intent = intents.get(&b.name); + if let Some(split_data) = intent.and_then(|i| i.pending_split.as_ref()) { + let mut parent = match intent.and_then(|i| i.parent.as_ref()) { + Some(p) => p.clone(), + None => b.original_parent.clone(), + }; + + for part in &split_data.parts { + topo.add_branch(&part.name, Oid::from_bytes(&[0; 20]).unwrap()); + let _ = topo.set_parent(&part.name, parent.as_deref(), None, Intent::Implicit); + parent = Some(part.name.clone()); + } + + let _ = topo.set_parent(&b.name, parent.as_deref(), None, Intent::Implicit); + continue; + } + + let parent_name = match intent.and_then(|i| i.parent.as_ref()) { + Some(p) => p.as_deref(), + None => b.original_parent.as_deref(), + }; + + if let Some(parent_name) = parent_name { + let mut resolved_oid = None; + + let parent_is_visible = + if let Some(br) = branches.iter().find(|br| br.name == *parent_name) { + show_remote || !br.is_remote + } else { + false + }; + + if parent_is_visible { + topo.set_parent(&b.name, Some(parent_name), None, Intent::Implicit)?; + } else { + if let Ok(oid) = Oid::from_str(parent_name) { + resolved_oid = Some(oid); + } else if Some(parent_name) == b.upstream.as_deref() { + resolved_oid = b.upstream_oid; + } + + if let Some(oid) = resolved_oid { + if let Some(visible_parent) = history.resolve_visible_parent(oid, Some(&b.name)) + { + if topo.branches.contains_key(&visible_parent) { + topo.set_parent( + &b.name, + Some(&visible_parent), + None, + Intent::Implicit, + )?; + } else { + topo.set_parent(&b.name, None, Some(oid), Intent::Implicit)?; + } + } else { + topo.set_parent(&b.name, None, Some(oid), Intent::Implicit)?; + } + } else if parent_name.starts_with("origin/") || parent_name.starts_with("upstream/") + { + let suffix = if let Some(s) = parent_name.strip_prefix("origin/") { + s + } else { + parent_name.strip_prefix("upstream/").unwrap_or(parent_name) + }; + if let Some(br) = branches.iter().find(|br| br.name == suffix) { + if br.name == b.name { + if let Some(visible_parent) = + history.resolve_visible_parent(b.oid, Some(&b.name)) + && topo.branches.contains_key(&visible_parent) + { + topo.set_parent( + &b.name, + Some(&visible_parent), + None, + Intent::Implicit, + )?; + } + } else if topo.branches.contains_key(&br.name) { + topo.set_parent(&b.name, Some(&br.name), None, Intent::Implicit)?; + } + } + } + } + } + + // Heuristic parent is still repo data + if let Some(h_parent) = &b.heuristic_parent + && branches.iter().any(|br| br.name == *h_parent) + { + let _ = topo.set_parent(&b.name, Some(h_parent), None, Intent::Heuristic); + } + } + + Ok(topo) +} + +pub fn flatten_branches( + branches: &[BranchInfo], + intents: &HashMap, + history: &HistoryContext, + show_remote: bool, +) -> anyhow::Result> { + let topo = build_topology(branches, intents, history, show_remote)?; + Ok(topo.flatten()) +} + +pub fn is_descendant( + branches: &[BranchInfo], + intents: &HashMap, + potential_child: &str, + potential_parent: &str, +) -> bool { + let mut current = potential_child.to_string(); + let branch_map: HashMap = + branches.iter().map(|b| (b.name.clone(), b)).collect(); + + let mut visited = std::collections::HashSet::new(); + + while let Some(b) = branch_map.get(¤t) { + if !visited.insert(current.clone()) { + break; // Cycle detected + } + let intent = intents.get(¤t); + let parent = match intent.and_then(|i| i.parent.as_ref()) { + Some(p) => p.as_deref(), + None => b.original_parent.as_deref(), + }; + + if let Some(p) = parent { + if p == potential_parent { + return true; + } + current = p.to_string(); + } else { + break; + } + } + false +} + +pub fn apply_move( + intents: &mut HashMap, + name: &str, + new_parent: Option, +) -> anyhow::Result<()> { + let intent = intents.entry(name.to_string()).or_default(); + intent.parent = Some(new_parent); + + // We can't easily update has_conflict here anymore since it's not in the struct. + // The UI or the state manager will have to handle conflict checking. + + Ok(()) +} diff --git a/tools/gitui/src/engine/transaction.rs b/tools/gitui/src/engine/transaction.rs new file mode 100644 index 0000000..b6b9d46 --- /dev/null +++ b/tools/gitui/src/engine/transaction.rs @@ -0,0 +1,49 @@ +use crate::patch_utils::apply_selected_hunks_to_tree; +use git2::{Diff, Oid, Repository}; +use std::collections::HashSet; + +#[derive(Debug, Clone)] +pub struct Transaction { + pub original_branch: String, + pub parent_branch: String, + pub selected_hunks: HashSet<(String, usize)>, + /// The OID that the new "first" commit's tree will have. + pub projected_first_tree_oid: Option, + /// The OID that the new "second" (remainder) commit's tree will have. + pub projected_remainder_tree_oid: Option, +} + +impl Transaction { + pub fn new(original_branch: String, parent_branch: String) -> Self { + Self { + original_branch, + parent_branch, + selected_hunks: HashSet::new(), + projected_first_tree_oid: None, + projected_remainder_tree_oid: None, + } + } + + pub fn calculate_projected_oids( + &mut self, + repo: &Repository, + diff: &Diff, + ) -> anyhow::Result<()> { + let parent_obj = repo.revparse_single(&self.parent_branch)?; + let parent_commit = parent_obj.peel_to_commit()?; + let base_tree = parent_commit.tree()?; + + // 1. First commit tree (selected hunks) + let first_tree_oid = + apply_selected_hunks_to_tree(repo, &base_tree, diff, &self.selected_hunks)?; + self.projected_first_tree_oid = Some(first_tree_oid); + + // 2. Remainder commit tree (all hunks) + // For a 1-commit branch, the remainder tree is just the original branch's tree. + let original_obj = repo.revparse_single(&self.original_branch)?; + let original_commit = original_obj.peel_to_commit()?; + self.projected_remainder_tree_oid = Some(original_commit.tree_id()); + + Ok(()) + } +} diff --git a/tools/gitui/src/engine/types.rs b/tools/gitui/src/engine/types.rs new file mode 100644 index 0000000..df95418 --- /dev/null +++ b/tools/gitui/src/engine/types.rs @@ -0,0 +1,267 @@ +use git2::Oid; +use std::collections::HashSet; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct BranchIntent { + pub parent: Option>, + pub pending_push: bool, + pub pending_reset: bool, + pub pending_delete: bool, + pub pending_localize: bool, + pub pending_submit: bool, + pub pending_amend: bool, + pub pending_amend_message: Option, + pub pending_rename: Option, + pub pending_split: Option, + pub has_conflict: bool, +} + +#[derive(Debug, Clone)] +pub struct BranchInfo { + pub name: String, + pub oid: Oid, + pub upstream_oid: Option, + pub original_parent: Option, + pub children: Vec, + pub ahead: usize, + pub behind: usize, + pub parent_ahead: usize, + pub upstream: Option, + pub is_merged: bool, + pub is_ahead: bool, + pub is_remote: bool, + pub is_local: bool, + pub aliases: Vec, + pub heuristic_parent: Option, + pub heuristic_upstream_oid: Option, +} + +pub struct RepositorySnapshot { + pub branches: Vec, + pub history: crate::topology::HistoryContext, + pub is_dirty: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SplitPartData { + pub name: String, + pub commit_message: String, + pub selected_hunks: HashSet<(String, usize)>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SplitData { + pub parts: Vec, +} + +impl BranchInfo { + pub fn can_submit(&self) -> bool { + self.is_local + && self.is_ahead + && !self.is_merged + && self.upstream.is_some() + && self.ahead == 0 + && self.behind == 0 + } +} + +impl Default for BranchInfo { + fn default() -> Self { + Self { + name: String::new(), + oid: Oid::from_bytes(&[0; 20]).unwrap(), + upstream_oid: None, + original_parent: None, + children: Vec::new(), + ahead: 0, + behind: 0, + parent_ahead: 0, + upstream: None, + is_merged: false, + is_ahead: false, + is_remote: false, + is_local: true, + aliases: Vec::new(), + heuristic_parent: None, + heuristic_upstream_oid: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitInfo { + pub id: String, + pub summary: String, + pub author: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Operation { + Reset { + branch: String, + }, + Rebase { + branch: String, + onto: String, + upstream: Option, + predicted_conflict: Option, + }, + Push { + branch: String, + }, + Submit { + branch: String, + target: String, + }, + Delete { + branch: String, + }, + Localize { + branch: String, + }, + Amend { + message: Option, + }, + Rename { + branch: String, + new_name: String, + }, + Sync { + branch: String, + onto: String, + predicted_conflict: Option, + }, + Split { + branch: String, + parent: String, + data: SplitData, + }, +} + +impl Operation { + pub fn commands(&self) -> Vec> { + match self { + Operation::Reset { branch } => vec![ + vec!["checkout".to_string(), branch.clone()], + vec![ + "reset".to_string(), + "--hard".to_string(), + "@{u}".to_string(), + ], + ], + Operation::Rebase { + branch, + onto, + upstream, + predicted_conflict: _, + } => { + if let Some(u) = upstream { + vec![vec![ + "rebase".to_string(), + "--onto".to_string(), + onto.clone(), + u.clone(), + branch.clone(), + ]] + } else { + vec![vec!["rebase".to_string(), onto.clone(), branch.clone()]] + } + } + Operation::Push { branch } => vec![vec![ + "push".to_string(), + "--force-with-lease".to_string(), + "origin".to_string(), + branch.clone(), + ]], + Operation::Submit { branch, target } => vec![ + vec![ + "push".to_string(), + "upstream".to_string(), + format!("{}:{}", branch, target), + ], + vec![ + "push".to_string(), + "origin".to_string(), + "--delete".to_string(), + branch.clone(), + ], + vec!["checkout".to_string(), target.clone()], + vec!["merge".to_string(), "--ff-only".to_string(), branch.clone()], + vec!["push".to_string(), "origin".to_string(), target.clone()], + ], + Operation::Sync { + branch, + onto, + predicted_conflict: _, + } => vec![ + vec!["checkout".to_string(), branch.clone()], + vec!["merge".to_string(), "--ff-only".to_string(), onto.clone()], + vec!["push".to_string(), "origin".to_string(), branch.clone()], + ], + Operation::Delete { branch } => { + vec![vec!["branch".to_string(), "-d".to_string(), branch.clone()]] + } + Operation::Localize { branch } => { + let local_name = get_local_name(branch); + vec![vec![ + "checkout".to_string(), + "-B".to_string(), + local_name.to_string(), + "--track".to_string(), + branch.clone(), + ]] + } + Operation::Amend { message } => { + let mut cmd = vec!["commit".to_string(), "--amend".to_string()]; + if let Some(msg) = message { + cmd.push("-m".to_string()); + cmd.push(msg.clone()); + } else { + cmd.push("--no-edit".to_string()); + } + cmd.push("-a".to_string()); + vec![cmd] + } + Operation::Rename { branch, new_name } => { + vec![vec![ + "branch".to_string(), + "-m".to_string(), + branch.clone(), + new_name.clone(), + ]] + } + Operation::Split { + branch: _, + parent: _, + data: _, + } => { + vec![vec!["[SPLIT OPERATION]".to_string()]] + } + } + } +} + +pub fn get_local_name(branch_name: &str) -> &str { + branch_name + .split_once('/') + .map(|(_, suffix)| suffix) + .unwrap_or(branch_name) +} + +impl std::fmt::Display for Operation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Operation::Split { branch, data, .. } = self { + let names: Vec<_> = data.parts.iter().map(|p| p.name.as_str()).collect(); + return write!(f, "git split {} (creating {})", branch, names.join(", ")); + } + + let cmds = self.commands(); + for (i, cmd) in cmds.iter().enumerate() { + if i > 0 { + write!(f, " && ")?; + } + write!(f, "git {}", cmd.join(" "))?; + } + Ok(()) + } +} diff --git a/tools/gitui/src/lib.rs b/tools/gitui/src/lib.rs new file mode 100644 index 0000000..01a3a0e --- /dev/null +++ b/tools/gitui/src/lib.rs @@ -0,0 +1,513 @@ +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + style::Stylize, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; +use std::io; +use std::path::Path; +use tokio::sync::mpsc; + +pub mod diff_utils; +pub mod engine; +pub mod patch_utils; +pub mod runtime; +pub mod split_state; +pub mod state; +pub mod testing; +pub mod topology; +pub mod ui; + +use engine::{ + BranchInfo, CommitInfo, Executor, Git, HistoryContext, Operation, RealGit, ShellExecutor, + calculate_plan, flatten_branches, +}; + +pub fn print_tree>(path: P, show_remote: bool) -> anyhow::Result<()> { + print_tree_to(path, show_remote, &mut std::io::stdout()) +} + +pub fn print_tree_to, W: std::io::Write>( + path: P, + show_remote: bool, + writer: &mut W, +) -> anyhow::Result<()> { + let git = RealGit::new(path.as_ref())?; + let (branches, history) = git.get_branches(None)?; + let intents = std::collections::HashMap::::new(); + let flattened = flatten_branches(&branches, &intents, &history, show_remote)?; + let is_workspace_dirty = git.is_dirty().unwrap_or(false); + let current_branch = git.get_current_branch().unwrap_or_default(); + + if is_workspace_dirty { + writeln!( + writer, + "{}", + "Workspace is DIRTY - Read Only Mode".red().bold() + )?; + } + + let max_name_len = flattened + .iter() + .map(|(name, depth)| { + let base_name_len = name.len(); + // Since this is a static tree print, we don't have pending intents here usually, + // but for completeness we'll check the (empty) intents. + base_name_len + (*depth * 2) + }) + .max() + .unwrap_or(0); + + for (name, depth) in flattened { + let indent = " ".repeat(depth); + let Some(branch_info) = branches.iter().find(|b| b.name == name) else { + continue; + }; + + let is_current = name == current_branch; + let prefix = if is_current { "* " } else { " " }; + + let display_name_str = format!("{}{}{}", prefix, indent, name); + let padding_len = max_name_len.saturating_sub(display_name_str.len()) + 2; + let padding = " ".repeat(padding_len); + + let mut styled_name = display_name_str.stylize(); + + if branch_info.is_remote { + styled_name = styled_name.dark_grey(); + } + + if branch_info.ahead > 0 || branch_info.behind > 0 { + styled_name = styled_name.yellow(); + } + + if is_current { + styled_name = styled_name.bold(); + } + + write!(writer, "{}", styled_name)?; + + if !branch_info.aliases.is_empty() { + write!( + writer, + "{}{}", + padding, + branch_info.aliases.join(", ").dark_grey() + )?; + } + + if branch_info.ahead > 0 || branch_info.behind > 0 { + write!( + writer, + " {}", + format!("({}↑ {}↓)", branch_info.ahead, branch_info.behind).yellow() + )?; + } + + if branch_info.can_submit() { + write!(writer, " {}", "[READY]".green().bold())?; + } + + if branch_info.is_merged { + write!(writer, " {}", "[MERGED]".green())?; + } + + if let Some(h_parent) = &branch_info.heuristic_parent + && Some(h_parent) != branch_info.original_parent.as_ref() + { + write!(writer, " {}", "[DIVERGED]".red().bold())?; + } + + writeln!(writer)?; + } + Ok(()) +} + +pub fn print_submit_plan>(path: P, branch_name: &str) -> anyhow::Result<()> { + print_submit_plan_to(path, branch_name, &mut std::io::stdout()) +} + +pub fn print_submit_plan_to, W: std::io::Write>( + path: P, + branch_name: &str, + writer: &mut W, +) -> anyhow::Result<()> { + let git = RealGit::new(path.as_ref())?; + let (mut branches, history) = git.get_branches(None)?; + let is_dirty = git.is_dirty().unwrap_or(false); + + let branch = branches + .iter_mut() + .find(|b| b.name == branch_name) + .ok_or_else(|| anyhow::anyhow!("Branch not found: {}", branch_name))?; + + if !branch.can_submit() { + anyhow::bail!("Branch is not ready to submit: {}", branch_name); + } + + let mut intents = std::collections::HashMap::new(); + intents.insert( + branch_name.to_string(), + crate::engine::BranchIntent { + pending_submit: true, + ..Default::default() + }, + ); + + let snapshot = crate::engine::RepositorySnapshot { + branches, + history, + is_dirty, + }; + + let mut plan = calculate_plan(&snapshot, &intents)?; + engine::predict_conflicts(&mut plan, &git, &snapshot.branches, None); + + if plan.is_empty() { + writeln!(writer, "No operations to perform.")?; + } else { + writeln!(writer, "Plan to submit {}:", branch_name)?; + for op in plan { + let label = match op { + Operation::Rebase { + predicted_conflict: Some(true), + .. + } => " [CONFLICT]", + Operation::Rebase { + predicted_conflict: Some(false), + .. + } => " [CLEAN]", + _ => "", + }; + writeln!(writer, " {}{}", op, label)?; + } + } + + Ok(()) +} + +pub fn print_sync_plan>(path: P, branch_name: &str) -> anyhow::Result<()> { + print_sync_plan_to(path, branch_name, &mut std::io::stdout()) +} + +pub fn print_sync_plan_to, W: std::io::Write>( + path: P, + branch_name: &str, + writer: &mut W, +) -> anyhow::Result<()> { + let git = RealGit::new(path.as_ref())?; + let (branches, history) = git.get_branches(None)?; + let is_dirty = git.is_dirty().unwrap_or(false); + + if !branches.iter().any(|b| b.name == branch_name) { + anyhow::bail!("Branch not found: {}", branch_name); + } + + let mut intents = std::collections::HashMap::new(); + intents.insert( + branch_name.to_string(), + crate::engine::BranchIntent { + pending_reset: true, + ..Default::default() + }, + ); + + let snapshot = crate::engine::RepositorySnapshot { + branches, + history, + is_dirty, + }; + + let mut plan = calculate_plan(&snapshot, &intents)?; + engine::predict_conflicts(&mut plan, &git, &snapshot.branches, None); + + if plan.is_empty() { + writeln!(writer, "No operations to perform.")?; + } else { + writeln!(writer, "Plan to sync {}:", branch_name)?; + for op in plan { + let label = match op { + Operation::Rebase { + predicted_conflict: Some(true), + .. + } => " [CONFLICT]", + Operation::Sync { + predicted_conflict: Some(true), + .. + } => " [NOT FF]", + Operation::Rebase { + predicted_conflict: Some(false), + .. + } => " [CLEAN]", + Operation::Sync { + predicted_conflict: Some(false), + .. + } => " [FF]", + _ => "", + }; + writeln!(writer, " {}{}", op, label)?; + } + } + + Ok(()) +} + +pub fn print_converge_plan>(path: P, branch_name: &str) -> anyhow::Result<()> { + print_converge_plan_to(path, branch_name, &mut std::io::stdout()) +} + +pub fn print_converge_plan_to, W: std::io::Write>( + path: P, + branch_name: &str, + writer: &mut W, +) -> anyhow::Result<()> { + let git = RealGit::new(path.as_ref())?; + let (branches, history) = git.get_branches(None)?; + let is_dirty = git.is_dirty().unwrap_or(false); + + let branch = branches + .iter() + .find(|b| b.name == branch_name) + .ok_or_else(|| anyhow::anyhow!("Branch not found: {}", branch_name))?; + + let h_parent = branch.heuristic_parent.clone().ok_or_else(|| { + anyhow::anyhow!("No heuristic parent detected for branch: {}", branch_name) + })?; + + let mut intents = std::collections::HashMap::new(); + crate::engine::topology::apply_move(&mut intents, branch_name, Some(h_parent))?; + + let snapshot = crate::engine::RepositorySnapshot { + branches, + history, + is_dirty, + }; + + let mut plan = calculate_plan(&snapshot, &intents)?; + engine::predict_conflicts(&mut plan, &git, &snapshot.branches, None); + + if plan.is_empty() { + writeln!(writer, "No operations to perform.")?; + } else { + writeln!(writer, "Plan to converge {}:", branch_name)?; + for op in plan { + let label = match op { + Operation::Rebase { + predicted_conflict: Some(true), + .. + } => " [CONFLICT]", + Operation::Sync { + predicted_conflict: Some(true), + .. + } => " [NOT FF]", + Operation::Rebase { + predicted_conflict: Some(false), + .. + } => " [CLEAN]", + Operation::Sync { + predicted_conflict: Some(false), + .. + } => " [FF]", + _ => "", + }; + writeln!(writer, " {}{}", op, label)?; + } + } + + Ok(()) +} + +pub enum GitCommand { + GetBranches, + GetCurrentBranch, + CheckConflict { + branch: String, + onto: String, + }, + GetCommitLog { + branch: String, + }, + FetchDiff { + branch: String, + parent: String, + }, + PredictConflicts { + plan: Vec, + branches: Vec, + }, +} + +pub enum GitResponse { + Branches(anyhow::Result<(Vec, HistoryContext, bool)>), + CurrentBranch(anyhow::Result), + Progress { message: String, percentage: f64 }, + ConflictCheck(String, String, anyhow::Result), + CommitLog(String, anyhow::Result>), + DiffLoaded(String, anyhow::Result>), + ConflictsPredicted(Vec), + PredictionProgress { index: usize, result: Option }, +} + +pub struct TerminalGuard { + pub terminal: Terminal>, +} + +impl TerminalGuard { + pub fn new() -> anyhow::Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + Ok(Self { terminal }) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!( + self.terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ); + let _ = self.terminal.show_cursor(); + } +} + +pub async fn run>(path: P) -> anyhow::Result<()> { + runtime::run_runtime(path.as_ref()).await +} + +fn spawn_worker( + path: std::path::PathBuf, + mut cmd_rx: mpsc::Receiver, + resp_tx: mpsc::Sender, +) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + let git = match RealGit::new(&path) { + Ok(g) => g, + Err(_) => return, + }; + + while let Some(cmd) = cmd_rx.blocking_recv() { + match cmd { + GitCommand::GetBranches => { + let progress = |message: String, percentage: f64| { + let _ = resp_tx.blocking_send(GitResponse::Progress { + message, + percentage, + }); + }; + let branches_res = git.get_branches(Some(&progress)); + progress("Checking for dirty worktree...".to_string(), 0.95); + let dirty_res = git.is_dirty(); + + let res = match (branches_res, dirty_res) { + (Ok((b, h)), Ok(d)) => Ok((b, h, d)), + (Err(e), _) => Err(e), + (_, Err(e)) => Err(e), + }; + + let _ = resp_tx.blocking_send(GitResponse::Branches(res)); + } + GitCommand::GetCurrentBranch => { + let _ = + resp_tx.blocking_send(GitResponse::CurrentBranch(git.get_current_branch())); + } + GitCommand::CheckConflict { branch, onto } => { + let res = git.check_conflict(&branch, &onto); + let _ = resp_tx.blocking_send(GitResponse::ConflictCheck(branch, onto, res)); + } + GitCommand::GetCommitLog { branch } => { + let res = git.get_commit_log(&branch); + let _ = resp_tx.blocking_send(GitResponse::CommitLog(branch, res)); + } + GitCommand::FetchDiff { branch, parent } => { + let _ = resp_tx.blocking_send(GitResponse::Progress { + message: format!("Calculating diff for {}...", branch), + percentage: 0.1, + }); + let res = (|| { + let repo = git + .repo + .lock() + .map_err(|_| anyhow::anyhow!("Repository mutex poisoned"))?; + let branch_oid = repo.revparse_single(&branch)?.peel_to_commit()?.id(); + let parent_oid = repo.revparse_single(&parent)?.peel_to_commit()?.id(); + let branch_tree = repo.find_commit(branch_oid)?.tree()?; + let parent_tree = repo.find_commit(parent_oid)?.tree()?; + let diff = + repo.diff_tree_to_tree(Some(&parent_tree), Some(&branch_tree), None)?; + let res = diff_utils::parse_diff(&diff); + let _ = resp_tx.blocking_send(GitResponse::Progress { + message: format!("Parsing diff for {}...", branch), + percentage: 0.5, + }); + res + })(); + let _ = resp_tx.blocking_send(GitResponse::DiffLoaded(branch, res)); + } + GitCommand::PredictConflicts { mut plan, branches } => { + let tx = resp_tx.clone(); + let progress = move |index: usize, result: Option| { + let _ = tx.blocking_send(GitResponse::PredictionProgress { index, result }); + }; + engine::predict_conflicts(&mut plan, &git, &branches, Some(&progress)); + let _ = resp_tx.blocking_send(GitResponse::ConflictsPredicted(plan)); + } + } + } + }) +} + +pub fn execute_plan>( + git: &dyn Git, + branches: &[BranchInfo], + intents: &std::collections::HashMap, + history: &HistoryContext, + path: P, +) -> anyhow::Result<()> { + let is_dirty = git.is_dirty().unwrap_or(false); + let snapshot = crate::engine::RepositorySnapshot { + branches: branches.to_vec(), + history: history.clone(), + is_dirty, + }; + let plan = calculate_plan(&snapshot, intents)?; + execute_given_plan(git, &plan, path) +} + +pub fn execute_rebases>( + git: &dyn Git, + branches: &[BranchInfo], + intents: &std::collections::HashMap, + history: &HistoryContext, + path: P, +) -> anyhow::Result<()> { + let is_dirty = git.is_dirty().unwrap_or(false); + let snapshot = crate::engine::RepositorySnapshot { + branches: branches.to_vec(), + history: history.clone(), + is_dirty, + }; + let plan = calculate_plan(&snapshot, intents)?; + let rebase_only_plan: Vec<_> = plan + .into_iter() + .filter(|op| matches!(op, Operation::Rebase { .. })) + .collect(); + execute_given_plan(git, &rebase_only_plan, path) +} + +fn execute_given_plan>( + git: &dyn Git, + plan: &[Operation], + path: P, +) -> anyhow::Result<()> { + let executor = ShellExecutor::new(true); + for op in plan { + executor.execute(git, op, path.as_ref())?; + } + Ok(()) +} diff --git a/tools/gitui/src/main.rs b/tools/gitui/src/main.rs new file mode 100644 index 0000000..43b9e6d --- /dev/null +++ b/tools/gitui/src/main.rs @@ -0,0 +1,45 @@ +use clap::Parser; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +struct Args { + #[arg(short, long, default_value = ".")] + path: String, + + /// Print the branch tree and exit + #[arg(short, long)] + tree: bool, + + /// Show remote branches from origin and upstream + #[arg(short, long)] + all: bool, + + /// Show the plan to submit a specific branch + #[arg(long)] + submit: Option, + + /// Show the plan to converge a specific branch (move to heuristic parent) + #[arg(long)] + converge: Option, + + /// Show the plan to sync a branch with its upstream + #[arg(long)] + sync: Option, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + if let Some(branch_name) = args.submit { + gitui::print_submit_plan(&args.path, &branch_name)?; + } else if let Some(branch_name) = args.converge { + gitui::print_converge_plan(&args.path, &branch_name)?; + } else if let Some(branch_name) = args.sync { + gitui::print_sync_plan(&args.path, &branch_name)?; + } else if args.tree { + gitui::print_tree(&args.path, args.all)?; + } else { + gitui::run(&args.path).await?; + } + Ok(()) +} diff --git a/tools/gitui/src/patch_utils.rs b/tools/gitui/src/patch_utils.rs new file mode 100644 index 0000000..9e2358d --- /dev/null +++ b/tools/gitui/src/patch_utils.rs @@ -0,0 +1,146 @@ +use git2::{Diff, DiffFormat, Oid, Repository, Tree}; +use std::collections::HashSet; + +pub fn create_filtered_diff<'a>( + diff: &Diff<'a>, + selected_hunks: &HashSet<(String, usize)>, // (file_path, hunk_index) +) -> anyhow::Result> { + let mut patch = Vec::new(); + let mut current_file_path = String::new(); + let mut hunk_index = 0; + let mut hunk_selected = false; + let mut file_header = Vec::new(); + let mut file_header_printed = false; + let mut skew: isize = 0; + + diff.print(DiffFormat::Patch, |delta, hunk, line| { + let path_str = delta + .new_file() + .path() + .and_then(|p| p.to_str()) + .unwrap_or(""); + + if path_str != current_file_path { + if !current_file_path.is_empty() + && !file_header_printed + && selected_hunks.contains(&(current_file_path.clone(), 0)) + && hunk_index == 0 + { + patch.extend_from_slice(&file_header); + } + + current_file_path = path_str.to_string(); + hunk_index = 0; + hunk_selected = false; + file_header.clear(); + file_header_printed = false; + skew = 0; + } + + if let Some(hunk) = hunk { + if line.origin() == 'H' { + // Hunk header line + hunk_selected = selected_hunks.contains(&(current_file_path.clone(), hunk_index)); + + if hunk_selected { + let old_start = hunk.old_start(); + let old_lines = hunk.old_lines(); + let new_start = (hunk.new_start() as isize - skew).max(1) as u32; + let new_lines = hunk.new_lines(); + + // Reconstruct header with context + // We need to preserve the function context if present in the original header + let content = String::from_utf8_lossy(line.content()); + let suffix = if let Some(idx) = content.find(" @@") { + // Find the second " @@" which ends the numbers part + // Usually header is "@@ -x,y +z,w @@ function_name" + // content starts with "@@ " + &content[idx..] + } else { + " @@\n" + }; + + // Ensure suffix ends with newline + let suffix = if suffix.ends_with('\n') { + suffix.to_string() + } else { + format!("{}\n", suffix) + }; + + let header = format!( + "@@ -{},{} +{},{}{}", + old_start, old_lines, new_start, new_lines, suffix + ); + + if !file_header_printed { + patch.extend_from_slice(&file_header); + file_header_printed = true; + } + + patch.extend_from_slice(header.as_bytes()); + } else { + let shift = hunk.new_lines() as isize - hunk.old_lines() as isize; + skew += shift; + } + + hunk_index += 1; + } else if hunk_selected { + let origin = line.origin(); + match origin { + '+' | '-' | ' ' => { + patch.push(origin as u8); + patch.extend_from_slice(line.content()); + } + _ => { + // Ignore other line types in body + } + } + // Ensure newline if missing (though git usually provides it or handled by origin) + if (origin == '+' || origin == '-' || origin == ' ') && !patch.ends_with(b"\n") { + // check if line content had newline + if !line.content().ends_with(b"\n") { + patch.push(b'\n'); + } + } + if origin == '>' || origin == '<' { + // We should probably preserve these if they relate to selected hunks + patch.extend_from_slice(line.content()); + if !line.content().ends_with(b"\n") { + patch.push(b'\n'); + } + } + } + } else { + // This is a file header line + file_header.extend_from_slice(line.content()); + } + true + })?; + + if !current_file_path.is_empty() + && !file_header_printed + && selected_hunks.contains(&(current_file_path, 0)) + && hunk_index == 0 + { + patch.extend_from_slice(&file_header); + } + + Ok(patch) +} + +pub fn apply_selected_hunks_to_tree( + repo: &Repository, + base_tree: &Tree, + diff: &Diff, + selected_hunks: &HashSet<(String, usize)>, +) -> anyhow::Result { + let patch_data = create_filtered_diff(diff, selected_hunks)?; + if patch_data.is_empty() { + return Ok(base_tree.id()); + } + + let filtered_diff = Diff::from_buffer(&patch_data)?; + let mut index = repo.apply_to_tree(base_tree, &filtered_diff, None)?; + let tree_oid = index.write_tree_to(repo)?; + Ok(tree_oid) +} diff --git a/tools/gitui/src/runtime.rs b/tools/gitui/src/runtime.rs new file mode 100644 index 0000000..a22ebf3 --- /dev/null +++ b/tools/gitui/src/runtime.rs @@ -0,0 +1,256 @@ +use crate::engine::{BranchInfo, Git, RealGit}; +use crate::state::{AppMode, AppState, Effect, Msg}; +use crate::{GitCommand, GitResponse, TerminalGuard, execute_plan, spawn_worker, ui}; +use crossterm::event::{self, Event}; +use ratatui::Terminal; +use ratatui::backend::Backend; +use std::path::Path; +use std::time::Duration; +use tokio::sync::mpsc; + +pub async fn run_runtime>(path: P) -> anyhow::Result<()> { + let mut state = AppState::default(); + let path = path.as_ref(); + + loop { + let (cmd_tx, cmd_rx) = mpsc::channel::(32); + let (resp_tx, mut resp_rx) = mpsc::channel::(32); + + let path_clone = path.to_path_buf(); + let _worker = spawn_worker(path_clone, cmd_rx, resp_tx); + + // Initial data + cmd_tx.send(GitCommand::GetBranches).await?; + cmd_tx.send(GitCommand::GetCurrentBranch).await?; + + let mut guard = TerminalGuard::new()?; + + let run_res = run_loop(&mut guard.terminal, &mut state, &cmd_tx, &mut resp_rx).await; + + drop(guard); + + match run_res { + Ok(Some((branches_to_execute, intents))) => { + let git = RealGit::new(path)?; + let exec_res = + execute_plan(&git, &branches_to_execute, &intents, &state.history, path); + + if exec_res.is_ok() { + state.clear_pending_operations(); + if let Ok(current) = git.get_current_branch() + && current != state.initial_branch + { + let branch_exists = |name: &str| -> bool { + git.run_command(&[ + "show-ref".to_string(), + "--verify".to_string(), + "--quiet".to_string(), + format!("refs/heads/{}", name), + ]) + .unwrap_or(false) + }; + + if branch_exists(&state.initial_branch) { + println!("\nRestoring initial branch: {}...", state.initial_branch); + let _ = git.checkout(&state.initial_branch); + } else { + println!( + "\nInitial branch {} no longer exists.", + state.initial_branch + ); + + let mut children: Vec = branches_to_execute + .iter() + .filter(|b| { + let intent = intents.get(&b.name); + let effective_parent = + match intent.and_then(|i| i.parent.as_ref()) { + Some(p) => p.clone(), + None => b.original_parent.clone(), + }; + effective_parent.as_ref() == Some(&state.initial_branch) + }) + .map(|b| b.name.clone()) + .collect(); + children.sort(); + + let mut target = None; + for child in children { + if branch_exists(&child) { + target = Some(child); + break; + } + } + + if target.is_none() { + if branch_exists("master") { + target = Some("master".to_string()); + } else if branch_exists("main") { + target = Some("main".to_string()); + } + } + + if let Some(t) = target { + if t != current { + println!("Switching to: {}...", t); + let _ = git.checkout(&t); + } else { + println!("Staying on {}.", current); + } + } else { + println!("Staying on {}.", current); + } + } + } + } + + println!("\nExecution finished. Press Enter to return to TUI..."); + let mut _buf = String::new(); + let _ = std::io::stdin().read_line(&mut _buf); + + if let Err(e) = exec_res { + eprintln!("Error during execution: {:?}", e); + let _ = std::io::stdin().read_line(&mut _buf); + } + } + Ok(None) => break, + Err(e) => { + eprintln!("{:?}", e); + break; + } + } + } + + Ok(()) +} + +enum LoopControl { + Continue, + Break( + Option<( + Vec, + std::collections::HashMap, + )>, + ), +} + +async fn run_loop( + terminal: &mut Terminal, + state: &mut AppState, + cmd_tx: &mpsc::Sender, + resp_rx: &mut mpsc::Receiver, +) -> anyhow::Result< + Option<( + Vec, + std::collections::HashMap, + )>, +> +where + anyhow::Error: From, +{ + loop { + // Draw + if state.needs_redraw { + terminal.draw(|f| match state.mode { + AppMode::Preview => { + let plan = state.plan.as_ref().cloned().unwrap_or_default(); + ui::draw_preview(f, &plan, state); + } + AppMode::Split => { + ui::draw_split_view(f, state); + } + AppMode::Tree => { + ui::draw_main(f, state); + } + AppMode::Prompt => { + ui::draw_prompt(f, state); + } + })?; + state.needs_redraw = false; + } + + // Handle Input & Ticks + let timeout = Duration::from_millis(50); + if event::poll(timeout)? { + let event = event::read()?; + if let Event::Key(key) = event { + let effects = state.update(Msg::KeyPressed(key)); + if let LoopControl::Break(res) = handle_effects(effects, cmd_tx).await? { + return Ok(res); + } + } + } else { + let effects = state.update(Msg::Tick); + if let LoopControl::Break(res) = handle_effects(effects, cmd_tx).await? { + return Ok(res); + } + } + + // Handle Worker Responses + while let Ok(resp) = resp_rx.try_recv() { + let msg = match resp { + GitResponse::Branches(res) => Msg::BranchesLoaded(res), + GitResponse::CurrentBranch(res) => Msg::CurrentBranchLoaded(res), + GitResponse::ConflictCheck(branch, onto, res) => { + Msg::ConflictChecked(branch, onto, res) + } + GitResponse::CommitLog(branch, res) => Msg::CommitLogLoaded(branch, res), + GitResponse::Progress { + message, + percentage, + } => Msg::ProgressUpdated { + message, + percentage, + }, + GitResponse::ConflictsPredicted(plan) => Msg::ConflictsPredicted(plan), + GitResponse::PredictionProgress { index, result } => { + Msg::PredictionProgress { index, result } + } + GitResponse::DiffLoaded(branch, res) => Msg::DiffLoaded(branch, res), + }; + let effects = state.update(msg); + if let LoopControl::Break(res) = handle_effects(effects, cmd_tx).await? { + return Ok(res); + } + } + } +} + +async fn handle_effects( + effects: Vec, + cmd_tx: &mpsc::Sender, +) -> anyhow::Result { + for effect in effects { + match effect { + Effect::FetchBranches => { + cmd_tx.send(GitCommand::GetBranches).await?; + } + Effect::FetchCurrentBranch => { + cmd_tx.send(GitCommand::GetCurrentBranch).await?; + } + Effect::CheckConflict { branch, onto } => { + cmd_tx + .send(GitCommand::CheckConflict { branch, onto }) + .await?; + } + Effect::FetchCommitLog { branch } => { + cmd_tx.send(GitCommand::GetCommitLog { branch }).await?; + } + Effect::PredictConflicts { plan, branches } => { + cmd_tx + .send(GitCommand::PredictConflicts { plan, branches }) + .await?; + } + Effect::FetchDiff { branch, parent } => { + cmd_tx + .send(GitCommand::FetchDiff { branch, parent }) + .await?; + } + Effect::Quit => return Ok(LoopControl::Break(None)), + Effect::ApplyAndQuit(branches, intents) => { + return Ok(LoopControl::Break(Some((branches, intents)))); + } + } + } + Ok(LoopControl::Continue) +} diff --git a/tools/gitui/src/split_state.rs b/tools/gitui/src/split_state.rs new file mode 100644 index 0000000..bf67537 --- /dev/null +++ b/tools/gitui/src/split_state.rs @@ -0,0 +1,481 @@ +use crate::diff_utils::FileDiff; +use ratatui::widgets::ListState; +use std::collections::HashSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SplitViewMode { + Files, + Hunks, + Lines, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SplitPart { + pub name: String, + pub commit_message: String, + pub selected_hunks: HashSet<(String, usize)>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RenderedItem { + FileHeader { + file_idx: usize, + }, + HunkHeader { + file_idx: usize, + hunk_idx: usize, + }, + Line { + file_idx: usize, + hunk_idx: usize, + line_idx: usize, + }, +} + +impl RenderedItem { + pub fn file_idx(&self) -> usize { + match self { + RenderedItem::FileHeader { file_idx } => *file_idx, + RenderedItem::HunkHeader { file_idx, .. } => *file_idx, + RenderedItem::Line { file_idx, .. } => *file_idx, + } + } + + pub fn hunk_idx(&self) -> Option { + match self { + RenderedItem::HunkHeader { hunk_idx, .. } => Some(*hunk_idx), + RenderedItem::Line { hunk_idx, .. } => Some(*hunk_idx), + _ => None, + } + } +} + +#[derive(Debug)] +pub struct SplitState { + pub files: Vec, + pub parts: Vec, + pub current_selection: HashSet<(String, usize)>, + pub mode: SplitViewMode, + pub list_state: ListState, + pub rendered_items: Vec, + pub selected_view_idx: usize, + pub focused_file_idx: usize, + pub focused_hunk_idx: usize, + /// Which hunk is expanded in Lines mode. + /// (file_idx, hunk_idx) + pub expanded_hunk: Option<(usize, usize)>, +} + +impl SplitState { + pub fn new(files: Vec) -> Self { + let mut state = Self { + files, + parts: Vec::new(), + current_selection: HashSet::new(), + mode: SplitViewMode::Files, + list_state: ListState::default(), + rendered_items: Vec::new(), + selected_view_idx: 0, + focused_file_idx: 0, + focused_hunk_idx: 0, + expanded_hunk: None, + }; + state.rebuild_view(); + state + } + + pub fn is_hunk_taken(&self, file_path: &str, hunk_idx: usize) -> bool { + self.parts.iter().any(|p| { + p.selected_hunks + .contains(&(file_path.to_string(), hunk_idx)) + }) + } + + pub fn part_for_hunk(&self, file_path: &str, hunk_idx: usize) -> Option { + self.parts.iter().position(|p| { + p.selected_hunks + .contains(&(file_path.to_string(), hunk_idx)) + }) + } + + pub fn rebuild_view(&mut self) { + // Save previously selected item to restore focus later + let prev_selected_item = self.rendered_items.get(self.selected_view_idx).cloned(); + + self.rendered_items.clear(); + + for (f_idx, file) in self.files.iter().enumerate() { + let available_hunks: Vec = file + .hunks + .iter() + .enumerate() + .filter(|(h_idx, _)| !self.is_hunk_taken(&file.path, *h_idx)) + .map(|(h_idx, _)| h_idx) + .collect(); + + // Only show file header if it has available hunks or if it's empty (no hunks at all) + if available_hunks.is_empty() && !file.hunks.is_empty() { + continue; + } + + self.rendered_items + .push(RenderedItem::FileHeader { file_idx: f_idx }); + + let is_file_expanded = match self.mode { + SplitViewMode::Files => false, + _ => f_idx == self.focused_file_idx, + }; + + if is_file_expanded { + for h_idx in available_hunks { + self.rendered_items.push(RenderedItem::HunkHeader { + file_idx: f_idx, + hunk_idx: h_idx, + }); + + if self.mode == SplitViewMode::Lines + && self.expanded_hunk == Some((f_idx, h_idx)) + { + for l_idx in 0..file.hunks[h_idx].lines.len() { + self.rendered_items.push(RenderedItem::Line { + file_idx: f_idx, + hunk_idx: h_idx, + line_idx: l_idx, + }); + } + } + } + } + } + + // Restore selection or find something sensible + if let Some(prev) = prev_selected_item { + if let Some(new_idx) = self.rendered_items.iter().position(|item| item == &prev) { + self.selected_view_idx = new_idx; + } else { + // Try to find a logical fallback + let fallback = match prev { + RenderedItem::Line { + file_idx, hunk_idx, .. + } => Some(RenderedItem::HunkHeader { file_idx, hunk_idx }), + RenderedItem::HunkHeader { file_idx, .. } + if self.mode == SplitViewMode::Files => + { + Some(RenderedItem::FileHeader { file_idx }) + } + _ => None, + }; + + let mut found_fallback = false; + if let Some(f) = fallback { + if let Some(new_idx) = self.rendered_items.iter().position(|item| item == &f) { + self.selected_view_idx = new_idx; + found_fallback = true; + } else { + // If hunk header not found (e.g. hunk taken), try file header + if let RenderedItem::HunkHeader { file_idx, .. } = f { + let f2 = RenderedItem::FileHeader { file_idx }; + if let Some(new_idx) = + self.rendered_items.iter().position(|item| item == &f2) + { + self.selected_view_idx = new_idx; + found_fallback = true; + } + } + } + } + + if !found_fallback { + self.selected_view_idx = self + .selected_view_idx + .min(self.rendered_items.len().saturating_sub(1)); + } + } + } else { + self.selected_view_idx = self + .selected_view_idx + .min(self.rendered_items.len().saturating_sub(1)); + } + + // Update focused trackers based on selection + if let Some(item) = self.rendered_items.get(self.selected_view_idx) { + match item { + RenderedItem::FileHeader { file_idx } => { + self.focused_file_idx = *file_idx; + } + RenderedItem::HunkHeader { file_idx, hunk_idx } => { + self.focused_file_idx = *file_idx; + self.focused_hunk_idx = *hunk_idx; + } + RenderedItem::Line { + file_idx, hunk_idx, .. + } => { + self.focused_file_idx = *file_idx; + self.focused_hunk_idx = *hunk_idx; + } + } + } + + self.list_state.select(Some(self.selected_view_idx)); + } + + fn can_navigate(&self, from: &RenderedItem, to: &RenderedItem) -> bool { + if self.mode == SplitViewMode::Files { + return true; + } + + if from.file_idx() != to.file_idx() { + return false; + } + + if matches!(to, RenderedItem::FileHeader { .. }) { + return false; + } + + if self.mode == SplitViewMode::Lines && from.hunk_idx() != to.hunk_idx() { + return false; + } + true + } + + pub fn next(&mut self) { + if self.rendered_items.is_empty() { + return; + } + if self.selected_view_idx + 1 < self.rendered_items.len() { + let current = &self.rendered_items[self.selected_view_idx]; + let next = &self.rendered_items[self.selected_view_idx + 1]; + + if self.can_navigate(current, next) { + self.selected_view_idx += 1; + self.rebuild_view(); + } + } + } + + pub fn prev(&mut self) { + if self.rendered_items.is_empty() { + return; + } + if self.selected_view_idx > 0 { + let current = &self.rendered_items[self.selected_view_idx]; + let prev = &self.rendered_items[self.selected_view_idx - 1]; + + if self.can_navigate(current, prev) { + self.selected_view_idx -= 1; + self.rebuild_view(); + } + } + } + + pub fn page_down(&mut self, height: usize) { + for _ in 0..height { + if self.rendered_items.is_empty() { + break; + } + if self.selected_view_idx + 1 < self.rendered_items.len() { + let current = &self.rendered_items[self.selected_view_idx]; + let next = &self.rendered_items[self.selected_view_idx + 1]; + + if self.can_navigate(current, next) { + self.selected_view_idx += 1; + } else { + break; + } + } else { + break; + } + } + self.rebuild_view(); + } + + pub fn page_up(&mut self, height: usize) { + for _ in 0..height { + if self.rendered_items.is_empty() { + break; + } + if self.selected_view_idx > 0 { + let current = &self.rendered_items[self.selected_view_idx]; + let prev = &self.rendered_items[self.selected_view_idx - 1]; + + if self.can_navigate(current, prev) { + self.selected_view_idx -= 1; + } else { + break; + } + } else { + break; + } + } + self.rebuild_view(); + } + + pub fn enter_hunks(&mut self) { + if self.mode == SplitViewMode::Files + && let Some(RenderedItem::FileHeader { file_idx }) = + self.rendered_items.get(self.selected_view_idx) + && !self.files[*file_idx].hunks.is_empty() + { + self.mode = SplitViewMode::Hunks; + self.focused_file_idx = *file_idx; + self.rebuild_view(); + // Move to the first hunk of this file + if let Some(pos) = self.rendered_items.iter().position(|item| match item { + RenderedItem::HunkHeader { file_idx: fi, .. } => *fi == self.focused_file_idx, + _ => false, + }) { + self.selected_view_idx = pos; + self.rebuild_view(); + } + // When entering hunks, scroll so the file header is at the top + *self.list_state.offset_mut() = self.selected_view_idx.saturating_sub(1); + } + } + + pub fn exit_hunks(&mut self) { + if self.mode == SplitViewMode::Hunks { + let target_file_idx = self.focused_file_idx; + self.mode = SplitViewMode::Files; + self.rebuild_view(); + // Move back to file header + if let Some(pos) = self.rendered_items.iter().position(|item| match item { + RenderedItem::FileHeader { file_idx } => *file_idx == target_file_idx, + _ => false, + }) { + self.selected_view_idx = pos; + self.rebuild_view(); + } + } + } + + pub fn enter_lines(&mut self) { + if self.mode == SplitViewMode::Hunks + && let Some(RenderedItem::HunkHeader { file_idx, hunk_idx }) = + self.rendered_items.get(self.selected_view_idx) + { + self.mode = SplitViewMode::Lines; + self.expanded_hunk = Some((*file_idx, *hunk_idx)); + self.rebuild_view(); + // Move to first line + if let Some(pos) = self.rendered_items.iter().position(|item| match item { + RenderedItem::Line { + file_idx: fi, + hunk_idx: hi, + .. + } => *fi == self.focused_file_idx && *hi == self.focused_hunk_idx, + _ => false, + }) { + self.selected_view_idx = pos; + self.rebuild_view(); + } + // Try to keep the file header visible + *self.list_state.offset_mut() = self + .rendered_items + .iter() + .position(|item| match item { + RenderedItem::FileHeader { file_idx } => *file_idx == self.focused_file_idx, + _ => false, + }) + .unwrap_or(0); + } + } + + pub fn exit_lines(&mut self) { + if self.mode == SplitViewMode::Lines { + let target_file_idx = self.focused_file_idx; + let target_hunk_idx = self.focused_hunk_idx; + self.mode = SplitViewMode::Hunks; + self.expanded_hunk = None; + self.rebuild_view(); + // Move back to hunk header + if let Some(pos) = self.rendered_items.iter().position(|item| match item { + RenderedItem::HunkHeader { + file_idx: fi, + hunk_idx: hi, + } => *fi == target_file_idx && *hi == target_hunk_idx, + _ => false, + }) { + self.selected_view_idx = pos; + self.rebuild_view(); + } + } + } + + pub fn toggle_selection(&mut self) { + let item = match self.rendered_items.get(self.selected_view_idx) { + Some(i) => i.clone(), + None => return, + }; + + match item { + RenderedItem::FileHeader { file_idx } => { + let file = &self.files[file_idx]; + let available_hunks: Vec = file + .hunks + .iter() + .enumerate() + .filter(|(i, _)| !self.is_hunk_taken(&file.path, *i)) + .map(|(i, _)| i) + .collect(); + + if available_hunks.is_empty() { + if file.hunks.is_empty() && !self.is_hunk_taken(&file.path, 0) { + let key = (file.path.clone(), 0); + if self.current_selection.contains(&key) { + self.current_selection.remove(&key); + } else { + self.current_selection.insert(key); + } + } + return; + } + + let all_selected = available_hunks + .iter() + .all(|&i| self.current_selection.contains(&(file.path.clone(), i))); + + if all_selected { + for &i in &available_hunks { + self.current_selection.remove(&(file.path.clone(), i)); + } + } else { + for &i in &available_hunks { + self.current_selection.insert((file.path.clone(), i)); + } + } + } + RenderedItem::HunkHeader { file_idx, hunk_idx } + | RenderedItem::Line { + file_idx, hunk_idx, .. + } => { + let file_path = &self.files[file_idx].path; + let key = (file_path.clone(), hunk_idx); + if self.current_selection.contains(&key) { + self.current_selection.remove(&key); + } else { + self.current_selection.insert(key); + } + } + } + self.rebuild_view(); + } + + pub fn get_focused_file_idx(&self) -> Option { + Some(self.focused_file_idx) + } + + pub fn get_focused_hunk_idx(&self) -> Option { + match self.rendered_items.get(self.selected_view_idx) { + Some(RenderedItem::HunkHeader { hunk_idx, .. }) => Some(*hunk_idx), + Some(RenderedItem::Line { hunk_idx, .. }) => Some(*hunk_idx), + _ => None, + } + } + + pub fn get_focused_line_idx(&self) -> Option { + match self.rendered_items.get(self.selected_view_idx) { + Some(RenderedItem::Line { line_idx, .. }) => Some(*line_idx), + _ => None, + } + } +} diff --git a/tools/gitui/src/state/actions.rs b/tools/gitui/src/state/actions.rs new file mode 100644 index 0000000..5483efa --- /dev/null +++ b/tools/gitui/src/state/actions.rs @@ -0,0 +1,234 @@ +use crate::engine::{BranchIntent, Intent, build_topology}; +use crate::state::types::{AppMode, AppState, ConflictCheckState, Effect, SidebarState}; +use crate::ui::SPINNERS; +use std::time::{Duration, Instant}; + +impl AppState { + pub fn mutate_intent(&mut self, branch_name: &str, f: F) + where + F: FnOnce(&mut BranchIntent), + { + let intent = self.intents.entry(branch_name.to_string()).or_default(); + f(intent); + } + + pub fn get_intent(&self, branch_name: &str) -> BranchIntent { + self.intents.get(branch_name).cloned().unwrap_or_default() + } + + pub fn get_effective_parent(&self, branch_name: &str) -> Option { + let b = self.branches.iter().find(|b| b.name == branch_name)?; + let intent = self.intents.get(branch_name); + match intent.and_then(|i| i.parent.as_ref()) { + Some(p) => p.clone(), + None => b.original_parent.clone(), + } + } + + pub fn try_apply_move(&mut self, name: &str, new_parent: Option) -> bool { + let mut topo = match build_topology( + &self.branches, + &self.intents, + &self.history, + self.show_remote, + ) { + Ok(t) => t, + Err(_) => return false, + }; + + let is_valid = topo + .set_parent(name, new_parent.as_deref(), None, Intent::Structural) + .is_ok(); + + if is_valid { + let is_remote = self + .branches + .iter() + .find(|b| b.name == name) + .map(|b| b.is_remote) + .unwrap_or(false); + + self.mutate_intent(name, |i| { + i.parent = Some(new_parent); + if is_remote { + i.pending_localize = true; + } + }); + true + } else { + false + } + } + + pub fn handle_tick(&mut self) -> Vec { + let mut effects = Vec::new(); + + if self.is_loading || self.is_log_loading() { + self.spinner_index = (self.spinner_index + 1) % SPINNERS.len(); + self.needs_redraw = true; + } + + // Debounced commit log fetch + if let SidebarState::Debouncing { ref branch, since } = self.sidebar + && since.elapsed() >= Duration::from_millis(500) + { + let branch = branch.clone(); + self.sidebar = SidebarState::Loading { + branch: branch.clone(), + }; + self.needs_redraw = true; + effects.push(Effect::FetchCommitLog { branch }); + } + + // Debounced conflict check + if let ConflictCheckState::Debouncing { + ref branch, + ref onto, + since, + } = self.conflict_check + && since.elapsed() >= Duration::from_millis(200) + { + if let Some(&has_conflict) = self.conflict_cache.get(&(branch.clone(), onto.clone())) { + let branch = branch.clone(); + self.conflict_check = ConflictCheckState::Idle; + self.mutate_intent(&branch, |i| { + i.has_conflict = has_conflict; + }); + self.needs_redraw = true; + } else { + let branch = branch.clone(); + let onto = onto.clone(); + self.conflict_check = ConflictCheckState::Checking { + branch: branch.clone(), + onto: onto.clone(), + }; + self.needs_redraw = true; + effects.push(Effect::CheckConflict { branch, onto }); + } + } + + effects + } + + pub fn on_selection_change(&mut self) -> Vec { + if let Some(idx) = self.list_state.selected() { + if let Some((name, _)) = self.flattened_tree.get(idx) { + let name = name.clone(); + self.sidebar = SidebarState::Debouncing { + branch: name, + since: Instant::now(), + }; + } + } else { + self.sidebar = SidebarState::Idle; + } + Vec::new() + } + + pub fn on_move_change(&mut self) { + if let (Some(grabbed), Some(target)) = (&self.grabbed_branch, &self.target_parent) { + self.conflict_check = ConflictCheckState::Debouncing { + branch: grabbed.clone(), + onto: target.clone(), + since: Instant::now(), + }; + } else { + self.conflict_check = ConflictCheckState::Idle; + } + } + + pub fn is_log_loading(&self) -> bool { + matches!(self.sidebar, SidebarState::Loading { .. }) + } + + pub fn get_selected_branch_name(&self) -> Option { + self.list_state + .selected() + .and_then(|i| self.flattened_tree.get(i).map(|(n, _)| n.clone())) + } + + pub fn refresh_tree(&mut self, preview_move: Option<(&str, Option<&str>)>) { + let selected_name = self.get_selected_branch_name(); + + let mut topo = match build_topology( + &self.branches, + &self.intents, + &self.history, + self.show_remote, + ) { + Ok(t) => t, + Err(_) => return, + }; + if let Some((grabbed, target)) = preview_move { + let _ = topo.set_parent(grabbed, target, None, Intent::Structural); + } + + self.virtual_layer.apply(&mut topo); + + let current_order: Vec = + self.flattened_tree.iter().map(|(n, _)| n.clone()).collect(); + topo.set_visual_memory(current_order); + self.flattened_tree = topo.flatten(); + + if let Some(name) = selected_name + && let Some(idx) = self.flattened_tree.iter().position(|(n, _)| n == &name) + { + self.list_state.select(Some(idx)); + } + } + + pub fn update_target_parent(&mut self) { + if let Some(grabbed) = self.grabbed_branch.clone() { + if let Some(idx) = self.list_state.selected() { + if idx >= self.flattened_tree.len() { + return; + } + let hovered = &self.flattened_tree[idx].0; + + if hovered == &grabbed { + self.target_parent = self.get_effective_parent(&grabbed); + } else { + let topo = match build_topology( + &self.branches, + &self.intents, + &self.history, + self.show_remote, + ) { + Ok(t) => t, + Err(_) => return, + }; + // Check if target (hovered) is a descendant of grabbed. + let mut is_desc = false; + if let Some(&target_idx) = topo.branches.get(hovered) + && let Some(&grab_idx) = topo.branches.get(&grabbed) + && petgraph::algo::has_path_connecting( + &topo.graph, + target_idx, + grab_idx, + None, + ) + { + is_desc = true; + } + + if !is_desc { + self.target_parent = Some(hovered.clone()); + } else { + self.target_parent = None; + } + } + } + let target = self.target_parent.clone(); + self.refresh_tree(Some((&grabbed, target.as_deref()))); + self.on_move_change(); + } + } + + pub fn clear_pending_operations(&mut self) { + self.intents.clear(); + self.virtual_layer = crate::topology::virtual_layer::VirtualLayer::new(); + self.plan = None; + self.mode = AppMode::Tree; + self.show_preview = false; + } +} diff --git a/tools/gitui/src/state/input.rs b/tools/gitui/src/state/input.rs new file mode 100644 index 0000000..b69ef76 --- /dev/null +++ b/tools/gitui/src/state/input.rs @@ -0,0 +1,800 @@ +use crate::engine::{BranchInfo, BranchIntent, RepositorySnapshot, calculate_plan}; +use crate::split_state::{SplitState, SplitViewMode}; +use crate::state::types::{AppMode, AppState, Effect, PromptAction, PromptFocus, PromptState}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use unicode_segmentation::UnicodeSegmentation; + +impl AppState { + pub fn handle_key(&mut self, key: KeyEvent) -> Vec { + if self.error_message.is_some() { + self.error_message = None; + return Vec::new(); + } + + if self.show_quit_confirmation { + return self.handle_quit_confirmation(key); + } + + match self.mode { + AppMode::Split => { + let mut split_state = self.split_state.take(); + let effects = if let Some(ref mut s) = split_state { + self.handle_key_split_inner(s, key) + } else { + match (key.code, key.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => { + self.is_loading = false; + self.mode = AppMode::Tree; + Vec::new() + } + _ => Vec::new(), + } + }; + self.split_state = split_state; + effects + } + AppMode::Preview => self.handle_key_preview(key), + AppMode::Tree => self.handle_key_tree(key), + AppMode::Prompt => self.handle_key_prompt(key), + } + } + + pub fn handle_key_prompt(&mut self, key: KeyEvent) -> Vec { + let mut prompt = match self.prompt.take() { + Some(p) => p, + None => { + self.mode = AppMode::Tree; + return Vec::new(); + } + }; + + match key.code { + KeyCode::Tab => { + prompt.focus = match prompt.focus { + PromptFocus::Input => PromptFocus::Ok, + PromptFocus::Ok => PromptFocus::Cancel, + PromptFocus::Cancel => PromptFocus::Input, + }; + self.prompt = Some(prompt); + return Vec::new(); + } + KeyCode::BackTab => { + prompt.focus = match prompt.focus { + PromptFocus::Input => PromptFocus::Cancel, + PromptFocus::Ok => PromptFocus::Input, + PromptFocus::Cancel => PromptFocus::Ok, + }; + self.prompt = Some(prompt); + return Vec::new(); + } + KeyCode::Esc => { + self.mode = AppMode::Split; + self.prompt = None; + } + KeyCode::Enter => { + match prompt.focus { + PromptFocus::Input + if prompt.action.is_multiline() + && !key.modifiers.contains(KeyModifiers::CONTROL) => + { + prompt.value.insert(prompt.cursor_position, '\n'); + prompt.cursor_position += 1; + self.prompt = Some(prompt); + return Vec::new(); + } + PromptFocus::Cancel => { + self.mode = AppMode::Split; + self.prompt = None; + return Vec::new(); + } + _ => {} + } + + let value = prompt.value.clone(); + match prompt.action { + PromptAction::SplitPartName => { + let branch_name = self.splitting_branch.as_deref().unwrap_or(""); + let part_num = self + .split_state + .as_ref() + .map(|s| s.parts.len() + 1) + .unwrap_or(1); + let default_msg = format!("Split from {}: part {}", branch_name, part_num); + let cursor_pos = default_msg.len(); + + // Switch to message prompt + self.prompt = Some(PromptState { + title: format!("Commit Message for: {}", value), + value: default_msg, + cursor_position: cursor_pos, + action: PromptAction::SplitPartMessage, + focus: PromptFocus::Input, + }); + self.splitting_branch_temp_name = Some(value); + self.mode = AppMode::Prompt; + } + PromptAction::SplitPartMessage => { + let name = self.splitting_branch_temp_name.take().unwrap_or_default(); + let message = value; + if let Some(ref mut split_state) = self.split_state { + let selected = split_state.current_selection.drain().collect(); + split_state.parts.push(crate::split_state::SplitPart { + name, + commit_message: message, + selected_hunks: selected, + }); + split_state.rebuild_view(); + } + self.mode = AppMode::Split; + self.prompt = None; + } + PromptAction::AmendMessage => { + let message = self.prompt.as_ref().unwrap().value.clone(); + if let Some(selected_name) = self.get_selected_branch_name() { + self.mutate_intent(&selected_name, |i| { + i.pending_amend_message = Some(message); + }); + } + } + PromptAction::RenameBranch => { + let new_name = self.prompt.as_ref().unwrap().value.clone(); + if let Some(selected_name) = self.get_selected_branch_name() { + self.mutate_intent(&selected_name, |i| { + i.pending_rename = Some(new_name); + }); + } + } + } + } + KeyCode::Char(c) if prompt.focus == PromptFocus::Input => { + prompt.value.insert(prompt.cursor_position, c); + prompt.cursor_position += c.len_utf8(); + self.prompt = Some(prompt); + } + KeyCode::Backspace if prompt.focus == PromptFocus::Input => { + if prompt.cursor_position > 0 { + let pre_cursor = &prompt.value[..prompt.cursor_position]; + if let Some(last_grapheme) = pre_cursor.graphemes(true).next_back() { + let len = last_grapheme.len(); + prompt.cursor_position -= len; + prompt + .value + .drain(prompt.cursor_position..prompt.cursor_position + len); + } + } + self.prompt = Some(prompt); + } + KeyCode::Left => { + if prompt.cursor_position > 0 { + let pre_cursor = &prompt.value[..prompt.cursor_position]; + if let Some(last_grapheme) = pre_cursor.graphemes(true).next_back() { + prompt.cursor_position -= last_grapheme.len(); + } + } + self.prompt = Some(prompt); + } + KeyCode::Right => { + if prompt.cursor_position < prompt.value.len() { + let post_cursor = &prompt.value[prompt.cursor_position..]; + if let Some(first_grapheme) = post_cursor.graphemes(true).next() { + prompt.cursor_position += first_grapheme.len(); + } + } + self.prompt = Some(prompt); + } + KeyCode::Up => { + if prompt.action.is_multiline() { + let pre_cursor = &prompt.value[..prompt.cursor_position]; + // Find start of current line (searching backwards for '\n') + let current_line_start = pre_cursor.rfind('\n').map(|i| i + 1).unwrap_or(0); + + // Calculate "visual" column (grapheme count) + let current_line_content = &pre_cursor[current_line_start..]; + let col_graphemes = current_line_content.graphemes(true).count(); + + if current_line_start > 0 { + // There is a previous line + let prev_line_end = current_line_start - 1; + let text_before = &prompt.value[..prev_line_end]; + let prev_line_start = text_before.rfind('\n').map(|i| i + 1).unwrap_or(0); + + let prev_line_content = &prompt.value[prev_line_start..prev_line_end]; + + // Find equivalent grapheme position + let mut target_offset = 0; + for (current_grapheme_count, g) in + prev_line_content.graphemes(true).enumerate() + { + if current_grapheme_count == col_graphemes { + break; + } + target_offset += g.len(); + } + prompt.cursor_position = prev_line_start + target_offset; + } + } + self.prompt = Some(prompt); + } + KeyCode::Down => { + if prompt.action.is_multiline() { + // Check if there is a next line + let post_cursor = &prompt.value[prompt.cursor_position..]; + if let Some(newline_idx_rel) = post_cursor.find('\n') { + let next_line_start = prompt.cursor_position + newline_idx_rel + 1; + + // Calculate current column + let pre_cursor = &prompt.value[..prompt.cursor_position]; + let current_line_start = pre_cursor.rfind('\n').map(|i| i + 1).unwrap_or(0); + let current_line_content = &pre_cursor[current_line_start..]; + let col_graphemes = current_line_content.graphemes(true).count(); + + // Find next line content + let rest = &prompt.value[next_line_start..]; + let next_line_end_rel = rest.find('\n').unwrap_or(rest.len()); + let next_line_content = &rest[..next_line_end_rel]; + + // Find equivalent grapheme position + let mut target_offset = 0; + for (current_grapheme_count, g) in + next_line_content.graphemes(true).enumerate() + { + if current_grapheme_count == col_graphemes { + break; + } + target_offset += g.len(); + } + prompt.cursor_position = next_line_start + target_offset; + } + } + self.prompt = Some(prompt); + } + _ => { + self.prompt = Some(prompt); + } + } + Vec::new() + } + + pub fn handle_key_split_inner( + &mut self, + split_state: &mut SplitState, + key: KeyEvent, + ) -> Vec { + match (key.code, key.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => { + self.mode = AppMode::Tree; + self.splitting_branch = None; + self.split_state = None; + return Vec::new(); + } + (KeyCode::Char('j'), _) | (KeyCode::Down, _) => { + split_state.next(); + } + (KeyCode::Char('k'), _) | (KeyCode::Up, _) => { + split_state.prev(); + } + (KeyCode::PageDown, _) => { + split_state.page_down(15); + } + (KeyCode::PageUp, _) => { + split_state.page_up(15); + } + (KeyCode::Char('l'), _) | (KeyCode::Right, _) => { + if split_state.mode == SplitViewMode::Hunks { + split_state.enter_lines(); + } else { + split_state.enter_hunks(); + } + } + (KeyCode::Char('h'), _) | (KeyCode::Left, _) => { + if split_state.mode == SplitViewMode::Lines { + split_state.exit_lines(); + } else { + split_state.exit_hunks(); + } + } + (KeyCode::Char(' '), _) => { + split_state.toggle_selection(); + } + (KeyCode::Enter, _) => { + if !split_state.current_selection.is_empty() { + // If something is selected, Enter creates a new incremental part. + let branch_name = self.splitting_branch.as_deref().unwrap_or(""); + let part_num = split_state.parts.len() + 1; + self.mode = AppMode::Prompt; + self.prompt = Some(PromptState { + title: format!("Branch Name for Part {}", part_num), + value: format!("{}-{}", branch_name, part_num), + cursor_position: format!("{}-{}", branch_name, part_num).len(), + action: PromptAction::SplitPartName, + focus: PromptFocus::Input, + }); + return Vec::new(); + } + + if !split_state.parts.is_empty() { + let branch_name = match self.splitting_branch.take() { + Some(n) => n, + None => { + self.mode = AppMode::Tree; + return Vec::new(); + } + }; + + let branch_info = match self.branches.iter().find(|b| b.name == branch_name) { + Some(b) => b, + None => { + self.mode = AppMode::Tree; + return Vec::new(); + } + }; + + let mut parent = self + .get_effective_parent(&branch_name) + .unwrap_or_else(|| "master".to_string()); + + // Add to virtual layer + self.virtual_layer.hide_branch(&branch_name); + + let mut parts_data = Vec::new(); + for part in &split_state.parts { + self.virtual_layer.add_virtual_branch( + part.name.clone(), + Some(parent.clone()), + None, + ); + parts_data.push(( + part.name.clone(), + part.selected_hunks.clone(), + part.commit_message.clone(), + )); + parent = part.name.clone(); + } + + self.virtual_layer.add_virtual_branch( + branch_name.clone(), + Some(parent.clone()), + Some(branch_info.oid), + ); + + self.refresh_tree(None); + + let mut split_data = crate::engine::SplitData { parts: Vec::new() }; + for part in &split_state.parts { + split_data.parts.push(crate::engine::SplitPartData { + name: part.name.clone(), + commit_message: part.commit_message.clone(), + selected_hunks: part.selected_hunks.clone(), + }); + } + self.mutate_intent(&branch_name, |i| { + i.pending_split = Some(split_data); + }); + + self.mode = AppMode::Tree; + self.split_state = None; + } + } + _ => {} + } + Vec::new() + } + + pub fn handle_key_preview(&mut self, key: KeyEvent) -> Vec { + match (key.code, key.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('q'), _) | (KeyCode::Char('v'), _) => { + self.show_preview = false; + self.mode = AppMode::Tree; + Vec::new() + } + (KeyCode::Char('c'), _) => vec![Effect::ApplyAndQuit( + self.branches.clone(), + self.intents.clone(), + )], + _ => Vec::new(), + } + } + + pub fn handle_key_tree(&mut self, key: KeyEvent) -> Vec { + match (key.code, key.modifiers) { + (KeyCode::Char('q'), _) => self.handle_quit(), + (KeyCode::Esc, _) => self.handle_escape(), + (KeyCode::Char('v'), _) => self.handle_preview_toggle(), + (KeyCode::Char('a'), _) => self.handle_remote_toggle(), + (KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.handle_navigation(1), + (KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.handle_navigation(-1), + (KeyCode::Char('h'), _) | (KeyCode::Left, _) => self.handle_move_to_root(), + (KeyCode::Char(' '), _) => self.handle_space(), + (KeyCode::Char('p'), _) => { + let is_dirty = self.is_dirty; + self.toggle_pending_action(|b, i| { + if !is_dirty + || i.pending_amend + || i.pending_split.is_some() + || i.parent != Some(b.original_parent.clone()) + { + i.pending_push = !i.pending_push; + } + }); + Vec::new() + } + (KeyCode::Char('s'), _) => { + self.toggle_pending_action(|b, i| { + if b.can_submit() { + i.pending_submit = !i.pending_submit; + } + }); + Vec::new() + } + (KeyCode::Char('x'), _) => self.handle_trigger_split(), + (KeyCode::Char('d'), _) => { + self.toggle_pending_action(|b, i| { + if b.is_local { + i.pending_delete = !i.pending_delete; + } + }); + Vec::new() + } + (KeyCode::Char('r'), _) => { + if !self.show_preview && !self.is_dirty { + self.handle_reset(); + } + Vec::new() + } + (KeyCode::Char('f'), _) => { + self.toggle_pending_action(|b, i| { + if b.is_remote { + i.pending_localize = !i.pending_localize; + } + }); + Vec::new() + } + (KeyCode::Char('m'), _) => { + let initial_branch = self.initial_branch.clone(); + self.toggle_pending_action(|b, i| { + if b.is_local && b.name == initial_branch { + i.pending_amend = !i.pending_amend; + } + }); + Vec::new() + } + (KeyCode::Char('M'), _) => self.handle_trigger_amend_message(), + (KeyCode::Char('R'), _) => self.handle_trigger_rename(), + (KeyCode::Char('c'), _) => vec![Effect::ApplyAndQuit( + self.branches.clone(), + self.intents.clone(), + )], + (KeyCode::Char('u'), _) => self.handle_trigger_converge(), + _ => Vec::new(), + } + } + + fn handle_trigger_split(&mut self) -> Vec { + if let Some(idx) = self.list_state.selected() + && idx < self.flattened_tree.len() + { + let branch_name = self.flattened_tree[idx].0.clone(); + if let Some(branch_info) = self.branches.iter().find(|b| b.name == branch_name) + && branch_info.is_local + && branch_info.parent_ahead == 1 + && let Some(parent) = self.get_effective_parent(&branch_name) + { + let parent = parent.clone(); + self.is_loading = true; + self.progress_message = format!("Fetching diff for {}...", branch_name); + self.progress_percentage = 0.0; + self.mode = AppMode::Split; + self.splitting_branch = Some(branch_name.clone()); + return vec![Effect::FetchDiff { + branch: branch_name, + parent, + }]; + } + } + Vec::new() + } + + fn handle_trigger_amend_message(&mut self) -> Vec { + let initial_branch = self.initial_branch.clone(); + let is_local = self + .branches + .iter() + .find(|b| b.name == initial_branch) + .map(|b| b.is_local) + .unwrap_or(false); + + if is_local { + self.mutate_intent(&initial_branch, |i| { + i.pending_amend = true; + }); + self.mode = AppMode::Prompt; + self.prompt = Some(PromptState { + title: "Amend Commit Message".to_string(), + value: String::new(), + cursor_position: 0, + action: PromptAction::AmendMessage, + focus: PromptFocus::Input, + }); + } + Vec::new() + } + + fn handle_trigger_rename(&mut self) -> Vec { + if let Some(idx) = self.list_state.selected() + && let Some((name, _)) = self.flattened_tree.get(idx) + { + let name = name.clone(); + if let Some(b) = self.branches.iter().find(|b| b.name == name) + && b.is_local + && b.upstream.is_none() + { + self.mode = AppMode::Prompt; + self.prompt = Some(PromptState { + title: format!("Rename branch '{}' to:", name), + value: name.clone(), + cursor_position: name.len(), + action: PromptAction::RenameBranch, + focus: PromptFocus::Input, + }); + self.splitting_branch_temp_name = Some(name); // Reuse temp field + } + } + Vec::new() + } + + fn handle_trigger_converge(&mut self) -> Vec { + if let Some(idx) = self.list_state.selected() + && let Some((name, _)) = self.flattened_tree.get(idx) + { + let name = name.clone(); + let mut heuristic_parent = None; + + if let Some(b) = self.branches.iter().find(|b| b.name == name) + && let Some(hp) = &b.heuristic_parent + { + let mut should_converge = false; + if self.get_effective_parent(&name).as_ref() != Some(hp) { + should_converge = true; + } else { + // Names match, check OIDs + let current_parent_oid = + self.history.oid_to_ancestor.get(&b.oid).and_then(|o| *o); + if let Some(h_oid) = b.heuristic_upstream_oid + && Some(h_oid) != current_parent_oid + { + should_converge = true; + } + } + + if should_converge { + heuristic_parent = Some(hp.clone()); + } + } + + if let Some(hp) = heuristic_parent { + let current_parent = self.get_effective_parent(&name); + if current_parent.as_ref() == Some(&hp) { + // Already converged, toggle back to original + if let Some(b) = self.branches.iter().find(|b| b.name == name) { + let orig = b.original_parent.clone(); + self.try_apply_move(&name, orig); + } + } else { + self.try_apply_move(&name, Some(hp)); + } + self.refresh_tree(None); + self.on_move_change(); + } + } + Vec::new() + } + + pub fn handle_quit_confirmation(&mut self, key: KeyEvent) -> Vec { + match (key.code, key.modifiers) { + (KeyCode::Char('y'), _) | (KeyCode::Char('q'), _) => vec![Effect::Quit], + (KeyCode::Char('n'), _) | (KeyCode::Esc, _) => { + self.show_quit_confirmation = false; + Vec::new() + } + _ => Vec::new(), + } + } + + pub fn handle_quit(&mut self) -> Vec { + if self.show_preview { + self.show_preview = false; + Vec::new() + } else { + let snapshot = RepositorySnapshot { + branches: self.branches.clone(), + history: self.history.clone(), + is_dirty: self.is_dirty, + }; + let plan = calculate_plan(&snapshot, &self.intents).unwrap_or_default(); + if plan.is_empty() { + vec![Effect::Quit] + } else { + self.show_quit_confirmation = true; + Vec::new() + } + } + } + + pub fn handle_escape(&mut self) -> Vec { + if self.grabbed_branch.is_some() { + self.grabbed_branch = None; + self.target_parent = None; + self.refresh_tree(None); + let effects = self.on_selection_change(); + self.on_move_change(); + effects + } else { + Vec::new() + } + } + + pub fn handle_preview_toggle(&mut self) -> Vec { + if !self.show_preview { + let snapshot = RepositorySnapshot { + branches: self.branches.clone(), + history: self.history.clone(), + is_dirty: self.is_dirty, + }; + let plan = match calculate_plan(&snapshot, &self.intents) { + Ok(p) => p, + Err(_) => return Vec::new(), // TODO: handle error in UI + }; + self.show_preview = true; + self.mode = AppMode::Preview; + self.plan = Some(plan.clone()); + if !plan.is_empty() { + self.is_predicting_conflicts = true; + return vec![Effect::PredictConflicts { + plan, + branches: self.branches.clone(), + }]; + } + } + Vec::new() + } + + pub fn handle_remote_toggle(&mut self) -> Vec { + if !self.show_preview && !self.is_loading { + self.show_remote = !self.show_remote; + self.is_loading = true; + return vec![Effect::FetchBranches]; + } + Vec::new() + } + + pub fn handle_move_to_root(&mut self) -> Vec { + if self.grabbed_branch.is_some() { + self.target_parent = None; + if let Some(grabbed) = self.grabbed_branch.clone() { + self.refresh_tree(Some((&grabbed, None))); + self.on_move_change(); + } + } + Vec::new() + } + + pub fn handle_space(&mut self) -> Vec { + if !self.show_preview + && !self.is_dirty + && let Some(idx) = self.list_state.selected() + && idx < self.flattened_tree.len() + { + let branch_name = self.flattened_tree[idx].0.clone(); + if let Some(grabbed) = self.grabbed_branch.take() { + self.finalize_move(grabbed); + } else { + self.grabbed_branch = Some(branch_name); + self.update_target_parent(); + } + } + Vec::new() + } + + pub fn handle_navigation(&mut self, delta: i32) -> Vec { + if self.show_preview || self.flattened_tree.is_empty() { + return Vec::new(); + } + + let current = self.list_state.selected().unwrap_or(0) as i32; + let len = self.flattened_tree.len() as i32; + + let mut next = ((current + delta).rem_euclid(len)) as usize; + + if let Some(grabbed) = &self.grabbed_branch { + // Find range of grabbed branch + its descendants in flattened tree + if let Some(start_idx) = self.flattened_tree.iter().position(|(n, _)| n == grabbed) { + let start_depth = self.flattened_tree[start_idx].1; + let mut end_idx = start_idx + 1; + while end_idx < self.flattened_tree.len() { + if self.flattened_tree[end_idx].1 <= start_depth { + break; + } + end_idx += 1; + } + + // If next lands inside the grabbed subtree, skip it + if next >= start_idx && next < end_idx { + if delta > 0 { + // Moving down: skip to end + next = end_idx % self.flattened_tree.len(); + } else { + // Moving up: skip to start - 1 + if start_idx == 0 { + next = self.flattened_tree.len() - 1; + } else { + next = start_idx - 1; + } + } + } + } + } + + self.list_state.select(Some(next)); + self.update_target_parent(); + self.on_selection_change() + } + + pub fn toggle_pending_action(&mut self, f: F) + where + F: FnOnce(&BranchInfo, &mut BranchIntent), + { + if let Some(idx) = self.list_state.selected() + && idx < self.flattened_tree.len() + { + let branch_name = self.flattened_tree[idx].0.clone(); + let info = self + .branches + .iter() + .find(|b| b.name == branch_name) + .cloned(); + if let Some(info) = info { + self.mutate_intent(&branch_name, |i| { + f(&info, i); + }); + self.on_selection_change(); + } + } + } + + pub fn finalize_move(&mut self, grabbed: String) { + let new_parent = self.target_parent.clone(); + self.try_apply_move(&grabbed, new_parent); + self.target_parent = None; + self.refresh_tree(None); + self.on_selection_change(); + self.on_move_change(); + } + + pub fn handle_reset(&mut self) { + if let Some(idx) = self.list_state.selected() + && idx < self.flattened_tree.len() + { + let branch_name = self.flattened_tree[idx].0.clone(); + let b_info = self + .branches + .iter() + .find(|b| b.name == branch_name) + .cloned(); + if let Some(b) = b_info { + self.mutate_intent(&branch_name, |i| { + i.pending_reset = !i.pending_reset; + if i.pending_reset { + if let Some(u) = b.upstream.clone() { + i.parent = Some(Some(u)); + } + } else { + i.parent = Some(b.original_parent.clone()); + } + }); + self.refresh_tree(None); + self.on_selection_change(); + self.on_move_change(); + } + } + } +} diff --git a/tools/gitui/src/state/mod.rs b/tools/gitui/src/state/mod.rs new file mode 100644 index 0000000..34067b3 --- /dev/null +++ b/tools/gitui/src/state/mod.rs @@ -0,0 +1,147 @@ +pub mod actions; +pub mod input; +pub mod reducer; +pub mod types; + +pub use types::*; + +use crate::engine::Operation; + +impl AppState { + pub fn update(&mut self, msg: Msg) -> Vec { + let mut effects = Vec::new(); + + if !matches!(msg, Msg::Tick) { + self.needs_redraw = true; + } + + match msg { + Msg::KeyPressed(key) => { + effects.extend(self.handle_key(key)); + } + Msg::Tick => { + effects.extend(self.handle_tick()); + } + Msg::BranchesLoaded(res) => match res { + Ok((branches, history, is_dirty)) => { + self.branches = branches; + self.history = history; + self.is_dirty = is_dirty; + self.is_loading = false; + self.refresh_tree(None); + + let initial_branch = self.initial_branch.clone(); + if !initial_branch.is_empty() + && let Some(idx) = self + .flattened_tree + .iter() + .position(|(n, _)| n == &initial_branch) + { + self.list_state.select(Some(idx)); + } + + if self.list_state.selected().is_none() && !self.flattened_tree.is_empty() { + self.list_state.select(Some(0)); + } + + effects.extend(self.on_selection_change()); + } + Err(e) => { + self.is_loading = false; + self.error_message = Some(format!("Error loading branches: {}", e)); + } + }, + Msg::CurrentBranchLoaded(res) => match res { + Ok(name) => { + self.initial_branch = name.clone(); + if let Some(idx) = self.flattened_tree.iter().position(|(n, _)| n == &name) { + self.list_state.select(Some(idx)); + effects.extend(self.on_selection_change()); + } + } + Err(e) => { + self.error_message = Some(format!("Error getting current branch: {}", e)); + } + }, + Msg::ConflictChecked(branch, onto, res) => { + if let ConflictCheckState::Checking { + branch: ref b, + onto: ref o, + } = self.conflict_check + && b == &branch + && o == &onto + { + self.conflict_check = ConflictCheckState::Idle; + match res { + Ok(has_conflict) => { + self.conflict_cache + .insert((branch.clone(), onto), has_conflict); + if self.grabbed_branch.as_ref() == Some(&branch) { + self.mutate_intent(&branch, |i| { + i.has_conflict = has_conflict; + }); + } + } + Err(e) => { + self.error_message = Some(format!("Error checking conflict: {}", e)); + } + } + } + } + Msg::CommitLogLoaded(branch, res) => { + if let SidebarState::Loading { + branch: ref loading_branch, + } = self.sidebar + && loading_branch == &branch + { + match res { + Ok(commits) => { + self.sidebar = SidebarState::Ready { branch, commits }; + } + Err(_) => { + self.sidebar = SidebarState::Idle; + // We don't show an error for commit log failure to avoid being too noisy + } + } + } + } + Msg::ProgressUpdated { + message, + percentage, + } => { + self.is_loading = true; + self.progress_message = message; + self.progress_percentage = percentage; + } + Msg::ConflictsPredicted(plan) => { + self.plan = Some(plan); + self.is_predicting_conflicts = false; + } + Msg::PredictionProgress { index, result } => { + self.prediction_index = index; + if let Some(plan) = &mut self.plan + && index < plan.len() + && let Operation::Rebase { + predicted_conflict, .. + } = &mut plan[index] + { + *predicted_conflict = result; + } + } + Msg::DiffLoaded(_branch, res) => { + self.is_loading = false; + match res { + Ok(files) => { + self.split_state = Some(crate::split_state::SplitState::new(files)); + } + Err(e) => { + self.error_message = Some(format!("Error loading diff: {}", e)); + self.mode = AppMode::Tree; + } + } + } + } + + effects + } +} diff --git a/tools/gitui/src/state/reducer.rs b/tools/gitui/src/state/reducer.rs new file mode 100644 index 0000000..c6cd3e6 --- /dev/null +++ b/tools/gitui/src/state/reducer.rs @@ -0,0 +1,6 @@ +use crate::state::{AppState, Effect, Msg}; + +pub fn reduce(mut state: AppState, msg: Msg) -> (AppState, Vec) { + let effects = state.update(msg); + (state, effects) +} diff --git a/tools/gitui/src/state/types.rs b/tools/gitui/src/state/types.rs new file mode 100644 index 0000000..1d61995 --- /dev/null +++ b/tools/gitui/src/state/types.rs @@ -0,0 +1,188 @@ +use crate::diff_utils::FileDiff; +use crate::engine::transaction::Transaction; +use crate::engine::{BranchInfo, BranchIntent, CommitInfo, HistoryContext, Operation}; +use crate::split_state::SplitState; +use crate::topology::virtual_layer::VirtualLayer; +use crossterm::event::KeyEvent; +use ratatui::widgets::ListState; +use std::collections::HashMap; +use std::time::Instant; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SidebarState { + Idle, + Debouncing { + branch: String, + since: Instant, + }, + Loading { + branch: String, + }, + Ready { + branch: String, + commits: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConflictCheckState { + Idle, + Debouncing { + branch: String, + onto: String, + since: Instant, + }, + Checking { + branch: String, + onto: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppMode { + Tree, + Split, + Preview, + Prompt, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptFocus { + Input, + Ok, + Cancel, +} + +pub struct PromptState { + pub title: String, + pub value: String, + pub cursor_position: usize, + pub action: PromptAction, + pub focus: PromptFocus, +} + +pub enum PromptAction { + SplitPartName, + SplitPartMessage, + AmendMessage, + RenameBranch, +} + +impl PromptAction { + pub fn is_multiline(&self) -> bool { + matches!( + self, + PromptAction::SplitPartMessage | PromptAction::AmendMessage + ) + } +} + +pub struct AppState { + pub branches: Vec, + pub history: HistoryContext, + pub list_state: ListState, + pub flattened_tree: Vec<(String, usize)>, + pub show_preview: bool, + pub grabbed_branch: Option, + pub target_parent: Option, + pub is_loading: bool, + pub spinner_index: usize, + pub progress_message: String, + pub progress_percentage: f64, + pub show_quit_confirmation: bool, + pub conflict_cache: HashMap<(String, String), bool>, + pub is_dirty: bool, + pub show_remote: bool, + pub initial_branch: String, + pub sidebar: SidebarState, + pub conflict_check: ConflictCheckState, + pub needs_redraw: bool, + pub plan: Option>, + pub is_predicting_conflicts: bool, + pub prediction_index: usize, + pub split_state: Option, + pub splitting_branch: Option, + pub virtual_layer: VirtualLayer, + pub mode: AppMode, + pub transaction: Option, + pub prompt: Option, + pub splitting_branch_temp_name: Option, + pub intents: HashMap, + pub error_message: Option, +} + +impl Default for AppState { + fn default() -> Self { + let mut list_state = ListState::default(); + list_state.select(Some(0)); + Self { + branches: Vec::new(), + history: HistoryContext::new(), + list_state, + flattened_tree: Vec::new(), + show_preview: false, + grabbed_branch: None, + target_parent: None, + is_loading: false, + spinner_index: 0, + progress_message: String::new(), + progress_percentage: 0.0, + show_quit_confirmation: false, + conflict_cache: HashMap::new(), + is_dirty: false, + show_remote: false, + initial_branch: String::new(), + sidebar: SidebarState::Idle, + conflict_check: ConflictCheckState::Idle, + needs_redraw: true, + plan: None, + is_predicting_conflicts: false, + prediction_index: 0, + split_state: None, + splitting_branch: None, + virtual_layer: VirtualLayer::new(), + mode: AppMode::Tree, + transaction: None, + prompt: None, + splitting_branch_temp_name: None, + intents: HashMap::new(), + error_message: None, + } + } +} + +pub enum Msg { + KeyPressed(KeyEvent), + Tick, + BranchesLoaded(anyhow::Result<(Vec, HistoryContext, bool)>), + CurrentBranchLoaded(anyhow::Result), + ConflictChecked(String, String, anyhow::Result), + CommitLogLoaded(String, anyhow::Result>), + ProgressUpdated { message: String, percentage: f64 }, + ConflictsPredicted(Vec), + PredictionProgress { index: usize, result: Option }, + DiffLoaded(String, anyhow::Result>), +} + +#[derive(Debug, Clone)] +pub enum Effect { + FetchBranches, + FetchCurrentBranch, + CheckConflict { + branch: String, + onto: String, + }, + FetchCommitLog { + branch: String, + }, + PredictConflicts { + plan: Vec, + branches: Vec, + }, + FetchDiff { + branch: String, + parent: String, + }, + Quit, + ApplyAndQuit(Vec, HashMap), +} diff --git a/tools/gitui/src/testing.rs b/tools/gitui/src/testing.rs new file mode 100644 index 0000000..cfbc793 --- /dev/null +++ b/tools/gitui/src/testing.rs @@ -0,0 +1,218 @@ +use git2::{Repository, Signature, Time}; +use std::fs::File; +use std::io::Write; +use std::process::Command; +use std::sync::{Arc, Mutex}; +use tempfile::TempDir; + +use crate::diff_utils::FileDiff; +use crate::engine::{BranchInfo, CommitInfo, Git, HistoryContext, SplitData}; + +pub fn setup_repo() -> (TempDir, Repository, String) { + let dir = tempfile::tempdir().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + { + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + } + + let branch_name = { + // Use a fixed signature for deterministic OIDs in tests + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686000, 0)).unwrap(); + let mut index = repo.index().unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[]) + .unwrap(); + + let head = repo.head().unwrap(); + head.shorthand().unwrap().to_string() + }; + + (dir, repo, branch_name) +} + +pub fn create_commit(repo: &Repository, filename: &str, content: &str, msg: &str) -> git2::Oid { + let path = repo.workdir().unwrap().join(filename); + File::create(path) + .unwrap() + .write_all(content.as_bytes()) + .unwrap(); + + Command::new("git") + .arg("-C") + .arg(repo.workdir().unwrap()) + .args(["add", filename]) + .status() + .unwrap(); + + let mut index = repo.index().unwrap(); + index.read(false).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686000, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + let oid = repo + .commit(Some("HEAD"), &sig, &sig, msg, &tree, &[&parent]) + .unwrap(); + + Command::new("git") + .arg("-C") + .arg(repo.workdir().unwrap()) + .args(["reset", "--hard", "HEAD"]) + .status() + .unwrap(); + oid +} + +pub fn run_git>(path: P, args: &[&str]) -> String { + run_git_with_env(path, args, vec![]) +} + +pub fn run_git_with_env>( + path: P, + args: &[&str], + env: Vec<(&str, &str)>, +) -> String { + let mut cmd = Command::new("git"); + cmd.arg("-C") + .arg(path.as_ref()) + .args(args) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE"); + + for (key, value) in env { + cmd.env(key, value); + } + + let output = cmd.output().expect("Failed to execute git command"); + + if !output.status.success() { + panic!( + "git command failed: git -C {} {:?}\nStdout: {}\nStderr: {}", + path.as_ref().display(), + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + String::from_utf8(output.stdout).unwrap().trim().to_string() +} + +pub fn mock_oid(b: u8) -> git2::Oid { + let mut bytes = [0u8; 20]; + bytes[0] = b; + git2::Oid::from_bytes(&bytes).unwrap() +} + +pub struct MockGit { + pub branches: Arc>>, + pub current_branch: String, + pub rebase_calls: Arc>>, + pub check_conflict_between_calls: Arc>>, +} + +impl MockGit { + pub fn new(branches: Vec) -> Self { + Self { + branches: Arc::new(Mutex::new(branches)), + current_branch: "master".to_string(), + rebase_calls: Arc::new(Mutex::new(Vec::new())), + check_conflict_between_calls: Arc::new(Mutex::new(Vec::new())), + } + } +} + +impl Git for MockGit { + fn find_parent_for_oid( + &self, + _oid: git2::Oid, + _exclude_name: &str, + _branches: &std::collections::HashMap, + ) -> Option { + None + } + + fn get_branches( + &self, + _progress: Option<&dyn Fn(String, f64)>, + ) -> anyhow::Result<(Vec, HistoryContext)> { + Ok((self.branches.lock().unwrap().clone(), HistoryContext::new())) + } + + fn get_current_branch(&self) -> anyhow::Result { + Ok(self.current_branch.clone()) + } + + fn check_conflict(&self, _branch_name: &str, _new_parent_name: &str) -> anyhow::Result { + Ok(false) + } + + fn check_conflict_between(&self, oid_a: git2::Oid, oid_b: git2::Oid) -> anyhow::Result { + self.check_conflict_between_calls + .lock() + .unwrap() + .push((oid_a, oid_b)); + Ok(false) + } + + fn is_descendant(&self, _ancestor: git2::Oid, _descendant: git2::Oid) -> anyhow::Result { + Ok(true) + } + + fn rebase(&self, branch: &str, onto: &str) -> anyhow::Result { + self.rebase_calls + .lock() + .unwrap() + .push((branch.to_string(), onto.to_string())); + Ok(true) + } + + fn checkout(&self, _branch: &str) -> anyhow::Result<()> { + Ok(()) + } + + fn push(&self, _branch: &str) -> anyhow::Result { + Ok(true) + } + + fn delete_branch(&self, _branch: &str) -> anyhow::Result { + Ok(true) + } + + fn run_command(&self, args: &[String]) -> anyhow::Result { + if args[0] == "rebase" { + self.rebase_calls + .lock() + .unwrap() + .push((args[2].clone(), args[1].clone())); + } + Ok(true) + } + + fn is_dirty(&self) -> anyhow::Result { + Ok(false) + } + + fn reset_to_upstream(&self, _branch: &str) -> anyhow::Result { + Ok(true) + } + + fn get_commit_log(&self, _branch: &str) -> anyhow::Result> { + Ok(vec![CommitInfo { + id: "abc1234".to_string(), + summary: "Mock commit".to_string(), + author: "Author".to_string(), + }]) + } + + fn get_diff(&self, _branch: &str, _parent: &str) -> anyhow::Result> { + Ok(Vec::new()) + } + + fn split_branch(&self, _branch: &str, _parent: &str, _data: &SplitData) -> anyhow::Result<()> { + Ok(()) + } +} diff --git a/tools/gitui/src/topology/mod.rs b/tools/gitui/src/topology/mod.rs new file mode 100644 index 0000000..1e35368 --- /dev/null +++ b/tools/gitui/src/topology/mod.rs @@ -0,0 +1,370 @@ +use git2::Oid; +use petgraph::Direction; +use petgraph::graph::NodeIndex; +use petgraph::stable_graph::StableGraph; +use petgraph::visit::EdgeRef; +use std::collections::{HashMap, HashSet}; + +pub mod virtual_layer; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TopologyNode { + /// A branch pointer. + Branch { name: String, oid: Oid }, + /// A raw commit in history that is NOT a branch head we are tracking. + Commit { oid: Oid }, +} + +impl TopologyNode { + pub fn oid(&self) -> Oid { + match self { + TopologyNode::Branch { oid, .. } => *oid, + TopologyNode::Commit { oid } => *oid, + } + } + + pub fn name(&self) -> Option<&str> { + match self { + TopologyNode::Branch { name, .. } => Some(name), + TopologyNode::Commit { .. } => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Intent { + /// User explicitly moved this branch to a new stack. + Structural, + /// User is syncing/resetting the branch, but it should stay in its current visual stack. + Synchronizing, + /// A child following its parent. + Implicit, + /// Parent inferred from heuristics (e.g. commit message). + Heuristic, +} + +#[derive(Debug, Clone, Default)] +pub struct HistoryContext { + /// Maps an OID to its nearest visible ancestor OID. + pub oid_to_ancestor: HashMap>, + /// Maps an OID to the "best" visible branch name at that commit. + pub oid_to_visible_branch: HashMap, +} + +impl HistoryContext { + pub fn new() -> Self { + Self::default() + } + + /// Resolves a parent for a given OID by walking up the compressed history + /// until it finds a visible branch name that is NOT the excluded name. + pub fn resolve_visible_parent(&self, oid: Oid, exclude_name: Option<&str>) -> Option { + let mut current_oid = oid; + let mut visited = HashSet::new(); + while visited.insert(current_oid) { + if let Some(name) = self.oid_to_visible_branch.get(¤t_oid) + && Some(name.as_str()) != exclude_name + { + return Some(name.clone()); + } + match self.oid_to_ancestor.get(¤t_oid) { + Some(Some(parent_oid)) => current_oid = *parent_oid, + _ => break, + } + } + None + } +} + +pub struct VirtualTopology { + pub graph: StableGraph, + /// Maps branch names to their unique node in the graph. + pub branches: HashMap, + /// Maps OIDs to their "canonical" commit node (if it's not a branch). + commits: HashMap, + /// Visual memory: stores the last seen order of branches for stable rendering. + visual_memory: Vec, +} + +impl VirtualTopology { + pub fn new() -> Self { + Self { + graph: StableGraph::new(), + branches: HashMap::new(), + commits: HashMap::new(), + visual_memory: Vec::new(), + } + } + + pub fn set_visual_memory(&mut self, order: Vec) { + self.visual_memory = order; + } + + pub fn add_branch(&mut self, name: &str, oid: Oid) -> NodeIndex { + if let Some(&idx) = self.branches.get(name) { + if let TopologyNode::Branch { oid: old_oid, .. } = &mut self.graph[idx] { + *old_oid = oid; + } + return idx; + } + let idx = self.graph.add_node(TopologyNode::Branch { + name: name.to_string(), + oid, + }); + self.branches.insert(name.to_string(), idx); + idx + } + + pub fn add_commit(&mut self, oid: Oid) -> NodeIndex { + if let Some(&idx) = self.commits.get(&oid) { + return idx; + } + let idx = self.graph.add_node(TopologyNode::Commit { oid }); + self.commits.insert(oid, idx); + idx + } + + pub fn add_edge(&mut self, child_oid: Oid, parent_oid: Oid) { + let child_idx = self.add_commit(child_oid); + let parent_idx = self.add_commit(parent_oid); + self.graph + .update_edge(child_idx, parent_idx, Intent::Implicit); + } + + pub fn add_commit_parent(&mut self, oid: Oid, parent_name: &str) -> anyhow::Result<()> { + let child_idx = self.add_commit(oid); + let parent_idx = self + .branches + .get(parent_name) + .copied() + .ok_or_else(|| anyhow::anyhow!("Branch {} not found", parent_name))?; + + if petgraph::algo::has_path_connecting(&self.graph, parent_idx, child_idx, None) { + anyhow::bail!("Cycle detected"); + } + + self.graph + .update_edge(child_idx, parent_idx, Intent::Implicit); + Ok(()) + } + + pub fn set_parent( + &mut self, + branch_name: &str, + parent_name: Option<&str>, + parent_oid: Option, + intent: Intent, + ) -> anyhow::Result<()> { + let branch_idx = self + .branches + .get(branch_name) + .copied() + .ok_or_else(|| anyhow::anyhow!("Branch {} not found", branch_name))?; + + let parent_idx = if let Some(p_name) = parent_name { + self.branches + .get(p_name) + .copied() + .ok_or_else(|| anyhow::anyhow!("Parent branch {} not found", p_name))? + } else if let Some(p_oid) = parent_oid { + self.add_commit(p_oid) + } else { + let edges: Vec<_> = self + .graph + .edges_directed(branch_idx, Direction::Outgoing) + .map(|e| e.id()) + .collect(); + for e in edges { + self.graph.remove_edge(e); + } + return Ok(()); + }; + + if branch_idx == parent_idx { + return Ok(()); + } + + if petgraph::algo::has_path_connecting(&self.graph, parent_idx, branch_idx, None) { + anyhow::bail!( + "Cannot set parent of {} to {} as it would create a cycle", + branch_name, + parent_name.unwrap_or("commit") + ); + } + + let edges: Vec<_> = self + .graph + .edges_directed(branch_idx, Direction::Outgoing) + .map(|e| e.id()) + .collect(); + for e in edges { + self.graph.remove_edge(e); + } + + self.graph.add_edge(branch_idx, parent_idx, intent); + Ok(()) + } + + pub fn get_parents(&self, branch_name: &str) -> Vec { + if let Some(&idx) = self.branches.get(branch_name) { + self.graph + .neighbors_directed(idx, Direction::Outgoing) + .map(|idx| self.graph[idx].clone()) + .collect() + } else { + Vec::new() + } + } + + pub fn get_children(&self, branch_name: &str) -> Vec { + if let Some(&idx) = self.branches.get(branch_name) { + let mut children: Vec<_> = self + .graph + .neighbors_directed(idx, Direction::Incoming) + .filter_map(|idx| { + if let TopologyNode::Branch { name, .. } = &self.graph[idx] { + Some(name.clone()) + } else { + None + } + }) + .collect(); + children.sort(); + children + } else { + Vec::new() + } + } + + pub fn get_branch_oid(&self, name: &str) -> Option { + self.branches.get(name).map(|&idx| self.graph[idx].oid()) + } + + pub fn remove_branch(&mut self, name: &str) { + if let Some(idx) = self.branches.remove(name) { + self.graph.remove_node(idx); + } + } + + pub fn list_roots(&self) -> Vec { + let mut roots = Vec::new(); + for &idx in self.branches.values() { + if self + .graph + .neighbors_directed(idx, Direction::Outgoing) + .next() + .is_none() + && let TopologyNode::Branch { name, .. } = &self.graph[idx] + { + roots.push(name.clone()); + } + } + roots.sort(); + roots + } + + pub fn list_all_branches(&self) -> Vec { + let mut names: Vec<_> = self.branches.keys().cloned().collect(); + names.sort(); + names + } + + pub fn flatten(&self) -> Vec<(String, usize)> { + let mut result = Vec::new(); + let mut visited = HashSet::new(); + + // 1. Identify "Effective Roots" for the TUI. + // If a branch has a parent via Synchronizing intent, we might want to treat it as a child + // of its original visual parent even if the graph says it's now parented to an alias. + // But the VirtualTopology already handles alias resolution in build_topology. + + // We use the graph as source of truth for relationships, but use visual_memory for tie-breaking. + + let mut roots: Vec<_> = self + .graph + .node_indices() + .filter(|&idx| { + self.graph + .neighbors_directed(idx, Direction::Outgoing) + .next() + .is_none() + }) + .collect(); + + // Sort roots using visual_memory then name/oid + roots.sort_by(|&a, &b| self.compare_nodes(a, b)); + + for root_idx in roots { + self.flatten_recursive(root_idx, 0, &mut result, &mut visited); + } + + result + } + + fn flatten_recursive( + &self, + node_idx: NodeIndex, + depth: usize, + result: &mut Vec<(String, usize)>, + visited: &mut HashSet, + ) { + if visited.contains(&node_idx) { + return; + } + visited.insert(node_idx); + + let mut next_depth = depth; + if let TopologyNode::Branch { name, .. } = &self.graph[node_idx] { + result.push((name.clone(), depth)); + next_depth = depth + 1; + } + + let mut children: Vec<_> = self + .graph + .neighbors_directed(node_idx, Direction::Incoming) + .collect(); + children.sort_by(|&a, &b| self.compare_nodes(a, b)); + + for child_idx in children { + self.flatten_recursive(child_idx, next_depth, result, visited); + } + } + + fn compare_nodes(&self, a: NodeIndex, b: NodeIndex) -> std::cmp::Ordering { + let node_a = &self.graph[a]; + let node_b = &self.graph[b]; + + let name_a = node_a.name(); + let name_b = node_b.name(); + + match (name_a, name_b) { + (Some(na), Some(nb)) => { + // Primary: alphabetical sorting + let res = na.cmp(nb); + if res != std::cmp::Ordering::Equal { + return res; + } + + // Tie-breaker: Use visual memory if available + let pos_a = self.visual_memory.iter().position(|n| n == na); + let pos_b = self.visual_memory.iter().position(|n| n == nb); + + match (pos_a, pos_b) { + (Some(pa), Some(pb)) => pa.cmp(&pb), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + } + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => node_a.oid().to_string().cmp(&node_b.oid().to_string()), + } + } +} + +impl Default for VirtualTopology { + fn default() -> Self { + Self::new() + } +} diff --git a/tools/gitui/src/topology/virtual_layer.rs b/tools/gitui/src/topology/virtual_layer.rs new file mode 100644 index 0000000..1f1a600 --- /dev/null +++ b/tools/gitui/src/topology/virtual_layer.rs @@ -0,0 +1,62 @@ +use crate::topology::{Intent, VirtualTopology}; +use git2::Oid; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct VirtualBranch { + pub name: String, + pub oid: Option, + pub parent: Option, +} + +#[derive(Default)] +pub struct VirtualLayer { + /// Branches that are entirely virtual (don't exist in git). + pub virtual_branches: HashMap, + /// Real branches that should be hidden from the UI (e.g. because they are being replaced). + pub hidden_branches: Vec, +} + +impl VirtualLayer { + pub fn new() -> Self { + Self::default() + } + + pub fn add_virtual_branch(&mut self, name: String, parent: Option, oid: Option) { + self.virtual_branches + .insert(name.clone(), VirtualBranch { name, oid, parent }); + } + + pub fn hide_branch(&mut self, name: &str) { + if !self.hidden_branches.contains(&name.to_string()) { + self.hidden_branches.push(name.to_string()); + } + } + + pub fn is_hidden(&self, name: &str) -> bool { + self.hidden_branches.contains(&name.to_string()) + } + + pub fn is_virtual(&self, name: &str) -> bool { + self.virtual_branches.contains_key(name) + } + + pub fn apply(&self, topo: &mut VirtualTopology) { + for name in &self.hidden_branches { + if !self.virtual_branches.contains_key(name) { + topo.remove_branch(name); + } + } + // First pass: add all branches + for (name, vb) in &self.virtual_branches { + let oid = vb.oid.unwrap_or(Oid::from_bytes(&[0; 20]).unwrap()); + topo.add_branch(name, oid); + } + // Second pass: set parents + for (name, vb) in &self.virtual_branches { + if let Some(parent) = &vb.parent { + let _ = topo.set_parent(name, Some(parent), None, Intent::Implicit); + } + } + } +} diff --git a/tools/gitui/src/ui/common.rs b/tools/gitui/src/ui/common.rs new file mode 100644 index 0000000..dced0b5 --- /dev/null +++ b/tools/gitui/src/ui/common.rs @@ -0,0 +1,64 @@ +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{ + Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, +}; + +pub fn draw_error(f: &mut Frame, error: &str) { + let area = centered_rect(60, 10, f.area()); + let block = Block::default() + .title(" Error ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)); + + let paragraph = Paragraph::new(format!("\n{}", error)) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: true }) + .alignment(Alignment::Center); + + f.render_widget(Clear, area); + f.render_widget(paragraph, area); +} + +pub fn render_scrollbar(f: &mut Frame, area: Rect, content_length: usize, offset: usize) { + let viewport_height = area.height.saturating_sub(2) as usize; + if content_length > viewport_height { + let scrollbar = Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + let mut scrollbar_state = + ScrollbarState::new(content_length.saturating_sub(viewport_height)) + .viewport_content_length(viewport_height) + .position(offset); + f.render_stateful_widget( + scrollbar, + area.inner(ratatui::layout::Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + } +} + +pub fn centered_rect(percent_x: u16, height: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length((r.height.saturating_sub(height)) / 2), + Constraint::Length(height), + Constraint::Min(0), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} diff --git a/tools/gitui/src/ui/main_view.rs b/tools/gitui/src/ui/main_view.rs new file mode 100644 index 0000000..de665a7 --- /dev/null +++ b/tools/gitui/src/ui/main_view.rs @@ -0,0 +1,405 @@ +use crate::engine::BranchInfo; +use crate::state::{AppState, SidebarState}; +use crate::ui::SPINNERS; +use crate::ui::common::{centered_rect, draw_error, render_scrollbar}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Gauge, List, ListItem, Paragraph}; + +pub fn draw_main(f: &mut Frame, state: &mut AppState) { + let branch_map: std::collections::HashMap = + state.branches.iter().map(|b| (b.name.clone(), b)).collect(); + + let mut constraints = vec![Constraint::Min(0), Constraint::Length(3)]; + if state.is_loading { + constraints.insert(1, Constraint::Length(3)); + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(f.area()); + + let spinner = if state.is_loading { + Some(SPINNERS[state.spinner_index]) + } else { + None + }; + + let mut title = if let Some(s) = spinner { + format!("Branches {}", s) + } else { + "Branches".to_string() + }; + + if state.is_dirty { + title = format!("{} [DIRTY - Read Only Mode]", title); + } + + let max_name_len = state + .flattened_tree + .iter() + .map(|(name, depth)| { + let mut base_name_len = name.len(); + let intent = state.get_intent(name); + if intent.pending_localize + && let Some(slash_idx) = name.find('/') + { + base_name_len = name[slash_idx + 1..].len(); + } + base_name_len + (*depth * 2) + }) + .max() + .unwrap_or(0); + + let items: Vec = state + .flattened_tree + .iter() + .map(|(name, depth)| { + let default_info = BranchInfo { + name: name.clone(), + ..Default::default() + }; + let branch_info = branch_map.get(name).copied().unwrap_or(&default_info); + render_branch_row(name, *depth, branch_info, state, max_name_len) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(title)) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">>"); + + let main_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(chunks[0]); + + let content_length = state.flattened_tree.len(); + + f.render_stateful_widget(list, main_layout[0], &mut state.list_state); + + render_scrollbar(f, main_layout[0], content_length, state.list_state.offset()); + + let log_items: Vec = match &state.sidebar { + SidebarState::Ready { commits, .. } => { + if commits.is_empty() { + vec![ + ListItem::new("(No unique commits)") + .style(Style::default().fg(Color::DarkGray)), + ] + } else { + commits + .iter() + .map(|commit| { + let header = Line::from(vec![ + Span::styled(&commit.id, Style::default().fg(Color::Yellow)), + Span::raw(" "), + Span::styled(&commit.author, Style::default().fg(Color::Cyan)), + ]); + let summary = Line::from(vec![Span::raw(" "), Span::raw(&commit.summary)]); + ListItem::new(vec![header, summary, Line::from("")]) + }) + .collect() + } + } + _ => Vec::new(), + }; + + let mut log_title = "Recent Commits".to_string(); + if state.is_log_loading() { + log_title = format!("Recent Commits {}", SPINNERS[state.spinner_index]); + } + + let log_list = + List::new(log_items).block(Block::default().borders(Borders::ALL).title(log_title)); + f.render_widget(log_list, main_layout[1]); + + let help_chunk = if state.is_loading { + let gauge = Gauge::default() + .block( + Block::default() + .borders(Borders::ALL) + .title(state.progress_message.clone()), + ) + .gauge_style(Style::default().fg(Color::Yellow)) + .percent((state.progress_percentage * 100.0) as u16); + f.render_widget(gauge, chunks[1]); + chunks[2] + } else { + chunks[1] + }; + + let help_parts = generate_help_parts(state, &branch_map); + let help = Paragraph::new(help_parts.join(" | ")) + .block(Block::default().borders(Borders::ALL).title("Help")); + f.render_widget(help, help_chunk); + + if state.show_quit_confirmation { + let area = centered_rect(40, 7, f.area()); + let popup_block = Block::default() + .title("Confirm Quit") + .borders(Borders::ALL) + .style(Style::default().fg(Color::Yellow)); + + let message = + Paragraph::new("\nYou have pending operations!\n\nDiscard changes and quit? (y/n)") + .block(popup_block) + .style(Style::default().add_modifier(Modifier::BOLD)) + .alignment(Alignment::Center); + + f.render_widget(Clear, area); + f.render_widget(message, area); + } + + if let Some(error) = &state.error_message { + draw_error(f, error); + } +} + +pub fn render_branch_row( + name: &str, + depth: usize, + branch_info: &BranchInfo, + state: &AppState, + max_name_len: usize, +) -> ListItem<'static> { + let intent = state.get_intent(name); + let indent = " ".repeat(depth); + let mut style = Style::default(); + + let is_grabbed = state.grabbed_branch.as_deref() == Some(name); + let is_target_parent = state.target_parent.as_deref() == Some(name); + + let mut base_name = name.to_string(); + if intent.pending_localize + && let Some(slash_idx) = name.find('/') + { + base_name = name[slash_idx + 1..].to_string(); + } + + let display_name = format!("{}{}", indent, base_name); + let padding = " ".repeat(max_name_len.saturating_sub(display_name.len()) + 2); + + let mut ahead = branch_info.ahead; + let mut behind = branch_info.behind; + + if intent.pending_reset { + ahead = 0; + behind = 0; + } else if intent.pending_push { + ahead = 0; + } + + if branch_info.is_remote { + style = style.fg(Color::DarkGray); + } + + if ahead > 0 || behind > 0 { + style = style.fg(Color::Yellow); + } + + if is_grabbed { + style = style.fg(Color::Magenta).add_modifier(Modifier::BOLD); + } + + if is_target_parent { + style = style.add_modifier(Modifier::UNDERLINED); + } + + let effective_parent = intent + .parent + .flatten() + .or_else(|| branch_info.original_parent.clone()); + let is_reparented = effective_parent != branch_info.original_parent + || (is_grabbed && state.target_parent != branch_info.original_parent); + + if is_reparented { + style = style.fg(Color::Cyan); + } + + let mut spans = vec![Span::styled(display_name, style)]; + + if !branch_info.aliases.is_empty() { + spans.push(Span::raw(padding)); + spans.push(Span::styled( + branch_info.aliases.join(", "), + Style::default().fg(Color::DarkGray), + )); + } + + if ahead > 0 || behind > 0 { + spans.push(Span::styled( + format!(" ({}↑ {}↓)", ahead, behind), + Style::default().fg(Color::Yellow), + )); + } + + if intent.pending_push { + spans.push(Span::styled( + " [PUSH]", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::ITALIC), + )); + } + + if intent.pending_submit { + spans.push(Span::styled( + " [SUBMIT]", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )); + } else if branch_info.can_submit() { + spans.push(Span::styled( + " [READY]", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )); + } + + if intent.pending_reset { + if branch_info.upstream.is_some() { + spans.push(Span::styled(" [REBASE]", Style::default().fg(Color::Cyan))); + } else { + spans.push(Span::styled( + " [RESET]", + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::CROSSED_OUT), + )); + } + } else if effective_parent != branch_info.original_parent { + spans.push(Span::styled(" [REBASE]", Style::default().fg(Color::Cyan))); + } + + if intent.pending_delete { + spans.push(Span::styled( + " [DELETE]", + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::CROSSED_OUT), + )); + } else if branch_info.is_merged { + spans.push(Span::styled(" [MERGED]", Style::default().fg(Color::Green))); + } + + if intent.pending_localize { + spans.push(Span::styled( + " [LOCALIZE]", + Style::default().fg(Color::Cyan), + )); + } + + if intent.pending_amend { + if intent.pending_amend_message.is_some() { + spans.push(Span::styled( + " [AMEND MSG]", + Style::default().fg(Color::Cyan), + )); + } else { + spans.push(Span::styled(" [AMEND]", Style::default().fg(Color::Cyan))); + } + } + + if let Some(new_name) = &intent.pending_rename { + spans.push(Span::styled( + format!(" [RENAME -> {}]", new_name), + Style::default().fg(Color::Cyan), + )); + } + + if is_reparented && intent.has_conflict { + spans.push(Span::styled( + " (CONFLICT)", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )); + } + + if let Some(h_parent) = &branch_info.heuristic_parent + && effective_parent.as_ref() != Some(h_parent) + { + spans.push(Span::styled( + " [DIVERGED]", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )); + } + + ListItem::new(Line::from(spans)) +} + +fn generate_help_parts( + state: &AppState, + branch_map: &std::collections::HashMap, +) -> Vec<&'static str> { + let mut help_parts = vec!["q: quit", "j/k: navigate", "v: preview", "c: commit"]; + if state.grabbed_branch.is_some() { + help_parts = vec![ + "esc: cancel", + "space: release", + "j/k: select parent", + "h: move to root", + ]; + } else { + help_parts.push("a: toggle all"); + if let Some(idx) = state.list_state.selected() + && idx < state.flattened_tree.len() + { + let name = &state.flattened_tree[idx].0; + let intent = state.get_intent(name); + if let Some(b) = branch_map.get(name) { + if b.is_local { + if !state.is_dirty || intent.pending_amend { + help_parts.push("space: grab"); + } + + let effective_parent = intent + .parent + .flatten() + .or_else(|| b.original_parent.clone()); + let will_change = intent.pending_amend + || intent.pending_split.is_some() + || effective_parent != b.original_parent + || (name == &state.initial_branch && state.is_dirty); + if intent.pending_push || b.ahead > 0 || will_change { + help_parts.push("p: push"); + } + + if b.can_submit() { + help_parts.push("s: submit"); + } + if !state.is_dirty { + help_parts.push("r: reset"); + } + if name == &state.initial_branch { + help_parts.push("f: fetch/localize"); + help_parts.push("m: amend"); + help_parts.push("M: amend w/ msg"); + help_parts.push("R: rename"); + help_parts.push("c: commit plan"); + } + if b.parent_ahead == 1 { + help_parts.push("x: split"); + } + if let Some(h_parent) = &b.heuristic_parent + && effective_parent.as_ref() != Some(h_parent) + { + help_parts.push("u: converge"); + } + help_parts.push("d: delete"); + } else if b.is_remote && !state.is_dirty { + help_parts.push("f: localize"); + } + } + } + } + help_parts +} diff --git a/tools/gitui/src/ui/mod.rs b/tools/gitui/src/ui/mod.rs new file mode 100644 index 0000000..2652e28 --- /dev/null +++ b/tools/gitui/src/ui/mod.rs @@ -0,0 +1,12 @@ +pub mod common; +pub mod main_view; +pub mod preview_view; +pub mod prompt_view; +pub mod split_view; + +pub use main_view::draw_main; +pub use preview_view::draw_preview; +pub use prompt_view::draw_prompt; +pub use split_view::draw_split_view; + +pub const SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; diff --git a/tools/gitui/src/ui/preview_view.rs b/tools/gitui/src/ui/preview_view.rs new file mode 100644 index 0000000..26ceeef --- /dev/null +++ b/tools/gitui/src/ui/preview_view.rs @@ -0,0 +1,109 @@ +use crate::engine::Operation; +use crate::state::AppState; +use crate::ui::common::draw_error; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; + +pub fn draw_preview(f: &mut Frame, plan: &[Operation], state: &AppState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(3)]) + .split(f.area()); + + let mut items: Vec = if plan.is_empty() { + vec![ListItem::new("No operations to perform.")] + } else { + plan.iter() + .map(|op| { + let mut spans = vec![Span::raw("> ")]; + match op { + Operation::Rebase { + branch, + onto, + upstream: _, + predicted_conflict, + } => { + spans.push(Span::raw(format!("git rebase {} {}", onto, branch))); + if let Some(conflict) = predicted_conflict { + if *conflict { + spans.push(Span::styled( + " [CONFLICT]", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + " [CLEAN]", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )); + } + } + } + Operation::Sync { + branch, + onto, + predicted_conflict, + } => { + spans.push(Span::raw(format!("git sync {} from {}", branch, onto))); + if let Some(conflict) = predicted_conflict { + if *conflict { + spans.push(Span::styled( + " [NOT FF]", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + " [FF]", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )); + } + } + } + _ => { + spans.push(Span::raw(format!("{}", op))); + } + } + + ListItem::new(Line::from(spans)) + }) + .collect() + }; + + if state.is_predicting_conflicts { + items.push(ListItem::new("")); + let pct = if plan.is_empty() { + 100 + } else { + ((state.prediction_index + 1) * 100) / plan.len() + }; + items.push( + ListItem::new(format!("Predicting conflicts... {}%", pct)).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::ITALIC), + ), + ); + } + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .title("Planned Operations"), + ); + + f.render_widget(list, chunks[0]); + + let help = Paragraph::new("q: back to tree | c: commit all") + .block(Block::default().borders(Borders::ALL).title("Help")); + f.render_widget(help, chunks[1]); + + if let Some(error) = &state.error_message { + draw_error(f, error); + } +} diff --git a/tools/gitui/src/ui/prompt_view.rs b/tools/gitui/src/ui/prompt_view.rs new file mode 100644 index 0000000..fb774c7 --- /dev/null +++ b/tools/gitui/src/ui/prompt_view.rs @@ -0,0 +1,95 @@ +use crate::state::{AppState, PromptFocus}; +use crate::ui::common::{centered_rect, draw_error}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Direction, Layout}; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; + +pub fn draw_prompt(f: &mut Frame, state: &AppState) { + let prompt = match &state.prompt { + Some(p) => p, + None => return, + }; + + let is_multiline = prompt.action.is_multiline(); + let height = if is_multiline { 12 } else { 6 }; + let area = centered_rect(60, height, f.area()); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(3)]) + .split(area); + + let block = Block::default() + .title(prompt.title.clone()) + .borders(Borders::ALL) + .border_style(if prompt.focus == PromptFocus::Input { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }); + + let input = Paragraph::new(prompt.value.clone()).block(block); + f.render_widget(Clear, area); + f.render_widget(input, chunks[0]); + + // Render buttons + let button_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(0), + Constraint::Length(12), // [ OK ] + Constraint::Length(12), // [ Cancel ] + Constraint::Min(0), + ]) + .split(chunks[1]); + + let ok_style = if prompt.focus == PromptFocus::Ok { + Style::default().bg(Color::Yellow).fg(Color::Black) + } else { + Style::default().fg(Color::White) + }; + let ok_button = Paragraph::new(" OK ") + .block(Block::default().borders(Borders::ALL)) + .style(ok_style) + .alignment(Alignment::Center); + f.render_widget(ok_button, button_chunks[1]); + + let cancel_style = if prompt.focus == PromptFocus::Cancel { + Style::default().bg(Color::Yellow).fg(Color::Black) + } else { + Style::default().fg(Color::White) + }; + let cancel_button = Paragraph::new(" Cancel ") + .block(Block::default().borders(Borders::ALL)) + .style(cancel_style) + .alignment(Alignment::Center); + f.render_widget(cancel_button, button_chunks[2]); + + // Calculate cursor position for multi-line + let mut cursor_x = 0; + let mut cursor_y = 0; + for (i, c) in prompt.value.chars().enumerate() { + if i >= prompt.cursor_position { + break; + } + if c == '\n' { + cursor_x = 0; + cursor_y += 1; + } else { + cursor_x += 1; + } + } + + // Set cursor position only if Input is focused + if prompt.focus == PromptFocus::Input { + f.set_cursor_position(( + chunks[0].x + cursor_x as u16 + 1, + chunks[0].y + cursor_y as u16 + 1, + )); + } + + if let Some(error) = &state.error_message { + draw_error(f, error); + } +} diff --git a/tools/gitui/src/ui/split_view.rs b/tools/gitui/src/ui/split_view.rs new file mode 100644 index 0000000..ce3040f --- /dev/null +++ b/tools/gitui/src/ui/split_view.rs @@ -0,0 +1,195 @@ +use crate::diff_utils::LineType; +use crate::split_state::SplitViewMode; +use crate::state::AppState; +use crate::ui::SPINNERS; +use crate::ui::common::{centered_rect, draw_error, render_scrollbar}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, Paragraph}; + +pub fn draw_split_view(f: &mut Frame, state: &mut AppState) { + let split_state = match &mut state.split_state { + Some(s) => s, + None => { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(3)]) + .split(f.area()); + + let area = centered_rect(60, 3, chunks[0]); + let gauge = Gauge::default() + .block(Block::default().borders(Borders::ALL).title(format!( + "{} {}", + state.progress_message, SPINNERS[state.spinner_index] + ))) + .gauge_style(Style::default().fg(Color::Yellow)) + .percent((state.progress_percentage * 100.0) as u16); + f.render_widget(gauge, area); + + let help = Paragraph::new("q: cancel") + .block(Block::default().borders(Borders::ALL).title("Help")); + f.render_widget(help, chunks[1]); + return; + } + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(3)]) + .split(f.area()); + + let mut items = Vec::new(); + use crate::split_state::RenderedItem; + + for (item_idx, item) in split_state.rendered_items.iter().enumerate() { + let is_selected = Some(item_idx) == split_state.list_state.selected(); + + match item { + RenderedItem::FileHeader { file_idx } => { + let file = &split_state.files[*file_idx]; + let selected_count = file + .hunks + .iter() + .enumerate() + .filter(|(h_idx, _)| { + split_state + .current_selection + .contains(&(file.path.clone(), *h_idx)) + }) + .count(); + + let checkbox = if selected_count == file.hunks.len() { + "[x] " + } else if selected_count > 0 { + "[-] " + } else { + "[ ] " + }; + + let mut spans = vec![ + Span::raw(if is_selected { ">>" } else { " " }), + Span::raw(checkbox), + Span::styled( + &file.path, + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::Cyan), + ), + ]; + + if is_selected { + for span in &mut spans { + span.style = span.style.fg(Color::Yellow); + } + } + items.push(ListItem::new(Line::from(spans))); + } + RenderedItem::HunkHeader { file_idx, hunk_idx } => { + let file = &split_state.files[*file_idx]; + let hunk = &file.hunks[*hunk_idx]; + let selected = split_state + .current_selection + .contains(&(file.path.clone(), *hunk_idx)); + + let mut spans = vec![ + Span::raw(if is_selected { " >>" } else { " " }), + Span::raw(if selected { "[x] " } else { "[ ] " }), + Span::styled(&hunk.header, Style::default().fg(Color::Yellow)), + ]; + if is_selected { + for span in &mut spans { + span.style = span.style.fg(Color::Yellow); + } + } + items.push(ListItem::new(Line::from(spans))); + } + RenderedItem::Line { + file_idx, + hunk_idx, + line_idx, + } => { + let file = &split_state.files[*file_idx]; + let hunk = &file.hunks[*hunk_idx]; + let line = &hunk.lines[*line_idx]; + + let style = match line.line_type { + LineType::Addition => Style::default().fg(Color::Green), + LineType::Deletion => Style::default().fg(Color::Red), + _ => Style::default().fg(Color::DarkGray), + }; + + let prefix = match line.line_type { + LineType::Addition => "+", + LineType::Deletion => "-", + _ => " ", + }; + + let mut spans = vec![ + Span::raw(if is_selected { " >>" } else { " " }), + Span::raw(" "), + Span::styled(format!("{}{}", prefix, line.content.trim_end()), style), + ]; + if is_selected { + spans[0].style = Style::default().fg(Color::Yellow); + } + items.push(ListItem::new(Line::from(spans))); + } + } + } + + let mut title_parts = Vec::new(); + for part in &split_state.parts { + title_parts.push(part.name.as_str()); + } + title_parts.push(state.splitting_branch.as_deref().unwrap_or("remainder")); + + let list = List::new(items.clone()) + .block(Block::default().borders(Borders::ALL).title(format!( + "Split Commit: {} -> [{}]", + state.splitting_branch.as_deref().unwrap_or(""), + title_parts.join(" | ") + ))) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .highlight_symbol(">>"); + + f.render_stateful_widget(list, chunks[0], &mut split_state.list_state); + + render_scrollbar(f, chunks[0], items.len(), split_state.list_state.offset()); + + let enter_desc = if split_state.current_selection.is_empty() { + "finish" + } else { + "new part" + }; + + let help_text = match split_state.mode { + SplitViewMode::Files => { + format!( + "j/k: navigate | space: toggle file | ->: view hunks | enter: {} | q: cancel", + enter_desc + ) + } + SplitViewMode::Hunks => { + format!( + "j/k: navigate hunks | space: toggle hunk | ->: view lines | <-: back to files | enter: {} | q: cancel", + enter_desc + ) + } + SplitViewMode::Lines => { + format!( + "j/k: navigate lines | space: toggle hunk | <-: back to hunks | enter: {} | q: cancel", + enter_desc + ) + } + }; + + let help = + Paragraph::new(help_text).block(Block::default().borders(Borders::ALL).title("Help")); + f.render_widget(help, chunks[1]); + + if let Some(error) = &state.error_message { + draw_error(f, error); + } +} diff --git a/tools/gitui/test/amend_integration_tests.rs b/tools/gitui/test/amend_integration_tests.rs new file mode 100644 index 0000000..573cbd3 --- /dev/null +++ b/tools/gitui/test/amend_integration_tests.rs @@ -0,0 +1,107 @@ +use gitui::engine::{BranchIntent, Git, RealGit}; +use gitui::execute_plan; +use gitui::testing::{create_commit, run_git, setup_repo}; +use std::collections::HashMap; +use std::process::Command; + +#[test] +fn test_integration_amend_branch_with_children() { + let (dir, repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup toxav-bench -> test-framework + run_git(path_str, &["checkout", "-b", "toxav-bench"]); + create_commit(&repo, "toxav.txt", "toxav content", "Add toxav-bench"); + + run_git(path_str, &["checkout", "-b", "test-framework"]); + create_commit(&repo, "test.txt", "test content", "Add test-framework"); + + // 2. Go back to toxav-bench and add some staged changes + run_git(path_str, &["checkout", "toxav-bench"]); + let toxav_path = dir.path().join("toxav.txt"); + std::fs::write(&toxav_path, "toxav amended content").unwrap(); + run_git(path_str, &["add", "toxav.txt"]); + + // 3. Mark toxav-bench for amend + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + + { + let _toxav = branches + .iter() + .find(|b| b.name == "toxav-bench") + .expect("toxav-bench not found"); + intents.insert( + "toxav-bench".to_string(), + BranchIntent { + pending_amend: true, + ..Default::default() + }, + ); + } + + // 4. Execute plan + execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // 5. Verify toxav-bench is amended + let content = std::fs::read_to_string(&toxav_path).unwrap(); + assert_eq!(content, "toxav amended content"); + + // 6. Verify test-framework is rebased onto the new toxav-bench + let toxav_oid = run_git(path_str, &["rev-parse", "toxav-bench"]); + let merge_base = run_git(path_str, &["merge-base", "test-framework", "toxav-bench"]); + assert_eq!( + merge_base, toxav_oid, + "test-framework should be rebased onto amended toxav-bench" + ); +} + +#[test] +fn test_integration_amend_with_message() { + let (dir, repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Create a feature branch with a commit + create_commit(&repo, "feat.txt", "content", "Old message"); + repo.branch( + "feat", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + Command::new("git") + .arg("-C") + .arg(path_str) + .args(["checkout", "feat"]) + .status() + .unwrap(); + + // 2. Setup RealGit and load branches + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + + // 3. Mark feat for amend with message + intents.insert( + "feat".to_string(), + BranchIntent { + pending_amend: true, + pending_amend_message: Some("New message".to_string()), + ..Default::default() + }, + ); + + // 4. Execute plan + execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // 5. Verify commit message changed + let head = repo.head().unwrap(); + let commit = head.peel_to_commit().unwrap(); + assert_eq!(commit.message().unwrap(), "New message\n"); + + // 6. Verify content is same (we didn't change content, just message) + let path = dir.path().join("feat.txt"); + let content = std::fs::read_to_string(path).unwrap(); + assert_eq!(content, "content"); +} diff --git a/tools/gitui/test/atomicity_integration_tests.rs b/tools/gitui/test/atomicity_integration_tests.rs new file mode 100644 index 0000000..5342f55 --- /dev/null +++ b/tools/gitui/test/atomicity_integration_tests.rs @@ -0,0 +1,99 @@ +use git2::{Signature, Time}; +use gitui::engine::{Git, RealGit, SplitData, SplitPartData}; +use gitui::testing::setup_repo; +use std::collections::HashSet; + +#[test] +fn test_split_partial_failure_leaves_part_branches() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup a commit with multiple hunks + let path_a = dir.path().join("a.txt"); + let mut initial_content = String::new(); + for i in 1..=50 { + initial_content.push_str(&format!("line {}\n", i)); + } + std::fs::write(&path_a, &initial_content).unwrap(); + { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686000, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Base", &tree, &[&parent]) + .unwrap(); + } + + let mut new_content = initial_content.clone(); + new_content.replace_range(0..7, "line 1 modified\n"); // Hunk 1 + new_content.push_str("line 51 added\n"); // Hunk 2 + std::fs::write(&path_a, &new_content).unwrap(); + let feat_oid = { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686001, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit( + Some("refs/heads/feat"), + &sig, + &sig, + "Big", + &tree, + &[&parent], + ) + .unwrap() + }; + + let git = RealGit::new(path_str).unwrap(); + + // 2. Part 1: Valid. Part 2: INVALID branch name (empty or invalid chars) + let mut sel1 = HashSet::new(); + sel1.insert(("a.txt".to_string(), 0)); // first hunk + let mut sel2 = HashSet::new(); + sel2.insert(("a.txt".to_string(), 1)); // second hunk + + let split_data = SplitData { + parts: vec![ + SplitPartData { + name: "valid-part".to_string(), + commit_message: "Msg1".to_string(), + selected_hunks: sel1, + }, + SplitPartData { + name: "invalid branch name".to_string(), // Space is invalid in branch names + commit_message: "Msg2".to_string(), + selected_hunks: sel2, + }, + ], + }; + + let res = git.split_branch("feat", &master, &split_data); + + // 3. Verify it failed + if let Err(ref e) = res { + println!("Split failed as expected: {}", e); + } + assert!( + res.is_err(), + "Split should have failed due to invalid branch name" + ); + + // 4. Verify that "valid-part" WAS created (partial success/no atomicity) + assert!( + repo.find_branch("valid-part", git2::BranchType::Local) + .is_ok(), + "valid-part branch should exist even though the whole operation failed" + ); + + // 5. Verify that "feat" still points to the original commit + let feat_branch = repo.find_branch("feat", git2::BranchType::Local).unwrap(); + assert_eq!( + feat_branch.get().target().unwrap(), + feat_oid, + "Original feat branch should NOT have moved if the operation failed midway" + ); +} diff --git a/tools/gitui/test/branch_integration_tests.rs b/tools/gitui/test/branch_integration_tests.rs new file mode 100644 index 0000000..b901d1e --- /dev/null +++ b/tools/gitui/test/branch_integration_tests.rs @@ -0,0 +1,485 @@ +use gitui::engine::{BranchIntent, Git, RealGit}; +use gitui::execute_plan; +use gitui::state::AppState; +use gitui::testing::{create_commit, run_git, setup_repo}; +use std::collections::HashMap; + +#[test] +fn test_integration_push() { + let (dir, repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup a "remote" + let remote_dir = tempfile::tempdir().unwrap(); + run_git(remote_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + remote_dir.path().to_str().unwrap(), + ], + ); + + // 2. Create a branch and a commit + create_commit(&repo, "push.txt", "push me", "Push commit"); + repo.branch( + "push-branch", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 3. Mark for push + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + intents.insert( + "push-branch".to_string(), + BranchIntent { + pending_push: true, + ..Default::default() + }, + ); + + execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // 4. Verify it was pushed to remote + let output = run_git(path_str, &["ls-remote", "origin", "push-branch"]); + assert!(!output.is_empty(), "Branch should exist on remote"); +} + +#[test] +fn test_integration_delete() { + let (dir, repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Create a branch + create_commit(&repo, "delete.txt", "delete me", "Delete commit"); + repo.branch( + "delete-branch", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 2. Merge it into master so it's safe to delete with -d + run_git(path_str, &["checkout", "main"]); // assuming main/master + run_git(path_str, &["merge", "delete-branch"]); + + // 3. Verify is_merged is true + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + { + let b = branches.iter().find(|b| b.name == "delete-branch").unwrap(); + assert!(b.is_merged, "Branch should be marked as merged"); + } + + // 4. Mark for delete + let mut intents = HashMap::new(); + intents.insert( + "delete-branch".to_string(), + BranchIntent { + pending_delete: true, + ..Default::default() + }, + ); + + execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // 4. Verify it's gone + let output = std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["rev-parse", "--verify", "delete-branch"]) + .status() + .unwrap(); + assert!(!output.success(), "Branch should be deleted"); +} + +#[test] +fn test_integration_origin_remote_filtering() { + let (dir, repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup two remotes + let origin_dir = tempfile::tempdir().unwrap(); + run_git(origin_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + origin_dir.path().to_str().unwrap(), + ], + ); + + let upstream_dir = tempfile::tempdir().unwrap(); + run_git(upstream_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "upstream", + upstream_dir.path().to_str().unwrap(), + ], + ); + + let other_dir = tempfile::tempdir().unwrap(); + run_git(other_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &["remote", "add", "other", other_dir.path().to_str().unwrap()], + ); + + // 2. Create commits and push to all + create_commit(&repo, "o.txt", "origin content", "Push to origin"); + run_git(path_str, &["push", "origin", "HEAD:origin-branch"]); + + create_commit(&repo, "u.txt", "upstream content", "Push to upstream"); + run_git(path_str, &["push", "upstream", "HEAD:upstream-branch"]); + + create_commit(&repo, "x.txt", "other content", "Push to other"); + run_git(path_str, &["push", "other", "HEAD:other-branch"]); + + // 3. Fetch so they appear as remote branches + run_git(path_str, &["fetch", "--all"]); + + let git = RealGit::new(path_str).unwrap(); + + // 4. Test with show_remote = true + let (branches, _history) = git.get_branches(None).unwrap(); + + // Should find origin/origin-branch + assert!( + branches.iter().any(|b| b.name == "origin/origin-branch"), + "origin/origin-branch should be visible" + ); + + // Should find upstream/upstream-branch + assert!( + branches + .iter() + .any(|b| b.name == "upstream/upstream-branch"), + "upstream/upstream-branch should be visible" + ); + + // Should NOT find other/other-branch + assert!( + !branches.iter().any(|b| b.name == "other/other-branch"), + "other/other-branch should NOT be visible" + ); +} + +#[test] +fn test_integration_branch_aliasing() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup a remote + let remote_dir = tempfile::tempdir().unwrap(); + run_git(remote_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + remote_dir.path().to_str().unwrap(), + ], + ); + + // 2. Create a commit on master and push it + create_commit(&repo, "alias.txt", "alias content", "Alias commit"); + run_git( + path_str, + &["push", "origin", &format!("{}:{}", master, master)], + ); + + // 3. Fetch so origin/master exists at the same commit as master + run_git(path_str, &["fetch", "origin"]); + + // 4. Get branches with show_remote = true + let git = RealGit::new(path_str).unwrap(); + let (branches, _history) = git.get_branches(None).unwrap(); + + // 5. Verify that master has origin/master as an alias + let master_info = branches + .iter() + .find(|b| b.name == master) + .expect("master branch not found"); + assert!( + master_info.aliases.contains(&format!("origin/{}", master)), + "origin/master should be an alias of master" + ); +} + +#[test] +fn test_integration_multiple_aliases_sorting() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup origin remote + let remote_dir = tempfile::tempdir().unwrap(); + run_git(remote_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + remote_dir.path().to_str().unwrap(), + ], + ); + + // 2. Create a commit and push to multiple branch names on origin + create_commit(&repo, "sort.txt", "content", "Sort commit"); + let aliases = ["z-alias", "a-alias", master.as_str()]; + for alias in aliases { + run_git(path_str, &["push", "origin", &format!("HEAD:{}", alias)]); + } + run_git(path_str, &["fetch", "origin"]); + + // 3. Get branches + let git = RealGit::new(path_str).unwrap(); + let (branches, _history) = git.get_branches(None).unwrap(); + + // 4. Verify sorting (a-alias, master, z-alias) - prefixed with origin/ + let master_info = branches.iter().find(|b| b.name == master).unwrap(); + let expected_aliases = vec![ + format!("origin/a-alias"), + format!("origin/{}", master), + format!("origin/z-alias"), + ]; + assert_eq!(master_info.aliases, expected_aliases); +} + +#[test] +fn test_integration_local_branches_at_same_commit() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Create another local branch at the same commit + repo.branch( + "other-local", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 2. Get branches + let git = RealGit::new(path_str).unwrap(); + let (branches, _history) = git.get_branches(None).unwrap(); + + // 3. Verify that 'other-local' is NOT an alias but its own branch + let master_info = branches.iter().find(|b| b.name == master).unwrap(); + assert!( + master_info.aliases.is_empty(), + "master should not have local aliases" + ); + + let other_info = branches + .iter() + .find(|b| b.name == "other-local") + .expect("other-local branch not found"); + assert_eq!( + other_info.original_parent.as_deref(), + Some(master.as_str()), + "other-local should be a child of master" + ); +} + +#[test] +fn test_integration_remote_only_branch_visibility() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup a remote + let remote_dir = tempfile::tempdir().unwrap(); + run_git(remote_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + remote_dir.path().to_str().unwrap(), + ], + ); + + // 2. Create a branch on remote only + run_git(path_str, &["checkout", &master]); + run_git(path_str, &["checkout", "-b", "remote-only"]); + create_commit(&repo, "remote.txt", "content", "Remote commit"); + run_git(path_str, &["push", "origin", "remote-only"]); + run_git(path_str, &["checkout", &master]); + run_git(path_str, &["branch", "-D", "remote-only"]); + run_git(path_str, &["fetch", "origin"]); + + // 3. Initialize AppState with show_remote = true + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + show_remote: true, + ..AppState::default() + }; + state.refresh_tree(None); + + // 4. Verify origin/remote-only is visible + assert!( + state + .flattened_tree + .iter() + .any(|(n, _)| n == "origin/remote-only"), + "origin/remote-only should be visible when show_remote is true" + ); + + // 5. Toggle show_remote = false + state.show_remote = false; + state.refresh_tree(None); + + // 6. Verify origin/remote-only is NOT visible + assert!( + !state + .flattened_tree + .iter() + .any(|(n, _)| n == "origin/remote-only"), + "origin/remote-only should NOT be visible when show_remote is false" + ); +} + +#[test] +fn test_integration_alias_parent_resolution() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup origin/master alias + let remote_dir = tempfile::tempdir().unwrap(); + run_git(remote_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + remote_dir.path().to_str().unwrap(), + ], + ); + run_git( + path_str, + &["push", "origin", &format!("{}:{}", master, master)], + ); + run_git(path_str, &["fetch", "origin"]); + + // 2. Create feature branch from master + create_commit(&repo, "f.txt", "f", "Feature commit"); + repo.branch( + "feature", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 3. Get branches - origin/master will be an alias of master + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + show_remote: false, + ..AppState::default() + }; + + // 4. Simulate a move where parent is set to the alias name + state.mutate_intent("feature", |i| { + i.parent = Some(Some(format!("origin/{}", master))); + }); + + // 5. Refresh tree + state.refresh_tree(None); + let flattened = &state.flattened_tree; + + // 6. Verify feature is still a child (depth 1) of master (depth 0), not a root + let master_depth = flattened + .iter() + .find(|(n, _)| n == &master) + .map(|(_, d)| *d) + .expect("master not found"); + let feature_depth = flattened + .iter() + .find(|(n, _)| n == "feature") + .map(|(_, d)| *d) + .expect("feature not found"); + + assert_eq!(master_depth, 0); + assert_eq!( + feature_depth, 1, + "Feature should be a child of master even if parented to its alias" + ); +} + +#[test] +fn test_integration_localize_existing_branch() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup a remote + let remote_dir = tempfile::tempdir().unwrap(); + run_git(remote_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + remote_dir.path().to_str().unwrap(), + ], + ); + + // 2. Create 'feat' on remote + run_git(path_str, &["checkout", "-b", "feat"]); + create_commit(&repo, "feat.txt", "remote content", "Remote commit"); + run_git(path_str, &["push", "origin", "feat"]); + run_git(path_str, &["checkout", &master]); + + // 3. Create a local 'feat' at a DIFFERENT commit (off master) + create_commit(&repo, "local_feat.txt", "local content", "Local commit"); + run_git(path_str, &["branch", "-f", "feat", "HEAD"]); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + + // 4. Find origin/feat and mark for localization + intents.insert( + "origin/feat".to_string(), + BranchIntent { + pending_localize: true, + ..Default::default() + }, + ); + + // 5. Execute plan (should use checkout -B) + execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // 6. Verify local 'feat' now matches 'origin/feat' + let feat_oid = run_git(path_str, &["rev-parse", "feat"]); + let origin_feat_oid = run_git(path_str, &["rev-parse", "origin/feat"]); + assert_eq!( + feat_oid, origin_feat_oid, + "Local 'feat' should match 'origin/feat'" + ); + + // Verify it's tracking + let upstream = run_git(path_str, &["config", "branch.feat.merge"]); + assert_eq!(upstream, "refs/heads/feat"); + let remote = run_git(path_str, &["config", "branch.feat.remote"]); + assert_eq!(remote, "origin"); +} diff --git a/tools/gitui/test/cli_output_tests.rs b/tools/gitui/test/cli_output_tests.rs new file mode 100644 index 0000000..e54557b --- /dev/null +++ b/tools/gitui/test/cli_output_tests.rs @@ -0,0 +1,255 @@ +use gitui::testing::{create_commit, run_git, setup_repo}; +use gitui::{print_converge_plan_to, print_submit_plan_to, print_tree_to}; +use insta::assert_snapshot; +use std::path::PathBuf; + +fn strip_ansi(s: &str) -> String { + let re = regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap(); + re.replace_all(s, "").to_string() +} + +pub fn configure_insta() -> insta::Settings { + let mut settings = insta::Settings::clone_current(); + let path = if let Ok(workspace_dir) = std::env::var("BUILD_WORKSPACE_DIRECTORY") { + let mut p = PathBuf::from(workspace_dir); + p.push("hs-github-tools"); + p.push("tools"); + p.push("gitui"); + p.push("test"); + p.push("snapshots"); + p + } else { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); + let mut p = std::env::current_dir().unwrap(); + p.push(manifest_dir); + p.push("test"); + p.push("snapshots"); + p + }; + settings.set_snapshot_path(path); + settings +} + +#[test] +fn test_cli_print_tree_snapshot() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // Setup a small tree + // master + // feat1 + // feat1-child + // feat2 + create_commit(&repo, "f1.txt", "1", "feat1 commit"); + run_git(path_str, &["branch", "feat1"]); + + run_git(path_str, &["checkout", "feat1"]); + create_commit(&repo, "f1c.txt", "1c", "feat1-child commit"); + run_git(path_str, &["branch", "feat1-child"]); + + run_git(path_str, &["checkout", &master]); + create_commit(&repo, "f2.txt", "2", "feat2 commit"); + run_git(path_str, &["branch", "feat2"]); + + // Currently on master + let mut buffer = Vec::new(); + print_tree_to(path_str, false, &mut buffer).unwrap(); + + let output = String::from_utf8(buffer).unwrap(); + let clean_output = strip_ansi(&output); + + let settings = configure_insta(); + settings.bind(|| { + assert_snapshot!(clean_output); + }); +} + +#[test] +fn test_cli_print_diverged_tree_snapshot() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // Detach HEAD so main doesn't move + repo.set_head_detached(repo.head().unwrap().target().unwrap()) + .unwrap(); + + // 1. feat1 at C1 + let oid1 = create_commit(&repo, "f1.txt", "1", "feat1"); + repo.branch("feat1", &repo.find_commit(oid1).unwrap(), false) + .unwrap(); + + // 2. feat2 at C2 (parent C1) + repo.set_head_detached(oid1).unwrap(); + let oid2 = create_commit(&repo, "f2.txt", "2", "feat2"); + repo.branch("feat2", &repo.find_commit(oid2).unwrap(), false) + .unwrap(); + + // 3. Amend feat1 (C1 -> C1') + run_git(path_str, &["checkout", "feat1"]); + std::fs::write(dir.path().join("f1.txt"), "1 amended").unwrap(); + run_git(path_str, &["add", "f1.txt"]); + run_git(path_str, &["commit", "--amend", "-m", "feat1", "-a"]); + + run_git(path_str, &["checkout", &master]); + + let mut buffer = Vec::new(); + print_tree_to(path_str, false, &mut buffer).unwrap(); + + let output = String::from_utf8(buffer).unwrap(); + let clean_output = strip_ansi(&output); + + let settings = configure_insta(); + settings.bind(|| { + assert_snapshot!(clean_output); + }); +} + +#[test] +fn test_cli_print_submit_plan_snapshot() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup a "remote" + let remote_dir = tempfile::tempdir().unwrap(); + run_git(remote_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + remote_dir.path().to_str().unwrap(), + ], + ); + run_git( + path_str, + &[ + "remote", + "add", + "upstream", + remote_dir.path().to_str().unwrap(), + ], + ); + + // 2. Setup master tracking origin/master + run_git( + path_str, + &["push", "origin", &format!("{}:{}", master, master)], + ); + run_git( + path_str, + &[ + "branch", + "--set-upstream-to", + &format!("origin/{}", master), + &master, + ], + ); + + // 3. Setup feat branch ready to submit + run_git(path_str, &["checkout", "-b", "feat"]); + create_commit(&repo, "f.txt", "f", "Feat commit"); + run_git(path_str, &["push", "origin", "feat"]); + run_git( + path_str, + &["branch", "--set-upstream-to", "origin/feat", "feat"], + ); + run_git(path_str, &["fetch", "origin"]); + + let mut buffer = Vec::new(); + print_submit_plan_to(path_str, "feat", &mut buffer).unwrap(); + + let output = String::from_utf8(buffer).unwrap(); + let clean_output = strip_ansi(&output); + + let settings = configure_insta(); + settings.bind(|| { + assert_snapshot!(clean_output); + }); +} + +#[test] +fn test_cli_print_converge_plan_snapshot() { + let (dir, repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // Detach HEAD so main doesn't move + repo.set_head_detached(repo.head().unwrap().target().unwrap()) + .unwrap(); + + // 1. Setup feat1 + let oid1 = create_commit(&repo, "f1.txt", "1", "feat1"); + repo.branch("feat1", &repo.find_commit(oid1).unwrap(), false) + .unwrap(); + + // 2. Setup feat2 on top of feat1 + repo.set_head_detached(oid1).unwrap(); + let oid2 = create_commit(&repo, "f2.txt", "2", "feat2"); + repo.branch("feat2", &repo.find_commit(oid2).unwrap(), false) + .unwrap(); + + // 3. Amend feat1 + run_git(path_str, &["checkout", "feat1"]); + std::fs::write(dir.path().join("f1.txt"), "1 updated").unwrap(); + run_git(path_str, &["add", "f1.txt"]); + run_git(path_str, &["commit", "--amend", "-m", "feat1", "-a"]); + + let mut buffer = Vec::new(); + print_converge_plan_to(path_str, "feat2", &mut buffer).unwrap(); + + let output = String::from_utf8(buffer).unwrap(); + let clean_output = strip_ansi(&output); + + let settings = configure_insta(); + settings.bind(|| { + assert_snapshot!(clean_output); + }); +} + +#[test] +fn test_cli_cascading_divergence() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + repo.set_head_detached(repo.head().unwrap().target().unwrap()) + .unwrap(); + + // 1. master -> A -> B -> C + let oid_a = create_commit(&repo, "a.txt", "a", "branch-a"); + repo.branch("branch-a", &repo.find_commit(oid_a).unwrap(), false) + .unwrap(); + + repo.set_head_detached(oid_a).unwrap(); + let oid_b = create_commit(&repo, "b.txt", "b", "branch-b"); + repo.branch("branch-b", &repo.find_commit(oid_b).unwrap(), false) + .unwrap(); + + repo.set_head_detached(oid_b).unwrap(); + let oid_c = create_commit(&repo, "c.txt", "c", "branch-c"); + repo.branch("branch-c", &repo.find_commit(oid_c).unwrap(), false) + .unwrap(); + + // 2. Amend A + run_git(path_str, &["checkout", "branch-a"]); + std::fs::write(dir.path().join("a.txt"), "amended").unwrap(); + run_git(path_str, &["add", "a.txt"]); + run_git(path_str, &["commit", "--amend", "-m", "branch-a", "-a"]); + + run_git(path_str, &["checkout", &master]); + + let mut buffer = Vec::new(); + print_tree_to(path_str, false, &mut buffer).unwrap(); + + let output = String::from_utf8(buffer).unwrap(); + let clean_output = strip_ansi(&output); + + // branch-b should be [DIVERGED] because its parent A has moved + assert!(clean_output.contains("branch-b [DIVERGED]")); + // branch-c should NOT be [DIVERGED] because its parent B hasn't moved relative to it + assert!(!clean_output.contains("branch-c [DIVERGED]")); + + let settings = configure_insta(); + settings.bind(|| { + assert_snapshot!(clean_output); + }); +} diff --git a/tools/gitui/test/component_tests.rs b/tools/gitui/test/component_tests.rs new file mode 100644 index 0000000..57ff0b3 --- /dev/null +++ b/tools/gitui/test/component_tests.rs @@ -0,0 +1,123 @@ +use gitui::engine::BranchInfo; +use gitui::state::AppState; +use gitui::testing::mock_oid; +use gitui::ui::main_view::render_branch_row; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::widgets::List; + +#[test] +fn test_render_branch_row_diverged() { + let state = AppState::default(); + let name = "my-branch"; + let oid = mock_oid(1); + + let info = BranchInfo { + name: name.to_string(), + oid, + original_parent: Some("master".to_string()), + heuristic_parent: Some("other".to_string()), // DIVERGED! + ..Default::default() + }; + + let item = render_branch_row(name, 1, &info, &state, 20); + + let backend = TestBackend::new(80, 1); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let list = List::new(vec![item]); + f.render_widget(list, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let mut line = String::new(); + for x in 0..80 { + line.push_str(buffer[(x, 0)].symbol()); + } + + assert!(line.contains("my-branch")); + assert!(line.contains("[DIVERGED]")); +} + +#[test] +fn test_render_branch_row_reparented_conflict() { + let mut state = AppState::default(); + let name = "my-branch"; + let oid = mock_oid(1); + + let info = BranchInfo { + name: name.to_string(), + oid, + original_parent: Some("master".to_string()), + ..Default::default() + }; + + // Set intent to move it to "new-parent" and mark as conflict + state.mutate_intent(name, |i| { + i.parent = Some(Some("new-parent".to_string())); + i.has_conflict = true; + }); + + let item = render_branch_row(name, 0, &info, &state, 20); + + let backend = TestBackend::new(80, 1); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let list = List::new(vec![item]); + f.render_widget(list, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let mut line = String::new(); + for x in 0..80 { + line.push_str(buffer[(x, 0)].symbol()); + } + + assert!(line.contains("my-branch")); + assert!(line.contains("[REBASE]")); + assert!(line.contains("(CONFLICT)")); +} + +#[test] +fn test_render_branch_row_localized() { + let mut state = AppState::default(); + let name = "origin/my-branch"; + let oid = mock_oid(1); + + let info = BranchInfo { + name: name.to_string(), + oid, + is_remote: true, + ..Default::default() + }; + + state.mutate_intent(name, |i| { + i.pending_localize = true; + }); + + let item = render_branch_row(name, 0, &info, &state, 20); + + let backend = TestBackend::new(80, 1); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let list = List::new(vec![item]); + f.render_widget(list, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let mut line = String::new(); + for x in 0..80 { + line.push_str(buffer[(x, 0)].symbol()); + } + + // Should show localized name (without origin/) + assert!(line.contains("my-branch")); + assert!(!line.contains("origin/my-branch")); + assert!(line.contains("[LOCALIZE]")); +} diff --git a/tools/gitui/test/conflict_integration_tests.rs b/tools/gitui/test/conflict_integration_tests.rs new file mode 100644 index 0000000..36143d0 --- /dev/null +++ b/tools/gitui/test/conflict_integration_tests.rs @@ -0,0 +1,154 @@ +use gitui::engine::RealGit; +use gitui::testing::{create_commit, run_git, setup_repo}; + +#[test] +fn test_integration_conflict_detection() { + let (dir, repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 0. Base commit with a.txt + let base_oid = create_commit(&repo, "a.txt", "base content", "Add a.txt to master"); + + // 1. Create feat1 from base + run_git(path_str, &["checkout", &base_oid.to_string()]); + create_commit(&repo, "a.txt", "content v1", "Modify a.txt in feat1"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 2. Create feat2 from base (CONFLICT with feat1) + run_git(path_str, &["checkout", &base_oid.to_string()]); + create_commit(&repo, "a.txt", "content v2", "Modify a.txt in feat2"); + repo.branch( + "feat2", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 3. Create feat3 from base (NO CONFLICT with feat1) + run_git(path_str, &["checkout", &base_oid.to_string()]); + create_commit(&repo, "b.txt", "other content", "Add b.txt in feat3"); + repo.branch( + "feat3", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + + // feat1 vs feat2 (Conflict) + assert!( + gitui::engine::Git::check_conflict(&git, "feat1", "feat2").unwrap(), + "feat1 and feat2 should conflict" + ); + + // feat1 vs feat3 (No conflict) + assert!( + !gitui::engine::Git::check_conflict(&git, "feat1", "feat3").unwrap(), + "feat1 and feat3 should NOT conflict" + ); +} + +#[test] +fn test_integration_remote_conflict_detection() { + let (dir, repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Base commit with a.txt + let base_oid = create_commit(&repo, "a.txt", "base content", "Add a.txt to master"); + + // 2. Create local feat1 from base + run_git(path_str, &["checkout", &base_oid.to_string()]); + create_commit(&repo, "a.txt", "content v1", "Modify a.txt in feat1"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 3. Create remote branch 'origin/feat2' from base (CONFLICT with feat1) + run_git(path_str, &["checkout", &base_oid.to_string()]); + create_commit(&repo, "a.txt", "content v2", "Modify a.txt in feat2"); + + let remote_dir = tempfile::tempdir().unwrap(); + run_git(remote_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + remote_dir.path().to_str().unwrap(), + ], + ); + run_git(path_str, &["push", "origin", "HEAD:refs/heads/feat2"]); + run_git(path_str, &["fetch", "origin"]); + + let git = RealGit::new(path_str).unwrap(); + + // 4. Verify conflict detection + assert!( + gitui::engine::Git::check_conflict(&git, "feat1", "origin/feat2").unwrap(), + "feat1 and origin/feat2 should conflict" + ); +} + +#[test] +fn test_integration_predict_conflict_hunks() { + let (dir, repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Create a base file with multiple lines + let base_content = "line 1\nline 2\nline 3\nline 4\n"; + let base_oid = create_commit(&repo, "file.txt", base_content, "Base commit"); + + // 2. Branch A: change line 1 + run_git(path_str, &["checkout", &base_oid.to_string()]); + create_commit( + &repo, + "file.txt", + "CHANGE A\nline 2\nline 3\nline 4\n", + "Branch A", + ); + let oid_a = repo.head().unwrap().target().unwrap(); + + // 3. Branch B: change line 4 (Clean merge with A) + run_git(path_str, &["checkout", &base_oid.to_string()]); + create_commit( + &repo, + "file.txt", + "line 1\nline 2\nline 3\nCHANGE B\n", + "Branch B", + ); + let oid_b = repo.head().unwrap().target().unwrap(); + + // 4. Branch C: change line 1 (Conflict with A) + run_git(path_str, &["checkout", &base_oid.to_string()]); + create_commit( + &repo, + "file.txt", + "CONFLICT C\nline 2\nline 3\nline 4\n", + "Branch C", + ); + let oid_c = repo.head().unwrap().target().unwrap(); + + let git = RealGit::new(path_str).unwrap(); + + // A vs B: Clean + assert!( + !gitui::engine::Git::check_conflict_between(&git, oid_a, oid_b).unwrap(), + "A and B should be a clean merge (different hunks)" + ); + + // A vs C: Conflict + assert!( + gitui::engine::Git::check_conflict_between(&git, oid_a, oid_c).unwrap(), + "A and C should conflict (same hunk)" + ); +} diff --git a/tools/gitui/test/diff_utils_tests.rs b/tools/gitui/test/diff_utils_tests.rs new file mode 100644 index 0000000..134f88a --- /dev/null +++ b/tools/gitui/test/diff_utils_tests.rs @@ -0,0 +1,127 @@ +use git2::Signature; +use gitui::diff_utils::{LineType, parse_diff}; +use gitui::testing::setup_repo; +use std::fs::File; +use std::io::Write; + +#[test] +fn test_parse_diff_single_file() { + let (tmp_dir, repo, _branch) = setup_repo(); + let file_path = tmp_dir.path().join("test.txt"); + + // 1. Initial commit + { + let mut file = File::create(&file_path).unwrap(); + file.write_all(b"line1\nline2\nline3\n").unwrap(); + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("test.txt")).unwrap(); + let oid = index.write_tree().unwrap(); + let signature = Signature::now("Test User", "test@example.com").unwrap(); + let tree = repo.find_tree(oid).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit( + Some("HEAD"), + &signature, + &signature, + "Initial commit", + &tree, + &[&parent], + ) + .unwrap(); + } + + // 2. Modify file + { + let mut file = File::create(&file_path).unwrap(); + file.write_all(b"line1\nline2 changed\nline3\nline4\n") + .unwrap(); + } + + // 3. Get diff + let head = repo.head().unwrap().peel_to_tree().unwrap(); + let diff = repo + .diff_tree_to_workdir_with_index(Some(&head), None) + .unwrap(); + + let file_diffs = parse_diff(&diff).unwrap(); + assert_eq!(file_diffs.len(), 1); + let f = &file_diffs[0]; + assert_eq!(f.path, "test.txt"); + assert_eq!(f.hunks.len(), 1); + let h = &f.hunks[0]; + + // line1 (context) + // -line2 + // +line2 changed + // line3 (context) + // +line4 + assert_eq!(h.lines.len(), 5); + assert_eq!(h.lines[0].line_type, LineType::Context); + assert_eq!(h.lines[1].line_type, LineType::Deletion); + assert_eq!(h.lines[2].line_type, LineType::Addition); + assert_eq!(h.lines[3].line_type, LineType::Context); + assert_eq!(h.lines[4].line_type, LineType::Addition); +} + +#[test] +fn test_parse_diff_multiple_hunks() { + let (tmp_dir, repo, _branch) = setup_repo(); + let file_path = tmp_dir.path().join("test.txt"); + + // 1. Initial commit with many lines + { + let mut file = File::create(&file_path).unwrap(); + let mut content = String::new(); + for i in 1..=100 { + content.push_str(&format!("line{}\n", i)); + } + file.write_all(content.as_bytes()).unwrap(); + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("test.txt")).unwrap(); + let oid = index.write_tree().unwrap(); + let signature = Signature::now("Test User", "test@example.com").unwrap(); + let tree = repo.find_tree(oid).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit( + Some("HEAD"), + &signature, + &signature, + "Initial commit", + &tree, + &[&parent], + ) + .unwrap(); + } + + // 2. Modify file in two distant places + { + let mut file = File::create(&file_path).unwrap(); + let mut content = String::new(); + for i in 1..=100 { + if i == 5 { + content.push_str("line5 changed\n"); + } else if i == 95 { + content.push_str("line95 changed\n"); + } else { + content.push_str(&format!("line{}\n", i)); + } + } + file.write_all(content.as_bytes()).unwrap(); + } + + // 3. Get diff + let head = repo.head().unwrap().peel_to_tree().unwrap(); + let diff = repo + .diff_tree_to_workdir_with_index(Some(&head), None) + .unwrap(); + + let file_diffs = parse_diff(&diff).unwrap(); + assert_eq!(file_diffs.len(), 1); + let f = &file_diffs[0]; + assert_eq!(f.hunks.len(), 2); + + assert!(f.hunks[0].header.contains("@@ -2,7 +2,7 @@")); + assert!(f.hunks[1].header.contains("@@ -92,7 +92,7 @@")); +} + +// end of file diff --git a/tools/gitui/test/diverge_integration_tests.rs b/tools/gitui/test/diverge_integration_tests.rs new file mode 100644 index 0000000..ab1fb52 --- /dev/null +++ b/tools/gitui/test/diverge_integration_tests.rs @@ -0,0 +1,64 @@ +use gitui::testing::{create_commit, run_git, setup_repo}; + +#[test] +fn test_divergence_detection_and_convergence_plan() { + let (tdir, repo, _main_branch) = setup_repo(); + let path_str = tdir.path().to_str().unwrap(); + + // 1. Create Branch A with "Commit 1" + run_git(path_str, &["checkout", "-b", "branch-a"]); + create_commit(&repo, "file1.txt", "content1", "Commit 1"); + + // 2. Create Branch B from A with "Commit 2" + run_git(path_str, &["checkout", "-b", "branch-b"]); + create_commit(&repo, "file2.txt", "content2", "Commit 2"); + + // 3. Amend Branch A (change content, keep summary) + run_git(path_str, &["checkout", "branch-a"]); + let file1_path = tdir.path().join("file1.txt"); + std::fs::write(&file1_path, "content1 updated").unwrap(); + run_git(path_str, &["add", "file1.txt"]); + run_git(path_str, &["commit", "--amend", "--no-edit"]); + + // 4. Verify Branch B is detected as diverged from A + let mut buf = Vec::new(); + gitui::print_tree_to(tdir.path(), false, &mut buf).expect("Failed to print tree"); + let output_str = String::from_utf8(buf).expect("Output not UTF-8"); + + println!("Tree Output:\n{}", output_str); + assert!(output_str.contains("branch-b")); + assert!(output_str.contains("[DIVERGED]")); + + // 5. Verify --converge B suggests the correct rebase command + let mut buf = Vec::new(); + gitui::print_converge_plan_to(tdir.path(), "branch-b", &mut buf) + .expect("Failed to print converge plan"); + let plan_str = String::from_utf8(buf).expect("Plan output not UTF-8"); + + println!("Plan Output:\n{}", plan_str); + assert!(plan_str.contains("Plan to converge branch-b:")); + assert!(plan_str.contains("git rebase --onto branch-a")); + assert!(plan_str.contains("branch-b")); + + // Verify that it uses the 3-argument rebase (with upstream OID) + // The output should look like: git rebase --onto branch-a branch-b + let parts: Vec<&str> = plan_str + .lines() + .find(|line| line.contains("git rebase --onto")) + .unwrap() + .split_whitespace() + .collect(); + + // git, rebase, --onto, branch-a, , branch-b [, [CONFLICT]/[CLEAN]] + assert!( + parts.len() == 6 || parts.len() == 7, + "Rebase command should have 6 or 7 parts" + ); + assert_eq!(parts[0], "git"); + assert_eq!(parts[1], "rebase"); + assert_eq!(parts[2], "--onto"); + assert_eq!(parts[3], "branch-a"); + assert_eq!(parts[5], "branch-b"); + // parts[4] is the OID + assert!(parts[4].len() >= 7, "Fifth part should be a commit OID"); +} diff --git a/tools/gitui/test/heuristic_integration_tests.rs b/tools/gitui/test/heuristic_integration_tests.rs new file mode 100644 index 0000000..4b442a3 --- /dev/null +++ b/tools/gitui/test/heuristic_integration_tests.rs @@ -0,0 +1,108 @@ +use gitui::engine::{BranchIntent, Git, RealGit}; +use gitui::execute_plan; +use gitui::testing::{create_commit, run_git, setup_repo}; +use std::collections::HashMap; + +#[test] +fn test_integration_diverged_branch_detection() { + let (dir, repo, _main) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // Detach HEAD so main doesn't move + repo.set_head_detached(repo.head().unwrap().target().unwrap()) + .unwrap(); + + // 1. Create a base branch 'feat1' + create_commit(&repo, "f1.txt", "f1 content", "feat1"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 2. Create 'feat2' based on 'feat1' + run_git(path_str, &["checkout", "feat1"]); + create_commit(&repo, "f2.txt", "f2 content", "Add feat2"); + repo.branch( + "feat2", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 3. Rebase 'feat1' so 'feat2' becomes diverged + run_git(path_str, &["checkout", "feat1"]); + std::fs::write(dir.path().join("f1.txt"), "f1 amended").unwrap(); + run_git(path_str, &["commit", "--amend", "-m", "feat1", "-a"]); + + let git = RealGit::new(path_str).unwrap(); + let (branches, _) = git.get_branches(None).unwrap(); + + let feat2 = branches + .iter() + .find(|b| b.name == "feat2") + .expect("feat2 not found"); + + assert_eq!(feat2.heuristic_parent, Some("feat1".to_string())); +} + +#[test] +fn test_integration_converge_operation() { + let (dir, repo, _main) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // Detach HEAD so main doesn't move + repo.set_head_detached(repo.head().unwrap().target().unwrap()) + .unwrap(); + + // 1. Create 'feat1' + create_commit(&repo, "f1.txt", "f1", "feat1"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 2. Create 'feat2' dependent on 'feat1' + run_git(path_str, &["checkout", "feat1"]); + create_commit(&repo, "f2.txt", "f2", "feat2"); + repo.branch( + "feat2", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 3. Rewrite 'feat1' + run_git(path_str, &["checkout", "feat1"]); + std::fs::write(dir.path().join("f1.txt"), "f1 amended").unwrap(); + run_git(path_str, &["commit", "--amend", "-m", "feat1", "-a"]); + let new_feat1_oid = run_git(path_str, &["rev-parse", "feat1"]); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + + { + let feat2 = branches.iter().find(|b| b.name == "feat2").unwrap(); + intents.insert( + "feat2".to_string(), + BranchIntent { + parent: Some(feat2.heuristic_parent.clone()), + ..Default::default() + }, + ); + } + + // 4. Execute plan + execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // 5. Verify feat2 is now on top of new feat1 + let merge_base = run_git(path_str, &["merge-base", "feat2", "feat1"]); + assert_eq!( + merge_base, new_feat1_oid, + "feat2 should be rebased onto new feat1" + ); +} diff --git a/tools/gitui/test/heuristic_tests.rs b/tools/gitui/test/heuristic_tests.rs new file mode 100644 index 0000000..a1e9d17 --- /dev/null +++ b/tools/gitui/test/heuristic_tests.rs @@ -0,0 +1,515 @@ +use gitui::engine::{Git, RealGit}; +use gitui::state::{AppState, Msg}; +use gitui::testing::{create_commit, run_git, setup_repo}; + +#[test] +fn test_heuristic_parent_parsing() { + let (dir, repo, main) = setup_repo(); + let path = dir.path(); + + // Detach HEAD so 'main' doesn't move + repo.set_head_detached(repo.head().unwrap().target().unwrap()) + .unwrap(); + + // Create branches foo and bar first so they are in the summary map + let oid_foo = create_commit(&repo, "foo.txt", "foo", "foo"); + repo.branch("foo", &repo.find_commit(oid_foo).unwrap(), false) + .unwrap(); + let oid_bar = create_commit(&repo, "bar.txt", "bar", "bar"); + repo.branch("bar", &repo.find_commit(oid_bar).unwrap(), false) + .unwrap(); + + repo.set_head_detached( + repo.find_branch(&main, git2::BranchType::Local) + .unwrap() + .get() + .target() + .unwrap(), + ) + .unwrap(); + + // 1. Create a branch whose history contains a commit with summary "foo" + let _oid_pre_a = create_commit(&repo, "pre_a.txt", "pre_a", "foo"); + let oid_a = create_commit(&repo, "a.txt", "a", "Feature A"); + repo.branch("feat-a", &repo.find_commit(oid_a).unwrap(), false) + .unwrap(); + + // 2. Create another branch whose history contains a commit with summary "bar" + // Reset to main's OID first + let main_oid = repo + .find_branch(&main, git2::BranchType::Local) + .unwrap() + .get() + .target() + .unwrap(); + repo.set_head_detached(main_oid).unwrap(); + let _oid_pre_b = create_commit(&repo, "pre_b.txt", "pre_b", "bar"); + let oid_b = create_commit(&repo, "b.txt", "b", "Feature B"); + repo.branch("feat-b", &repo.find_commit(oid_b).unwrap(), false) + .unwrap(); + + // Now amend foo and bar so that feat-a and feat-b are diverged + run_git(path, &["checkout", "foo"]); + create_commit(&repo, "foo2.txt", "foo2", "foo"); + run_git(path, &["checkout", "bar"]); + create_commit(&repo, "bar2.txt", "bar2", "bar"); + + let git = RealGit::new(path).unwrap(); + let (branches, _) = git.get_branches(None).unwrap(); + + let a = branches + .iter() + .find(|b| b.name == "feat-a") + .expect("feat-a missing"); + assert_eq!(a.heuristic_parent.as_deref(), Some("foo")); + + let b = branches + .iter() + .find(|b| b.name == "feat-b") + .expect("feat-b missing"); + assert_eq!(b.heuristic_parent.as_deref(), Some("bar")); +} + +#[test] +fn test_heuristic_sync_interaction() { + let (dir, repo, main) = setup_repo(); + let path = dir.path(); + let main_oid = repo.head().unwrap().target().unwrap(); + + // Detach HEAD so 'main' stays at initial commit + repo.set_head_detached(main_oid).unwrap(); + + // 1. Setup main -> feat1 -> feat2 + let oid1 = create_commit(&repo, "f1.txt", "1", "Add feat1"); + repo.branch("feat1", &repo.find_commit(oid1).unwrap(), false) + .unwrap(); + + let oid2 = create_commit(&repo, "f2.txt", "2", "Add feat2"); + repo.branch("feat2", &repo.find_commit(oid2).unwrap(), false) + .unwrap(); + + // 2. Amend feat1 (break the link) + run_git(path, &["checkout", "-f", "feat1"]); + std::fs::write(path.join("f1.txt"), "1 amended").unwrap(); + run_git(path, &["add", "f1.txt"]); + run_git(path, &["commit", "--amend", "--no-edit"]); + + let git = RealGit::new(path).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + ..AppState::default() + }; + state.refresh_tree(None); + + // Verify initial (broken) topology: feat2 is under main because its parent OID (old feat1) + // is no longer a branch head, so it walks up to main. + let feat2_info = state + .branches + .iter() + .find(|b| b.name == "feat2") + .expect("feat2 missing"); + assert_eq!( + state.get_effective_parent("feat2").as_ref().unwrap(), + &main, + "feat2 should have fallen back to main" + ); + assert_eq!(feat2_info.heuristic_parent.as_deref(), Some("feat1")); + + // 3. Simulate pressing 'u' on feat2 to sync it back to feat1 + let feat2_pos = state + .flattened_tree + .iter() + .position(|(n, _)| n == "feat2") + .expect("feat2 missing from tree"); + state.list_state.select(Some(feat2_pos)); + + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('u'), + KeyModifiers::NONE, + ))); + + // 4. Verify feat2 is now parented to feat1 + assert_eq!( + state.get_effective_parent("feat2").as_deref(), + Some("feat1"), + "feat2 should be synced back to feat1" + ); + + // Verify tree refresh + state.refresh_tree(None); + let feat2_flat_pos = state + .flattened_tree + .iter() + .position(|(n, _)| n == "feat2") + .unwrap(); + let feat1_flat_pos = state + .flattened_tree + .iter() + .position(|(n, _)| n == "feat1") + .unwrap(); + assert!( + feat2_flat_pos > feat1_flat_pos, + "feat2 should come after feat1" + ); + assert_eq!( + state.flattened_tree[feat2_flat_pos].1, + state.flattened_tree[feat1_flat_pos].1 + 1 + ); +} + +#[test] +fn test_heuristic_sync_with_cycle_prevention() { + let (dir, repo, main) = setup_repo(); + let path = dir.path(); + let main_oid = repo.head().unwrap().target().unwrap(); + + repo.set_head_detached(main_oid).unwrap(); + + // Create A -> B + let oid_a = create_commit(&repo, "a.txt", "a", "A"); + repo.branch("A", &repo.find_commit(oid_a).unwrap(), false) + .unwrap(); + + let oid_b = create_commit(&repo, "b.txt", "b", "B"); + repo.branch("B", &repo.find_commit(oid_b).unwrap(), false) + .unwrap(); + + let git = RealGit::new(path).unwrap(); + + // Let's use the amended message to get the heuristic parent B into A. + run_git(path, &["checkout", "-f", "A"]); + run_git(path, &["commit", "--amend", "-m", "B"]); // Set summary to "B" to match branch B + + // Reload branches + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + ..AppState::default() + }; + state.refresh_tree(None); + + // To get a cycle, we need B.parent = A in state.branches. + // Since we amended A, B's parent OID (old A) is gone, so B.parent will walk up to main. + // We manually force it back to A for the test. + state.mutate_intent("B", |i| { + i.parent = Some(Some("A".to_string())); + }); + state.refresh_tree(None); + + let a_pos = state + .flattened_tree + .iter() + .position(|(n, _)| n == "A") + .expect("A missing"); + state.list_state.select(Some(a_pos)); + + // A has heuristic parent B. + // B has parent A. + // Pressing 'u' on A tries to set A.parent = B. CYCLE! + + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('u'), + KeyModifiers::NONE, + ))); + + // Verify it DID NOT sync + assert_ne!( + state.get_effective_parent("A").as_deref(), + Some("B"), + "Should not have synced A to B because B is its child" + ); + + // Verify it remains parented to its topological parent (main) + assert_eq!( + state.get_effective_parent("A").as_deref(), + Some(main.as_str()), + "A should have remained child of main" + ); +} + +#[test] +fn test_heuristic_sync_by_commit_summary() { + let (dir, repo, main) = setup_repo(); + let path = dir.path(); + let main_oid = repo.head().unwrap().target().unwrap(); + + repo.set_head_detached(main_oid).unwrap(); + + // 1. Setup main -> feat1 (parent) -> feat2 (child) + let summary1 = "feat1 unique summary"; + let oid1 = create_commit(&repo, "f1.txt", "1", summary1); + repo.branch("feat1", &repo.find_commit(oid1).unwrap(), false) + .unwrap(); + + let oid2 = create_commit(&repo, "f2.txt", "2", "feat2 summary"); + repo.branch("feat2", &repo.find_commit(oid2).unwrap(), false) + .unwrap(); + + // 2. Amend feat1 (change OID, keep summary) + run_git(path, &["checkout", "-f", "feat1"]); + std::fs::write(path.join("f1.txt"), "1 amended").unwrap(); + run_git(path, &["add", "f1.txt"]); + run_git(path, &["commit", "--amend", "-m", summary1]); + + // Now feat2's parent is the OLD feat1 commit (which is no longer a branch head). + // feat1 (branch) now points to a NEW commit with the same summary. + + let git = RealGit::new(path).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + ..AppState::default() + }; + state.refresh_tree(None); + + // Initial state: feat2 should be under main (since its parent OID is lost) + assert_eq!( + state.get_effective_parent("feat2").as_ref().unwrap(), + &main, + "feat2 should have fallen back to main" + ); + + // We expect the heuristic to identify "feat1" as the intended parent + // because feat2's history contains a commit with the same summary as feat1's current head. + let feat2_info = state + .branches + .iter() + .find(|b| b.name == "feat2") + .expect("feat2 missing"); + assert_eq!( + feat2_info.heuristic_parent.as_deref(), + Some("feat1"), + "Should have found feat1 via commit summary matching" + ); + + // 3. Simulate pressing 'u' on feat2 + let feat2_pos = state + .flattened_tree + .iter() + .position(|(n, _)| n == "feat2") + .expect("feat2 missing from tree"); + state.list_state.select(Some(feat2_pos)); + + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('u'), + KeyModifiers::NONE, + ))); + + // 4. Verify feat2 is now parented to feat1 + assert_eq!( + state.get_effective_parent("feat2").as_deref(), + Some("feat1"), + "feat2 should have synced back to feat1 via summary identity" + ); +} + +#[test] +fn test_heuristic_ambiguous_summary() { + let (dir, repo, _main) = setup_repo(); + let path = dir.path(); + + // 1. Create a base branch with a unique summary + let summary = "Unique Shared Summary"; + let oid_base = create_commit(&repo, "base.txt", "base", "Base Commit"); + repo.branch("base-branch", &repo.find_commit(oid_base).unwrap(), false) + .unwrap(); + + repo.set_head_detached(oid_base).unwrap(); + let oid1 = create_commit(&repo, "1.txt", "1", summary); + repo.branch("feat1", &repo.find_commit(oid1).unwrap(), false) + .unwrap(); + + let oid2 = create_commit(&repo, "2.txt", "2", summary); + repo.branch("feat2", &repo.find_commit(oid2).unwrap(), false) + .unwrap(); + + // 2. Create a child branch whose history contains oid1 + repo.set_head_detached(oid1).unwrap(); + let oid3 = create_commit(&repo, "3.txt", "3", "feat3 head"); + repo.branch("feat3", &repo.find_commit(oid3).unwrap(), false) + .unwrap(); + + // 3. Amend feat1 and feat2 so they point to new commits but keep the same summaries + run_git(path, &["checkout", "feat1"]); + create_commit(&repo, "1a.txt", "1a", summary); + + run_git(path, &["checkout", "feat2"]); + create_commit(&repo, "2a.txt", "2a", summary); + + let git = RealGit::new(path).unwrap(); + let (branches, _) = git.get_branches(None).unwrap(); + let feat3 = branches.iter().find(|b| b.name == "feat3").unwrap(); + + // Now feat3 is diverged. Its parent is oid1, which is no longer a branch tip. + // But oid1 has "Unique Shared Summary", which matches feat1 and feat2's current tips. + assert!( + feat3.heuristic_parent.is_some(), + "Should have found a heuristic parent for feat3. Found: {:?}", + feat3.heuristic_parent + ); + let hp = feat3.heuristic_parent.as_ref().unwrap(); + assert!( + hp == "feat1" || hp == "feat2", + "Heuristic parent should be feat1 or feat2, got: {}", + hp + ); +} + +#[test] +fn test_heuristic_summary_match_in_history() { + let (dir, repo, _main) = setup_repo(); + let path = dir.path(); + + // 1. Create a branch "parent-br" + let oid_p = create_commit(&repo, "p.txt", "p", "Parent Summary"); + repo.branch("parent-br", &repo.find_commit(oid_p).unwrap(), false) + .unwrap(); + + // 2. Commit with same summary in history of another branch + repo.set_head_detached( + repo.find_branch("main", git2::BranchType::Local) + .unwrap() + .get() + .target() + .unwrap(), + ) + .unwrap(); + let _oid_a = create_commit(&repo, "a.txt", "a", "Parent Summary"); + + // 3. Commit without matching summary on top + let oid_b = create_commit(&repo, "b.txt", "b", "Feature B"); + repo.branch("feat-b", &repo.find_commit(oid_b).unwrap(), false) + .unwrap(); + + // Amend parent-br to make it diverged + run_git(path, &["checkout", "parent-br"]); + create_commit(&repo, "p2.txt", "p2", "Parent Summary"); + + let git = RealGit::new(path).unwrap(); + let (branches, _) = git.get_branches(None).unwrap(); + let b = branches.iter().find(|b| b.name == "feat-b").unwrap(); + + // Summary match in history should be found + assert_eq!(b.heuristic_parent.as_deref(), Some("parent-br")); +} + +#[test] +fn test_heuristic_precedence_local_vs_remote() { + let (dir, repo, main) = setup_repo(); + let path = dir.path(); + + // Detach HEAD so master doesn't move + repo.set_head_detached(repo.head().unwrap().target().unwrap()) + .unwrap(); + + let shared_summary = "Shared Summary"; + + // 1. Create remote branch 'origin/feat' at OID 1 + let oid1 = create_commit(&repo, "r.txt", "remote", shared_summary); + repo.branch("origin/feat", &repo.find_commit(oid1).unwrap(), false) + .unwrap(); + + // 2. Create local branch 'feat' at OID 2 (different OID, same summary) + repo.set_head_detached( + repo.find_branch(&main, git2::BranchType::Local) + .unwrap() + .get() + .target() + .unwrap(), + ) + .unwrap(); + let oid2 = create_commit(&repo, "l.txt", "local", shared_summary); + repo.branch("feat", &repo.find_commit(oid2).unwrap(), false) + .unwrap(); + + // 3. Create 'child' branch whose history contains a commit with 'shared_summary' + repo.set_head_detached(oid1).unwrap(); // based on remote for test diversity + let oid_c = create_commit(&repo, "c.txt", "child", "Child Commit"); + repo.branch("child", &repo.find_commit(oid_c).unwrap(), false) + .unwrap(); + + // Now amend origin/feat and feat to diverge + run_git(path, &["checkout", "origin/feat"]); + create_commit(&repo, "r2.txt", "r2", shared_summary); + run_git(path, &["checkout", "feat"]); + create_commit(&repo, "l2.txt", "l2", shared_summary); + + let git = RealGit::new(path).unwrap(); + // We must show remote to have origin/feat in summary_to_branch + let (branches, _) = git.get_branches(None).unwrap(); + + let child = branches + .iter() + .find(|b| b.name == "child") + .expect("child missing"); + + // We want the LOCAL 'feat' to win the summary collision in summary_to_branch map. + assert_eq!( + child.heuristic_parent.as_deref(), + Some("feat"), + "Local branch should take precedence over remote for heuristic parent" + ); +} + +#[test] +fn test_heuristic_diverged_at_head() { + let (dir, repo, main) = setup_repo(); + let path = dir.path(); + + // Detach HEAD so master doesn't move + repo.set_head_detached(repo.head().unwrap().target().unwrap()) + .unwrap(); + + let shared_summary = "Diverged Summary"; + + // 1. Create branch 'feat-a' at OID 1 + let oid1 = create_commit(&repo, "a.txt", "a", shared_summary); + repo.branch("feat-a", &repo.find_commit(oid1).unwrap(), false) + .unwrap(); + + // 2. Create branch 'feat-b' at OID 2 (different OID, same summary), also from master + repo.set_head_detached( + repo.find_branch(&main, git2::BranchType::Local) + .unwrap() + .get() + .target() + .unwrap(), + ) + .unwrap(); + let oid2 = create_commit(&repo, "b.txt", "b", shared_summary); + repo.branch("feat-b", &repo.find_commit(oid2).unwrap(), false) + .unwrap(); + + let git = RealGit::new(path).unwrap(); + let (branches, _) = git.get_branches(None).unwrap(); + + let b = branches + .iter() + .find(|b| b.name == "feat-b") + .expect("feat-b missing"); + + // Even if it's just one commit, it should find feat-a (or vice-versa) + assert!( + b.heuristic_parent.is_some(), + "Should find a heuristic parent even if it's the tip commit" + ); + let hp = b.heuristic_parent.as_ref().unwrap(); + assert!( + hp == "feat-a" || hp == "main", + "Heuristic parent should be feat-a (or main if summaries collide)" + ); + // The summaries differ ("Diverged Summary" vs "Initial commit"), so the heuristic must deterministically pick feat-a. + assert_eq!(hp, "feat-a"); +} + +// end of file diff --git a/tools/gitui/test/misc_integration_tests.rs b/tools/gitui/test/misc_integration_tests.rs new file mode 100644 index 0000000..26a8af1 --- /dev/null +++ b/tools/gitui/test/misc_integration_tests.rs @@ -0,0 +1,275 @@ +use git2::BranchType; +use gitui::engine::{BranchIntent, Git, Intent, RealGit, build_topology, flatten_branches}; +use gitui::testing::{create_commit, run_git, setup_repo}; +use std::collections::HashMap; +use std::fs::File; +use std::io::Write; + +#[test] +fn test_integration_large_complex_tree() { + let (dir, repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // Create a deep chain + let mut last_oid = repo.head().unwrap().target().unwrap(); + for i in 1..=20 { + let branch_name = format!("chain-{}", i); + repo.set_head_detached(last_oid).unwrap(); + last_oid = create_commit( + &repo, + &format!("f{}.txt", i), + "content", + &format!("commit {}", i), + ); + repo.branch(&branch_name, &repo.find_commit(last_oid).unwrap(), false) + .unwrap(); + } + + // Create many side branches at different levels + for i in (1..=20).step_by(2) { + let parent_branch = format!("chain-{}", i); + let parent_oid = repo + .find_branch(&parent_branch, BranchType::Local) + .unwrap() + .get() + .target() + .unwrap(); + for j in 1..=5 { + let branch_name = format!("side-{}-{}", i, j); + repo.set_head_detached(parent_oid).unwrap(); + let side_oid = create_commit( + &repo, + &format!("s{}-{}.txt", i, j), + "content", + &format!("side commit {}-{}", i, j), + ); + repo.branch(&branch_name, &repo.find_commit(side_oid).unwrap(), false) + .unwrap(); + } + } + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let intents = HashMap::new(); + + assert!(branches.len() >= 70); + let flattened = flatten_branches(&branches, &intents, &history, false).unwrap(); + assert_eq!(flattened.len(), branches.len()); + + let chain_20_depth = flattened + .iter() + .find(|(n, _)| n == "chain-20") + .map(|(_, d)| *d) + .unwrap(); + assert_eq!(chain_20_depth, 20); +} + +#[test] +fn test_integration_commit_bridge() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + let c1 = create_commit(&repo, "1.txt", "1", "c1"); + let c2 = create_commit(&repo, "2.txt", "2", "c2"); + + repo.branch("feature", &repo.find_commit(c2).unwrap(), false) + .unwrap(); + + repo.set_head_detached(c1).unwrap(); + repo.branch(&master, &repo.find_commit(c1).unwrap(), true) + .unwrap(); + repo.set_head(&format!("refs/heads/{}", master)).unwrap(); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + + intents.insert( + "feature".to_string(), + BranchIntent { + parent: Some(Some(master.clone())), + ..Default::default() + }, + ); + + let flattened = flatten_branches(&branches, &intents, &history, false).unwrap(); + let feature_depth = flattened + .iter() + .find(|(n, _)| n == "feature") + .map(|(_, d)| *d) + .unwrap(); + assert_eq!(feature_depth, 1); +} + +#[test] +fn test_integration_cycle_prevention_on_real_repo() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + create_commit(&repo, "f1.txt", "1", "c1"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + run_git(path_str, &["checkout", "feat1"]); + create_commit(&repo, "f2.txt", "2", "c2"); + repo.branch( + "feat2", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let intents = HashMap::new(); + + let mut topo = build_topology(&branches, &intents, &history, false).unwrap(); + let res = topo.set_parent(&master, Some("feat2"), None, Intent::Structural); + + assert!(res.is_err()); + assert!(res.unwrap_err().to_string().contains("cycle")); +} + +#[test] +fn test_integration_is_dirty_ignores_submodules() { + let (dir, _repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + let sub_dir = tempfile::tempdir().unwrap(); + run_git(sub_dir.path().to_str().unwrap(), &["init"]); + File::create(sub_dir.path().join("sub.txt")) + .unwrap() + .write_all(b"sub content") + .unwrap(); + run_git(sub_dir.path().to_str().unwrap(), &["add", "sub.txt"]); + run_git( + sub_dir.path().to_str().unwrap(), + &["commit", "-m", "Sub commit"], + ); + + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args([ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + sub_dir.path().to_str().unwrap(), + "mysub", + ]) + .status() + .unwrap(); + + run_git(path_str, &["commit", "-m", "Add submodule"]); + + let git = RealGit::new(path_str).unwrap(); + assert!(!git.is_dirty().unwrap()); + + File::create(dir.path().join("mysub").join("sub.txt")) + .unwrap() + .write_all(b"modified sub content") + .unwrap(); + + assert!(!git.is_dirty().unwrap()); + + File::create(dir.path().join("dirty.txt")) + .unwrap() + .write_all(b"dirty") + .unwrap(); + run_git(path_str, &["add", "dirty.txt"]); + assert!(git.is_dirty().unwrap()); +} + +#[test] +fn test_integration_commit_log_stopping() { + let (dir, repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + run_git(path_str, &["checkout", "--detach"]); + + let c1 = create_commit(&repo, "1.txt", "1", "Commit 1"); + repo.branch("feat1", &repo.find_commit(c1).unwrap(), false) + .unwrap(); + + let _c2 = create_commit(&repo, "2.txt", "2", "Commit 2"); + let c3 = create_commit(&repo, "3.txt", "3", "Commit 3"); + repo.branch("feat2", &repo.find_commit(c3).unwrap(), false) + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + let logs = git.get_commit_log("feat2").unwrap(); + + assert_eq!(logs.len(), 2); + assert!(logs[0].summary.contains("Commit 3")); + assert!(logs[1].summary.contains("Commit 2")); + assert!(!logs.iter().any(|l| l.summary.contains("Commit 1"))); + + let logs1 = git.get_commit_log("feat1").unwrap(); + assert_eq!(logs1.len(), 1); + assert!(logs1[0].summary.contains("Commit 1")); +} + +#[test] +fn test_integration_commit_log_stack_uniqueness() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + run_git(path_str, &["checkout", "--detach"]); + + let c1 = create_commit(&repo, "f1.txt", "1", "Commit 1 (feat1)"); + repo.branch("feat1", &repo.find_commit(c1).unwrap(), false) + .unwrap(); + + let c2 = create_commit(&repo, "f2.txt", "2", "Commit 2 (feat2)"); + repo.branch("feat2", &repo.find_commit(c2).unwrap(), false) + .unwrap(); + + let master_oid = repo + .find_branch(&master, BranchType::Local) + .unwrap() + .get() + .target() + .unwrap(); + run_git(path_str, &["checkout", "--detach", &master_oid.to_string()]); + let s1 = create_commit(&repo, "s1.txt", "s", "Side commit 1"); + repo.branch("side1", &repo.find_commit(s1).unwrap(), false) + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + + let logs_f1 = git.get_commit_log("feat1").unwrap(); + assert_eq!(logs_f1.len(), 1); + assert!(logs_f1[0].summary.contains("Commit 1 (feat1)")); + + let logs_f2 = git.get_commit_log("feat2").unwrap(); + assert_eq!(logs_f2.len(), 1); + assert!(logs_f2[0].summary.contains("Commit 2 (feat2)")); + + repo.branch("feat-empty", &repo.find_commit(c2).unwrap(), false) + .unwrap(); + let logs_empty = git.get_commit_log("feat-empty").unwrap(); + assert_eq!(logs_empty.len(), 0); +} + +#[test] +fn test_get_branches_progress() { + let (dir, _repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + let git = RealGit::new(path_str).unwrap(); + + let progress_calls = std::sync::Mutex::new(Vec::new()); + let progress = |msg: String, pct: f64| { + progress_calls.lock().unwrap().push((msg, pct)); + }; + + let _ = git.get_branches(Some(&progress)).unwrap(); + + let calls = progress_calls.lock().unwrap(); + assert!(!calls.is_empty()); + assert!(calls.iter().any(|(m, _)| m.contains("Collecting"))); +} diff --git a/tools/gitui/test/move_integration_tests.rs b/tools/gitui/test/move_integration_tests.rs new file mode 100644 index 0000000..74be967 --- /dev/null +++ b/tools/gitui/test/move_integration_tests.rs @@ -0,0 +1,167 @@ +use gitui::engine::{BranchIntent, Git, RealGit, apply_move}; +use gitui::execute_plan; +use gitui::testing::{create_commit, run_git, setup_repo}; +use std::collections::HashMap; + +#[test] +fn test_integration_move_and_execute() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // Create feat1 + create_commit(&repo, "f1.txt", "f1", "Add f1"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // Create feat2 from master (sibling of feat1) + run_git(path_str, &["checkout", &master]); + create_commit(&repo, "f2.txt", "f2", "Add f2"); + repo.branch( + "feat2", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + + // Move feat2 to be child of feat1 + intents.insert( + "feat2".to_string(), + BranchIntent { + parent: Some(Some("feat1".to_string())), + ..Default::default() + }, + ); + + execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // Verify feat1 is now ancestor of feat2 + let feat1_oid = run_git(path_str, &["rev-parse", "feat1"]); + let merge_base = run_git(path_str, &["merge-base", "feat2", "feat1"]); + assert_eq!(merge_base, feat1_oid); +} + +#[test] +fn test_integration_complex_move() { + let (dir, repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + let initial_oid = run_git(path_str, &["rev-parse", "HEAD"]); + + // 1. Setup feat1 -> feat2 + run_git(path_str, &["checkout", &initial_oid]); + create_commit(&repo, "f1.txt", "f1", "Add f1"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + run_git(path_str, &["checkout", "feat1"]); + create_commit(&repo, "f2.txt", "f2", "Add f2"); + repo.branch( + "feat2", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 2. Setup target-base + run_git(path_str, &["checkout", &initial_oid]); + create_commit(&repo, "t.txt", "t", "Add target"); + repo.branch( + "target-base", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 3. Move feat1 (and thus feat2) to target-base + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + + apply_move(&mut intents, "feat1", Some("target-base".to_string())).unwrap(); + + execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // 4. Verify feat1 is on target-base + let target_oid = run_git(path_str, &["rev-parse", "target-base"]); + assert_eq!( + run_git(path_str, &["merge-base", "feat1", "target-base"]), + target_oid + ); + + // 5. Verify feat2 is on feat1 + let feat1_oid = run_git(path_str, &["rev-parse", "feat1"]); + assert_eq!( + run_git(path_str, &["merge-base", "feat2", "feat1"]), + feat1_oid + ); +} + +#[test] +fn test_integration_reparent_across_subtrees() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + let initial_oid = run_git(path_str, &["rev-parse", "HEAD"]); + + // 1. feat1 -> feat2 + run_git(path_str, &["checkout", &initial_oid]); + create_commit(&repo, "f1.txt", "f1", "Add f1"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + run_git(path_str, &["checkout", "feat1"]); + create_commit(&repo, "f2.txt", "f2", "Add f2"); + repo.branch( + "feat2", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 2. feat3 (sibling of feat1) + run_git(path_str, &["checkout", &initial_oid]); + create_commit(&repo, "f3.txt", "f3", "Add f3"); + repo.branch( + "feat3", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 3. Move feat2 to be child of feat3 + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + + apply_move(&mut intents, "feat2", Some("feat3".to_string())).unwrap(); + + execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // 4. Verify feat2 is now child of feat3 + let feat3_oid = run_git(path_str, &["rev-parse", "feat3"]); + assert_eq!( + run_git(path_str, &["merge-base", "feat2", "feat3"]), + feat3_oid + ); + + // 5. Verify feat1 remains as it was + let master_oid = run_git(path_str, &["rev-parse", &master]); + assert_eq!( + run_git(path_str, &["merge-base", "feat1", &master]), + master_oid + ); +} diff --git a/tools/gitui/test/patch_utils_tests.rs b/tools/gitui/test/patch_utils_tests.rs new file mode 100644 index 0000000..51164e3 --- /dev/null +++ b/tools/gitui/test/patch_utils_tests.rs @@ -0,0 +1,83 @@ +use git2::Signature; +use gitui::patch_utils::apply_selected_hunks_to_tree; +use gitui::testing::setup_repo; +use std::collections::HashSet; +use std::fs::File; +use std::io::Write; + +#[test] +fn test_apply_selected_hunks() { + let (tmp_dir, repo, _branch) = setup_repo(); + let file_path = tmp_dir.path().join("test.txt"); + + // 1. Initial commit + let mut content = String::new(); + for i in 1..=20 { + content.push_str(&format!("line{}\n", i)); + } + File::create(&file_path) + .unwrap() + .write_all(content.as_bytes()) + .unwrap(); + + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("test.txt")).unwrap(); + let oid = index.write_tree().unwrap(); + let signature = Signature::now("Test User", "test@example.com").unwrap(); + let parent_tree = repo.find_tree(oid).unwrap(); + let parent_commit = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit( + Some("HEAD"), + &signature, + &signature, + "Initial commit", + &parent_tree, + &[&parent_commit], + ) + .unwrap(); + + // 2. Modify in two places + let mut new_content = String::new(); + for i in 1..=20 { + if i == 5 { + new_content.push_str("line5 changed\n"); + } else if i == 15 { + new_content.push_str("line15 changed\n"); + } else { + new_content.push_str(&format!("line{}\n", i)); + } + } + File::create(&file_path) + .unwrap() + .write_all(new_content.as_bytes()) + .unwrap(); + + // 3. Get diff + let diff = repo + .diff_tree_to_workdir_with_index(Some(&parent_tree), None) + .unwrap(); + + // 4. Select ONLY the first hunk (line 5) + let mut selected = HashSet::new(); + selected.insert(("test.txt".to_string(), 0)); + + let patch_data = gitui::patch_utils::create_filtered_diff(&diff, &selected).unwrap(); + println!("Patch data:\n{}", std::str::from_utf8(&patch_data).unwrap()); + let new_tree_oid = apply_selected_hunks_to_tree(&repo, &parent_tree, &diff, &selected).unwrap(); + let new_tree = repo.find_tree(new_tree_oid).unwrap(); + + // 5. Verify the new tree has line 5 changed but NOT line 15 + let obj = new_tree + .get_path(std::path::Path::new("test.txt")) + .unwrap() + .to_object(&repo) + .unwrap(); + let blob = obj.as_blob().unwrap(); + let content = std::str::from_utf8(blob.content()).unwrap(); + + assert!(content.contains("line5 changed")); + assert!(content.contains("line15\n")); + assert!(!content.contains("line15 changed")); +} + +// end of file diff --git a/tools/gitui/test/plan_proptests.rs b/tools/gitui/test/plan_proptests.rs new file mode 100644 index 0000000..4b8bd4e --- /dev/null +++ b/tools/gitui/test/plan_proptests.rs @@ -0,0 +1,208 @@ +use git2::Oid; +use gitui::engine::{ + BranchInfo, BranchIntent, HistoryContext, Operation, RepositorySnapshot, calculate_plan, +}; +use gitui::testing::mock_oid; +use proptest::prelude::*; +use std::collections::{HashMap, HashSet}; + +fn make_branch(name: &str, oid: Oid, parent: Option<&str>) -> BranchInfo { + BranchInfo { + name: name.to_string(), + oid, + original_parent: parent.map(|s| s.to_string()), + ..Default::default() + } +} + +/// Strategy for a linear chain of branches. +/// Returns (branches, history) +fn arb_linear_chain(n: usize) -> impl Strategy, HistoryContext)> { + Just(n).prop_map(|n| { + let mut branches = Vec::new(); + let mut history = HistoryContext::new(); + + // Root: master + branches.push(BranchInfo { + name: "master".to_string(), + oid: mock_oid(0), + ..Default::default() + }); + history + .oid_to_visible_branch + .insert(mock_oid(0), "master".to_string()); + history.oid_to_ancestor.insert(mock_oid(0), None); + + for i in 1..=n { + let name = format!("feat{}", i); + let oid = mock_oid(i as u8); + let parent_name = if i == 1 { + "master".to_string() + } else { + format!("feat{}", i - 1) + }; + let parent_oid = mock_oid((i - 1) as u8); + + branches.push(BranchInfo { + name: name.clone(), + oid, + original_parent: Some(parent_name.clone()), + ..Default::default() + }); + + history.oid_to_visible_branch.insert(oid, name); + history.oid_to_ancestor.insert(oid, Some(parent_oid)); + } + + (branches, history) + }) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(50))] + + #[test] + fn test_plan_no_op_identity( + data in arb_linear_chain(10) + ) { + let (branches, history) = data; + let intents = HashMap::new(); + let snapshot = RepositorySnapshot { branches, history, is_dirty: false }; + let plan = calculate_plan(&snapshot, &intents).unwrap(); + prop_assert!(plan.is_empty(), "Plan should be empty when no changes are made. Got: {:?}", plan); + } + + #[test] + fn test_plan_transitive_rebase_completeness( + data in arb_linear_chain(5) + ) { + let (branches, history) = data; + let mut intents = HashMap::new(); + // Mark the first feature branch for amend + intents.insert("feat1".to_string(), BranchIntent { + pending_amend: true, + ..Default::default() + }); + + let snapshot = RepositorySnapshot { branches, history, is_dirty: false }; + let plan = calculate_plan(&snapshot, &intents).unwrap(); + + // Property: feat1 is amended, and all its descendants (feat2..5) must be rebased + prop_assert!(plan.iter().any(|op| matches!(op, Operation::Amend { .. })), "feat1 should be amended"); + + let expected_rebased = vec!["feat2", "feat3", "feat4", "feat5"]; + let actual_rebased: HashSet<_> = plan.iter().filter_map(|op| { + if let Operation::Rebase { branch, .. } = op { + Some(branch.as_str()) + } else { + None + } + }).collect(); + + for name in expected_rebased { + prop_assert!(actual_rebased.contains(name), "Branch '{}' was not rebased despite its ancestor being amended", name); + } + } + + #[test] + fn test_plan_topological_order( + data in arb_linear_chain(5) + ) { + let (mut branches, history) = data; + // Move feat1 to master2 + branches.push(BranchInfo { + name: "master2".to_string(), + oid: mock_oid(100), + ..Default::default() + }); + + let mut intents = HashMap::new(); + intents.insert("feat1".to_string(), BranchIntent { + parent: Some(Some("master2".to_string())), + ..Default::default() + }); + + let snapshot = RepositorySnapshot { branches, history, is_dirty: false }; + let plan = calculate_plan(&snapshot, &intents).unwrap(); + + // Property: For any two rebases, if A is an ancestor of B, Rebase(A) must come before Rebase(B) + let rebase_indices: std::collections::HashMap<_, _> = plan.iter().enumerate().filter_map(|(i, op)| { + if let Operation::Rebase { branch, .. } = op { + Some((branch.as_str(), i)) + } else { + None + } + }).collect(); + + for i in 1..5 { + let parent = format!("feat{}", i); + let child = format!("feat{}", i + 1); + + if let (Some(&p_idx), Some(&c_idx)) = (rebase_indices.get(parent.as_str()), rebase_indices.get(child.as_str())) { + prop_assert!(p_idx < c_idx, "Parent rebase of '{}' (idx {}) must come before child rebase of '{}' (idx {})", parent, p_idx, child, c_idx); + } + } + } + + #[test] + fn test_plan_parallel_subtree_rebases( + _ in 0..1u32 + ) { + let mut branches = Vec::new(); + let mut history = HistoryContext::new(); + + let oid_m = mock_oid(0); + let oid_f1 = mock_oid(1); + let oid_f1c = mock_oid(2); + let oid_f2 = mock_oid(3); + let oid_f2c = mock_oid(4); + let oid_o1 = mock_oid(10); + let oid_o2 = mock_oid(20); + + branches.push(make_branch("master", oid_m, None)); + branches.push(make_branch("feat1", oid_f1, Some("master"))); + branches.push(make_branch("feat1_child", oid_f1c, Some("feat1"))); + branches.push(make_branch("feat2", oid_f2, Some("master"))); + branches.push(make_branch("feat2_child", oid_f2c, Some("feat2"))); + branches.push(make_branch("other1", oid_o1, None)); + branches.push(make_branch("other2", oid_o2, None)); + + history.oid_to_visible_branch.insert(oid_m, "master".to_string()); + history.oid_to_visible_branch.insert(oid_f1, "feat1".to_string()); + history.oid_to_visible_branch.insert(oid_f1c, "feat1_child".to_string()); + history.oid_to_visible_branch.insert(oid_f2, "feat2".to_string()); + history.oid_to_visible_branch.insert(oid_f2c, "feat2_child".to_string()); + history.oid_to_visible_branch.insert(oid_o1, "other1".to_string()); + history.oid_to_visible_branch.insert(oid_o2, "other2".to_string()); + + history.oid_to_ancestor.insert(oid_f1, Some(oid_m)); + history.oid_to_ancestor.insert(oid_f1c, Some(oid_f1)); + history.oid_to_ancestor.insert(oid_f2, Some(oid_m)); + history.oid_to_ancestor.insert(oid_f2c, Some(oid_f2)); + + // Move feat1 to other1, feat2 to other2 + let mut intents = HashMap::new(); + intents.insert("feat1".to_string(), BranchIntent { + parent: Some(Some("other1".to_string())), + ..Default::default() + }); + intents.insert("feat2".to_string(), BranchIntent { + parent: Some(Some("other2".to_string())), + ..Default::default() + }); + + let snapshot = RepositorySnapshot { branches, history, is_dirty: false }; + let plan = calculate_plan(&snapshot, &intents).unwrap(); + + let rebased: HashSet<_> = plan.iter().filter_map(|op| { + if let Operation::Rebase { branch, .. } = op { Some(branch.as_str()) } else { None } + }).collect(); + + prop_assert!(rebased.contains("feat1")); + prop_assert!(rebased.contains("feat1_child")); + prop_assert!(rebased.contains("feat2")); + prop_assert!(rebased.contains("feat2_child")); + } +} + +// end of file diff --git a/tools/gitui/test/planner_logic_tests.rs b/tools/gitui/test/planner_logic_tests.rs new file mode 100644 index 0000000..7b589c6 --- /dev/null +++ b/tools/gitui/test/planner_logic_tests.rs @@ -0,0 +1,230 @@ +use gitui::engine::{ + BranchInfo, BranchIntent, HistoryContext, Operation, RepositorySnapshot, calculate_plan, +}; +use gitui::testing::mock_oid; +use std::collections::HashMap; + +struct TestRepo { + branches: HashMap, + history: HistoryContext, + intents: HashMap, + is_dirty: bool, +} + +impl TestRepo { + fn new() -> Self { + Self { + branches: HashMap::new(), + history: HistoryContext::new(), + intents: HashMap::new(), + is_dirty: false, + } + } + + fn branch(mut self, name: &str, oid_val: u8) -> Self { + let oid = mock_oid(oid_val); + let mut info = BranchInfo { + name: name.to_string(), + oid, + is_local: !name.starts_with("origin/"), + is_remote: name.starts_with("origin/"), + ..Default::default() + }; + if name == "master" || name == "main" { + info.is_local = true; + } + self.branches.insert(name.to_string(), info); + self.history + .oid_to_visible_branch + .insert(oid, name.to_string()); + self + } + + fn parent(mut self, child: &str, parent: &str) -> Self { + let p_oid = self.branches.get(parent).map(|b| b.oid); + let c_oid = self.branches.get(child).map(|b| b.oid).unwrap(); + + if let Some(mut b) = self.branches.remove(child) { + b.original_parent = Some(parent.to_string()); + self.branches.insert(child.to_string(), b); + } + + self.history.oid_to_ancestor.insert(c_oid, p_oid); + self + } + + fn heuristic(mut self, branch: &str, h_parent: &str, h_upstream_oid_val: u8) -> Self { + if let Some(mut b) = self.branches.remove(branch) { + b.heuristic_parent = Some(h_parent.to_string()); + b.heuristic_upstream_oid = Some(mock_oid(h_upstream_oid_val)); + self.branches.insert(branch.to_string(), b); + } + self + } + + fn intent_move(mut self, branch: &str, new_parent: Option<&str>) -> Self { + let is_remote = self + .branches + .get(branch) + .map(|b| b.is_remote) + .unwrap_or(false); + let intent = self.intents.entry(branch.to_string()).or_default(); + intent.parent = Some(new_parent.map(|s| s.to_string())); + if is_remote { + intent.pending_localize = true; + } + self + } + + fn intent_converge(mut self, branch: &str) -> Self { + let h_parent = self + .branches + .get(branch) + .and_then(|b| b.heuristic_parent.clone()); + let is_remote = self + .branches + .get(branch) + .map(|b| b.is_remote) + .unwrap_or(false); + + let intent = self.intents.entry(branch.to_string()).or_default(); + intent.parent = Some(h_parent); + if is_remote { + intent.pending_localize = true; + } + self + } + + fn plan(&self) -> anyhow::Result> { + let snapshot = RepositorySnapshot { + branches: self.branches.values().cloned().collect(), + history: self.history.clone(), + is_dirty: self.is_dirty, + }; + calculate_plan(&snapshot, &self.intents) + } +} + +#[test] +fn test_plan_simple_rebase() { + let plan = TestRepo::new() + .branch("master", 1) + .branch("feat", 2) + .parent("feat", "master") + .branch("new-base", 3) + .intent_move("feat", Some("new-base")) + .plan() + .unwrap(); + + assert!(plan.iter().any(|op| matches!(op, Operation::Rebase { branch, onto, .. } if branch == "feat" && onto == "new-base"))); +} + +#[test] +fn test_plan_transitive_rebase() { + // A -> B -> C + // Move A to master. Both B and C should rebase. + let plan = TestRepo::new() + .branch("master", 1) + .branch("A", 2) + .parent("A", "master") + .branch("B", 3) + .parent("B", "A") + .branch("C", 4) + .parent("C", "B") + .branch("new-root", 5) + .intent_move("A", Some("new-root")) + .plan() + .unwrap(); + + let rebased_branches: Vec<_> = plan + .iter() + .filter_map(|op| match op { + Operation::Rebase { branch, .. } => Some(branch.as_str()), + _ => None, + }) + .collect(); + + assert!(rebased_branches.contains(&"A")); + assert!(rebased_branches.contains(&"B")); + assert!(rebased_branches.contains(&"C")); +} + +#[test] +fn test_plan_converge_no_op_when_already_there() { + // master (1) -> feat (2) -> subfeat (3) + // subfeat heuristic says it belongs on feat (2) + // The branch is already at the target location, so the plan should be empty. + let plan = TestRepo::new() + .branch("master", 1) + .branch("feat", 2) + .parent("feat", "master") + .branch("subfeat", 3) + .parent("subfeat", "feat") + .heuristic("subfeat", "feat", 2) + .intent_converge("subfeat") + .plan() + .unwrap(); + + assert!(plan.is_empty(), "Should be empty plan, but got: {:?}", plan); +} + +#[test] +fn test_plan_converge_cycle_prevention_error() { + // master (1) + // owner (2) -> head summary matches hacks (3) + // hacks (3) -> child of owner (2) + // Alphabetical tie-break makes hacks the heuristic representative. + // owner heuristic says it should be under hacks (3). + // hacks is already under owner (2). + // Moving owner under hacks would be a cycle. + // build_topology should return an Err. + + let res = TestRepo::new() + .branch("master", 1) + .branch("owner", 2) + .parent("owner", "master") + .branch("hacks", 3) + .parent("hacks", "owner") + .heuristic("owner", "hacks", 3) + .intent_converge("owner") + .plan(); + + assert!(res.is_err()); + assert!(format!("{:?}", res.unwrap_err()).contains("cycle")); +} + +#[test] +fn test_plan_rebase_to_different_heuristic_upstream() { + // master (1) + // feat (2) + // subfeat (3) -> currently on master (1), but summary matches feat (2) + let plan = TestRepo::new() + .branch("master", 1) + .branch("feat", 2) + .parent("feat", "master") + .branch("subfeat", 3) + .parent("subfeat", "master") + .heuristic("subfeat", "feat", 2) + .intent_converge("subfeat") + .plan() + .unwrap(); + + assert!(plan.iter().any(|op| matches!(op, Operation::Rebase { branch, onto, .. } if branch == "subfeat" && onto == "feat"))); +} + +#[test] +fn test_plan_localization_on_move() { + // Moving a remote branch should trigger localization + let plan = TestRepo::new() + .branch("master", 1) + .branch("origin/feat", 2) + .parent("origin/feat", "master") + .intent_move("origin/feat", Some("master")) // move it (even to same place) + .plan() + .unwrap(); + + assert!( + plan.iter() + .any(|op| matches!(op, Operation::Localize { branch } if branch == "origin/feat")) + ); +} diff --git a/tools/gitui/test/push_flow_integration_tests.rs b/tools/gitui/test/push_flow_integration_tests.rs new file mode 100644 index 0000000..01b49f9 --- /dev/null +++ b/tools/gitui/test/push_flow_integration_tests.rs @@ -0,0 +1,92 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use gitui::engine::{Git, RealGit}; +use gitui::execute_plan; +use gitui::state::{AppState, Effect, Msg}; +use gitui::testing::{create_commit, run_git, setup_repo}; + +#[tokio::test] +async fn test_push_flow_p_then_c() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup a "remote" + let remote_dir = tempfile::tempdir().unwrap(); + run_git(remote_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + remote_dir.path().to_str().unwrap(), + ], + ); + + // 2. Create a branch and a commit + create_commit(&repo, "push.txt", "push me", "Push commit"); + repo.branch( + "push-branch", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 3. Initialize AppState + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut state = AppState { + branches, + history, + initial_branch: master.clone(), + ..AppState::default() + }; + state.refresh_tree(None); + + // 4. Navigate to "push-branch" (it should be at index 1, index 0 is master) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + assert_eq!(state.get_selected_branch_name().unwrap(), "push-branch"); + + // 5. Press 'p' to toggle push + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('p'), + KeyModifiers::NONE, + ))); + assert!(state.get_intent("push-branch").pending_push); + + // 6. Press 'c' to commit/execute + let effects = state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::NONE, + ))); + + // 7. Verify ApplyAndQuit effect + let mut branches_to_exec = None; + let mut intents_to_exec = None; + for effect in effects { + if let Effect::ApplyAndQuit(b, i) = effect { + branches_to_exec = Some(b); + intents_to_exec = Some(i); + } + } + + let branches_to_exec = branches_to_exec.expect("ApplyAndQuit effect not found"); + let intents_to_exec = intents_to_exec.expect("ApplyAndQuit effect not found"); + assert!(intents_to_exec.get("push-branch").unwrap().pending_push); + + // 8. Execute the plan + execute_plan( + &git, + &branches_to_exec, + &intents_to_exec, + &state.history, + path_str, + ) + .unwrap(); + + // 9. Verify it was pushed to remote + let output = run_git(path_str, &["ls-remote", "origin", "push-branch"]); + assert!(!output.is_empty(), "Branch should exist on remote"); +} diff --git a/tools/gitui/test/rebase_integration_tests.rs b/tools/gitui/test/rebase_integration_tests.rs new file mode 100644 index 0000000..30e4e34 --- /dev/null +++ b/tools/gitui/test/rebase_integration_tests.rs @@ -0,0 +1,363 @@ +use git2::BranchType; +use gitui::engine::{BranchIntent, Git, RealGit}; +use gitui::execute_rebases; +use gitui::testing::{create_commit, run_git, setup_repo}; +use std::collections::HashMap; +use std::process::Command; + +#[test] +fn test_integration_simple_rebase() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + let initial_oid = repo.head().unwrap().target().unwrap(); + + // Create feat1 from initial commit + create_commit(&repo, "f1.txt", "f1 content", "Add feat1"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // Reset master to initial commit and create new-base from there + repo.set_head_detached(initial_oid).unwrap(); + Command::new("git") + .arg("-C") + .arg(path_str) + .args(["reset", "--hard", &initial_oid.to_string()]) + .status() + .unwrap(); + repo.branch(&master, &repo.find_commit(initial_oid).unwrap(), true) + .unwrap(); + repo.set_head(&format!("refs/heads/{}", master)).unwrap(); + + create_commit(&repo, "nb.txt", "nb content", "Add new-base"); + repo.branch( + "new-base", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + + intents.insert( + "feat1".to_string(), + BranchIntent { + parent: Some(Some("new-base".to_string())), + ..Default::default() + }, + ); + + execute_rebases(&git, &branches, &intents, &history, path_str).unwrap(); + + // After rebase, feat1 should have new-base as its parent in history + let _ = RealGit::new(path_str).unwrap().get_branches(None).unwrap(); + + let output = Command::new("git") + .arg("-C") + .arg(path_str) + .args(["merge-base", "feat1", "new-base"]) + .output() + .unwrap(); + let merge_base = String::from_utf8(output.stdout).unwrap().trim().to_string(); + let nb_oid = repo + .find_branch("new-base", BranchType::Local) + .unwrap() + .get() + .target() + .unwrap() + .to_string(); + + assert_eq!(merge_base, nb_oid, "feat1 should be rebased onto new-base"); +} + +#[test] +fn test_integration_recursive_rebase() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + let initial_oid = repo.head().unwrap().target().unwrap(); + + // Create feat1 -> feat2 from initial commit + create_commit(&repo, "f1.txt", "f1 content", "Add feat1"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + Command::new("git") + .arg("-C") + .arg(path_str) + .args(["checkout", "feat1"]) + .status() + .unwrap(); + create_commit(&repo, "f2.txt", "f2 content", "Add feat2"); + repo.branch( + "feat2", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // Reset master to initial commit and create new-base + repo.set_head_detached(initial_oid).unwrap(); + Command::new("git") + .arg("-C") + .arg(path_str) + .args(["reset", "--hard", &initial_oid.to_string()]) + .status() + .unwrap(); + repo.branch(&master, &repo.find_commit(initial_oid).unwrap(), true) + .unwrap(); + repo.set_head(&format!("refs/heads/{}", master)).unwrap(); + + create_commit(&repo, "nb.txt", "nb content", "Add new-base"); + repo.branch( + "new-base", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + + intents.insert( + "feat1".to_string(), + BranchIntent { + parent: Some(Some("new-base".to_string())), + ..Default::default() + }, + ); + + execute_rebases(&git, &branches, &intents, &history, path_str).unwrap(); + + // Verify both rebased + let nb_oid = repo + .find_branch("new-base", BranchType::Local) + .unwrap() + .get() + .target() + .unwrap() + .to_string(); + + let output1 = Command::new("git") + .arg("-C") + .arg(path_str) + .args(["merge-base", "feat1", "new-base"]) + .output() + .unwrap(); + assert_eq!(String::from_utf8(output1.stdout).unwrap().trim(), nb_oid); + + let output2 = Command::new("git") + .arg("-C") + .arg(path_str) + .args(["merge-base", "feat2", "new-base"]) + .output() + .unwrap(); + assert_eq!(String::from_utf8(output2.stdout).unwrap().trim(), nb_oid); +} + +#[test] +fn test_integration_reset_rebase() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Create feat1 + create_commit(&repo, "f1.txt", "f1", "Add f1"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 2. Create feat2 on top of feat1 + run_git(path_str, &["checkout", "feat1"]); + create_commit(&repo, "f2.txt", "f2", "Add f2"); + repo.branch( + "feat2", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 3. Create an "upstream" branch for feat1 + run_git(path_str, &["checkout", &master]); + let initial_oid = run_git(path_str, &["rev-parse", "HEAD"]); + create_commit(&repo, "u1.txt", "u1", "Add u1"); + let upstream_commit = repo.head().unwrap().peel_to_commit().unwrap(); + repo.branch("feat1-upstream", &upstream_commit, false) + .unwrap(); + let upstream_oid = upstream_commit.id().to_string(); + + // Reset master back to initial so feat1's parent remains master. + run_git(path_str, &["reset", "--hard", &initial_oid]); + + run_git( + path_str, + &["branch", "--set-upstream-to=feat1-upstream", "feat1"], + ); + + // 4. Mark feat1 for reset in our engine + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + + { + let feat1 = branches + .iter() + .find(|b| b.name == "feat1") + .expect("feat1 not found"); + // Ensure feat1 has master as parent + assert_eq!(feat1.original_parent.as_ref().unwrap(), &master); + intents.insert( + "feat1".to_string(), + BranchIntent { + pending_reset: true, + ..Default::default() + }, + ); + } + + gitui::execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // Verify feat1 now has upstream as ancestor + let merge_base = run_git(path_str, &["merge-base", "feat1", "feat1-upstream"]); + assert_eq!(merge_base, upstream_oid); + + // Verify feat2 is now child of the new feat1 + let feat1_oid = run_git(path_str, &["rev-parse", "feat1"]); + let merge_base2 = run_git(path_str, &["merge-base", "feat2", "feat1"]); + assert_eq!(merge_base2, feat1_oid); +} + +#[test] +fn test_integration_sync_root_rebase() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Create feat1 on top of master + create_commit(&repo, "f1.txt", "f1", "Add f1"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // 2. Create an upstream for master that is ahead + let initial_oid = run_git(path_str, &["rev-parse", "HEAD"]); + run_git(path_str, &["checkout", "--detach", &initial_oid]); + + create_commit(&repo, "m2.txt", "m2", "Add m2"); + let upstream_commit = repo.head().unwrap().peel_to_commit().unwrap(); + repo.branch("master-upstream", &upstream_commit, false) + .unwrap(); + let upstream_oid = upstream_commit.id().to_string(); + + // 3. Set master's upstream and go back to master + run_git(path_str, &["checkout", &master]); + run_git( + path_str, + &["branch", "--set-upstream-to=master-upstream", &master], + ); + + // 4. Mark master for reset/sync + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + intents.insert( + master.clone(), + BranchIntent { + pending_reset: true, + ..Default::default() + }, + ); + + gitui::execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // 5. Verify master moved to upstream OID + let master_oid = run_git(path_str, &["rev-parse", &master]); + assert_eq!(master_oid, upstream_oid); + + // 6. Verify feat1 was rebased onto new master + let merge_base = run_git(path_str, &["merge-base", "feat1", &master]); + assert_eq!(merge_base, master_oid); +} + +#[test] +fn test_integration_rebase_remote_branch() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup a remote and a branch on it + let remote_dir = tempfile::tempdir().unwrap(); + run_git(remote_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + remote_dir.path().to_str().unwrap(), + ], + ); + + let initial_oid = run_git(path_str, &["rev-parse", "HEAD"]); + + // Make master ahead of initial commit + create_commit(&repo, "master.txt", "master", "Master ahead"); + + // Create 'feat' on remote, off initial commit (not master) + run_git(path_str, &["checkout", "--detach", &initial_oid]); + run_git(path_str, &["checkout", "-b", "feat"]); + create_commit(&repo, "feat.txt", "feat", "Add feat"); + run_git(path_str, &["push", "origin", "feat"]); + run_git(path_str, &["checkout", &master]); + run_git(path_str, &["branch", "-D", "feat"]); + run_git(path_str, &["fetch", "origin"]); + + // 2. Get branches with show_remote = true + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + // 3. Move origin/feat to master + let mut intents = HashMap::new(); + { + let feat = branches + .iter() + .find(|b| b.name == "origin/feat") + .expect("origin/feat not found"); + assert_ne!(feat.original_parent.as_deref(), Some(master.as_str())); + intents.insert( + "origin/feat".to_string(), + BranchIntent { + parent: Some(Some(master.clone())), + ..Default::default() + }, + ); + } + + // 4. Execute plan + gitui::execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // 5. Verify local branch 'feat' exists and is rebased onto master + let master_oid = run_git(path_str, &["rev-parse", &master]); + let merge_base = run_git(path_str, &["merge-base", "feat", &master]); + + assert_eq!( + merge_base, master_oid, + "Local branch 'feat' should be rebased onto master" + ); + + // Verify we are not on a detached HEAD + let head_shorthand = run_git(path_str, &["rev-parse", "--abbrev-ref", "HEAD"]); + assert_ne!(head_shorthand, "HEAD", "Should not be on a detached HEAD"); +} diff --git a/tools/gitui/test/repro_issue_noise_ik.rs b/tools/gitui/test/repro_issue_noise_ik.rs new file mode 100644 index 0000000..bc8acd6 --- /dev/null +++ b/tools/gitui/test/repro_issue_noise_ik.rs @@ -0,0 +1,218 @@ +use git2::{BranchType, Signature, Time}; +use gitui::engine::{Git, RealGit}; +use gitui::testing::setup_repo; +use std::collections::HashSet; + +#[test] +fn test_repro_issue_noise_ik_split() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Create base commit + let path_crypto_test = dir.path().join("auto_tests").join("crypto_test.c"); + let path_crypto_core_c = dir.path().join("toxcore").join("crypto_core.c"); + let path_crypto_core_h = dir.path().join("toxcore").join("crypto_core.h"); + let path_crypto_core_test = dir.path().join("toxcore").join("crypto_core_test.cc"); + + std::fs::create_dir_all(dir.path().join("auto_tests")).unwrap(); + std::fs::create_dir_all(dir.path().join("toxcore")).unwrap(); + + // Content designed to produce 3 hunks when modified + let crypto_test_base = r#" +#include "../toxcore/os_memory.h" +#include "../toxcore/os_random.h" +#include "check_compat.h" + +static void rand_bytes(const Random *rng, uint8_t *b, size_t blen) +{ + // ... +} + +static void test_memzero(void) +{ + // ... +} + +int main(void) +{ + setvbuf(stdout, nullptr, _IONBF, 0); + + test_very_large_data(); + test_increment_nonce(); + test_memzero(); + + return 0; +} +"#; + std::fs::write(&path_crypto_test, crypto_test_base).unwrap(); + std::fs::write(&path_crypto_core_c, "base content c\n").unwrap(); + std::fs::write(&path_crypto_core_h, "base content h\n").unwrap(); + std::fs::write(&path_crypto_core_test, "base content test\n").unwrap(); + + { + let mut index = repo.index().unwrap(); + index + .add_path(std::path::Path::new("auto_tests/crypto_test.c")) + .unwrap(); + index + .add_path(std::path::Path::new("toxcore/crypto_core.c")) + .unwrap(); + index + .add_path(std::path::Path::new("toxcore/crypto_core.h")) + .unwrap(); + index + .add_path(std::path::Path::new("toxcore/crypto_core_test.cc")) + .unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686000, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Base commit", &tree, &[&parent]) + .unwrap(); + } + + // 2. Modify files to create the "noise-ik" state + let crypto_test_mod = r#" +#include "../toxcore/os_memory.h" +#include "../toxcore/os_random.h" +#include "check_compat.h" +// TODO(goldroom): necessary to print bytes +// #include "../other/fun/create_common.h" + +static void rand_bytes(const Random *rng, uint8_t *b, size_t blen) +{ + // ... +} + +static void test_memzero(void) +{ + // ... +} + +/* Noise_IK_25519_ChaChaPoly_BLAKE2b test vectors */ +static void test_noiseik(void) +{ + // ... huge implementation ... +} + +int main(void) +{ + setvbuf(stdout, nullptr, _IONBF, 0); + + test_very_large_data(); + test_increment_nonce(); + test_memzero(); + test_noiseik(); + + return 0; +} +"#; + + std::fs::write(&path_crypto_test, crypto_test_mod).unwrap(); + std::fs::write(&path_crypto_core_c, "base content c\nmodified\n").unwrap(); + std::fs::write(&path_crypto_core_h, "base content h\nmodified\n").unwrap(); + std::fs::write(&path_crypto_core_test, "base content test\nmodified\n").unwrap(); + + let feat_oid = { + let mut index = repo.index().unwrap(); + index + .add_path(std::path::Path::new("auto_tests/crypto_test.c")) + .unwrap(); + index + .add_path(std::path::Path::new("toxcore/crypto_core.c")) + .unwrap(); + index + .add_path(std::path::Path::new("toxcore/crypto_core.h")) + .unwrap(); + index + .add_path(std::path::Path::new("toxcore/crypto_core_test.cc")) + .unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686001, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit( + Some("refs/heads/noise-ik"), + &sig, + &sig, + "noise-ik commit", + &tree, + &[&parent], + ) + .unwrap() + }; + + // Setup branches + let giant_commit = repo.find_commit(feat_oid).unwrap(); + repo.set_head_detached(feat_oid).unwrap(); + repo.branch("noise-ik", &giant_commit, true).unwrap(); + + let base_commit = giant_commit.parents().next().unwrap(); + repo.branch(&master, &base_commit, true).unwrap(); + + let git = RealGit::new(path_str).unwrap(); + + // 3. Define the split + let mut selected = HashSet::new(); + selected.insert(("toxcore/crypto_core.c".to_string(), 0)); + selected.insert(("toxcore/crypto_core.h".to_string(), 0)); + selected.insert(("toxcore/crypto_core_test.cc".to_string(), 0)); + + // Select hunks 1 and 2 for crypto_test.c (0-based) + // In code we need to select indices. + // The diff will have: + // Hunk 0: The include + // Hunk 1: The function + // Hunk 2: The call + // We want function (1) and call (2). + selected.insert(("auto_tests/crypto_test.c".to_string(), 1)); + selected.insert(("auto_tests/crypto_test.c".to_string(), 2)); + + let split_data = gitui::engine::SplitData { + parts: vec![gitui::engine::SplitPartData { + name: "noise-ik-part1".to_string(), + commit_message: "Split part 1".to_string(), + selected_hunks: selected, + }], + }; + + // Split 'noise-ik' branch, using 'master' as the base/upstream + git.split_branch("noise-ik", &master, &split_data).unwrap(); + + // 4. Verify results + let part1_branch = repo + .find_branch("noise-ik-part1", BranchType::Local) + .unwrap(); + let part1_commit = part1_branch.get().peel_to_commit().unwrap(); + let tree1 = part1_commit.tree().unwrap(); + + // Check crypto_test.c content in part 1 + let obj = tree1 + .get_path(std::path::Path::new("auto_tests/crypto_test.c")) + .unwrap() + .to_object(&repo) + .unwrap(); + let content = std::str::from_utf8(obj.as_blob().unwrap().content()).unwrap(); + + // Should NOT contain the include + assert!(!content.contains("#include \"../other/fun/create_common.h\"")); + assert!(!content.contains("TODO(goldroom)")); + + // Should contain the function + assert!(content.contains("static void test_noiseik(void)")); + + // Should contain the call + assert!(content.contains("test_noiseik();")); + + // Check other files are modified + let obj_c = tree1 + .get_path(std::path::Path::new("toxcore/crypto_core.c")) + .unwrap() + .to_object(&repo) + .unwrap(); + assert!( + std::str::from_utf8(obj_c.as_blob().unwrap().content()) + .unwrap() + .contains("modified") + ); +} diff --git a/tools/gitui/test/runtime_logic_tests.rs b/tools/gitui/test/runtime_logic_tests.rs new file mode 100644 index 0000000..46aace7 --- /dev/null +++ b/tools/gitui/test/runtime_logic_tests.rs @@ -0,0 +1,143 @@ +use gitui::engine::{BranchInfo, Git, RealGit}; +use gitui::testing::{create_commit, run_git, setup_repo}; + +// Replicate the logic from runtime.rs in a testable way +fn check_post_execution_switch( + git: &dyn Git, + initial_branch: &str, + branches_to_execute: &[BranchInfo], + intents: &std::collections::HashMap, +) -> String { + let current = git.get_current_branch().unwrap(); + + // Check if initial branch exists + let exists = git + .run_command(&[ + "show-ref".to_string(), + "--verify".to_string(), + "--quiet".to_string(), + format!("refs/heads/{}", initial_branch), + ]) + .unwrap_or(false); + + if exists { + git.checkout(initial_branch).unwrap(); + return initial_branch.to_string(); + } + + // Fallback logic + let mut children: Vec = branches_to_execute + .iter() + .filter(|b| { + let intent = intents.get(&b.name); + let effective_parent = match intent.and_then(|i| i.parent.as_ref()) { + Some(p) => p.clone(), + None => b.original_parent.clone(), + }; + effective_parent.as_ref() == Some(&initial_branch.to_string()) + }) + .map(|b| b.name.clone()) + .collect(); + children.sort(); + + let branch_exists = |name: &str| -> bool { + git.run_command(&[ + "show-ref".to_string(), + "--verify".to_string(), + "--quiet".to_string(), + format!("refs/heads/{}", name), + ]) + .unwrap_or(false) + }; + + let mut target = None; + for child in children { + if branch_exists(&child) { + target = Some(child); + break; + } + } + + if target.is_none() { + if branch_exists("master") { + target = Some("master".to_string()); + } else if branch_exists("main") { + target = Some("main".to_string()); + } + } + + if let Some(t) = target + && t != current + { + git.checkout(&t).unwrap(); + return t; + } + current +} + +#[test] +fn test_post_submit_branch_switching() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup tree: master -> feat1 -> feat2 + create_commit(&repo, "f1.txt", "1", "c1"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + run_git(path_str, &["checkout", "feat1"]); + create_commit(&repo, "f2.txt", "2", "c2"); + repo.branch( + "feat2", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + + // Simulate State before execution + let branches_to_execute = vec![ + BranchInfo { + name: "feat1".to_string(), + original_parent: Some(master.clone()), + ..Default::default() + }, + BranchInfo { + name: "feat2".to_string(), + original_parent: Some("feat1".to_string()), + ..Default::default() + }, + ]; + let intents = std::collections::HashMap::new(); + + // Case 1: Initial branch 'feat1' exists (normal case) + run_git(path_str, &["checkout", &master]); // Currently on master + let result = check_post_execution_switch(&git, "feat1", &branches_to_execute, &intents); + assert_eq!(result, "feat1", "Should restore existing initial branch"); + + // Case 2: Initial branch 'feat1' is deleted (simulating submit), switch to child 'feat2' + run_git(path_str, &["checkout", &master]); + run_git(path_str, &["branch", "-D", "feat1"]); + let result = check_post_execution_switch(&git, "feat1", &branches_to_execute, &intents); + assert_eq!( + result, "feat2", + "Should switch to child branch feat2 when feat1 is deleted" + ); + + // Case 3: Both 'feat1' and 'feat2' deleted, fallback to master + run_git(path_str, &["checkout", &master]); + // feat1 already deleted + run_git(path_str, &["branch", "-D", "feat2"]); + let result = check_post_execution_switch(&git, "feat1", &branches_to_execute, &intents); + assert_eq!( + result, master, + "Should fallback to master when children also deleted" + ); +} + +// end of file diff --git a/tools/gitui/test/snapshots/cli_output_tests__cli_cascading_divergence.snap b/tools/gitui/test/snapshots/cli_output_tests__cli_cascading_divergence.snap new file mode 100644 index 0000000..915019c --- /dev/null +++ b/tools/gitui/test/snapshots/cli_output_tests__cli_cascading_divergence.snap @@ -0,0 +1,8 @@ +--- +source: hs-github-tools/tools/gitui/test/cli_output_tests.rs +expression: clean_output +--- +* main + branch-a + branch-b [DIVERGED] + branch-c diff --git a/tools/gitui/test/snapshots/cli_output_tests__cli_print_converge_plan_snapshot.snap b/tools/gitui/test/snapshots/cli_output_tests__cli_print_converge_plan_snapshot.snap new file mode 100644 index 0000000..eefc7c2 --- /dev/null +++ b/tools/gitui/test/snapshots/cli_output_tests__cli_print_converge_plan_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: hs-github-tools/tools/gitui/test/cli_output_tests.rs +expression: clean_output +--- +Plan to converge feat2: + git rebase --onto feat1 44875c1660a43225930982cfb5d86ec8b52e5a62 feat2 [CONFLICT] \ No newline at end of file diff --git a/tools/gitui/test/snapshots/cli_output_tests__cli_print_diverged_tree_snapshot.snap b/tools/gitui/test/snapshots/cli_output_tests__cli_print_diverged_tree_snapshot.snap new file mode 100644 index 0000000..60bccc1 --- /dev/null +++ b/tools/gitui/test/snapshots/cli_output_tests__cli_print_diverged_tree_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: hs-github-tools/tools/gitui/test/cli_output_tests.rs +expression: clean_output +--- +* main + feat1 + feat2 [DIVERGED] \ No newline at end of file diff --git a/tools/gitui/test/snapshots/cli_output_tests__cli_print_submit_plan_snapshot.snap b/tools/gitui/test/snapshots/cli_output_tests__cli_print_submit_plan_snapshot.snap new file mode 100644 index 0000000..6afce34 --- /dev/null +++ b/tools/gitui/test/snapshots/cli_output_tests__cli_print_submit_plan_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: hs-github-tools/tools/gitui/test/cli_output_tests.rs +expression: clean_output +--- +Plan to submit feat: + git push upstream feat:main && git push origin --delete feat && git checkout main && git merge --ff-only feat && git push origin main + git branch -d feat \ No newline at end of file diff --git a/tools/gitui/test/snapshots/cli_output_tests__cli_print_tree_snapshot.snap b/tools/gitui/test/snapshots/cli_output_tests__cli_print_tree_snapshot.snap new file mode 100644 index 0000000..31efbf8 --- /dev/null +++ b/tools/gitui/test/snapshots/cli_output_tests__cli_print_tree_snapshot.snap @@ -0,0 +1,8 @@ +--- +source: hs-github-tools/tools/gitui/test/cli_output_tests.rs +expression: clean_output +--- + feat1 + feat1-child +* main + feat2 [MERGED] \ No newline at end of file diff --git a/tools/gitui/test/snapshots/tui_integration_tests__tui_subtree_movement.snap b/tools/gitui/test/snapshots/tui_integration_tests__tui_subtree_movement.snap new file mode 100644 index 0000000..fab0e04 --- /dev/null +++ b/tools/gitui/test/snapshots/tui_integration_tests__tui_subtree_movement.snap @@ -0,0 +1,24 @@ +--- +source: hs-github-tools/tools/gitui/test/tui_integration_tests.rs +expression: output +--- +┌Branches────────────────────┐┌Recent Commits────┐ +│>>main ││ │ +│ branch_1 ││ │ +│ branch_2 ││ │ +│ branch_3 ││ │ +│ branch_4 ││ │ +│ stack_root ││ │ +│ stack_child_1 ││ │ +│ stack_child_2 ││ │ +│ stack_child_3 ││ │ +│ z_branch ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +└────────────────────────────┘└──────────────────┘ +┌Help────────────────────────────────────────────┐ +│q: quit | j/k: navigate | v: preview | c: commit│ +└────────────────────────────────────────────────┘ diff --git a/tools/gitui/test/snapshots/tui_integration_tests__tui_subtree_movement_in_progress.snap b/tools/gitui/test/snapshots/tui_integration_tests__tui_subtree_movement_in_progress.snap new file mode 100644 index 0000000..d5606d4 --- /dev/null +++ b/tools/gitui/test/snapshots/tui_integration_tests__tui_subtree_movement_in_progress.snap @@ -0,0 +1,24 @@ +--- +source: hs-github-tools/tools/gitui/test/tui_integration_tests.rs +expression: output +--- +┌Branches────────────────────┐┌Recent Commits────┐ +│ main ││ │ +│ branch_1 ││ │ +│>> branch_2 ││ │ +│ stack_root ││ │ +│ stack_child_1 ││ │ +│ stack_child_2 ││ │ +│ stack_child_3 ││ │ +│ branch_3 ││ │ +│ branch_4 ││ │ +│ z_branch ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +└────────────────────────────┘└──────────────────┘ +┌Help────────────────────────────────────────────┐ +│esc: cancel | space: release | j/k: select paren│ +└────────────────────────────────────────────────┘ diff --git a/tools/gitui/test/split_execution_tests.rs b/tools/gitui/test/split_execution_tests.rs new file mode 100644 index 0000000..0d3b5bd --- /dev/null +++ b/tools/gitui/test/split_execution_tests.rs @@ -0,0 +1,172 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use gitui::engine::{Git, Operation, RealGit}; +use gitui::state::{AppMode, AppState, Effect, Msg}; +use gitui::testing::{create_commit, run_git, setup_repo}; + +#[tokio::test] +async fn test_split_execution_clears_plan() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Create a branch with 2 files to split, and a child branch + run_git(path_str, &["checkout", "-b", "feat"]); + let path_a = dir.path().join("a.txt"); + let path_b = dir.path().join("b.txt"); + std::fs::write(&path_a, "change a\n").unwrap(); + std::fs::write(&path_b, "change b\n").unwrap(); + run_git(path_str, &["add", "a.txt", "b.txt"]); + run_git(path_str, &["commit", "-m", "Giant commit"]); + + run_git(path_str, &["checkout", "-b", "child"]); + create_commit(&repo, "child.txt", "child content", "Child commit"); + run_git(path_str, &["checkout", "feat"]); + + // 2. Initialize TUI state + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + ..AppState::default() + }; + state.refresh_tree(None); + + // Select "feat" + if let Some(idx) = state.flattened_tree.iter().position(|(n, _)| n == "feat") { + state.list_state.select(Some(idx)); + } + + // 3. Trigger split + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))); + + // Load diff and select a.txt for feat-1 + let diff = git.get_diff("feat", &master).unwrap(); + state.update(Msg::DiffLoaded("feat".to_string(), Ok(diff))); + + // Select first file + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::NONE, + ))); + + // Start Split part creation (trigger Name prompt) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))); + + // Confirm name (move to Message prompt) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))); + + // Confirm message (Ctrl+Enter to return to Split mode) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::CONTROL, + ))); + + // Now back in Split mode with parts.len() == 1 + + // Press Enter again in Split mode to finalize all splits + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))); + assert_eq!(state.mode, AppMode::Tree); + + // 4. Verify plan exists + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('v'), + KeyModifiers::NONE, + ))); + let plan = state.plan.as_ref().unwrap(); + assert!( + !plan.is_empty(), + "Plan should not be empty before execution" + ); + + // The plan should contain a Split and a Rebase + assert!(plan.iter().any(|op| matches!(op, Operation::Split { .. }))); + assert!(plan.iter().any(|op| matches!(op, Operation::Rebase { .. }))); + + // 5. Simulate "Apply and Quit" (pressing 'c' in preview) + let effects = state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::NONE, + ))); + + let mut found_apply = false; + for effect in effects { + if let Effect::ApplyAndQuit(branches, intents) = effect { + found_apply = true; + // Execute the plan as runtime.rs would + gitui::execute_plan(&git, &branches, &intents, &state.history, path_str).unwrap(); + state.clear_pending_operations(); + } + } + assert!(found_apply); + + // 6. NOW THE CRITICAL PART: After execution, if we "return" to the app (simulated by reloading state), + // the plan should be empty. + + let (new_branches, new_history) = git.get_branches(None).unwrap(); + let is_dirty = git.is_dirty().unwrap(); + state.update(Msg::BranchesLoaded(Ok(( + new_branches, + new_history, + is_dirty, + )))); + + // 7. Verify topology after reload + let names: Vec = state + .flattened_tree + .iter() + .map(|(n, _)| n.clone()) + .collect(); + + assert!(names.contains(&"feat-1".to_string())); + assert!(names.contains(&"feat".to_string())); + assert!(names.contains(&"child".to_string())); + + let master_idx = names.iter().position(|n| n == &master).unwrap(); + let feat1_idx = names.iter().position(|n| n == "feat-1").unwrap(); + let feat_idx = names.iter().position(|n| n == "feat").unwrap(); + let child_idx = names.iter().position(|n| n == "child").unwrap(); + + assert!(feat1_idx > master_idx); + assert!(feat_idx > feat1_idx); + assert!(child_idx > feat_idx); + + let depths: Vec = state.flattened_tree.iter().map(|(_, d)| *d).collect(); + assert_eq!(depths[feat1_idx], depths[master_idx] + 1); + assert_eq!(depths[feat_idx], depths[feat1_idx] + 1); + assert_eq!(depths[child_idx], depths[feat_idx] + 1); + + // 8. Try to quit - it should NOT ask for confirmation because plan should be empty + let snapshot = gitui::engine::RepositorySnapshot { + branches: state.branches.clone(), + history: state.history.clone(), + is_dirty: state.is_dirty, + }; + let plan = gitui::engine::calculate_plan(&snapshot, &state.intents).unwrap(); + + let quit_effects = state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('q'), + KeyModifiers::NONE, + ))); + assert!( + quit_effects.iter().any(|e| matches!(e, Effect::Quit)), + "Should quit directly if plan is empty. Plan: {:?}", + plan + ); + assert!( + !state.show_quit_confirmation, + "Should not show quit confirmation after execution" + ); +} diff --git a/tools/gitui/test/split_integration_tests.rs b/tools/gitui/test/split_integration_tests.rs new file mode 100644 index 0000000..e6dc425 --- /dev/null +++ b/tools/gitui/test/split_integration_tests.rs @@ -0,0 +1,733 @@ +use git2::{BranchType, Signature, Time}; +use gitui::engine::{Git, RealGit}; +use gitui::testing::setup_repo; +use std::collections::HashSet; + +#[test] +fn test_integration_split_branch_on_disk() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Create a 1-commit branch that modifies two files + // Initial files on master + let path_a = dir.path().join("a.txt"); + std::fs::write(&path_a, "original a\n").unwrap(); + let path_b = dir.path().join("b.txt"); + std::fs::write(&path_b, "original b\n").unwrap(); + + { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + index.add_path(std::path::Path::new("b.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686000, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Base commit", &tree, &[&parent]) + .unwrap(); + } + + // Now create the "feat" branch with changes to both + std::fs::write(&path_a, "modified a\n").unwrap(); + std::fs::write(&path_b, "modified b\n").unwrap(); + + let feat_oid = { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + index.add_path(std::path::Path::new("b.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686001, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Giant commit", &tree, &[&parent]) + .unwrap() + }; + + // feat now points to Giant commit, master also points to Giant commit because we committed to HEAD. + // Move master back to Base commit. + let giant_commit = repo.find_commit(feat_oid).unwrap(); + let base_commit = giant_commit.parents().next().unwrap(); + + repo.set_head_detached(feat_oid).unwrap(); + repo.branch("feat", &giant_commit, true).unwrap(); + + // Reset master to base_commit + repo.branch(&master, &base_commit, true).unwrap(); + + let git = RealGit::new(path_str).unwrap(); + + // 2. Split "feat" by selecting only a.txt change + let mut selected = HashSet::new(); + selected.insert(("a.txt".to_string(), 0)); + + let split_data = gitui::engine::SplitData { + parts: vec![gitui::engine::SplitPartData { + name: "feat-part1".to_string(), + commit_message: "Split from feat: first part".to_string(), + selected_hunks: selected, + }], + }; + + git.split_branch("feat", &master, &split_data).unwrap(); + + // 3. Verify results + // We expect: master -> feat-part1 -> feat + + let feat_branch = repo.find_branch("feat", BranchType::Local).unwrap(); + let feat_part1_branch = repo.find_branch("feat-part1", BranchType::Local).unwrap(); + + let feat_commit = feat_branch.get().peel_to_commit().unwrap(); + let feat_part1_commit = feat_part1_branch.get().peel_to_commit().unwrap(); + let master_commit = repo + .find_branch(&master, BranchType::Local) + .unwrap() + .get() + .peel_to_commit() + .unwrap(); + + // Parentage + assert_eq!( + feat_commit.parents().next().unwrap().id(), + feat_part1_commit.id() + ); + assert_eq!( + feat_part1_commit.parents().next().unwrap().id(), + master_commit.id() + ); + + // Content of feat-part1 (should have modified a but original b) + let tree1 = feat_part1_commit.tree().unwrap(); + let obj_a1 = tree1 + .get_path(std::path::Path::new("a.txt")) + .unwrap() + .to_object(&repo) + .unwrap(); + let blob_a1 = obj_a1.as_blob().unwrap(); + let obj_b1 = tree1 + .get_path(std::path::Path::new("b.txt")) + .unwrap() + .to_object(&repo) + .unwrap(); + let blob_b1 = obj_b1.as_blob().unwrap(); + assert_eq!( + std::str::from_utf8(blob_a1.content()).unwrap(), + "modified a\n" + ); + assert_eq!( + std::str::from_utf8(blob_b1.content()).unwrap(), + "original b\n" + ); + + // Content of feat (should have both modified) + let tree2 = feat_commit.tree().unwrap(); + let obj_a2 = tree2 + .get_path(std::path::Path::new("a.txt")) + .unwrap() + .to_object(&repo) + .unwrap(); + let blob_a2 = obj_a2.as_blob().unwrap(); + let obj_b2 = tree2 + .get_path(std::path::Path::new("b.txt")) + .unwrap() + .to_object(&repo) + .unwrap(); + let blob_b2 = obj_b2.as_blob().unwrap(); + assert_eq!( + std::str::from_utf8(blob_a2.content()).unwrap(), + "modified a\n" + ); + assert_eq!( + std::str::from_utf8(blob_b2.content()).unwrap(), + "modified b\n" + ); +} + +#[test] +fn test_integration_split_branch_3way() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Create a commit that modifies three things + let path_a = dir.path().join("a.txt"); + let path_b = dir.path().join("b.txt"); + let path_c = dir.path().join("c.txt"); + std::fs::write(&path_a, "a\n").unwrap(); + std::fs::write(&path_b, "b\n").unwrap(); + std::fs::write(&path_c, "c\n").unwrap(); + + { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + index.add_path(std::path::Path::new("b.txt")).unwrap(); + index.add_path(std::path::Path::new("c.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686000, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Base", &tree, &[&parent]) + .unwrap(); + } + + std::fs::write(&path_a, "A\n").unwrap(); + std::fs::write(&path_b, "B\n").unwrap(); + std::fs::write(&path_c, "C\n").unwrap(); + + let feat_oid = { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + index.add_path(std::path::Path::new("b.txt")).unwrap(); + index.add_path(std::path::Path::new("c.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686001, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Big", &tree, &[&parent]) + .unwrap() + }; + + let giant_commit = repo.find_commit(feat_oid).unwrap(); + let base_commit = giant_commit.parents().next().unwrap(); + repo.set_head_detached(feat_oid).unwrap(); + repo.branch("feat", &giant_commit, true).unwrap(); + repo.branch(&master, &base_commit, true).unwrap(); + + let git = RealGit::new(path_str).unwrap(); + + // 2. Split into 3 parts + let mut sel1 = HashSet::new(); + sel1.insert(("a.txt".to_string(), 0)); + let mut sel2 = HashSet::new(); + sel2.insert(("b.txt".to_string(), 0)); + + let split_data = gitui::engine::SplitData { + parts: vec![ + gitui::engine::SplitPartData { + name: "part1".to_string(), + commit_message: "Msg1".to_string(), + selected_hunks: sel1, + }, + gitui::engine::SplitPartData { + name: "part2".to_string(), + commit_message: "Msg2".to_string(), + selected_hunks: sel2, + }, + ], + }; + + git.split_branch("feat", &master, &split_data).unwrap(); + + // 3. Verify: master -> part1 -> part2 -> feat + let feat_branch = repo.find_branch("feat", BranchType::Local).unwrap(); + let p2_branch = repo.find_branch("part2", BranchType::Local).unwrap(); + let p1_branch = repo.find_branch("part1", BranchType::Local).unwrap(); + + let feat_c = feat_branch.get().peel_to_commit().unwrap(); + let p2_c = p2_branch.get().peel_to_commit().unwrap(); + let p1_c = p1_branch.get().peel_to_commit().unwrap(); + + assert_eq!(feat_c.parents().next().unwrap().id(), p2_c.id()); + assert_eq!(p2_c.parents().next().unwrap().id(), p1_c.id()); + assert_eq!(p1_c.parents().next().unwrap().id(), base_commit.id()); + + // Check content of part1 (A, b, c) + let t1 = p1_c.tree().unwrap(); + assert_eq!( + std::str::from_utf8( + t1.get_path(std::path::Path::new("a.txt")) + .unwrap() + .to_object(&repo) + .unwrap() + .as_blob() + .unwrap() + .content() + ) + .unwrap(), + "A\n" + ); + assert_eq!( + std::str::from_utf8( + t1.get_path(std::path::Path::new("b.txt")) + .unwrap() + .to_object(&repo) + .unwrap() + .as_blob() + .unwrap() + .content() + ) + .unwrap(), + "b\n" + ); + + // Check content of part2 (A, B, c) + let t2 = p2_c.tree().unwrap(); + assert_eq!( + std::str::from_utf8( + t2.get_path(std::path::Path::new("a.txt")) + .unwrap() + .to_object(&repo) + .unwrap() + .as_blob() + .unwrap() + .content() + ) + .unwrap(), + "A\n" + ); + assert_eq!( + std::str::from_utf8( + t2.get_path(std::path::Path::new("b.txt")) + .unwrap() + .to_object(&repo) + .unwrap() + .as_blob() + .unwrap() + .content() + ) + .unwrap(), + "B\n" + ); + assert_eq!( + std::str::from_utf8( + t2.get_path(std::path::Path::new("c.txt")) + .unwrap() + .to_object(&repo) + .unwrap() + .as_blob() + .unwrap() + .content() + ) + .unwrap(), + "c\n" + ); + + // Check content of feat (A, B, C) + let tf = feat_c.tree().unwrap(); + assert_eq!( + std::str::from_utf8( + tf.get_path(std::path::Path::new("c.txt")) + .unwrap() + .to_object(&repo) + .unwrap() + .as_blob() + .unwrap() + .content() + ) + .unwrap(), + "C\n" + ); +} + +#[test] +fn test_integration_split_current_branch() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // Setup master -> feat (Giant commit) + let path_a = dir.path().join("a.txt"); + std::fs::write(&path_a, "original a\n").unwrap(); + let path_b = dir.path().join("b.txt"); + std::fs::write(&path_b, "original b\n").unwrap(); + + { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + index.add_path(std::path::Path::new("b.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686000, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Base commit", &tree, &[&parent]) + .unwrap(); + } + + std::fs::write(&path_a, "modified a\n").unwrap(); + std::fs::write(&path_b, "modified b\n").unwrap(); + + { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + index.add_path(std::path::Path::new("b.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686001, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit( + Some("refs/heads/feat"), + &sig, + &sig, + "Giant commit", + &tree, + &[&parent], + ) + .unwrap(); + } + + // Checkout 'feat' branch + repo.set_head("refs/heads/feat").unwrap(); + repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())) + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + + let mut selected = HashSet::new(); + selected.insert(("a.txt".to_string(), 0)); + + let split_data = gitui::engine::SplitData { + parts: vec![gitui::engine::SplitPartData { + name: "feat-part1".to_string(), + commit_message: "Split from feat: first part".to_string(), + selected_hunks: selected, + }], + }; + + // This used to fail with "cannot force update branch ... as it is the current HEAD" + git.split_branch("feat", &master, &split_data) + .expect("Should allow splitting the current branch"); + + // Verify we are still on feat + let head = repo.head().unwrap(); + assert_eq!(head.shorthand(), Some("feat")); +} + +#[test] +fn test_integration_split_with_rename() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Initial file + let path_old = dir.path().join("old.txt"); + std::fs::write(&path_old, "line 1\nline 2\nline 3\n").unwrap(); + + { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("old.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686000, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Base", &tree, &[&parent]) + .unwrap(); + } + + // 2. Rename and modify + // Note: Repository operations are used to ensure it's a "proper" rename in Git's eyes if possible, + // though Git usually detects renames heuristically. + std::fs::remove_file(&path_old).unwrap(); + let path_new = dir.path().join("new.txt"); + std::fs::write(&path_new, "line 1\nline 2 modified\nline 3\n").unwrap(); + + { + let mut index = repo.index().unwrap(); + index.remove_path(std::path::Path::new("old.txt")).unwrap(); + index.add_path(std::path::Path::new("new.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686001, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit( + Some("refs/heads/feat"), + &sig, + &sig, + "Rename commit", + &tree, + &[&parent], + ) + .unwrap(); + } + + let git = RealGit::new(path_str).unwrap(); + + // 3. Attempt to split. + // Since renames aren't explicitly handled as "Rename" operations in our Hunk-based UI, + // they show up as a deletion hunk and an addition hunk (unless find_similar is used). + // In our current implementation, we just see "new.txt" as an addition. + let mut selected = HashSet::new(); + selected.insert(("new.txt".to_string(), 0)); + + let split_data = gitui::engine::SplitData { + parts: vec![gitui::engine::SplitPartData { + name: "feat-part1".to_string(), + commit_message: "Split rename".to_string(), + selected_hunks: selected, + }], + }; + + git.split_branch("feat", &master, &split_data).unwrap(); + + // 4. Verify results + let part1_branch = repo.find_branch("feat-part1", BranchType::Local).unwrap(); + let part1_tree = part1_branch.get().peel_to_tree().unwrap(); + + // Check if new.txt exists + assert!(part1_tree.get_path(std::path::Path::new("new.txt")).is_ok()); + + // With rename detection, selecting the hunk in new.txt should also include the rename metadata, + // meaning old.txt should be GONE in the new tree. + assert!( + part1_tree + .get_path(std::path::Path::new("old.txt")) + .is_err(), + "old.txt should NOT be present because it was renamed to new.txt" + ); +} + +#[test] +fn test_integration_split_pure_rename() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Initial file + let path_old = dir.path().join("old.txt"); + std::fs::write(&path_old, "line 1\nline 2\nline 3\n").unwrap(); + + { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("old.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686000, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Base", &tree, &[&parent]) + .unwrap(); + } + + // 2. Pure Rename (no modification) + std::fs::remove_file(&path_old).unwrap(); + let path_new = dir.path().join("new.txt"); + std::fs::write(&path_new, "line 1\nline 2\nline 3\n").unwrap(); + + { + let mut index = repo.index().unwrap(); + index.remove_path(std::path::Path::new("old.txt")).unwrap(); + index.add_path(std::path::Path::new("new.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686001, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit( + Some("refs/heads/feat"), + &sig, + &sig, + "Rename commit", + &tree, + &[&parent], + ) + .unwrap(); + } + + let git = RealGit::new(path_str).unwrap(); + + // 3. Attempt to split. + // For a pure rename, there are NO hunks. + // We expect that selecting hunk 0 (or the file itself) should work. + let mut selected = HashSet::new(); + selected.insert(("new.txt".to_string(), 0)); + + let split_data = gitui::engine::SplitData { + parts: vec![gitui::engine::SplitPartData { + name: "feat-part1".to_string(), + commit_message: "Split pure rename".to_string(), + selected_hunks: selected, + }], + }; + + git.split_branch("feat", &master, &split_data).unwrap(); + + // 4. Verify results + let part1_branch = repo.find_branch("feat-part1", BranchType::Local).unwrap(); + let part1_tree = part1_branch.get().peel_to_tree().unwrap(); + + assert!(part1_tree.get_path(std::path::Path::new("new.txt")).is_ok()); + assert!( + part1_tree + .get_path(std::path::Path::new("old.txt")) + .is_err(), + "old.txt should NOT be present in the split part" + ); +} + +#[test] +fn test_integration_split_rename_with_edit() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Initial file + let path_old = dir.path().join("file.txt"); + let mut initial_content = String::new(); + for i in 1..=30 { + initial_content.push_str(&format!("line {}\n", i)); + } + std::fs::write(&path_old, &initial_content).unwrap(); + + { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("file.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686000, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Base", &tree, &[&parent]) + .unwrap(); + } + + // 2. Rename and Edit (two separate hunks) + std::fs::remove_file(&path_old).unwrap(); + let path_new = dir.path().join("renamed.txt"); + + let mut content = String::new(); + content.push_str("line 1 modified\n"); + for i in 2..=30 { + content.push_str(&format!("line {}\n", i)); + } + content.push_str("line 31 added\n"); + std::fs::write(&path_new, &content).unwrap(); + + { + let mut index = repo.index().unwrap(); + index.remove_path(std::path::Path::new("file.txt")).unwrap(); + index.add_path(std::path::Path::new("renamed.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686001, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit( + Some("refs/heads/feat"), + &sig, + &sig, + "Rename and edit", + &tree, + &[&parent], + ) + .unwrap(); + } + + let git = RealGit::new(path_str).unwrap(); + + // 3. Split: Pick ONLY the first hunk (line 1 modification) + let mut selected = HashSet::new(); + selected.insert(("renamed.txt".to_string(), 0)); + + let split_data = gitui::engine::SplitData { + parts: vec![gitui::engine::SplitPartData { + name: "feat-part1".to_string(), + commit_message: "Split rename + first edit".to_string(), + selected_hunks: selected, + }], + }; + + git.split_branch("feat", &master, &split_data).unwrap(); + + // 4. Verify results + let part1_branch = repo.find_branch("feat-part1", BranchType::Local).unwrap(); + let part1_tree = part1_branch.get().peel_to_tree().unwrap(); + + // Should be renamed to renamed.txt + assert!( + part1_tree + .get_path(std::path::Path::new("renamed.txt")) + .is_ok() + ); + assert!( + part1_tree + .get_path(std::path::Path::new("file.txt")) + .is_err() + ); + + let obj = part1_tree + .get_path(std::path::Path::new("renamed.txt")) + .unwrap() + .to_object(&repo) + .unwrap(); + let content = std::str::from_utf8(obj.as_blob().unwrap().content()).unwrap(); + + assert!(content.contains("line 1 modified")); + assert!( + !content.contains("line 31 added"), + "Should not have the second hunk yet" + ); + + // Remainder branch should have everything + let feat_branch = repo.find_branch("feat", BranchType::Local).unwrap(); + let feat_tree = feat_branch.get().peel_to_tree().unwrap(); + let obj_final = feat_tree + .get_path(std::path::Path::new("renamed.txt")) + .unwrap() + .to_object(&repo) + .unwrap(); + let content_final = std::str::from_utf8(obj_final.as_blob().unwrap().content()).unwrap(); + assert!(content_final.contains("line 31 added")); +} + +#[test] +fn test_integration_split_with_mode_change() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Initial file + let path = dir.path().join("script.sh"); + std::fs::write(&path, "echo hello\n").unwrap(); + + { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("script.sh")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686000, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Base", &tree, &[&parent]) + .unwrap(); + } + + // 2. Chmod +x and modify + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&path, perms).unwrap(); + } + // If not unix, this might not work as expected but git2 might still track it if forced. + + std::fs::write(&path, "echo hello world\n").unwrap(); + + { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("script.sh")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686001, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit( + Some("refs/heads/feat"), + &sig, + &sig, + "Chmod commit", + &tree, + &[&parent], + ) + .unwrap(); + } + + let git = RealGit::new(path_str).unwrap(); + + let mut selected = HashSet::new(); + selected.insert(("script.sh".to_string(), 0)); + + let split_data = gitui::engine::SplitData { + parts: vec![gitui::engine::SplitPartData { + name: "feat-part1".to_string(), + commit_message: "Split mode change".to_string(), + selected_hunks: selected, + }], + }; + + git.split_branch("feat", &master, &split_data).unwrap(); + + // 3. Verify results + let part1_branch = repo.find_branch("feat-part1", BranchType::Local).unwrap(); + let part1_tree = part1_branch.get().peel_to_tree().unwrap(); + let entry = part1_tree.get_name("script.sh").unwrap(); + + #[cfg(unix)] + assert_eq!(entry.filemode(), 0o100755, "File mode should be executable"); +} diff --git a/tools/gitui/test/split_state_proptests.rs b/tools/gitui/test/split_state_proptests.rs new file mode 100644 index 0000000..cd3665d --- /dev/null +++ b/tools/gitui/test/split_state_proptests.rs @@ -0,0 +1,101 @@ +use gitui::diff_utils::{DiffLine, FileDiff, Hunk, LineType}; +use gitui::split_state::{SplitState, SplitViewMode}; +use proptest::prelude::*; + +fn arb_file_diff(max_hunks: usize, max_lines: usize) -> impl Strategy { + ( + ".*", // Path + prop::collection::vec(arb_hunk(max_lines), 1..=max_hunks), + ) + .prop_map(|(path, hunks)| FileDiff { path, hunks }) +} + +fn arb_hunk(max_lines: usize) -> impl Strategy { + prop::collection::vec(arb_diff_line(), 0..=max_lines).prop_map(|lines| Hunk { + header: "@@ hunk @@".to_string(), + lines, + ..Default::default() + }) +} + +fn arb_diff_line() -> impl Strategy { + prop::sample::select(vec![ + LineType::Addition, + LineType::Deletion, + LineType::Context, + ]) + .prop_map(|line_type| DiffLine { + content: "line\n".to_string(), + line_type, + ..Default::default() + }) +} + +fn arb_split_state( + max_files: usize, + max_hunks: usize, + max_lines: usize, +) -> impl Strategy { + prop::collection::vec(arb_file_diff(max_hunks, max_lines), 1..=max_files) + .prop_map(SplitState::new) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn test_selection_persistence_across_modes( + mut state in arb_split_state(5, 5, 5), + toggle_indices in prop::collection::vec(0..10usize, 1..5) + ) { + // 1. Initial selection in Files mode + for &idx in &toggle_indices { + state.selected_view_idx = idx % state.rendered_items.len(); + state.toggle_selection(); + } + let selection_files = state.current_selection.clone(); + + // 2. Switch to Hunks mode + state.mode = SplitViewMode::Hunks; + state.rebuild_view(); + prop_assert_eq!(&state.current_selection, &selection_files, "Selection changed when switching to Hunks mode"); + + // 3. Toggle more in Hunks mode + if !state.rendered_items.is_empty() { + for &idx in &toggle_indices { + state.selected_view_idx = idx % state.rendered_items.len(); + state.toggle_selection(); + } + } + let selection_hunks = state.current_selection.clone(); + + // 4. Switch to Lines mode + state.mode = SplitViewMode::Lines; + state.rebuild_view(); + prop_assert_eq!(&state.current_selection, &selection_hunks, "Selection changed when switching to Lines mode"); + + // 5. Switch back to Files mode + state.mode = SplitViewMode::Files; + state.rebuild_view(); + prop_assert_eq!(&state.current_selection, &selection_hunks, "Selection changed when switching back to Files mode"); + } + + #[test] + fn test_file_selection_idempotency( + mut state in arb_split_state(3, 5, 0) + ) { + let initial_selection = state.current_selection.clone(); + + // Select first file + state.selected_view_idx = 0; + state.toggle_selection(); + + // If it was empty, it should now have all hunks of file 0 + // If it was full, it should now be empty + + // Toggle again + state.toggle_selection(); + + prop_assert_eq!(state.current_selection, initial_selection, "Double toggle of file header should be idempotent"); + } +} diff --git a/tools/gitui/test/split_tui_integration_tests.rs b/tools/gitui/test/split_tui_integration_tests.rs new file mode 100644 index 0000000..2c2fdf6 --- /dev/null +++ b/tools/gitui/test/split_tui_integration_tests.rs @@ -0,0 +1,207 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git2::{Signature, Time}; +use gitui::engine::{Git, RealGit}; +use gitui::split_state::SplitViewMode; +use gitui::state::{AppMode, AppState, Msg}; +use gitui::testing::setup_repo; +use gitui::ui; +use ratatui::{Terminal, backend::TestBackend}; + +#[tokio::test] +async fn test_tui_split_branch_workflow() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Create a 1-commit branch that modifies two files + let path_a = dir.path().join("a.txt"); + std::fs::write(&path_a, "original a\n").unwrap(); + let path_b = dir.path().join("b.txt"); + std::fs::write(&path_b, "original b\n").unwrap(); + + { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + index.add_path(std::path::Path::new("b.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686000, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Base commit", &tree, &[&parent]) + .unwrap(); + } + + // Now create the "feat" branch with changes to both + std::fs::write(&path_a, "modified a\n").unwrap(); + std::fs::write(&path_b, "modified b\n").unwrap(); + + let feat_oid = { + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("a.txt")).unwrap(); + index.add_path(std::path::Path::new("b.txt")).unwrap(); + let id = index.write_tree().unwrap(); + let tree = repo.find_tree(id).unwrap(); + let sig = Signature::new("Test", "test@example.com", &Time::new(1735686001, 0)).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Giant commit", &tree, &[&parent]) + .unwrap() + }; + + let giant_commit = repo.find_commit(feat_oid).unwrap(); + let base_commit = giant_commit.parents().next().unwrap(); + + repo.set_head_detached(feat_oid).unwrap(); + repo.branch("feat", &giant_commit, true).unwrap(); + repo.branch(&master, &base_commit, true).unwrap(); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + initial_branch: "feat".to_string(), + ..AppState::default() + }; + state.refresh_tree(None); + + // Ensure "feat" is selected + if let Some(idx) = state.flattened_tree.iter().position(|(n, _)| n == "feat") { + state.list_state.select(Some(idx)); + } else { + panic!("feat branch not found in flattened tree"); + } + + // 2. Press 'x' to trigger split + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))); + assert_eq!(state.mode, AppMode::Split); + + // 3. Manually load diff + let diff = git.get_diff("feat", &master).unwrap(); + state.update(Msg::DiffLoaded("feat".to_string(), Ok(diff))); + + { + let split_state = state.split_state.as_ref().unwrap(); + assert_eq!(split_state.files.len(), 2); + assert_eq!(split_state.mode, SplitViewMode::Files); + } + + // 4. Select only first file + // feat branch modifies a.txt and b.txt. + // By default, focus is on first file (a.txt). + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::NONE, + ))); + + { + let split_state = state.split_state.as_ref().unwrap(); + assert_eq!(split_state.current_selection.len(), 1); + let selected = split_state.current_selection.iter().next().unwrap(); + assert_eq!(selected.0, "a.txt"); + } + + // 5. Press 'Enter' to trigger name prompt (since we have a selection) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))); + assert_eq!(state.mode, AppMode::Prompt); + assert_eq!(state.prompt.as_ref().unwrap().value, "feat-1"); + + // Verify it's rendered on screen + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw_prompt(f, &state)).unwrap(); + let buffer = terminal.backend().buffer(); + + let mut found_default_name = false; + for y in 0..20 { + let mut line = String::new(); + for x in 0..80 { + line.push_str(buffer[(x, y)].symbol()); + } + if line.contains("feat-1") { + found_default_name = true; + break; + } + } + assert!( + found_default_name, + "Default branch name 'feat-1' not found on screen" + ); + + // 6. Confirm name -> message prompt + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))); + assert_eq!(state.mode, AppMode::Prompt); + assert!( + state + .prompt + .as_ref() + .unwrap() + .title + .contains("Commit Message") + ); + + // 7. Confirm message -> back to split view + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::CONTROL, + ))); + assert_eq!(state.mode, AppMode::Split); + { + let split_state = state.split_state.as_ref().unwrap(); + assert_eq!(split_state.parts.len(), 1); + assert_eq!(split_state.parts[0].name, "feat-1"); + } + + // 8. Confirm all splits + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))); + + // 9. Verify final state + assert_eq!(state.mode, AppMode::Tree); + + // Verify virtual layer + assert!(state.virtual_layer.is_hidden("feat")); + assert!(state.virtual_layer.is_virtual("feat-1")); + assert!(state.virtual_layer.is_virtual("feat")); + + // Verify tree structure + state.refresh_tree(None); + + // Test rendering + let backend = ratatui::backend::TestBackend::new(80, 20); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|f| gitui::ui::draw_main(f, &mut state)) + .unwrap(); + + // Tree should now be master -> feat-1 -> feat + let names: Vec = state + .flattened_tree + .iter() + .map(|(n, _)| n.clone()) + .collect(); + assert!(names.contains(&master)); + assert!(names.contains(&"feat-1".to_string())); + assert!(names.contains(&"feat".to_string())); + + let master_idx = names.iter().position(|n| n == &master).unwrap(); + let feat1_idx = names.iter().position(|n| n == "feat-1").unwrap(); + let feat_idx = names.iter().position(|n| n == "feat").unwrap(); + + assert!(feat1_idx > master_idx); + assert!(feat_idx > feat1_idx); + + let depths: Vec = state.flattened_tree.iter().map(|(_, d)| *d).collect(); + assert_eq!(depths[feat1_idx], depths[master_idx] + 1); + assert_eq!(depths[feat_idx], depths[feat1_idx] + 1); +} diff --git a/tools/gitui/test/split_ui_tests.rs b/tools/gitui/test/split_ui_tests.rs new file mode 100644 index 0000000..d7fa016 --- /dev/null +++ b/tools/gitui/test/split_ui_tests.rs @@ -0,0 +1,1012 @@ +use gitui::diff_utils::{DiffLine, FileDiff, Hunk, LineType}; +use gitui::split_state::{RenderedItem, SplitPart, SplitState, SplitViewMode}; +use gitui::state::AppState; +use gitui::ui; +use ratatui::{Terminal, backend::TestBackend}; + +#[test] +fn test_file_header_hidden_when_all_hunks_taken() { + let file1 = FileDiff { + path: "a.txt".to_string(), + hunks: vec![Hunk { + header: "h1".to_string(), + ..Default::default() + }], + }; + let file2 = FileDiff { + path: "b.txt".to_string(), + hunks: vec![Hunk { + header: "h2".to_string(), + ..Default::default() + }], + }; + + let mut state = SplitState::new(vec![file1, file2]); + assert_eq!(state.rendered_items.len(), 2); // Two file headers + + // Take the only hunk of file 1 + state.parts.push(SplitPart { + name: "p1".to_string(), + commit_message: "m1".to_string(), + selected_hunks: [("a.txt".to_string(), 0)].into_iter().collect(), + }); + state.rebuild_view(); + + // Now only file 2 should be visible + assert_eq!(state.rendered_items.len(), 1); + match &state.rendered_items[0] { + RenderedItem::FileHeader { file_idx } => assert_eq!(*file_idx, 1), + _ => panic!("Expected file header for file 2"), + } +} + +#[test] +fn test_split_state_cursor_recovery_when_hunk_taken() { + let file1 = FileDiff { + path: "a.txt".to_string(), + hunks: vec![ + Hunk { + header: "h1".to_string(), + ..Default::default() + }, + Hunk { + header: "h2".to_string(), + ..Default::default() + }, + ], + }; + + let mut state = SplitState::new(vec![file1]); + state.mode = SplitViewMode::Hunks; + state.rebuild_view(); + + // 1. Focus hunk 2 (second hunk of file 1) + state.next(); // Focus hunk 1 + state.next(); // Focus hunk 2 + assert_eq!(state.get_focused_hunk_idx(), Some(1)); + + // 2. Take hunk 2 + state.parts.push(SplitPart { + name: "p1".to_string(), + commit_message: "m1".to_string(), + selected_hunks: [("a.txt".to_string(), 1)].into_iter().collect(), + }); + state.rebuild_view(); + + // 3. Cursor should move to hunk 1 (since hunk 2 is gone) + assert_eq!(state.get_focused_hunk_idx(), Some(0)); + assert_eq!(state.mode, SplitViewMode::Hunks); +} + +#[test] +fn test_taken_hunks_not_rendered() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::default(); + + let file = FileDiff { + path: "test.txt".to_string(), + hunks: vec![ + Hunk { + header: "hunk1".to_string(), + ..Default::default() + }, + Hunk { + header: "hunk2".to_string(), + ..Default::default() + }, + ], + }; + + let mut split_state = SplitState::new(vec![file]); + split_state.mode = SplitViewMode::Hunks; + // Mark hunk1 as taken + split_state.parts.push(SplitPart { + name: "p1".to_string(), + commit_message: "m1".to_string(), + selected_hunks: [("test.txt".to_string(), 0)].into_iter().collect(), + }); + + split_state.mode = SplitViewMode::Hunks; + split_state.rebuild_view(); + + state.split_state = Some(split_state); + + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + + let mut found_hunk1 = false; + let mut found_hunk2 = false; + for y in 0..20 { + let mut line = String::new(); + for x in 0..80 { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("hunk1") { + found_hunk1 = true; + } + if line.contains("hunk2") { + found_hunk2 = true; + } + } + + assert!( + !found_hunk1, + "hunk1 should be hidden because it is already in a part" + ); + assert!(found_hunk2, "hunk2 should be visible"); +} + +#[test] +fn test_split_state_navigation() { + let file1 = FileDiff { + path: "a.txt".to_string(), + hunks: vec![ + Hunk { + header: "h1".to_string(), + lines: vec![DiffLine { + content: "l1\n".to_string(), + ..Default::default() + }], + ..Default::default() + }, + Hunk { + header: "h2".to_string(), + lines: vec![DiffLine { + content: "l2\n".to_string(), + ..Default::default() + }], + ..Default::default() + }, + ], + }; + let file2 = FileDiff { + path: "b.txt".to_string(), + hunks: vec![Hunk { + header: "h3".to_string(), + lines: vec![DiffLine { + content: "l3\n".to_string(), + ..Default::default() + }], + ..Default::default() + }], + }; + + let mut state = SplitState::new(vec![file1, file2]); + + assert_eq!(state.mode, SplitViewMode::Files); + assert_eq!(state.get_focused_file_idx(), Some(0)); + + state.next(); + assert_eq!(state.get_focused_file_idx(), Some(1)); + + state.prev(); + assert_eq!(state.get_focused_file_idx(), Some(0)); + + // Enter hunks + state.enter_hunks(); + assert_eq!(state.mode, SplitViewMode::Hunks); + assert_eq!(state.get_focused_hunk_idx(), Some(0)); + + state.next(); + assert_eq!(state.get_focused_hunk_idx(), Some(1)); + + state.next(); // Should stop at the end of the current file's hunks + assert_eq!(state.get_focused_file_idx(), Some(0)); + assert_eq!(state.get_focused_hunk_idx(), Some(1)); + + state.prev(); + assert_eq!(state.get_focused_hunk_idx(), Some(0)); + + state.prev(); // Should stop at the start of the current file's hunks + assert_eq!(state.get_focused_file_idx(), Some(0)); + assert_eq!(state.get_focused_hunk_idx(), Some(0)); + + state.enter_lines(); + assert_eq!(state.mode, SplitViewMode::Lines); + state.exit_lines(); + assert_eq!(state.mode, SplitViewMode::Hunks); + + // Exit hunks + state.exit_hunks(); + assert_eq!(state.mode, SplitViewMode::Files); +} + +#[test] +fn test_navigation_bug_exit_hunks() { + let file0 = FileDiff { + path: "file0.txt".to_string(), + hunks: vec![Hunk { + header: "h0".to_string(), + ..Default::default() + }], + }; + let file1 = FileDiff { + path: "file1.txt".to_string(), + hunks: vec![Hunk { + header: "h1".to_string(), + ..Default::default() + }], + }; + let file2 = FileDiff { + path: "file2.txt".to_string(), + hunks: vec![Hunk { + header: "h2".to_string(), + ..Default::default() + }], + }; + + let mut state = SplitState::new(vec![file0, file1, file2]); + + // 1. We are on file0 + assert_eq!(state.get_focused_file_idx(), Some(0)); + + // 2. Move to file1 + state.next(); + assert_eq!(state.get_focused_file_idx(), Some(1)); + + // 3. Enter hunks of file1 + state.enter_hunks(); + assert_eq!(state.mode, SplitViewMode::Hunks); + assert_eq!(state.get_focused_file_idx(), Some(1)); + assert_eq!(state.get_focused_hunk_idx(), Some(0)); + // Index in Hunks mode: + // 0: File0 (Header) + // 1: File1 (Header) + // 2: Hunk1 (Header) <-- we are here + // 3: File2 (Header) + assert_eq!(state.selected_view_idx, 2); + + // 4. Exit hunks + state.exit_hunks(); + + // 5. We should be back on File1 + assert_eq!(state.mode, SplitViewMode::Files); + assert_eq!( + state.get_focused_file_idx(), + Some(1), + "Should be on File1 after exiting hunks" + ); + assert_eq!( + state.selected_view_idx, 1, + "Should be at index 1 (File1) in Files mode" + ); +} + +#[test] +fn test_split_state_page_navigation() { + let mut files = Vec::new(); + for i in 0..50 { + files.push(FileDiff { + path: format!("file-{}.txt", i), + hunks: Vec::new(), + }); + } + + let mut state = SplitState::new(files); + assert_eq!(state.get_focused_file_idx(), Some(0)); + + state.page_down(15); + assert_eq!(state.get_focused_file_idx(), Some(15)); + + state.page_down(100); // Beyond end + assert_eq!(state.get_focused_file_idx(), Some(49)); + + state.page_up(10); + assert_eq!(state.get_focused_file_idx(), Some(39)); + + state.page_up(100); // Beyond start + assert_eq!(state.get_focused_file_idx(), Some(0)); +} + +#[test] +fn test_split_state_line_navigation() { + let file1 = FileDiff { + path: "a.txt".to_string(), + hunks: vec![Hunk { + header: "h1".to_string(), + lines: vec![ + DiffLine { + content: "l1\n".to_string(), + ..Default::default() + }, + DiffLine { + content: "l2\n".to_string(), + ..Default::default() + }, + ], + ..Default::default() + }], + }; + + let mut state = SplitState::new(vec![file1]); + state.enter_hunks(); + + assert_eq!(state.get_focused_hunk_idx(), Some(0)); + assert_eq!(state.mode, SplitViewMode::Hunks); + assert_eq!(state.list_state.selected(), Some(1)); // File header (0), Hunk header (1) + + // In Hunks mode, next should skip lines (but here there's only one hunk) + state.next(); + assert_eq!(state.get_focused_hunk_idx(), Some(0)); // Still 0 because only one hunk + assert_eq!(state.mode, SplitViewMode::Hunks); + + // Enter lines mode + state.enter_lines(); + assert_eq!(state.mode, SplitViewMode::Lines); + assert_eq!(state.get_focused_line_idx(), Some(0)); + assert_eq!(state.list_state.selected(), Some(2)); // Hunk line 0 + + state.next(); + assert_eq!(state.get_focused_line_idx(), Some(1)); + assert_eq!(state.list_state.selected(), Some(3)); // Hunk line 1 + + state.next(); + assert_eq!(state.get_focused_line_idx(), Some(1)); // Trapped in hunk + + state.prev(); + assert_eq!(state.get_focused_line_idx(), Some(0)); + + state.prev(); + assert_eq!(state.get_focused_line_idx(), None); + + state.prev(); + assert_eq!(state.get_focused_line_idx(), None); // Trapped in hunk (at header level) + + state.exit_lines(); + assert_eq!(state.mode, SplitViewMode::Hunks); +} + +#[test] +fn test_hunks_mode_sticky_header() { + let mut hunks = Vec::new(); + for i in 0..20 { + hunks.push(Hunk { + header: format!("hunk-{}", i), + ..Default::default() + }); + } + let file1 = FileDiff { + path: "file1.txt".to_string(), + hunks, + }; + + let mut state = SplitState::new(vec![file1]); + state.enter_hunks(); + + // Move to next hunk + state.next(); + assert_eq!(state.get_focused_hunk_idx(), Some(1)); + // Offset should be the file index (0) + assert_eq!(state.list_state.offset(), 0); + + // Move down more + for _ in 0..10 { + state.next(); + } + assert_eq!(state.get_focused_hunk_idx(), Some(11)); + assert_eq!( + state.list_state.offset(), + 0, + "File header should remain at the top" + ); +} + +#[test] +fn test_lines_mode_marker_rendering() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::default(); + + let file = FileDiff { + path: "test.txt".to_string(), + hunks: vec![Hunk { + header: "@@ hunk1 @@".to_string(), + lines: vec![ + DiffLine { + content: "line1\n".to_string(), + line_type: LineType::Addition, + ..Default::default() + }, + DiffLine { + content: "line2\n".to_string(), + line_type: LineType::Addition, + ..Default::default() + }, + ], + ..Default::default() + }], + }; + + let mut split_state = SplitState::new(vec![file]); + split_state.enter_hunks(); + split_state.enter_lines(); + split_state.next(); // Focus second line ("line2") + state.split_state = Some(split_state); + + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + + // Search for the marker ">>" and "line2" on the same line + let mut found_line_marker = false; + for y in 0..20 { + let mut line_content = String::new(); + for x in 0..80 { + line_content.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line_content.contains(">>") && line_content.contains("+line2") { + found_line_marker = true; + break; + } + } + + assert!( + found_line_marker, + "Focus marker '>>' not found on focused line in Lines mode" + ); +} + +#[test] +fn test_lines_mode_scrolling_and_marker() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::default(); + + let file = FileDiff { + path: "test.txt".to_string(), + hunks: vec![Hunk { + header: "@@ hunk1 @@".to_string(), + lines: vec![ + DiffLine { + content: "l1\n".to_string(), + ..Default::default() + }, + DiffLine { + content: "l2\n".to_string(), + ..Default::default() + }, + DiffLine { + content: "l3\n".to_string(), + ..Default::default() + }, + ], + ..Default::default() + }], + }; + + let mut split_state = SplitState::new(vec![file]); + split_state.enter_hunks(); + split_state.enter_lines(); + + // Initially on line 1 + state.split_state = Some(split_state); + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert!(buffer_contains_line(buffer, ">>", "l1")); + + // Scroll down to line 2 + if let Some(ref mut s) = state.split_state { + s.next(); + } + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert!(buffer_contains_line(buffer, ">>", "l2")); + assert!(!buffer_contains_line(buffer, ">>", "l1")); + + // Scroll down to line 3 + if let Some(ref mut s) = state.split_state { + s.next(); + } + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert!(buffer_contains_line(buffer, ">>", "l3")); +} + +fn buffer_contains_line(buffer: &ratatui::buffer::Buffer, marker: &str, content: &str) -> bool { + for y in 0..buffer.area.height { + let mut line_str = String::new(); + for x in 0..buffer.area.width { + line_str.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line_str.contains(marker) && line_str.contains(content) { + return true; + } + } + false +} + +#[test] +fn test_split_state_selection() { + let file1 = FileDiff { + path: "a.txt".to_string(), + hunks: vec![ + Hunk { + header: "h1".to_string(), + ..Default::default() + }, + Hunk { + header: "h2".to_string(), + ..Default::default() + }, + ], + }; + + let mut state = SplitState::new(vec![file1]); + + // Toggle entire file + state.toggle_selection(); + assert_eq!(state.current_selection.len(), 2); + assert!(state.current_selection.contains(&("a.txt".to_string(), 0))); + assert!(state.current_selection.contains(&("a.txt".to_string(), 1))); + + state.toggle_selection(); + assert_eq!(state.current_selection.len(), 0); + + // Toggle individual hunk + state.enter_hunks(); + state.toggle_selection(); + assert_eq!(state.current_selection.len(), 1); + assert!(state.current_selection.contains(&("a.txt".to_string(), 0))); +} + +#[test] +fn test_ui_dynamic_help_text() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::default(); + + let file = FileDiff { + path: "test.txt".to_string(), + hunks: vec![Hunk { + header: "h1".to_string(), + ..Default::default() + }], + }; + + let split_state = SplitState::new(vec![file]); + state.split_state = Some(split_state); + + // 1. No selection -> should show "enter: finish" + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert!(buffer_contains_line(buffer, "enter: finish", "")); + + // 2. With selection -> should show "enter: new part" + if let Some(ref mut s) = state.split_state { + s.toggle_selection(); + } + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert!(buffer_contains_line(buffer, "enter: new part", "")); +} + +#[test] +fn test_ui_renders_split_view_modes() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::default(); + + let file = FileDiff { + path: "test.txt".to_string(), + hunks: vec![Hunk { + header: "@@ hunk1 @@".to_string(), + lines: vec![DiffLine { + content: "line1\n".to_string(), + line_type: LineType::Addition, + ..Default::default() + }], + ..Default::default() + }], + }; + + let split_state = SplitState::new(vec![file]); + state.split_state = Some(split_state); + + // Test Files Mode + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let mut found_help_files = false; + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("->: view hunks") { + found_help_files = true; + } + } + assert!(found_help_files, "Help text for Files mode not found"); + + // Test Hunks Mode + if let Some(ref mut s) = state.split_state { + s.enter_hunks(); + } + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let mut found_help_hunks = false; + let mut found_hunk_indicator = false; + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("<-: back to files") { + found_help_hunks = true; + } + if line.contains(">>") && line.contains("@@ hunk1 @@") { + found_hunk_indicator = true; + } + } + assert!(found_help_hunks, "Help text for Hunks mode not found"); + assert!( + found_hunk_indicator, + "Hunk focus indicator not found in Hunks mode" + ); +} + +#[test] +fn test_split_view_renders_scrollbar() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::default(); + + let mut files = Vec::new(); + for i in 0..30 { + files.push(FileDiff { + path: format!("file-{}.txt", i), + hunks: Vec::new(), + }); + } + + state.split_state = Some(SplitState::new(files)); + + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let mut found_scrollbar = false; + + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + let symbol = buffer[(x, y)].symbol(); + if symbol == "↑" || symbol == "↓" { + found_scrollbar = true; + break; + } + } + } + + assert!( + found_scrollbar, + "Scrollbar symbols (↑ or ↓) not found in split view when items should fit" + ); +} + +#[test] +fn test_split_view_scrollbar_at_bottom() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::default(); + + let mut files = Vec::new(); + for i in 0..100 { + files.push(FileDiff { + path: format!("file-{}.txt", i), + hunks: Vec::new(), + }); + } + + let mut split_state = SplitState::new(files); + // Focus the last file + for _ in 0..99 { + split_state.next(); + } + state.split_state = Some(split_state); + + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + + // Check if the thumb is at the bottom. + // The viewport is 20 lines. 3 are used for help and borders of chunks. + // Chunk[0] height is 17. Inner height is 15. + // We have 100 items. Offset should be 85. + // Position 85 in total 100 with viewport 15 should be at the bottom. + // So symbol '↓' should be at (or near) the bottom of chunk[0]. + + let mut found_down_arrow_at_bottom = false; + + for x in 0..buffer.area.width { + let symbol = buffer[(x, 15)].symbol(); // Last line before bottom border + if symbol == "↓" { + found_down_arrow_at_bottom = true; + } + } + + assert!( + found_down_arrow_at_bottom, + "Scrollbar bottom symbol '↓' not found at expected position" + ); +} + +#[test] +fn test_ui_no_split_scrollbar_when_items_fit() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::default(); + + let file = FileDiff { + path: "test.txt".to_string(), + hunks: Vec::new(), + }; + + state.split_state = Some(SplitState::new(vec![file])); + + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + let symbol = buffer[(x, y)].symbol(); + assert!( + symbol != "↑" && symbol != "↓", + "Scrollbar symbols found in split view when items should fit" + ); + } + } +} + +#[test] +fn test_split_view_no_hunks_in_files_mode() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::default(); + + let mut files = Vec::new(); + for i in 0..10 { + files.push(FileDiff { + path: format!("file-{:02}.txt", i), + hunks: vec![Hunk { + header: format!("hunk-{:02}", i), + ..Default::default() + }], + }); + } + + let mut split_state = SplitState::new(files); + split_state.mode = SplitViewMode::Files; + // Focus the first file + state.split_state = Some(split_state); + + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + + let mut found_hunk = false; + for y in 0..20 { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("hunk-") { + found_hunk = true; + break; + } + } + + assert!(!found_hunk, "Hunks should not be shown in Files mode"); +} + +#[test] +fn test_split_interaction_deep_navigation() { + let file1 = FileDiff { + path: "a.txt".to_string(), + hunks: vec![Hunk { + header: "h1".to_string(), + lines: vec![DiffLine { + content: "l1\n".to_string(), + ..Default::default() + }], + ..Default::default() + }], + }; + let file2 = FileDiff { + path: "b.txt".to_string(), + hunks: vec![ + Hunk { + header: "h2".to_string(), + lines: vec![DiffLine { + content: "l2\n".to_string(), + ..Default::default() + }], + ..Default::default() + }, + Hunk { + header: "h3".to_string(), + lines: vec![DiffLine { + content: "l3\n".to_string(), + ..Default::default() + }], + ..Default::default() + }, + ], + }; + let file3 = FileDiff { + path: "c.txt".to_string(), + hunks: vec![Hunk { + header: "h4".to_string(), + ..Default::default() + }], + }; + + let mut state = SplitState::new(vec![file1, file2, file3]); + + // 1. Initial state: File 0 + assert_eq!(state.mode, SplitViewMode::Files); + assert_eq!(state.get_focused_file_idx(), Some(0)); + + // 2. Down once: Move to second file (file index 1) + state.next(); + assert_eq!(state.get_focused_file_idx(), Some(1)); + + // 3. Right: enter second file's hunks + state.enter_hunks(); + assert_eq!(state.mode, SplitViewMode::Hunks); + assert_eq!(state.get_focused_file_idx(), Some(1)); + assert_eq!(state.get_focused_hunk_idx(), Some(0)); // First hunk of second file (h2) + + // 4. Down once: move to second chunk of that file (h3) + state.next(); + assert_eq!(state.get_focused_file_idx(), Some(1)); + assert_eq!(state.get_focused_hunk_idx(), Some(1)); // Second hunk of second file (h3) + + // 5. Right: expand/enter second chunk's lines + state.enter_lines(); + assert_eq!(state.mode, SplitViewMode::Lines); + assert_eq!(state.get_focused_file_idx(), Some(1)); + assert_eq!(state.get_focused_hunk_idx(), Some(1)); + assert_eq!(state.get_focused_line_idx(), Some(0)); + + // 6. Left: exit lines back to chunks + state.exit_lines(); + assert_eq!(state.mode, SplitViewMode::Hunks); + assert_eq!( + state.get_focused_file_idx(), + Some(1), + "Should stay on file 1" + ); + assert_eq!( + state.get_focused_hunk_idx(), + Some(1), + "Should stay on hunk 1" + ); +} + +#[test] +fn test_split_page_up_out_of_hunk() { + let mut files = Vec::new(); + for i in 0..3 { + files.push(FileDiff { + path: format!("file-{}.txt", i), + hunks: vec![ + Hunk { + header: format!("f{}h0", i), + ..Default::default() + }, + Hunk { + header: format!("f{}h1", i), + ..Default::default() + }, + ], + }); + } + + let mut state = SplitState::new(files); + + // 1. Enter hunks mode on file 1 + state.next(); // file 1 + state.enter_hunks(); + assert_eq!(state.mode, SplitViewMode::Hunks); + assert_eq!(state.get_focused_file_idx(), Some(1)); + assert_eq!(state.get_focused_hunk_idx(), Some(0)); + + // 2. Move to second hunk of file 1 + state.next(); + assert_eq!(state.get_focused_hunk_idx(), Some(1)); + + // 3. Page up with small height (1). Should move to first hunk. + state.page_up(1); + assert_eq!(state.get_focused_file_idx(), Some(1)); + assert_eq!(state.get_focused_hunk_idx(), Some(0)); + + // 4. Page up again. In Hunks mode, we are constrained to the current file. + // Index 0 is file0 header, index 1 is file1 header, index 2 is f1h0. + // In Hunks mode, we cannot move to the file header of the current file because + // prev() (and now page_up) stops if the previous item is a FileHeader. + state.page_up(1); + assert_eq!(state.get_focused_file_idx(), Some(1)); + assert_eq!(state.get_focused_hunk_idx(), Some(0)); // Still on first hunk + + // 5. Page up again. Should stay at the first hunk of the current file. + state.page_up(1); + assert_eq!(state.get_focused_file_idx(), Some(1)); + assert_eq!(state.get_focused_hunk_idx(), Some(0)); + + // 6. Test Files mode: page up should cross file boundaries + state.exit_hunks(); + state.next(); // file 2 + assert_eq!(state.get_focused_file_idx(), Some(2)); + state.page_up(2); + assert_eq!(state.get_focused_file_idx(), Some(0)); +} + +#[test] +fn test_split_view_scrollbar_200_files() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::default(); + + let mut files = Vec::new(); + for i in 0..200 { + files.push(FileDiff { + path: format!("file-{:03}.txt", i), + hunks: Vec::new(), + }); + } + + let mut split_state = SplitState::new(files); + // Focus the last file (index 199) + for _ in 0..199 { + split_state.next(); + } + state.split_state = Some(split_state); + + terminal + .draw(|f| ui::draw_split_view(f, &mut state)) + .unwrap(); + let buffer = terminal.backend().buffer(); + + // chunks[0] height is 17 (0 to 16). + // Scrollbar is in chunks[0].inner(vertical: 1), so height 15 (1 to 15). + // Thumb should be at the very bottom, just above or at the '↓' symbol. + + let mut thumb_y = None; + for y in 1..16 { + if buffer[(79, y)].symbol() == "█" { + thumb_y = Some(y); + } + } + + assert!(thumb_y.is_some(), "Scrollbar thumb '█' not found"); + // In a 15-height scrollbar (including arrows), bottom thumb should be at y=14 + assert_eq!( + thumb_y.unwrap(), + 14, + "Thumb should be at the bottom (y=14) for 200 items, but found at y={}", + thumb_y.unwrap() + ); +} + +// end of file diff --git a/tools/gitui/test/submit_integration_tests.rs b/tools/gitui/test/submit_integration_tests.rs new file mode 100644 index 0000000..c15a38d --- /dev/null +++ b/tools/gitui/test/submit_integration_tests.rs @@ -0,0 +1,198 @@ +use gitui::engine::{BranchIntent, Git, RealGit}; +use gitui::execute_plan; +use gitui::testing::{create_commit, run_git, setup_repo}; +use std::collections::HashMap; + +#[test] +fn test_integration_submit_readiness() { + let (dir, repo, initial_branch) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // Ensure we have a branch named "master" + let master = if initial_branch != "master" { + run_git(path_str, &["branch", "-m", &initial_branch, "master"]); + "master".to_string() + } else { + initial_branch + }; + + let base_oid = run_git(path_str, &["rev-parse", &master]); + + // 1. Setup a remote + let remote_dir = tempfile::tempdir().unwrap(); + run_git(remote_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + remote_dir.path().to_str().unwrap(), + ], + ); + + // 2. feat-ready + run_git(path_str, &["checkout", "-b", "feat-ready", &base_oid]); + create_commit(&repo, "ready.txt", "ready", "Commit ready"); + run_git(path_str, &["push", "origin", "feat-ready"]); + run_git( + path_str, + &[ + "branch", + "--set-upstream-to=origin/feat-ready", + "feat-ready", + ], + ); + run_git(path_str, &["fetch", "origin"]); + + // 3. feat-not-pushed + run_git(path_str, &["checkout", "-b", "feat-not-pushed", &base_oid]); + create_commit(&repo, "no-push.txt", "no-push", "Commit no-push"); + + // 4. feat-out-of-sync + run_git(path_str, &["checkout", "-b", "feat-out-of-sync", &base_oid]); + create_commit(&repo, "oos.txt", "oos", "Commit oos"); + run_git(path_str, &["push", "origin", "feat-out-of-sync"]); + run_git( + path_str, + &[ + "branch", + "--set-upstream-to=origin/feat-out-of-sync", + "feat-out-of-sync", + ], + ); + run_git(path_str, &["fetch", "origin"]); + create_commit(&repo, "oos2.txt", "oos2", "Commit oos2"); + + { + let git = RealGit::new(path_str).unwrap(); + let (branches, _) = git.get_branches(None).unwrap(); + let get_b = |name: &str| { + branches + .iter() + .find(|b| b.name == name) + .expect("branch not found") + }; + let is_ready = |name: &str| { + let b = get_b(name); + b.can_submit() + }; + + assert!(get_b("feat-ready").is_ahead); + assert!(is_ready("feat-ready")); + assert!(!is_ready("feat-not-pushed")); + assert!(!is_ready("feat-out-of-sync")); + } + + // 5. feat-merged + run_git(path_str, &["checkout", "-b", "feat-merged", &base_oid]); + create_commit(&repo, "merged.txt", "merged", "Commit merged"); + run_git(path_str, &["checkout", &master]); + run_git(path_str, &["merge", "feat-merged"]); + + let git = RealGit::new(path_str).unwrap(); + let (branches, _) = git.get_branches(None).unwrap(); + let get_b = |name: &str| { + branches + .iter() + .find(|b| b.name == name) + .expect("branch not found") + }; + let is_ready = |name: &str| { + let b = get_b(name); + b.can_submit() + }; + + assert!(!get_b("feat-ready").is_ahead); + assert!(!is_ready("feat-ready")); + + let fm = get_b("feat-merged"); + assert!(fm.is_merged); + assert!(!is_ready("feat-merged")); +} + +#[test] +fn test_integration_complex_submit_execution() { + let (dir, repo, initial_branch) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + let master = if initial_branch != "master" { + run_git(path_str, &["branch", "-m", &initial_branch, "master"]); + "master".to_string() + } else { + initial_branch + }; + + let base_oid = run_git(path_str, &["rev-parse", "HEAD"]); + + // 1. Setup remotes + let origin_dir = tempfile::tempdir().unwrap(); + run_git(origin_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + origin_dir.path().to_str().unwrap(), + ], + ); + + let upstream_dir = tempfile::tempdir().unwrap(); + run_git(upstream_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "upstream", + upstream_dir.path().to_str().unwrap(), + ], + ); + + // 2. Setup tree + run_git(path_str, &["checkout", "-b", "feat-a", &base_oid]); + create_commit(&repo, "a.txt", "a", "Commit A"); + run_git(path_str, &["checkout", "-b", "feat-b"]); + create_commit(&repo, "b.txt", "b", "Commit B"); + run_git(path_str, &["push", "origin", "feat-b"]); + run_git( + path_str, + &["branch", "--set-upstream-to=origin/feat-b", "feat-b"], + ); + run_git(path_str, &["checkout", "-b", "feat-c"]); + create_commit(&repo, "c.txt", "c", "Commit C"); + run_git(path_str, &["checkout", "-b", "feat-d", &base_oid]); + create_commit(&repo, "d.txt", "d", "Commit D"); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut intents = HashMap::new(); + + // 3. Mark feat-b for submit + { + let _b = branches.iter().find(|b| b.name == "feat-b").unwrap(); + intents.insert( + "feat-b".to_string(), + BranchIntent { + pending_submit: true, + ..Default::default() + }, + ); + } + + // 4. Execute plan + execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // 5. Verify results + let master_oid = run_git(path_str, &["rev-parse", &master]); + let origin_feat_b = run_git(path_str, &["ls-remote", "origin", "feat-b"]); + assert!(origin_feat_b.is_empty()); + let upstream_master_oid = run_git(path_str, &["ls-remote", "upstream", &master]); + assert!(upstream_master_oid.contains(&master_oid)); + + let merge_base_c = run_git(path_str, &["merge-base", "feat-c", &master]); + assert_eq!(merge_base_c, master_oid); + let merge_base_d = run_git(path_str, &["merge-base", "feat-d", &master]); + assert_eq!(merge_base_d, master_oid); +} diff --git a/tools/gitui/test/sync_conflict_tests.rs b/tools/gitui/test/sync_conflict_tests.rs new file mode 100644 index 0000000..2c4c661 --- /dev/null +++ b/tools/gitui/test/sync_conflict_tests.rs @@ -0,0 +1,134 @@ +use gitui::engine::{ + BranchIntent, Git, Operation, RealGit, RepositorySnapshot, calculate_plan, predict_conflicts, +}; +use gitui::testing::{run_git, setup_repo}; +use std::collections::HashMap; + +#[test] +fn test_sync_conflict_prediction_uses_new_master() { + let (dir, _repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Create a file in initial commit + let path_conflict = dir.path().join("conflict.txt"); + std::fs::write(&path_conflict, "initial\n").unwrap(); + run_git(path_str, &["add", "conflict.txt"]); + run_git(path_str, &["commit", "-m", "Add conflict.txt"]); + + // 2. Setup 'upstream' remote + let upstream_dir = tempfile::tempdir().unwrap(); + run_git(upstream_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "upstream", + upstream_dir.path().to_str().unwrap(), + ], + ); + + // 3. Create a conflict in upstream/master + // Current master has "initial" in conflict.txt. + // upstream/master will have "upstream change" in conflict.txt. + std::fs::write(&path_conflict, "upstream change\n").unwrap(); + run_git(path_str, &["add", "conflict.txt"]); + run_git(path_str, &["commit", "-m", "Upstream change"]); + run_git( + path_str, + &["push", "upstream", &format!("{}:{}", master, master)], + ); + + // 4. Reset local master back to "initial" state (one commit back) + run_git(path_str, &["reset", "--hard", "HEAD^"]); + + // 5. Create a feature branch that ALSO modifies conflict.txt (conflicting with upstream) + // but it DOES NOT conflict with current local master. + run_git(path_str, &["checkout", "-b", "feat", &master]); + std::fs::write(&path_conflict, "local change\n").unwrap(); + run_git(path_str, &["add", "conflict.txt"]); + run_git(path_str, &["commit", "-m", "Local change"]); + + // 6. Set master to track origin (simulated fork) + let origin_dir = tempfile::tempdir().unwrap(); + run_git(origin_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git(path_str, &["checkout", &master]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + origin_dir.path().to_str().unwrap(), + ], + ); + run_git( + path_str, + &[ + "push", + "--force", + "origin", + &format!("{}:{}", master, master), + ], + ); + run_git( + path_str, + &[ + "branch", + &format!("--set-upstream-to=origin/{}", master), + &master, + ], + ); + + // Fetch upstream to have the conflicting commit locally + run_git(path_str, &["fetch", "upstream"]); + + // 7. Initialize Git and Snapshot + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let is_dirty = git.is_dirty().unwrap(); + + let mut intents = HashMap::new(); + intents.insert( + master.clone(), + BranchIntent { + pending_reset: true, + ..Default::default() + }, + ); + + let snapshot = RepositorySnapshot { + branches, + history, + is_dirty, + }; + + // 8. Calculate plan and predict conflicts + let mut plan = calculate_plan(&snapshot, &intents).unwrap(); + predict_conflicts(&mut plan, &git, &snapshot.branches, None); + + // 9. Verify that 'feat' rebase is marked as CONFLICT + let feat_rebase = plan.iter().find(|op| { + if let Operation::Rebase { branch, .. } = op { + branch == "feat" + } else { + false + } + }); + + if feat_rebase.is_none() { + panic!("Rebase operation for 'feat' not found. Plan: {:?}", plan); + } + + if let Some(Operation::Rebase { + predicted_conflict, .. + }) = feat_rebase + { + assert_eq!( + *predicted_conflict, + Some(true), + "Rebase of 'feat' should be predicted as CONFLICT because of upstream changes in master. Plan: {:?}", + plan + ); + } +} diff --git a/tools/gitui/test/sync_integration_tests.rs b/tools/gitui/test/sync_integration_tests.rs new file mode 100644 index 0000000..f497a66 --- /dev/null +++ b/tools/gitui/test/sync_integration_tests.rs @@ -0,0 +1,105 @@ +use gitui::engine::{BranchIntent, Git, RealGit}; +use gitui::testing::{create_commit, run_git, setup_repo}; +use std::collections::HashMap; + +#[test] +fn test_integration_sync_master_from_upstream() { + let (dir, repo, mut master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + if master != "master" && master != "main" { + run_git(path_str, &["branch", "-m", &master, "master"]); + master = "master".to_string(); + } + + // 1. Setup 'upstream' remote + let upstream_dir = tempfile::tempdir().unwrap(); + run_git(upstream_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "upstream", + upstream_dir.path().to_str().unwrap(), + ], + ); + + // 2. Setup 'origin' remote + let origin_dir = tempfile::tempdir().unwrap(); + run_git(origin_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + origin_dir.path().to_str().unwrap(), + ], + ); + + // 3. Make 'upstream/master' ahead + create_commit(&repo, "upstream.txt", "upstream", "Upstream commit"); + let upstream_oid = run_git(path_str, &["rev-parse", "HEAD"]); + run_git( + path_str, + &["push", "upstream", &format!("{}:{}", master, master)], + ); + + // Reset local master back to initial commit + run_git(path_str, &["reset", "--hard", "HEAD^"]); + let initial_oid = run_git(path_str, &["rev-parse", "HEAD"]); + assert_ne!(initial_oid, upstream_oid); + + // 4. Setup local master to track origin/master (simulating a fork) + run_git( + path_str, + &["push", "origin", &format!("{}:{}", master, master)], + ); + run_git( + path_str, + &[ + "branch", + &format!("--set-upstream-to=origin/{}", master), + &master, + ], + ); + + // 5. Create feat1 off the current master + run_git(path_str, &["checkout", "-b", "feat1", &master]); + create_commit(&repo, "feat1.txt", "feat1", "Add feat1"); + run_git(path_str, &["checkout", &master]); + + // Fetch upstream so it exists in our repo + run_git(path_str, &["fetch", "upstream"]); + + // 6. Run gitui engine + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + // 7. Mark master for reset + let mut intents = HashMap::new(); + intents.insert( + master.clone(), + BranchIntent { + pending_reset: true, + ..Default::default() + }, + ); + + // 8. Execute plan + gitui::execute_plan(&git, &branches, &intents, &history, path_str).unwrap(); + + // 9. Verify master is updated to upstream/master's OID + let master_oid = run_git(path_str, &["rev-parse", &master]); + assert_eq!(master_oid, upstream_oid); + + // 10. Verify origin/master is also updated (since Sync pushes to origin) + run_git(path_str, &["fetch", "origin"]); + let origin_oid = run_git(path_str, &["rev-parse", &format!("origin/{}", master)]); + assert_eq!(origin_oid, upstream_oid); + + // 11. Verify feat1 was rebased onto new master + let feat1_merge_base = run_git(path_str, &["merge-base", "feat1", &master]); + assert_eq!(feat1_merge_base, master_oid); +} diff --git a/tools/gitui/test/sync_tui_integration_tests.rs b/tools/gitui/test/sync_tui_integration_tests.rs new file mode 100644 index 0000000..009e62d --- /dev/null +++ b/tools/gitui/test/sync_tui_integration_tests.rs @@ -0,0 +1,113 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use gitui::engine::{Git, Operation, RealGit}; +use gitui::state::{AppMode, AppState, Msg}; +use gitui::testing::{create_commit, run_git, setup_repo}; + +#[tokio::test] +async fn test_tui_sync_master_plan() { + let (dir, repo, mut master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + if master != "master" && master != "main" { + run_git(path_str, &["branch", "-m", &master, "master"]); + master = "master".to_string(); + } + + // 1. Setup 'upstream' remote + let upstream_dir = tempfile::tempdir().unwrap(); + run_git(upstream_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "upstream", + upstream_dir.path().to_str().unwrap(), + ], + ); + + // 2. Setup 'origin' remote + let origin_dir = tempfile::tempdir().unwrap(); + run_git(origin_dir.path().to_str().unwrap(), &["init", "--bare"]); + run_git( + path_str, + &[ + "remote", + "add", + "origin", + origin_dir.path().to_str().unwrap(), + ], + ); + + // 3. Make 'upstream/master' ahead + create_commit(&repo, "upstream.txt", "upstream", "Upstream commit"); + run_git( + path_str, + &["push", "upstream", &format!("{}:{}", master, master)], + ); + + // Reset local master back to initial commit + run_git(path_str, &["reset", "--hard", "HEAD^"]); + + // 4. Setup local master to track origin/master + run_git( + path_str, + &["push", "origin", &format!("{}:{}", master, master)], + ); + run_git( + path_str, + &[ + "branch", + &format!("--set-upstream-to=origin/{}", master), + &master, + ], + ); + + // Fetch upstream so it exists + run_git(path_str, &["fetch", "upstream"]); + + // 5. Initialize TUI state (show_remote = false by default) + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + is_dirty: false, + show_remote: false, + ..AppState::default() + }; + state.refresh_tree(None); + + // Ensure master is selected + if let Some(idx) = state.flattened_tree.iter().position(|(n, _)| n == &master) { + state.list_state.select(Some(idx)); + } else { + panic!("master branch not found in flattened tree"); + } + + // 6. Press 'r' to sync + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('r'), + KeyModifiers::NONE, + ))); + + // Verify intent is set + assert!(state.get_intent(&master).pending_reset); + + // 7. Press 'v' to preview + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('v'), + KeyModifiers::NONE, + ))); + + assert_eq!(state.mode, AppMode::Preview); + let plan = state.plan.as_ref().expect("Plan should be calculated"); + + let has_sync = plan.iter().any(|op| matches!(op, Operation::Sync { .. })); + assert!( + has_sync, + "Plan should contain a Sync operation even if remote branches are not shown in UI. Current plan: {:?}", + plan + ); +} diff --git a/tools/gitui/test/topology_proptests.rs b/tools/gitui/test/topology_proptests.rs new file mode 100644 index 0000000..e1f3d6a --- /dev/null +++ b/tools/gitui/test/topology_proptests.rs @@ -0,0 +1,132 @@ +use gitui::testing::mock_oid; +use gitui::topology::{Intent, VirtualTopology}; +use proptest::prelude::*; +use std::collections::HashSet; + +/// Generates a random DAG of branch names and their parent relationships. +fn arb_topology( + max_branches: usize, +) -> impl Strategy, Vec<(String, String)>)> { + // Generate between 1 and max_branches unique branch names + prop::collection::vec("[a-z]{1,5}", 1..max_branches) + .prop_map(|names| { + let mut unique_names = HashSet::new(); + names + .into_iter() + .filter(|name| unique_names.insert(name.clone())) + .collect::>() + }) + .prop_flat_map(|names| { + let n = names.len(); + let mut strategies = Vec::new(); + for i in 1..n { + let name = names[i].clone(); + let possible_parents = names[0..i].to_vec(); + strategies.push(prop::option::weighted( + 0.7, + (Just(name), prop::sample::select(possible_parents)), + )); + } + (Just(names), strategies) + }) + .prop_map(|(names, relationships)| { + let rels: Vec<(String, String)> = relationships.into_iter().flatten().collect(); + (names, rels) + }) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn test_flatten_is_complete_and_consistent( + (names, rels) in arb_topology(20) + ) { + let mut topo = VirtualTopology::new(); + for (i, name) in names.iter().enumerate() { + topo.add_branch(name, mock_oid(i as u8)); + } + + for (child, parent) in rels { + topo.set_parent(&child, Some(&parent), None, Intent::Implicit).unwrap(); + } + + let flattened = topo.flatten(); + + // 1. Completeness: All branches must be present exactly once + prop_assert_eq!(flattened.len(), names.len(), "Flattened length {} does not match names length {}", flattened.len(), names.len()); + let flattened_names: HashSet<_> = flattened.iter().map(|(n, _)| n.clone()).collect(); + prop_assert_eq!(flattened_names.len(), names.len(), "Duplicate names in flattened result"); + for name in &names { + prop_assert!(flattened_names.contains(name), "Name '{}' missing from flattened result", name); + } + + // 2. Hierarchical Consistency: Parent must precede its children + for (i, (name, depth)) in flattened.iter().enumerate() { + let parents = topo.get_parents(name); + if let Some(parent_node) = parents.first() { + if let Some(parent_name) = parent_node.name() { + let parent_pos = flattened.iter().position(|(n, _)| n == parent_name) + .expect("Parent should be in flattened list"); + + prop_assert!(parent_pos < i, "Parent '{}' (pos {}) must precede child '{}' (pos {})", parent_name, parent_pos, name, i); + + // Depth should be parent_depth + 1 + let parent_depth = flattened[parent_pos].1; + prop_assert_eq!(*depth, parent_depth + 1, "Child '{}' depth {} should be parent '{}' depth {} + 1", name, depth, parent_name, parent_depth); + } + } else { + // Root nodes should have depth 0 + prop_assert_eq!(*depth, 0, "Root node '{}' should have depth 0, found {}", name, depth); + } + } + } + + #[test] + fn test_set_parent_cycle_prevention( + (names, rels) in arb_topology(10) + ) { + let mut topo = VirtualTopology::new(); + for (i, name) in names.iter().enumerate() { + topo.add_branch(name, mock_oid(i as u8)); + } + + for (child, parent) in rels { + topo.set_parent(&child, Some(&parent), None, Intent::Implicit).unwrap(); + } + + // Try to create a cycle by picking two random branches A and B + // where B is an ancestor of A, and setting A as the parent of B. + let flattened = topo.flatten(); + if flattened.len() >= 2 { + for i in 0..flattened.len() { + for j in i+1..flattened.len() { + let ancestor_name = &flattened[i].0; + let descendant_name = &flattened[j].0; + + // Verify topological relationship in our flattened list + // (we just need to check if there's a path) + let mut is_descendant = false; + let mut current = descendant_name.clone(); + while let Some(parent_node) = topo.get_parents(¤t).first() { + if let Some(p_name) = parent_node.name() { + if p_name == ancestor_name { + is_descendant = true; + break; + } + current = p_name.to_string(); + } else { + break; + } + } + + if is_descendant { + // Setting descendant as parent of ancestor should fail + let res = topo.set_parent(ancestor_name, Some(descendant_name), None, Intent::Structural); + prop_assert!(res.is_err(), "Cycle should have been detected when setting {} as parent of {}", descendant_name, ancestor_name); + } + } + } + } + } +} diff --git a/tools/gitui/test/topology_tests.rs b/tools/gitui/test/topology_tests.rs new file mode 100644 index 0000000..4a74360 --- /dev/null +++ b/tools/gitui/test/topology_tests.rs @@ -0,0 +1,366 @@ +use gitui::testing::mock_oid; +use gitui::topology::{Intent, TopologyNode, VirtualTopology}; + +#[test] +fn test_basic_branch_parenting() { + let mut topo = VirtualTopology::new(); + let root_oid = mock_oid(0); + let master_oid = mock_oid(1); + let feature_oid = mock_oid(2); + + topo.add_branch("master", master_oid); + topo.add_branch("feature", feature_oid); + + topo.set_parent("master", None, Some(root_oid), Intent::Implicit) + .unwrap(); + topo.set_parent("feature", Some("master"), None, Intent::Implicit) + .unwrap(); + + let parents = topo.get_parents("feature"); + assert_eq!(parents.len(), 1); + match &parents[0] { + TopologyNode::Branch { name, .. } => assert_eq!(name, "master"), + _ => panic!("Expected branch parent"), + } +} + +#[test] +fn test_flatten_linear() { + let mut topo = VirtualTopology::new(); + topo.add_branch("a", mock_oid(1)); + topo.add_branch("b", mock_oid(2)); + topo.add_branch("c", mock_oid(3)); + + topo.set_parent("b", Some("a"), None, Intent::Implicit) + .unwrap(); + topo.set_parent("c", Some("b"), None, Intent::Implicit) + .unwrap(); + + let flat = topo.flatten(); + assert_eq!( + flat, + vec![ + ("a".to_string(), 0), + ("b".to_string(), 1), + ("c".to_string(), 2), + ] + ); +} + +#[test] +fn test_stable_flatten_visual_memory() { + let mut topo = VirtualTopology::new(); + topo.add_branch("master", mock_oid(1)); + topo.add_branch("feature1", mock_oid(2)); + topo.add_branch("feature2", mock_oid(3)); + + topo.set_parent("feature1", Some("master"), None, Intent::Implicit) + .unwrap(); + topo.set_parent("feature2", Some("master"), None, Intent::Implicit) + .unwrap(); + + // Default alphabetical: feature1, feature2 + assert_eq!( + topo.flatten(), + vec![ + ("master".to_string(), 0), + ("feature1".to_string(), 1), + ("feature2".to_string(), 1), + ] + ); + + // Set visual memory to swap them - but alphabetical is now primary + topo.set_visual_memory(vec![ + "master".to_string(), + "feature2".to_string(), + "feature1".to_string(), + ]); + + // Alphabetical still wins: feature1, feature2 + assert_eq!( + topo.flatten(), + vec![ + ("master".to_string(), 0), + ("feature1".to_string(), 1), + ("feature2".to_string(), 1), + ] + ); +} + +#[test] +fn test_intent_based_stability() { + let mut topo = VirtualTopology::new(); + let master = mock_oid(1); + let feature = mock_oid(2); + + topo.add_branch("master", master); + topo.add_branch("feature", feature); + topo.set_parent("feature", Some("master"), None, Intent::Implicit) + .unwrap(); + + // Visual Memory: master, feature + topo.set_visual_memory(vec!["master".to_string(), "feature".to_string()]); + + // Simulate "Synchronizing" reset (r) + // Even if it moves to a root OID or alias, we want to maintain visual relationship. + let root = mock_oid(0); + topo.set_parent("feature", None, Some(root), Intent::Synchronizing) + .unwrap(); + + // Feature becomes a root but visual memory keeps it after master + let flat = topo.flatten(); + assert_eq!(flat[0].0, "master"); + assert_eq!(flat[1].0, "feature"); +} + +#[test] +fn test_cycle_prevention() { + let mut topo = VirtualTopology::new(); + topo.add_branch("a", mock_oid(1)); + topo.add_branch("b", mock_oid(2)); + + topo.set_parent("b", Some("a"), None, Intent::Implicit) + .unwrap(); + + let res = topo.set_parent("a", Some("b"), None, Intent::Structural); + assert!(res.is_err()); +} + +#[test] +fn test_flatten_with_commit_bridge() { + let mut topo = VirtualTopology::new(); + let root_branch = "master"; + let bridge_commit = mock_oid(100); + let child_branch = "feature"; + + topo.add_branch(root_branch, mock_oid(1)); + topo.add_branch(child_branch, mock_oid(2)); + + topo.set_parent(child_branch, None, Some(bridge_commit), Intent::Implicit) + .unwrap(); + topo.add_commit_parent(bridge_commit, root_branch).unwrap(); + + let flat = topo.flatten(); + assert_eq!( + flat, + vec![("master".to_string(), 0), ("feature".to_string(), 1),] + ); +} + +#[test] +fn test_remove_branch() { + let mut topo = VirtualTopology::new(); + topo.add_branch("a", mock_oid(1)); + topo.add_branch("b", mock_oid(2)); + topo.set_parent("b", Some("a"), None, Intent::Implicit) + .unwrap(); + + topo.remove_branch("a"); + + assert!(topo.get_branch_oid("a").is_none()); + assert_eq!(topo.get_parents("b"), Vec::::new()); +} + +#[test] +fn test_list_roots() { + let mut topo = VirtualTopology::new(); + topo.add_branch("master", mock_oid(1)); + topo.add_branch("feature", mock_oid(2)); + topo.set_parent("feature", Some("master"), None, Intent::Implicit) + .unwrap(); + + assert_eq!(topo.list_roots(), vec!["master"]); +} + +#[test] +fn test_chained_structural_move() { + let mut topo = VirtualTopology::new(); + let r = mock_oid(0); + let a = mock_oid(1); + let b = mock_oid(2); + let m = mock_oid(3); + + topo.add_branch("a", a); + topo.add_branch("b", b); + topo.add_branch("master", m); + + topo.set_parent("a", None, Some(r), Intent::Implicit) + .unwrap(); + topo.set_parent("b", Some("a"), None, Intent::Implicit) + .unwrap(); + topo.set_parent("master", None, Some(r), Intent::Implicit) + .unwrap(); + + // Stack: a <- b, master + assert_eq!( + topo.flatten(), + vec![ + ("a".to_string(), 0), + ("b".to_string(), 1), + ("master".to_string(), 0), + ] + ); + + // Move 'a' onto 'master' structurally + topo.set_parent("a", Some("master"), None, Intent::Structural) + .unwrap(); + + assert_eq!( + topo.flatten(), + vec![ + ("master".to_string(), 0), + ("a".to_string(), 1), + ("b".to_string(), 2), + ] + ); +} + +#[test] +fn test_ambiguous_tie_breaking_no_memory() { + let mut topo = VirtualTopology::new(); + let common = mock_oid(1); + + // Two branches at the same OID with no visual memory + topo.add_branch("z-branch", common); + topo.add_branch("a-branch", common); + + let flat = topo.flatten(); + // Should fall back to alphabetical: a-branch, z-branch + assert_eq!(flat[0].0, "a-branch"); + assert_eq!(flat[1].0, "z-branch"); +} + +#[test] +fn test_parent_child_preservation_across_renders() { + let mut topo = VirtualTopology::new(); + let r = mock_oid(0); + let a = mock_oid(1); + let b = mock_oid(2); + + topo.add_branch("root", r); + topo.add_branch("middle", a); + topo.add_branch("leaf", b); + + topo.set_parent("middle", Some("root"), None, Intent::Implicit) + .unwrap(); + topo.set_parent("leaf", Some("middle"), None, Intent::Implicit) + .unwrap(); + + // Store order + let order: Vec = topo.flatten().into_iter().map(|(n, _)| n).collect(); + assert_eq!(order, vec!["root", "middle", "leaf"]); + + // New render with visual memory + let mut topo2 = VirtualTopology::new(); + topo2.set_visual_memory(order); + topo2.add_branch("root", r); + topo2.add_branch("middle", a); + topo2.add_branch("leaf", b); + topo2 + .set_parent("middle", Some("root"), None, Intent::Implicit) + .unwrap(); + topo2 + .set_parent("leaf", Some("middle"), None, Intent::Implicit) + .unwrap(); + + assert_eq!( + topo2.flatten(), + vec![ + ("root".to_string(), 0), + ("middle".to_string(), 1), + ("leaf".to_string(), 2), + ] + ); +} + +#[test] +fn test_diamond_merge_visit_uniqueness() { + let mut topo = VirtualTopology::new(); + let base = mock_oid(0); + let side1 = mock_oid(1); + let side2 = mock_oid(2); + let merge = mock_oid(3); + + topo.add_branch("base", base); + topo.add_branch("side1", side1); + topo.add_branch("side2", side2); + topo.add_branch("merge", merge); + + topo.set_parent("side1", Some("base"), None, Intent::Implicit) + .unwrap(); + topo.set_parent("side2", Some("base"), None, Intent::Implicit) + .unwrap(); + + // Manually create a second parent edge for merge + let merge_idx = topo.branches["merge"]; + let side1_idx = topo.branches["side1"]; + let side2_idx = topo.branches["side2"]; + topo.graph.add_edge(merge_idx, side1_idx, Intent::Implicit); + topo.graph.add_edge(merge_idx, side2_idx, Intent::Implicit); + + let flat = topo.flatten(); + // "merge" should only appear once despite having two parents + let merge_count = flat.iter().filter(|(n, _)| n == "merge").count(); + assert_eq!(merge_count, 1); +} + +#[test] +fn test_visual_memory_with_new_branches() { + let mut topo = VirtualTopology::new(); + topo.add_branch("old1", mock_oid(1)); + topo.add_branch("old2", mock_oid(2)); + + topo.set_visual_memory(vec!["old2".to_string(), "old1".to_string()]); + + // Add a NEW branch that wasn't in memory + topo.add_branch("newbie", mock_oid(3)); + + let flat = topo.flatten(); + // alphabetical: newbie < old1 < old2 + assert_eq!(flat[0].0, "newbie"); + assert_eq!(flat[1].0, "old1"); + assert_eq!(flat[2].0, "old2"); +} + +#[test] +fn test_resolve_visible_parent() { + use gitui::topology::HistoryContext; + let mut history = HistoryContext::new(); + + let oid1 = mock_oid(1); + let oid2 = mock_oid(2); + let oid3 = mock_oid(3); + + history + .oid_to_visible_branch + .insert(oid1, "master".to_string()); + history + .oid_to_visible_branch + .insert(oid2, "feat1".to_string()); + + history.oid_to_ancestor.insert(oid3, Some(oid2)); + history.oid_to_ancestor.insert(oid2, Some(oid1)); + + // From oid3, the nearest visible is feat1 + assert_eq!( + history.resolve_visible_parent(oid3, None), + Some("feat1".to_string()) + ); + + // From oid3, excluding feat1, the nearest visible is master + assert_eq!( + history.resolve_visible_parent(oid3, Some("feat1")), + Some("master".to_string()) + ); + + // From oid2, excluding feat1, the nearest visible is master + assert_eq!( + history.resolve_visible_parent(oid2, Some("feat1")), + Some("master".to_string()) + ); + + // From oid1, excluding master, there is nothing + assert_eq!(history.resolve_visible_parent(oid1, Some("master")), None); +} + +// end of file diff --git a/tools/gitui/test/transaction_tests.rs b/tools/gitui/test/transaction_tests.rs new file mode 100644 index 0000000..ff6d072 --- /dev/null +++ b/tools/gitui/test/transaction_tests.rs @@ -0,0 +1,116 @@ +use git2::Signature; +use gitui::engine::transaction::Transaction; +use gitui::testing::setup_repo; +use std::collections::HashSet; +use std::fs::File; +use std::io::Write; + +#[test] +fn test_transaction_projection() { + let (tmp_dir, repo, _branch) = setup_repo(); + let file_path = tmp_dir.path().join("test.txt"); + + // 1. Initial commit (parent) + let mut initial_content = String::new(); + for i in 1..=20 { + initial_content.push_str(&format!("line{}\n", i)); + } + File::create(&file_path) + .unwrap() + .write_all(initial_content.as_bytes()) + .unwrap(); + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("test.txt")).unwrap(); + let oid = index.write_tree().unwrap(); + let signature = Signature::now("Test User", "test@example.com").unwrap(); + let parent_tree = repo.find_tree(oid).unwrap(); + let parent_commit = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit( + Some("HEAD"), + &signature, + &signature, + "Initial commit", + &parent_tree, + &[&parent_commit], + ) + .unwrap(); + repo.branch( + "master", + &repo.head().unwrap().peel_to_commit().unwrap(), + true, + ) + .unwrap(); + + // 2. Feature commit (1 commit ahead) + let mut feat_content = String::new(); + for i in 1..=20 { + if i == 5 { + feat_content.push_str("line5 changed\n"); + } else if i == 15 { + feat_content.push_str("line15 changed\n"); + } else { + feat_content.push_str(&format!("line{}\n", i)); + } + } + File::create(&file_path) + .unwrap() + .write_all(feat_content.as_bytes()) + .unwrap(); + index.add_path(std::path::Path::new("test.txt")).unwrap(); + let oid = index.write_tree().unwrap(); + let feat_tree = repo.find_tree(oid).unwrap(); + let parent_commit = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit( + Some("HEAD"), + &signature, + &signature, + "Feat commit", + &feat_tree, + &[&parent_commit], + ) + .unwrap(); + repo.branch( + "feat", + &repo.head().unwrap().peel_to_commit().unwrap(), + true, + ) + .unwrap(); + + // 3. Setup transaction to split "feat" + let mut tx = Transaction::new("feat".to_string(), "master".to_string()); + + // Get diff + let diff = repo + .diff_tree_to_tree(Some(&parent_tree), Some(&feat_tree), None) + .unwrap(); + + // Select only first hunk (line 5 change) + let mut selected = HashSet::new(); + selected.insert(("test.txt".to_string(), 0)); + tx.selected_hunks = selected; + + tx.calculate_projected_oids(&repo, &diff).unwrap(); + + assert!(tx.projected_first_tree_oid.is_some()); + assert!(tx.projected_remainder_tree_oid.is_some()); + + let first_tree = repo + .find_tree(tx.projected_first_tree_oid.unwrap()) + .unwrap(); + let obj = first_tree + .get_path(std::path::Path::new("test.txt")) + .unwrap() + .to_object(&repo) + .unwrap(); + let blob = obj.as_blob().unwrap(); + let content = std::str::from_utf8(blob.content()).unwrap(); + println!("Projected content:\n{}", content); + + // Should have line 5 changed, but line 15 original + assert!(content.contains("line5 changed")); + assert!(content.contains("line15\n")); + assert!(!content.contains("line15 changed")); + + // Remainder tree should be the tip tree + assert_eq!(tx.projected_remainder_tree_oid.unwrap(), feat_tree.id()); +} diff --git a/tools/gitui/test/tui_integration_tests.rs b/tools/gitui/test/tui_integration_tests.rs new file mode 100644 index 0000000..583d03b --- /dev/null +++ b/tools/gitui/test/tui_integration_tests.rs @@ -0,0 +1,1707 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use git2::Signature; +use gitui::engine::{Git, RealGit, build_topology, calculate_plan}; +use gitui::state::{AppState, ConflictCheckState, Effect, Msg}; +use gitui::testing::{create_commit, run_git, run_git_with_env, setup_repo}; +use gitui::ui; +use insta::assert_snapshot; +use ratatui::Terminal; +use ratatui::backend::TestBackend; + +fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String { + let mut s = String::new(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + s.push_str(buffer[(x, y)].symbol()); + } + s.push('\n'); + } + s +} + +fn configure_insta() -> insta::Settings { + let mut settings = insta::Settings::clone_current(); + if let Ok(workspace_dir) = std::env::var("BUILD_WORKSPACE_DIRECTORY") { + let mut p = std::path::PathBuf::from(workspace_dir); + p.push("hs-github-tools"); + p.push("tools"); + p.push("gitui"); + p.push("test"); + p.push("snapshots"); + settings.set_snapshot_path(p); + } else { + // Fallback: Check if we are in Bazel sandbox structure + let cwd = std::env::current_dir().unwrap(); + let sandbox_path = cwd.join("hs-github-tools/tools/gitui/test/snapshots"); + if sandbox_path.exists() { + settings.set_snapshot_path(sandbox_path); + } else { + // Cargo fallback + // In cargo, we are at .../gitui + let cargo_path = cwd.join("test/snapshots"); + settings.set_snapshot_path(cargo_path); + } + } + settings +} + +#[tokio::test] +async fn test_tui_reset_stability_groups_perf() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup scenario: master -> groups-perf + // master is at 'Initial commit' + let gp_oid = create_commit(&repo, "f.txt", "c", "groups-perf commit"); + repo.branch("groups-perf", &repo.find_commit(gp_oid).unwrap(), false) + .unwrap(); + + // Create a bare remote + let remote_dir = tempfile::tempdir().unwrap(); + let remote_path = remote_dir.path().to_str().unwrap(); + std::process::Command::new("git") + .arg("init") + .arg("--bare") + .arg(remote_path) + .status() + .unwrap(); + + // Add remote and push groups-perf + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["remote", "add", "origin", remote_path]) + .status() + .unwrap(); + + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["push", "origin", "groups-perf:groups-perf"]) + .status() + .unwrap(); + + // Set upstream properly + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args([ + "branch", + "--set-upstream-to=origin/groups-perf", + "groups-perf", + ]) + .status() + .unwrap(); + + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["fetch", "origin"]) + .status() + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let intents = std::collections::HashMap::new(); + + // Verify initial state: groups-perf should be a child of master/main + let initial_topo = build_topology(&branches, &intents, &history, false).unwrap(); + let initial_flat = initial_topo.flatten(); + let gp_depth = initial_flat + .iter() + .find(|(n, _)| n == "groups-perf") + .map(|(_, d)| *d) + .expect("groups-perf not found"); + assert!( + gp_depth > 0, + "groups-perf should be indented under master initially" + ); + + // 2. Setup AppState + let mut state = AppState { + branches, + history, + initial_branch: master.clone(), + ..Default::default() + }; + state.refresh_tree(None); + + // 3. Simulate events + // Use 'j' to select groups-perf. It should be after master. + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + // Press 'r' to trigger reset + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('r'), + KeyModifiers::NONE, + ))); + + // 4. Render and Inspect the final buffer + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw_main(f, &mut state)).unwrap(); + + let buffer = terminal.backend().buffer(); + + let mut found_groups_perf = false; + let mut indentation_correct = false; + + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + + if line.contains("groups-perf") && (line.contains("[REBASE]") || line.contains("[RESET]")) { + found_groups_perf = true; + // The debug output showed: " 2: │>> groups-perf [REBASE] [MERGED] ..." + // The marker is ">>", followed by 2 spaces of indentation for depth 1. + if line.contains(">> groups-perf") { + indentation_correct = true; + } + } + } + + assert!( + found_groups_perf, + "groups-perf [REBASE]/[RESET] not found in UI" + ); + assert!( + indentation_correct, + "groups-perf has incorrect indentation!" + ); +} + +#[tokio::test] +async fn test_tui_noop_preview_is_empty() { + let (dir, _repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup scenario: master tracking origin/master, already in sync + let remote_dir = tempfile::tempdir().unwrap(); + let remote_path = remote_dir.path().to_str().unwrap(); + std::process::Command::new("git") + .arg("init") + .arg("--bare") + .arg(remote_path) + .status() + .unwrap(); + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["remote", "add", "origin", remote_path]) + .status() + .unwrap(); + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["push", "origin", &format!("{}:{}", master, master)]) + .status() + .unwrap(); + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args([ + "branch", + &format!("--set-upstream-to=origin/{}", master), + &master, + ]) + .status() + .unwrap(); + + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["fetch", "origin"]) + .status() + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + // Verify it's actually in sync + { + let main_info = branches.iter().find(|b| b.name == master).unwrap(); + assert_eq!(main_info.ahead, 0); + assert_eq!(main_info.behind, 0); + assert!(main_info.upstream_oid.is_some()); + assert_eq!(main_info.upstream_oid, Some(main_info.oid)); + } + + // 2. Setup AppState + let mut state = AppState { + branches, + history, + initial_branch: master.clone(), + ..Default::default() + }; + state.refresh_tree(None); + + // 3. Simulate events: 'r' (reset), 'v' (preview) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('r'), + KeyModifiers::NONE, + ))); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('v'), + KeyModifiers::NONE, + ))); + + // 4. Verify preview buffer + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let snapshot = gitui::engine::RepositorySnapshot { + branches: state.branches.clone(), + history: state.history.clone(), + is_dirty: state.is_dirty, + }; + let plan = calculate_plan(&snapshot, &state.intents).unwrap(); + ui::draw_preview(f, &plan, &state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let mut found_no_ops_msg = false; + let mut found_git_command = false; + + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("No operations to perform.") { + found_no_ops_msg = true; + } + if line.contains("git rebase") || line.contains("git reset") { + found_git_command = true; + } + } + + assert!( + found_no_ops_msg, + "Preview should say 'No operations to perform.'. Buffer:\n{:?}", + buffer + ); + assert!( + !found_git_command, + "Preview should NOT show any git commands for a no-op reset" + ); +} + +#[tokio::test] +async fn test_tui_quit_confirmation() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + let remote_dir = tempfile::tempdir().unwrap(); + let remote_path = remote_dir.path().to_str().unwrap(); + std::process::Command::new("git") + .arg("init") + .arg("--bare") + .arg(remote_path) + .status() + .unwrap(); + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["remote", "add", "origin", remote_path]) + .status() + .unwrap(); + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["push", "origin", "main:main"]) + .status() + .unwrap(); + + // Create a commit on local only + create_commit(&repo, "f.txt", "c", "local change"); + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["branch", "--set-upstream-to=origin/main", "main"]) + .status() + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + initial_branch: master.clone(), + ..Default::default() + }; + state.refresh_tree(None); + + // 1. Press 'r' (reset) -> pending operation created + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('r'), + KeyModifiers::NONE, + ))); + + // 2. Press 'q' (quit) -> should show confirmation + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('q'), + KeyModifiers::NONE, + ))); + assert!(state.show_quit_confirmation); + + // 3. Press 'n' (no) -> stay in app + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('n'), + KeyModifiers::NONE, + ))); + assert!(!state.show_quit_confirmation); + + // 4. Press 'q' then 'y' -> quit + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('q'), + KeyModifiers::NONE, + ))); + let effects = state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('y'), + KeyModifiers::NONE, + ))); + + // Verification: ensure Effect::Quit was emitted + assert!(effects.iter().any(|e| matches!(e, Effect::Quit))); +} + +#[tokio::test] +async fn test_tui_move_remote_to_localize() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup a remote and a branch on it + let remote_dir = tempfile::tempdir().unwrap(); + let remote_path = remote_dir.path().to_str().unwrap(); + std::process::Command::new("git") + .arg("init") + .arg("--bare") + .arg(remote_path) + .status() + .unwrap(); + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["remote", "add", "origin", remote_path]) + .status() + .unwrap(); + + // Create 'feat' on remote + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["checkout", "-b", "feat"]) + .status() + .unwrap(); + let sig = Signature::now("Test", "test@example.com").unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + let tree = repo + .find_tree(repo.index().unwrap().write_tree().unwrap()) + .unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "Feat commit", &tree, &[&parent]) + .unwrap(); + + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["push", "origin", "feat"]) + .status() + .unwrap(); + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["checkout", &master]) + .status() + .unwrap(); + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["branch", "-D", "feat"]) + .status() + .unwrap(); + std::process::Command::new("git") + .arg("-C") + .arg(path_str) + .args(["fetch", "origin"]) + .status() + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + // Start with remote branches visible + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + initial_branch: master.clone(), + show_remote: true, + ..Default::default() + }; + state.refresh_tree(None); + + // 2. Simulate TUI events + // 1. Move to origin/feat (should be index 1 if master is index 0) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + // 2. Press Space to grab + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::NONE, + ))); + // 3. Move back to master + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('k'), + KeyModifiers::NONE, + ))); + // 4. Press Space to release + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::NONE, + ))); + + // 3. Verify buffer contains "feat [LOCALIZE]" and NOT "origin/feat" + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw_main(f, &mut state)).unwrap(); + + let buffer = terminal.backend().buffer(); + let mut found_localized_feat = false; + let mut found_origin_feat = false; + + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("feat") && line.contains("[LOCALIZE]") { + found_localized_feat = true; + } + if line.contains("origin/feat") { + found_origin_feat = true; + } + } + + assert!( + found_localized_feat, + "UI should show 'feat [LOCALIZE]' after move" + ); + assert!( + !found_origin_feat, + "UI should NOT show 'origin/feat' after move" + ); +} + +#[tokio::test] +async fn test_tui_predictive_conflict_detection() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup conflicting branches + // Base: a.txt="base" + let base_oid = create_commit(&repo, "f.txt", "c", "Base"); + + // feat1: a.txt="feat1" + run_git(path_str, &["checkout", "--detach", &base_oid.to_string()]); + std::fs::write(dir.path().join("a.txt"), "feat1").unwrap(); + run_git(path_str, &["add", "a.txt"]); + let feat1_oid = create_commit(&repo, "f.txt", "c", "Feat1"); + repo.branch("feat1", &repo.find_commit(feat1_oid).unwrap(), false) + .unwrap(); + + // feat2: a.txt="feat2" + run_git(path_str, &["checkout", "--detach", &base_oid.to_string()]); + std::fs::write(dir.path().join("a.txt"), "feat2").unwrap(); + run_git(path_str, &["add", "a.txt"]); + let feat2_oid = create_commit(&repo, "f.txt", "c", "Feat2"); + repo.branch("feat2", &repo.find_commit(feat2_oid).unwrap(), false) + .unwrap(); + + // 2. Setup AppState + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + initial_branch: master.clone(), + ..Default::default() + }; + state.refresh_tree(None); + + // 3. Setup TUI and simulate events + // Select feat2 (index 2: master, feat1, feat2) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + // Grab feat2 + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::NONE, + ))); + // Move to hover over feat1 + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('k'), + KeyModifiers::NONE, + ))); + + // Transition state machine to Checking so it accepts the result + state.conflict_check = ConflictCheckState::Checking { + branch: "feat2".to_string(), + onto: "feat1".to_string(), + }; + + // Simulate Worker Response + state.update(Msg::ConflictChecked( + "feat2".to_string(), + "feat1".to_string(), + Ok(true), + )); + + // 4. Verify conflict warning + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw_main(f, &mut state)).unwrap(); + + let buffer = terminal.backend().buffer(); + let mut found_conflict = false; + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("feat2") && line.contains("(CONFLICT)") { + found_conflict = true; + } + } + assert!( + found_conflict, + "UI should show (CONFLICT) for feat2 when hovering over feat1" + ); +} + +#[tokio::test] +async fn test_tui_realtime_reflattening() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup sibling branches + create_commit(&repo, "f.txt", "c", "feat1 commit"); + repo.branch( + "feat1", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + create_commit(&repo, "f.txt", "c", "feat2 commit"); + repo.branch( + "feat2", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + initial_branch: master.clone(), + ..Default::default() + }; + state.refresh_tree(None); + + // Simulate events + // Select feat2 (index 2: master, feat1, feat2) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + // Grab feat2 + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::NONE, + ))); + + // 2. Render and verify buffer shows feat2 indented (since we preserve parent on grab) + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw_main(f, &mut state)).unwrap(); + + let buffer = terminal.backend().buffer(); + let mut feat2_line = None; + + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("feat2") { + feat2_line = Some(line.clone()); + // It should have ">>" marker and leading spaces (depth 1 -> 2 spaces + padding) + // ">> feat2" + assert!( + line.contains(">> feat2"), + "feat2 should be indented when grabbed. line: {}", + line + ); + } + } + feat2_line.expect("feat2 not found"); +} + +#[tokio::test] +async fn test_tui_move_skip_behavior() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup 3 root branches on different commits to ensure they are all roots + let initial_commit = repo.head().unwrap().peel_to_commit().unwrap(); + + // a-branch + create_commit(&repo, "f.txt", "c", "commit a"); + repo.branch( + "a-branch", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // b-branch (back from initial) + repo.set_head_detached(initial_commit.id()).unwrap(); + create_commit(&repo, "f.txt", "c", "commit b"); + repo.branch( + "b-branch", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // c-branch (back from initial) + repo.set_head_detached(initial_commit.id()).unwrap(); + create_commit(&repo, "f.txt", "c", "commit c"); + repo.branch( + "c-branch", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // Cleanup: move to a-branch and delete master + run_git(path_str, &["checkout", "a-branch"]); + run_git(path_str, &["branch", "-D", &master]); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + initial_branch: "a-branch".to_string(), + ..Default::default() + }; + state.refresh_tree(None); + + // Simulate events + // Start: a-branch (0), b-branch (1), c-branch (2) + // Select c-branch + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + // Grab c-branch + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::NONE, + ))); + // Move to hover a-branch (index 0) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('k'), + KeyModifiers::NONE, + ))); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('k'), + KeyModifiers::NONE, + ))); + // State now: a-branch (0), c-branch (1, child of a), b-branch (2) + + // Press Down. Should skip c-branch (1) and land on b-branch (2). + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + + // Press 'h' to move c-branch to root. + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('h'), + KeyModifiers::NONE, + ))); + + // 2. Render and verify final state: c-branch is a root and at index 2 + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw_main(f, &mut state)).unwrap(); + + let buffer = terminal.backend().buffer(); + let mut c_branch_line = None; + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("c-branch") { + c_branch_line = Some(line); + break; + } + } + + let line = c_branch_line.expect("c-branch not found"); + // Indent of 2 spaces for depth 1 + 2 spaces padding = 4 spaces. + // Root should only have 2 spaces padding. + assert!( + !line.contains(" c-branch"), + "c-branch should be root (depth 0). Actual line: '{}'", + line + ); +} + +#[tokio::test] +async fn test_tui_cancel_grab() { + let (dir, _repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup root branches + run_git(path_str, &["branch", "feat1"]); + let initial_oid = run_git(path_str, &["rev-parse", "HEAD"]); + run_git(path_str, &["checkout", "--detach", &initial_oid]); + run_git(path_str, &["branch", "-D", &master]); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + initial_branch: "feat1".to_string(), + ..Default::default() + }; + state.refresh_tree(None); + + // 1. Grab feat1 + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::NONE, + ))); + assert!(state.grabbed_branch.is_some()); + + // 2. Cancel grab with 'Esc' + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Esc, + KeyModifiers::NONE, + ))); + assert!(state.grabbed_branch.is_none()); + + // 3. Grab again + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::NONE, + ))); + assert!(state.grabbed_branch.is_some()); + + // 4. Cancel grab with 'Esc' (was 'Esc' in original code too) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Esc, + KeyModifiers::NONE, + ))); + assert!(state.grabbed_branch.is_none()); +} + +#[tokio::test] +async fn test_tui_move_sorting() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup branches exactly as in the user report: + // master (root) + // groups-perf (child of master) + // loglogdata (root, sorts before master) + + // Create groups-perf as child of master + create_commit(&repo, "f.txt", "c", "groups-perf commit"); + run_git(path_str, &["branch", "groups-perf"]); + + // Create loglogdata as a root sibling (off initial commit) + let initial_oid = run_git(path_str, &["rev-parse", "HEAD^"]); + run_git(path_str, &["checkout", "--detach", &initial_oid]); + create_commit(&repo, "f.txt", "c", "loglogdata commit"); + run_git(path_str, &["branch", "loglogdata"]); + + // Switch back to master (default) + run_git(path_str, &["checkout", &master]); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + initial_branch: master.clone(), + ..Default::default() + }; + state.refresh_tree(None); + + // Initial Tree (Alphabetical Roots: loglogdata < master): + // 0: loglogdata (root) + // 1: master (root) + // 2: groups-perf (child) + assert_eq!(state.flattened_tree[0].0, "loglogdata"); + assert_eq!(state.flattened_tree[1].0, master); + assert_eq!(state.flattened_tree[2].0, "groups-perf"); + + // Cursor starts at 0: loglogdata + // 1. Grab loglogdata + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::NONE, + ))); + // 2. Press Down. Skips loglogdata, lands on master (index 1). + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + + // 3. Render and verify sorting: groups-perf should be ABOVE loglogdata under master + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw_main(f, &mut state)).unwrap(); + + let buffer = terminal.backend().buffer(); + let mut gp_y = None; + let mut log_y = None; + + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("groups-perf") { + gp_y = Some(y); + } + if line.contains("loglogdata") { + log_y = Some(y); + } + } + + let y1 = gp_y.expect("groups-perf not found"); + let y2 = log_y.expect("loglogdata not found"); + + assert!( + y1 < y2, + "groups-perf (y={}) should be above loglogdata (y={})", + y1, + y2 + ); +} + +#[tokio::test] +async fn test_tui_subtree_movement() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + let initial_commit = repo.head().unwrap().peel_to_commit().unwrap(); + + // Create 4 children of master + for i in 1..=4 { + // master is at initial_commit + repo.set_head_detached(initial_commit.id()).unwrap(); + create_commit(&repo, "f.txt", "c", &format!("commit {}", i)); + repo.branch( + &format!("branch_{}", i), + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + } + + // Create Stacked Subtree (also child of master) + // stack_root + repo.set_head_detached(initial_commit.id()).unwrap(); + create_commit(&repo, "f.txt", "c", "commit stack root"); + let root_oid = repo.head().unwrap().peel_to_commit().unwrap(); + repo.branch("stack_root", &root_oid, false).unwrap(); + + // stack_child_1 + repo.set_head_detached(root_oid.id()).unwrap(); + create_commit(&repo, "f.txt", "c", "commit stack child 1"); + let c1_oid = repo.head().unwrap().peel_to_commit().unwrap(); + repo.branch("stack_child_1", &c1_oid, false).unwrap(); + + // stack_child_2 + repo.set_head_detached(c1_oid.id()).unwrap(); + create_commit(&repo, "f.txt", "c", "commit stack child 2"); + let c2_oid = repo.head().unwrap().peel_to_commit().unwrap(); + repo.branch("stack_child_2", &c2_oid, false).unwrap(); + + // stack_child_3 + repo.set_head_detached(c2_oid.id()).unwrap(); + create_commit(&repo, "f.txt", "c", "commit stack child 3"); + repo.branch( + "stack_child_3", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // Create z_branch (child of master, should appear after stack_root) + repo.set_head_detached(initial_commit.id()).unwrap(); + create_commit(&repo, "f.txt", "c", "commit z"); + repo.branch( + "z_branch", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // Switch to master to view the tree from root + run_git(path_str, &["checkout", &master]); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut state = AppState { + branches, + history, + initial_branch: master.clone(), + ..Default::default() + }; + state.refresh_tree(None); + + // Initial Tree (master is root): + // 0: master + // 1: branch_1 + // 2: branch_2 + // 3: branch_3 + // 4: branch_4 + // 5: stack_root + // 6: stack_child_1 + // 7: stack_child_2 + // 8: stack_child_3 + // 9: z_branch + + // Navigate to stack_root (index 5) + for _ in 0..5 { + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + } + + // Grab stack_root + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::NONE, + ))); + + // Move Up 5 steps to master (index 0) + for _ in 0..5 { + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('k'), + KeyModifiers::NONE, + ))); + } + + // Release (place back into master) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::NONE, + ))); + + // After release, the drag state (target_parent) is cleared. + // Verify the move was successful by checking the branch's effective parent. + assert_eq!( + state.get_effective_parent("stack_root"), + Some(master.clone()), + "stack_root should have master as parent" + ); + + // Verify UI with Snapshot + // Increase height to see all lines + borders + let backend = TestBackend::new(50, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw_main(f, &mut state)).unwrap(); + + let output = buffer_to_string(terminal.backend().buffer()); + + let settings = configure_insta(); + settings.bind(|| { + assert_snapshot!(output); + }); +} + +#[tokio::test] +async fn test_tui_subtree_movement_in_progress() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + let initial_commit = repo.head().unwrap().peel_to_commit().unwrap(); + + // Create 4 children of master + for i in 1..=4 { + // master is at initial_commit + repo.set_head_detached(initial_commit.id()).unwrap(); + create_commit(&repo, "f.txt", "c", &format!("commit {}", i)); + repo.branch( + &format!("branch_{}", i), + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + } + + // Create Stacked Subtree (also child of master) + // stack_root + repo.set_head_detached(initial_commit.id()).unwrap(); + create_commit(&repo, "f.txt", "c", "commit stack root"); + let root_oid = repo.head().unwrap().peel_to_commit().unwrap(); + repo.branch("stack_root", &root_oid, false).unwrap(); + + // stack_child_1 + repo.set_head_detached(root_oid.id()).unwrap(); + create_commit(&repo, "f.txt", "c", "commit stack child 1"); + let c1_oid = repo.head().unwrap().peel_to_commit().unwrap(); + repo.branch("stack_child_1", &c1_oid, false).unwrap(); + + // stack_child_2 + repo.set_head_detached(c1_oid.id()).unwrap(); + create_commit(&repo, "f.txt", "c", "commit stack child 2"); + let c2_oid = repo.head().unwrap().peel_to_commit().unwrap(); + repo.branch("stack_child_2", &c2_oid, false).unwrap(); + + // stack_child_3 + repo.set_head_detached(c2_oid.id()).unwrap(); + create_commit(&repo, "f.txt", "c", "commit stack child 3"); + repo.branch( + "stack_child_3", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // Create z_branch (child of master, should appear after stack_root) + repo.set_head_detached(initial_commit.id()).unwrap(); + create_commit(&repo, "f.txt", "c", "commit z"); + repo.branch( + "z_branch", + &repo.head().unwrap().peel_to_commit().unwrap(), + false, + ) + .unwrap(); + + // Switch to master to view the tree from root + run_git(path_str, &["checkout", &master]); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + let mut state = AppState { + branches, + history, + initial_branch: master.clone(), + ..Default::default() + }; + state.refresh_tree(None); + + // Initial Tree (master is root): + // 0: master + // 1: branch_1 + // 2: branch_2 + // 3: branch_3 + // 4: branch_4 + // 5: stack_root + // 6: stack_child_1 + // 7: stack_child_2 + // 8: stack_child_3 + // 9: z_branch + + // Navigate to stack_root (index 5) + for _ in 0..5 { + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + } + + // Grab stack_root + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char(' '), + KeyModifiers::NONE, + ))); + + // Move Up 5 steps to master (index 0) + for _ in 0..5 { + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('k'), + KeyModifiers::NONE, + ))); + } + + // Move Down 2 steps (to branch_2) + for _ in 0..2 { + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))); + } + + // Verify UI with Snapshot while still grabbed + let backend = TestBackend::new(50, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw_main(f, &mut state)).unwrap(); + + let output = buffer_to_string(terminal.backend().buffer()); + + let settings = configure_insta(); + settings.bind(|| { + assert_snapshot!(output); + }); +} + +#[tokio::test] +async fn test_tui_converge_heuristic_sync() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // Detach HEAD so master doesn't move + let master_oid = repo.head().unwrap().target().unwrap(); + repo.set_head_detached(master_oid).unwrap(); + + // 1. master (C0) -> fake-net (C1) + let oid1 = create_commit(&repo, "f.txt", "c", "fake-net"); + repo.branch("fake-net", &repo.find_commit(oid1).unwrap(), false) + .unwrap(); + + // 2. fake-net (C1) -> benchmarks (C2) + repo.set_head_detached(oid1).unwrap(); + let oid2 = create_commit(&repo, "f.txt", "c", "benchmarks"); + repo.branch("benchmarks", &repo.find_commit(oid2).unwrap(), false) + .unwrap(); + + // 3. Reset fake-net back to master (C0) + run_git(path_str, &["checkout", "fake-net"]); + run_git(path_str, &["reset", "--hard", &master_oid.to_string()]); + // Also amend it so its summary matches "fake-net" (heuristic detection) + run_git( + path_str, + &[ + "commit", + "--allow-empty", + "--amend", + "-m", + "fake-net", + "--no-edit", + ], + ); + + run_git(path_str, &["checkout", &master]); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + initial_branch: master.clone(), + ..Default::default() + }; + state.refresh_tree(None); + + // Verify benchmarks is initially under master because it's diverged from fake-net OID + assert_eq!( + state.get_effective_parent("benchmarks").as_deref(), + Some(master.as_str()), + "benchmarks should have fallen back to master initially because it's diverged" + ); + + // Verify heuristic parent of benchmarks is 'fake-net' + // (matched summary of C1 in history with new fake-net tip) + let benchmarks_info = state + .branches + .iter() + .find(|b| b.name == "benchmarks") + .unwrap(); + assert_eq!( + benchmarks_info.heuristic_parent.as_deref(), + Some("fake-net") + ); + assert!(benchmarks_info.heuristic_upstream_oid.is_some()); + assert_eq!( + benchmarks_info.heuristic_upstream_oid.unwrap(), + oid1, + "Heuristic upstream should be the old C1" + ); + + // 4. Press 'u' on benchmarks. + let b_pos = state + .flattened_tree + .iter() + .position(|(n, _)| n == "benchmarks") + .unwrap(); + state.list_state.select(Some(b_pos)); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('u'), + KeyModifiers::NONE, + ))); + + // 5. Verify plan + let snapshot = gitui::engine::RepositorySnapshot { + branches: state.branches.clone(), + history: state.history.clone(), + is_dirty: state.is_dirty, + }; + let plan = calculate_plan(&snapshot, &state.intents).unwrap(); + + assert!( + !plan.is_empty(), + "Plan should NOT be empty when converging even if parent_changed is false, if heuristic upstream differs" + ); + + match &plan[0] { + gitui::engine::Operation::Rebase { + branch, + onto, + upstream, + .. + } => { + assert_eq!(branch, "benchmarks"); + assert_eq!(onto, "fake-net"); + assert_eq!( + upstream.as_ref().unwrap(), + &oid1.to_string(), + "Should rebase FROM the old C1 OID" + ); + } + _ => panic!("Expected rebase operation"), + } + + // 6. Render and verify [REBASE] label is present + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw_main(f, &mut state)).unwrap(); + + let buffer = terminal.backend().buffer(); + let mut found_benchmarks_with_rebase = false; + + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("benchmarks") && line.contains("[REBASE]") { + found_benchmarks_with_rebase = true; + } + } + + assert!( + found_benchmarks_with_rebase, + "benchmarks should show [REBASE] after sync" + ); +} + +#[tokio::test] +async fn test_tui_converge_toggle() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + let master_oid = repo.head().unwrap().target().unwrap(); + + // 1. master (C0) -> feat (C1) + let oid1 = create_commit(&repo, "f.txt", "c", "feat"); + repo.branch("feat", &repo.find_commit(oid1).unwrap(), false) + .unwrap(); + + // 2. feat (C1) -> subfeat (C2) + repo.set_head_detached(oid1).unwrap(); + let oid2 = create_commit(&repo, "f.txt", "c", "subfeat"); + repo.branch("subfeat", &repo.find_commit(oid2).unwrap(), false) + .unwrap(); + + // Move feat to be sibling of master (C0) + run_git(path_str, &["checkout", "feat"]); + run_git(path_str, &["reset", "--hard", &master_oid.to_string()]); + run_git(path_str, &["commit", "--allow-empty", "-m", "feat"]); + + // Move main back to C0 so C1 is not a branch head + run_git(path_str, &["checkout", &master]); + run_git(path_str, &["reset", "--hard", &master_oid.to_string()]); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + initial_branch: master.clone(), + ..Default::default() + }; + state.refresh_tree(None); + + // subfeat should be under master initially because it's diverged + assert_eq!( + state.get_effective_parent("subfeat").as_deref(), + Some(master.as_str()) + ); + + let subfeat_pos = state + .flattened_tree + .iter() + .position(|(n, _)| n == "subfeat") + .unwrap(); + state.list_state.select(Some(subfeat_pos)); + + // 1. Press 'u' -> should converge to 'feat' + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('u'), + KeyModifiers::NONE, + ))); + assert_eq!( + state.get_effective_parent("subfeat").as_deref(), + Some("feat") + ); + + // 2. Press 'u' again -> should undo (back to master) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('u'), + KeyModifiers::NONE, + ))); + assert_eq!( + state.get_effective_parent("subfeat").as_deref(), + Some(master.as_str()) + ); +} + +#[tokio::test] +async fn test_tui_intent_toggles() { + let (dir, repo, _master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + // 1. Setup a branch that is ready for everything + // Start from detached HEAD so master doesn't move + let master_oid = repo.head().unwrap().target().unwrap(); + repo.set_head_detached(master_oid).unwrap(); + + // Create feat commit + let feat_oid1 = create_commit(&repo, "f.txt", "c", "feat commit"); + repo.branch("feat", &repo.find_commit(feat_oid1).unwrap(), false) + .unwrap(); + + // Create a bare remote and push feat to it + let remote_dir = tempfile::tempdir().unwrap(); + let remote_path = remote_dir.path().to_str().unwrap(); + run_git(remote_path, &["init", "--bare"]); + run_git(path_str, &["remote", "add", "origin", remote_path]); + run_git(path_str, &["push", "origin", "feat:feat"]); + run_git( + path_str, + &["branch", "--set-upstream-to=origin/feat", "feat"], + ); + run_git(path_str, &["fetch", "origin"]); + + // Create a remote-only branch + run_git(path_str, &["checkout", "-b", "remote-only"]); + create_commit(&repo, "remote.txt", "c", "remote only commit"); + run_git(path_str, &["push", "origin", "remote-only:remote-only"]); + run_git(path_str, &["checkout", "feat"]); + run_git(path_str, &["branch", "-D", "remote-only"]); + run_git(path_str, &["fetch", "origin"]); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + initial_branch: "feat".to_string(), + show_remote: true, + ..Default::default() + }; + state.refresh_tree(None); + + let feat_pos = state + .flattened_tree + .iter() + .position(|(n, _)| n == "feat") + .unwrap(); + state.list_state.select(Some(feat_pos)); + + // 'p': pending_push + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('p'), + KeyModifiers::NONE, + ))); + assert!(state.get_intent("feat").pending_push); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('p'), + KeyModifiers::NONE, + ))); + assert!(!state.get_intent("feat").pending_push); + + // 's': pending_submit (feat should be ready to submit since it's local and ahead of master, and in sync with origin/feat) + // Add another commit and push it to origin/feat to keep it in sync + run_git(path_str, &["checkout", "feat"]); + create_commit(&repo, "f2.txt", "c", "feat ahead of master"); + run_git(path_str, &["push", "origin", "feat:feat"]); + let (branches, history) = git.get_branches(None).unwrap(); + state.branches = branches; + state.history = history; + state.refresh_tree(None); + + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('s'), + KeyModifiers::NONE, + ))); + assert!(state.get_intent("feat").pending_submit); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('s'), + KeyModifiers::NONE, + ))); + assert!(!state.get_intent("feat").pending_submit); + + // 'd': pending_delete + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::NONE, + ))); + assert!(state.get_intent("feat").pending_delete); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::NONE, + ))); + assert!(!state.get_intent("feat").pending_delete); + + // 'r': pending_reset + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('r'), + KeyModifiers::NONE, + ))); + assert!(state.get_intent("feat").pending_reset); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('r'), + KeyModifiers::NONE, + ))); + assert!(!state.get_intent("feat").pending_reset); + + // 'f': pending_localize (on a remote branch) + let remote_only_pos = state + .flattened_tree + .iter() + .position(|(n, _)| n == "origin/remote-only") + .unwrap(); + state.list_state.select(Some(remote_only_pos)); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('f'), + KeyModifiers::NONE, + ))); + assert!(state.get_intent("origin/remote-only").pending_localize); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('f'), + KeyModifiers::NONE, + ))); + assert!(!state.get_intent("origin/remote-only").pending_localize); + + // 'm': pending_amend (on initial branch) + state.initial_branch = "feat".to_string(); + let feat_pos = state + .flattened_tree + .iter() + .position(|(n, _)| n == "feat") + .unwrap(); + state.list_state.select(Some(feat_pos)); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('m'), + KeyModifiers::NONE, + ))); + assert!(state.get_intent("feat").pending_amend); + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('m'), + KeyModifiers::NONE, + ))); + assert!(!state.get_intent("feat").pending_amend); +} + +#[tokio::test] +async fn test_tui_convergence_full_cycle() { + let (dir, repo, master) = setup_repo(); + let path_str = dir.path().to_str().unwrap(); + + let master_oid = repo.head().unwrap().target().unwrap(); + + // 1. master (C0) -> feat (C1) + let oid1 = create_commit(&repo, "f.txt", "c", "feat"); + repo.branch("feat", &repo.find_commit(oid1).unwrap(), false) + .unwrap(); + + // 2. feat (C1) -> subfeat (C2) + repo.set_head_detached(oid1).unwrap(); + let oid2 = create_commit(&repo, "f.txt", "c", "subfeat"); + repo.branch("subfeat", &repo.find_commit(oid2).unwrap(), false) + .unwrap(); + + // Move feat to be sibling of master (C0) + run_git(path_str, &["checkout", "feat"]); + run_git(path_str, &["reset", "--hard", &master_oid.to_string()]); + // Use fixed time for the new feat commit as well (matches setup_repo) + run_git_with_env( + path_str, + &["commit", "--allow-empty", "-m", "feat"], + vec![ + ("GIT_AUTHOR_DATE", "1735686000 +0000"), + ("GIT_COMMITTER_DATE", "1735686000 +0000"), + ], + ); + + // Move main back to C0 + run_git(path_str, &["checkout", &master]); + run_git(path_str, &["reset", "--hard", &master_oid.to_string()]); + + let git = RealGit::new(path_str).unwrap(); + let (branches, history) = git.get_branches(None).unwrap(); + + let mut state = AppState { + branches, + history, + initial_branch: master.clone(), + ..Default::default() + }; + state.refresh_tree(None); + + // subfeat should be diverged + let subfeat_pos = state + .flattened_tree + .iter() + .position(|(n, _)| n == "subfeat") + .unwrap(); + state.list_state.select(Some(subfeat_pos)); + + // Verify [DIVERGED] is present, and NO [REBASE] + { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw_main(f, &mut state)).unwrap(); + let buffer = terminal.backend().buffer(); + let line = (0..80) + .map(|x| buffer[(x, 1 + subfeat_pos as u16)].symbol()) + .collect::(); + assert!(line.contains("subfeat")); + assert!(line.contains("[DIVERGED]")); + assert!(!line.contains("[REBASE]")); + } + + // 1. Press 'u' -> should converge to 'feat' + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('u'), + KeyModifiers::NONE, + ))); + + // Verify [DIVERGED] is GONE, and [REBASE] is present + { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw_main(f, &mut state)).unwrap(); + let buffer = terminal.backend().buffer(); + let line = (0..80) + .map(|x| buffer[(x, 1 + subfeat_pos as u16)].symbol()) + .collect::(); + assert!(line.contains("subfeat")); + assert!(!line.contains("[DIVERGED]")); + assert!(line.contains("[REBASE]")); + } + + // 2. Simulate "Completion": actually rebase in repo and reload + // We rebase subfeat onto feat. subfeat summary was "subfeat". + // feat tip summary is "feat". + run_git(path_str, &["checkout", "subfeat"]); + run_git(path_str, &["rebase", "feat"]); + run_git(path_str, &["checkout", &master]); + + let (branches, history) = git.get_branches(None).unwrap(); + { + let subfeat = branches.iter().find(|b| b.name == "subfeat").unwrap(); + // Check that subfeat's heuristic parent is updated to point to the NEW feat + assert_eq!(subfeat.heuristic_parent.as_deref(), Some("feat")); + } + state.branches = branches; + state.history = history; + state.intents.clear(); // Clear intents as if we just started fresh after execution + state.refresh_tree(None); + + // Verify UI is clean + { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw_main(f, &mut state)).unwrap(); + let buffer = terminal.backend().buffer(); + let subfeat_new_pos = state + .flattened_tree + .iter() + .position(|(n, _)| n == "subfeat") + .unwrap(); + let line = (0..80) + .map(|x| buffer[(x, 1 + subfeat_new_pos as u16)].symbol()) + .collect::(); + assert!(line.contains("subfeat")); + assert!(!line.contains("[DIVERGED]")); + assert!(!line.contains("[REBASE]")); + } + + // 3. Press 'v' -> verify no operations + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('v'), + KeyModifiers::NONE, + ))); + { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let snapshot = gitui::engine::RepositorySnapshot { + branches: state.branches.clone(), + history: state.history.clone(), + is_dirty: state.is_dirty, + }; + let plan = calculate_plan(&snapshot, &state.intents).unwrap(); + ui::draw_preview(f, &plan, &state); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + let mut found_no_ops = false; + for y in 0..20 { + let line = (0..80).map(|x| buffer[(x, y)].symbol()).collect::(); + if line.contains("No operations to perform.") { + found_no_ops = true; + break; + } + } + assert!(found_no_ops); + } +} + +// end of file diff --git a/tools/gitui/test/ui_error_tests.rs b/tools/gitui/test/ui_error_tests.rs new file mode 100644 index 0000000..f4017dd --- /dev/null +++ b/tools/gitui/test/ui_error_tests.rs @@ -0,0 +1,59 @@ +use gitui::state::{AppState, Msg}; +use gitui::ui; +use ratatui::{Terminal, backend::TestBackend}; + +#[test] +fn test_ui_renders_error_message() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState { + error_message: Some("Detailed error message here".to_string()), + ..AppState::default() + }; + + terminal + .draw(|f| { + ui::draw_main(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let mut found_error = false; + let mut found_msg = false; + + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("Error") { + found_error = true; + } + if line.contains("Detailed error message here") { + found_msg = true; + } + } + + assert!(found_error, "Error title not found in UI"); + assert!(found_msg, "Error message body not found in UI"); +} + +#[test] +fn test_error_dismissal() { + let mut state = AppState { + error_message: Some("Some error".to_string()), + ..AppState::default() + }; + + // Any key should dismiss the error + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))); + + assert!( + state.error_message.is_none(), + "Error message should be dismissed on key press" + ); +} diff --git a/tools/gitui/test/ui_tests.rs b/tools/gitui/test/ui_tests.rs new file mode 100644 index 0000000..a0d7c76 --- /dev/null +++ b/tools/gitui/test/ui_tests.rs @@ -0,0 +1,670 @@ +use git2::Oid; +use gitui::diff_utils::{DiffLine, FileDiff, Hunk, LineType}; +use gitui::engine::{BranchInfo, CommitInfo}; +use gitui::split_state::{SplitState, SplitViewMode}; +use gitui::state::{AppState, SidebarState}; +use gitui::ui; +use ratatui::{Terminal, backend::TestBackend}; + +#[test] +fn test_ui_loading_bar_rendered() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let oid = Oid::from_bytes(&[0; 20]).unwrap(); + let mut state = AppState { + is_loading: true, + progress_message: "Testing Progress".to_string(), + progress_percentage: 0.5, + branches: vec![BranchInfo { + name: "master".to_string(), + oid, + ..Default::default() + }], + ..AppState::default() + }; + + terminal + .draw(|f| { + ui::draw_main(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + let mut found_msg = false; + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("Testing Progress") { + found_msg = true; + break; + } + } + assert!(found_msg, "Progress message not found in UI output"); +} + +#[test] +fn test_ui_no_loading_bar_when_not_loading() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let oid = Oid::from_bytes(&[0; 20]).unwrap(); + let mut state = AppState { + is_loading: false, + branches: vec![BranchInfo { + name: "master".to_string(), + oid, + ..Default::default() + }], + ..AppState::default() + }; + + terminal + .draw(|f| { + ui::draw_main(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + let mut found_msg = false; + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("Testing Progress") { + found_msg = true; + break; + } + } + assert!( + !found_msg, + "Progress message found in UI output when not loading" + ); +} + +#[test] +fn test_ui_renders_aliases() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let oid = Oid::from_bytes(&[0; 20]).unwrap(); + let mut state = AppState { + branches: vec![BranchInfo { + name: "master".to_string(), + oid, + aliases: vec!["origin/master".to_string(), "upstream/master".to_string()], + ..Default::default() + }], + flattened_tree: vec![("master".to_string(), 0)], + ..AppState::default() + }; + + terminal + .draw(|f| { + ui::draw_main(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + let mut found_alias = false; + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("master") && line.contains("origin/master, upstream/master") { + found_alias = true; + break; + } + } + assert!(found_alias, "Branch aliases not found in UI output"); +} + +#[test] +fn test_ui_renders_commit_sidebar() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let oid = Oid::from_bytes(&[0; 20]).unwrap(); + let mut state = AppState { + sidebar: SidebarState::Ready { + branch: "master".to_string(), + commits: vec![CommitInfo { + id: "abc1234".to_string(), + summary: "Fix bug".to_string(), + author: "Alice".to_string(), + }], + }, + flattened_tree: vec![("master".to_string(), 0)], + branches: vec![BranchInfo { + name: "master".to_string(), + oid, + ..Default::default() + }], + ..AppState::default() + }; + + terminal + .draw(|f| { + ui::draw_main(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let mut found_commit_id = false; + let mut found_summary = false; + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("abc1234") { + found_commit_id = true; + } + if line.contains("Fix bug") { + found_summary = true; + } + } + assert!(found_commit_id, "Commit ID not found in sidebar"); + assert!(found_summary, "Commit summary not found in sidebar"); +} + +#[test] +fn test_ui_renders_aligned_aliases() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let oid = Oid::from_bytes(&[0; 20]).unwrap(); + let mut state = AppState { + branches: vec![ + BranchInfo { + name: "short".to_string(), + oid, + aliases: vec!["origin/short".to_string()], + ..Default::default() + }, + BranchInfo { + name: "much-longer-name".to_string(), + oid, + ..Default::default() + }, + ], + flattened_tree: vec![ + ("short".to_string(), 0), + ("much-longer-name".to_string(), 1), + ], + ..AppState::default() + }; + + terminal + .draw(|f| { + ui::draw_main(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + let mut alias_offset = None; + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("short") && line.contains("origin/short") { + alias_offset = Some(line.find("origin/short").unwrap()); + break; + } + } + + assert!(alias_offset.is_some(), "Alias not found"); + // "much-longer-name" depth 1 -> len 16 + 2 = 18. + // "short" depth 0 -> len 5. + // max_name_len = 18. + // Padding = 18 - 5 + 2 = 15. + // Offset within the item is 20. + // ratatui List with Borders::ALL and highlight_symbol(">>") adds 5 chars of prefix (1 for border, 2 for symbol, 2 for padding). + assert_eq!(alias_offset.unwrap(), 25, "Alias not properly aligned"); +} + +#[test] +fn test_ui_renders_multiline_commits() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState { + sidebar: SidebarState::Ready { + branch: "master".to_string(), + commits: vec![CommitInfo { + id: "abc1234".to_string(), + summary: "Multiline Summary".to_string(), + author: "Alice".to_string(), + }], + }, + flattened_tree: vec![("master".to_string(), 0)], + branches: vec![BranchInfo { + name: "master".to_string(), + oid: Oid::from_bytes(&[0; 20]).unwrap(), + ..Default::default() + }], + ..AppState::default() + }; + + terminal + .draw(|f| { + ui::draw_main(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + let mut found_header_y = None; + let mut found_summary_y = None; + + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("abc1234") && line.contains("Alice") { + found_header_y = Some(y); + } + if line.contains("Multiline Summary") { + found_summary_y = Some(y); + } + } + + assert!(found_header_y.is_some(), "Commit header not found"); + assert!(found_summary_y.is_some(), "Commit summary not found"); + assert_eq!( + found_summary_y.unwrap(), + found_header_y.unwrap() + 1, + "Summary should be on the line after the header" + ); +} + +#[test] +fn test_ui_renders_aligned_aliases_with_localize() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let oid = Oid::from_bytes(&[0; 20]).unwrap(); + let mut state = AppState { + branches: vec![ + BranchInfo { + name: "origin/short".to_string(), + oid, + aliases: vec!["alias".to_string()], + ..Default::default() + }, + BranchInfo { + name: "long-name".to_string(), + oid, + ..Default::default() + }, + ], + flattened_tree: vec![ + ("origin/short".to_string(), 0), + ("long-name".to_string(), 0), + ], + ..AppState::default() + }; + state.mutate_intent("origin/short", |i| { + i.pending_localize = true; + }); + + terminal + .draw(|f| { + ui::draw_main(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + let mut alias_offset = None; + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("short") && line.contains("alias") && !line.contains("origin/short") { + alias_offset = Some(line.find("alias").unwrap()); + break; + } + } + + assert!(alias_offset.is_some(), "Alias not found"); + // "long-name" len 9. + // "origin/short" -> "short" len 5. + // max_name_len = 9. + // Padding = 9 - 5 + 2 = 6. + // Offset within item = 11. + // Offset in line = 5 (prefix) + 11 = 16. + assert_eq!( + alias_offset.unwrap(), + 16, + "Alias not properly aligned with localization" + ); +} + +#[test] +fn test_ui_renders_sidebar_spinner() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let oid = Oid::from_bytes(&[0; 20]).unwrap(); + let mut state = AppState { + sidebar: SidebarState::Loading { + branch: "master".to_string(), + }, + spinner_index: 0, + flattened_tree: vec![("master".to_string(), 0)], + branches: vec![BranchInfo { + name: "master".to_string(), + oid, + ..Default::default() + }], + ..AppState::default() + }; + + terminal + .draw(|f| { + ui::draw_main(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let spinner_char = ui::SPINNERS[0]; + let mut found_spinner = false; + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("Recent Commits") && line.contains(spinner_char) { + found_spinner = true; + break; + } + } + assert!(found_spinner, "Sidebar spinner not found while loading"); +} + +#[test] +fn test_ui_renders_split_view() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::default(); + + let file = FileDiff { + path: "test.txt".to_string(), + hunks: vec![Hunk { + header: "@@ -1,1 +1,1 @@".to_string(), + lines: vec![DiffLine { + content: "line1\n".to_string(), + line_type: LineType::Addition, + old_lineno: None, + new_lineno: Some(1), + }], + old_start: 1, + old_lines: 0, + new_start: 1, + new_lines: 1, + }], + }; + + let mut split_state = SplitState::new(vec![file]); + split_state.mode = SplitViewMode::Hunks; + split_state.rebuild_view(); + split_state + .current_selection + .insert(("test.txt".to_string(), 0)); + state.split_state = Some(split_state); + + terminal + .draw(|f| { + ui::draw_split_view(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let mut found_file = false; + let mut found_selected_hunk = false; + + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("test.txt") { + found_file = true; + } + if line.contains("[x]") && line.contains("@@ -1,1 +1,1 @@") { + found_selected_hunk = true; + } + } + + assert!(found_file, "File name not found in split view"); + assert!( + found_selected_hunk, + "Selected hunk [x] not found in split view" + ); +} + +#[test] +fn test_ui_renders_split_mode_loading() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState { + mode: gitui::state::AppMode::Split, + split_state: None, // Still loading + is_loading: true, + progress_message: "Fetching diff".to_string(), + progress_percentage: 0.4, + spinner_index: 0, + ..AppState::default() + }; + + terminal + .draw(|f| { + ui::draw_split_view(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let mut found_progress = false; + + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("Fetching diff") { + found_progress = true; + break; + } + } + + assert!( + found_progress, + "Progress message not found in Split Mode loading view" + ); +} + +#[test] +fn test_ui_renders_tree_scrollbar() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::default(); + + let oid = git2::Oid::from_bytes(&[0; 20]).unwrap(); + // Add many branches to ensure scrollbar would be useful (though it should render anyway) + for i in 0..30 { + let name = format!("branch-{}", i); + state.branches.push(gitui::engine::BranchInfo { + name: name.clone(), + oid, + ..Default::default() + }); + state.flattened_tree.push((name, 0)); + } + state.list_state.select(Some(5)); + + terminal + .draw(|f| { + ui::draw_main(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let mut found_scrollbar = false; + + // ScrollbarOrientation::VerticalRight with borders renders on the right edge. + // The main layout partition is 60% of 80, resulting in a width of 48 characters. + // We scan the buffer to verify the scrollbar symbols ("↑" and "↓") are present. + // main_layout[0].inner(...) with horizontal: 0 keeps width 48. + // Scrollbar symbols are "↑" and "↓". + + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + let symbol = buffer[(x, y)].symbol(); + if symbol == "↑" || symbol == "↓" { + found_scrollbar = true; + break; + } + } + } + + assert!( + found_scrollbar, + "Scrollbar symbols (↑ or ↓) not found in tree view" + ); +} + +#[test] +fn test_ui_shows_push_help_when_pending_amend() { + let backend = TestBackend::new(100, 20); + let mut terminal = Terminal::new(backend).unwrap(); + + let oid = Oid::from_bytes(&[0; 20]).unwrap(); + let mut state = AppState { + initial_branch: "feat".to_string(), + branches: vec![BranchInfo { + name: "feat".to_string(), + oid, + is_local: true, + ahead: 0, + ..Default::default() + }], + flattened_tree: vec![("feat".to_string(), 0)], + ..AppState::default() + }; + state.mutate_intent("feat", |i| { + i.pending_amend = true; + }); + state.list_state.select(Some(0)); + + terminal + .draw(|f| { + ui::draw_main(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let mut found_push_help = false; + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("p: push") { + found_push_help = true; + break; + } + } + assert!( + found_push_help, + "Push help should be shown when pending_amend is true" + ); +} + +#[test] +fn test_ui_shows_push_help_when_dirty_on_initial_branch() { + let backend = TestBackend::new(100, 20); + let mut terminal = Terminal::new(backend).unwrap(); + + let oid = Oid::from_bytes(&[0; 20]).unwrap(); + let mut state = AppState { + initial_branch: "feat".to_string(), + is_dirty: true, + branches: vec![BranchInfo { + name: "feat".to_string(), + oid, + is_local: true, + ahead: 0, + ..Default::default() + }], + flattened_tree: vec![("feat".to_string(), 0)], + ..AppState::default() + }; + state.list_state.select(Some(0)); + + terminal + .draw(|f| { + ui::draw_main(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let mut found_push_help = false; + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if line.contains("p: push") { + found_push_help = true; + break; + } + } + assert!( + found_push_help, + "Push help should be shown when is_dirty is true on initial branch" + ); +} + +#[test] +fn test_ui_no_tree_scrollbar_when_items_fit() { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::default(); + + let oid = git2::Oid::from_bytes(&[0; 20]).unwrap(); + // 5 branches fit in height 20 (viewport height is roughly 17 lines) + for i in 0..5 { + let name = format!("branch-{}", i); + state.branches.push(BranchInfo { + name: name.clone(), + oid, + ..Default::default() + }); + state.flattened_tree.push((name, 0)); + } + + terminal + .draw(|f| { + ui::draw_main(f, &mut state); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + let symbol = buffer[(x, y)].symbol(); + assert!( + symbol != "↑" && symbol != "↓", + "Scrollbar symbols found when items should fit at ({}, {})", + x, + y + ); + } + } +} + +// end of file diff --git a/tools/gitui/test/unit_tests.rs b/tools/gitui/test/unit_tests.rs new file mode 100644 index 0000000..b481912 --- /dev/null +++ b/tools/gitui/test/unit_tests.rs @@ -0,0 +1,1215 @@ +use git2::Oid; +use gitui::engine::{ + BranchInfo, BranchIntent, HistoryContext, Operation, RepositorySnapshot, apply_move, + calculate_plan, flatten_branches, is_descendant, +}; +use gitui::state::{AppState, ConflictCheckState, Effect, Msg, SidebarState}; +use gitui::testing::MockGit; +use std::collections::HashMap; + +fn make_branch(name: &str, oid: Oid, parent: Option<&str>) -> BranchInfo { + BranchInfo { + name: name.to_string(), + oid, + original_parent: parent.map(|s| s.to_string()), + ..Default::default() + } +} + +#[test] +fn test_is_descendant() { + let oid_m = Oid::from_bytes(&[1; 20]).unwrap(); + let oid_f1 = Oid::from_bytes(&[2; 20]).unwrap(); + let oid_f2 = Oid::from_bytes(&[3; 20]).unwrap(); + let branches = vec![ + make_branch("master", oid_m, None), + make_branch("feat1", oid_f1, Some("master")), + make_branch("feat2", oid_f2, Some("feat1")), + ]; + let intents = HashMap::new(); + + assert!(is_descendant(&branches, &intents, "feat1", "master")); + assert!(is_descendant(&branches, &intents, "feat2", "master")); + assert!(is_descendant(&branches, &intents, "feat2", "feat1")); + assert!(!is_descendant(&branches, &intents, "master", "feat1")); + assert!(!is_descendant(&branches, &intents, "feat1", "feat2")); +} + +#[test] +fn test_tree_flattening() { + let oid_m = Oid::from_bytes(&[1; 20]).unwrap(); + let oid_f1 = Oid::from_bytes(&[2; 20]).unwrap(); + let oid_f2 = Oid::from_bytes(&[3; 20]).unwrap(); + let branches = vec![ + BranchInfo { + name: "master".to_string(), + oid: oid_m, + children: vec!["feat1".to_string()], + ..Default::default() + }, + BranchInfo { + name: "feat1".to_string(), + oid: oid_f1, + original_parent: Some("master".to_string()), + children: vec!["feat2".to_string()], + ..Default::default() + }, + BranchInfo { + name: "feat2".to_string(), + oid: oid_f2, + original_parent: Some("feat1".to_string()), + ..Default::default() + }, + ]; + let intents = HashMap::new(); + + let flattened = flatten_branches(&branches, &intents, &HistoryContext::new(), false).unwrap(); + assert_eq!(flattened.len(), 3); + assert_eq!(flattened[0].0, "master"); + assert_eq!(flattened[1].0, "feat1"); + assert_eq!(flattened[2].0, "feat2"); + assert_eq!(flattened[2].1, 2); // depth +} + +#[test] +fn test_tree_with_missing_parent() { + let oid = Oid::from_bytes(&[0; 20]).unwrap(); + let branches = vec![BranchInfo { + name: "master".to_string(), + oid, + original_parent: Some("origin/master".to_string()), + ..Default::default() + }]; + let intents = HashMap::new(); + + let flattened = flatten_branches(&branches, &intents, &HistoryContext::new(), false).unwrap(); + assert_eq!(flattened.len(), 1); + assert_eq!(flattened[0].0, "master"); +} + +#[test] +fn test_swap_branches_high_level() { + let oid_m = Oid::from_bytes(&[1; 20]).unwrap(); + let oid_t = Oid::from_bytes(&[2; 20]).unwrap(); + let oid_g = Oid::from_bytes(&[3; 20]).unwrap(); + let oid_r = Oid::from_bytes(&[4; 20]).unwrap(); + let branches = vec![ + BranchInfo { + name: "master".to_string(), + oid: oid_m, + children: vec!["toxav-msi".to_string()], + ..Default::default() + }, + BranchInfo { + name: "toxav-msi".to_string(), + oid: oid_t, + original_parent: Some("master".to_string()), + children: vec!["groups-perf".to_string()], + ..Default::default() + }, + BranchInfo { + name: "groups-perf".to_string(), + oid: oid_g, + original_parent: Some("toxav-msi".to_string()), + children: vec!["rtp-hbo".to_string()], + ..Default::default() + }, + BranchInfo { + name: "rtp-hbo".to_string(), + oid: oid_r, + original_parent: Some("groups-perf".to_string()), + ..Default::default() + }, + ]; + + let _mock_git = MockGit::new(branches.clone()); + let mut intents = HashMap::new(); + + // Move rtp-hbo to be child of toxav-msi + apply_move(&mut intents, "rtp-hbo", Some("toxav-msi".to_string())).unwrap(); + assert_eq!( + intents + .get("rtp-hbo") + .unwrap() + .parent + .as_ref() + .and_then(|o| o.as_deref()), + Some("toxav-msi") + ); + + // Move groups-perf to be child of rtp-hbo + apply_move(&mut intents, "groups-perf", Some("rtp-hbo".to_string())).unwrap(); + assert_eq!( + intents + .get("groups-perf") + .unwrap() + .parent + .as_ref() + .and_then(|o| o.as_deref()), + Some("rtp-hbo") + ); +} + +#[test] +fn test_calculate_plan_rebase() { + use Operation; + let oid_m = Oid::from_bytes(&[1; 20]).unwrap(); + let oid_f1 = Oid::from_bytes(&[2; 20]).unwrap(); + let oid_f2 = Oid::from_bytes(&[3; 20]).unwrap(); + let branches = vec![ + BranchInfo { + name: "master".to_string(), + oid: oid_m, + children: vec!["feat1".to_string()], + ..Default::default() + }, + BranchInfo { + name: "feat1".to_string(), + oid: oid_f1, + original_parent: Some("master".to_string()), + children: vec!["feat2".to_string()], + ..Default::default() + }, + BranchInfo { + name: "feat2".to_string(), + oid: oid_f2, + original_parent: Some("feat1".to_string()), + ..Default::default() + }, + ]; + + let mut intents = HashMap::new(); + // Move feat2 to be child of master. + intents.insert( + "feat2".to_string(), + BranchIntent { + parent: Some(Some("master".to_string())), + ..Default::default() + }, + ); + + let snapshot = RepositorySnapshot { + branches, + history: HistoryContext::new(), + is_dirty: false, + }; + + let plan = calculate_plan(&snapshot, &intents).unwrap(); + assert_eq!(plan.len(), 1); + if let Operation::Rebase { + upstream: _, + branch, + onto, + predicted_conflict: _, + } = &plan[0] + { + assert_eq!(branch, "feat2"); + assert_eq!(onto, "master"); + } else { + panic!("Expected rebase operation"); + } +} + +#[test] +fn test_transitive_rebase_planning() { + use Operation; + let oid_m = Oid::from_bytes(&[1; 20]).unwrap(); + let oid_f1 = Oid::from_bytes(&[2; 20]).unwrap(); + let oid_f2 = Oid::from_bytes(&[3; 20]).unwrap(); + let branches = vec![ + BranchInfo { + name: "master".to_string(), + oid: oid_m, + children: vec!["feat1".to_string()], + ..Default::default() + }, + BranchInfo { + name: "feat1".to_string(), + oid: oid_f1, + original_parent: Some("master".to_string()), + children: vec!["feat2".to_string()], + ..Default::default() + }, + BranchInfo { + name: "feat2".to_string(), + oid: oid_f2, + original_parent: Some("feat1".to_string()), + ..Default::default() + }, + ]; + + let mut intents = HashMap::new(); + // Move feat1 to be root (effectively a rebase onto nothing, or another base) + // For this test, let's just mark feat1 for reset. + intents.insert( + "feat1".to_string(), + BranchIntent { + pending_reset: true, + ..Default::default() + }, + ); + + let snapshot = RepositorySnapshot { + branches, + history: HistoryContext::new(), + is_dirty: false, + }; + + let plan = calculate_plan(&snapshot, &intents).unwrap(); + + // Expect: + // 1. Reset feat1 (due to pending_reset and no upstream) + // 2. Rebase feat2 (because its parent feat1 was rebased/reset) + assert!( + plan.iter() + .any(|op| matches!(op, Operation::Reset { branch, .. } if branch == "feat1")) + ); + assert!(plan.iter().any( + |op| matches!(op, Operation::Rebase { upstream: _, branch, .. } if branch == "feat2") + )); +} + +#[test] +fn test_noop_filtering_reset() { + let oid = Oid::from_bytes(&[1; 20]).unwrap(); + let mut history = HistoryContext::new(); + history + .oid_to_visible_branch + .insert(oid, "master".to_string()); + + let branches = vec![BranchInfo { + name: "master".to_string(), + oid, + upstream_oid: Some(oid), // Already in sync + upstream: Some("origin/master".to_string()), + ..Default::default() + }]; + let mut intents = HashMap::new(); + intents.insert( + "master".to_string(), + BranchIntent { + pending_reset: true, + ..Default::default() + }, + ); + + let snapshot = RepositorySnapshot { + branches, + history, + is_dirty: false, + }; + + let plan = calculate_plan(&snapshot, &intents).unwrap(); + assert!( + plan.is_empty(), + "Reset should be filtered out as no-op when OIDs match" + ); +} + +#[test] +fn test_noop_filtering_structural_move() { + let oid_a = Oid::from_bytes(&[1; 20]).unwrap(); + let oid_b = Oid::from_bytes(&[2; 20]).unwrap(); + + let mut history = HistoryContext::new(); + // B is already a child of A + history.oid_to_ancestor.insert(oid_b, Some(oid_a)); + history.oid_to_visible_branch.insert(oid_a, "A".to_string()); + history.oid_to_visible_branch.insert(oid_b, "B".to_string()); + + let branches = vec![ + BranchInfo { + name: "A".to_string(), + oid: oid_a, + children: vec!["B".to_string()], + ..Default::default() + }, + BranchInfo { + name: "B".to_string(), + oid: oid_b, + original_parent: None, // It was a root before + ..Default::default() + }, + ]; + let mut intents = HashMap::new(); + intents.insert( + "B".to_string(), + BranchIntent { + parent: Some(Some("A".to_string())), + ..Default::default() + }, + ); + + let snapshot = RepositorySnapshot { + branches, + history, + is_dirty: false, + }; + + let plan = calculate_plan(&snapshot, &intents).unwrap(); + assert!( + plan.is_empty(), + "Structural move should be filtered out as no-op if topological relationship already exists" + ); +} + +#[test] +fn test_dirty_filtering() { + let oid_m = Oid::from_bytes(&[1; 20]).unwrap(); + let oid_f1 = Oid::from_bytes(&[2; 20]).unwrap(); + let branches = vec![ + BranchInfo { + name: "master".to_string(), + oid: oid_m, + children: vec!["feat1".to_string()], + ..Default::default() + }, + BranchInfo { + name: "feat1".to_string(), + oid: oid_f1, + original_parent: Some("master".to_string()), + ..Default::default() + }, + ]; + + let mut intents = HashMap::new(); + intents.insert( + "feat1".to_string(), + BranchIntent { + parent: None, + pending_push: true, + ..Default::default() + }, + ); + intents.insert( + "master".to_string(), + BranchIntent { + pending_delete: true, + ..Default::default() + }, + ); + + let snapshot = RepositorySnapshot { + branches, + history: HistoryContext::new(), + is_dirty: true, + }; + + // When dirty: rebase should be filtered out, but push and delete should remain + let plan = calculate_plan(&snapshot, &intents).unwrap(); + assert_eq!(plan.len(), 2); + assert!(plan.iter().any(|op| matches!(op, Operation::Push { .. }))); + assert!(plan.iter().any(|op| matches!(op, Operation::Delete { .. }))); + assert!( + !plan + .iter() + .any(|op| matches!(op, Operation::Rebase { upstream: _, .. })) + ); +} + +#[test] +fn test_is_better_name() { + use gitui::engine::is_better_name; + + assert!(is_better_name("master", "feat1")); + assert!(!is_better_name("feat1", "master")); + assert!(is_better_name("main", "feat1")); + assert!(!is_better_name("feat1", "main")); + assert!(is_better_name("a-feat", "z-feat")); + assert!(!is_better_name("z-feat", "a-feat")); + assert!(!is_better_name("same", "same")); + + // master is better than main + assert!(is_better_name("master", "main")); + assert!(!is_better_name("main", "master")); +} + +#[test] +fn test_operation_commands() { + use Operation; + + let op = Operation::Rebase { + upstream: None, + branch: "feat".to_string(), + onto: "master".to_string(), + predicted_conflict: None, + }; + assert_eq!( + op.commands(), + vec![vec![ + "rebase".to_string(), + "master".to_string(), + "feat".to_string() + ]] + ); + assert_eq!(format!("{}", op), "git rebase master feat"); + + let op = Operation::Reset { + branch: "feat".to_string(), + }; + assert_eq!( + op.commands(), + vec![ + vec!["checkout".to_string(), "feat".to_string()], + vec![ + "reset".to_string(), + "--hard".to_string(), + "@{u}".to_string() + ], + ] + ); + assert_eq!( + format!("{}", op), + "git checkout feat && git reset --hard @{u}" + ); + + let op = Operation::Localize { + branch: "origin/feat".to_string(), + }; + assert_eq!( + op.commands(), + vec![vec![ + "checkout".to_string(), + "-B".to_string(), + "feat".to_string(), + "--track".to_string(), + "origin/feat".to_string(), + ]] + ); +} + +#[test] +fn test_app_state_multiline_prompt_navigation() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use gitui::state::{AppMode, PromptAction, PromptFocus, PromptState}; + + let mut state = AppState { + mode: AppMode::Prompt, + prompt: Some(PromptState { + title: "Test".to_string(), + value: "line1\nline2\nline3".to_string(), + cursor_position: 11, // End of "line2" (index of second \n) + action: PromptAction::SplitPartMessage, + focus: PromptFocus::Input, + }), + ..Default::default() + }; + + // 1. Move UP to line 1 + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Up, + KeyModifiers::NONE, + ))); + // Cursor was at end of line2 (pos 11), should move to end of line1 (pos 5) + assert_eq!(state.prompt.as_ref().unwrap().cursor_position, 5); + + // 2. Move DOWN to line 2 + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Down, + KeyModifiers::NONE, + ))); + assert_eq!(state.prompt.as_ref().unwrap().cursor_position, 11); + + // 3. Move DOWN to line 3 + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Down, + KeyModifiers::NONE, + ))); + assert_eq!(state.prompt.as_ref().unwrap().cursor_position, 17); // End of line 3 (last char) + + // 4. Test Enter for newline + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))); + assert_eq!( + state.prompt.as_ref().unwrap().value, + "line1\nline2\nline3\n" + ); + assert_eq!(state.prompt.as_ref().unwrap().cursor_position, 18); + + // 5. Test Tab navigation + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Tab, + KeyModifiers::NONE, + ))); + assert_eq!(state.prompt.as_ref().unwrap().focus, PromptFocus::Ok); + + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Tab, + KeyModifiers::NONE, + ))); + assert_eq!(state.prompt.as_ref().unwrap().focus, PromptFocus::Cancel); + + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Tab, + KeyModifiers::NONE, + ))); + assert_eq!(state.prompt.as_ref().unwrap().focus, PromptFocus::Input); + + // 6. Test BackTab + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::BackTab, + KeyModifiers::NONE, + ))); + assert_eq!(state.prompt.as_ref().unwrap().focus, PromptFocus::Cancel); +} + +#[test] +fn test_app_state_log_loading_logic() { + use std::time::Instant; + + let mut state = AppState::default(); + + // Initial state: not loading (no selection time yet) + assert!(!state.is_log_loading()); + + // Selection changed: sets debouncing state + state.sidebar = SidebarState::Debouncing { + branch: "master".to_string(), + since: Instant::now(), + }; + + // Should NOT be loading yet (waiting for debounce) + assert!(!state.is_log_loading()); + + // Request sent: state becomes Loading + state.sidebar = SidebarState::Loading { + branch: "master".to_string(), + }; + assert!(state.is_log_loading()); + + // Result received: state becomes Ready + state.sidebar = SidebarState::Ready { + branch: "master".to_string(), + commits: vec![gitui::engine::CommitInfo { + id: "abc1234".to_string(), + summary: "commit 1".to_string(), + author: "Author".to_string(), + }], + }; + + // Should not be loading anymore + assert!(!state.is_log_loading()); +} + +#[test] +fn test_app_state_log_fetch_debounce() { + use std::time::{Duration, Instant}; + + let mut state = AppState { + flattened_tree: vec![("master".to_string(), 0)], + ..Default::default() + }; + + // 1. Initial state: nothing to fetch + assert!(state.sidebar == SidebarState::Idle); + + // 2. Selection changed: last_selection_time set + state.sidebar = SidebarState::Debouncing { + branch: "master".to_string(), + since: Instant::now(), + }; + + // 3. Tick immediately: too soon, no effect returned + assert!(state.update(Msg::Tick).is_empty()); + + // 4. Wait for debounce (500ms) + state.sidebar = SidebarState::Debouncing { + branch: "master".to_string(), + since: Instant::now() - Duration::from_millis(550), + }; + + // 5. First call: triggers fetch, returns effect, updates state to Loading + let effects = state.update(Msg::Tick); + assert!( + effects + .iter() + .any(|e| matches!(e, Effect::FetchCommitLog { .. })) + ); + assert!(state.is_log_loading()); + + // 6. Second call: already fetching, returns nothing + assert!(state.update(Msg::Tick).is_empty()); +} + +#[test] +fn test_app_state_update_branches_loaded() { + let mut state = AppState::default(); + let oid = Oid::from_bytes(&[1; 20]).unwrap(); + let branches = vec![BranchInfo { + name: "master".to_string(), + oid, + ..Default::default() + }]; + + let history = HistoryContext::new(); + let msg = Msg::BranchesLoaded(Ok((branches, history, false))); + + let effects = state.update(msg); + + // Selection should be at 0, Sidebar should be Debouncing + assert_eq!(state.branches.len(), 1); + assert_eq!(state.list_state.selected(), Some(0)); + assert!(matches!(state.sidebar, SidebarState::Debouncing { .. })); + // No immediate effects expected on load (wait for tick) + assert!(effects.is_empty()); +} + +#[test] +fn test_app_state_keyboard_navigation() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let mut state = AppState { + flattened_tree: vec![ + ("a".to_string(), 0), + ("b".to_string(), 0), + ("c".to_string(), 0), + ], + ..Default::default() + }; + + // Down + let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); + state.update(Msg::KeyPressed(key)); + assert_eq!(state.list_state.selected(), Some(1)); + assert!(matches!(state.sidebar, SidebarState::Debouncing { ref branch, .. } if branch == "b")); + + // Up + let key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE); + state.update(Msg::KeyPressed(key)); + assert_eq!(state.list_state.selected(), Some(0)); + assert!(matches!(state.sidebar, SidebarState::Debouncing { ref branch, .. } if branch == "a")); +} + +#[test] +fn test_app_state_conflict_checked_flow() { + let mut state = AppState { + branches: vec![BranchInfo { + name: "feat".to_string(), + oid: Oid::from_bytes(&[1; 20]).unwrap(), + ..Default::default() + }], + grabbed_branch: Some("feat".to_string()), + conflict_check: ConflictCheckState::Checking { + branch: "feat".to_string(), + onto: "master".to_string(), + }, + ..Default::default() + }; + + state.update(Msg::ConflictChecked( + "feat".to_string(), + "master".to_string(), + Ok(true), + )); + + assert_eq!(state.conflict_check, ConflictCheckState::Idle); + assert_eq!( + state + .conflict_cache + .get(&("feat".to_string(), "master".to_string())), + Some(&true) + ); + assert!(state.get_intent("feat").has_conflict); +} + +#[test] +fn test_app_state_prediction_flow() { + let mut state = AppState::default(); + let oid_m = Oid::from_bytes(&[1; 20]).unwrap(); + let oid_f = Oid::from_bytes(&[2; 20]).unwrap(); + state.branches = vec![ + BranchInfo { + name: "master".to_string(), + oid: oid_m, + ..Default::default() + }, + BranchInfo { + name: "feat".to_string(), + oid: oid_f, + original_parent: None, + ..Default::default() + }, + ]; + state.mutate_intent("feat", |i| { + i.parent = Some(Some("master".to_string())); + }); + state.flattened_tree = vec![("master".to_string(), 0), ("feat".to_string(), 1)]; + + // 1. Enter preview mode + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let key_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE); + let effects = state.update(Msg::KeyPressed(key_v)); + + assert!(state.show_preview); + assert!(state.is_predicting_conflicts); + assert!( + effects + .iter() + .any(|e| matches!(e, Effect::PredictConflicts { .. })) + ); + + // 2. Receive prediction result + let predicted_plan = vec![Operation::Rebase { + upstream: None, + branch: "feat".to_string(), + onto: "master".to_string(), + predicted_conflict: Some(true), + }]; + state.update(Msg::ConflictsPredicted(predicted_plan.clone())); + + assert!(!state.is_predicting_conflicts); + assert_eq!(state.plan.as_ref(), Some(&predicted_plan)); +} + +#[test] +fn test_app_state_commit_effect() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let mut state = AppState::default(); + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE); + let effects = state.update(Msg::KeyPressed(key)); + + assert!( + effects + .iter() + .any(|e| matches!(e, Effect::ApplyAndQuit(_, _))) + ); +} + +#[test] +fn test_quit_effect() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let mut state = AppState::default(); + + // 1. Initial state: empty branches, so calculate_plan is empty. 'q' should quit immediately. + let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE); + let effects = state.update(Msg::KeyPressed(key)); + assert!(effects.iter().any(|e| matches!(e, Effect::Quit))); + + // 2. Modified state: calculate_plan will NOT be empty because feat's parent changed. + // 'q' should show confirmation. + state.is_dirty = false; // reset to be sure + let oid_m = Oid::from_bytes(&[1; 20]).unwrap(); + let oid_f = Oid::from_bytes(&[2; 20]).unwrap(); + state.branches = vec![ + BranchInfo { + name: "master".to_string(), + oid: oid_m, + ..Default::default() + }, + BranchInfo { + name: "feat".to_string(), + oid: oid_f, + original_parent: None, // This makes it NOT empty + ..Default::default() + }, + ]; + state.mutate_intent("feat", |i| { + i.parent = Some(Some("master".to_string())); + }); + state.flattened_tree = vec![("master".to_string(), 0), ("feat".to_string(), 1)]; + + let effects = state.update(Msg::KeyPressed(key)); + assert!( + effects.is_empty(), + "Expected empty effects, but got {:?}", + effects + ); + assert!(state.show_quit_confirmation); + + // 3. Confirming quit: 'y' or 'q' should quit + let key_y = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE); + let effects = state.update(Msg::KeyPressed(key_y)); + assert!(effects.iter().any(|e| matches!(e, Effect::Quit))); + + // 4. In preview mode: 'q' should exit preview, not quit app + state.show_quit_confirmation = false; + state.show_preview = true; + let effects = state.update(Msg::KeyPressed(key)); + assert!(effects.is_empty()); + assert!(!state.show_preview); +} + +#[test] +fn test_calculate_plan_submit() { + let oid_m = Oid::from_bytes(&[1; 20]).unwrap(); + let oid_f = Oid::from_bytes(&[2; 20]).unwrap(); + let branches = vec![ + BranchInfo { + name: "master".to_string(), + oid: oid_m, + upstream_oid: Some(oid_m), + upstream: Some("origin/master".to_string()), + ..Default::default() + }, + BranchInfo { + name: "feat".to_string(), + oid: oid_f, + original_parent: Some("master".to_string()), + ahead: 1, + ..Default::default() + }, + BranchInfo { + name: "desc".to_string(), + oid: Oid::from_bytes(&[3; 20]).unwrap(), + original_parent: Some("feat".to_string()), + ahead: 1, + ..Default::default() + }, + ]; + let mut intents = HashMap::new(); + intents.insert( + "feat".to_string(), + BranchIntent { + pending_submit: true, + ..Default::default() + }, + ); + + let mut history = HistoryContext::new(); + history.oid_to_ancestor.insert(oid_f, Some(oid_m)); + history.oid_to_ancestor.insert(oid_m, None); + history + .oid_to_visible_branch + .insert(oid_m, "master".to_string()); + history + .oid_to_visible_branch + .insert(oid_f, "feat".to_string()); + + let snapshot = RepositorySnapshot { + branches, + history, + is_dirty: false, + }; + + let plan = calculate_plan(&snapshot, &intents).unwrap(); + + // Expected: + // 1. Submit feat + // 2. Rebase desc onto feat (actually feat is now master, but parent remains feat in topology) + // 3. Push desc + + assert!( + plan.iter() + .any(|op| matches!(op, Operation::Submit { branch, .. } if branch == "feat")) + ); + assert!( + !plan.iter().any( + |op| matches!(op, Operation::Rebase { upstream: _, branch, .. } if branch == "desc") + ), + "Descendant should NOT be rebased if the parent was submitted (stays at same OID)" + ); + assert!( + plan.iter() + .any(|op| matches!(op, Operation::Delete { branch } if branch == "feat")), + "Submitted branch should be deleted after landing" + ); +} + +#[test] +fn test_predict_conflicts_simulation() { + use gitui::engine::predict_conflicts; + + let oid_m = Oid::from_bytes(&[1; 20]).unwrap(); + let oid_f = Oid::from_bytes(&[2; 20]).unwrap(); + let branches = vec![ + BranchInfo { + name: "master".to_string(), + oid: oid_m, + ..Default::default() + }, + BranchInfo { + name: "feat".to_string(), + oid: oid_f, + ..Default::default() + }, + ]; + + let mut plan = vec![ + Operation::Submit { + branch: "feat".to_string(), + target: "master".to_string(), + }, + Operation::Rebase { + upstream: None, + branch: "other".to_string(), + onto: "master".to_string(), + predicted_conflict: None, + }, + ]; + + // Add "other" to branches so it's found + let mut branches_with_other = branches.clone(); + let oid_o = Oid::from_bytes(&[3; 20]).unwrap(); + branches_with_other.push(BranchInfo { + name: "other".to_string(), + oid: oid_o, + ..Default::default() + }); + + let mock_git = MockGit::new(branches_with_other.clone()); + predict_conflicts(&mut plan, &mock_git, &branches_with_other, None); + + // Verify that the rebase check used the OID of "feat" (the new master), not the old master OID + let calls = mock_git.check_conflict_between_calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0], (oid_o, oid_f)); // (branch_oid, onto_oid) +} + +#[test] +fn test_calculate_plan_amend_and_rebase_subtree() { + use Operation; + let oid_m = Oid::from_bytes(&[1; 20]).unwrap(); + let oid_f1 = Oid::from_bytes(&[2; 20]).unwrap(); + let oid_f2 = Oid::from_bytes(&[3; 20]).unwrap(); + let branches = vec![ + BranchInfo { + name: "master".to_string(), + oid: oid_m, + ..Default::default() + }, + BranchInfo { + name: "feat1".to_string(), + oid: oid_f1, + original_parent: Some("master".to_string()), + ..Default::default() + }, + BranchInfo { + name: "feat2".to_string(), + oid: oid_f2, + original_parent: Some("feat1".to_string()), + ..Default::default() + }, + ]; + + let mut intents = HashMap::new(); + intents.insert( + "feat1".to_string(), + BranchIntent { + pending_amend: true, + ..Default::default() + }, + ); + + let snapshot = RepositorySnapshot { + branches, + history: HistoryContext::new(), + is_dirty: true, + }; + + // Test with is_dirty = true + let plan = calculate_plan(&snapshot, &intents).unwrap(); + + // 1. Amend feat1 (even if dirty, because it's pending_amend) + // 2. Rebase feat2 onto feat1 (because feat1 was amended, and effectively_dirty becomes false) + assert!(matches!(plan[0], Operation::Amend { .. })); + match &plan[1] { + Operation::Rebase { + branch, + onto, + upstream: _, + predicted_conflict: _, + } => { + assert_eq!(branch, "feat2"); + assert_eq!(onto, "feat1"); + } + _ => panic!("Expected rebase operation"), + } +} + +#[test] +fn test_amend_operation_commands() { + use Operation; + let op = Operation::Amend { message: None }; + let cmds = op.commands(); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0], vec!["commit", "--amend", "--no-edit", "-a"]); + assert_eq!(format!("{}", op), "git commit --amend --no-edit -a"); + + let op_msg = Operation::Amend { + message: Some("new msg".to_string()), + }; + let cmds_msg = op_msg.commands(); + assert_eq!(cmds_msg.len(), 1); + assert_eq!( + cmds_msg[0], + vec!["commit", "--amend", "-m", "new msg", "-a"] + ); +} + +#[test] +fn test_split_mode_cancellation() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let mut state = AppState::default(); + + // 1. Enter split mode (simulated) + state.mode = gitui::state::AppMode::Split; + state.is_loading = true; + state.split_state = None; + + // 2. Press 'q' to cancel + let key_q = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE); + state.update(Msg::KeyPressed(key_q)); + + // 3. Verify state + assert_eq!(state.mode, gitui::state::AppMode::Tree); + assert!(!state.is_loading); +} + +#[test] +fn test_calculate_plan_upstream_mismatch() { + use git2::Oid; + use gitui::engine::types::{BranchInfo, BranchIntent, Operation, RepositorySnapshot}; + use gitui::engine::{HistoryContext, calculate_plan}; + use std::collections::HashMap; + + let oid_m = Oid::from_bytes(&[1; 20]).unwrap(); + let oid_f1 = Oid::from_bytes(&[2; 20]).unwrap(); + let oid_f2 = Oid::from_bytes(&[3; 20]).unwrap(); + + let branches = vec![ + BranchInfo { + name: "master".to_string(), + oid: oid_m, + ..Default::default() + }, + BranchInfo { + name: "feat1".to_string(), + oid: oid_f1, + original_parent: Some("master".to_string()), + ..Default::default() + }, + BranchInfo { + name: "feat2".to_string(), + oid: oid_f2, + original_parent: Some("feat1".to_string()), + heuristic_parent: Some("feat1".to_string()), + heuristic_upstream_oid: Some(oid_f1), // Heuristic says it belongs on feat1 head + ..Default::default() + }, + ]; + + let mut intents = HashMap::new(); + // Converge feat2 to feat1 (names match original_parent) + intents.insert( + "feat2".to_string(), + BranchIntent { + parent: Some(Some("feat1".to_string())), + ..Default::default() + }, + ); + + let mut history = HistoryContext::new(); + // feat2 is currently on master, but heuristic says it belongs on feat1 (at oid_f1) + history.oid_to_ancestor.insert(oid_f2, Some(oid_m)); + + let snapshot = RepositorySnapshot { + branches, + history, + is_dirty: false, + }; + + let plan = calculate_plan(&snapshot, &intents).unwrap(); + + // It should plan a rebase because heuristic upstream (oid_f1) differs from current topo parent (oid_m). + assert!( + plan.iter().any(|op| matches!(op, Operation::Rebase { branch, onto, .. } if branch == "feat2" && onto == "feat1")), + "Should plan rebase because heuristic upstream differs from current parent. Plan: {:?}", plan + ); +} + +#[test] +fn test_rebase_command_generation() { + use gitui::engine::types::Operation; + + let op_simple = Operation::Rebase { + branch: "my-branch".to_string(), + onto: "master".to_string(), + upstream: None, + predicted_conflict: None, + }; + let cmds_simple = op_simple.commands(); + assert_eq!(cmds_simple.len(), 1); + assert_eq!(cmds_simple[0], vec!["rebase", "master", "my-branch"]); + + let op_3arg = Operation::Rebase { + branch: "my-branch".to_string(), + onto: "master".to_string(), + upstream: Some("old-parent-oid".to_string()), + predicted_conflict: None, + }; + let cmds_3arg = op_3arg.commands(); + assert_eq!(cmds_3arg.len(), 1); + assert_eq!( + cmds_3arg[0], + vec!["rebase", "--onto", "master", "old-parent-oid", "my-branch"] + ); +} + +#[test] +fn test_input_utf8_handling() { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use gitui::state::{AppMode, PromptAction, PromptFocus, PromptState}; + + let mut state = AppState { + mode: AppMode::Prompt, + prompt: Some(PromptState { + title: "Test".to_string(), + value: "".to_string(), + cursor_position: 0, + action: PromptAction::RenameBranch, + focus: PromptFocus::Input, + }), + ..Default::default() + }; + + // 1. Insert '€' (3 bytes) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('€'), + KeyModifiers::NONE, + ))); + + // Check cursor position moved by 3 + assert_eq!(state.prompt.as_ref().unwrap().cursor_position, 3); + assert_eq!(state.prompt.as_ref().unwrap().value, "€"); + + // 2. Insert 'a' (1 byte) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::NONE, + ))); + assert_eq!(state.prompt.as_ref().unwrap().cursor_position, 4); + assert_eq!(state.prompt.as_ref().unwrap().value, "€a"); + + // 3. Move Left (should jump over 'a' (1 byte) to pos 3) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Left, + KeyModifiers::NONE, + ))); + assert_eq!(state.prompt.as_ref().unwrap().cursor_position, 3); + + // 4. Move Left (should jump over '€' (3 bytes) to pos 0) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Left, + KeyModifiers::NONE, + ))); + assert_eq!(state.prompt.as_ref().unwrap().cursor_position, 0); + + // 5. Move Right (jump over '€' to pos 3) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Right, + KeyModifiers::NONE, + ))); + assert_eq!(state.prompt.as_ref().unwrap().cursor_position, 3); + + // 6. Delete (Backspace) '€' (from pos 3) + state.update(Msg::KeyPressed(KeyEvent::new( + KeyCode::Backspace, + KeyModifiers::NONE, + ))); + assert_eq!(state.prompt.as_ref().unwrap().cursor_position, 0); + assert_eq!(state.prompt.as_ref().unwrap().value, "a"); +} diff --git a/tools/gitui/test/virtual_layer_proptests.rs b/tools/gitui/test/virtual_layer_proptests.rs new file mode 100644 index 0000000..a666807 --- /dev/null +++ b/tools/gitui/test/virtual_layer_proptests.rs @@ -0,0 +1,134 @@ +use gitui::testing::mock_oid; +use gitui::topology::virtual_layer::VirtualLayer; +use gitui::topology::{Intent, VirtualTopology}; +use proptest::prelude::*; +use std::collections::HashSet; + +#[derive(Debug, Clone)] +struct SplitScenario { + base_branches: Vec<(String, u8)>, + base_rels: Vec<(String, String)>, + hidden: Vec, + virtuals: Vec<(String, Option, Option)>, +} + +/// Strategy for a base topology and a set of virtual replacements. +fn arb_split_scenario() -> impl Strategy { + // Fixed base structure: master -> feat1 -> feat2 + // feat1 -> child1 + // feat2 -> child2 + let base_branches = vec![ + ("master".to_string(), 1u8), + ("feat1".to_string(), 2u8), + ("feat2".to_string(), 3u8), + ("child1".to_string(), 4u8), + ("child2".to_string(), 5u8), + ]; + let base_rels = vec![ + ("feat1".to_string(), "master".to_string()), + ("feat2".to_string(), "feat1".to_string()), + ("child1".to_string(), "feat1".to_string()), + ("child2".to_string(), "feat2".to_string()), + ]; + + // Decide which of feat1, feat2 to "split" (hide and replace with virtual parts) + ( + prop::sample::subsequence(vec!["feat1".to_string(), "feat2".to_string()], 1..2), + prop::collection::vec("[a-z]{1,5}", 1..3), // prefix for virtual parts + ) + .prop_map(move |(to_split, prefixes)| { + let mut hidden = Vec::new(); + let mut virtuals = Vec::new(); + + for (prefix_idx, name) in to_split.into_iter().enumerate() { + hidden.push(name.clone()); + // Create a part and then the branch itself + let part_name = format!("{}-part", prefixes[prefix_idx % prefixes.len()]); + + // The split: parent -> part -> original_name + // We need to know the parent in the base rels + let parent = base_rels + .iter() + .find(|(c, _)| c == &name) + .map(|(_, p)| p.clone()); + + virtuals.push((part_name.clone(), parent, None)); + virtuals.push((name.clone(), Some(part_name), Some(3u8))); // Use feat2's oid or similar + } + + SplitScenario { + base_branches: base_branches.clone(), + base_rels: base_rels.clone(), + hidden, + virtuals, + } + }) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn test_virtual_layer_relationship_preservation( + scenario in arb_split_scenario() + ) { + let SplitScenario { base_branches, base_rels, hidden, virtuals } = scenario; + let mut topo = VirtualTopology::new(); + for (name, oid_b) in &base_branches { + topo.add_branch(name, mock_oid(*oid_b)); + } + for (child, parent) in &base_rels { + topo.set_parent(child, Some(parent), None, Intent::Implicit).unwrap(); + } + + let mut layer = VirtualLayer::new(); + for h in &hidden { + layer.hide_branch(h); + } + for (v_name, v_parent, v_oid_b) in &virtuals { + layer.add_virtual_branch(v_name.clone(), v_parent.clone(), v_oid_b.map(mock_oid)); + } + + layer.apply(&mut topo); + let flattened = topo.flatten(); + + // Property: If a branch 'C' was a child of 'P' in the base, + // and 'P' was replaced by a virtual branch with the same name 'P', + // 'P' must still be an ancestor of 'C'. + + for (child, parent) in &base_rels { + // If the parent was hidden but REPLACED by a virtual branch of the same name + let is_parent_replaced = hidden.contains(parent) && virtuals.iter().any(|(n, _, _)| n == parent); + // If the child was NOT truly removed + let is_child_removed = hidden.contains(child) && !virtuals.iter().any(|(n, _, _)| n == child); + + if is_parent_replaced && !is_child_removed { + let child_idx = flattened.iter().position(|(n, _)| n == child) + .unwrap_or_else(|| panic!("Child {} should exist", child)); + let parent_idx = flattened.iter().position(|(n, _)| n == parent) + .unwrap_or_else(|| panic!("Parent {} should exist", parent)); + + prop_assert!(parent_idx < child_idx, + "Relationship broken: Parent '{}' (pos {}) must precede child '{}' (pos {}) after split", + parent, parent_idx, child, child_idx + ); + + // Also verify it's a descendant in the graph + let mut current = child.clone(); + let mut found = false; + let mut visited = HashSet::new(); + while let Some(p_node) = topo.get_parents(¤t).first() { + if let Some(p_name) = p_node.name() { + if p_name == parent { + found = true; + break; + } + current = p_name.to_string(); + if !visited.insert(current.clone()) { break; } + } else { break; } + } + prop_assert!(found, "Child '{}' is no longer a topological descendant of parent '{}'", child, parent); + } + } + } +} diff --git a/tools/gitui/test/virtual_layer_tests.rs b/tools/gitui/test/virtual_layer_tests.rs new file mode 100644 index 0000000..1a0bd3b --- /dev/null +++ b/tools/gitui/test/virtual_layer_tests.rs @@ -0,0 +1,153 @@ +use git2::Oid; +use gitui::topology::VirtualTopology; +use gitui::topology::virtual_layer::VirtualLayer; + +#[test] +fn test_virtual_layer_apply() { + let mut topo = VirtualTopology::new(); + let root_oid = Oid::from_bytes(&[1; 20]).unwrap(); + let branch_oid = Oid::from_bytes(&[2; 20]).unwrap(); + + topo.add_branch("master", root_oid); + topo.add_branch("feat", branch_oid); + let _ = topo.set_parent( + "feat", + Some("master"), + None, + gitui::topology::Intent::Implicit, + ); + + let mut layer = VirtualLayer::new(); + layer.hide_branch("feat"); + layer.add_virtual_branch("feat-1".to_string(), Some("master".to_string()), None); + layer.add_virtual_branch( + "feat".to_string(), + Some("feat-1".to_string()), + Some(branch_oid), + ); + + layer.apply(&mut topo); + topo.set_visual_memory(vec![ + "master".to_string(), + "feat-1".to_string(), + "feat".to_string(), + ]); + + let flattened = topo.flatten(); + assert_eq!(flattened.len(), 3); + assert_eq!(flattened[0], ("master".to_string(), 0)); + assert_eq!(flattened[1], ("feat-1".to_string(), 1)); + assert_eq!(flattened[2], ("feat".to_string(), 2)); +} + +#[test] +fn test_child_preservation_after_split_replacement() { + let mut topo = VirtualTopology::new(); + let root_oid = Oid::from_bytes(&[1; 20]).unwrap(); + let parent_oid = Oid::from_bytes(&[2; 20]).unwrap(); + let child_oid = Oid::from_bytes(&[3; 20]).unwrap(); + + topo.add_branch("master", root_oid); + topo.add_branch("parent", parent_oid); + topo.add_branch("child", child_oid); + + let _ = topo.set_parent( + "parent", + Some("master"), + None, + gitui::topology::Intent::Implicit, + ); + let _ = topo.set_parent( + "child", + Some("parent"), + None, + gitui::topology::Intent::Implicit, + ); + + let mut layer = VirtualLayer::new(); + // Splitting "parent" into "part1" and "parent" + layer.hide_branch("parent"); + layer.add_virtual_branch("part1".to_string(), Some("master".to_string()), None); + layer.add_virtual_branch( + "parent".to_string(), + Some("part1".to_string()), + Some(parent_oid), + ); + + layer.apply(&mut topo); + + let flattened = topo.flatten(); + // master + // part1 + // parent + // child <-- We want this! + + let child_pos = flattened.iter().position(|(n, _)| n == "child"); + assert!( + child_pos.is_some(), + "Child 'child' should be preserved in the flattened tree" + ); + let (name, depth) = &flattened[child_pos.unwrap()]; + assert_eq!( + *depth, 3, + "Child {} should be at depth 3 (master -> part1 -> parent -> child)", + name + ); +} + +#[test] +fn test_child_preservation_complex_split() { + let mut topo = VirtualTopology::new(); + let root_oid = Oid::from_bytes(&[1; 20]).unwrap(); + let parent_oid = Oid::from_bytes(&[2; 20]).unwrap(); + let child_oid = Oid::from_bytes(&[3; 20]).unwrap(); + + topo.add_branch("master", root_oid); + topo.add_branch("noise-ik", parent_oid); + topo.add_branch("nc-modular", child_oid); + + topo.set_parent( + "noise-ik", + Some("master"), + None, + gitui::topology::Intent::Implicit, + ) + .unwrap(); + topo.set_parent( + "nc-modular", + Some("noise-ik"), + None, + gitui::topology::Intent::Implicit, + ) + .unwrap(); + + let mut layer = VirtualLayer::new(); + layer.hide_branch("noise-ik"); + layer.add_virtual_branch("part1".to_string(), Some("master".to_string()), None); + layer.add_virtual_branch("part2".to_string(), Some("part1".to_string()), None); + layer.add_virtual_branch( + "noise-ik".to_string(), + Some("part2".to_string()), + Some(parent_oid), + ); + + layer.apply(&mut topo); + + let flattened = topo.flatten(); + // master (0) + // part1 (1) + // part2 (2) + // noise-ik (3) + // nc-modular (4) + + let nc_pos = flattened + .iter() + .position(|(n, _)| n == "nc-modular") + .expect("nc-modular missing"); + assert_eq!(flattened[nc_pos].1, 4, "nc-modular should be at depth 4"); + + // Verify parent of nc-modular is noise-ik + let parents = topo.get_parents("nc-modular"); + assert_eq!(parents.len(), 1); + assert_eq!(parents[0].name(), Some("noise-ik")); +} diff --git a/web/TokTok/Webhooks.hs b/web/TokTok/Webhooks.hs index 88bc2e2..7034007 100644 --- a/web/TokTok/Webhooks.hs +++ b/web/TokTok/Webhooks.hs @@ -48,9 +48,9 @@ showDiff a b = Text.pack . PP.render . toDoc $ diff where toDoc = Diff.prettyContextDiff (PP.text "payload") (PP.text "value") - (PP.text . Text.unpack) + (\(Diff.Numbered _ t) -> PP.text . Text.unpack $ t) diff = Diff.getContextDiff linesOfContext (Text.lines a) (Text.lines b) - linesOfContext = 3 + linesOfContext = Just 3 showError :: Error -> Text