use std::path::{Path, PathBuf};
use crate::git::{GitError, Oid, github_repo_from_url, remotes, run};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Clone {
pub top: PathBuf,
pub remote: String,
pub branch: Option<String>,
}
pub async fn clone_of(dir: &Path, owner: &str, name: &str) -> Result<Option<Clone>, GitError> {
let Ok(top) = run(Some(dir), &["rev-parse", "--show-toplevel"]).await else {
return Ok(None);
};
let top = PathBuf::from(top.trim());
let remotes = remotes(&top).await?;
let Some(remote) = remotes.into_iter().find(|r| {
github_repo_from_url(&r.url).is_some_and(|repo| {
repo.owner.eq_ignore_ascii_case(owner) && repo.name.eq_ignore_ascii_case(name)
})
}) else {
return Ok(None);
};
let branch = run(Some(&top), &["symbolic-ref", "--quiet", "--short", "HEAD"])
.await
.ok()
.map(|b| b.trim().to_owned())
.filter(|b| !b.is_empty());
Ok(Some(Clone {
top,
remote: remote.name,
branch,
}))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitMessage {
pub oid: String,
pub message: String,
}
impl CommitMessage {
pub fn subject(&self) -> &str {
self.message.lines().next().unwrap_or_default().trim()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Branch {
pub name: String,
pub head: Oid,
pub remote: String,
pub owner: Option<String>,
pub on_github: Option<Oid>,
pub ahead: Option<u64>,
pub behind: Option<u64>,
pub tracked: bool,
}
impl Branch {
pub fn pushed(&self) -> bool {
self.on_github.is_some() && self.ahead == Some(0)
}
}
async fn config(top: &Path, key: &str) -> Option<String> {
let value = run(Some(top), &["config", "--get", key]).await.ok()?;
Some(value.trim().to_owned()).filter(|v| !v.is_empty())
}
async fn commit(top: &Path, rev: &str) -> Option<Oid> {
let rev = format!("{rev}^{{commit}}");
let out = run(Some(top), &["rev-parse", "--verify", "--quiet", &rev]).await;
Oid::parse(out.ok()?.trim()).ok()
}
pub async fn branch(top: &Path, fallback: &str, name: &str) -> Result<Option<Branch>, GitError> {
let Some(head) = commit(top, &format!("refs/heads/{name}")).await else {
return Ok(None);
};
let upstream = config(top, &format!("branch.{name}.remote")).await;
let remote = match config(top, &format!("branch.{name}.pushRemote")).await {
Some(remote) => remote,
None => match config(top, "remote.pushDefault").await {
Some(remote) => remote,
None => upstream.clone().unwrap_or_else(|| fallback.to_owned()),
},
};
let url = config(top, &format!("remote.{remote}.url")).await;
let owner = url.and_then(|u| github_repo_from_url(&u)).map(|r| r.owner);
let into = format!("refs/ghtui/remote/{remote}/{name}");
let spec = format!("+refs/heads/{name}:{into}");
let fetched = run(
Some(top),
&[
"fetch",
"--no-tags",
"--no-write-fetch-head",
&remote,
&spec,
],
)
.await;
let on_github = match fetched {
Ok(_) => commit(top, &into).await,
Err(GitError::Failed { stderr, .. }) if stderr.contains("couldn't find remote ref") => None,
Err(err) => return Err(err),
};
let (mut ahead, mut behind) = (None, None);
if let Some(there) = &on_github {
let range = format!("{there}...{head}");
let counts = run(Some(top), &["rev-list", "--left-right", "--count", &range]).await?;
let mut counts = counts.split_whitespace().map(|n| n.parse::<u64>().ok());
behind = counts.next().flatten();
ahead = counts.next().flatten();
}
Ok(Some(Branch {
name: name.to_owned(),
head,
remote,
owner,
on_github,
ahead,
behind,
tracked: upstream.is_some(),
}))
}
pub async fn commits(
top: &Path,
remote: &str,
base: &str,
head: &str,
) -> Result<Vec<CommitMessage>, GitError> {
let into = format!("refs/ghtui/base/{base}");
let spec = format!("+refs/heads/{base}:{into}");
run(
Some(top),
&["fetch", "--no-tags", "--no-write-fetch-head", remote, &spec],
)
.await?;
let range = format!("{into}..refs/heads/{head}");
let out = run(
Some(top),
&["log", "--max-count=250", "--format=%H%x1f%B%x1e", &range],
)
.await?;
let mut commits: Vec<_> = (out.split('\u{1e}'))
.filter_map(|c| c.trim_start_matches('\n').split_once('\u{1f}'))
.map(|(oid, message)| CommitMessage {
oid: oid.to_owned(),
message: message.trim_end().to_owned(),
})
.collect();
commits.reverse();
Ok(commits)
}
pub async fn push(top: &Path, remote: &str, branch: &str, upstream: bool) -> Result<(), GitError> {
let spec = format!("refs/heads/{branch}:refs/heads/{branch}");
let mut args = vec!["push"];
if upstream {
args.push("--set-upstream");
}
args.extend([remote, spec.as_str()]);
run(Some(top), &args).await.map(drop)
}
pub async fn uncommitted(top: &Path) -> Result<Vec<String>, GitError> {
let out = run(
Some(top),
&["status", "--porcelain", "--untracked-files=no"],
)
.await?;
Ok((out.lines())
.filter_map(|entry| entry.get(3..))
.filter(|path| !path.is_empty())
.map(str::to_owned)
.collect())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Checkout {
Uncommitted(Vec<String>),
Created(String),
Switched(String),
Diverged(String),
}
pub async fn checkout(
top: &Path,
remote: &str,
source: &str,
name: &str,
track: Option<&str>,
) -> Result<Checkout, GitError> {
let changed = uncommitted(top).await?;
if !changed.is_empty() {
return Ok(Checkout::Uncommitted(changed));
}
let head_ref = format!(
"refs/ghtui/checkout/{}",
source.strip_prefix("refs/").unwrap_or(source)
);
let mut specs = vec![format!("+{source}:{head_ref}")];
if let Some(branch) = track {
specs.push(format!(
"+refs/heads/{branch}:refs/remotes/{remote}/{branch}"
));
}
let mut args = vec!["fetch", "--no-tags", "--no-write-fetch-head", remote];
args.extend(specs.iter().map(String::as_str));
run(Some(top), &args).await?;
let head = commit(top, &head_ref)
.await
.ok_or_else(|| GitError::Parse(format!("{head_ref} isn't a commit")))?;
let Some(local) = commit(top, &format!("refs/heads/{name}")).await else {
run(Some(top), &["switch", "--quiet", "-c", name, &head]).await?;
if let Some(branch) = track {
let upstream = format!("--set-upstream-to={remote}/{branch}");
run(Some(top), &["branch", "--quiet", &upstream, name]).await?;
}
return Ok(Checkout::Created(name.to_owned()));
};
run(Some(top), &["switch", "--quiet", name]).await?;
if local == head {
return Ok(Checkout::Switched(name.to_owned()));
}
let behind = run(Some(top), &["merge-base", "--is-ancestor", &local, &head]).await;
if behind.is_err() {
return Ok(Checkout::Diverged(name.to_owned()));
}
run(Some(top), &["merge", "--quiet", "--ff-only", &head]).await?;
Ok(Checkout::Switched(name.to_owned()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::set_up as git;
async fn repo(dir: &Path) {
git(dir, &["init", "-q", "-b", "main"]).await;
for (key, value) in [("user.name", "T"), ("user.email", "t@example.com")] {
git(dir, &["config", key, value]).await;
}
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
git(dir, &["add", "a.txt"]).await;
git(dir, &["commit", "-q", "-m", "First"]).await;
}
async fn github_and_clone() -> (tempfile::TempDir, tempfile::TempDir) {
let github = tempfile::tempdir().unwrap();
repo(github.path()).await;
git(github.path(), &["switch", "-q", "-c", "feature"]).await;
std::fs::write(github.path().join("b.txt"), "two\n").unwrap();
git(github.path(), &["add", "b.txt"]).await;
git(github.path(), &["commit", "-q", "-m", "Add b\n\nBecause."]).await;
git(
github.path(),
&["update-ref", "refs/pull/7/head", "feature"],
)
.await;
git(github.path(), &["switch", "-q", "main"]).await;
let clone = tempfile::tempdir().unwrap();
let from = github.path().to_str().unwrap();
git(clone.path(), &["clone", "-q", from, "."]).await;
for (key, value) in [("user.name", "T"), ("user.email", "t@example.com")] {
git(clone.path(), &["config", key, value]).await;
}
(github, clone)
}
#[tokio::test]
async fn checkout_refuses_a_dirty_tree_and_switches_on_a_clean_one() {
let (_github, clone) = github_and_clone().await;
let top = clone.path();
std::fs::write(top.join("a.txt"), "changed\n").unwrap();
let refused = checkout(
top,
"origin",
"refs/pull/7/head",
"feature",
Some("feature"),
)
.await
.unwrap();
assert_eq!(refused, Checkout::Uncommitted(vec!["a.txt".into()]));
assert_eq!(git(top, &["branch", "--show-current"]).await.trim(), "main");
git(top, &["commit", "-q", "-am", "Mine"]).await;
let done = checkout(
top,
"origin",
"refs/pull/7/head",
"feature",
Some("feature"),
)
.await
.unwrap();
assert_eq!(done, Checkout::Created("feature".into()));
assert_eq!(
git(top, &["branch", "--show-current"]).await.trim(),
"feature"
);
assert!(top.join("b.txt").exists());
let upstream = git(top, &["rev-parse", "--abbrev-ref", "feature@{upstream}"]).await;
assert_eq!(upstream.trim(), "origin/feature");
let again = checkout(top, "origin", "refs/pull/7/head", "feature", None)
.await
.unwrap();
assert_eq!(again, Checkout::Switched("feature".into()));
}
#[tokio::test]
async fn a_branch_made_on_github_is_checked_out_tracking_it() {
let (_github, clone) = github_and_clone().await;
let top = clone.path();
let done = checkout(
top,
"origin",
"refs/heads/feature",
"feature",
Some("feature"),
)
.await
.unwrap();
assert_eq!(done, Checkout::Created("feature".into()));
assert!(top.join("b.txt").exists());
let upstream = git(top, &["rev-parse", "--abbrev-ref", "feature@{upstream}"]).await;
assert_eq!(upstream.trim(), "origin/feature");
}
#[tokio::test]
async fn a_branch_is_compared_with_github_and_pushed() {
let (github, clone) = github_and_clone().await;
let top = clone.path();
git(top, &["switch", "-q", "-c", "topic"]).await;
std::fs::write(top.join("c.txt"), "three\n").unwrap();
git(top, &["add", "c.txt"]).await;
git(top, &["commit", "-q", "-m", "Add c\n\nWith a body."]).await;
let found = clone_of(top, "o", "r").await.unwrap();
assert_eq!(found, None, "the remote isn't github.com");
let b = branch(top, "origin", "topic").await.unwrap().unwrap();
assert_eq!(
(b.on_github.as_ref(), b.remote.as_str(), b.tracked),
(None, "origin", false)
);
let commits = commits(top, "origin", "main", "topic").await.unwrap();
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].message, "Add c\n\nWith a body.");
push(top, "origin", "topic", true).await.unwrap();
let b = branch(top, "origin", "topic").await.unwrap().unwrap();
assert!(b.pushed() && b.tracked, "{b:?}");
assert_eq!((b.ahead, b.behind), (Some(0), Some(0)));
let there = git(github.path(), &["rev-parse", "topic"]).await;
assert_eq!(there.trim(), b.head.to_string());
}
}