ralph_workflow/git_helpers/
start_commit.rs1use std::io;
19use std::path::Path;
20
21use crate::workspace::{Workspace, WorkspaceFs};
22
23const START_COMMIT_FILE: &str = ".agent/start_commit";
28
29const EMPTY_REPO_SENTINEL: &str = "__EMPTY_REPO__";
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum StartPoint {
37 Commit(git2::Oid),
39 EmptyRepo,
41}
42
43pub fn get_current_head_oid() -> io::Result<String> {
56 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
57 get_current_head_oid_impl(&repo)
58}
59
60fn get_current_head_oid_impl(repo: &git2::Repository) -> io::Result<String> {
62 let head = repo.head().map_err(|e| {
63 if e.code() == git2::ErrorCode::UnbornBranch {
66 io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
67 } else {
68 to_io_error(&e)
69 }
70 })?;
71
72 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
74
75 Ok(head_commit.id().to_string())
76}
77
78fn get_current_start_point(repo: &git2::Repository) -> io::Result<StartPoint> {
79 let head = repo.head();
80 let start_point = match head {
81 Ok(head) => {
82 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
83 StartPoint::Commit(head_commit.id())
84 }
85 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => StartPoint::EmptyRepo,
86 Err(e) => return Err(to_io_error(&e)),
87 };
88 Ok(start_point)
89}
90
91pub fn save_start_commit() -> io::Result<()> {
105 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
106 let repo_root = repo
107 .workdir()
108 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
109 save_start_commit_impl(&repo, repo_root)
110}
111
112fn save_start_commit_impl(repo: &git2::Repository, repo_root: &Path) -> io::Result<()> {
114 if load_start_point_impl(repo, repo_root).is_ok() {
117 return Ok(());
118 }
119
120 write_start_point(repo_root, get_current_start_point(repo)?)
121}
122
123fn write_start_commit_with_oid(repo_root: &Path, oid: &str) -> io::Result<()> {
124 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
125 workspace.write(Path::new(START_COMMIT_FILE), oid)
126}
127
128fn write_start_point(repo_root: &Path, start_point: StartPoint) -> io::Result<()> {
129 let content = match start_point {
130 StartPoint::Commit(oid) => oid.to_string(),
131 StartPoint::EmptyRepo => EMPTY_REPO_SENTINEL.to_string(),
132 };
133 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
134 workspace.write(Path::new(START_COMMIT_FILE), &content)
135}
136
137fn write_start_point_with_workspace(
142 workspace: &dyn Workspace,
143 start_point: StartPoint,
144) -> io::Result<()> {
145 let path = Path::new(START_COMMIT_FILE);
146 let content = match start_point {
147 StartPoint::Commit(oid) => oid.to_string(),
148 StartPoint::EmptyRepo => EMPTY_REPO_SENTINEL.to_string(),
149 };
150 workspace.write(path, &content)
151}
152
153pub fn load_start_point_with_workspace(
161 workspace: &dyn Workspace,
162 repo: &git2::Repository,
163) -> io::Result<StartPoint> {
164 let path = Path::new(START_COMMIT_FILE);
165 let content = workspace.read(path)?;
166 let raw = content.trim();
167
168 if raw.is_empty() {
169 return Err(io::Error::new(
170 io::ErrorKind::InvalidData,
171 "Starting commit file is empty. Run 'ralph --reset-start-commit' to fix.",
172 ));
173 }
174
175 if raw == EMPTY_REPO_SENTINEL {
176 return Ok(StartPoint::EmptyRepo);
177 }
178
179 let oid = git2::Oid::from_str(raw).map_err(|_| {
181 io::Error::new(
182 io::ErrorKind::InvalidData,
183 format!(
184 "Invalid OID format in {}: '{}'. Run 'ralph --reset-start-commit' to fix.",
185 START_COMMIT_FILE, raw
186 ),
187 )
188 })?;
189
190 repo.find_commit(oid).map_err(|e| {
192 let err_msg = e.message();
193 if err_msg.contains("not found") || err_msg.contains("invalid") {
194 io::Error::new(
195 io::ErrorKind::NotFound,
196 format!(
197 "Start commit '{}' no longer exists (history rewritten). \
198 Run 'ralph --reset-start-commit' to fix.",
199 raw
200 ),
201 )
202 } else {
203 to_io_error(&e)
204 }
205 })?;
206
207 Ok(StartPoint::Commit(oid))
208}
209
210pub fn save_start_commit_with_workspace(
215 workspace: &dyn Workspace,
216 repo: &git2::Repository,
217) -> io::Result<()> {
218 if load_start_point_with_workspace(workspace, repo).is_ok() {
220 return Ok(());
221 }
222
223 write_start_point_with_workspace(workspace, get_current_start_point(repo)?)
224}
225
226pub fn load_start_point() -> io::Result<StartPoint> {
238 let repo = git2::Repository::discover(".").map_err(|e| {
239 io::Error::new(
240 io::ErrorKind::NotFound,
241 format!("Git repository error: {e}. Run 'ralph --reset-start-commit' to fix."),
242 )
243 })?;
244 let repo_root = repo
245 .workdir()
246 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
247 load_start_point_impl(&repo, repo_root)
248}
249
250fn load_start_point_impl(repo: &git2::Repository, repo_root: &Path) -> io::Result<StartPoint> {
252 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
253 load_start_point_with_workspace(&workspace, repo)
254}
255
256#[derive(Debug, Clone)]
258pub struct ResetStartCommitResult {
259 pub oid: String,
261 pub default_branch: Option<String>,
263 pub fell_back_to_head: bool,
265}
266
267pub fn reset_start_commit() -> io::Result<ResetStartCommitResult> {
284 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
285 let repo_root = repo
286 .workdir()
287 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
288 reset_start_commit_impl(&repo, repo_root)
289}
290
291fn reset_start_commit_impl(
293 repo: &git2::Repository,
294 repo_root: &Path,
295) -> io::Result<ResetStartCommitResult> {
296 let head = repo.head().map_err(|e| {
298 if e.code() == git2::ErrorCode::UnbornBranch {
299 io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
300 } else {
301 to_io_error(&e)
302 }
303 })?;
304 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
305
306 let current_branch = head.shorthand().unwrap_or("HEAD");
308 if current_branch == "main" || current_branch == "master" {
309 let oid = head_commit.id().to_string();
310 write_start_commit_with_oid(repo_root, &oid)?;
311 return Ok(ResetStartCommitResult {
312 oid,
313 default_branch: None,
314 fell_back_to_head: true,
315 });
316 }
317
318 let default_branch = super::branch::get_default_branch_at(repo_root)?;
320
321 let default_ref = format!("refs/heads/{}", default_branch);
323 let default_commit = match repo.find_reference(&default_ref) {
324 Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
325 Err(_) => {
326 let origin_ref = format!("refs/remotes/origin/{}", default_branch);
328 match repo.find_reference(&origin_ref) {
329 Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
330 Err(_) => {
331 return Err(io::Error::new(
332 io::ErrorKind::NotFound,
333 format!(
334 "Default branch '{}' not found locally or in origin. \
335 Make sure the branch exists.",
336 default_branch
337 ),
338 ));
339 }
340 }
341 }
342 };
343
344 let merge_base = repo
346 .merge_base(head_commit.id(), default_commit.id())
347 .map_err(|e| {
348 if e.code() == git2::ErrorCode::NotFound {
349 io::Error::new(
350 io::ErrorKind::NotFound,
351 format!(
352 "No common ancestor between current branch and '{}' (unrelated branches)",
353 default_branch
354 ),
355 )
356 } else {
357 to_io_error(&e)
358 }
359 })?;
360
361 let oid = merge_base.to_string();
362 write_start_commit_with_oid(repo_root, &oid)?;
363
364 Ok(ResetStartCommitResult {
365 oid,
366 default_branch: Some(default_branch),
367 fell_back_to_head: false,
368 })
369}
370
371#[derive(Debug, Clone)]
375pub struct StartCommitSummary {
376 pub start_oid: Option<String>,
378 pub commits_since: usize,
380 pub is_stale: bool,
382}
383
384impl StartCommitSummary {
385 pub fn format_compact(&self) -> String {
387 match &self.start_oid {
388 Some(oid) => {
389 let short_oid = &oid[..8.min(oid.len())];
390 if self.is_stale {
391 format!(
392 "Start: {} (+{} commits, STALE)",
393 short_oid, self.commits_since
394 )
395 } else if self.commits_since > 0 {
396 format!("Start: {} (+{} commits)", short_oid, self.commits_since)
397 } else {
398 format!("Start: {}", short_oid)
399 }
400 }
401 None => "Start: not set".to_string(),
402 }
403 }
404}
405
406pub fn get_start_commit_summary() -> io::Result<StartCommitSummary> {
412 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
413 let repo_root = repo
414 .workdir()
415 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
416 get_start_commit_summary_impl(&repo, repo_root)
417}
418
419fn get_start_commit_summary_impl(
421 repo: &git2::Repository,
422 repo_root: &Path,
423) -> io::Result<StartCommitSummary> {
424 let start_oid = match load_start_point_impl(repo, repo_root)? {
425 StartPoint::Commit(oid) => Some(oid.to_string()),
426 StartPoint::EmptyRepo => None,
427 };
428
429 let (commits_since, is_stale) = if let Some(ref oid) = start_oid {
430 let head_oid = get_current_head_oid_impl(repo)?;
432 let head_commit = repo
433 .find_commit(git2::Oid::from_str(&head_oid).map_err(|_| {
434 io::Error::new(io::ErrorKind::InvalidData, "Invalid HEAD OID format")
435 })?)
436 .map_err(|e| to_io_error(&e))?;
437
438 let start_commit_oid = git2::Oid::from_str(oid)
439 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid start OID format"))?;
440
441 let start_commit = repo
442 .find_commit(start_commit_oid)
443 .map_err(|e| to_io_error(&e))?;
444
445 let mut revwalk = repo.revwalk().map_err(|e| to_io_error(&e))?;
447 revwalk
448 .push(head_commit.id())
449 .map_err(|e| to_io_error(&e))?;
450
451 let mut count = 0;
452 for commit_id in revwalk {
453 let commit_id = commit_id.map_err(|e| to_io_error(&e))?;
454 if commit_id == start_commit.id() {
455 break;
456 }
457 count += 1;
458 if count > 1000 {
459 break;
460 }
461 }
462
463 let is_stale = count > 10;
464 (count, is_stale)
465 } else {
466 (0, false)
467 };
468
469 Ok(StartCommitSummary {
470 start_oid,
471 commits_since,
472 is_stale,
473 })
474}
475
476#[cfg(test)]
477fn has_start_commit() -> bool {
478 load_start_point().is_ok()
479}
480
481fn to_io_error(err: &git2::Error) -> io::Error {
483 io::Error::other(err.to_string())
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 #[test]
491 fn test_start_commit_file_path_defined() {
492 assert_eq!(START_COMMIT_FILE, ".agent/start_commit");
494 }
495
496 #[test]
497 fn test_has_start_commit_returns_bool() {
498 let result = has_start_commit();
500 let _ = result;
503 }
504
505 #[test]
506 fn test_get_current_head_oid_returns_result() {
507 let result = get_current_head_oid();
509 let _ = result;
512 }
513
514 #[test]
515 fn test_load_start_commit_returns_result() {
516 let result = load_start_point();
519 assert!(result.is_ok() || result.is_err());
520 }
521
522 #[test]
523 fn test_reset_start_commit_returns_result() {
524 let result = reset_start_commit();
527 assert!(result.is_ok() || result.is_err());
528 }
529
530 #[test]
531 fn test_save_start_commit_returns_result() {
532 let result = save_start_commit();
535 assert!(result.is_ok() || result.is_err());
536 }
537
538 }