use std::path::Path;
use crate::common::domain_types::GitOid;
use crate::workspace::{Workspace, WorkspaceFs};
mod iot {
pub type Result<T> = std::io::Result<T>;
pub type Error = std::io::Error;
pub type ErrorKind = std::io::ErrorKind;
}
include!("start_commit/io.rs");
const START_COMMIT_FILE: &str = ".agent/start_commit";
const EMPTY_REPO_SENTINEL: &str = "__EMPTY_REPO__";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StartPoint {
Commit(GitOid),
EmptyRepo,
}
pub fn get_current_head_oid() -> iot::Result<String> {
let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
get_current_head_oid_impl(&repo)
}
pub fn get_current_head_oid_at(repo_root: &Path) -> iot::Result<String> {
let repo = git2::Repository::open(repo_root).map_err(|e| to_io_error(&e))?;
get_current_head_oid_impl(&repo)
}
fn get_current_head_oid_impl(repo: &git2::Repository) -> iot::Result<String> {
let head = repo.head().map_err(|e| {
if e.code() == git2::ErrorCode::UnbornBranch {
iot::Error::new(iot::ErrorKind::NotFound, "No commits yet (unborn branch)")
} else {
to_io_error(&e)
}
})?;
let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
Ok(head_commit.id().to_string())
}
fn get_current_start_point(repo: &git2::Repository) -> iot::Result<StartPoint> {
let head = repo.head();
let start_point = match head {
Ok(head) => {
let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
StartPoint::Commit(git2_oid_to_git_oid(head_commit.id())?)
}
Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => StartPoint::EmptyRepo,
Err(e) => return Err(to_io_error(&e)),
};
Ok(start_point)
}
pub fn save_start_commit() -> iot::Result<()> {
let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
let repo_root = repo
.workdir()
.ok_or_else(|| iot::Error::new(iot::ErrorKind::NotFound, "No workdir for repository"))?;
save_start_commit_impl(&repo, repo_root)
}
fn save_start_commit_impl(repo: &git2::Repository, repo_root: &Path) -> iot::Result<()> {
if load_start_point_impl(repo, repo_root).is_ok() {
return Ok(());
}
write_start_point(repo_root, get_current_start_point(repo)?)
}
fn write_start_commit_with_oid(repo_root: &Path, oid: &str) -> iot::Result<()> {
let workspace = WorkspaceFs::new(repo_root.to_path_buf());
workspace.write(Path::new(START_COMMIT_FILE), oid)
}
fn write_start_point(repo_root: &Path, start_point: StartPoint) -> iot::Result<()> {
let content = match start_point {
StartPoint::Commit(oid) => oid.to_string(),
StartPoint::EmptyRepo => EMPTY_REPO_SENTINEL.to_string(),
};
let workspace = WorkspaceFs::new(repo_root.to_path_buf());
workspace.write(Path::new(START_COMMIT_FILE), &content)
}
fn write_start_point_with_workspace(
workspace: &dyn Workspace,
start_point: StartPoint,
) -> iot::Result<()> {
let path = Path::new(START_COMMIT_FILE);
let content = match start_point {
StartPoint::Commit(oid) => oid.to_string(),
StartPoint::EmptyRepo => EMPTY_REPO_SENTINEL.to_string(),
};
workspace.write(path, &content)
}
pub fn load_start_point_with_workspace(
workspace: &dyn Workspace,
repo: &git2::Repository,
) -> iot::Result<StartPoint> {
let path = Path::new(START_COMMIT_FILE);
let content = workspace.read(path)?;
let raw = content.trim();
if raw.is_empty() {
return Err(iot::Error::new(
iot::ErrorKind::InvalidData,
"Starting commit file is empty. Run 'ralph --reset-start-commit' to fix.",
));
}
if raw == EMPTY_REPO_SENTINEL {
return Ok(StartPoint::EmptyRepo);
}
let git_oid = parse_git_oid(raw)?;
let oid = git_oid_to_git2_oid(&git_oid)?;
repo.find_commit(oid).map_err(|e| {
let err_msg = e.message();
if err_msg.contains("not found") || err_msg.contains("invalid") {
iot::Error::new(
iot::ErrorKind::NotFound,
format!(
"Start commit '{raw}' no longer exists (history rewritten). \
Run 'ralph --reset-start-commit' to fix."
),
)
} else {
to_io_error(&e)
}
})?;
Ok(StartPoint::Commit(git_oid))
}
pub fn save_start_commit_with_workspace(
workspace: &dyn Workspace,
repo: &git2::Repository,
) -> iot::Result<()> {
if load_start_point_with_workspace(workspace, repo).is_ok() {
return Ok(());
}
write_start_point_with_workspace(workspace, get_current_start_point(repo)?)
}
pub fn load_start_point() -> iot::Result<StartPoint> {
let repo = git2::Repository::discover(".").map_err(|e| {
iot::Error::new(
iot::ErrorKind::NotFound,
format!("Git repository error: {e}. Run 'ralph --reset-start-commit' to fix."),
)
})?;
let repo_root = repo
.workdir()
.ok_or_else(|| iot::Error::new(iot::ErrorKind::NotFound, "No workdir for repository"))?;
load_start_point_impl(&repo, repo_root)
}
fn load_start_point_impl(repo: &git2::Repository, repo_root: &Path) -> iot::Result<StartPoint> {
let workspace = WorkspaceFs::new(repo_root.to_path_buf());
load_start_point_with_workspace(&workspace, repo)
}
#[derive(Debug, Clone)]
pub struct ResetStartCommitResult {
pub oid: String,
pub default_branch: Option<String>,
pub fell_back_to_head: bool,
}
pub fn reset_start_commit() -> iot::Result<ResetStartCommitResult> {
let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
let repo_root = repo
.workdir()
.ok_or_else(|| iot::Error::new(iot::ErrorKind::NotFound, "No workdir for repository"))?;
reset_start_commit_impl(&repo, repo_root)
}
fn reset_start_commit_impl(
repo: &git2::Repository,
repo_root: &Path,
) -> iot::Result<ResetStartCommitResult> {
let head = repo.head().map_err(|e| {
if e.code() == git2::ErrorCode::UnbornBranch {
iot::Error::new(iot::ErrorKind::NotFound, "No commits yet (unborn branch)")
} else {
to_io_error(&e)
}
})?;
let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
let current_branch = head.shorthand().unwrap_or("HEAD");
if current_branch == "main" || current_branch == "master" {
let oid = head_commit.id().to_string();
write_start_commit_with_oid(repo_root, &oid)?;
return Ok(ResetStartCommitResult {
oid,
default_branch: None,
fell_back_to_head: true,
});
}
let default_branch = super::branch::get_default_branch_at(repo_root)?;
let default_ref = format!("refs/heads/{default_branch}");
let default_commit = if let Ok(reference) = repo.find_reference(&default_ref) {
reference.peel_to_commit().map_err(|e| to_io_error(&e))?
} else {
let origin_ref = format!("refs/remotes/origin/{default_branch}");
match repo.find_reference(&origin_ref) {
Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
Err(_) => {
return Err(iot::Error::new(
iot::ErrorKind::NotFound,
format!(
"Default branch '{default_branch}' not found locally or in origin. \
Make sure the branch exists."
),
));
}
}
};
let merge_base = repo
.merge_base(head_commit.id(), default_commit.id())
.map_err(|e| {
if e.code() == git2::ErrorCode::NotFound {
iot::Error::new(
iot::ErrorKind::NotFound,
format!(
"No common ancestor between current branch and '{default_branch}' (unrelated branches)"
),
)
} else {
to_io_error(&e)
}
})?;
let oid = merge_base.to_string();
write_start_commit_with_oid(repo_root, &oid)?;
Ok(ResetStartCommitResult {
oid,
default_branch: Some(default_branch),
fell_back_to_head: false,
})
}
#[derive(Debug, Clone)]
pub struct StartCommitSummary {
pub start_oid: Option<GitOid>,
pub commits_since: usize,
pub is_stale: bool,
}
impl StartCommitSummary {
pub fn format_compact(&self) -> String {
self.start_oid.as_ref().map_or_else(
|| "Start: not set".to_string(),
|oid| {
let oid_str = oid.as_str();
let short_oid = &oid_str[..8.min(oid_str.len())];
if self.is_stale {
format!(
"Start: {} (+{} commits, STALE)",
short_oid, self.commits_since
)
} else if self.commits_since > 0 {
format!("Start: {} (+{} commits)", short_oid, self.commits_since)
} else {
format!("Start: {short_oid}")
}
},
)
}
}
pub fn get_start_commit_summary() -> iot::Result<StartCommitSummary> {
let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
let repo_root = repo
.workdir()
.ok_or_else(|| iot::Error::new(iot::ErrorKind::NotFound, "No workdir for repository"))?;
get_start_commit_summary_impl(&repo, repo_root)
}
fn get_start_commit_summary_impl(
repo: &git2::Repository,
repo_root: &Path,
) -> iot::Result<StartCommitSummary> {
let start_point = load_start_point_impl(repo, repo_root)?;
let start_oid = match start_point {
StartPoint::Commit(oid) => Some(oid),
StartPoint::EmptyRepo => None,
};
let (commits_since, is_stale) = if let Some(ref oid) = start_oid {
let head_oid = get_current_head_oid_impl(repo)?;
let head_commit = repo
.find_commit(git2::Oid::from_str(&head_oid).map_err(|_| {
iot::Error::new(iot::ErrorKind::InvalidData, "Invalid HEAD OID format")
})?)
.map_err(|e| to_io_error(&e))?;
let start_commit_oid = git_oid_to_git2_oid(oid)?;
let start_commit = repo
.find_commit(start_commit_oid)
.map_err(|e| to_io_error(&e))?;
let count = revwalk_count_commits_since(repo, head_commit.id(), start_commit.id(), 1000)?;
let is_stale = count > 10;
(count, is_stale)
} else {
(0, false)
};
Ok(StartCommitSummary {
start_oid,
commits_since,
is_stale,
})
}
pub(crate) fn git2_oid_to_git_oid(oid: git2::Oid) -> iot::Result<GitOid> {
let text = oid.to_string();
GitOid::try_from_str(&text).map_err(|err| {
iot::Error::new(
iot::ErrorKind::InvalidData,
format!("Invalid git2 OID from git: {text} ({err})"),
)
})
}
pub fn git_oid_to_git2_oid(oid: &GitOid) -> iot::Result<git2::Oid> {
git2::Oid::from_str(oid.as_str()).map_err(|_| {
iot::Error::new(
iot::ErrorKind::InvalidData,
format!("Stored start commit '{oid}' is not valid for git2"),
)
})
}
pub(crate) fn parse_git_oid(raw: &str) -> iot::Result<GitOid> {
GitOid::try_from_str(raw).map_err(|_| {
iot::Error::new(
iot::ErrorKind::InvalidData,
format!(
"Invalid OID format in {START_COMMIT_FILE}: '{raw}'. Run 'ralph --reset-start-commit' to fix."
),
)
})
}
fn to_io_error(err: &git2::Error) -> iot::Error {
iot::Error::other(err.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::domain_types::GitOid;
#[test]
fn test_start_commit_file_path_defined() {
assert_eq!(START_COMMIT_FILE, ".agent/start_commit");
}
#[test]
fn test_get_current_head_oid_returns_result() {
let result = get_current_head_oid();
assert!(
result.is_ok(),
"get_current_head_oid failed in a git repo: {:?}",
result.err()
);
}
#[test]
fn start_point_commit_holds_git_oid() {
let oid_text = "0123456789abcdef0123456789abcdef01234567";
let git_oid = match GitOid::try_from_str(oid_text) {
Ok(oid) => oid,
Err(err) => panic!("Failed to parse valid OID: {err}"),
};
let start_point = StartPoint::Commit(git_oid.clone());
if let StartPoint::Commit(stored) = start_point {
assert_eq!(stored, git_oid);
} else {
panic!("StartPoint::Commit did not store the GitOid variant");
}
}
}