use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result, bail};
use chrono::DateTime;
use git2::{BranchType, Repository, StatusOptions};
use crate::core::msg;
use crate::git;
pub fn open_repo() -> Result<Repository> {
let cwd = std::env::current_dir()?;
Ok(Repository::discover(cwd)?)
}
pub fn require_workdir<'a>(repo: &'a Repository, operation: &str) -> Result<&'a Path> {
repo.workdir()
.with_context(|| format!("Cannot {operation} in bare repository"))
}
pub fn head_oid(repo: &Repository) -> Result<git2::Oid> {
repo.head()?.target().context("HEAD has no target")
}
pub fn commit_subject(commit: &git2::Commit) -> String {
commit.summary().unwrap_or("").to_string()
}
pub fn snapshot_branch_refs(repo: &Repository) -> Result<HashMap<String, git2::Oid>> {
let mut refs = HashMap::new();
for branch_result in repo.branches(Some(BranchType::Local))? {
let (branch, _) = branch_result?;
if let Some(name) = branch.name()?
&& let Some(oid) = branch.get().target()
{
refs.insert(name.to_string(), oid);
}
}
Ok(refs)
}
pub fn restore_branch_refs(workdir: &Path, snapshot: &HashMap<String, git2::Oid>) -> Result<()> {
let repo = Repository::discover(workdir)?;
let mut current_branches: HashMap<String, git2::Oid> = HashMap::new();
for branch_result in repo.branches(Some(BranchType::Local))? {
let (branch, _) = branch_result?;
if let Some(name) = branch.name()?
&& let Some(oid) = branch.get().target()
{
current_branches.insert(name.to_string(), oid);
}
}
let head_branch = repo
.head()
.ok()
.and_then(|h| h.shorthand().map(|s| s.to_string()));
let mut failures: Vec<String> = Vec::new();
for name in current_branches.keys() {
if !snapshot.contains_key(name)
&& Some(name.as_str()) != head_branch.as_deref()
&& let Err(e) = git::branch_delete(workdir, name)
{
failures.push(format!("delete '{}': {}", name, e));
}
}
for (name, oid) in snapshot {
if Some(name.as_str()) == head_branch.as_deref() {
continue; }
let oid_str = oid.to_string();
if let Err(e) = git::branch_force_create(workdir, name, &oid_str) {
failures.push(format!("restore '{}': {}", name, e));
}
}
if !failures.is_empty() {
msg::warn(&format!(
"Partial rollback — some branch refs could not be restored:\n{}",
failures.join("\n")
));
}
Ok(())
}
pub fn ensure_branch_not_exists(repo: &Repository, name: &str) -> Result<()> {
if repo.find_branch(name, BranchType::Local).is_ok() {
bail!("Branch '{name}' already exists");
}
Ok(())
}
pub(crate) const DEFAULT_HIDE_PATTERN: &str = "local-";
pub fn hide_branch_pattern(repo: &Repository) -> Option<String> {
repo.config()
.ok()?
.get_string("loom.hideBranchPattern")
.ok()
}
pub fn upstream_local_branch(upstream_ref: &str) -> String {
upstream_ref
.split('/')
.skip(1)
.collect::<Vec<_>>()
.join("/")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
Commit(String),
Branch(String),
File(String),
CommitFile { commit: String, path: String },
Unstaged,
}
impl Target {
pub fn expect_branch(self) -> Result<String> {
match self {
Target::Branch(name) => Ok(name),
Target::Commit(_) => bail!("Target must be a branch, not a commit"),
Target::File(_) => bail!("Target must be a branch, not a file"),
Target::Unstaged => bail!("Target must be a branch"),
Target::CommitFile { .. } => bail!("Target must be a branch, not a commit file"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetKind {
File,
Branch,
Commit,
CommitFile,
Unstaged,
}
impl std::fmt::Display for TargetKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TargetKind::File => write!(f, "file"),
TargetKind::Branch => write!(f, "branch"),
TargetKind::Commit => write!(f, "commit"),
TargetKind::CommitFile => write!(f, "commit file"),
TargetKind::Unstaged => write!(f, "unstaged changes"),
}
}
}
pub fn resolve_arg(repo: &Repository, arg: &str, accept: &[TargetKind]) -> Result<Target> {
for kind in accept {
let result = match kind {
TargetKind::File => try_resolve_file(repo, arg)?,
TargetKind::Branch => try_resolve_branch(repo, arg)?,
TargetKind::Commit => try_resolve_commit(repo, arg)?,
TargetKind::CommitFile | TargetKind::Unstaged => None,
};
if let Some(target) = result {
return Ok(target);
}
}
if let Some(target) = try_resolve_shortid(repo, arg, accept)? {
return Ok(target);
}
let types: Vec<_> = accept.iter().map(|k| k.to_string()).collect();
bail!("'{}' did not resolve to a {}", arg, types.join(" or "))
}
fn reject_merge_commit(repo: &Repository, oid: git2::Oid) -> Result<()> {
let commit = repo.find_commit(oid)?;
if commit.parent_count() > 1 {
bail!("Cannot operate on a merge commit");
}
Ok(())
}
fn try_resolve_file(repo: &Repository, arg: &str) -> Result<Option<Target>> {
let repo_path = cwd_to_repo_path(repo, arg).unwrap_or_else(|_| arg.to_string());
let workdir = match repo.workdir() {
Some(w) => w,
None => return Ok(None),
};
let full_path = workdir.join(&repo_path);
if full_path.exists() {
return Ok(Some(Target::File(repo_path)));
}
let diff = crate::git::diff_head_file(workdir, &repo_path)?;
if !diff.is_empty() {
return Ok(Some(Target::File(repo_path)));
}
Ok(None)
}
fn try_resolve_branch(repo: &Repository, arg: &str) -> Result<Option<Target>> {
if let Ok(branch) = repo.find_branch(arg, BranchType::Local) {
let name = branch
.name()?
.context("Branch name is not valid UTF-8")?
.to_string();
return Ok(Some(Target::Branch(name)));
}
Ok(None)
}
fn try_resolve_commit(repo: &Repository, arg: &str) -> Result<Option<Target>> {
if repo.find_branch(arg, BranchType::Local).is_ok() {
return Ok(None);
}
if let Ok(obj) = repo.revparse_single(arg)
&& let Ok(commit) = obj.peel_to_commit()
{
let oid = commit.id();
reject_merge_commit(repo, oid)?;
return Ok(Some(Target::Commit(oid.to_string())));
}
Ok(None)
}
fn try_resolve_shortid(
repo: &Repository,
arg: &str,
accept: &[TargetKind],
) -> Result<Option<Target>> {
let needs_files = arg.contains(':');
let info = gather_repo_info(repo, needs_files, 1)?;
let entities = info.collect_entities();
let allocator = crate::core::shortid::IdAllocator::new(entities);
for kind in accept {
match kind {
TargetKind::Unstaged => {
if allocator.get_unstaged() == arg {
return Ok(Some(Target::Unstaged));
}
}
TargetKind::Branch => {
for branch in &info.branches {
if allocator.get_branch(&branch.name) == arg {
return Ok(Some(Target::Branch(branch.name.clone())));
}
}
}
TargetKind::Commit => {
for commit in &info.commits {
if allocator.get_commit(commit.oid) == arg {
reject_merge_commit(repo, commit.oid)?;
return Ok(Some(Target::Commit(commit.oid.to_string())));
}
}
}
TargetKind::File => {
for file in &info.working_changes {
if allocator.get_file(&file.path) == arg || file.path == arg {
return Ok(Some(Target::File(file.path.clone())));
}
}
}
TargetKind::CommitFile => {
if let Some((commit_part, index_part)) = arg.split_once(':')
&& let Ok(index) = index_part.parse::<usize>()
{
for commit in &info.commits {
if allocator.get_commit(commit.oid) == commit_part {
if let Some(file) = commit.files.get(index) {
return Ok(Some(Target::CommitFile {
commit: commit.oid.to_string(),
path: file.path.clone(),
}));
}
bail!(
"Commit has no file at index {}\nRun `loom status -f` to see available IDs",
index
);
}
}
}
}
}
}
Ok(None)
}
fn cwd_to_repo_path(repo: &Repository, arg: &str) -> Result<String> {
let prefix = cwd_relative_to_repo(repo)?;
if prefix.is_empty() {
return Ok(arg.to_string());
}
Ok(format!("{}/{}", prefix, arg))
}
pub fn cwd_relative_to_repo(repo: &Repository) -> Result<String> {
let workdir = repo
.workdir()
.context("Repository has no working directory")?;
let cwd = std::env::current_dir()?;
let workdir_canonical =
std::fs::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf());
let cwd_canonical = std::fs::canonicalize(&cwd).unwrap_or(cwd);
let rel = cwd_canonical
.strip_prefix(&workdir_canonical)
.unwrap_or(std::path::Path::new(""));
let s = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
Ok(s)
}
pub fn cwd_relative_path(repo_path: &str, cwd_prefix: &str) -> String {
if cwd_prefix.is_empty() {
return repo_path.to_string();
}
let cwd_parts: Vec<&str> = cwd_prefix.split('/').collect();
let file_parts: Vec<&str> = repo_path.split('/').collect();
let common = cwd_parts
.iter()
.zip(file_parts.iter())
.take_while(|(a, b)| a == b)
.count();
let ups = cwd_parts.len() - common;
let remaining = &file_parts[common..];
let mut result: Vec<&str> = vec![".."; ups];
result.extend_from_slice(remaining);
result.join("/")
}
#[derive(Debug)]
pub struct UpstreamInfo {
pub label: String,
pub merge_base_oid: git2::Oid,
pub base_short_id: String,
pub base_message: String,
pub base_date: String,
pub commits_ahead: usize,
}
#[derive(Debug)]
pub struct RepoInfo {
pub branch_name: String,
pub upstream: UpstreamInfo,
pub commits: Vec<CommitInfo>,
pub branches: Vec<BranchInfo>,
pub working_changes: Vec<FileChange>,
pub context_commits: Vec<ContextCommit>,
}
impl RepoInfo {
pub fn collect_entities(&self) -> Vec<crate::core::shortid::Entity> {
let mut entities = vec![crate::core::shortid::Entity::Unstaged];
for branch in &self.branches {
entities.push(crate::core::shortid::Entity::Branch(branch.name.clone()));
}
for commit in &self.commits {
entities.push(crate::core::shortid::Entity::Commit(commit.oid));
}
for file in &self.working_changes {
entities.push(crate::core::shortid::Entity::File(file.path.clone()));
}
entities
}
}
#[derive(Debug)]
pub struct CommitInfo {
pub oid: git2::Oid,
pub short_id: String,
pub message: String,
pub parent_oid: Option<git2::Oid>,
pub files: Vec<FileChange>,
}
#[derive(Debug, Clone)]
pub enum RemoteStatus {
Synced,
Ahead,
Gone,
}
#[derive(Debug)]
pub struct BranchInfo {
pub name: String,
pub tip_oid: git2::Oid,
pub remote: Option<RemoteStatus>,
}
#[derive(Debug)]
pub struct ContextCommit {
pub short_hash: String,
pub message: String,
pub date: String,
}
#[derive(Debug)]
pub struct FileChange {
pub path: String,
pub index: char,
pub worktree: char,
}
pub fn gather_repo_info(repo: &Repository, show_files: bool, context: usize) -> Result<RepoInfo> {
let head = repo.head()?;
if !head.is_branch() {
bail!("HEAD is detached\nSwitch to an integration branch");
}
let head_oid = head.target().context("HEAD does not point to a commit")?;
let branch_name = head.shorthand().unwrap_or("HEAD").to_string();
let local_branch = repo
.find_branch(&branch_name, BranchType::Local)
.with_context(|| format!("Branch '{}' not found — are you on a branch?", branch_name))?;
let upstream = local_branch.upstream().with_context(|| {
format!(
"Branch '{}' has no upstream tracking branch\n\
Set one with: git branch --set-upstream-to=<upstream> {}",
branch_name, branch_name
)
})?;
let upstream_name = upstream
.name()?
.context("Upstream branch name is not valid UTF-8")?
.to_string();
let upstream_oid = upstream
.get()
.target()
.context("Upstream does not point to a commit")?;
let merge_base_oid = repo.merge_base(head_oid, upstream_oid)?;
let commits = walk_commits(repo, head_oid, merge_base_oid, show_files)?;
let commit_set: std::collections::HashSet<git2::Oid> = commits.iter().map(|c| c.oid).collect();
let branches = find_branches_in_range(
repo,
&commit_set,
merge_base_oid,
&branch_name,
&upstream_name,
)?;
let working_changes = get_working_changes(repo)?;
let commits_ahead = count_commits(repo, upstream_oid, merge_base_oid)?;
let base_commit = repo.find_commit(merge_base_oid)?;
let base_short_id = base_commit
.as_object()
.short_id()?
.as_str()
.context("Base commit short_id is not valid UTF-8")?
.to_string();
let base_message = commit_subject(&base_commit);
let base_time = base_commit.time();
let base_date = format_epoch(base_time.seconds());
let context_commits = walk_context_commits(repo, merge_base_oid, context)?;
Ok(RepoInfo {
branch_name,
upstream: UpstreamInfo {
label: upstream_name,
merge_base_oid,
base_short_id,
base_message,
base_date,
commits_ahead,
},
commits,
branches,
working_changes,
context_commits,
})
}
pub fn path_has_changes(repo: &Repository, path: &str) -> Result<bool> {
let mut opts = StatusOptions::new();
opts.pathspec(path)
.include_untracked(true)
.recurse_untracked_dirs(true);
let statuses = repo.statuses(Some(&mut opts))?;
Ok(!statuses.is_empty())
}
pub fn get_staged_files(repo: &Repository) -> Result<Vec<String>> {
let mut opts = StatusOptions::new();
opts.include_untracked(false);
let statuses = repo.statuses(Some(&mut opts))?;
let mut paths = Vec::new();
for entry in statuses.iter() {
let status = entry.status();
if (status.is_index_new()
|| status.is_index_modified()
|| status.is_index_deleted()
|| status.is_index_renamed()
|| status.is_index_typechange())
&& let Some(path) = entry.path()
{
paths.push(path.to_string());
}
}
Ok(paths)
}
pub fn resolve_file_arg(repo: &Repository, arg: &str) -> Result<String> {
match resolve_arg(repo, arg, &[TargetKind::File])? {
Target::File(path) => Ok(path),
_ => unreachable!(),
}
}
pub fn verify_has_staged_changes(repo: &Repository) -> Result<()> {
if get_staged_files(repo)?.is_empty() {
anyhow::bail!("Nothing to commit");
}
Ok(())
}
fn count_commits(repo: &Repository, from: git2::Oid, hide: git2::Oid) -> Result<usize> {
if from == hide {
return Ok(0);
}
let mut revwalk = repo.revwalk()?;
revwalk.push(from)?;
revwalk.hide(hide)?;
let mut count = 0usize;
for oid_result in revwalk {
oid_result?;
count += 1;
}
Ok(count)
}
fn walk_context_commits(
repo: &Repository,
merge_base_oid: git2::Oid,
count: usize,
) -> Result<Vec<ContextCommit>> {
if count <= 1 {
return Ok(vec![]);
}
let base_commit = repo.find_commit(merge_base_oid)?;
let mut commits = Vec::new();
let mut current = base_commit.parent(0).ok();
let remaining = count - 1;
while let Some(commit) = current {
if commits.len() >= remaining {
break;
}
let short_hash = commit
.as_object()
.short_id()?
.as_str()
.context("Context commit short_id is not valid UTF-8")?
.to_string();
let message = commit_subject(&commit);
let date = format_epoch(commit.time().seconds());
commits.push(ContextCommit {
short_hash,
message,
date,
});
current = commit.parent(0).ok();
}
Ok(commits)
}
fn format_epoch(epoch: i64) -> String {
DateTime::from_timestamp(epoch, 0)
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "????-??-??".to_string())
}
fn walk_commits(
repo: &Repository,
head_oid: git2::Oid,
stop_oid: git2::Oid,
show_files: bool,
) -> Result<Vec<CommitInfo>> {
let mut revwalk = repo.revwalk()?;
revwalk.push(head_oid)?;
revwalk.hide(stop_oid)?;
revwalk.set_sorting(git2::Sort::TOPOLOGICAL)?;
let mut commits = Vec::new();
for oid_result in revwalk {
let oid = oid_result?;
let commit = repo.find_commit(oid)?;
if commit.parent_count() > 1 {
continue;
}
let short_id = commit
.as_object()
.short_id()?
.as_str()
.context("Commit short_id is not valid UTF-8")?
.to_string();
let message = commit_subject(&commit);
let parent_oid = commit.parent_id(0).ok();
let files = if show_files {
get_commit_files(repo, &commit)?
} else {
vec![]
};
commits.push(CommitInfo {
oid,
short_id,
message,
parent_oid,
files,
});
}
Ok(commits)
}
pub fn commit_file_paths(repo: &Repository, oid: git2::Oid) -> Result<Vec<String>> {
let commit = repo.find_commit(oid)?;
let files = get_commit_files(repo, &commit)?;
Ok(files.into_iter().map(|f| f.path).collect())
}
fn get_commit_files(repo: &Repository, commit: &git2::Commit) -> Result<Vec<FileChange>> {
let commit_tree = commit.tree()?;
let parent_tree = if commit.parent_count() > 0 {
Some(commit.parent(0)?.tree()?)
} else {
None
};
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
let mut files = Vec::new();
for delta in diff.deltas() {
let status = match delta.status() {
git2::Delta::Added => 'A',
git2::Delta::Modified => 'M',
git2::Delta::Deleted => 'D',
git2::Delta::Renamed => 'R',
_ => '?',
};
let path = delta
.new_file()
.path()
.and_then(|p| p.to_str())
.unwrap_or("")
.to_string();
files.push(FileChange {
path,
index: status,
worktree: ' ',
});
}
Ok(files)
}
fn find_branches_in_range(
repo: &Repository,
commit_set: &std::collections::HashSet<git2::Oid>,
merge_base_oid: git2::Oid,
current_branch: &str,
upstream_name: &str,
) -> Result<Vec<BranchInfo>> {
let mut branches = Vec::new();
for branch_result in repo.branches(Some(BranchType::Local))? {
let (branch, _) = branch_result?;
let Some(name) = branch.name()? else {
continue;
};
let name = name.to_string();
if name == current_branch {
continue;
}
if let Ok(up) = branch.upstream()
&& let Ok(Some(up_name)) = up.name()
&& up_name == upstream_name
{
continue;
}
if let Some(tip_oid) = branch.get().target() {
if tip_oid == merge_base_oid && name == upstream_local_branch(upstream_name) {
continue;
}
if commit_set.contains(&tip_oid) || tip_oid == merge_base_oid {
let remote = detect_remote_status(repo, &branch, &name, tip_oid);
branches.push(BranchInfo {
name,
tip_oid,
remote,
});
}
}
}
Ok(branches)
}
fn detect_remote_status(
repo: &Repository,
branch: &git2::Branch,
name: &str,
tip_oid: git2::Oid,
) -> Option<RemoteStatus> {
if let Ok(upstream) = branch.upstream() {
return Some(match upstream.get().target() {
Some(upstream_oid) if upstream_oid == tip_oid => RemoteStatus::Synced,
_ => RemoteStatus::Ahead,
});
}
let config = repo.config().ok()?;
let remote_key = format!("branch.{}.remote", name);
let Ok(remote) = config.get_string(&remote_key) else {
return None; };
let merge_key = format!("branch.{}.merge", name);
let Ok(merge) = config.get_string(&merge_key) else {
return None;
};
let branch_part = merge.strip_prefix("refs/heads/").unwrap_or(&merge);
let tracking_ref = format!("refs/remotes/{}/{}", remote, branch_part);
if repo.find_reference(&tracking_ref).is_err() {
Some(RemoteStatus::Gone)
} else {
None
}
}
pub(crate) fn get_working_changes(repo: &Repository) -> Result<Vec<FileChange>> {
get_working_changes_opts(repo, false)
}
pub(crate) fn get_working_changes_recurse(repo: &Repository) -> Result<Vec<FileChange>> {
get_working_changes_opts(repo, true)
}
fn get_working_changes_opts(repo: &Repository, recurse_untracked: bool) -> Result<Vec<FileChange>> {
let mut opts = StatusOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(recurse_untracked);
let statuses = repo.statuses(Some(&mut opts))?;
let mut changes = Vec::new();
for entry in statuses.iter() {
let path = match entry.path() {
Some(p) => p.to_string(),
None => {
String::from_utf8_lossy(entry.path_bytes()).into_owned()
}
};
let status = entry.status();
let index = if status.is_conflicted() {
'!'
} else if status.is_index_new() {
'A'
} else if status.is_index_modified() {
'M'
} else if status.is_index_deleted() {
'D'
} else if status.is_index_renamed() {
'R'
} else if status.is_wt_new() {
'?'
} else {
' '
};
let worktree = if status.is_wt_new() {
'?'
} else if status.is_conflicted() {
'!'
} else if status.is_wt_modified() {
'M'
} else if status.is_wt_deleted() {
'D'
} else if status.is_wt_renamed() {
'R'
} else {
' '
};
changes.push(FileChange {
path,
index,
worktree,
});
}
Ok(changes)
}
#[cfg(test)]
#[path = "repo_test.rs"]
mod tests;