loopflow 0.11.0

Run steps and flows with coding agents
Documentation
use loopflow::ops::{
    plan_rebase, rebase_with_recovery, NullProgress, OpsError, RebaseClass, RebaseOptions,
    RebaseStrategy,
};
use loopflow_test_support::TestRepo;
use std::process::Command;

fn git(repo: &std::path::Path, args: &[&str]) -> String {
    let output = Command::new("git")
        .args(args)
        .current_dir(repo)
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "git {args:?} failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8_lossy(&output.stdout).trim().to_string()
}

#[test]
fn rebase_onto_main_succeeds() {
    let repo = TestRepo::new();
    repo.create_branch("feature");
    repo.create_file("feature.txt", "feature");
    repo.stage_all();
    repo.commit("feature work");

    repo.checkout("main");
    repo.create_file("main.txt", "main");
    repo.stage_all();
    repo.commit("main work");
    repo.push();

    repo.checkout("feature");
    rebase_with_recovery(
        repo.path(),
        &RebaseOptions {
            onto: "origin/main".to_string(),
            push: false,
        },
        &NullProgress,
    )
    .expect("rebase");
}

#[test]
fn rebase_conflict_returns_error() {
    let repo = TestRepo::new();
    repo.create_branch("feature");
    repo.create_file("conflict.txt", "feature line\n");
    repo.stage_all();
    repo.commit("feature work");

    repo.checkout("main");
    repo.create_file("conflict.txt", "main line\n");
    repo.stage_all();
    repo.commit("main work");
    repo.push();

    repo.checkout("feature");
    let result = rebase_with_recovery(
        repo.path(),
        &RebaseOptions {
            onto: "origin/main".to_string(),
            push: false,
        },
        &NullProgress,
    );

    assert!(
        matches!(result, Err(OpsError::RebaseConflict { ref onto, .. }) if onto == "origin/main"),
        "expected rebase conflict, got {result:?}"
    );
}

#[test]
fn rebase_after_squash_merge_replays_only_unique_work() {
    let repo = TestRepo::new();
    repo.create_branch("parent");
    repo.create_file("a1.txt", "a1");
    repo.stage_all();
    repo.commit("a1");
    repo.create_file("a2.txt", "a2");
    repo.stage_all();
    repo.commit("a2");

    repo.create_branch("feature");
    repo.create_file("feature.txt", "feature");
    repo.stage_all();
    repo.commit("feature work");

    repo.checkout("main");
    git(repo.path(), &["merge", "--squash", "parent"]);
    git(repo.path(), &["commit", "-m", "squash parent"]);
    repo.push();

    repo.checkout("feature");
    rebase_with_recovery(
        repo.path(),
        &RebaseOptions {
            onto: "origin/main".to_string(),
            push: false,
        },
        &NullProgress,
    )
    .expect("rebase after squash merge");

    assert!(repo.path().join("feature.txt").exists());
    assert_eq!(
        git(repo.path(), &["diff", "--name-only", "origin/main...HEAD"]),
        "feature.txt"
    );
}

#[test]
fn dotted_branch_names_do_not_imply_a_parent() {
    let repo = TestRepo::new();
    repo.create_branch("jack/a.b");
    repo.create_file("feature.txt", "feature");
    repo.stage_all();
    repo.commit("feature work");

    let plan = plan_rebase(repo.path(), None).expect("plan rebase");
    assert_eq!(plan.base_ref, "origin/main");
    assert_eq!(plan.class, RebaseClass::CleanAuthored);
    assert_eq!(plan.strategy, RebaseStrategy::DirectRebase);
}

#[test]
fn explicit_onto_is_the_only_alternate_base() {
    let repo = TestRepo::new();
    repo.create_branch("alternate");
    repo.create_file("alternate.txt", "alternate");
    repo.stage_all();
    repo.commit("alternate work");
    repo.create_branch("feature");

    let plan = plan_rebase(repo.path(), Some("alternate")).expect("plan rebase");
    assert_eq!(plan.base_ref, "alternate");
}

#[test]
fn dirty_scratch_only_branch_resets_to_base() {
    let repo = TestRepo::new();
    repo.create_branch("feature");
    repo.create_file("scratch/design.md", "notes");

    let plan = plan_rebase(repo.path(), None).expect("plan rebase");
    assert_eq!(plan.class, RebaseClass::ScratchOnly);
    assert_eq!(plan.strategy, RebaseStrategy::ResetToBase);
    assert_eq!(plan.unique_commits, 0);
    assert!(!plan.protected);
}

#[test]
fn modified_scratch_file_keeps_its_leading_path_character() {
    let repo = TestRepo::new();
    repo.create_file("scratch/notes.md", "original\n");
    repo.stage_all();
    repo.commit("add scratch notes");
    repo.push();
    repo.create_branch("feature");
    repo.create_file("scratch/notes.md", "evolved\n");

    let plan = plan_rebase(repo.path(), None).expect("plan rebase");
    assert_eq!(plan.class, RebaseClass::ScratchOnly);
    assert!(plan
        .changed_files
        .iter()
        .all(|path| path.starts_with("scratch")));
}

#[test]
fn wave_changes_are_protected() {
    let repo = TestRepo::new();
    repo.create_branch("feature");
    repo.create_file("wave/goals/MEMORY.md", "state");
    repo.stage_all();
    repo.commit("update wave memory");

    let plan = plan_rebase(repo.path(), None).expect("plan rebase");
    assert_eq!(plan.class, RebaseClass::Protected);
    assert_eq!(plan.strategy, RebaseStrategy::DirectRebase);
    assert!(plan.protected);
}