use std::ffi::OsStr;
use std::fs;
use std::path::{Component, Path};
use crate::error::{Error, Result};
use crate::objects::{parse_commit, parse_tree, ObjectId, ObjectKind};
use crate::reflog::read_reflog;
use crate::refs;
use crate::repo::Repository;
pub fn discover_optional(start: Option<&Path>) -> Result<Option<Repository>> {
match Repository::discover(start) {
Ok(repo) => Ok(Some(repo)),
Err(Error::NotARepository(_)) => Ok(None),
Err(err) => Err(err),
}
}
#[must_use]
pub fn is_inside_work_tree(repo: &Repository, cwd: &Path) -> bool {
let Some(work_tree) = &repo.work_tree else {
return false;
};
path_is_within(cwd, work_tree)
}
#[must_use]
pub fn is_inside_git_dir(repo: &Repository, cwd: &Path) -> bool {
path_is_within(cwd, &repo.git_dir)
}
#[must_use]
pub fn show_prefix(repo: &Repository, cwd: &Path) -> String {
let Some(work_tree) = &repo.work_tree else {
return String::new();
};
if !path_is_within(cwd, work_tree) {
return String::new();
}
if cwd == work_tree {
return String::new();
}
let Ok(rel) = cwd.strip_prefix(work_tree) else {
return String::new();
};
let mut out = rel
.components()
.filter_map(component_to_text)
.collect::<Vec<_>>()
.join("/");
if !out.is_empty() {
out.push('/');
}
out
}
#[must_use]
pub fn symbolic_full_name(repo: &Repository, spec: &str) -> Option<String> {
if let Some(base) = spec.strip_suffix("@{upstream}")
.or_else(|| spec.strip_suffix("@{u}"))
.or_else(|| spec.strip_suffix("@{UPSTREAM}"))
.or_else(|| spec.strip_suffix("@{U}"))
.or_else(|| spec.strip_suffix("@{UpSTReam}"))
{
return resolve_upstream_ref(repo, base);
}
if let Some(base) = spec.strip_suffix("@{push}") {
return resolve_push_ref(repo, base);
}
if spec == "HEAD" {
if let Ok(Some(target)) = refs::read_symbolic_ref(&repo.git_dir, "HEAD") {
return Some(target);
}
return None;
}
if spec.starts_with("refs/") {
if refs::resolve_ref(&repo.git_dir, spec).is_ok() {
return Some(spec.to_owned());
}
return None;
}
for prefix in &["refs/heads/", "refs/tags/", "refs/remotes/"] {
let candidate = format!("{prefix}{spec}");
if refs::resolve_ref(&repo.git_dir, &candidate).is_ok() {
return Some(candidate);
}
}
None
}
#[must_use]
pub fn abbreviate_ref_name(full_name: &str) -> String {
for prefix in &["refs/heads/", "refs/tags/", "refs/remotes/"] {
if let Some(short) = full_name.strip_prefix(prefix) {
return short.to_owned();
}
}
if let Some(short) = full_name.strip_prefix("refs/") {
return short.to_owned();
}
full_name.to_owned()
}
fn resolve_upstream_ref(repo: &Repository, branch: &str) -> Option<String> {
let branch_name = if branch.is_empty() {
match refs::read_head(&repo.git_dir) {
Ok(Some(target)) => target.strip_prefix("refs/heads/")?.to_owned(),
_ => return None,
}
} else {
branch.to_owned()
};
let config_path = repo.git_dir.join("config");
let config_content = fs::read_to_string(&config_path).ok()?;
let (remote, merge) = parse_branch_tracking(&config_content, &branch_name)?;
let merge_branch = merge.strip_prefix("refs/heads/")?;
Some(format!("refs/remotes/{remote}/{merge_branch}"))
}
fn resolve_push_ref(repo: &Repository, branch: &str) -> Option<String> {
resolve_upstream_ref(repo, branch)
}
fn parse_branch_tracking(config: &str, branch: &str) -> Option<(String, String)> {
let mut remote = None;
let mut merge = None;
let mut in_section = false;
let target_section = format!("[branch \"{}\"]", branch);
for line in config.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_section = trimmed == target_section
|| trimmed.starts_with(&format!("[branch \"{}\"", branch));
continue;
}
if !in_section {
continue;
}
if let Some(value) = trimmed.strip_prefix("remote = ") {
remote = Some(value.trim().to_owned());
} else if let Some(value) = trimmed.strip_prefix("merge = ") {
merge = Some(value.trim().to_owned());
}
if let Some(value) = trimmed.strip_prefix("remote=") {
remote = Some(value.trim().to_owned());
} else if let Some(value) = trimmed.strip_prefix("merge=") {
merge = Some(value.trim().to_owned());
}
}
match (remote, merge) {
(Some(r), Some(m)) => Some((r, m)),
_ => None,
}
}
pub fn resolve_revision(repo: &Repository, spec: &str) -> Result<ObjectId> {
let (base_with_nav, peel) = parse_peel_suffix(spec);
let (base, nav_steps) = parse_nav_steps(base_with_nav);
let mut oid = resolve_base(repo, base)?;
for step in nav_steps {
oid = apply_nav_step(repo, oid, step)?;
}
apply_peel(repo, oid, peel)
}
#[derive(Debug, Clone, Copy)]
enum NavStep {
ParentN(usize),
AncestorN(usize),
}
fn parse_nav_steps(spec: &str) -> (&str, Vec<NavStep>) {
let mut steps = Vec::new();
let mut remaining = spec;
loop {
if let Some(tilde_pos) = remaining.rfind('~') {
let after = &remaining[tilde_pos + 1..];
if after.is_empty() {
steps.push(NavStep::AncestorN(1));
remaining = &remaining[..tilde_pos];
continue;
}
if after.bytes().all(|b| b.is_ascii_digit()) {
let n: usize = after.parse().unwrap_or(1);
steps.push(NavStep::AncestorN(n));
remaining = &remaining[..tilde_pos];
continue;
}
}
if let Some(caret_pos) = remaining.rfind('^') {
let after = &remaining[caret_pos + 1..];
if after.is_empty() {
steps.push(NavStep::ParentN(1));
remaining = &remaining[..caret_pos];
continue;
}
if after.len() == 1 && after.as_bytes()[0].is_ascii_digit() {
let n = (after.as_bytes()[0] - b'0') as usize;
steps.push(NavStep::ParentN(n));
remaining = &remaining[..caret_pos];
continue;
}
}
break;
}
steps.reverse();
(remaining, steps)
}
fn apply_nav_step(repo: &Repository, oid: ObjectId, step: NavStep) -> Result<ObjectId> {
match step {
NavStep::ParentN(0) => Ok(oid),
NavStep::ParentN(n) => {
let obj = repo.odb.read(&oid)?;
if obj.kind != ObjectKind::Commit {
return Err(Error::InvalidRef(format!("{oid} is not a commit")));
}
let commit = parse_commit(&obj.data)?;
commit
.parents
.get(n - 1)
.copied()
.ok_or_else(|| Error::ObjectNotFound(format!("{oid}^{n}")))
}
NavStep::AncestorN(n) => {
let mut current = oid;
for _ in 0..n {
current = apply_nav_step(repo, current, NavStep::ParentN(1))?;
}
Ok(current)
}
}
}
pub fn abbreviate_object_id(repo: &Repository, oid: ObjectId, min_len: usize) -> Result<String> {
if !repo.odb.exists(&oid) {
return Err(Error::ObjectNotFound(oid.to_hex()));
}
let min_len = min_len.clamp(4, 40);
let target = oid.to_hex();
let all = collect_loose_object_ids(repo)?;
for len in min_len..=40 {
let prefix = &target[..len];
let matches = all
.iter()
.filter(|candidate| candidate.starts_with(prefix))
.count();
if matches <= 1 {
return Ok(prefix.to_owned());
}
}
Ok(target)
}
#[must_use]
pub fn to_relative_path(path: &Path, cwd: &Path) -> String {
let path_components = normalize_components(path);
let cwd_components = normalize_components(cwd);
let mut common = 0usize;
let max_common = path_components.len().min(cwd_components.len());
while common < max_common && path_components[common] == cwd_components[common] {
common += 1;
}
let mut parts = Vec::new();
let up_count = cwd_components.len().saturating_sub(common);
for _ in 0..up_count {
parts.push("..".to_owned());
}
for item in path_components.iter().skip(common) {
parts.push(item.clone());
}
if parts.is_empty() {
".".to_owned()
} else {
parts.join("/")
}
}
fn resolve_base(repo: &Repository, spec: &str) -> Result<ObjectId> {
if let Some(full_ref) = try_resolve_at_suffix(repo, spec) {
return refs::resolve_ref(&repo.git_dir, &full_ref)
.map_err(|_| Error::ObjectNotFound(spec.to_owned()));
}
if let Some(oid) = try_resolve_reflog_index(repo, spec)? {
return Ok(oid);
}
if let Some((treeish, path)) = split_treeish_spec(spec) {
let root_oid = resolve_base(repo, treeish)?;
return resolve_treeish_path(repo, root_oid, path);
}
if let Ok(oid) = spec.parse::<ObjectId>() {
if repo.odb.exists(&oid) {
return Ok(oid);
}
}
if is_hex_prefix(spec) {
let matches = find_abbrev_matches(repo, spec)?;
if matches.len() == 1 {
return Ok(matches[0]);
}
if matches.len() > 1 {
return Err(Error::InvalidRef(format!(
"short object ID {} is ambiguous",
spec
)));
}
}
if let Ok(oid) = refs::resolve_ref(&repo.git_dir, spec) {
return Ok(oid);
}
for candidate in &[
format!("refs/heads/{spec}"),
format!("refs/tags/{spec}"),
format!("refs/remotes/{spec}"),
] {
if let Ok(oid) = refs::resolve_ref(&repo.git_dir, candidate) {
return Ok(oid);
}
}
Err(Error::ObjectNotFound(spec.to_owned()))
}
fn try_resolve_reflog_index(repo: &Repository, spec: &str) -> Result<Option<ObjectId>> {
let at_pos = match spec.find("@{") {
Some(p) => p,
None => return Ok(None),
};
if !spec.ends_with('}') {
return Ok(None);
}
let inner = &spec[at_pos + 2..spec.len() - 1];
let index: usize = match inner.parse() {
Ok(n) => n,
Err(_) => return Ok(None),
};
let refname_raw = &spec[..at_pos];
let refname = if refname_raw.is_empty() {
"HEAD".to_string()
} else if refname_raw == "HEAD" || refname_raw.starts_with("refs/") {
refname_raw.to_string()
} else {
let candidate = format!("refs/heads/{refname_raw}");
if refs::resolve_ref(&repo.git_dir, &candidate).is_ok() {
candidate
} else {
refname_raw.to_string()
}
};
let entries = read_reflog(&repo.git_dir, &refname)?;
if entries.is_empty() {
return Err(Error::InvalidRef(format!("log for '{}' is empty", refname_raw)));
}
let reversed_idx = entries.len().checked_sub(1 + index)
.ok_or_else(|| Error::InvalidRef(format!("log for '{}' only has {} entries", refname_raw, entries.len())))?;
Ok(Some(entries[reversed_idx].new_oid))
}
fn try_resolve_at_suffix(repo: &Repository, spec: &str) -> Option<String> {
let lower = spec.to_lowercase();
if lower.ends_with("@{upstream}") || lower.ends_with("@{u}") {
let suffix_len = if lower.ends_with("@{upstream}") { 11 } else { 4 };
let base = &spec[..spec.len() - suffix_len];
return resolve_upstream_ref(repo, base);
}
if lower.ends_with("@{push}") {
let base = &spec[..spec.len() - 7];
return resolve_push_ref(repo, base);
}
None
}
fn split_treeish_spec(spec: &str) -> Option<(&str, &str)> {
let (treeish, path) = spec.split_once(':')?;
if treeish.is_empty() || path.is_empty() {
return None;
}
Some((treeish, path))
}
fn resolve_treeish_path(repo: &Repository, treeish: ObjectId, path: &str) -> Result<ObjectId> {
let object = repo.odb.read(&treeish)?;
let mut current_tree = match object.kind {
ObjectKind::Commit => parse_commit(&object.data)?.tree,
ObjectKind::Tree => treeish,
_ => {
return Err(Error::InvalidRef(format!(
"object {treeish} does not name a tree"
)))
}
};
let mut parts = path.split('/').filter(|part| !part.is_empty()).peekable();
if parts.peek().is_none() {
return Ok(current_tree);
}
while let Some(part) = parts.next() {
let tree_object = repo.odb.read(¤t_tree)?;
if tree_object.kind != ObjectKind::Tree {
return Err(Error::CorruptObject(format!(
"object {current_tree} is not a tree"
)));
}
let entries = parse_tree(&tree_object.data)?;
let Some(entry) = entries.iter().find(|entry| entry.name == part.as_bytes()) else {
return Err(Error::ObjectNotFound(path.to_owned()));
};
if parts.peek().is_none() {
return Ok(entry.oid);
}
current_tree = entry.oid;
}
Err(Error::ObjectNotFound(path.to_owned()))
}
fn apply_peel(repo: &Repository, mut oid: ObjectId, peel: Option<&str>) -> Result<ObjectId> {
match peel {
None | Some("object") => Ok(oid),
Some("") => {
while let Ok(obj) = repo.odb.read(&oid) {
if obj.kind != ObjectKind::Tag {
break;
}
oid = parse_tag_target(&obj.data)?;
}
Ok(oid)
}
Some("commit") => {
oid = apply_peel(repo, oid, Some(""))?;
let obj = repo.odb.read(&oid)?;
if obj.kind == ObjectKind::Commit {
Ok(oid)
} else {
Err(Error::InvalidRef("expected commit".to_owned()))
}
}
Some("tree") => {
oid = apply_peel(repo, oid, Some(""))?;
let obj = repo.odb.read(&oid)?;
match obj.kind {
ObjectKind::Tree => Ok(oid),
ObjectKind::Commit => Ok(parse_commit(&obj.data)?.tree),
_ => Err(Error::InvalidRef("expected tree or commit".to_owned())),
}
}
Some(other) => Err(Error::InvalidRef(format!(
"unsupported peel operator '{{{other}}}'"
))),
}
}
fn parse_peel_suffix(spec: &str) -> (&str, Option<&str>) {
if let Some(base) = spec.strip_suffix("^{}") {
return (base, Some(""));
}
if let Some(start) = spec.rfind("^{") {
if spec.ends_with('}') {
let base = &spec[..start];
let op = &spec[start + 2..spec.len() - 1];
return (base, Some(op));
}
}
(spec, None)
}
fn parse_tag_target(data: &[u8]) -> Result<ObjectId> {
let text = std::str::from_utf8(data)
.map_err(|_| Error::CorruptObject("invalid tag object".to_owned()))?;
let Some(line) = text.lines().find(|line| line.starts_with("object ")) else {
return Err(Error::CorruptObject("tag missing object header".to_owned()));
};
let oid_text = line.trim_start_matches("object ").trim();
oid_text.parse::<ObjectId>()
}
fn find_abbrev_matches(repo: &Repository, prefix: &str) -> Result<Vec<ObjectId>> {
if !is_hex_prefix(prefix) || !(4..=40).contains(&prefix.len()) {
return Ok(Vec::new());
}
let all = collect_loose_object_ids(repo)?;
let mut matches = Vec::new();
for candidate in all {
if candidate.starts_with(prefix) {
matches.push(candidate.parse::<ObjectId>()?);
}
}
Ok(matches)
}
fn collect_loose_object_ids(repo: &Repository) -> Result<Vec<String>> {
let mut ids = Vec::new();
let objects_dir = repo.git_dir.join("objects");
let read = match fs::read_dir(&objects_dir) {
Ok(read) => read,
Err(err) => return Err(Error::Io(err)),
};
for dir_entry in read {
let dir_entry = dir_entry?;
let name = dir_entry.file_name();
let Some(prefix) = name.to_str() else {
continue;
};
if !is_two_hex(prefix) {
continue;
}
if !dir_entry.file_type()?.is_dir() {
continue;
}
let files = fs::read_dir(dir_entry.path())?;
for file_entry in files {
let file_entry = file_entry?;
if !file_entry.file_type()?.is_file() {
continue;
}
let file_name = file_entry.file_name();
let Some(suffix) = file_name.to_str() else {
continue;
};
if suffix.len() == 38 && suffix.chars().all(|ch| ch.is_ascii_hexdigit()) {
ids.push(format!("{prefix}{suffix}"));
}
}
}
Ok(ids)
}
fn is_two_hex(text: &str) -> bool {
text.len() == 2 && text.chars().all(|ch| ch.is_ascii_hexdigit())
}
fn is_hex_prefix(text: &str) -> bool {
!text.is_empty() && text.chars().all(|ch| ch.is_ascii_hexdigit())
}
fn path_is_within(path: &Path, container: &Path) -> bool {
if path == container {
return true;
}
path.starts_with(container)
}
fn normalize_components(path: &Path) -> Vec<String> {
path.components()
.filter_map(|component| match component {
Component::RootDir => Some(String::from("/")),
Component::Normal(item) => Some(item.to_string_lossy().into_owned()),
_ => None,
})
.collect()
}
fn component_to_text(component: Component<'_>) -> Option<String> {
match component {
Component::Normal(item) => Some(os_to_string(item)),
_ => None,
}
}
fn os_to_string(text: &OsStr) -> String {
text.to_string_lossy().into_owned()
}