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"))]
161pub fn load_start_point_with_workspace(
162 workspace: &dyn Workspace,
163 repo: &git2::Repository,
164) -> io::Result<StartPoint> {
165 let path = Path::new(START_COMMIT_FILE);
166 let content = workspace.read(path)?;
167 let raw = content.trim();
168
169 if raw.is_empty() {
170 return Err(io::Error::new(
171 io::ErrorKind::InvalidData,
172 "Starting commit file is empty. Run 'ralph --reset-start-commit' to fix.",
173 ));
174 }
175
176 if raw == EMPTY_REPO_SENTINEL {
177 return Ok(StartPoint::EmptyRepo);
178 }
179
180 let oid = git2::Oid::from_str(raw).map_err(|_| {
182 io::Error::new(
183 io::ErrorKind::InvalidData,
184 format!(
185 "Invalid OID format in {}: '{}'. Run 'ralph --reset-start-commit' to fix.",
186 START_COMMIT_FILE, raw
187 ),
188 )
189 })?;
190
191 repo.find_commit(oid).map_err(|e| {
193 let err_msg = e.message();
194 if err_msg.contains("not found") || err_msg.contains("invalid") {
195 io::Error::new(
196 io::ErrorKind::NotFound,
197 format!(
198 "Start commit '{}' no longer exists (history rewritten). \
199 Run 'ralph --reset-start-commit' to fix.",
200 raw
201 ),
202 )
203 } else {
204 to_io_error(&e)
205 }
206 })?;
207
208 Ok(StartPoint::Commit(oid))
209}
210
211pub fn load_start_point() -> io::Result<StartPoint> {
223 let repo = git2::Repository::discover(".").map_err(|e| {
224 io::Error::new(
225 io::ErrorKind::NotFound,
226 format!("Git repository error: {e}. Run 'ralph --reset-start-commit' to fix."),
227 )
228 })?;
229 let repo_root = repo
230 .workdir()
231 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
232 load_start_point_impl(&repo, repo_root)
233}
234
235fn load_start_point_impl(repo: &git2::Repository, repo_root: &Path) -> io::Result<StartPoint> {
237 let path = repo_root.join(START_COMMIT_FILE);
238 let content = fs::read_to_string(&path)?;
239
240 let raw = content.trim();
241
242 if raw.is_empty() {
243 return Err(io::Error::new(
244 io::ErrorKind::InvalidData,
245 "Starting commit file is empty. Run 'ralph --reset-start-commit' to fix.",
246 ));
247 }
248
249 if raw == EMPTY_REPO_SENTINEL {
250 return Ok(StartPoint::EmptyRepo);
251 }
252
253 let oid = git2::Oid::from_str(raw).map_err(|_| {
257 io::Error::new(
258 io::ErrorKind::InvalidData,
259 format!(
260 "Invalid OID format in {}: '{}'. Run 'ralph --reset-start-commit' to fix.",
261 START_COMMIT_FILE, raw
262 ),
263 )
264 })?;
265
266 repo.find_commit(oid).map_err(|e| {
268 let err_msg = e.message();
269 if err_msg.contains("not found") || err_msg.contains("invalid") {
270 io::Error::new(
271 io::ErrorKind::NotFound,
272 format!("Start commit '{}' no longer exists (history rewritten). Run 'ralph --reset-start-commit' to fix.", raw),
273 )
274 } else {
275 to_io_error(&e)
276 }
277 })?;
278
279 Ok(StartPoint::Commit(oid))
280}
281
282#[derive(Debug, Clone)]
284pub struct ResetStartCommitResult {
285 pub oid: String,
287 pub default_branch: Option<String>,
289 pub fell_back_to_head: bool,
291}
292
293pub fn reset_start_commit() -> io::Result<ResetStartCommitResult> {
310 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
311 let repo_root = repo
312 .workdir()
313 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
314 reset_start_commit_impl(&repo, repo_root)
315}
316
317fn reset_start_commit_impl(
319 repo: &git2::Repository,
320 repo_root: &Path,
321) -> io::Result<ResetStartCommitResult> {
322 let head = repo.head().map_err(|e| {
324 if e.code() == git2::ErrorCode::UnbornBranch {
325 io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
326 } else {
327 to_io_error(&e)
328 }
329 })?;
330 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
331
332 let current_branch = head.shorthand().unwrap_or("HEAD");
334 if current_branch == "main" || current_branch == "master" {
335 let oid = head_commit.id().to_string();
336 write_start_commit_with_oid(repo_root, &oid)?;
337 return Ok(ResetStartCommitResult {
338 oid,
339 default_branch: None,
340 fell_back_to_head: true,
341 });
342 }
343
344 let default_branch = super::branch::get_default_branch_at(repo_root)?;
346
347 let default_ref = format!("refs/heads/{}", default_branch);
349 let default_commit = match repo.find_reference(&default_ref) {
350 Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
351 Err(_) => {
352 let origin_ref = format!("refs/remotes/origin/{}", default_branch);
354 match repo.find_reference(&origin_ref) {
355 Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
356 Err(_) => {
357 return Err(io::Error::new(
358 io::ErrorKind::NotFound,
359 format!(
360 "Default branch '{}' not found locally or in origin. \
361 Make sure the branch exists.",
362 default_branch
363 ),
364 ));
365 }
366 }
367 }
368 };
369
370 let merge_base = repo
372 .merge_base(head_commit.id(), default_commit.id())
373 .map_err(|e| {
374 if e.code() == git2::ErrorCode::NotFound {
375 io::Error::new(
376 io::ErrorKind::NotFound,
377 format!(
378 "No common ancestor between current branch and '{}' (unrelated branches)",
379 default_branch
380 ),
381 )
382 } else {
383 to_io_error(&e)
384 }
385 })?;
386
387 let oid = merge_base.to_string();
388 write_start_commit_with_oid(repo_root, &oid)?;
389
390 Ok(ResetStartCommitResult {
391 oid,
392 default_branch: Some(default_branch),
393 fell_back_to_head: false,
394 })
395}
396
397#[derive(Debug, Clone)]
401pub struct StartCommitSummary {
402 pub start_oid: Option<String>,
404 pub commits_since: usize,
406 pub is_stale: bool,
408}
409
410impl StartCommitSummary {
411 pub fn format_compact(&self) -> String {
413 match &self.start_oid {
414 Some(oid) => {
415 let short_oid = &oid[..8.min(oid.len())];
416 if self.is_stale {
417 format!(
418 "Start: {} (+{} commits, STALE)",
419 short_oid, self.commits_since
420 )
421 } else if self.commits_since > 0 {
422 format!("Start: {} (+{} commits)", short_oid, self.commits_since)
423 } else {
424 format!("Start: {}", short_oid)
425 }
426 }
427 None => "Start: not set".to_string(),
428 }
429 }
430}
431
432pub fn get_start_commit_summary() -> io::Result<StartCommitSummary> {
438 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
439 let repo_root = repo
440 .workdir()
441 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
442 get_start_commit_summary_impl(&repo, repo_root)
443}
444
445fn get_start_commit_summary_impl(
447 repo: &git2::Repository,
448 repo_root: &Path,
449) -> io::Result<StartCommitSummary> {
450 let start_oid = match load_start_point_impl(repo, repo_root)? {
451 StartPoint::Commit(oid) => Some(oid.to_string()),
452 StartPoint::EmptyRepo => None,
453 };
454
455 let (commits_since, is_stale) = if let Some(ref oid) = start_oid {
456 let head_oid = get_current_head_oid_impl(repo)?;
458 let head_commit = repo
459 .find_commit(git2::Oid::from_str(&head_oid).map_err(|_| {
460 io::Error::new(io::ErrorKind::InvalidData, "Invalid HEAD OID format")
461 })?)
462 .map_err(|e| to_io_error(&e))?;
463
464 let start_commit_oid = git2::Oid::from_str(oid)
465 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid start OID format"))?;
466
467 let start_commit = repo
468 .find_commit(start_commit_oid)
469 .map_err(|e| to_io_error(&e))?;
470
471 let mut revwalk = repo.revwalk().map_err(|e| to_io_error(&e))?;
473 revwalk
474 .push(head_commit.id())
475 .map_err(|e| to_io_error(&e))?;
476
477 let mut count = 0;
478 for commit_id in revwalk {
479 let commit_id = commit_id.map_err(|e| to_io_error(&e))?;
480 if commit_id == start_commit.id() {
481 break;
482 }
483 count += 1;
484 if count > 1000 {
485 break;
486 }
487 }
488
489 let is_stale = count > 10;
490 (count, is_stale)
491 } else {
492 (0, false)
493 };
494
495 Ok(StartCommitSummary {
496 start_oid,
497 commits_since,
498 is_stale,
499 })
500}
501
502#[cfg(test)]
503fn has_start_commit() -> bool {
504 load_start_point().is_ok()
505}
506
507fn to_io_error(err: &git2::Error) -> io::Error {
509 io::Error::other(err.to_string())
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 #[test]
517 fn test_start_commit_file_path_defined() {
518 assert_eq!(START_COMMIT_FILE, ".agent/start_commit");
520 }
521
522 #[test]
523 fn test_has_start_commit_returns_bool() {
524 let result = has_start_commit();
526 let _ = result;
529 }
530
531 #[test]
532 fn test_get_current_head_oid_returns_result() {
533 let result = get_current_head_oid();
535 let _ = result;
538 }
539
540 #[test]
541 fn test_load_start_commit_returns_result() {
542 let result = load_start_point();
545 assert!(result.is_ok() || result.is_err());
546 }
547
548 #[test]
549 fn test_reset_start_commit_returns_result() {
550 let result = reset_start_commit();
553 assert!(result.is_ok() || result.is_err());
554 }
555
556 #[test]
557 fn test_save_start_commit_returns_result() {
558 let result = save_start_commit();
561 assert!(result.is_ok() || result.is_err());
562 }
563
564 }