ralph-agent-loop 0.4.0

A Rust CLI for managing AI agent loops with a structured JSON task queue
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! Git operations for post-run supervision.
//!
//! Responsibilities:
//! - Finalize git state: commit changes and push if configured.
//! - Validate LFS configuration and warn about issues.
//! - Handle upstream push when ahead.
//!
//! Not handled here:
//! - Queue file operations (see queue_ops.rs).
//! - CI gate execution (see ci.rs).
//!
//! Invariants/assumptions:
//! - Git repo is initialized and accessible.
//! - LFS validation respects the strict flag for error vs warn behavior.

use super::PushPolicy;
use crate::contracts::GitPublishMode;
use crate::git;
use crate::git::GitError;
use crate::outpututil;
use anyhow::{Result, anyhow, bail};
use std::path::Path;

/// Handles the final git commit and push if enabled, and verifies the repo is clean.
pub(crate) fn finalize_git_state(
    resolved: &crate::config::Resolved,
    task_id: &str,
    task_title: &str,
    git_publish_mode: GitPublishMode,
    push_policy: PushPolicy,
) -> Result<()> {
    if git_publish_mode != GitPublishMode::Off {
        let commit_message = outpututil::format_task_commit_message(task_id, task_title);
        git::commit_all(&resolved.repo_root, &commit_message)?;
        if git_publish_mode == GitPublishMode::CommitAndPush {
            push_if_ahead(&resolved.repo_root, push_policy)?;
        } else {
            log::info!("Git publish mode is commit-only; skipping push.");
        }
        git::require_clean_repo_ignoring_paths(
            &resolved.repo_root,
            false,
            git::RALPH_RUN_CLEAN_ALLOWED_PATHS,
        )?;
    } else {
        log::info!("Git publish mode is off; leaving repo dirty after queue updates.");
    }
    Ok(())
}

/// Pushes to upstream if the local branch is ahead.
pub(crate) fn push_if_ahead(repo_root: &Path, push_policy: PushPolicy) -> Result<()> {
    match git::is_ahead_of_upstream(repo_root) {
        Ok(ahead) => {
            if !ahead {
                return Ok(());
            }
            if let Err(err) = git::push_upstream_with_rebase(repo_root) {
                bail!(
                    "Git push failed: the repository has unpushed commits and rebase-aware push failed. Push manually to sync with upstream. Error: {:#}",
                    err
                );
            }
            Ok(())
        }
        Err(GitError::NoUpstream) | Err(GitError::NoUpstreamConfigured) => match push_policy {
            PushPolicy::RequireUpstream => {
                let branch = git::current_branch(repo_root).unwrap_or_else(|_| "HEAD".to_string());
                log::warn!(
                    "skipping push for branch '{}' (no upstream configured). Set upstream with `git push -u origin {}` or run with upstream creation enabled.",
                    branch,
                    branch
                );
                Ok(())
            }
            PushPolicy::AllowCreateUpstream => {
                if let Err(err) = git::push_upstream_with_rebase(repo_root) {
                    bail!(
                        "Git push failed: unable to sync branch without upstream using rebase-aware push. Push manually to sync with upstream. Error: {:#}",
                        err
                    );
                }
                Ok(())
            }
        },
        Err(err) => Err(anyhow!("upstream check failed: {:#}", err)),
    }
}

/// Validates LFS configuration and warns about potential issues.
///
/// When `strict` is true, returns an error if LFS filters are misconfigured,
/// if there are files that should be LFS but aren't tracked properly, or if
/// any git/LFS command fails unexpectedly.
///
/// When `strict` is false, logs warnings for any issues or command failures
/// and returns `Ok(())`.
pub(crate) fn warn_if_modified_lfs(repo_root: &Path, strict: bool) -> Result<()> {
    match git::has_lfs(repo_root) {
        Ok(true) => {}
        Ok(false) => return Ok(()),
        Err(err) => {
            if strict {
                return Err(anyhow!("Git LFS detection failed: {:#}", err));
            }
            log::warn!("Git LFS detection failed: {:#}", err);
            return Ok(());
        }
    }

    // Perform comprehensive LFS health check
    let health_report = match git::check_lfs_health(repo_root) {
        Ok(report) => report,
        Err(err) => {
            if strict {
                return Err(anyhow!("Git LFS health check failed: {:#}", err));
            }
            log::warn!("Git LFS health check failed: {:#}", err);
            return Ok(());
        }
    };

    if !health_report.lfs_initialized {
        return Ok(());
    }

    // Check filter configuration
    if let Some(ref filter_status) = health_report.filter_status
        && !filter_status.is_healthy()
    {
        let issues = filter_status.issues();
        if strict {
            return Err(anyhow!(
                "Git LFS filters misconfigured: {}. Run 'git lfs install' to fix.",
                issues.join("; ")
            ));
        } else {
            log::error!(
                "Git LFS filters misconfigured: {}. Run 'git lfs install' to fix. This may cause data loss if LFS files are committed as pointers!",
                issues.join("; ")
            );
        }
    }

    // Check LFS status for untracked files
    if let Some(ref status_summary) = health_report.status_summary
        && !status_summary.is_clean()
    {
        let issues = status_summary.issue_descriptions();
        if strict {
            return Err(anyhow!("Git LFS issues detected: {}", issues.join("; ")));
        } else {
            for issue in issues {
                log::warn!("LFS issue: {}", issue);
            }
        }
    }

    // Check for pointer file issues
    if !health_report.pointer_issues.is_empty() {
        for issue in &health_report.pointer_issues {
            if strict {
                return Err(anyhow!("LFS pointer issue: {}", issue.description()));
            } else {
                log::warn!("LFS pointer issue: {}", issue.description());
            }
        }
    }

    // Original modified files check
    let status_paths = match git::status_paths(repo_root) {
        Ok(paths) => paths,
        Err(err) => {
            if strict {
                return Err(anyhow!(
                    "Unable to read git status for LFS check: {:#}",
                    err
                ));
            }
            log::warn!("Unable to read git status for LFS warning: {:#}", err);
            return Ok(());
        }
    };

    if status_paths.is_empty() {
        return Ok(());
    }

    let lfs_files = match git::list_lfs_files(repo_root) {
        Ok(files) => files,
        Err(err) => {
            if strict {
                return Err(anyhow!("Unable to list LFS files: {:#}", err));
            }
            log::warn!("Unable to list LFS files: {:#}", err);
            return Ok(());
        }
    };

    if lfs_files.is_empty() {
        log::warn!(
            "Git LFS detected but no tracked files were listed; review LFS changes manually."
        );
        return Ok(());
    }

    let modified = git::filter_modified_lfs_files(&status_paths, &lfs_files);
    if !modified.is_empty() {
        log::warn!("Modified Git LFS files detected: {}", modified.join(", "));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testsupport::git as git_test;
    use tempfile::TempDir;

    #[test]
    fn push_if_ahead_skips_when_not_ahead() -> Result<()> {
        let temp = TempDir::new()?;
        git_test::init_repo(temp.path())?;
        // Create a file to commit (init_repo creates .ralph dir but git needs a file)
        std::fs::write(temp.path().join("init.txt"), "init")?;
        git_test::commit_all(temp.path(), "init")?;

        // No upstream configured, so should skip without error
        push_if_ahead(temp.path(), PushPolicy::RequireUpstream)?;

        Ok(())
    }

    #[test]
    fn push_if_ahead_errors_on_missing_remote() -> Result<()> {
        let temp = TempDir::new()?;
        git_test::init_repo(temp.path())?;
        // Create a file to commit (init_repo creates .ralph dir but git needs a file)
        std::fs::write(temp.path().join("init.txt"), "init")?;
        git_test::commit_all(temp.path(), "init")?;

        // Set up a remote that doesn't exist
        let remote = TempDir::new()?;
        git_test::git_run(remote.path(), &["init", "--bare"])?;
        let branch = git_test::git_output(temp.path(), &["rev-parse", "--abbrev-ref", "HEAD"])?;
        git_test::git_run(
            temp.path(),
            &["remote", "add", "origin", remote.path().to_str().unwrap()],
        )?;
        git_test::git_run(temp.path(), &["push", "-u", "origin", &branch])?;

        // Now change the remote URL to something that doesn't exist
        let missing_remote = temp.path().join("missing-remote");
        git_test::git_run(
            temp.path(),
            &[
                "remote",
                "set-url",
                "origin",
                missing_remote.to_str().unwrap(),
            ],
        )?;

        // Create a commit so we're ahead
        std::fs::write(temp.path().join("work.txt"), "change")?;
        git_test::commit_all(temp.path(), "work")?;

        // Should error on push failure
        let err = push_if_ahead(temp.path(), PushPolicy::RequireUpstream).unwrap_err();
        assert!(format!("{err:#}").contains("Git push failed"));

        Ok(())
    }

    #[test]
    fn push_if_ahead_creates_upstream_when_allowed() -> Result<()> {
        let temp = TempDir::new()?;
        git_test::init_repo(temp.path())?;
        std::fs::write(temp.path().join("init.txt"), "init")?;
        git_test::commit_all(temp.path(), "init")?;

        let remote = TempDir::new()?;
        git_test::git_run(remote.path(), &["init", "--bare"])?;
        git_test::git_run(
            temp.path(),
            &["remote", "add", "origin", remote.path().to_str().unwrap()],
        )?;

        std::fs::write(temp.path().join("work.txt"), "change")?;
        git_test::commit_all(temp.path(), "work")?;

        push_if_ahead(temp.path(), PushPolicy::AllowCreateUpstream)?;

        let upstream = git_test::git_output(
            temp.path(),
            &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
        )?;
        assert!(upstream.starts_with("origin/"));

        Ok(())
    }

    #[test]
    fn push_if_ahead_allow_create_handles_existing_remote_branch_without_local_upstream()
    -> Result<()> {
        let remote = TempDir::new()?;
        git_test::init_bare_repo(remote.path())?;

        let seed = TempDir::new()?;
        git_test::init_repo(seed.path())?;
        git_test::add_remote(seed.path(), "origin", remote.path())?;
        std::fs::write(seed.path().join("base.txt"), "base\n")?;
        git_test::commit_all(seed.path(), "init")?;
        git_test::git_run(seed.path(), &["push", "-u", "origin", "HEAD"])?;
        git_test::git_run(seed.path(), &["checkout", "-b", "ralph/RQ-0940"])?;
        std::fs::write(seed.path().join("task.txt"), "remote-only\n")?;
        git_test::commit_all(seed.path(), "remote task")?;
        git_test::git_run(seed.path(), &["push", "-u", "origin", "ralph/RQ-0940"])?;

        let local = TempDir::new()?;
        git_test::clone_repo(remote.path(), local.path())?;
        git_test::configure_user(local.path())?;
        git_test::git_run(
            local.path(),
            &[
                "checkout",
                "--no-track",
                "-b",
                "ralph/RQ-0940",
                "origin/main",
            ],
        )?;

        // Should not fail with non-fast-forward; should attach upstream and continue.
        push_if_ahead(local.path(), PushPolicy::AllowCreateUpstream)?;

        let upstream = git_test::git_output(
            local.path(),
            &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
        )?;
        assert_eq!(upstream, "origin/ralph/RQ-0940");

        Ok(())
    }

    #[test]
    fn push_if_ahead_recovers_from_non_fast_forward() -> Result<()> {
        let remote = TempDir::new()?;
        git_test::init_bare_repo(remote.path())?;

        let repo_a = TempDir::new()?;
        git_test::init_repo(repo_a.path())?;
        git_test::add_remote(repo_a.path(), "origin", remote.path())?;

        std::fs::write(repo_a.path().join("base.txt"), "base\n")?;
        git_test::commit_all(repo_a.path(), "init")?;
        git_test::git_run(repo_a.path(), &["push", "-u", "origin", "HEAD"])?;

        let repo_b = TempDir::new()?;
        git_test::clone_repo(remote.path(), repo_b.path())?;
        git_test::configure_user(repo_b.path())?;
        std::fs::write(repo_b.path().join("remote.txt"), "remote\n")?;
        git_test::commit_all(repo_b.path(), "remote update")?;
        git_test::git_run(repo_b.path(), &["push"])?;

        std::fs::write(repo_a.path().join("local.txt"), "local\n")?;
        git_test::commit_all(repo_a.path(), "local update")?;

        // Should succeed by rebasing local commit onto remote and retrying push.
        push_if_ahead(repo_a.path(), PushPolicy::RequireUpstream)?;

        let verify = TempDir::new()?;
        git_test::clone_repo(remote.path(), verify.path())?;
        let history =
            git_test::git_output(verify.path(), &["log", "--oneline", "--max-count", "4"])?;
        assert!(
            history.contains("local update"),
            "expected rebased local commit in remote history: {}",
            history
        );
        assert!(
            history.contains("remote update"),
            "expected remote commit preserved in history: {}",
            history
        );

        Ok(())
    }

    #[test]
    fn warn_if_modified_lfs_strict_errors_when_lfs_detected_but_git_config_fails() {
        let temp = TempDir::new().expect("tempdir");
        // Create a valid git repo
        git_test::init_repo(temp.path()).expect("init repo");
        // Create .gitattributes with LFS filter
        std::fs::write(temp.path().join(".gitattributes"), "*.bin filter=lfs\n")
            .expect("write gitattributes");
        // Create a fake .git/lfs directory to trigger LFS detection
        std::fs::create_dir_all(temp.path().join(".git/lfs")).expect("create lfs dir");

        // Break git by corrupting .git/config
        std::fs::write(temp.path().join(".git/config"), "not a valid config")
            .expect("write invalid config");

        let err = warn_if_modified_lfs(temp.path(), true).unwrap_err();
        let msg = format!("{err:#}");
        // Should fail because git config commands fail with invalid config
        assert!(
            msg.to_lowercase().contains("git") || msg.to_lowercase().contains("lfs"),
            "unexpected error: {msg}"
        );
    }

    #[test]
    fn warn_if_modified_lfs_non_strict_warns_and_continues_on_errors() -> Result<()> {
        let temp = TempDir::new()?;
        // Create a valid git repo
        git_test::init_repo(temp.path())?;
        // Create .gitattributes with LFS filter
        std::fs::write(temp.path().join(".gitattributes"), "*.bin filter=lfs\n")?;
        // Create a fake .git/lfs directory to trigger LFS detection
        std::fs::create_dir_all(temp.path().join(".git/lfs"))?;

        // Break git by corrupting .git/config
        std::fs::write(temp.path().join(".git/config"), "not a valid config")?;

        // Should return Ok(()) in non-strict mode even though git commands will fail
        // because `strict=false` is warn-and-continue.
        warn_if_modified_lfs(temp.path(), false)?;
        Ok(())
    }
}