use super::git::{split_nul, Git};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteState {
UpToDate,
Ahead(usize),
Behind(usize),
Diverged {
ahead: usize,
behind: usize,
},
Unknown,
Unpublished,
}
impl RemoteState {
pub fn from_counts(ahead: usize, behind: usize) -> RemoteState {
match (ahead, behind) {
(0, 0) => RemoteState::UpToDate,
(ahead, 0) => RemoteState::Ahead(ahead),
(0, behind) => RemoteState::Behind(behind),
(ahead, behind) => RemoteState::Diverged { ahead, behind },
}
}
pub fn render(&self) -> String {
match self {
RemoteState::UpToDate => "up to date".to_string(),
RemoteState::Ahead(n) => format!("ahead {}", n),
RemoteState::Behind(n) => format!("behind {}", n),
RemoteState::Diverged { ahead, behind } => {
format!("diverged ahead {} behind {}", ahead, behind)
}
RemoteState::Unknown => "unknown".to_string(),
RemoteState::Unpublished => "unpublished".to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SideStatus {
pub changes: Vec<String>,
pub remote: RemoteState,
}
impl SideStatus {
pub fn is_clean(&self) -> bool {
self.changes.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LedgerStatus {
pub data: SideStatus,
pub docs: SideStatus,
pub fetch_note: Option<String>,
}
impl LedgerStatus {
pub fn is_clean(&self) -> bool {
self.data.is_clean() && self.docs.is_clean()
}
pub fn changes_lines(&self) -> Vec<String> {
let mut lines = Vec::new();
for (name, side) in [("data", &self.data), ("docs", &self.docs)] {
if side.is_clean() {
lines.push(format!("{}\tclean", name));
} else {
lines.push(format!("{}\tlocal changes", name));
for change in &side.changes {
lines.push(format!("{}\t{}", name, change));
}
}
}
lines
}
pub fn lines(&self) -> Vec<String> {
let mut lines = self.changes_lines();
lines.push(format!("data\tremote\t{}", self.data.remote.render()));
lines.push(format!("docs\tremote\t{}", self.docs.remote.render()));
lines
}
}
pub fn fetch_failed_note(remote: &str, error: &str) -> String {
let lines: Vec<&str> = error
.split(['\n', '\r'])
.map(str::trim)
.filter(|line| line.chars().any(char::is_alphanumeric))
.collect();
let cause = lines
.iter()
.find_map(|line| line.find("fatal:").map(|at| &line[at..]))
.or_else(|| lines.last().copied())
.unwrap_or("unknown error")
.trim_start_matches("error: ");
format!(
"note: could not fetch {} ({}); reporting from what is local",
remote, cause
)
}
pub struct WorktreeSide {
pub files: Vec<(char, String)>,
pub remote: RemoteState,
}
pub fn worktree_side(
wt: &Git,
fetched: &Result<Option<String>, String>,
) -> Result<WorktreeSide, String> {
let (base, remote) = match fetched {
Ok(Some(tip)) => {
let base = wt
.run_line(["merge-base", "HEAD", tip.as_str()])
.unwrap_or_else(|_| "HEAD".to_string());
let range = format!("HEAD...{}", tip);
let counts = wt.run_line(["rev-list", "--left-right", "--count", range.as_str()])?;
let (ahead, behind) = parse_counts(&counts)?;
(base, RemoteState::from_counts(ahead, behind))
}
Ok(None) => ("HEAD".to_string(), RemoteState::Unpublished),
Err(_) => ("HEAD".to_string(), RemoteState::Unknown),
};
let diff = wt.run(["diff", "--name-status", "--no-renames", "-z", base.as_str()])?;
let mut files = parse_name_status(&diff);
let untracked = wt.run(["ls-files", "--others", "--exclude-standard", "-z"])?;
for path in split_nul(&untracked) {
files.push(('A', path.to_string()));
}
files.sort_by(|a, b| a.1.cmp(&b.1));
files.dedup_by(|a, b| a.1 == b.1);
Ok(WorktreeSide { files, remote })
}
pub fn discard_worktree(wt: &Git, tip: Option<&str>) -> Result<(), String> {
wt.run(["reset", "--hard", "--quiet", tip.unwrap_or("HEAD")])?;
wt.run(["clean", "-fdq"])?;
Ok(())
}
pub fn parse_counts(text: &str) -> Result<(usize, usize), String> {
let mut parts = text.split_whitespace().map(str::parse::<usize>);
match (parts.next(), parts.next(), parts.next()) {
(Some(Ok(left)), Some(Ok(right)), None) => Ok((left, right)),
_ => Err(format!(
"error: git printed '{}' where two commit counts were expected",
text.trim()
)),
}
}
pub fn parse_name_status(text: &str) -> Vec<(char, String)> {
let fields = split_nul(text);
let mut files = Vec::new();
for pair in fields.chunks(2) {
if let [status, path] = pair {
let letter = match status.chars().next() {
Some('A') => 'A',
Some('D') => 'D',
_ => 'M',
};
files.push((letter, path.to_string()));
}
}
files
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_give_the_remote_state() {
assert_eq!(RemoteState::from_counts(0, 0), RemoteState::UpToDate);
assert_eq!(RemoteState::from_counts(2, 0), RemoteState::Ahead(2));
assert_eq!(RemoteState::from_counts(0, 3), RemoteState::Behind(3));
assert_eq!(
RemoteState::from_counts(1, 4),
RemoteState::Diverged {
ahead: 1,
behind: 4
}
);
}
#[test]
fn the_remote_state_renders_as_one_phrase() {
assert_eq!(RemoteState::UpToDate.render(), "up to date");
assert_eq!(RemoteState::Ahead(1).render(), "ahead 1");
assert_eq!(RemoteState::Behind(2).render(), "behind 2");
assert_eq!(
RemoteState::from_counts(1, 2).render(),
"diverged ahead 1 behind 2"
);
assert_eq!(RemoteState::Unknown.render(), "unknown");
assert_eq!(RemoteState::Unpublished.render(), "unpublished");
}
#[test]
fn rev_list_counts_are_parsed_and_junk_is_refused() {
assert_eq!(parse_counts("3\t0\n").unwrap(), (3, 0));
assert_eq!(parse_counts("0 12").unwrap(), (0, 12));
assert!(parse_counts("").is_err());
assert!(parse_counts("1\t2\t3").is_err());
assert!(parse_counts("a\tb").is_err());
}
#[test]
fn name_status_output_is_read_as_letters_and_paths() {
let text = "A\0new.md\0M\0dir/a b.md\0D\0gone.md\0T\0odd.md\0";
assert_eq!(
parse_name_status(text),
vec![
('A', "new.md".to_string()),
('M', "dir/a b.md".to_string()),
('D', "gone.md".to_string()),
('M', "odd.md".to_string()),
]
);
assert!(parse_name_status("").is_empty());
}
fn side(changes: &[&str], remote: RemoteState) -> SideStatus {
SideStatus {
changes: changes.iter().map(|c| c.to_string()).collect(),
remote,
}
}
#[test]
fn a_clean_ledger_prints_four_lines() {
let status = LedgerStatus {
data: side(&[], RemoteState::UpToDate),
docs: side(&[], RemoteState::UpToDate),
fetch_note: None,
};
assert!(status.is_clean());
assert_eq!(
status.lines(),
vec![
"data\tclean",
"docs\tclean",
"data\tremote\tup to date",
"docs\tremote\tup to date"
]
);
}
#[test]
fn local_changes_print_one_line_each_and_the_discard_summary_leaves_out_the_remote() {
let status = LedgerStatus {
data: side(
&["missions\tadded 1\tmodified 0\tdeleted 0"],
RemoteState::Ahead(1),
),
docs: side(&["A\tnotes.md", "D\told.md"], RemoteState::Behind(2)),
fetch_note: None,
};
assert!(!status.is_clean());
assert_eq!(
status.lines(),
vec![
"data\tlocal changes",
"data\tmissions\tadded 1\tmodified 0\tdeleted 0",
"docs\tlocal changes",
"docs\tA\tnotes.md",
"docs\tD\told.md",
"data\tremote\tahead 1",
"docs\tremote\tbehind 2",
]
);
assert_eq!(status.changes_lines().len(), 5);
}
#[test]
fn the_fetch_note_is_one_line() {
let note = fetch_failed_note(
"origin",
"error: git fetch failed (exit status 128):\nfatal: unable to access 'x'\n",
);
assert_eq!(
note,
"note: could not fetch origin (fatal: unable to access 'x'); reporting from what is local"
);
assert!(!note.contains('\n'));
let dolt = fetch_failed_note(
"origin",
"error: dolt fetch origin failed (exit status 1):\n- Fetching...fatal: '/o.git' does not appear to be a git repository\nfatal: Could not read\n\n \r",
);
assert_eq!(
dolt,
"note: could not fetch origin (fatal: '/o.git' does not appear to be a git repository); reporting from what is local"
);
assert_eq!(
fetch_failed_note("origin", "error: plain failure\n \n"),
"note: could not fetch origin (plain failure); reporting from what is local"
);
}
}