use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use crate::metadata::RawMetadata;
use crate::outpost::AheadBehind;
use crate::source_repo::{
SourceRepo, canonicalize_path, invoker_at, is_dirty, read_optional_config,
};
use crate::{BranchName, GitInvoker, OutpostError, OutpostResult, RefName, RemoteName};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusReport {
pub outpost_path: PathBuf,
pub source_path: Option<PathBuf>,
pub source_present: bool,
pub remote_name: Option<RemoteName>,
pub current_branch: Option<BranchName>,
pub outpost_dirty: bool,
pub source_ahead_behind_upstream: Option<AheadBehind>,
pub outpost_ahead_behind_source: Option<AheadBehind>,
pub problems: Vec<ConfigProblem>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigProblem {
MissingSourceRepoConfig,
SourceMissing(PathBuf),
MissingRemoteNameConfig,
LocalRemoteMismatch {
configured: PathBuf,
actual: PathBuf,
},
NoUpstreamTracking {
branch: BranchName,
},
NotInRegistry,
PushWouldFail {
branch: BranchName,
},
}
pub fn run(target_path: &Path) -> OutpostResult<StatusReport> {
run_with(target_path, &BTreeMap::new())
}
pub fn run_with(
target_path: &Path,
env: &BTreeMap<OsString, OsString>,
) -> OutpostResult<StatusReport> {
let outpost_path = discover_work_tree(target_path, env)?;
let git = invoker_at(&outpost_path, env);
let raw = RawMetadata::read(&git)?;
if raw.managed != Some(true) {
return Err(OutpostError::NotAnOutpost(outpost_path));
}
report_from_raw(outpost_path, raw, &git, env)
}
fn discover_work_tree(
target_path: &Path,
env: &BTreeMap<OsString, OsString>,
) -> OutpostResult<PathBuf> {
let git = invoker_at(target_path, env);
let work_tree = git
.run_capture(["rev-parse", "--show-toplevel"])
.map_err(|err| map_discovery_error(err, target_path))?;
canonicalize_path(Path::new(&work_tree))
}
fn report_from_raw(
outpost_path: PathBuf,
raw: RawMetadata,
git: &GitInvoker,
env: &BTreeMap<OsString, OsString>,
) -> OutpostResult<StatusReport> {
let mut problems = Vec::new();
let source_path = match raw.source_repo {
Some(path) => Some(canonicalize_existing_or_missing(&path)?),
None => {
problems.push(ConfigProblem::MissingSourceRepoConfig);
None
}
};
if raw.remote_name.is_none() {
problems.push(ConfigProblem::MissingRemoteNameConfig);
}
let source_present = source_path.as_ref().is_some_and(|path| path.exists());
if let Some(path) = source_path.as_ref().filter(|_| !source_present) {
problems.push(ConfigProblem::SourceMissing(path.clone()));
}
let remote_name = raw.remote_name;
let current_branch = current_branch_or_detached(git)?;
let outpost_dirty = is_dirty(git)?;
let mut source_ahead_behind_upstream = None;
let mut outpost_ahead_behind_source = None;
if let (Some(source_path), Some(remote_name)) = (source_path.as_ref(), remote_name.as_ref()) {
if source_present {
check_local_remote(git, &outpost_path, source_path, remote_name, &mut problems)?;
let source = SourceRepo::at_with(source_path, env)?;
check_registry(&source, &outpost_path, &mut problems)?;
if let Some(branch) = current_branch.as_ref() {
outpost_ahead_behind_source =
ahead_behind_outpost_source(git, branch, remote_name, &mut problems)?;
source_ahead_behind_upstream =
ahead_behind_source_upstream(source_path, branch, env, &mut problems)?;
check_push_would_fail(&source, branch, &mut problems)?;
}
}
}
Ok(StatusReport {
outpost_path,
source_path,
source_present,
remote_name,
current_branch,
outpost_dirty,
source_ahead_behind_upstream,
outpost_ahead_behind_source,
problems,
})
}
fn map_discovery_error(err: OutpostError, path: &Path) -> OutpostError {
match err {
OutpostError::GitFailed { .. } => OutpostError::NotARepo(path.to_path_buf()),
other => other,
}
}
fn canonicalize_existing_or_missing(path: &Path) -> OutpostResult<PathBuf> {
if path.exists() {
canonicalize_path(path)
} else {
Ok(canonicalize_missing(path))
}
}
fn canonicalize_missing(path: &Path) -> PathBuf {
let Some(parent) = path.parent() else {
return path.to_path_buf();
};
match std::fs::canonicalize(parent) {
Ok(parent) => parent.join(path.file_name().unwrap_or_default()),
Err(_) => path.to_path_buf(),
}
}
fn current_branch_or_detached(git: &GitInvoker) -> OutpostResult<Option<BranchName>> {
match git.run_capture(["symbolic-ref", "--quiet", "--short", "HEAD"]) {
Ok(branch) => BranchName::parse(branch).map(Some),
Err(OutpostError::GitFailed { code: 1, .. }) => Ok(None),
Err(err) => Err(err),
}
}
fn check_local_remote(
git: &GitInvoker,
outpost_path: &Path,
configured: &Path,
remote_name: &RemoteName,
problems: &mut Vec<ConfigProblem>,
) -> OutpostResult<()> {
let actual = match git.run_capture(["remote", "get-url", remote_name.as_str()]) {
Ok(actual) => canonicalize_remote_path(outpost_path, &actual)?,
Err(OutpostError::GitFailed { .. }) => return Ok(()),
Err(err) => return Err(err),
};
if actual != configured {
problems.push(ConfigProblem::LocalRemoteMismatch {
configured: configured.to_path_buf(),
actual,
});
}
Ok(())
}
fn check_registry(
source: &SourceRepo,
outpost_path: &Path,
problems: &mut Vec<ConfigProblem>,
) -> OutpostResult<()> {
if !source
.registry()?
.entries()
.iter()
.any(|entry| entry.path == outpost_path)
{
problems.push(ConfigProblem::NotInRegistry);
}
Ok(())
}
fn check_push_would_fail(
source: &SourceRepo,
branch: &BranchName,
problems: &mut Vec<ConfigProblem>,
) -> OutpostResult<()> {
if read_optional_config(source.git(), "receive.denyCurrentBranch")?.as_deref()
== Some("updateInstead")
{
return Ok(());
}
if source
.checked_out_branches()?
.iter()
.any(|checked_out| checked_out == branch)
{
problems.push(ConfigProblem::PushWouldFail {
branch: branch.clone(),
});
}
Ok(())
}
fn ahead_behind_outpost_source(
git: &GitInvoker,
branch: &BranchName,
remote_name: &RemoteName,
problems: &mut Vec<ConfigProblem>,
) -> OutpostResult<Option<AheadBehind>> {
let Some(remote_branch) = tracking_branch(git, branch, Some(remote_name), problems)? else {
return Ok(None);
};
let local_ref = format!("refs/heads/{}", branch.as_str());
let remote_ref = format!("refs/remotes/{}/{remote_branch}", remote_name.as_str());
ahead_behind_existing_refs(git, &local_ref, &remote_ref)
}
fn ahead_behind_source_upstream(
source_path: &Path,
branch: &BranchName,
env: &BTreeMap<OsString, OsString>,
problems: &mut Vec<ConfigProblem>,
) -> OutpostResult<Option<AheadBehind>> {
let source_git = invoker_at(source_path, env);
let Some(remote_branch) = tracking_branch(&source_git, branch, None, problems)? else {
return Ok(None);
};
let Some(remote_name) = read_branch_remote(&source_git, branch)? else {
return Ok(None);
};
let local_ref = format!("refs/heads/{}", branch.as_str());
let remote_ref = format!("refs/remotes/{}/{remote_branch}", remote_name.as_str());
ahead_behind_existing_refs(&source_git, &local_ref, &remote_ref)
}
fn tracking_branch(
git: &GitInvoker,
branch: &BranchName,
expected_remote: Option<&RemoteName>,
problems: &mut Vec<ConfigProblem>,
) -> OutpostResult<Option<String>> {
let Some(remote) = read_branch_remote(git, branch)? else {
push_no_upstream_once(problems, branch);
return Ok(None);
};
if expected_remote.is_some_and(|expected| expected != &remote) {
push_no_upstream_once(problems, branch);
return Ok(None);
}
let merge_key = format!("branch.{}.merge", branch.as_str());
let Some(merge_ref) = read_optional_config(git, &merge_key)? else {
push_no_upstream_once(problems, branch);
return Ok(None);
};
let merge_ref = RefName::parse(merge_ref)?;
let Some(remote_branch) = merge_ref.as_str().strip_prefix("refs/heads/") else {
push_no_upstream_once(problems, branch);
return Ok(None);
};
Ok(Some(remote_branch.to_owned()))
}
fn read_branch_remote(git: &GitInvoker, branch: &BranchName) -> OutpostResult<Option<RemoteName>> {
let remote_key = format!("branch.{}.remote", branch.as_str());
read_optional_config(git, &remote_key)?
.map(RemoteName::parse)
.transpose()
}
fn push_no_upstream_once(problems: &mut Vec<ConfigProblem>, branch: &BranchName) {
let problem = ConfigProblem::NoUpstreamTracking {
branch: branch.clone(),
};
if !problems.contains(&problem) {
problems.push(problem);
}
}
fn ahead_behind_existing_refs(
git: &GitInvoker,
local_ref: &str,
remote_ref: &str,
) -> OutpostResult<Option<AheadBehind>> {
if !ref_exists(git, local_ref)? || !ref_exists(git, remote_ref)? {
return Ok(None);
}
let range = format!("{local_ref}...{remote_ref}");
let output = git.run_capture(["rev-list", "--left-right", "--count", &range])?;
parse_ahead_behind(git.cwd(), &output).map(Some)
}
fn ref_exists(git: &GitInvoker, ref_name: &str) -> OutpostResult<bool> {
git.run_status(["rev-parse", "--verify", "--quiet", ref_name])
}
fn parse_ahead_behind(repo: &Path, output: &str) -> OutpostResult<AheadBehind> {
let mut parts = output.split_whitespace();
let ahead = parts
.next()
.and_then(|value| value.parse::<u32>().ok())
.ok_or_else(|| invalid_ahead_behind_output(repo, output))?;
let behind = parts
.next()
.and_then(|value| value.parse::<u32>().ok())
.ok_or_else(|| invalid_ahead_behind_output(repo, output))?;
if parts.next().is_some() {
return Err(invalid_ahead_behind_output(repo, output));
}
Ok(AheadBehind { ahead, behind })
}
fn invalid_ahead_behind_output(repo: &Path, output: &str) -> OutpostError {
OutpostError::IoAt {
path: repo.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("unexpected rev-list output: {output}"),
),
}
}
fn canonicalize_remote_path(outpost_path: &Path, value: &str) -> OutpostResult<PathBuf> {
let path = PathBuf::from(value);
let path = if path.is_absolute() {
path
} else {
outpost_path.join(path)
};
canonicalize_existing_or_missing(&path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_from_raw_records_missing_metadata_problems() {
let temp = tempfile::tempdir().expect("tempdir");
crate::GitInvoker::at(temp.path())
.run_check(["init", "--initial-branch=main"])
.expect("init repo");
let report = report_from_raw(
temp.path().to_path_buf(),
RawMetadata {
managed: Some(true),
source_repo: None,
remote_name: None,
},
&crate::GitInvoker::at(temp.path()),
&BTreeMap::new(),
)
.expect("report");
assert_eq!(report.source_path, None);
assert!(!report.source_present);
assert_eq!(report.remote_name, None);
assert_eq!(
report.problems,
vec![
ConfigProblem::MissingSourceRepoConfig,
ConfigProblem::MissingRemoteNameConfig,
]
);
}
}