From 20c329b69281c8e83cb8b4e996b029a12e2f57bf Mon Sep 17 00:00:00 2001 From: c0de-intention <26400289+c0de-intention@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:48:36 +0900 Subject: [PATCH 1/4] test: add test for worktrees rendered wip test wip --- .gitignore | 1 + Cargo.lock | 35 +++++++++++- Cargo.toml | 3 + src/action.rs | 4 +- src/app.rs | 52 ++++++++++++++---- src/components/worktree_list.rs | 55 ++++++++++++++----- src/git_path.rs | 14 +++++ src/git_repo.rs | 18 ++++-- src/lib.rs | 8 +++ src/main.rs | 3 +- tests/common/helpers.rs | 42 ++++++++++++++ tests/common/mod.rs | 2 + tests/common/temp_directory.rs | 18 ++++++ .../worktree_list__renders_worktree_list.snap | 24 ++++++++ tests/worktree_list.rs | 22 ++++++++ 15 files changed, 266 insertions(+), 35 deletions(-) create mode 100644 src/lib.rs create mode 100644 tests/common/helpers.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/common/temp_directory.rs create mode 100644 tests/snapshots/worktree_list__renders_worktree_list.snap create mode 100644 tests/worktree_list.rs diff --git a/.gitignore b/.gitignore index a368894..7fb8e05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target .data/*.log /.env +tmp diff --git a/Cargo.lock b/Cargo.lock index 031bf44..46acbc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,7 +184,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fa9e1d11a268684cbd90ed36370d7577afb6c62d912ddff5c15fc34343e5036" dependencies = [ "backtrace", - "console", + "console 0.15.11", ] [[package]] @@ -464,6 +464,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "const-random" version = "0.1.18" @@ -1142,6 +1153,7 @@ dependencies = [ "futures", "git2", "human-panic", + "insta", "json5", "libc", "pretty_assertions", @@ -2047,6 +2059,21 @@ dependencies = [ "rustversion", ] +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console 0.16.4", + "once_cell", + "pest", + "pest_derive", + "serde", + "similar", + "tempfile", +] + [[package]] name = "instability" version = "0.3.12" @@ -3214,6 +3241,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index dcfd04b..cd7b515 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,3 +60,6 @@ codegen-units = 1 lto = true opt-level = "s" strip = true + +[dev-dependencies] +insta = { version = "1.48.0", features = ["redactions"] } diff --git a/src/action.rs b/src/action.rs index c2792cf..9dbb46a 100644 --- a/src/action.rs +++ b/src/action.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use serde::{Deserialize, Serialize}; use strum::Display; @@ -12,7 +14,7 @@ pub enum Action { ClearScreen, Error(String), Help, - WorktreeUpdate, + WorktreeUpdate(Option), WorktreeOpenEditor(String), WorktreeNext, WorktreePrevious, diff --git a/src/app.rs b/src/app.rs index 03dce9e..aaf61dc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,7 @@ use crossterm::event::KeyEvent; -use ratatui::prelude::Rect; +use ratatui::{Frame, prelude::Rect}; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use tokio::sync::mpsc; use tracing::{debug, info}; @@ -12,6 +13,7 @@ use crate::{ }; pub struct App { + path: Option, config: Config, tick_rate: f64, frame_rate: f64, @@ -31,12 +33,32 @@ pub enum Mode { } impl App { - pub fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result { + fn default() -> color_eyre::Result { let (action_tx, action_rx) = mpsc::unbounded_channel(); Ok(Self { + path: None, + tick_rate: 4.0, + frame_rate: 60.0, + components: vec![Box::new(WorktreeList::new(None)?)], + should_quit: false, + should_suspend: false, + config: Config::default(), + mode: Mode::Home, + last_tick_key_events: Vec::new(), + action_tx, + action_rx, + }) + } +} + +impl App { + pub fn new(tick_rate: f64, frame_rate: f64, path: Option) -> color_eyre::Result { + let (action_tx, action_rx) = mpsc::unbounded_channel(); + Ok(Self { + path: path.clone(), tick_rate, frame_rate, - components: vec![Box::new(WorktreeList::new())], + components: vec![Box::new(WorktreeList::new(path)?)], should_quit: false, should_suspend: false, config: Config::new()?, @@ -66,7 +88,7 @@ impl App { let action_tx = self.action_tx.clone(); - action_tx.send(Action::WorktreeUpdate)?; + action_tx.send(Action::WorktreeUpdate(self.path.clone()))?; loop { self.handle_events(&mut tui).await?; self.handle_actions(&mut tui)?; @@ -163,15 +185,21 @@ impl App { Ok(()) } - fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> { - tui.draw(|frame| { - for component in self.components.iter_mut() { - if let Err(err) = component.draw(frame, frame.area()) { - let _ = self - .action_tx - .send(Action::Error(format!("Failed to draw: {:?}", err))); - } + pub fn draw(&mut self, frame: &mut Frame, area: Rect) -> color_eyre::Result<()> { + for component in self.components.iter_mut() { + if let Err(err) = component.draw(frame, frame.area()) { + let _ = self + .action_tx + .send(Action::Error(format!("Failed to draw: {:?}", err))); } + } + Ok(()) + } + + pub fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> { + tui.draw(|frame| { + self.draw(frame, frame.area()) + .expect("failed to draw to terminal") })?; Ok(()) } diff --git a/src/components/worktree_list.rs b/src/components/worktree_list.rs index 207273b..a1d1069 100644 --- a/src/components/worktree_list.rs +++ b/src/components/worktree_list.rs @@ -11,11 +11,13 @@ use ratatui::layout::{Constraint, Rect}; use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Padding, Row, Table, TableState}; use std::io::stdout; +use std::path::PathBuf; use std::process::Command; use tokio::sync::mpsc::UnboundedSender; #[derive(Default)] pub struct WorktreeList { + repo: GitRepo, command_tx: Option>, config: Config, table_items: Vec, @@ -23,16 +25,46 @@ pub struct WorktreeList { } impl WorktreeList { - pub fn new() -> Self { - Self::default() + pub fn new(path: Option) -> color_eyre::Result { + let repo = GitRepo::new(path); + let mut table_state = TableState::default(); + let mut table_items = vec![]; + + let worktrees = repo.get_worktrees().context("Unable to get worktrees")?; + table_state.select(Some(0)); + for worktree in worktrees { + table_items.push(worktree); + } + Ok(Self { + repo, + table_items, + table_state, + ..Default::default() + }) } fn run_editor(&self, path: &str) -> color_eyre::Result<()> { stdout().execute(LeaveAlternateScreen)?; disable_raw_mode()?; + //TODO: should call EDITOR Command::new("zed").arg(path).status()?; Ok(()) } + + fn update_repos(&mut self, path: Option) -> color_eyre::Result<()> { + let worktrees = self + .repo + .get_worktrees() + .context("Unable to get worktrees")?; + if !&worktrees.is_empty() && self.table_state.selected().is_none() { + self.table_state.select(Some(0)) + } + for worktree in worktrees { + self.table_items.push(worktree); + } + + Ok(()) + } } impl Component for WorktreeList { @@ -50,8 +82,8 @@ impl Component for WorktreeList { match key.code { KeyCode::Enter if self.table_state.selected().is_some() => { if let Some(current_path_index) = self.table_state.selected() { - let path = &self.table_items[current_path_index]; - return Ok(Some(Action::WorktreeOpenEditor(path.path.to_string()))); + let workspace = &self.table_items[current_path_index]; + return Ok(Some(Action::WorktreeOpenEditor(workspace.path.to_string()))); } color_eyre::eyre::bail!("expected to have selected path"); } @@ -79,15 +111,8 @@ impl Component for WorktreeList { Action::Render => { // add any logic here that should run on every render } - Action::WorktreeUpdate => { - let repo = GitRepo::new(); - let worktrees = repo.get_worktrees().context("Unable to get worktrees")?; - if !&worktrees.is_empty() && self.table_state.selected().is_none() { - self.table_state.select(Some(0)) - } - for worktree in worktrees { - self.table_items.push(worktree); - } + Action::WorktreeUpdate(path) => { + self.update_repos(path)?; } Action::WorktreePrevious => { self.table_state.select_previous(); @@ -115,7 +140,7 @@ impl Component for WorktreeList { let rows: Vec = self .table_items .iter() - .map(|item| Row::new([item.branch.clone(), item.path.to_string()])) + .map(|item| Row::new([item.branch.clone(), item.path.get_short_path()])) .collect(); let widths = [Constraint::Percentage(20), Constraint::Percentage(80)]; @@ -135,7 +160,7 @@ impl Component for WorktreeList { .style(Color::White) .row_highlight_style(Style::new().on_black().bold()) .column_highlight_style(Color::Gray) - .highlight_symbol("🐸 "); + .highlight_symbol("> "); frame.render_stateful_widget(table, area, &mut self.table_state); Ok(()) diff --git a/src/git_path.rs b/src/git_path.rs index 769bee8..9d70d7f 100644 --- a/src/git_path.rs +++ b/src/git_path.rs @@ -7,6 +7,20 @@ impl GitPath { pub fn new(path_buf: PathBuf) -> Self { Self(path_buf) } + + pub fn get_short_path(&self) -> String { + let mut short_path = String::new(); + let mut parts = self.0.iter().rev(); + // let mut parts = self.0.ancestors(); + for _ in 0..3 { + if let Some(part) = parts.next() + && let Some(part) = part.to_str() + { + short_path = format!("{}/{}", part, short_path,); + } + } + short_path + } } impl std::fmt::Display for GitPath { diff --git a/src/git_repo.rs b/src/git_repo.rs index 309fa52..23e1b3b 100644 --- a/src/git_repo.rs +++ b/src/git_repo.rs @@ -1,16 +1,24 @@ +use std::path::PathBuf; + use git2::Repository; use crate::{git_path::GitPath, git_worktree::GitWorktree}; -#[derive(Debug)] -pub struct GitRepo {} +#[derive(Debug, Default)] +pub struct GitRepo { + path: Option, +} impl GitRepo { - pub fn new() -> Self { - Self {} + pub fn new(path: Option) -> Self { + Self { path } } fn get_repo(&self) -> Result { - Repository::open_from_env() + if let Some(path) = &self.path { + Repository::open(path) + } else { + Repository::open_from_env() + } } pub fn get_worktrees(&self) -> Result, git2::Error> { diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1d14cc9 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,8 @@ +pub mod action; +pub mod app; +pub mod components; +pub mod config; +pub mod git_path; +pub mod git_repo; +pub mod git_worktree; +pub mod tui; diff --git a/src/main.rs b/src/main.rs index 3e0c22c..3ad6ab0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,8 @@ async fn main() -> color_eyre::Result<()> { crate::logging::init()?; let args = Cli::parse(); - let mut app = App::new(args.tick_rate, args.frame_rate)?; + //TODO: add path to args list + let mut app = App::new(args.tick_rate, args.frame_rate, None)?; app.run().await?; Ok(()) } diff --git a/tests/common/helpers.rs b/tests/common/helpers.rs new file mode 100644 index 0000000..ae5da64 --- /dev/null +++ b/tests/common/helpers.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; + +use git2::{Repository, RepositoryInitOptions, WorktreeAddOptions}; + +pub struct Fixture { + pub path: PathBuf, + pub repo: Repository, +} + +impl Fixture { + pub fn new(path: &PathBuf) -> Self { + let mut opts = RepositoryInitOptions::new(); + opts.initial_head("main"); + let repo = Repository::init_opts(path, &opts).unwrap(); + + { + let mut config = repo.config().unwrap(); + config.set_str("user.name", "name").unwrap(); + config.set_str("user.email", "email").unwrap(); + let mut index = repo.index().unwrap(); + let id = index.write_tree().unwrap(); + + let tree = repo.find_tree(id).unwrap(); + let sig = repo.signature().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "initial\n\nbody", &tree, &[]) + .unwrap(); + } + Self { + repo, + path: path.to_path_buf(), + } + } + + pub fn create_worktree(&mut self, name: &str) { + let opts = WorktreeAddOptions::new(); + + let _ = self + .repo + .worktree(name, &self.path.join(name), Some(&opts)) + .unwrap(); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..6da0b6d --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,2 @@ +pub mod helpers; +pub mod temp_directory; diff --git a/tests/common/temp_directory.rs b/tests/common/temp_directory.rs new file mode 100644 index 0000000..435dabe --- /dev/null +++ b/tests/common/temp_directory.rs @@ -0,0 +1,18 @@ +use std::fs::{create_dir, remove_dir_all}; +use std::path::PathBuf; + +pub struct TempDirectory { + pub path: PathBuf, +} +impl TempDirectory { + pub fn new(path: PathBuf) -> color_eyre::Result { + create_dir("tmp/repo").unwrap(); + Ok(Self { path }) + } +} + +impl Drop for TempDirectory { + fn drop(&mut self) { + remove_dir_all(self.path.clone()).unwrap(); + } +} diff --git a/tests/snapshots/worktree_list__renders_worktree_list.snap b/tests/snapshots/worktree_list__renders_worktree_list.snap new file mode 100644 index 0000000..ece43d0 --- /dev/null +++ b/tests/snapshots/worktree_list__renders_worktree_list.snap @@ -0,0 +1,24 @@ +--- +source: tests/worktree_list.rs +expression: terminal.backend() +--- +"β”ŒWorktree selector─────────────────────────────────────────────────────────────────────────────────┐" +"β”‚ Branch Path β”‚" +"β”‚ β”‚" +"β”‚ > worktree2 tmp/repo/worktree2/ β”‚" +"β”‚ worktree1 tmp/repo/worktree1/ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β”‚ β”‚" +"β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" diff --git a/tests/worktree_list.rs b/tests/worktree_list.rs new file mode 100644 index 0000000..5ebc323 --- /dev/null +++ b/tests/worktree_list.rs @@ -0,0 +1,22 @@ +mod common; +use crate::common::helpers::Fixture; +use crate::common::temp_directory::TempDirectory; +use git_treefrog::app::App; +use insta::assert_snapshot; +use ratatui::{Terminal, backend::TestBackend}; +use std::path::Path; + +#[tokio::test] +async fn renders_worktree_list() -> color_eyre::Result<()> { + let temp = TempDirectory::new(Path::new("tmp/repo").to_path_buf())?; + let mut fixture = Fixture::new(&temp.path); + fixture.create_worktree("worktree1"); + fixture.create_worktree("worktree2"); + let mut app = App::new(4.0, 60.0, Some(temp.path.clone()))?; + let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); + terminal.draw(|frame| { + app.draw(frame, frame.area()).unwrap(); + }); + assert_snapshot!(terminal.backend()); + Ok(()) +} From a571dd55cb6addccd659a356ce87c5da00bccb29 Mon Sep 17 00:00:00 2001 From: c0de-intention <26400289+c0de-intention@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:42:11 +0900 Subject: [PATCH 2/4] add .gitkeep --- .gitignore | 3 ++- src/components/worktree_list.rs | 2 +- tmp/.gitkeep | 0 3 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 tmp/.gitkeep diff --git a/.gitignore b/.gitignore index 7fb8e05..9683cb4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target .data/*.log /.env -tmp +tmp/* +!.gitkeep diff --git a/src/components/worktree_list.rs b/src/components/worktree_list.rs index a1d1069..75b11b2 100644 --- a/src/components/worktree_list.rs +++ b/src/components/worktree_list.rs @@ -46,7 +46,7 @@ impl WorktreeList { fn run_editor(&self, path: &str) -> color_eyre::Result<()> { stdout().execute(LeaveAlternateScreen)?; disable_raw_mode()?; - //TODO: should call EDITOR + //TODO: should call configured editor or fallback to $EDITOR Command::new("zed").arg(path).status()?; Ok(()) } diff --git a/tmp/.gitkeep b/tmp/.gitkeep new file mode 100644 index 0000000..e69de29 From 67ec58bdf15e4b07590a1e0e2bade53bdb7b9b4a Mon Sep 17 00:00:00 2001 From: c0de-intention <26400289+c0de-intention@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:43:38 +0900 Subject: [PATCH 3/4] clippy lints --- src/app.rs | 38 ++++++++++++++++----------------- src/components/worktree_list.rs | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/app.rs b/src/app.rs index aaf61dc..f4b6f24 100644 --- a/src/app.rs +++ b/src/app.rs @@ -32,24 +32,24 @@ pub enum Mode { Home, } -impl App { - fn default() -> color_eyre::Result { - let (action_tx, action_rx) = mpsc::unbounded_channel(); - Ok(Self { - path: None, - tick_rate: 4.0, - frame_rate: 60.0, - components: vec![Box::new(WorktreeList::new(None)?)], - should_quit: false, - should_suspend: false, - config: Config::default(), - mode: Mode::Home, - last_tick_key_events: Vec::new(), - action_tx, - action_rx, - }) - } -} +// impl App { +// fn default() -> color_eyre::Result { +// let (action_tx, action_rx) = mpsc::unbounded_channel(); +// Ok(Self { +// path: None, +// tick_rate: 4.0, +// frame_rate: 60.0, +// components: vec![Box::new(WorktreeList::new(None)?)], +// should_quit: false, +// should_suspend: false, +// config: Config::default(), +// mode: Mode::Home, +// last_tick_key_events: Vec::new(), +// action_tx, +// action_rx, +// }) +// } +// } impl App { pub fn new(tick_rate: f64, frame_rate: f64, path: Option) -> color_eyre::Result { @@ -185,7 +185,7 @@ impl App { Ok(()) } - pub fn draw(&mut self, frame: &mut Frame, area: Rect) -> color_eyre::Result<()> { + pub fn draw(&mut self, frame: &mut Frame, _area: Rect) -> color_eyre::Result<()> { for component in self.components.iter_mut() { if let Err(err) = component.draw(frame, frame.area()) { let _ = self diff --git a/src/components/worktree_list.rs b/src/components/worktree_list.rs index 75b11b2..22ec546 100644 --- a/src/components/worktree_list.rs +++ b/src/components/worktree_list.rs @@ -51,7 +51,7 @@ impl WorktreeList { Ok(()) } - fn update_repos(&mut self, path: Option) -> color_eyre::Result<()> { + fn update_repos(&mut self, _path: Option) -> color_eyre::Result<()> { let worktrees = self .repo .get_worktrees() From 220e9b98c69444fce8036c8735891d57ae264b3c Mon Sep 17 00:00:00 2001 From: c0de-intention <26400289+c0de-intention@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:38:26 +0900 Subject: [PATCH 4/4] cleanup --- src/app.rs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/app.rs b/src/app.rs index f4b6f24..3176413 100644 --- a/src/app.rs +++ b/src/app.rs @@ -32,25 +32,6 @@ pub enum Mode { Home, } -// impl App { -// fn default() -> color_eyre::Result { -// let (action_tx, action_rx) = mpsc::unbounded_channel(); -// Ok(Self { -// path: None, -// tick_rate: 4.0, -// frame_rate: 60.0, -// components: vec![Box::new(WorktreeList::new(None)?)], -// should_quit: false, -// should_suspend: false, -// config: Config::default(), -// mode: Mode::Home, -// last_tick_key_events: Vec::new(), -// action_tx, -// action_rx, -// }) -// } -// } - impl App { pub fn new(tick_rate: f64, frame_rate: f64, path: Option) -> color_eyre::Result { let (action_tx, action_rx) = mpsc::unbounded_channel();