ralph_workflow/git_helpers/
start_commit.rs1use std::fs;
19use std::io;
20use std::path::Path;
21
22#[cfg(any(test, feature = "test-utils"))]
23use crate::workspace::Workspace;
24
25const START_COMMIT_FILE: &str = ".agent/start_commit";
30
31const EMPTY_REPO_SENTINEL: &str = "__EMPTY_REPO__";
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum StartPoint {
39 Commit(git2::Oid),
41 EmptyRepo,
43}
44
45pub fn get_current_head_oid() -> io::Result<String> {
58 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
59 get_current_head_oid_impl(&repo)
60}
61
62fn get_current_head_oid_impl(repo: &git2::Repository) -> io::Result<String> {
64 let head = repo.head().map_err(|e| {
65 if e.code() == git2::ErrorCode::UnbornBranch {
68 io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
69 } else {
70 to_io_error(&e)
71 }
72 })?;
73
74 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
76
77 Ok(head_commit.id().to_string())
78}
79
80fn get_current_start_point(repo: &git2::Repository) -> io::Result<StartPoint> {
81 let head = repo.head();
82 let start_point = match head {
83 Ok(head) => {
84 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
85 StartPoint::Commit(head_commit.id())
86 }
87 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => StartPoint::EmptyRepo,
88 Err(e) => return Err(to_io_error(&e)),
89 };
90 Ok(start_point)
91}
92
93pub fn save_start_commit() -> io::Result<()> {
107 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
108 let repo_root = repo
109 .workdir()
110 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
111 save_start_commit_impl(&repo, repo_root)
112}
113
114fn save_start_commit_impl(repo: &git2::Repository, repo_root: &Path) -> io::Result<()> {
116 if load_start_point_impl(repo, repo_root).is_ok() {
119 return Ok(());
120 }
121
122 write_start_point(repo_root, get_current_start_point(repo)?)
123}
124
125fn write_start_commit_with_oid(repo_root: &Path, oid: &str) -> io::Result<()> {
126 let path = repo_root.join(START_COMMIT_FILE);
128 if let Some(parent) = path.parent() {
129 fs::create_dir_all(parent)?;
130 }
131
132 fs::write(&path, oid)?;
134
135 Ok(())
136}
137
138fn write_start_point(repo_root: &Path, start_point: StartPoint) -> io::Result<()> {
139 match start_point {
140 StartPoint::Commit(oid) => write_start_commit_with_oid(repo_root, &oid.to_string()),
141 StartPoint::EmptyRepo => {
142 let path = repo_root.join(START_COMMIT_FILE);
144 if let Some(parent) = path.parent() {
145 fs::create_dir_all(parent)?;
146 }
147 fs::write(&path, EMPTY_REPO_SENTINEL)?;
148 Ok(())
149 }
150 }
151}
152
153#[cfg(any(test, feature = "test-utils"))]
158fn write_start_point_with_workspace(
159 workspace: &dyn Workspace,
160 start_point: StartPoint,
161) -> io::Result<()> {
162 let path = Path::new(START_COMMIT_FILE);
163 let content = match start_point {
164 StartPoint::Commit(oid) => oid.to_string(),
165 StartPoint::EmptyRepo => EMPTY_REPO_SENTINEL.to_string(),
166 };
167 workspace.write(path, &content)
168}
169
170#[cfg(any(test, feature = "test-utils"))]
178pub fn load_start_point_with_workspace(
179 workspace: &dyn Workspace,
180 repo: &git2::Repository,
181) -> io::Result<StartPoint> {
182 let path = Path::new(START_COMMIT_FILE);
183 let content = workspace.read(path)?;
184 let raw = content.trim();
185
186 if raw.is_empty() {
187 return Err(io::Error::new(
188 io::ErrorKind::InvalidData,
189 "Starting commit file is empty. Run 'ralph --reset-start-commit' to fix.",
190 ));
191 }
192
193 if raw == EMPTY_REPO_SENTINEL {
194 return Ok(StartPoint::EmptyRepo);
195 }
196
197 let oid = git2::Oid::from_str(raw).map_err(|_| {
199 io::Error::new(
200 io::ErrorKind::InvalidData,
201 format!(
202 "Invalid OID format in {}: '{}'. Run 'ralph --reset-start-commit' to fix.",
203 START_COMMIT_FILE, raw
204 ),
205 )
206 })?;
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 io::Error::new(
213 io::ErrorKind::NotFound,
214 format!(
215 "Start commit '{}' no longer exists (history rewritten). \
216 Run 'ralph --reset-start-commit' to fix.",
217 raw
218 ),
219 )
220 } else {
221 to_io_error(&e)
222 }
223 })?;
224
225 Ok(StartPoint::Commit(oid))
226}
227
228#[cfg(any(test, feature = "test-utils"))]
233pub fn save_start_commit_with_workspace(
234 workspace: &dyn Workspace,
235 repo: &git2::Repository,
236) -> io::Result<()> {
237 if load_start_point_with_workspace(workspace, repo).is_ok() {
239 return Ok(());
240 }
241
242 write_start_point_with_workspace(workspace, get_current_start_point(repo)?)
243}
244
245pub fn load_start_point() -> io::Result<StartPoint> {
257 let repo = git2::Repository::discover(".").map_err(|e| {
258 io::Error::new(
259 io::ErrorKind::NotFound,
260 format!("Git repository error: {e}. Run 'ralph --reset-start-commit' to fix."),
261 )
262 })?;
263 let repo_root = repo
264 .workdir()
265 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
266 load_start_point_impl(&repo, repo_root)
267}
268
269fn load_start_point_impl(repo: &git2::Repository, repo_root: &Path) -> io::Result<StartPoint> {
271 let path = repo_root.join(START_COMMIT_FILE);
272 let content = fs::read_to_string(&path)?;
273
274 let raw = content.trim();
275
276 if raw.is_empty() {
277 return Err(io::Error::new(
278 io::ErrorKind::InvalidData,
279 "Starting commit file is empty. Run 'ralph --reset-start-commit' to fix.",
280 ));
281 }
282
283 if raw == EMPTY_REPO_SENTINEL {
284 return Ok(StartPoint::EmptyRepo);
285 }
286
287 let oid = git2::Oid::from_str(raw).map_err(|_| {
291 io::Error::new(
292 io::ErrorKind::InvalidData,
293 format!(
294 "Invalid OID format in {}: '{}'. Run 'ralph --reset-start-commit' to fix.",
295 START_COMMIT_FILE, raw
296 ),
297 )
298 })?;
299
300 repo.find_commit(oid).map_err(|e| {
302 let err_msg = e.message();
303 if err_msg.contains("not found") || err_msg.contains("invalid") {
304 io::Error::new(
305 io::ErrorKind::NotFound,
306 format!("Start commit '{}' no longer exists (history rewritten). Run 'ralph --reset-start-commit' to fix.", raw),
307 )
308 } else {
309 to_io_error(&e)
310 }
311 })?;
312
313 Ok(StartPoint::Commit(oid))
314}
315
316#[derive(Debug, Clone)]
318pub struct ResetStartCommitResult {
319 pub oid: String,
321 pub default_branch: Option<String>,
323 pub fell_back_to_head: bool,
325}
326
327pub fn reset_start_commit() -> io::Result<ResetStartCommitResult> {
344 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
345 let repo_root = repo
346 .workdir()
347 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
348 reset_start_commit_impl(&repo, repo_root)
349}
350
351fn reset_start_commit_impl(
353 repo: &git2::Repository,
354 repo_root: &Path,
355) -> io::Result<ResetStartCommitResult> {
356 let head = repo.head().map_err(|e| {
358 if e.code() == git2::ErrorCode::UnbornBranch {
359 io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
360 } else {
361 to_io_error(&e)
362 }
363 })?;
364 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
365
366 let current_branch = head.shorthand().unwrap_or("HEAD");
368 if current_branch == "main" || current_branch == "master" {
369 let oid = head_commit.id().to_string();
370 write_start_commit_with_oid(repo_root, &oid)?;
371 return Ok(ResetStartCommitResult {
372 oid,
373 default_branch: None,
374 fell_back_to_head: true,
375 });
376 }
377
378 let default_branch = super::branch::get_default_branch_at(repo_root)?;
380
381 let default_ref = format!("refs/heads/{}", default_branch);
383 let default_commit = match repo.find_reference(&default_ref) {
384 Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
385 Err(_) => {
386 let origin_ref = format!("refs/remotes/origin/{}", default_branch);
388 match repo.find_reference(&origin_ref) {
389 Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
390 Err(_) => {
391 return Err(io::Error::new(
392 io::ErrorKind::NotFound,
393 format!(
394 "Default branch '{}' not found locally or in origin. \
395 Make sure the branch exists.",
396 default_branch
397 ),
398 ));
399 }
400 }
401 }
402 };
403
404 let merge_base = repo
406 .merge_base(head_commit.id(), default_commit.id())
407 .map_err(|e| {
408 if e.code() == git2::ErrorCode::NotFound {
409 io::Error::new(
410 io::ErrorKind::NotFound,
411 format!(
412 "No common ancestor between current branch and '{}' (unrelated branches)",
413 default_branch
414 ),
415 )
416 } else {
417 to_io_error(&e)
418 }
419 })?;
420
421 let oid = merge_base.to_string();
422 write_start_commit_with_oid(repo_root, &oid)?;
423
424 Ok(ResetStartCommitResult {
425 oid,
426 default_branch: Some(default_branch),
427 fell_back_to_head: false,
428 })
429}
430
431#[derive(Debug, Clone)]
435pub struct StartCommitSummary {
436 pub start_oid: Option<String>,
438 pub commits_since: usize,
440 pub is_stale: bool,
442}
443
444impl StartCommitSummary {
445 pub fn format_compact(&self) -> String {
447 match &self.start_oid {
448 Some(oid) => {
449 let short_oid = &oid[..8.min(oid.len())];
450 if self.is_stale {
451 format!(
452 "Start: {} (+{} commits, STALE)",
453 short_oid, self.commits_since
454 )
455 } else if self.commits_since > 0 {
456 format!("Start: {} (+{} commits)", short_oid, self.commits_since)
457 } else {
458 format!("Start: {}", short_oid)
459 }
460 }
461 None => "Start: not set".to_string(),
462 }
463 }
464}
465
466pub fn get_start_commit_summary() -> io::Result<StartCommitSummary> {
472 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
473 let repo_root = repo
474 .workdir()
475 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
476 get_start_commit_summary_impl(&repo, repo_root)
477}
478
479fn get_start_commit_summary_impl(
481 repo: &git2::Repository,
482 repo_root: &Path,
483) -> io::Result<StartCommitSummary> {
484 let start_oid = match load_start_point_impl(repo, repo_root)? {
485 StartPoint::Commit(oid) => Some(oid.to_string()),
486 StartPoint::EmptyRepo => None,
487 };
488
489 let (commits_since, is_stale) = if let Some(ref oid) = start_oid {
490 let head_oid = get_current_head_oid_impl(repo)?;
492 let head_commit = repo
493 .find_commit(git2::Oid::from_str(&head_oid).map_err(|_| {
494 io::Error::new(io::ErrorKind::InvalidData, "Invalid HEAD OID format")
495 })?)
496 .map_err(|e| to_io_error(&e))?;
497
498 let start_commit_oid = git2::Oid::from_str(oid)
499 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid start OID format"))?;
500
501 let start_commit = repo
502 .find_commit(start_commit_oid)
503 .map_err(|e| to_io_error(&e))?;
504
505 let mut revwalk = repo.revwalk().map_err(|e| to_io_error(&e))?;
507 revwalk
508 .push(head_commit.id())
509 .map_err(|e| to_io_error(&e))?;
510
511 let mut count = 0;
512 for commit_id in revwalk {
513 let commit_id = commit_id.map_err(|e| to_io_error(&e))?;
514 if commit_id == start_commit.id() {
515 break;
516 }
517 count += 1;
518 if count > 1000 {
519 break;
520 }
521 }
522
523 let is_stale = count > 10;
524 (count, is_stale)
525 } else {
526 (0, false)
527 };
528
529 Ok(StartCommitSummary {
530 start_oid,
531 commits_since,
532 is_stale,
533 })
534}
535
536#[cfg(test)]
537fn has_start_commit() -> bool {
538 load_start_point().is_ok()
539}
540
541fn to_io_error(err: &git2::Error) -> io::Error {
543 io::Error::other(err.to_string())
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn test_start_commit_file_path_defined() {
552 assert_eq!(START_COMMIT_FILE, ".agent/start_commit");
554 }
555
556 #[test]
557 fn test_has_start_commit_returns_bool() {
558 let result = has_start_commit();
560 let _ = result;
563 }
564
565 #[test]
566 fn test_get_current_head_oid_returns_result() {
567 let result = get_current_head_oid();
569 let _ = result;
572 }
573
574 #[test]
575 fn test_load_start_commit_returns_result() {
576 let result = load_start_point();
579 assert!(result.is_ok() || result.is_err());
580 }
581
582 #[test]
583 fn test_reset_start_commit_returns_result() {
584 let result = reset_start_commit();
587 assert!(result.is_ok() || result.is_err());
588 }
589
590 #[test]
591 fn test_save_start_commit_returns_result() {
592 let result = save_start_commit();
595 assert!(result.is_ok() || result.is_err());
596 }
597
598 #[test]
606 fn test_write_start_point_with_workspace_commit() {
607 use crate::workspace::MemoryWorkspace;
608
609 let workspace = MemoryWorkspace::new_test();
610 let oid = git2::Oid::from_str("abcd1234abcd1234abcd1234abcd1234abcd1234").unwrap();
611
612 write_start_point_with_workspace(&workspace, StartPoint::Commit(oid)).unwrap();
613
614 let content = workspace.get_file(".agent/start_commit").unwrap();
615 assert_eq!(content, "abcd1234abcd1234abcd1234abcd1234abcd1234");
616 }
617
618 #[test]
619 fn test_write_start_point_with_workspace_empty_repo() {
620 use crate::workspace::MemoryWorkspace;
621
622 let workspace = MemoryWorkspace::new_test();
623
624 write_start_point_with_workspace(&workspace, StartPoint::EmptyRepo).unwrap();
625
626 let content = workspace.get_file(".agent/start_commit").unwrap();
627 assert_eq!(content, EMPTY_REPO_SENTINEL);
628 }
629}