use git2::{Repository, Signature};
use gwm::tui::keymap::Action;
use gwm::tui::{draw, App, SettingField, SettingsLayer};
use ratatui::{backend::TestBackend, Terminal};
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
}
fn init_repo_at(path: &Path) {
fs::create_dir_all(path).unwrap();
let repo = Repository::init(path).unwrap();
repo.set_head("refs/heads/main").ok();
let sig = Signature::now("gwm-test", "gwm@test").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
}
fn workspace_root() -> TempDir {
let root = TempDir::new().unwrap();
init_repo_at(&root.path().join("alpha"));
init_repo_at(&root.path().join("beta"));
fs::create_dir_all(root.path().join("notes")).unwrap();
root
}
#[test]
fn workspace_app_builds_a_merged_list_across_repos() {
let root = workspace_root();
let app = App::new_workspace_at_layered(root.path(), None).unwrap();
assert!(app.is_workspace(), "the app is in workspace mode");
assert!(
app.worktrees.len() >= 2,
"merged list spans both repos: {}",
app.worktrees.len()
);
assert_eq!(app.row_repo_name(0), Some("alpha"), "row 0 belongs to alpha");
let last = app.worktrees.len() - 1;
assert_eq!(app.row_repo_name(last), Some("beta"), "the last row belongs to beta");
}
#[test]
fn workspace_app_starts_active_on_the_first_repo() {
let root = workspace_root();
let app = App::new_workspace_at_layered(root.path(), None).unwrap();
assert_eq!(app.repo_name, "alpha", "active repo starts on the first row's repo");
assert!(
app.workdir.ends_with("alpha"),
"active workdir points at alpha, got {:?}",
app.workdir
);
}
#[test]
fn sync_active_repo_follows_the_selection_across_repos() {
let root = workspace_root();
let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
let last = app.worktrees.len() - 1;
app.list_state.select(Some(last));
app.sync_active_repo();
assert_eq!(app.repo_name, "beta", "active repo follows the selection to beta");
assert!(
app.workdir.ends_with("beta"),
"active workdir swapped to beta, got {:?}",
app.workdir
);
app.list_state.select(Some(0));
app.sync_active_repo();
assert_eq!(app.repo_name, "alpha", "active repo swaps back to alpha");
}
#[test]
fn sync_active_repo_reresolves_branch_types_from_the_selected_repo() {
let root = TempDir::new().unwrap();
init_repo_at(&root.path().join("alpha"));
init_repo_at(&root.path().join("beta"));
fs::write(
root.path().join("beta").join(".gwm.toml"),
"[[branch_types]]\nname = \"wibble\"\ndescription = \"custom\"\n",
)
.unwrap();
let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
assert!(
app.branch_types.iter().any(|t| t.name == "feat"),
"alpha uses default branch types"
);
let last = app.worktrees.len() - 1; app.list_state.select(Some(last));
app.sync_active_repo();
assert_eq!(app.repo_name, "beta", "swapped to beta");
let names: Vec<&str> = app.branch_types.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
vec!["wibble"],
"branch types now follow beta's config, got {names:?}"
);
}
#[test]
fn failed_repo_activation_marks_the_selection_stale_then_recovers() {
let root = workspace_root(); let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
assert!(!app.workspace_active_stale, "fresh workspace is not stale");
fs::remove_dir_all(root.path().join("beta")).unwrap();
let last = app.worktrees.len() - 1;
app.list_state.select(Some(last));
app.sync_active_repo();
assert!(app.workspace_active_stale, "an unreachable selected repo is stale");
assert_eq!(
app.repo_name, "alpha",
"the active repo stays on the last live one, not the dead beta"
);
app.list_state.select(Some(0));
app.sync_active_repo();
assert!(
!app.workspace_active_stale,
"selecting a live repo clears the stale flag"
);
}
#[test]
fn no_visible_selection_marks_workspace_stale() {
let root = workspace_root();
let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
assert!(!app.workspace_active_stale, "a fresh selection is not stale");
app.list_state.select(None);
app.sync_active_repo();
assert!(
app.workspace_active_stale,
"no selected row → stale (blocks create/etc.)"
);
app.list_state.select(Some(0));
app.sync_active_repo();
assert!(!app.workspace_active_stale, "a valid selection clears the stale flag");
}
#[test]
fn stale_selection_blocks_project_config_edits() {
let root = workspace_root();
let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
let before = app.config.tui.auto_refresh_secs;
fs::remove_dir_all(root.path().join("beta")).unwrap();
let last = app.worktrees.len() - 1;
app.list_state.select(Some(last));
app.sync_active_repo();
assert!(app.workspace_active_stale, "precondition: selection is stale");
app.apply_setting(SettingField::AutoRefreshSecs, "42");
assert_eq!(
app.config.tui.auto_refresh_secs, before,
"a Project-layer edit is refused while the selected repo is unavailable"
);
assert!(
app.status.contains("unavailable"),
"the refusal is surfaced on the status bar, got: {}",
app.status
);
}
#[test]
fn repo_mutating_actions_are_classified() {
assert!(Action::Create.is_repo_mutating());
assert!(Action::DeleteConfirm.is_repo_mutating());
assert!(Action::Bootstrap.is_repo_mutating());
assert!(Action::EditWorktree.is_repo_mutating());
assert!(Action::LinkPrompt.is_repo_mutating());
assert!(!Action::Down.is_repo_mutating());
assert!(!Action::Refresh.is_repo_mutating());
assert!(!Action::YankPath.is_repo_mutating());
}
#[test]
fn sync_active_repo_is_a_noop_in_single_repo_mode() {
let (dir, _repo) = {
let dir = TempDir::new().unwrap();
init_repo_at(dir.path());
(dir, ())
};
let mut app = App::new_at_layered(Some(dir.path()), None).unwrap();
assert!(!app.is_workspace(), "single-repo app is not in workspace mode");
let before = app.workdir.clone();
app.sync_active_repo();
assert_eq!(app.workdir, before, "sync is inert without a workspace");
}
#[test]
fn workspace_list_renders_a_repo_column_with_repo_names() {
let root = workspace_root();
let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
let backend = TestBackend::new(140, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draw(f, &mut app)).unwrap();
let text = buffer_text(&terminal);
assert!(
text.contains("REPO"),
"the list header carries a REPO column, got:\n{text}"
);
assert!(
text.contains("alpha"),
"the alpha repo name renders in a row, got:\n{text}"
);
assert!(
text.contains("beta"),
"the beta repo name renders in a row, got:\n{text}"
);
}
#[test]
fn workspace_settings_edit_survives_a_repo_swap_roundtrip() {
let root = workspace_root();
let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
assert_eq!(app.repo_name, "alpha");
app.apply_setting(SettingField::AutoRefreshSecs, "99");
assert_eq!(app.config.tui.auto_refresh_secs, 99, "edit applies live");
let last = app.worktrees.len() - 1;
app.list_state.select(Some(last));
app.sync_active_repo();
assert_eq!(app.repo_name, "beta");
app.list_state.select(Some(0));
app.sync_active_repo();
assert_eq!(app.repo_name, "alpha");
assert_eq!(
app.config.tui.auto_refresh_secs, 99,
"the settings edit survived the repo-swap round-trip"
);
}
#[test]
fn workspace_global_setting_edit_propagates_to_every_repo() {
let root = workspace_root();
let global = root.path().join("global-config.toml");
fs::write(&global, "").unwrap();
let mut app = App::new_workspace_at_layered(root.path(), Some(&global)).unwrap();
app.config_panel.layer = SettingsLayer::Global;
app.apply_setting(SettingField::AutoRefreshSecs, "77");
assert_eq!(
app.config.tui.auto_refresh_secs, 77,
"global edit applies to the active repo"
);
let last = app.worktrees.len() - 1;
app.list_state.select(Some(last));
app.sync_active_repo();
assert_eq!(app.repo_name, "beta");
assert_eq!(
app.config.tui.auto_refresh_secs, 77,
"the global edit reached the other repo's cached config"
);
}
#[test]
fn workspace_refresh_rebuilds_the_full_merged_list() {
let root = workspace_root();
let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
let before = app.worktrees.len();
app.refresh().unwrap();
assert_eq!(
app.worktrees.len(),
before,
"merged list keeps spanning every repo after refresh"
);
let last = app.worktrees.len() - 1;
assert_eq!(
app.row_repo_name(last),
Some("beta"),
"row→repo map survives the refresh"
);
}
#[test]
fn drain_applies_an_async_workspace_relist() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
let root = workspace_root();
let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
let last = app.worktrees.len() - 1;
assert_eq!(app.row_repo_name(0), Some("alpha"));
assert_eq!(app.row_repo_name(last), Some("beta"));
let generation = app.tasks.request(TaskKind::RefreshWorkspace).unwrap();
let rows: Vec<_> = app.worktrees.iter().cloned().map(|w| (w, 0usize)).collect();
app
.task_result_sender()
.send(TaskMsg::RefreshWorkspace(generation, rows))
.unwrap();
assert!(app.drain_task_results(), "drain applies the workspace re-list");
assert_eq!(app.row_repo_name(0), Some("alpha"));
assert_eq!(
app.row_repo_name(last),
Some("alpha"),
"the drain rebuilt the row→repo map from the worker's payload"
);
assert!(
!app.tasks.is_loading(TaskKind::RefreshWorkspace),
"the slot is cleared once the result applies"
);
}
#[test]
fn request_refresh_in_workspace_mode_coalesces_onto_an_inflight_relist() {
use gwm::tui::state::async_task::TaskKind;
let root = workspace_root();
let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
let _generation = app.tasks.request(TaskKind::RefreshWorkspace).unwrap();
app.request_refresh();
assert!(
app.tasks.is_loading(TaskKind::RefreshWorkspace),
"the in-flight workspace re-list is still the one and only run"
);
}
#[test]
fn refresh_invalidates_an_inflight_async_workspace_relist() {
use gwm::tui::state::async_task::{TaskKind, TaskMsg};
let root = workspace_root();
let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
let stale = app.tasks.request(TaskKind::RefreshWorkspace).unwrap();
app.refresh().unwrap();
let rows: Vec<_> = app.worktrees.iter().cloned().map(|w| (w, 0usize)).collect();
app
.task_result_sender()
.send(TaskMsg::RefreshWorkspace(stale, rows))
.unwrap();
app.drain_task_results();
let last = app.worktrees.len() - 1;
assert_eq!(
app.row_repo_name(last),
Some("beta"),
"the superseded workspace payload was dropped — the fresh map stands"
);
}