ralph_workflow/git_helpers/
start_commit.rs1use std::path::Path;
19
20use crate::common::domain_types::GitOid;
21use crate::workspace::{Workspace, WorkspaceFs};
22
23mod iot {
24 pub type Result<T> = std::io::Result<T>;
25 pub type Error = std::io::Error;
26 pub type ErrorKind = std::io::ErrorKind;
27}
28
29include!("start_commit/io.rs");
31
32const START_COMMIT_FILE: &str = ".agent/start_commit";
37
38const EMPTY_REPO_SENTINEL: &str = "__EMPTY_REPO__";
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum StartPoint {
46 Commit(GitOid),
48 EmptyRepo,
50}
51
52pub fn get_current_head_oid() -> iot::Result<String> {
65 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
66 get_current_head_oid_impl(&repo)
67}
68
69pub fn get_current_head_oid_at(repo_root: &Path) -> iot::Result<String> {
78 let repo = git2::Repository::open(repo_root).map_err(|e| to_io_error(&e))?;
79 get_current_head_oid_impl(&repo)
80}
81
82fn get_current_head_oid_impl(repo: &git2::Repository) -> iot::Result<String> {
84 let head = repo.head().map_err(|e| {
85 if e.code() == git2::ErrorCode::UnbornBranch {
88 iot::Error::new(iot::ErrorKind::NotFound, "No commits yet (unborn branch)")
89 } else {
90 to_io_error(&e)
91 }
92 })?;
93
94 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
96
97 Ok(head_commit.id().to_string())
98}
99
100fn get_current_start_point(repo: &git2::Repository) -> iot::Result<StartPoint> {
101 let head = repo.head();
102 let start_point = match head {
103 Ok(head) => {
104 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
105 StartPoint::Commit(git2_oid_to_git_oid(head_commit.id())?)
106 }
107 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => StartPoint::EmptyRepo,
108 Err(e) => return Err(to_io_error(&e)),
109 };
110 Ok(start_point)
111}
112
113pub fn save_start_commit() -> iot::Result<()> {
127 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
128 let repo_root = repo
129 .workdir()
130 .ok_or_else(|| iot::Error::new(iot::ErrorKind::NotFound, "No workdir for repository"))?;
131 save_start_commit_impl(&repo, repo_root)
132}
133
134fn save_start_commit_impl(repo: &git2::Repository, repo_root: &Path) -> iot::Result<()> {
136 if load_start_point_impl(repo, repo_root).is_ok() {
139 return Ok(());
140 }
141
142 write_start_point(repo_root, get_current_start_point(repo)?)
143}
144
145fn write_start_commit_with_oid(repo_root: &Path, oid: &str) -> iot::Result<()> {
146 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
147 workspace.write(Path::new(START_COMMIT_FILE), oid)
148}
149
150fn write_start_point(repo_root: &Path, start_point: StartPoint) -> iot::Result<()> {
151 let content = match start_point {
152 StartPoint::Commit(oid) => oid.to_string(),
153 StartPoint::EmptyRepo => EMPTY_REPO_SENTINEL.to_string(),
154 };
155 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
156 workspace.write(Path::new(START_COMMIT_FILE), &content)
157}
158
159fn write_start_point_with_workspace(
164 workspace: &dyn Workspace,
165 start_point: StartPoint,
166) -> iot::Result<()> {
167 let path = Path::new(START_COMMIT_FILE);
168 let content = match start_point {
169 StartPoint::Commit(oid) => oid.to_string(),
170 StartPoint::EmptyRepo => EMPTY_REPO_SENTINEL.to_string(),
171 };
172 workspace.write(path, &content)
173}
174
175pub fn load_start_point_with_workspace(
187 workspace: &dyn Workspace,
188 repo: &git2::Repository,
189) -> iot::Result<StartPoint> {
190 let path = Path::new(START_COMMIT_FILE);
191 let content = workspace.read(path)?;
192 let raw = content.trim();
193
194 if raw.is_empty() {
195 return Err(iot::Error::new(
196 iot::ErrorKind::InvalidData,
197 "Starting commit file is empty. Run 'ralph --reset-start-commit' to fix.",
198 ));
199 }
200
201 if raw == EMPTY_REPO_SENTINEL {
202 return Ok(StartPoint::EmptyRepo);
203 }
204
205 let git_oid = parse_git_oid(raw)?;
206 let oid = git_oid_to_git2_oid(&git_oid)?;
207
208 repo.find_commit(oid).map_err(|e| {
210 let err_msg = e.message();
211 if err_msg.contains("not found") || err_msg.contains("invalid") {
212 iot::Error::new(
213 iot::ErrorKind::NotFound,
214 format!(
215 "Start commit '{raw}' no longer exists (history rewritten). \
216 Run 'ralph --reset-start-commit' to fix."
217 ),
218 )
219 } else {
220 to_io_error(&e)
221 }
222 })?;
223
224 Ok(StartPoint::Commit(git_oid))
225}
226
227pub fn save_start_commit_with_workspace(
236 workspace: &dyn Workspace,
237 repo: &git2::Repository,
238) -> iot::Result<()> {
239 if load_start_point_with_workspace(workspace, repo).is_ok() {
241 return Ok(());
242 }
243
244 write_start_point_with_workspace(workspace, get_current_start_point(repo)?)
245}
246
247pub fn load_start_point() -> iot::Result<StartPoint> {
259 let repo = git2::Repository::discover(".").map_err(|e| {
260 iot::Error::new(
261 iot::ErrorKind::NotFound,
262 format!("Git repository error: {e}. Run 'ralph --reset-start-commit' to fix."),
263 )
264 })?;
265 let repo_root = repo
266 .workdir()
267 .ok_or_else(|| iot::Error::new(iot::ErrorKind::NotFound, "No workdir for repository"))?;
268 load_start_point_impl(&repo, repo_root)
269}
270
271fn load_start_point_impl(repo: &git2::Repository, repo_root: &Path) -> iot::Result<StartPoint> {
273 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
274 load_start_point_with_workspace(&workspace, repo)
275}
276
277#[derive(Debug, Clone)]
279pub struct ResetStartCommitResult {
280 pub oid: String,
282 pub default_branch: Option<String>,
284 pub fell_back_to_head: bool,
286}
287
288pub fn reset_start_commit() -> iot::Result<ResetStartCommitResult> {
305 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
306 let repo_root = repo
307 .workdir()
308 .ok_or_else(|| iot::Error::new(iot::ErrorKind::NotFound, "No workdir for repository"))?;
309 reset_start_commit_impl(&repo, repo_root)
310}
311
312fn reset_start_commit_impl(
314 repo: &git2::Repository,
315 repo_root: &Path,
316) -> iot::Result<ResetStartCommitResult> {
317 let head = repo.head().map_err(|e| {
319 if e.code() == git2::ErrorCode::UnbornBranch {
320 iot::Error::new(iot::ErrorKind::NotFound, "No commits yet (unborn branch)")
321 } else {
322 to_io_error(&e)
323 }
324 })?;
325 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
326
327 let current_branch = head.shorthand().unwrap_or("HEAD");
329 if current_branch == "main" || current_branch == "master" {
330 let oid = head_commit.id().to_string();
331 write_start_commit_with_oid(repo_root, &oid)?;
332 return Ok(ResetStartCommitResult {
333 oid,
334 default_branch: None,
335 fell_back_to_head: true,
336 });
337 }
338
339 let default_branch = super::branch::get_default_branch_at(repo_root)?;
341
342 let default_ref = format!("refs/heads/{default_branch}");
344 let default_commit = if let Ok(reference) = repo.find_reference(&default_ref) {
345 reference.peel_to_commit().map_err(|e| to_io_error(&e))?
346 } else {
347 let origin_ref = format!("refs/remotes/origin/{default_branch}");
349 match repo.find_reference(&origin_ref) {
350 Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
351 Err(_) => {
352 return Err(iot::Error::new(
353 iot::ErrorKind::NotFound,
354 format!(
355 "Default branch '{default_branch}' not found locally or in origin. \
356 Make sure the branch exists."
357 ),
358 ));
359 }
360 }
361 };
362
363 let merge_base = repo
365 .merge_base(head_commit.id(), default_commit.id())
366 .map_err(|e| {
367 if e.code() == git2::ErrorCode::NotFound {
368 iot::Error::new(
369 iot::ErrorKind::NotFound,
370 format!(
371 "No common ancestor between current branch and '{default_branch}' (unrelated branches)"
372 ),
373 )
374 } else {
375 to_io_error(&e)
376 }
377 })?;
378
379 let oid = merge_base.to_string();
380 write_start_commit_with_oid(repo_root, &oid)?;
381
382 Ok(ResetStartCommitResult {
383 oid,
384 default_branch: Some(default_branch),
385 fell_back_to_head: false,
386 })
387}
388
389#[derive(Debug, Clone)]
393pub struct StartCommitSummary {
394 pub start_oid: Option<GitOid>,
396 pub commits_since: usize,
398 pub is_stale: bool,
400}
401
402impl StartCommitSummary {
403 pub fn format_compact(&self) -> String {
405 self.start_oid.as_ref().map_or_else(
406 || "Start: not set".to_string(),
407 |oid| {
408 let oid_str = oid.as_str();
409 let short_oid = &oid_str[..8.min(oid_str.len())];
410 if self.is_stale {
411 format!(
412 "Start: {} (+{} commits, STALE)",
413 short_oid, self.commits_since
414 )
415 } else if self.commits_since > 0 {
416 format!("Start: {} (+{} commits)", short_oid, self.commits_since)
417 } else {
418 format!("Start: {short_oid}")
419 }
420 },
421 )
422 }
423}
424
425pub fn get_start_commit_summary() -> iot::Result<StartCommitSummary> {
435 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
436 let repo_root = repo
437 .workdir()
438 .ok_or_else(|| iot::Error::new(iot::ErrorKind::NotFound, "No workdir for repository"))?;
439 get_start_commit_summary_impl(&repo, repo_root)
440}
441
442fn get_start_commit_summary_impl(
444 repo: &git2::Repository,
445 repo_root: &Path,
446) -> iot::Result<StartCommitSummary> {
447 let start_point = load_start_point_impl(repo, repo_root)?;
448 let start_oid = match start_point {
449 StartPoint::Commit(oid) => Some(oid),
450 StartPoint::EmptyRepo => None,
451 };
452
453 let (commits_since, is_stale) = if let Some(ref oid) = start_oid {
454 let head_oid = get_current_head_oid_impl(repo)?;
456 let head_commit = repo
457 .find_commit(git2::Oid::from_str(&head_oid).map_err(|_| {
458 iot::Error::new(iot::ErrorKind::InvalidData, "Invalid HEAD OID format")
459 })?)
460 .map_err(|e| to_io_error(&e))?;
461
462 let start_commit_oid = git_oid_to_git2_oid(oid)?;
463
464 let start_commit = repo
465 .find_commit(start_commit_oid)
466 .map_err(|e| to_io_error(&e))?;
467
468 let count = revwalk_count_commits_since(repo, head_commit.id(), start_commit.id(), 1000)?;
470
471 let is_stale = count > 10;
472 (count, is_stale)
473 } else {
474 (0, false)
475 };
476
477 Ok(StartCommitSummary {
478 start_oid,
479 commits_since,
480 is_stale,
481 })
482}
483
484pub(crate) fn git2_oid_to_git_oid(oid: git2::Oid) -> iot::Result<GitOid> {
485 let text = oid.to_string();
486 GitOid::try_from_str(&text).map_err(|err| {
487 iot::Error::new(
488 iot::ErrorKind::InvalidData,
489 format!("Invalid git2 OID from git: {text} ({err})"),
490 )
491 })
492}
493
494pub fn git_oid_to_git2_oid(oid: &GitOid) -> iot::Result<git2::Oid> {
495 git2::Oid::from_str(oid.as_str()).map_err(|_| {
496 iot::Error::new(
497 iot::ErrorKind::InvalidData,
498 format!("Stored start commit '{oid}' is not valid for git2"),
499 )
500 })
501}
502
503pub(crate) fn parse_git_oid(raw: &str) -> iot::Result<GitOid> {
504 GitOid::try_from_str(raw).map_err(|_| {
505 iot::Error::new(
506 iot::ErrorKind::InvalidData,
507 format!(
508 "Invalid OID format in {START_COMMIT_FILE}: '{raw}'. Run 'ralph --reset-start-commit' to fix."
509 ),
510 )
511 })
512}
513
514fn to_io_error(err: &git2::Error) -> iot::Error {
516 iot::Error::other(err.to_string())
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use crate::common::domain_types::GitOid;
523
524 #[test]
525 fn test_start_commit_file_path_defined() {
526 assert_eq!(START_COMMIT_FILE, ".agent/start_commit");
527 }
528
529 #[test]
530 fn test_get_current_head_oid_returns_result() {
531 let result = get_current_head_oid();
533 assert!(
534 result.is_ok(),
535 "get_current_head_oid failed in a git repo: {:?}",
536 result.err()
537 );
538 }
539
540 #[test]
541 fn start_point_commit_holds_git_oid() {
542 let oid_text = "0123456789abcdef0123456789abcdef01234567";
543 let git_oid = match GitOid::try_from_str(oid_text) {
544 Ok(oid) => oid,
545 Err(err) => panic!("Failed to parse valid OID: {err}"),
546 };
547
548 let start_point = StartPoint::Commit(git_oid.clone());
549
550 if let StartPoint::Commit(stored) = start_point {
551 assert_eq!(stored, git_oid);
552 } else {
553 panic!("StartPoint::Commit did not store the GitOid variant");
554 }
555 }
556}