mod branches;
mod commits;
mod delete;
mod diff;
mod inspect;
mod keys;
mod repos;
mod stashes;
mod tags;
mod ui;
pub use branches::Branch;
use crate::git::RepoStatus;
use crate::git::load::{self, Batch, Commit, FileEntry, RemoteTags, Tag};
use crate::tui::difffeed::DiffFeed;
use crate::tui::highlight::RenderedDiff;
use crate::tui::input::{Accel, stepped};
use crate::tui::widgets::{CommandLine, Modal, NOTE, Query};
use crate::tui::{pane_width, pop_keyboard_enhancement, push_keyboard_enhancement};
use ratatui::DefaultTerminal;
use ratatui::crossterm::event::{self, Event, KeyEventKind};
use ratatui::text::Text;
use ratatui::widgets::ListState;
use std::collections::HashSet;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};
const COMMITS_PER_BRANCH: usize = 10_000;
use crate::tui::FRAME;
const SLOW: Duration = Duration::from_millis(120);
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum Level {
Repos,
Branches,
Tags,
Commits,
Diff,
}
impl Level {
fn back(self, start: Level) -> Option<Level> {
match self {
Level::Repos | Level::Tags => None,
Level::Branches => Some(Level::Repos),
Level::Commits if start == Level::Tags => Some(Level::Tags),
Level::Commits => Some(Level::Branches),
Level::Diff => Some(Level::Commits),
}
}
}
#[derive(Default)]
pub struct Sel {
pub visible: Vec<usize>,
pub cur: usize,
pub state: ListState,
pub scope: usize,
pub restore: Option<String>,
pub query: Query,
}
impl Sel {
fn scoped(scope: usize) -> Sel {
Sel {
scope,
..Sel::default()
}
}
fn show(&mut self, visible: Vec<usize>) {
self.visible = visible;
self.cur = 0;
self.state.select((!self.visible.is_empty()).then_some(0));
}
pub fn len(&self) -> usize {
self.visible.len()
}
pub fn is_empty(&self) -> bool {
self.visible.is_empty()
}
pub fn idx(&self) -> Option<usize> {
self.visible.get(self.cur).copied()
}
fn restored_at(&mut self, at: usize) {
self.cur = at;
self.state.select(Some(at));
self.restore = None;
}
fn append(&mut self, more: Vec<usize>) {
let was_empty = self.visible.is_empty();
self.visible.extend(more);
if was_empty && !self.visible.is_empty() {
self.cur = 0;
self.state.select(Some(0));
}
}
fn step(&mut self, down: bool, rows: usize) -> bool {
if self.visible.is_empty() {
return false;
}
let to = stepped(self.cur, self.visible.len(), down, rows);
if to == self.cur {
return false;
}
self.cur = to;
self.state.select(Some(to));
true
}
fn slide(&mut self, right: bool, len: usize) -> bool {
if right && self.scope + 1 < len {
self.scope += 1;
true
} else if !right && self.scope > 0 {
self.scope -= 1;
true
} else {
false
}
}
}
#[derive(Default)]
struct Feed {
latest: Arc<AtomicU64>,
shown: u64,
loading: bool,
since: Option<Instant>,
}
impl Feed {
fn issue(&mut self) -> u64 {
let seq = self.latest.fetch_add(1, Ordering::Relaxed) + 1;
self.shown = seq;
self.loading = true;
self.since = Some(Instant::now());
seq
}
fn slow(&self) -> bool {
self.loading && self.since.is_some_and(|t| t.elapsed() >= SLOW)
}
fn accepts(&self, seq: u64) -> bool {
seq == self.shown
}
}
#[derive(Clone, Copy, PartialEq)]
pub enum Pane {
Top,
Bottom,
}
impl Pane {
pub fn sigil(self) -> &'static str {
match self {
Pane::Top => "/",
Pane::Bottom => "ctrl-f",
}
}
}
pub struct App {
level: Level,
start: Level,
enhanced: bool,
width: u16,
note: Option<(bool, String)>,
note_at: Instant,
diff_rows: u16,
modal: Option<Modal>,
confirm: Option<delete::Confirm>,
sync_next: Option<bool>,
delete_next: Option<delete::Target>,
undo: Option<delete::Undo>,
command: Option<CommandLine>,
pending: bool,
editing: Option<Pane>,
accel: Accel,
tx: Sender<Batch>,
rx: Receiver<Batch>,
repos: Vec<RepoStatus>,
rsel: Sel,
repo: String,
base: PathBuf,
depth: usize,
branches: Vec<Branch>,
bsel: Sel,
bfeed: Feed,
tags: Vec<Tag>,
tsel: Sel,
remote: Option<RemoteTags>,
tag_remote: Option<String>,
tracked: bool,
rfeed: Feed,
range_tag: String,
since: tags::Since,
commits: Vec<Commit>,
csel: Sel,
cfeed: Feed,
unpushed: HashSet<String>,
unpushed_for: String,
log_args: Vec<String>,
limit: usize,
paths: Vec<String>,
stashes: bool,
files: Vec<FileEntry>,
fsel: Sel,
ffeed: Feed,
files_for: String,
dfeed: DiffFeed,
prepared: RenderedDiff,
diff: Text<'static>,
diff_scroll: u16,
diff_hscroll: u16,
reopen: Option<diff::Reopen>,
}
impl App {
fn new(start: Level, repo: String) -> App {
let (tx, rx) = mpsc::channel();
App {
level: start,
start,
enhanced: false,
width: 120,
note: None,
note_at: Instant::now(),
diff_rows: 1,
modal: None,
confirm: None,
sync_next: None,
delete_next: None,
undo: None,
command: None,
pending: false,
editing: None,
accel: Accel::default(),
tx,
rx,
repos: Vec::new(),
rsel: Sel::scoped(repos::DEFAULT_SCOPE),
repo,
base: PathBuf::from("."),
depth: 0,
branches: Vec::new(),
bsel: Sel::scoped(branches::DEFAULT_SCOPE),
bfeed: Feed::default(),
tags: Vec::new(),
tsel: Sel::scoped(tags::DEFAULT_SCOPE),
remote: None,
tag_remote: None,
tracked: false,
rfeed: Feed::default(),
range_tag: String::new(),
since: tags::Since::Asking,
commits: Vec::new(),
csel: Sel::scoped(commits::DEFAULT_SCOPE),
cfeed: Feed::default(),
unpushed: HashSet::new(),
unpushed_for: String::new(),
log_args: Vec::new(),
limit: COMMITS_PER_BRANCH,
paths: Vec::new(),
stashes: false,
files: Vec::new(),
fsel: Sel::default(),
ffeed: Feed::default(),
files_for: String::new(),
dfeed: DiffFeed::default(),
prepared: RenderedDiff::default(),
diff: Text::default(),
diff_scroll: 0,
diff_hscroll: 0,
reopen: None,
}
}
pub fn at_repos(base: &Path, depth: usize, scope: usize) -> Option<App> {
let mut app = App::new(Level::Repos, ".".to_string());
app.rsel.scope = scope;
app.base = base.to_path_buf();
app.depth = depth;
app.load_repos(base, depth);
if app.repos.is_empty() {
return None;
}
app.rescope_repos();
app.enter_repo();
Some(app)
}
pub fn at_branches(repo: String, scope: usize) -> Option<App> {
let mut app = App::new(Level::Branches, repo);
app.bsel.scope = scope;
app.load_branches();
if app.branches.is_empty() {
return None;
}
app.rescope_branches();
app.enter_branch();
Some(app)
}
pub fn at_tags(repo: String) -> Option<App> {
let mut app = App::new(Level::Tags, repo);
app.load_tags();
if app.tags.is_empty() {
return None;
}
app.tag_remote = load::tag_remote(&app.repo);
app.rescope_tags();
app.request_remote_tags(true);
app.enter_tag();
Some(app)
}
pub fn at_commits(
repo: String,
log_args: Vec<String>,
limit: usize,
paths: Vec<String>,
) -> Option<App> {
let mut app = App::new(Level::Commits, repo);
app.log_args = log_args;
app.limit = limit;
app.paths = paths;
app.ensure_unpushed();
if !app.has_any_commit() {
return None;
}
app.request_commits();
Some(app)
}
pub fn at_stashes(repo: String) -> Option<App> {
let mut app = App::new(Level::Commits, repo);
app.stashes = true;
app.log_args = stashes::stash_log();
if !app.has_any_commit() {
return None;
}
app.request_commits();
Some(app)
}
pub fn run(mut self, cmd: &str) {
let mut terminal = ratatui::init();
self.enhanced = push_keyboard_enhancement();
self.width = pane_width(&terminal);
let result = self.event_loop(&mut terminal);
if self.enhanced {
pop_keyboard_enhancement();
}
ratatui::restore();
if let Err(e) = result {
eprintln!("slu {cmd}: {e}");
}
}
fn event_loop(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
loop {
self.drain();
if self.pending && !event::poll(Duration::ZERO)? {
self.settle();
}
terminal.draw(|frame| ui::draw(frame, self))?;
if let Some(pull) = self.sync_next.take() {
if self.level == Level::Repos {
self.sync_all(pull);
} else {
self.sync(pull);
}
continue;
}
if let Some(target) = self.delete_next.take() {
self.delete_on_remote(target);
continue;
}
let wait = match (self.filling(), self.note_left()) {
(true, Some(left)) => Some(left.min(FRAME)),
(true, None) => Some(FRAME),
(false, left) => left,
};
if let Some(wait) = wait
&& !event::poll(wait)?
{
if self.note_left() == Some(Duration::ZERO) {
self.note = None;
}
continue;
}
match event::read()? {
Event::Resize(_, _) => {
let w = pane_width(terminal);
if w != self.width {
self.width = w;
if self.level == Level::Diff {
self.relayout_diff();
}
}
}
Event::Key(key)
if key.kind == KeyEventKind::Press && keys::on_key(self, key, terminal) =>
{
break;
}
_ => {}
}
}
Ok(())
}
fn set_status(&mut self, text: impl Into<String>) {
self.note = Some((true, text.into()));
self.note_at = Instant::now();
}
fn set_failed(&mut self, text: impl Into<String>) {
self.note = Some((false, text.into()));
self.note_at = Instant::now();
}
fn note_left(&self) -> Option<Duration> {
self.note
.as_ref()
.map(|_| NOTE.saturating_sub(self.note_at.elapsed()))
}
fn filling(&self) -> bool {
self.bfeed.loading
|| self.rfeed.loading
|| self.cfeed.loading
|| self.ffeed.loading
|| self.dfeed.loading()
}
fn drain(&mut self) {
while let Ok(batch) = self.rx.try_recv() {
match batch {
Batch::Branches { seq, rows, done } => {
if !self.bfeed.accepts(seq) {
continue;
}
let from = self.branches.len();
self.branches.extend(rows);
self.extend_branches(from);
if done {
self.bfeed.loading = false;
}
}
Batch::Commits { seq, rows, done } => {
if !self.cfeed.accepts(seq) {
continue;
}
let from = self.commits.len();
self.commits.extend(rows);
if self.stashes {
self.name_stashes(from);
}
self.extend_commits(from);
if done {
self.cfeed.loading = false;
}
}
Batch::Files { seq, rows, done } => {
if !self.ffeed.accepts(seq) {
continue;
}
let from = self.files.len();
self.files.extend(rows);
self.extend_files(from);
if done {
self.ffeed.loading = false;
}
}
Batch::TagBase { seq, prev } => {
if !self.cfeed.accepts(seq) {
continue;
}
self.since = prev.map_or(tags::Since::Start, tags::Since::Tag);
}
Batch::RemoteTags { seq, answer } => {
if !self.rfeed.accepts(seq) {
continue;
}
self.rfeed.loading = false;
self.apply_remote(answer);
}
}
}
if let Some(prepared) = self.dfeed.take() {
self.prepared = prepared;
self.clamp_pan();
self.relayout_diff();
self.clamp_diff();
}
if !self.pending {
self.sync_files();
}
self.settle_reopen();
}
fn settle(&mut self) {
self.pending = false;
match self.level {
Level::Repos => self.enter_repo(),
Level::Branches => self.enter_branch(),
Level::Tags => self.enter_tag(),
Level::Commits => self.enter_commit(),
Level::Diff => {}
}
}
fn pane_sel(&mut self, pane: Pane) -> Option<&mut Sel> {
Some(match (self.level, pane) {
(Level::Repos, Pane::Top) => &mut self.rsel,
(Level::Repos, Pane::Bottom) | (Level::Branches, Pane::Top) => &mut self.bsel,
(Level::Tags, Pane::Top) => &mut self.tsel,
(Level::Branches | Level::Tags, Pane::Bottom) | (Level::Commits, Pane::Top) => {
&mut self.csel
}
(Level::Commits, Pane::Bottom) => &mut self.fsel,
(Level::Diff, _) => return None,
})
}
fn open_query(&mut self, pane: Pane) {
let Some(sel) = self.pane_sel(pane) else {
return;
};
let dropped = sel.query.open();
self.editing = Some(pane);
if dropped {
self.refilter(pane);
}
}
fn refilter(&mut self, pane: Pane) {
match (self.level, pane) {
(Level::Repos, Pane::Top) => self.rescope_repos(),
(Level::Repos, Pane::Bottom) | (Level::Branches, Pane::Top) => self.rescope_branches(),
(Level::Tags, Pane::Top) => self.rescope_tags(),
(Level::Branches | Level::Tags, Pane::Bottom) | (Level::Commits, Pane::Top) => {
self.rescope_commits()
}
(Level::Commits, Pane::Bottom) => self.rescope_files(),
(Level::Diff, _) => {}
}
if pane == Pane::Top {
self.pending = true;
}
}
fn refresh(&mut self) {
self.unpushed_for.clear();
self.files_for.clear();
match self.level {
Level::Repos => {
let keep = self.rsel.idx().map(|i| self.repos[i].path.clone());
let (base, depth) = (self.base.clone(), self.depth);
self.load_repos(&base, depth);
self.rsel.restore = keep;
self.rescope_repos();
self.pending = true;
}
Level::Branches => {
self.bsel.restore = self.bsel.idx().map(|i| self.branches[i].name.clone());
self.request_branches();
self.pending = true;
}
Level::Tags => {
self.tsel.restore = self.tag_name().map(str::to_string);
self.load_tags();
self.rescope_tags();
self.request_remote_tags(true);
self.pending = true;
}
Level::Commits => {
self.ensure_unpushed();
self.csel.restore = self.commit_hash().map(str::to_string);
self.request_commits();
self.pending = true;
}
Level::Diff => {
self.ensure_unpushed();
self.reopen = self.reopen_point();
self.csel.restore = self.commit_hash().map(str::to_string);
self.request_commits();
}
}
}
fn back(&mut self) -> bool {
self.reopen = None;
match self.level.back(self.start) {
Some(prev) if self.level > self.start => {
self.level = prev;
false
}
_ => true,
}
}
}
pub fn branch_scope(gone: bool, remotes: bool) -> usize {
let wanted = if gone {
branches::Scope::Gone
} else if remotes {
branches::Scope::Remote
} else {
branches::Scope::Local
};
branches::SCOPES
.iter()
.position(|&s| s == wanted)
.unwrap_or(branches::DEFAULT_SCOPE)
}
pub fn repo_scope(dirty: bool) -> usize {
let wanted = if dirty {
repos::Scope::Dirty
} else {
repos::Scope::All
};
repos::SCOPES
.iter()
.position(|&s| s == wanted)
.unwrap_or(repos::DEFAULT_SCOPE)
}
#[cfg(test)]
mod tests {
use super::branches::{Branch, Scope as BranchScope};
use super::repos::Scope as RepoScope;
use super::{App, Level, Sel};
use crate::git::{RepoStatus, git_capture};
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
struct Stage {
dir: PathBuf,
}
impl Stage {
fn new() -> Stage {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!("sluuz-refresh-{stamp}"));
fs::create_dir_all(dir.join("work")).expect("temp dir");
let stage = Stage { dir };
let (work, remote) = (stage.work(), stage.remote());
git_capture(".", &["init", "-q", "--bare", &remote]).expect("bare remote");
git_capture(".", &["init", "-q", &work]).expect("work tree");
stage.git(&["remote", "add", "origin", &remote]);
stage.commit("base");
stage.git(&["push", "-q", "-u", "origin", "HEAD"]);
stage
}
fn work(&self) -> String {
self.dir
.join("work")
.to_str()
.expect("utf-8 temp path")
.to_string()
}
fn remote(&self) -> String {
self.dir
.join("remote.git")
.to_str()
.expect("utf-8 temp path")
.to_string()
}
fn git(&self, args: &[&str]) {
git_capture(&self.work(), args).unwrap_or_else(|| panic!("git {args:?}"));
}
fn commit(&self, msg: &str) {
self.git(&[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
msg,
]);
}
fn head(&self) -> String {
git_capture(&self.work(), &["rev-parse", "HEAD"]).expect("rev-parse")
}
}
impl Drop for Stage {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
#[test]
fn a_refreshed_log_sees_a_commit_made_after_it_opened_as_unpushed() {
let stage = Stage::new();
let app = App::at_commits(stage.work(), Vec::new(), 200, Vec::new());
let mut app = app.expect("the repo has a commit, so the view opens");
assert!(
app.unpushed.is_empty(),
"everything was pushed when the view opened"
);
stage.commit("made while the view was open");
let fresh = stage.head();
app.refresh();
assert!(
app.unpushed.contains(&fresh),
"a refresh has to read the push state again, not trust the cached one"
);
}
fn drain_until(app: &mut App, done: impl Fn(&App) -> bool) {
let start = Instant::now();
while !done(app) {
assert!(
start.elapsed() < Duration::from_secs(10),
"the background loads never settled"
);
app.drain();
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn refreshing_a_diff_shows_the_commit_as_it_is_now() {
let stage = Stage::new();
let file = PathBuf::from(stage.work()).join("a.txt");
fs::write(&file, "before\n").expect("write a.txt");
stage.git(&["add", "a.txt"]);
stage.commit("add a");
let app = App::at_commits(stage.work(), Vec::new(), 200, Vec::new());
let mut app = app.expect("the repo has commits, so the view opens");
drain_until(&mut app, |a| !a.cfeed.loading && !a.ffeed.loading);
app.open_diff();
drain_until(&mut app, |a| !a.dfeed.loading());
fs::write(&file, "after\n").expect("rewrite a.txt");
stage.git(&["add", "a.txt"]);
stage.git(&[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--amend",
"-m",
"add a",
]);
let amended = stage.head();
app.refresh();
drain_until(&mut app, |a| a.reopen.is_none() && !a.dfeed.loading());
assert!(
app.level == Level::Diff,
"the diff is open again, not closed"
);
assert_eq!(
app.commit_hash(),
Some(amended.as_str()),
"the diff is of the amended commit, not the one it was opened on"
);
let shows = |word: &str| {
app.diff
.lines
.iter()
.flat_map(|l| l.spans.iter())
.any(|s| s.content.contains(word))
};
assert!(shows("after"), "the amended change is on screen");
}
fn repo(dirty: usize, ahead: usize) -> RepoStatus {
RepoStatus {
name: "r".into(),
path: "/r".into(),
branch: "main".into(),
has_upstream: true,
dirty,
ahead,
behind: 0,
origin: "github.com:o/r".into(),
}
}
fn branch(remote: bool, upstream: bool, track: &str) -> Branch {
Branch {
is_head: false,
remote,
name: "b".into(),
refname: "refs/heads/b".into(),
rel: String::new(),
author: String::new(),
has_upstream: upstream,
upstream: if upstream {
"origin/b".into()
} else {
String::new()
},
track: track.into(),
}
}
#[test]
fn esc_steps_back_one_level_at_a_time() {
let start = Level::Repos;
assert_eq!(Level::Diff.back(start), Some(Level::Commits));
assert_eq!(Level::Commits.back(start), Some(Level::Branches));
assert_eq!(Level::Branches.back(start), Some(Level::Repos));
assert_eq!(Level::Repos.back(start), None);
}
#[test]
fn the_variant_order_is_the_drill_order() {
assert!(Level::Repos < Level::Branches);
assert!(Level::Branches < Level::Commits);
assert!(Level::Commits < Level::Diff);
}
#[test]
fn the_cursor_stops_at_both_ends() {
let mut sel = Sel::default();
sel.show(vec![0, 1]);
assert!(!sel.step(false, 1), "already at the top");
assert!(sel.step(true, 1));
assert!(!sel.step(true, 1), "already at the bottom");
assert_eq!(sel.cur, 1);
}
#[test]
fn an_empty_list_selects_nothing() {
let mut sel = Sel::default();
sel.show(Vec::new());
assert!(sel.is_empty());
assert_eq!(sel.idx(), None);
assert!(!sel.step(true, 1));
}
#[test]
fn the_cursor_indexes_the_underlying_list_not_the_filtered_one() {
let mut sel = Sel::default();
sel.show(vec![3, 7]); sel.step(true, 1);
assert_eq!(sel.idx(), Some(7));
}
#[test]
fn a_held_key_stops_at_both_ends() {
let mut sel = Sel::default();
sel.show((0..10).collect());
assert!(sel.step(true, 4));
assert_eq!(sel.cur, 4);
assert!(sel.step(true, 99), "a step past the bottom still moves");
assert_eq!(sel.cur, 9);
assert!(!sel.step(true, 4), "already on the last row");
assert!(sel.step(false, 99));
assert_eq!(sel.cur, 0);
assert!(!sel.step(false, 1), "already on the first row");
}
#[test]
fn an_empty_list_cannot_be_stepped() {
let mut sel = Sel::default();
sel.show(Vec::new());
assert!(!sel.step(true, 5));
assert_eq!(sel.idx(), None);
}
#[test]
fn rows_arriving_in_the_background_leave_the_cursor_alone() {
let mut sel = Sel::default();
sel.show(vec![0, 1, 2]);
sel.step(true, 1);
sel.step(true, 1);
assert_eq!(sel.idx(), Some(2));
sel.append(vec![3, 4, 5]);
assert_eq!(sel.idx(), Some(2), "the cursor stayed where it was put");
assert_eq!(sel.len(), 6);
}
#[test]
fn the_first_rows_to_arrive_are_what_gets_selected() {
let mut sel = Sel::default();
sel.show(Vec::new());
assert_eq!(sel.idx(), None);
sel.append(vec![7, 8]);
assert_eq!(sel.idx(), Some(7));
assert_eq!(sel.state.selected(), Some(0));
}
#[test]
fn the_scope_slider_stops_at_its_ends() {
let mut sel = Sel::scoped(1);
assert!(sel.slide(true, 3));
assert!(!sel.slide(true, 3), "already on the last stop");
assert_eq!(sel.scope, 2);
assert!(sel.slide(false, 3));
}
#[test]
fn repo_scopes_split_dirty_from_unpushed() {
assert!(RepoScope::Dirty.keeps(&repo(2, 0)));
assert!(!RepoScope::Dirty.keeps(&repo(0, 2)));
assert!(RepoScope::Unpushed.keeps(&repo(0, 2)));
assert!(!RepoScope::Unpushed.keeps(&repo(2, 0)));
assert!(RepoScope::All.keeps(&repo(0, 0)));
}
#[test]
fn branch_scopes_split_local_from_remote() {
assert!(BranchScope::Local.keeps(&branch(false, true, "")));
assert!(!BranchScope::Local.keeps(&branch(true, false, "")));
assert!(BranchScope::Remote.keeps(&branch(true, false, "")));
}
#[test]
fn a_branch_counts_as_unpushed_when_no_remote_has_it() {
assert!(branch(false, false, "").unpushed(), "never pushed");
assert!(branch(false, true, "[gone]").unpushed(), "upstream deleted");
assert!(
branch(false, true, "[ahead 2]").unpushed(),
"ahead of upstream"
);
assert!(!branch(false, true, "").unpushed(), "in sync");
assert!(
!branch(true, false, "").unpushed(),
"a remote branch is on a remote"
);
}
#[test]
fn push_state_reads_the_track_field() {
assert_eq!(branch(false, false, "").status(), "no remote");
assert_eq!(branch(false, true, "[gone]").status(), "gone");
assert_eq!(branch(false, true, "[ahead 2]").status(), "↑2");
assert_eq!(branch(false, true, "[behind 3]").status(), "↓3");
assert_eq!(branch(false, true, "[ahead 2, behind 1]").status(), "↑2 ↓1");
assert_eq!(branch(false, true, "").status(), "synced");
}
}