mod support;
use std::fs;
use std::process::{Command, Stdio};
use loopflow::ops::{create_or_update_pr, land, submit, LandOptions, NullProgress, PrOptions};
use loopflow_test_support::TestRepo;
use support::{register_task, EnvGuard};
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
if [ "$1" = "--version" ]; then
exit 0
fi
echo "$@" >> "{log_path}"
if [ "$1 $2" = "pr list" ]; then
echo '[{{"url":"https://example.com/pr/925","state":"OPEN","isDraft":false,"number":925,"mergeCommit":null}}]'
exit 0
fi
if [ "$1 $2" = "pr view" ]; then
echo 'https://example.com/pr/925'
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.session.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!(
range_commits.contains("serial PR commit"),
"the Task's own commit must be in the range, 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 publish_heals_the_recorded_base_after_rebasing_onto_origin() {
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();
let advanced = repo.head_sha();
repo.checkout(branch);
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 rebases and heals");
let runtime = tokio::runtime::Runtime::new().expect("read task runtime");
let pr = runtime
.block_on(task.store.active_task_pr(&task.session.id))
.expect("read active PR")
.expect("active PR");
assert_eq!(
pr.base_commit, advanced,
"publish must heal the stale base to the rebased fork point"
);
let files = git_out(
&repo,
&["diff", "--name-only", &format!("{}..HEAD", pr.base_commit)],
);
assert!(
files.contains("task.txt") && !files.contains("upstream.txt"),
"the healed range must show only this Task's work, got:\n{files}"
);
}