mod support;
use std::fs;
use std::process::{Command, Stdio};
use loopflow::ops::task::task_stack;
use loopflow::ops::{
arm as land, create_or_update_pr, 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("authority proof".to_string()),
agent: None,
}
}
fn noop_open_script() -> &'static str {
"#!/bin/sh\nexit 0\n"
}
fn gh_record_script(log_path: &str) -> String {
format!(
r#"#!/bin/sh
if [ "$1" = "--version" ]; then
echo "gh version 1.0.0"
exit 0
fi
echo "$@" >> "{log_path}"
if [ "$1 $2" = "pr list" ]; then
echo '[]'
exit 0
fi
if [ "$1 $2" = "pr create" ]; then
echo 'https://example.com/pr/1'
exit 0
fi
if [ "$1 $2" = "pr view" ]; then
echo 'OPEN'
exit 0
fi
exit 0
"#
)
}
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 assert_no_gh_pr_mutation(log_path: &std::path::Path) {
let log = fs::read_to_string(log_path).unwrap_or_default();
for mutation in ["pr create", "pr edit", "pr ready", "pr merge", "pr close"] {
assert!(
!log.contains(mutation),
"no gh `{mutation}` mutation may be issued before authority refusal, got log:\n{log}"
);
}
}
struct AmbientVarGuard {
name: &'static str,
}
impl AmbientVarGuard {
fn set(name: &'static str, value: &str) -> Self {
std::env::set_var(name, value);
Self { name }
}
}
impl Drop for AmbientVarGuard {
fn drop(&mut self) {
std::env::remove_var(self.name);
}
}
#[cfg(unix)]
fn make_registry_inaccessible(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o000))
.expect("make registry inaccessible");
}
#[test]
fn submit_refuses_when_registry_missing_before_any_push() {
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_record_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/authority-missing";
repo.create_branch(branch);
repo.create_file("task.txt", "task work\n");
repo.stage_all();
repo.commit("task commit");
let _task = register_task(home.path(), repo.path(), branch, &base);
std::fs::remove_file(home.path().join("loopflow.db")).expect("remove registry");
let run_id = loopflow::durable::RunId::new();
let _ambient = AmbientVarGuard::set(loopflow::durable::RUN_ID_ENV, run_id.as_str());
let err = submit(
repo.path(),
&land_options(true, "authority missing"),
&NullProgress,
)
.expect_err("missing registry must refuse before any push");
let message = err.to_string();
assert!(
message.contains("authority refused"),
"expected an authority refusal, got: {message}"
);
assert!(
message.contains("missing"),
"refusal must name the missing registry, got: {message}"
);
assert!(
!remote_branch_exists(&repo, branch),
"the branch must never reach the remote when authority is refused"
);
assert_no_gh_pr_mutation(&log_path);
}
#[cfg(unix)]
#[test]
fn publish_refuses_when_registry_inaccessible_before_any_push() {
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_record_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/authority-inaccessible";
repo.create_branch(branch);
repo.create_file("task.txt", "task work\n");
repo.stage_all();
repo.commit("task commit");
let _task = register_task(home.path(), repo.path(), branch, &base);
make_registry_inaccessible(&home.path().join("loopflow.db"));
let err = create_or_update_pr(
repo.path(),
&PrOptions {
title: Some("inaccessible".to_string()),
body: Some("authority proof".to_string()),
agent: None,
},
&NullProgress,
)
.expect_err("inaccessible registry must refuse before any push");
let message = err.to_string();
assert!(
message.contains("authority refused"),
"expected an authority refusal, got: {message}"
);
assert!(
message.contains("incompatible"),
"refusal must name the inaccessible/incompatible registry, got: {message}"
);
assert!(
!remote_branch_exists(&repo, branch),
"the branch must never reach the remote when authority is refused"
);
assert_no_gh_pr_mutation(&log_path);
}
#[cfg(unix)]
#[test]
fn land_refuses_when_registry_inaccessible_before_any_push() {
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_record_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/authority-inaccessible-land";
repo.create_branch(branch);
repo.create_file("task.txt", "task work\n");
repo.stage_all();
repo.commit("task commit");
let _task = register_task(home.path(), repo.path(), branch, &base);
make_registry_inaccessible(&home.path().join("loopflow.db"));
let err = land(
repo.path(),
&land_options(true, "inaccessible land"),
&NullProgress,
)
.expect_err("inaccessible registry must refuse before any push");
let message = err.to_string();
assert!(
message.contains("authority refused"),
"expected an authority refusal, got: {message}"
);
assert!(
!remote_branch_exists(&repo, branch),
"the branch must never reach the remote when authority is refused"
);
assert_no_gh_pr_mutation(&log_path);
}
#[cfg(unix)]
#[test]
fn task_stack_refuses_when_registry_inaccessible() {
let home = tempfile::TempDir::new().expect("temp home");
let repo = TestRepo::new();
let base = repo.head_sha();
let _env = EnvGuard::with_lf_home(&[("open", noop_open_script())], home.path());
let branch = "jack/authority-stack";
repo.create_branch(branch);
repo.create_file("task.txt", "task work\n");
repo.stage_all();
repo.commit("task commit");
let _task = register_task(home.path(), repo.path(), branch, &base);
make_registry_inaccessible(&home.path().join("loopflow.db"));
let err = task_stack(repo.path()).expect_err("inaccessible registry must refuse stacking");
let message = err.to_string();
assert!(
message.contains("authority refused"),
"expected an authority refusal, got: {message}"
);
}
#[test]
fn publish_refuses_when_registry_schema_incompatible_before_any_push() {
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_record_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/authority-schema-incompatible";
repo.create_branch(branch);
repo.create_file("task.txt", "task work\n");
repo.stage_all();
repo.commit("task commit");
let _task = register_task(home.path(), repo.path(), branch, &base);
let db = home.path().join("loopflow.db");
for sidecar in ["loopflow.db-wal", "loopflow.db-shm"] {
let _ = std::fs::remove_file(home.path().join(sidecar));
}
std::fs::write(&db, b"not a sqlite database").expect("corrupt the registry");
let err = create_or_update_pr(
repo.path(),
&PrOptions {
title: Some("schema-incompatible".to_string()),
body: Some("authority proof".to_string()),
agent: None,
},
&NullProgress,
)
.expect_err("schema-incompatible registry must refuse before any push");
let message = err.to_string();
assert!(
message.contains("authority refused"),
"expected an authority refusal, got: {message}"
);
assert!(
message.contains("incompatible"),
"refusal must name the incompatible registry, got: {message}"
);
assert!(
!remote_branch_exists(&repo, branch),
"the branch must never reach the remote when authority is refused"
);
assert_no_gh_pr_mutation(&log_path);
}
#[test]
fn valid_authority_publishes_and_records_the_pr() {
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_record_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/authority-valid";
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, &base);
create_or_update_pr(
repo.path(),
&PrOptions {
title: Some("valid authority".to_string()),
body: Some("authority proof".to_string()),
agent: None,
},
&NullProgress,
)
.expect("valid authority publishes");
assert!(
remote_branch_exists(&repo, branch),
"the Task branch must be pushed under valid authority"
);
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!(
pr.publication.is_some(),
"publication must be recorded under valid authority, not degraded to generic"
);
assert!(
pr.publication.as_ref().unwrap().merge.is_none(),
"publication alone must not request a merge"
);
let github = pr
.github()
.expect("GitHub PR must be attached under valid authority");
assert_eq!(github.number, 1, "the published PR number must be attached");
}
#[test]
fn ordinary_pr_publishes_when_worktree_is_not_a_task_worktree() {
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_record_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 elsewhere = tempfile::TempDir::new().expect("unrelated worktree");
let _unrelated = register_task(home.path(), elsewhere.path(), "jack/elsewhere", &base);
let branch = "jack/ordinary-pr";
repo.create_branch(branch);
repo.create_file("task.txt", "ordinary work\n");
repo.stage_all();
repo.commit("ordinary commit");
repo.push_new_branch(branch);
create_or_update_pr(
repo.path(),
&PrOptions {
title: Some("ordinary non-Task PR".to_string()),
body: Some("not a task".to_string()),
agent: None,
},
&NullProgress,
)
.expect("ordinary non-Task PR publishes");
assert!(
remote_branch_exists(&repo, branch),
"an ordinary non-Task PR must still push and publish"
);
}
#[test]
fn ordinary_pr_publishes_when_no_registry_exists() {
let home = tempfile::TempDir::new().expect("temp home");
let repo = TestRepo::new();
let log_path = home.path().join("gh.log");
let script = gh_record_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/ordinary-no-registry";
repo.create_branch(branch);
repo.create_file("task.txt", "ordinary work\n");
repo.stage_all();
repo.commit("ordinary commit");
repo.push_new_branch(branch);
create_or_update_pr(
repo.path(),
&PrOptions {
title: Some("ordinary PR no registry".to_string()),
body: Some("not a task".to_string()),
agent: None,
},
&NullProgress,
)
.expect("ordinary non-Task PR publishes without any registry");
assert!(
remote_branch_exists(&repo, branch),
"an ordinary non-Task PR must still push and publish with no registry"
);
}
#[test]
fn managed_task_submit_assigns_for_human_review() {
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_record_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/managed-submit";
repo.create_branch(branch);
repo.create_file("task.txt", "task work\n");
repo.stage_all();
repo.commit("task commit");
let _task = register_task(home.path(), repo.path(), branch, &base);
submit(
repo.path(),
&land_options(true, "managed submit"),
&NullProgress,
)
.expect("managed Task submits for human review");
assert!(
remote_branch_exists(&repo, branch),
"submit must publish the Task branch"
);
let log = fs::read_to_string(&log_path).unwrap_or_default();
assert!(
log.contains("pr ready") && log.contains("pr edit --add-assignee @me"),
"submit must prepare the Task PR for a human merge, got log:\n{log}"
);
assert!(
!log.contains("pr merge"),
"submit must not arm or merge the Task PR, got log:\n{log}"
);
}
#[test]
fn ordinary_submit_still_assigns_for_review() {
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_record_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 elsewhere = tempfile::TempDir::new().expect("unrelated worktree");
let _unrelated = register_task(home.path(), elsewhere.path(), "jack/elsewhere", &base);
let branch = "jack/ordinary-submit";
repo.create_branch(branch);
repo.create_file("task.txt", "ordinary work\n");
repo.stage_all();
repo.commit("ordinary commit");
repo.push_new_branch(branch);
submit(
repo.path(),
&land_options(true, "ordinary submit"),
&NullProgress,
)
.expect("ordinary non-Task submit still assigns for review");
let log = fs::read_to_string(&log_path).unwrap_or_default();
assert!(
log.contains("pr edit --add-assignee @me"),
"ordinary submit must assign the PR for a human merge, got log:\n{log}"
);
assert!(
!log.contains("merge --auto"),
"submit must never arm auto-merge, got log:\n{log}"
);
}