ralph_workflow/git_helpers/
start_commit.rs1use std::fs;
19use std::io;
20use std::path::PathBuf;
21
22const START_COMMIT_FILE: &str = ".agent/start_commit";
27
28const EMPTY_REPO_SENTINEL: &str = "__EMPTY_REPO__";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum StartPoint {
36 Commit(git2::Oid),
38 EmptyRepo,
40}
41
42pub fn get_current_head_oid() -> io::Result<String> {
53 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
54
55 let head = repo.head().map_err(|e| {
56 if e.code() == git2::ErrorCode::UnbornBranch {
59 io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
60 } else {
61 to_io_error(&e)
62 }
63 })?;
64
65 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
67
68 Ok(head_commit.id().to_string())
69}
70
71fn get_current_start_point() -> io::Result<StartPoint> {
72 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
73 let head = repo.head();
74 let start_point = match head {
75 Ok(head) => {
76 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
77 StartPoint::Commit(head_commit.id())
78 }
79 Err(ref e) if e.code() == git2::ErrorCode::UnbornBranch => StartPoint::EmptyRepo,
80 Err(e) => return Err(to_io_error(&e)),
81 };
82 Ok(start_point)
83}
84
85pub fn save_start_commit() -> io::Result<()> {
98 if load_start_point().is_ok() {
101 return Ok(());
102 }
103
104 write_start_point(get_current_start_point()?)
105}
106
107fn write_start_commit_with_oid(oid: &str) -> io::Result<()> {
108 let path = PathBuf::from(START_COMMIT_FILE);
110 if let Some(parent) = path.parent() {
111 fs::create_dir_all(parent)?;
112 }
113
114 fs::write(START_COMMIT_FILE, oid)?;
116
117 Ok(())
118}
119
120fn write_start_point(start_point: StartPoint) -> io::Result<()> {
121 match start_point {
122 StartPoint::Commit(oid) => write_start_commit_with_oid(&oid.to_string()),
123 StartPoint::EmptyRepo => {
124 let path = PathBuf::from(START_COMMIT_FILE);
126 if let Some(parent) = path.parent() {
127 fs::create_dir_all(parent)?;
128 }
129 fs::write(START_COMMIT_FILE, EMPTY_REPO_SENTINEL)?;
130 Ok(())
131 }
132 }
133}
134
135pub fn load_start_point() -> io::Result<StartPoint> {
146 let content = fs::read_to_string(START_COMMIT_FILE)?;
147
148 let raw = content.trim();
149
150 if raw.is_empty() {
151 return Err(io::Error::new(
152 io::ErrorKind::InvalidData,
153 "Starting commit file is empty. Run 'ralph --reset-start-commit' to fix.",
154 ));
155 }
156
157 if raw == EMPTY_REPO_SENTINEL {
158 return Ok(StartPoint::EmptyRepo);
159 }
160
161 let oid = git2::Oid::from_str(raw).map_err(|_| {
165 io::Error::new(
166 io::ErrorKind::InvalidData,
167 format!(
168 "Invalid OID format in {}: '{}'. Run 'ralph --reset-start-commit' to fix.",
169 START_COMMIT_FILE, raw
170 ),
171 )
172 })?;
173
174 let repo = git2::Repository::discover(".").map_err(|e| {
176 io::Error::new(
177 io::ErrorKind::NotFound,
178 format!("Git repository error: {e}. Run 'ralph --reset-start-commit' to fix."),
179 )
180 })?;
181
182 repo.find_commit(oid).map_err(|e| {
183 let err_msg = e.message();
184 if err_msg.contains("not found") || err_msg.contains("invalid") {
185 io::Error::new(
186 io::ErrorKind::NotFound,
187 format!("Start commit '{}' no longer exists (history rewritten). Run 'ralph --reset-start-commit' to fix.", raw),
188 )
189 } else {
190 to_io_error(&e)
191 }
192 })?;
193
194 Ok(StartPoint::Commit(oid))
195}
196
197#[derive(Debug, Clone)]
199pub struct ResetStartCommitResult {
200 pub oid: String,
202 pub default_branch: Option<String>,
204 pub fell_back_to_head: bool,
206}
207
208pub fn reset_start_commit() -> io::Result<ResetStartCommitResult> {
224 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
225
226 let head = repo.head().map_err(|e| {
228 if e.code() == git2::ErrorCode::UnbornBranch {
229 io::Error::new(io::ErrorKind::NotFound, "No commits yet (unborn branch)")
230 } else {
231 to_io_error(&e)
232 }
233 })?;
234 let head_commit = head.peel_to_commit().map_err(|e| to_io_error(&e))?;
235
236 let current_branch = head.shorthand().unwrap_or("HEAD");
238 if current_branch == "main" || current_branch == "master" {
239 let oid = head_commit.id().to_string();
240 write_start_commit_with_oid(&oid)?;
241 return Ok(ResetStartCommitResult {
242 oid,
243 default_branch: None,
244 fell_back_to_head: true,
245 });
246 }
247
248 let default_branch = super::branch::get_default_branch()?;
250
251 let default_ref = format!("refs/heads/{}", default_branch);
253 let default_commit = match repo.find_reference(&default_ref) {
254 Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
255 Err(_) => {
256 let origin_ref = format!("refs/remotes/origin/{}", default_branch);
258 match repo.find_reference(&origin_ref) {
259 Ok(reference) => reference.peel_to_commit().map_err(|e| to_io_error(&e))?,
260 Err(_) => {
261 return Err(io::Error::new(
262 io::ErrorKind::NotFound,
263 format!(
264 "Default branch '{}' not found locally or in origin. \
265 Make sure the branch exists.",
266 default_branch
267 ),
268 ));
269 }
270 }
271 }
272 };
273
274 let merge_base = repo
276 .merge_base(head_commit.id(), default_commit.id())
277 .map_err(|e| {
278 if e.code() == git2::ErrorCode::NotFound {
279 io::Error::new(
280 io::ErrorKind::NotFound,
281 format!(
282 "No common ancestor between current branch and '{}' (unrelated branches)",
283 default_branch
284 ),
285 )
286 } else {
287 to_io_error(&e)
288 }
289 })?;
290
291 let oid = merge_base.to_string();
292 write_start_commit_with_oid(&oid)?;
293
294 Ok(ResetStartCommitResult {
295 oid,
296 default_branch: Some(default_branch),
297 fell_back_to_head: false,
298 })
299}
300
301#[derive(Debug, Clone)]
305pub struct StartCommitSummary {
306 pub start_oid: Option<String>,
308 pub commits_since: usize,
310 pub is_stale: bool,
312}
313
314impl StartCommitSummary {
315 pub fn format_compact(&self) -> String {
317 match &self.start_oid {
318 Some(oid) => {
319 let short_oid = &oid[..8.min(oid.len())];
320 if self.is_stale {
321 format!(
322 "Start: {} (+{} commits, STALE)",
323 short_oid, self.commits_since
324 )
325 } else if self.commits_since > 0 {
326 format!("Start: {} (+{} commits)", short_oid, self.commits_since)
327 } else {
328 format!("Start: {}", short_oid)
329 }
330 }
331 None => "Start: not set".to_string(),
332 }
333 }
334}
335
336pub fn get_start_commit_summary() -> io::Result<StartCommitSummary> {
341 let start_oid = match load_start_point()? {
342 StartPoint::Commit(oid) => Some(oid.to_string()),
343 StartPoint::EmptyRepo => None,
344 };
345
346 let (commits_since, is_stale) = if let Some(ref oid) = start_oid {
347 let repo = git2::Repository::discover(".").map_err(|e| to_io_error(&e))?;
348
349 let head_oid = get_current_head_oid()?;
351 let head_commit = repo
352 .find_commit(git2::Oid::from_str(&head_oid).map_err(|_| {
353 io::Error::new(io::ErrorKind::InvalidData, "Invalid HEAD OID format")
354 })?)
355 .map_err(|e| to_io_error(&e))?;
356
357 let start_commit_oid = git2::Oid::from_str(oid)
358 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid start OID format"))?;
359
360 let start_commit = repo
361 .find_commit(start_commit_oid)
362 .map_err(|e| to_io_error(&e))?;
363
364 let mut revwalk = repo.revwalk().map_err(|e| to_io_error(&e))?;
366 revwalk
367 .push(head_commit.id())
368 .map_err(|e| to_io_error(&e))?;
369
370 let mut count = 0;
371 for commit_id in revwalk {
372 let commit_id = commit_id.map_err(|e| to_io_error(&e))?;
373 if commit_id == start_commit.id() {
374 break;
375 }
376 count += 1;
377 if count > 1000 {
378 break;
379 }
380 }
381
382 let is_stale = count > 10;
383 (count, is_stale)
384 } else {
385 (0, false)
386 };
387
388 Ok(StartCommitSummary {
389 start_oid,
390 commits_since,
391 is_stale,
392 })
393}
394
395#[cfg(test)]
396fn has_start_commit() -> bool {
397 load_start_point().is_ok()
398}
399
400fn to_io_error(err: &git2::Error) -> io::Error {
402 io::Error::other(err.to_string())
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn test_start_commit_file_path_defined() {
411 assert_eq!(START_COMMIT_FILE, ".agent/start_commit");
413 }
414
415 #[test]
416 fn test_has_start_commit_returns_bool() {
417 let result = has_start_commit();
419 let _ = result;
422 }
423
424 #[test]
425 fn test_get_current_head_oid_returns_result() {
426 let result = get_current_head_oid();
428 let _ = result;
431 }
432
433 #[test]
434 fn test_load_start_commit_returns_result() {
435 let result = load_start_point();
438 assert!(result.is_ok() || result.is_err());
439 }
440
441 #[test]
442 fn test_reset_start_commit_returns_result() {
443 let result = reset_start_commit();
446 assert!(result.is_ok() || result.is_err());
447 }
448
449 #[test]
450 fn test_save_start_commit_returns_result() {
451 let result = save_start_commit();
454 assert!(result.is_ok() || result.is_err());
455 }
456
457 }