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
60pub fn get_current_head_oid_at(repo_root: &Path) -> io::Result<String> {
69 let repo = git2::Repository::open(repo_root).map_err(|e| to_io_error(&e))?;
70 get_current_head_oid_impl(&repo)
71}
72
73fn get_current_head_oid_impl(repo: &git2::Repository) -> io::Result<String> {
75 let head = repo.head().map_err(|e| {
76 if e.code() == git2::ErrorCode::UnbornBranch {
79 io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
80 } else {
81 to_io_error(&e)
82 }
83 })?;
84
85 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
87
88 Ok(head_commit.id().to_string())
89}
90
91fn get_current_start_point(repo: &git2::Repository) -> io::Result<StartPoint> {
92 let head = repo.head();
93 let start_point = match head {
94 Ok(head) => {
95 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
96 StartPoint::Commit(head_commit.id())
97 }
98 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => StartPoint::EmptyRepo,
99 Err(e) => return Err(to_io_error(&e)),
100 };
101 Ok(start_point)
102}
103
104pub fn save_start_commit() -> io::Result<()> {
118 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
119 let repo_root = repo
120 .workdir()
121 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
122 save_start_commit_impl(&repo, repo_root)
123}
124
125fn save_start_commit_impl(repo: &git2::Repository, repo_root: &Path) -> io::Result<()> {
127 if load_start_point_impl(repo, repo_root).is_ok() {
130 return Ok(());
131 }
132
133 write_start_point(repo_root, get_current_start_point(repo)?)
134}
135
136fn write_start_commit_with_oid(repo_root: &Path, oid: &str) -> io::Result<()> {
137 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
138 workspace.write(Path::new(START_COMMIT_FILE), oid)
139}
140
141fn write_start_point(repo_root: &Path, start_point: StartPoint) -> io::Result<()> {
142 let content = match start_point {
143 StartPoint::Commit(oid) => oid.to_string(),
144 StartPoint::EmptyRepo => EMPTY_REPO_SENTINEL.to_string(),
145 };
146 let workspace = WorkspaceFs::new(repo_root.to_path_buf());
147 workspace.write(Path::new(START_COMMIT_FILE), &content)
148}
149
150fn write_start_point_with_workspace(
155 workspace: &dyn Workspace,
156 start_point: StartPoint,
157) -> io::Result<()> {
158 let path = Path::new(START_COMMIT_FILE);
159 let content = match start_point {
160 StartPoint::Commit(oid) => oid.to_string(),
161 StartPoint::EmptyRepo => EMPTY_REPO_SENTINEL.to_string(),
162 };
163 workspace.write(path, &content)
164}
165
166pub fn load_start_point_with_workspace(
178 workspace: &dyn Workspace,
179 repo: &git2::Repository,
180) -> io::Result<StartPoint> {
181 let path = Path::new(START_COMMIT_FILE);
182 let content = workspace.read(path)?;
183 let raw = content.trim();
184
185 if raw.is_empty() {
186 return Err(io::Error::new(
187 io::ErrorKind::InvalidData,
188 "Starting commit file is empty. Run 'ralph --reset-start-commit' to fix.",
189 ));
190 }
191
192 if raw == EMPTY_REPO_SENTINEL {
193 return Ok(StartPoint::EmptyRepo);
194 }
195
196 let oid = git2::Oid::from_str(raw).map_err(|_| {
198 io::Error::new(
199 io::ErrorKind::InvalidData,
200 format!(
201 "Invalid OID format in {START_COMMIT_FILE}: '{raw}'. Run 'ralph --reset-start-commit' to fix."
202 ),
203 )
204 })?;
205
206 repo.find_commit(oid).map_err(|e| {
208 let err_msg = e.message();
209 if err_msg.contains("not found") || err_msg.contains("invalid") {
210 io::Error::new(
211 io::ErrorKind::NotFound,
212 format!(
213 "Start commit '{raw}' no longer exists (history rewritten). \
214 Run 'ralph --reset-start-commit' to fix."
215 ),
216 )
217 } else {
218 to_io_error(&e)
219 }
220 })?;
221
222 Ok(StartPoint::Commit(oid))
223}
224
225pub 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 workspace = WorkspaceFs::new(repo_root.to_path_buf());
272 load_start_point_with_workspace(&workspace, repo)
273}
274
275#[derive(Debug, Clone)]
277pub struct ResetStartCommitResult {
278 pub oid: String,
280 pub default_branch: Option<String>,
282 pub fell_back_to_head: bool,
284}
285
286pub fn reset_start_commit() -> io::Result<ResetStartCommitResult> {
303 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
304 let repo_root = repo
305 .workdir()
306 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
307 reset_start_commit_impl(&repo, repo_root)
308}
309
310fn reset_start_commit_impl(
312 repo: &git2::Repository,
313 repo_root: &Path,
314) -> io::Result<ResetStartCommitResult> {
315 let head = repo.head().map_err(|e| {
317 if e.code() == git2::ErrorCode::UnbornBranch {
318 io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
319 } else {
320 to_io_error(&e)
321 }
322 })?;
323 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
324
325 let current_branch = head.shorthand().unwrap_or("HEAD");
327 if current_branch == "main" || current_branch == "master" {
328 let oid = head_commit.id().to_string();
329 write_start_commit_with_oid(repo_root, &oid)?;
330 return Ok(ResetStartCommitResult {
331 oid,
332 default_branch: None,
333 fell_back_to_head: true,
334 });
335 }
336
337 let default_branch = super::branch::get_default_branch_at(repo_root)?;
339
340 let default_ref = format!("refs/heads/{default_branch}");
342 let default_commit = if let Ok(reference) = repo.find_reference(&default_ref) {
343 reference.peel_to_commit().map_err(|e| to_io_error(&e))?
344 } else {
345 let origin_ref = format!("refs/remotes/origin/{default_branch}");
347 match repo.find_reference(&origin_ref) {
348 Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
349 Err(_) => {
350 return Err(io::Error::new(
351 io::ErrorKind::NotFound,
352 format!(
353 "Default branch '{default_branch}' not found locally or in origin. \
354 Make sure the branch exists."
355 ),
356 ));
357 }
358 }
359 };
360
361 let merge_base = repo
363 .merge_base(head_commit.id(), default_commit.id())
364 .map_err(|e| {
365 if e.code() == git2::ErrorCode::NotFound {
366 io::Error::new(
367 io::ErrorKind::NotFound,
368 format!(
369 "No common ancestor between current branch and '{default_branch}' (unrelated branches)"
370 ),
371 )
372 } else {
373 to_io_error(&e)
374 }
375 })?;
376
377 let oid = merge_base.to_string();
378 write_start_commit_with_oid(repo_root, &oid)?;
379
380 Ok(ResetStartCommitResult {
381 oid,
382 default_branch: Some(default_branch),
383 fell_back_to_head: false,
384 })
385}
386
387#[derive(Debug, Clone)]
391pub struct StartCommitSummary {
392 pub start_oid: Option<String>,
394 pub commits_since: usize,
396 pub is_stale: bool,
398}
399
400impl StartCommitSummary {
401 pub fn format_compact(&self) -> String {
403 self.start_oid.as_ref().map_or_else(
404 || "Start: not set".to_string(),
405 |oid| {
406 let short_oid = &oid[..8.min(oid.len())];
407 if self.is_stale {
408 format!(
409 "Start: {} (+{} commits, STALE)",
410 short_oid, self.commits_since
411 )
412 } else if self.commits_since > 0 {
413 format!("Start: {} (+{} commits)", short_oid, self.commits_since)
414 } else {
415 format!("Start: {short_oid}")
416 }
417 },
418 )
419 }
420}
421
422pub fn get_start_commit_summary() -> io::Result<StartCommitSummary> {
432 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
433 let repo_root = repo
434 .workdir()
435 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "No workdir for repository"))?;
436 get_start_commit_summary_impl(&repo, repo_root)
437}
438
439fn get_start_commit_summary_impl(
441 repo: &git2::Repository,
442 repo_root: &Path,
443) -> io::Result<StartCommitSummary> {
444 let start_oid = match load_start_point_impl(repo, repo_root)? {
445 StartPoint::Commit(oid) => Some(oid.to_string()),
446 StartPoint::EmptyRepo => None,
447 };
448
449 let (commits_since, is_stale) = if let Some(ref oid) = start_oid {
450 let head_oid = get_current_head_oid_impl(repo)?;
452 let head_commit = repo
453 .find_commit(git2::Oid::from_str(&head_oid).map_err(|_| {
454 io::Error::new(io::ErrorKind::InvalidData, "Invalid HEAD OID format")
455 })?)
456 .map_err(|e| to_io_error(&e))?;
457
458 let start_commit_oid = git2::Oid::from_str(oid)
459 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid start OID format"))?;
460
461 let start_commit = repo
462 .find_commit(start_commit_oid)
463 .map_err(|e| to_io_error(&e))?;
464
465 let mut revwalk = repo.revwalk().map_err(|e| to_io_error(&e))?;
467 revwalk
468 .push(head_commit.id())
469 .map_err(|e| to_io_error(&e))?;
470
471 let mut count = 0;
472 for commit_id in revwalk {
473 let commit_id = commit_id.map_err(|e| to_io_error(&e))?;
474 if commit_id == start_commit.id() {
475 break;
476 }
477 count += 1;
478 if count > 1000 {
479 break;
480 }
481 }
482
483 let is_stale = count > 10;
484 (count, is_stale)
485 } else {
486 (0, false)
487 };
488
489 Ok(StartCommitSummary {
490 start_oid,
491 commits_since,
492 is_stale,
493 })
494}
495
496#[cfg(test)]
497fn has_start_commit() -> bool {
498 load_start_point().is_ok()
499}
500
501fn to_io_error(err: &git2::Error) -> io::Error {
503 io::Error::other(err.to_string())
504}
505
506#[cfg(test)]
507mod tests;