use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use crate::outpost::Outpost;
use crate::registry::{Registry, RegistryMut};
use crate::{
BranchName, GitInvoker, OutpostError, OutpostResult, RefName, RemoteName, UpstreamRef,
};
pub struct SourceRepo {
work_tree: PathBuf,
git_dir: PathBuf,
git_common_dir: PathBuf,
git: GitInvoker,
env: BTreeMap<OsString, OsString>,
}
impl SourceRepo {
pub fn discover(start: &Path) -> OutpostResult<Self> {
Self::discover_with(start, &BTreeMap::new())
}
pub fn discover_with(start: &Path, env: &BTreeMap<OsString, OsString>) -> OutpostResult<Self> {
let git = invoker_at(start, env);
let work_tree = git
.run_capture(["rev-parse", "--show-toplevel"])
.map_err(|err| map_discovery_error(err, start))?;
Self::at_with(work_tree, env)
}
pub fn at(path: impl Into<PathBuf>) -> OutpostResult<Self> {
Self::at_with(path, &BTreeMap::new())
}
pub fn at_with(
path: impl Into<PathBuf>,
env: &BTreeMap<OsString, OsString>,
) -> OutpostResult<Self> {
let start = path.into();
let git = invoker_at(&start, env);
let work_tree_raw = git
.run_capture(["rev-parse", "--show-toplevel"])
.map_err(|err| map_discovery_error(err, &start))?;
let git_dir_raw = git
.run_capture(["rev-parse", "--git-dir"])
.map_err(|err| map_discovery_error(err, &start))?;
let git_common_dir_raw = git
.run_capture(["rev-parse", "--git-common-dir"])
.map_err(|err| map_discovery_error(err, &start))?;
let work_tree = canonicalize_path(Path::new(&work_tree_raw))?;
let git_dir = canonicalize_git_path(&start, &git_dir_raw)?;
let git_common_dir = canonicalize_git_path(&start, &git_common_dir_raw)?;
let git = invoker_at(&work_tree, env);
Ok(Self {
work_tree,
git_dir,
git_common_dir,
git,
env: env.clone(),
})
}
pub fn work_tree(&self) -> &Path {
&self.work_tree
}
pub fn git_dir(&self) -> &Path {
&self.git_dir
}
pub fn git_common_dir(&self) -> &Path {
&self.git_common_dir
}
pub fn outpost_at(&self, path: &Path) -> OutpostResult<Outpost> {
Outpost::at_with(path, &self.env)
}
pub fn env(&self) -> &BTreeMap<OsString, OsString> {
&self.env
}
#[cfg(any(test, feature = "test-helpers"))]
pub fn test_invoker(&self) -> &GitInvoker {
&self.git
}
pub fn current_branch(&self) -> OutpostResult<BranchName> {
current_branch(&self.git, &self.work_tree)
}
pub fn checked_out_branches(&self) -> OutpostResult<Vec<BranchName>> {
let mut branches = Vec::new();
if let Ok(branch) = self.current_branch() {
branches.push(branch);
}
let output = self.git.run_capture(["worktree", "list", "--porcelain"])?;
for line in output.lines() {
if let Some(branch) = line.strip_prefix("branch refs/heads/") {
let branch = BranchName::parse(branch.to_owned())?;
if !branches.iter().any(|existing| existing == &branch) {
branches.push(branch);
}
}
}
Ok(branches)
}
pub fn checked_out_worktree_for(&self, branch: &BranchName) -> OutpostResult<Option<PathBuf>> {
let output = self.git.run_capture(["worktree", "list", "--porcelain"])?;
let mut current_path: Option<PathBuf> = None;
for line in output.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
current_path = Some(canonicalize_path(Path::new(path))?);
} else if let Some(value) = line.strip_prefix("branch refs/heads/") {
if value == branch.as_str() {
return Ok(current_path);
}
}
}
Ok(None)
}
pub fn is_dirty(&self) -> OutpostResult<bool> {
is_dirty(&self.git)
}
pub fn upstream_for(&self, branch: &BranchName) -> OutpostResult<Option<UpstreamRef>> {
let remote_key = format!("branch.{}.remote", branch.as_str());
let merge_key = format!("branch.{}.merge", branch.as_str());
let Some(remote) = read_optional_config(&self.git, &remote_key)? else {
return Ok(None);
};
let Some(merge_ref) = read_optional_config(&self.git, &merge_key)? else {
return Ok(None);
};
Ok(Some(UpstreamRef {
remote: crate::RemoteName::parse(remote)?,
merge_ref: RefName::parse(merge_ref)?,
}))
}
pub fn remote_url(&self, remote: &RemoteName) -> OutpostResult<String> {
self.git.run_capture(["remote", "get-url", remote.as_str()])
}
pub fn branch_exists(&self, branch: &BranchName) -> OutpostResult<bool> {
let branch_ref = format!("refs/heads/{}", branch.as_str());
self.git
.run_status(["rev-parse", "--verify", "--quiet", &branch_ref])
}
pub fn branch_oid(&self, branch: &BranchName) -> OutpostResult<Option<String>> {
if !self.branch_exists(branch)? {
return Ok(None);
}
rev_parse(&self.git, &source_branch_ref(branch)).map(|oid| Some(oid.trim().to_owned()))
}
pub fn origin_branch_oid(&self, branch: &BranchName) -> OutpostResult<Option<String>> {
self.remote_branch_oid(&origin_remote(), branch)
}
pub fn remote_branch_oid(
&self,
remote: &RemoteName,
branch: &BranchName,
) -> OutpostResult<Option<String>> {
let remote_ref = source_branch_ref(branch);
let output = self
.git
.run_capture(["ls-remote", remote.as_str(), &remote_ref])?;
if output.is_empty() {
return Ok(None);
}
let mut fields = output.split_whitespace();
let oid = fields
.next()
.ok_or_else(|| invalid_git_output(&self.git, &output))?;
let name = fields
.next()
.ok_or_else(|| invalid_git_output(&self.git, &output))?;
if fields.next().is_some() || name != remote_ref {
return Err(invalid_git_output(&self.git, &output));
}
Ok(Some(oid.to_owned()))
}
pub fn origin_default_branch(&self) -> OutpostResult<Option<BranchName>> {
self.remote_default_branch(&origin_remote())
}
pub fn remote_default_branch(&self, remote: &RemoteName) -> OutpostResult<Option<BranchName>> {
let head_ref = format!("refs/remotes/{}/HEAD", remote.as_str());
if !self
.git
.run_status(["symbolic-ref", "--quiet", &head_ref])?
{
return Ok(None);
}
let reference = self
.git
.run_capture(["symbolic-ref", "--quiet", &head_ref])?;
let remote_prefix = format!("refs/remotes/{}/", remote.as_str());
let Some(branch) = reference.strip_prefix(&remote_prefix) else {
return Err(invalid_git_output(&self.git, &reference));
};
BranchName::parse(branch.to_owned()).map(Some)
}
pub fn fetch_origin_default_branch(&self) -> OutpostResult<Option<(BranchName, String)>> {
self.fetch_remote_default_branch(&origin_remote())
}
pub fn fetch_remote_default_branch(
&self,
remote: &RemoteName,
) -> OutpostResult<Option<(BranchName, String)>> {
let branch = match self.remote_default_branch(remote)? {
Some(branch) => Some(branch),
None => self.remote_head_branch(remote)?,
};
let Some(branch) = branch else {
return Ok(None);
};
let remote_tracking_ref = format!("refs/remotes/{}/{}", remote.as_str(), branch.as_str());
let fetch_refspec = format!("+{}:{remote_tracking_ref}", source_branch_ref(&branch));
self.git
.run_check(["fetch", remote.as_str(), &fetch_refspec])?;
let oid = rev_parse(&self.git, &remote_tracking_ref)?;
Ok(Some((branch, oid.trim().to_owned())))
}
fn remote_head_branch(&self, remote: &RemoteName) -> OutpostResult<Option<BranchName>> {
let output = self
.git
.run_capture(["ls-remote", "--symref", remote.as_str(), "HEAD"])?;
for line in output.lines() {
let Some(rest) = line.strip_prefix("ref: ") else {
continue;
};
let mut fields = rest.split_whitespace();
let Some(reference) = fields.next() else {
return Err(invalid_git_output(&self.git, &output));
};
let Some(name) = fields.next() else {
return Err(invalid_git_output(&self.git, &output));
};
if fields.next().is_some() {
return Err(invalid_git_output(&self.git, &output));
}
if name != "HEAD" {
continue;
}
let Some(branch) = reference.strip_prefix("refs/heads/") else {
return Err(invalid_git_output(&self.git, &output));
};
return BranchName::parse(branch.to_owned()).map(Some);
}
Ok(None)
}
pub fn is_ancestor_oid(&self, ancestor: &str, descendant: &str) -> OutpostResult<bool> {
is_ancestor(&self.git, ancestor, descendant)
}
pub fn is_branch_checked_out(&self, branch: &BranchName) -> OutpostResult<bool> {
self.checked_out_worktree_for(branch)
.map(|path| path.is_some())
}
pub fn delete_branch_if_oid(
&self,
branch: &BranchName,
expected_oid: &str,
) -> OutpostResult<()> {
self.git
.run_check(["update-ref", "-d", &source_branch_ref(branch), expected_oid])
}
pub fn delete_origin_branch_if_oid(
&self,
branch: &BranchName,
expected_oid: &str,
) -> OutpostResult<()> {
self.delete_remote_branch_if_oid(&origin_remote(), branch, expected_oid)
}
pub fn delete_remote_branch_if_oid(
&self,
remote: &RemoteName,
branch: &BranchName,
expected_oid: &str,
) -> OutpostResult<()> {
let lease = format!(
"--force-with-lease=refs/heads/{}:{expected_oid}",
branch.as_str()
);
let delete_refspec = format!(":refs/heads/{}", branch.as_str());
self.git
.run_check(["push", &lease, remote.as_str(), &delete_refspec])
}
pub fn fast_forward_branch_from_origin(&self, branch: &BranchName) -> OutpostResult<()> {
if !self.branch_exists(branch)? {
return Err(OutpostError::BranchNotFound {
branch: branch.as_str().to_owned(),
repo: self.work_tree.clone(),
});
}
let local_ref = format!("refs/heads/{}", branch.as_str());
let remote_ref = format!("refs/remotes/origin/{}", branch.as_str());
let fetch_refspec = format!("{}:{remote_ref}", branch.as_str());
self.git.run_check(["fetch", "origin", &fetch_refspec])?;
let local_oid = rev_parse(&self.git, &local_ref)?;
let remote_oid = rev_parse(&self.git, &remote_ref)?;
if local_oid == remote_oid || is_ancestor(&self.git, &remote_oid, &local_oid)? {
return Ok(());
}
if !is_ancestor(&self.git, &local_oid, &remote_oid)? {
return Err(OutpostError::Divergence {
branch: branch.as_str().to_owned(),
});
}
if let Some(worktree) = self.checked_out_worktree_for(branch)? {
let git = invoker_at(&worktree, &self.env);
git.run_check(["merge", "--ff-only", &remote_ref])?;
} else {
self.git
.run_check(["update-ref", &local_ref, &remote_oid, &local_oid])?;
}
Ok(())
}
pub fn registry_path(&self) -> PathBuf {
self.work_tree.join(".outpost").join("registry.json")
}
pub fn registry(&self) -> OutpostResult<Registry> {
Registry::load(self)
}
pub fn registry_mut(&self) -> OutpostResult<RegistryMut<'_>> {
RegistryMut::load(self)
}
pub(crate) fn local_exclude_path(&self) -> PathBuf {
self.git_dir.join("info").join("exclude")
}
pub(crate) fn git(&self) -> &GitInvoker {
&self.git
}
#[cfg(test)]
pub(crate) fn from_storage_paths(work_tree: &Path, git_dir: &Path) -> OutpostResult<Self> {
let work_tree = canonicalize_path(work_tree)?;
let git_dir = canonicalize_path(git_dir)?;
Ok(Self {
git_common_dir: git_dir.clone(),
git: GitInvoker::at(&work_tree),
env: BTreeMap::new(),
work_tree,
git_dir,
})
}
}
pub(crate) fn invoker_at(cwd: &Path, env: &BTreeMap<OsString, OsString>) -> GitInvoker {
env.iter().fold(GitInvoker::at(cwd), |git, (key, val)| {
git.with_env(key.clone(), val.clone())
})
}
pub(crate) fn current_branch(git: &GitInvoker, repo: &Path) -> OutpostResult<BranchName> {
let name = git
.run_capture(["symbolic-ref", "--quiet", "--short", "HEAD"])
.map_err(|err| match err {
OutpostError::GitFailed { .. } => OutpostError::BranchNotFound {
branch: "HEAD".to_owned(),
repo: repo.to_path_buf(),
},
other => other,
})?;
BranchName::parse(name)
}
pub(crate) fn is_dirty(git: &GitInvoker) -> OutpostResult<bool> {
Ok(!git
.run_capture(["status", "--porcelain=v1", "--untracked-files=normal"])?
.is_empty())
}
pub(crate) fn read_optional_config(git: &GitInvoker, key: &str) -> OutpostResult<Option<String>> {
if git.run_status(["config", "--local", "--get", key])? {
git.run_capture(["config", "--local", "--get", key])
.map(Some)
} else {
Ok(None)
}
}
pub(crate) fn rev_parse(git: &GitInvoker, reference: &str) -> OutpostResult<String> {
git.run_capture(["rev-parse", reference])
}
pub(crate) fn is_ancestor(
git: &GitInvoker,
ancestor: &str,
descendant: &str,
) -> OutpostResult<bool> {
git.run_status(["merge-base", "--is-ancestor", ancestor, descendant])
}
pub(crate) fn canonicalize_path(path: &Path) -> OutpostResult<PathBuf> {
fs::canonicalize(path).map_err(|source| OutpostError::IoAt {
path: path.to_path_buf(),
source,
})
}
fn canonicalize_git_path(start: &Path, value: &str) -> OutpostResult<PathBuf> {
let path = PathBuf::from(value);
if path.is_absolute() {
canonicalize_path(&path)
} else {
canonicalize_path(&start.join(path))
}
}
fn map_discovery_error(err: OutpostError, path: &Path) -> OutpostError {
match err {
OutpostError::GitFailed { .. } => OutpostError::NotARepo(path.to_path_buf()),
other => other,
}
}
fn source_branch_ref(branch: &BranchName) -> String {
format!("refs/heads/{}", branch.as_str())
}
fn origin_remote() -> RemoteName {
RemoteName::parse("origin").expect("origin is a valid remote name")
}
fn invalid_git_output(git: &GitInvoker, output: &str) -> OutpostError {
OutpostError::IoAt {
path: git.cwd().to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("unexpected git output: {output}"),
),
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
#[test]
fn source_at_canonicalizes_paths_and_reads_current_branch() {
let temp = tempfile::tempdir().expect("tempdir");
GitInvoker::at(temp.path())
.run_check(["init", "--initial-branch=main"])
.expect("init");
let source = SourceRepo::at(temp.path()).expect("source repo");
assert_eq!(source.work_tree(), fs::canonicalize(temp.path()).unwrap());
assert_eq!(
source.git_dir(),
fs::canonicalize(temp.path().join(".git")).unwrap()
);
assert_eq!(
source.git_common_dir(),
fs::canonicalize(temp.path().join(".git")).unwrap()
);
assert_eq!(source.current_branch().unwrap().as_str(), "main");
assert!(!source.is_dirty().unwrap());
}
#[test]
fn source_discover_rejects_non_repo() {
let temp = tempfile::tempdir().expect("tempdir");
let Err(err) = SourceRepo::discover(temp.path()) else {
panic!("non repo should fail");
};
assert!(matches!(err, OutpostError::NotARepo(path) if path == temp.path()));
}
#[test]
fn source_dirty_detects_untracked_files() {
let temp = tempfile::tempdir().expect("tempdir");
GitInvoker::at(temp.path())
.run_check(["init", "--initial-branch=main"])
.expect("init");
fs::write(temp.path().join("new.txt"), "dirty").expect("write untracked");
let source = SourceRepo::at(temp.path()).expect("source repo");
assert!(source.is_dirty().unwrap());
}
#[test]
fn source_branch_helpers_read_local_heads_upstream_and_worktrees() {
let temp = tempfile::tempdir().expect("tempdir");
let sibling = tempfile::tempdir().expect("worktree parent");
let feature_worktree = sibling.path().join("feature-worktree");
let git = GitInvoker::at(temp.path());
git.run_check(["init", "--initial-branch=main"])
.expect("init");
git.run_check(["config", "user.name", "Test User"])
.expect("user name");
git.run_check(["config", "user.email", "test@example.com"])
.expect("user email");
git.run_check(["commit", "--allow-empty", "-m", "initial"])
.expect("initial commit");
git.run_check(["branch", "feature"])
.expect("feature branch");
git.run_check(["config", "--local", "branch.main.remote", "origin"])
.expect("remote config");
git.run_check(["config", "--local", "branch.main.merge", "refs/heads/main"])
.expect("merge config");
git.run_check([
"worktree",
"add",
feature_worktree.to_str().unwrap(),
"feature",
])
.expect("add worktree");
let source = SourceRepo::at(temp.path()).expect("source repo");
let main = BranchName::parse("main").unwrap();
let feature = BranchName::parse("feature").unwrap();
assert!(source.branch_exists(&main).unwrap());
assert!(
!source
.branch_exists(&BranchName::parse("missing").unwrap())
.unwrap()
);
assert_eq!(
source
.upstream_for(&main)
.unwrap()
.expect("main upstream")
.merge_ref
.as_str(),
"refs/heads/main"
);
assert_eq!(
source.checked_out_worktree_for(&feature).unwrap(),
Some(fs::canonicalize(&feature_worktree).unwrap())
);
let checked_out = source.checked_out_branches().unwrap();
assert!(checked_out.iter().any(|branch| branch == &main));
assert!(checked_out.iter().any(|branch| branch == &feature));
}
}