mod support;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::process::{Command, Stdio};
use loopflow::ops::{
create_or_update_pr, land, rebase_with_recovery, submit, LandOptions, NullProgress, PrOptions,
RebaseOptions,
};
use loopflow::task::{
AfterMerge, GithubPr, PrMergeMode, PrMergeRequest, PrPresentation, PrPublication,
};
use loopflow_test_support::TestRepo;
use support::{register_task, EnvGuard};
use time::OffsetDateTime;
fn land_options(create_pr: bool, pr_title: &str) -> LandOptions {
LandOptions {
strict: true,
local: false,
create_pr,
complete: false,
next_slug: None,
worktree: None,
commit_message: None,
pr_title: Some(pr_title.to_string()),
pr_body: Some("proof body".to_string()),
agent: None,
}
}
fn gh_open_pr_script(log_path: &str) -> String {
format!(
r#"#!/bin/sh
auto_state="{log_path}.auto"
if [ "$1" = "--version" ]; then
exit 0
fi
echo "$@" >> "{log_path}"
if [ "$1 $2" = "pr list" ]; then
head=$(git rev-parse HEAD)
printf '[{{"url":"https://example.com/pr/925","state":"OPEN","isDraft":false,"number":925,"mergeCommit":null,"headRefOid":"%s"}}]\n' "$head"
exit 0
fi
if [ "$1 $2" = "api graphql" ]; then
if [ -f "$auto_state" ]; then echo 'true'; else echo 'false'; fi
exit 0
fi
if [ "$1 $2" = "pr view" ]; then
echo 'https://example.com/pr/925'
exit 0
fi
if [ "$1 $2" = "pr merge" ]; then
touch "$auto_state"
exit 0
fi
exit 0
"#
)
}
fn gh_auto_enabled_script(log_path: &str) -> String {
format!(
r#"#!/bin/sh
if [ "$1" = "--version" ]; then
exit 0
fi
echo "$@" >> "{log_path}"
if [ "$1 $2" = "api graphql" ]; then
echo 'true'
exit 0
fi
if [ "$1 $2 $3 $4" = "pr merge 912 --disable-auto" ]; then
exit 0
fi
exit 0
"#
)
}
fn noop_open_script() -> &'static str {
"#!/bin/sh\nexit 0\n"
}
fn remote_branch_exists(repo: &TestRepo, name: &str) -> bool {
Command::new("git")
.arg("--git-dir")
.arg(repo.bare_path())
.args(["show-ref", "--verify", &format!("refs/heads/{name}")])
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn git_out(repo: &TestRepo, args: &[&str]) -> String {
let output = Command::new("git")
.current_dir(repo.path())
.args(args)
.output()
.expect("run git");
assert!(
output.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[test]
fn submit_refuses_a_contaminated_range_before_any_push() {
let home = tempfile::TempDir::new().expect("temp home");
let repo = TestRepo::new();
let log_path = home.path().join("gh.log");
let script = gh_open_pr_script(log_path.to_string_lossy().as_ref());
let _env = EnvGuard::with_lf_home(
&[("gh", script.as_str()), ("open", noop_open_script())],
home.path(),
);
repo.create_file("foreign.txt", "not this task's work\n");
repo.stage_all();
repo.commit("foreign canonical-main commit");
let contaminated_base = repo.head_sha();
let branch = "jack/contaminated";
repo.create_branch(branch); repo.create_file("task.txt", "task work\n");
repo.stage_all();
repo.commit("task commit");
register_task(home.path(), repo.path(), branch, &contaminated_base);
let err = submit(
repo.path(),
&land_options(true, "contaminated"),
&NullProgress,
)
.expect_err("contaminated range must refuse");
let message = err.to_string();
assert!(
message.contains("contaminated"),
"expected contamination refusal, got: {message}"
);
assert!(
message.contains("foreign canonical-main commit"),
"refusal must name the foreign commit, got: {message}"
);
assert!(
message.contains("rebase --onto"),
"refusal must print the recovery action, got: {message}"
);
assert!(
!remote_branch_exists(&repo, branch),
"the branch must never reach the remote when the range is refused"
);
let log = fs::read_to_string(&log_path).unwrap_or_default();
assert!(
!log.contains("pr create") && !log.contains("pr edit") && !log.contains("pr ready"),
"no gh PR mutation may be issued before refusal, got log:\n{log}"
);
}
#[test]
fn serial_pr_heals_stale_base_and_aligns_the_three_views() {
let home = tempfile::TempDir::new().expect("temp home");
let repo = TestRepo::new(); let stale_base = repo.head_sha();
let log_path = home.path().join("gh.log");
let script = gh_open_pr_script(log_path.to_string_lossy().as_ref());
let _env = EnvGuard::with_lf_home(
&[("gh", script.as_str()), ("open", noop_open_script())],
home.path(),
);
let branch = "jack/serial-pr-proof";
repo.create_branch(branch);
repo.create_file("task.txt", "serial PR work\n");
repo.stage_all();
repo.commit("serial PR commit");
repo.push_new_branch(branch);
repo.checkout("main");
repo.create_file("upstream.txt", "landed upstream\n");
repo.stage_all();
repo.commit("upstream advance");
repo.push();
let advanced = repo.head_sha();
repo.checkout(branch);
let task = register_task(home.path(), repo.path(), branch, &stale_base);
land(
repo.path(),
&land_options(false, "serial pr"),
&NullProgress,
)
.expect("stale serial base heals and lands");
let runtime = tokio::runtime::Runtime::new().expect("read task runtime");
let pr = runtime
.block_on(task.store.active_task_pr(&task.task.id))
.expect("read active PR")
.expect("active PR");
assert_eq!(
pr.base_commit, advanced,
"the stale serial base must heal forward to origin/main"
);
let github_fork_point = git_out(&repo, &["merge-base", "origin/main", "HEAD"]);
assert_eq!(
pr.base_commit, github_fork_point,
"recorded base must equal GitHub's range fork point"
);
let range = format!("{}..HEAD", pr.base_commit);
let range_commits = git_out(&repo, &["log", "--oneline", "--no-decorate", &range]);
assert!(
!range_commits.contains("upstream advance"),
"the merged upstream commit must be excluded from the range, got:\n{range_commits}"
);
assert_eq!(
range_commits.lines().count(),
1,
"final range is one commit"
);
assert!(
range_commits.contains("lf land: collapse authored history"),
"the Task's authored tree must be represented by the final commit, got:\n{range_commits}"
);
let files = git_out(&repo, &["diff", "--name-only", &range]);
assert!(
files.contains("task.txt") && !files.contains("upstream.txt"),
"the aligned range must show this Task's file and never the upstream file, got:\n{files}"
);
}
#[test]
fn failed_rebase_push_does_not_advance_the_recorded_task_base() {
let home = tempfile::TempDir::new().expect("temp home");
let repo = TestRepo::new();
let stale_base = repo.head_sha();
let _env = EnvGuard::with_lf_home(&[], home.path());
let branch = "jack/rejected-rebase-push";
repo.create_branch(branch);
repo.create_file("task.txt", "task work\n");
repo.stage_all();
repo.commit("task commit");
repo.push_new_branch(branch);
repo.checkout("main");
repo.create_file("upstream.txt", "landed upstream\n");
repo.stage_all();
repo.commit("upstream advance");
repo.push();
let target = repo.head_sha();
repo.checkout(branch);
let task = register_task(home.path(), repo.path(), branch, &stale_base);
let hook = repo.bare_path().join("hooks/pre-receive");
fs::write(
&hook,
format!(
"#!/bin/sh\nwhile read old new ref; do\n if [ \"$ref\" = \"refs/heads/{branch}\" ]; then exit 1; fi\ndone\nexit 0\n"
),
)
.expect("write rejecting hook");
let mut permissions = fs::metadata(&hook).expect("hook metadata").permissions();
permissions.set_mode(0o755);
fs::set_permissions(&hook, permissions).expect("make hook executable");
let error = rebase_with_recovery(
repo.path(),
&RebaseOptions {
onto: "origin/main".to_string(),
push: true,
fork_base: None,
},
&NullProgress,
)
.expect_err("the remote must reject the rewritten branch");
assert!(
error.to_string().contains("git push --force-with-lease"),
"expected the push failure, got: {error}"
);
assert!(
loopflow::engine::git::is_ancestor(repo.path(), &target, &repo.head_sha()).unwrap(),
"the local rebase must complete before the rejected push"
);
let runtime = tokio::runtime::Runtime::new().expect("read task runtime");
let pr = runtime
.block_on(task.store.active_task_pr(&task.task.id))
.expect("read active PR")
.expect("active PR");
assert_eq!(
pr.base_commit, stale_base,
"a failed remote postcondition must not advance durable Task metadata"
);
}
#[test]
fn rebase_revokes_auto_before_force_pushing_a_new_task_head() {
let home = tempfile::TempDir::new().expect("temp home");
let repo = TestRepo::new();
let stale_base = repo.head_sha();
let log_path = home.path().join("rebase.log");
let script = gh_auto_enabled_script(log_path.to_string_lossy().as_ref());
let _env = EnvGuard::with_lf_home(&[("gh", script.as_str())], home.path());
let branch = "jack/rebase-revokes-auto";
repo.create_branch(branch);
repo.create_file("task.txt", "task work\n");
repo.stage_all();
repo.commit("task commit");
repo.push_new_branch(branch);
let task = register_task(home.path(), repo.path(), branch, &stale_base);
let old_head = repo.head_sha();
let now = OffsetDateTime::now_utc();
let mut pr = task.pr.clone();
pr.publication = Some(PrPublication {
requested_at: now,
presentation: Some(PrPresentation {
title: "Rebase proof".to_string(),
body: "Reviewer context".to_string(),
head_sha: old_head.clone(),
}),
github: Some(GithubPr {
number: 912,
url: "https://example.com/pr/912".to_string(),
head_sha: Some(old_head.clone()),
}),
merge: Some(PrMergeRequest {
mode: PrMergeMode::Auto,
requested_at: now,
head_sha: old_head,
after_merge: AfterMerge::CompleteTask,
next_slug: None,
}),
});
let runtime = tokio::runtime::Runtime::new().expect("task runtime");
runtime
.block_on(task.store.update_task_pr(&pr))
.expect("store Auto request");
repo.checkout("main");
repo.create_file("upstream.txt", "upstream advance\n");
repo.stage_all();
repo.commit("upstream advance");
repo.push();
repo.checkout(branch);
let hook = repo.bare_path().join("hooks/pre-receive");
fs::write(
&hook,
format!(
"#!/bin/sh\necho git-push >> '{}'\ncat >/dev/null\n",
log_path.display()
),
)
.expect("write push hook");
let mut permissions = fs::metadata(&hook).expect("hook metadata").permissions();
permissions.set_mode(0o755);
fs::set_permissions(&hook, permissions).expect("make hook executable");
rebase_with_recovery(
repo.path(),
&RebaseOptions {
onto: "origin/main".to_string(),
push: true,
fork_base: None,
},
&NullProgress,
)
.expect("rebase and push Task head");
let persisted = runtime
.block_on(task.store.active_task_pr(&task.task.id))
.expect("read active PR")
.expect("active PR");
assert!(persisted.merge_request().is_none());
let log = fs::read_to_string(log_path).expect("read operation log");
let disable = log
.find("pr merge 912 --disable-auto")
.expect("Auto is revoked");
let push = log.find("git-push").expect("rebased head is pushed");
assert!(
disable < push,
"Auto must be revoked before rebase push:\n{log}"
);
}
#[test]
fn publish_keeps_a_behind_branch_and_recorded_base_unchanged() {
let home = tempfile::TempDir::new().expect("temp home");
let repo = TestRepo::new(); let stale_base = repo.head_sha();
let log_path = home.path().join("gh.log");
let script = gh_open_pr_script(log_path.to_string_lossy().as_ref());
let _env = EnvGuard::with_lf_home(
&[("gh", script.as_str()), ("open", noop_open_script())],
home.path(),
);
let branch = "jack/publish-heal-proof";
repo.create_branch(branch);
repo.create_file("task.txt", "task work\n");
repo.stage_all();
repo.commit("task commit");
repo.push_new_branch(branch);
repo.checkout("main");
repo.create_file("upstream.txt", "landed upstream\n");
repo.stage_all();
repo.commit("upstream advance");
repo.push();
repo.checkout(branch);
let before_publish = repo.head_sha();
let task = register_task(home.path(), repo.path(), branch, &stale_base);
create_or_update_pr(
repo.path(),
&PrOptions {
title: Some("publish heal".to_string()),
body: Some("proof body".to_string()),
agent: None,
},
&NullProgress,
)
.expect("publish without integration");
assert_eq!(
repo.head_sha(),
before_publish,
"a clean publication must not rewrite local HEAD"
);
let runtime = tokio::runtime::Runtime::new().expect("read task runtime");
let pr = runtime
.block_on(task.store.active_task_pr(&task.task.id))
.expect("read active PR")
.expect("active PR");
assert_eq!(
pr.base_commit, stale_base,
"publication must not advance the recorded integration base"
);
let files = git_out(
&repo,
&["diff", "--name-only", &format!("{}..HEAD", pr.base_commit)],
);
assert!(
files.contains("task.txt") && !files.contains("upstream.txt"),
"the original authored range must remain intact, got:\n{files}"
);
assert!(
!std::path::Path::new(&git_out(&repo, &["rev-parse", "--absolute-git-dir"]))
.join("loopflow/rebase-owner.json")
.exists(),
"publication must not create rebase ownership state"
);
}
#[test]
fn submit_refuses_divergent_ancestry_naming_both_sides() {
let home = tempfile::TempDir::new().expect("temp home");
let repo = TestRepo::new();
let origin_tip = repo.head_sha();
let log_path = home.path().join("gh.log");
let script = gh_open_pr_script(log_path.to_string_lossy().as_ref());
let _env = EnvGuard::with_lf_home(
&[("gh", script.as_str()), ("open", noop_open_script())],
home.path(),
);
repo.create_file("foreign.txt", "not this task's work\n");
repo.stage_all();
repo.commit("foreign canonical-main commit");
let contaminated_base = repo.head_sha();
let branch = "jack/divergent-proof";
repo.create_branch(branch);
repo.create_file("task.txt", "task work\n");
repo.stage_all();
repo.commit("task commit");
repo.checkout("main");
git_out(&repo, &["reset", "--hard", &origin_tip]);
repo.create_file("upstream.txt", "landed upstream\n");
repo.stage_all();
repo.commit("upstream advance");
repo.push();
repo.checkout(branch);
git_out(
&repo,
&[
"rebase",
"--onto",
"origin/main",
&contaminated_base,
branch,
],
);
register_task(home.path(), repo.path(), branch, &contaminated_base);
let err = submit(repo.path(), &land_options(true, "divergent"), &NullProgress)
.expect_err("divergent ancestry must refuse");
let message = err.to_string();
assert!(
message.contains("diverged"),
"expected divergence refusal, got: {message}"
);
assert!(
message.contains("foreign canonical-main commit"),
"refusal must name the base-side foreign commit, got: {message}"
);
assert!(
message.contains("foreign.txt"),
"refusal must name the base-side file, got: {message}"
);
assert!(
message.contains("upstream advance"),
"refusal must name the upstream-side commit, got: {message}"
);
assert!(
message.contains("upstream.txt"),
"refusal must name the upstream-side file, got: {message}"
);
assert!(
message.contains("rebase --onto"),
"refusal must print the recovery action, got: {message}"
);
assert!(
!remote_branch_exists(&repo, branch),
"the branch must never reach the remote when ancestry is refused"
);
let log = fs::read_to_string(&log_path).unwrap_or_default();
assert!(
!log.contains("pr create") && !log.contains("pr edit") && !log.contains("pr ready"),
"no gh PR mutation may be issued before refusal, got log:\n{log}"
);
}
#[test]
fn submit_refuses_contaminated_range_after_squash_merged_parent() {
let home = tempfile::TempDir::new().expect("temp home");
let repo = TestRepo::new();
let log_path = home.path().join("gh.log");
let script = gh_open_pr_script(log_path.to_string_lossy().as_ref());
let _env = EnvGuard::with_lf_home(
&[("gh", script.as_str()), ("open", noop_open_script())],
home.path(),
);
let first_branch = "jack/pr-one";
repo.create_branch(first_branch);
repo.create_file("pr1-a.txt", "a\n");
repo.stage_all();
repo.commit("PR1 first commit");
repo.create_file("pr1-b.txt", "b\n");
repo.stage_all();
repo.commit("PR1 second commit");
let pr1_tip = repo.head_sha();
repo.checkout("main");
git_out(&repo, &["merge", "--squash", first_branch]);
repo.stage_all();
repo.commit("squash-merge PR1");
repo.push();
let second_branch = "jack/pr-two";
git_out(&repo, &["branch", second_branch, &pr1_tip]);
repo.checkout(second_branch);
repo.create_file("pr2.txt", "PR2 work\n");
repo.stage_all();
repo.commit("PR2 commit");
register_task(home.path(), repo.path(), second_branch, &pr1_tip);
let err = submit(
repo.path(),
&land_options(true, "squash-merged parent"),
&NullProgress,
)
.expect_err("squash-merged parent contamination must refuse");
let message = err.to_string();
assert!(
message.contains("contaminated"),
"expected contamination refusal, got: {message}"
);
assert!(
message.contains("PR1 first commit"),
"refusal must name the first pre-squash commit, got: {message}"
);
assert!(
message.contains("PR1 second commit"),
"refusal must name the second pre-squash commit, got: {message}"
);
assert!(
message.contains("rebase --onto"),
"refusal must print the recovery action, got: {message}"
);
assert!(
!remote_branch_exists(&repo, second_branch),
"the branch must never reach the remote when the range is refused"
);
}
#[test]
fn submit_refuses_contaminated_range_without_a_remote() {
let home = tempfile::TempDir::new().expect("temp home");
let repo = TestRepo::new();
let base = repo.head_sha();
let log_path = home.path().join("gh.log");
let script = gh_open_pr_script(log_path.to_string_lossy().as_ref());
let _env = EnvGuard::with_lf_home(
&[("gh", script.as_str()), ("open", noop_open_script())],
home.path(),
);
git_out(&repo, &["remote", "remove", "origin"]);
repo.create_file("foreign.txt", "not this task's work\n");
repo.stage_all();
repo.commit("foreign local-main commit");
let contaminated_base = repo.head_sha();
let branch = "jack/no-remote-contaminated";
repo.create_branch(branch);
repo.create_file("task.txt", "task work\n");
repo.stage_all();
repo.commit("task commit");
repo.checkout("main");
git_out(&repo, &["reset", "--hard", &base]);
repo.checkout(branch);
register_task(home.path(), repo.path(), branch, &contaminated_base);
let err = submit(
repo.path(),
&land_options(true, "no-remote contaminated"),
&NullProgress,
)
.expect_err("no-remote contaminated range must refuse");
let message = err.to_string();
assert!(
message.contains("contaminated"),
"expected contamination refusal, got: {message}"
);
assert!(
message.contains("foreign local-main commit"),
"refusal must name the foreign commit, got: {message}"
);
assert!(
message.contains("rebase --onto"),
"refusal must print the recovery action, got: {message}"
);
}
#[test]
fn serial_rotation_heals_stale_base_and_lands_the_continuation() {
let home = tempfile::TempDir::new().expect("temp home");
let repo = TestRepo::new();
let stale_base = repo.head_sha();
let log_path = home.path().join("gh.log");
let script = gh_open_pr_script(log_path.to_string_lossy().as_ref());
let _env = EnvGuard::with_lf_home(
&[("gh", script.as_str()), ("open", noop_open_script())],
home.path(),
);
let branch = "jack/serial-rotation-proof";
repo.create_branch(branch);
repo.create_file("task.txt", "serial rotation work\n");
repo.stage_all();
repo.commit("serial rotation commit");
repo.push_new_branch(branch);
repo.checkout("main");
repo.create_file("upstream.txt", "landed upstream\n");
repo.stage_all();
repo.commit("upstream advance");
repo.push();
let advanced = repo.head_sha();
repo.checkout(branch);
let task = register_task(home.path(), repo.path(), branch, &stale_base);
land(
repo.path(),
&land_options(false, "serial rotation"),
&NullProgress,
)
.expect("serial rotation heals stale base and lands");
let runtime = tokio::runtime::Runtime::new().expect("read task runtime");
let pr = runtime
.block_on(task.store.active_task_pr(&task.task.id))
.expect("read active PR")
.expect("active PR");
assert_eq!(
pr.base_commit, advanced,
"the stale serial base must heal forward to origin/main"
);
let github_fork_point = git_out(&repo, &["merge-base", "origin/main", "HEAD"]);
assert_eq!(
pr.base_commit, github_fork_point,
"recorded base must equal GitHub's range fork point"
);
let range = format!("{}..HEAD", pr.base_commit);
let range_commits = git_out(&repo, &["log", "--oneline", "--no-decorate", &range]);
assert!(
!range_commits.contains("upstream advance"),
"the merged upstream commit must be excluded, got:\n{range_commits}"
);
assert_eq!(
range_commits.lines().count(),
1,
"final range is one commit"
);
assert!(
range_commits.contains("lf land: collapse authored history"),
"the Task's authored tree must be represented by the final commit, got:\n{range_commits}"
);
let files = git_out(&repo, &["diff", "--name-only", &range]);
assert!(
files.contains("task.txt") && !files.contains("upstream.txt"),
"the collapsed range must preserve only Task work, got:\n{files}"
);
}
#[test]
fn submit_refuses_an_empty_range_before_any_gh_call() {
let home = tempfile::TempDir::new().expect("temp home");
let repo = TestRepo::new();
let base = repo.head_sha();
let log_path = home.path().join("gh.log");
let script = gh_open_pr_script(log_path.to_string_lossy().as_ref());
let _env = EnvGuard::with_lf_home(
&[("gh", script.as_str()), ("open", noop_open_script())],
home.path(),
);
let branch = "jack/empty-range";
repo.create_branch(branch);
let task = register_task(home.path(), repo.path(), branch, &base);
let runtime = tokio::runtime::Runtime::new().expect("update PR runtime");
runtime.block_on(async {
let mut pr = task
.store
.active_task_pr(&task.task.id)
.await
.expect("read active PR")
.expect("active PR exists");
pr.publication = Some(PrPublication {
requested_at: OffsetDateTime::now_utc(),
presentation: None,
github: Some(GithubPr {
number: 925,
url: "https://example.com/pr/925".to_string(),
head_sha: None,
}),
merge: None,
});
pr.updated_at = OffsetDateTime::now_utc();
task.store
.update_task_pr(&pr)
.await
.expect("set github number");
});
let err = submit(
repo.path(),
&land_options(true, "empty range"),
&NullProgress,
)
.expect_err("empty range must refuse");
assert!(
err.to_string().contains("empty"),
"expected empty-range refusal, got: {err}"
);
let log = fs::read_to_string(&log_path).unwrap_or_default();
assert!(
!log.contains("pr create") && !log.contains("pr edit") && !log.contains("pr ready"),
"no gh PR mutation may be issued for an empty range, got log:\n{log}"
);
}