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 sync_active_repo_reapplies_both_sidebar_knobs_from_the_selected_repo() {
use gwm::config::{SidebarOrientation, SidebarPosition};
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"),
"[tui]\nsidebar_orientation = \"side-by-side\"\nsidebar_position = \"left\"\n",
)
.unwrap();
let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
assert_eq!(app.sidebar.orientation, SidebarOrientation::Stacked);
assert_eq!(app.sidebar.position, SidebarPosition::Right);
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");
assert_eq!(
app.sidebar.orientation,
SidebarOrientation::SideBySide,
"orientation follows the newly-active repo's config"
);
assert_eq!(
app.sidebar.position,
SidebarPosition::Left,
"position follows the newly-active repo's config"
);
app.list_state.select(Some(0));
app.sync_active_repo();
assert_eq!(app.repo_name, "alpha", "swapped back to alpha");
assert_eq!(app.sidebar.orientation, SidebarOrientation::Stacked);
assert_eq!(app.sidebar.position, SidebarPosition::Right);
}
#[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 going_stale_closes_the_open_ci_checks_overlay() {
use gwm::tui::state::detail_overlay::{DetailKind, DetailRole, DetailRow};
let root = workspace_root(); let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
app.detail_overlay.open(
DetailKind::CiChecks,
"CI Checks".into(),
vec![DetailRow {
label: "✓".into(),
value: "stale-check".into(),
role: DetailRole::Success,
meta: None,
extra: None,
}],
);
app.view = gwm::tui::View::DetailOverlay;
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, "the dead selection is stale");
assert_eq!(
app.view,
gwm::tui::View::List,
"going stale closes the CI checks overlay"
);
}
#[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"
);
}
#[test]
fn workspace_pins_are_read_from_each_rows_own_repo() {
let root = workspace_root();
let alpha = Repository::open(root.path().join("alpha")).unwrap();
gwm::github::add_agent_pin(&alpha, "main", "sid-ws-alpha").unwrap();
let app = App::new_workspace_at_layered(root.path(), None).unwrap();
let pins = gwm::tui::read_pins_from_sources(&app.agent_pin_sources());
let alpha_row = app
.worktrees
.iter()
.find(|w| w.path.ends_with("alpha"))
.expect("alpha's main checkout is a row");
let key = alpha_row.path.to_string_lossy().to_string();
assert_eq!(
pins.get(&key).map(Vec::as_slice),
Some(&["sid-ws-alpha".to_string()][..]),
"the pin set in alpha's branch config reaches the per-path map"
);
}
#[test]
fn overlay_pin_markers_survive_an_active_repo_swap() {
use gwm::tui::state::async_task::TaskKind;
let root = workspace_root();
let alpha = Repository::open(root.path().join("alpha")).unwrap();
gwm::github::add_agent_pin(&alpha, "main", "sid-alpha-pin").unwrap();
let mut app = App::new_workspace_at_layered(root.path(), None).unwrap();
app.list_state.select(Some(0));
app.sync_active_repo();
app.open_agent_overlay();
let last = app.worktrees.len() - 1;
app.list_state.select(Some(last));
app.sync_active_repo();
assert_eq!(app.repo_name, "beta", "precondition: the active repo drifted");
let alpha_path = app.worktrees[0].path.clone();
let mut map = std::collections::BTreeMap::new();
map.insert(
alpha_path.to_string_lossy().to_string(),
gwm::agent_sessions::WorktreeAgents {
sessions: vec![gwm::agent_sessions::AgentSession {
kind: gwm::agent_sessions::AgentKind::ClaudeCode,
cwd: alpha_path,
last_activity: std::time::SystemTime::now(),
ended: false,
id: "sid-alpha-pin".into(),
name: None,
}],
},
);
let pins = gwm::tui::read_pins_from_sources(&app.agent_pin_sources());
let generation = app.tasks.request(TaskKind::AgentSessions).unwrap();
assert!(app.apply_agent_snapshot(generation, map, None, pins));
assert!(
app.detail_overlay.rows.iter().any(|r| r.value.contains("pinned")),
"the pin from alpha's own repo marks the row: {:?}",
app
.detail_overlay
.rows
.iter()
.map(|r| r.value.clone())
.collect::<Vec<_>>()
);
}