loopflow 0.10.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 {:?} failed: {}",
        args,
        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 error, got: {:?}",
        result
    );
}

#[test]
fn rebase_with_push() {
    let repo = TestRepo::new();
    repo.create_branch("feature");
    repo.push_new_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: true,
        },
        &NullProgress,
    )
    .expect("rebase");
}

#[test]
fn rebase_stacked_branch_after_squash_merge() {
    let repo = TestRepo::new();

    // Branch A: two commits on top of main.
    repo.create_branch("branch-a");
    repo.create_file("a1.txt", "a1");
    repo.stage_all();
    repo.commit("a1");
    repo.create_file("a2.txt", "a2");
    repo.stage_all();
    repo.commit("a2");

    // Branch B: stacked on A with its own commits.
    repo.create_branch("branch-b");
    repo.create_file("b1.txt", "b1");
    repo.stage_all();
    repo.commit("b1");

    let b_head = repo.head_sha();

    // Simulate squash-merge of A into main.
    repo.checkout("main");
    git(repo.path(), &["merge", "--squash", "branch-a"]);
    git(repo.path(), &["commit", "-m", "squash merge branch-a"]);
    repo.push();

    // Rebase B onto origin/main — without fork-point detection this would conflict.
    repo.checkout("branch-b");
    rebase_with_recovery(
        repo.path(),
        &RebaseOptions {
            onto: "origin/main".to_string(),
            push: false,
        },
        &NullProgress,
    )
    .expect("rebase of stacked branch after squash-merge should succeed");

    // B's commit (b1.txt) should be present.
    assert!(
        repo.path().join("b1.txt").exists(),
        "b1.txt should exist after rebase"
    );
    // A's changes should also be present (from the squash merge on main).
    assert!(
        repo.path().join("a1.txt").exists(),
        "a1.txt should exist from squash merge"
    );
    // HEAD should have changed (rebased onto new main).
    assert_ne!(repo.head_sha(), b_head, "HEAD should differ after rebase");
}

#[test]
fn plan_rebase_classifies_dirty_scratch_only_branch_as_reset() {
    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);
    assert!(plan
        .changed_files
        .iter()
        .all(|path| path.starts_with("scratch")));
}

#[test]
fn plan_rebase_handles_modified_scratch_file_with_leading_status() {
    // Regression: a working-tree-modified (unstaged) file produces a porcelain
    // line with a leading status column (" M scratch/notes.md"). Trimming the
    // first line's leading space shifts the fixed path offset and drops the
    // path's first character ("cratch/notes.md"), which no longer starts with
    // "scratch" — misclassifying the branch as clean_authored → direct_rebase.
    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");
    // Modify the tracked scratch file WITHOUT staging -> " M" porcelain status.
    repo.create_file("scratch/notes.md", "evolved working notes\n");

    let plan = plan_rebase(repo.path(), None).expect("plan rebase");

    assert_eq!(plan.class, RebaseClass::ScratchOnly);
    assert_eq!(plan.strategy, RebaseStrategy::ResetToBase);
    assert!(
        plan.changed_files
            .iter()
            .all(|path| path.starts_with("scratch")),
        "changed_files should all be under scratch/, got: {:?}",
        plan.changed_files
    );
}

#[test]
fn plan_rebase_ignores_upstream_changes_when_branch_is_only_behind() {
    let repo = TestRepo::new();
    repo.create_branch("feature");
    repo.checkout("main");
    repo.create_file("main.txt", "main");
    repo.stage_all();
    repo.commit("main change");
    repo.checkout("feature");
    repo.create_file("scratch/design.md", "notes");

    let plan = plan_rebase(repo.path(), Some("main")).expect("plan rebase");

    assert_eq!(plan.class, RebaseClass::ScratchOnly);
    assert_eq!(plan.strategy, RebaseStrategy::ResetToBase);
    assert!(plan
        .changed_files
        .iter()
        .all(|path| path.starts_with("scratch")));
}

#[test]
fn plan_rebase_classifies_wave_changes_as_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);
}

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

    let plan = plan_rebase(repo.path(), None).expect("plan rebase");

    assert_eq!(plan.stack_parent.as_deref(), Some("a"));
    assert_eq!(plan.base_ref, "a");
    assert_eq!(plan.class, RebaseClass::StackParentOpen);
    assert_eq!(plan.strategy, RebaseStrategy::RebaseOntoParent);
}