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::work::task::{
AfterMerge, GithubPr, PrMergeMode, PrMergeRequest, PrPresentation, PrPublication,
};
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 {
draft: false,
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 {
draft: false,
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 {
draft: false,
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");
}
fn gh_publication_script(home: &std::path::Path) -> String {
let home = home.display();
format!(
r#"#!/bin/sh
if [ "$1" = "--version" ]; then
echo 'gh version 1.0.0'
exit 0
fi
echo "$@" >> "{home}/gh.log"
case "$1 $2" in
'pr list')
if [ ! -f "{home}/created" ]; then
echo '[]'
elif [ -f "{home}/fail-read" ]; then
echo 'fixture follow-up read failed' >&2
exit 1
else
cat "{home}/pr.json"
fi
;;
'pr create')
touch "{home}/created"
echo 'https://example.com/pr/7'
;;
'pr ready')
if [ -f "{home}/fail-ready" ]; then
echo 'fixture readiness failed' >&2
exit 1
fi
;;
esac
"#
)
}
#[test]
fn acknowledged_creation_survives_failed_read_and_retries_without_duplicate() {
let home = tempfile::TempDir::new().unwrap();
let repo = TestRepo::new();
let base = repo.head_sha();
let script = gh_publication_script(home.path());
let _env = EnvGuard::with_lf_home(&[("gh", script.as_str())], home.path());
let branch = "jack/acknowledged-publication";
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);
let head = repo.head_sha();
fs::write(
home.path().join("pr.json"),
serde_json::json!([{
"url": "https://example.com/pr/7", "number": 7,
"state": "OPEN", "isDraft": false, "headRefOid": head
}])
.to_string(),
)
.unwrap();
fs::write(home.path().join("fail-read"), "").unwrap();
let options = PrOptions {
title: Some("acknowledged publication".to_string()),
body: Some("publication preservation proof".to_string()),
agent: None,
draft: false,
};
let error = create_or_update_pr(repo.path(), &options, &NullProgress).unwrap_err();
assert!(
error.to_string().contains("fixture follow-up read failed"),
"{error}"
);
let runtime = tokio::runtime::Runtime::new().unwrap();
let read_pr = || {
runtime
.block_on(task.store.active_task_pr(&task.task.id))
.unwrap()
.unwrap()
};
let acknowledged = read_pr();
assert_eq!(acknowledged.id, task.pr.id);
assert_eq!(acknowledged.parent_pr_id, task.pr.parent_pr_id);
let github = acknowledged.github().unwrap();
assert_eq!(github.number, 7);
assert_eq!(github.url, "https://example.com/pr/7");
assert_eq!(github.head_sha, None, "creation did not report a head");
assert!(acknowledged.linear_link_error.is_some());
fs::remove_file(home.path().join("fail-read")).unwrap();
let result = create_or_update_pr(repo.path(), &options, &NullProgress).unwrap();
assert!(!result.created);
let retried = read_pr();
assert_eq!(retried.id, acknowledged.id);
assert_eq!(retried.github().unwrap().number, 7);
assert_eq!(
retried.github().unwrap().head_sha.as_deref(),
Some(head.as_str())
);
assert!(retried.linear_link_error.is_some());
let log = fs::read_to_string(home.path().join("gh.log")).unwrap();
assert_eq!(
log.lines()
.filter(|line| line.starts_with("pr create "))
.count(),
1
);
}
#[test]
fn existing_pr_identity_survives_readiness_failure() {
let home = tempfile::TempDir::new().unwrap();
let repo = TestRepo::new();
let base = repo.head_sha();
let script = gh_publication_script(home.path());
let _env = EnvGuard::with_lf_home(&[("gh", script.as_str())], home.path());
let branch = "jack/failed-readiness";
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);
let head = repo.head_sha();
let runtime = tokio::runtime::Runtime::new().unwrap();
let now = time::OffsetDateTime::now_utc();
let mut pinned = task.pr.clone();
pinned.publication = Some(PrPublication {
requested_at: now,
presentation: Some(PrPresentation {
title: "Previous head".into(),
body: "Retained review".into(),
head_sha: base.clone(),
}),
github: Some(GithubPr {
number: 7,
url: "https://example.com/pr/7".into(),
head_sha: Some(base.clone()),
}),
merge: Some(PrMergeRequest {
mode: PrMergeMode::User,
requested_at: now,
head_sha: base.clone(),
after_merge: AfterMerge::CompleteTask,
next_slug: None,
}),
});
runtime
.block_on(task.store.update_task_pr(&pinned))
.unwrap();
fs::write(
home.path().join("pr.json"),
serde_json::json!([{
"url": "https://example.com/pr/7", "number": 7,
"state": "OPEN", "isDraft": true, "headRefOid": head
}])
.to_string(),
)
.unwrap();
fs::write(home.path().join("created"), "").unwrap();
fs::write(home.path().join("fail-ready"), "").unwrap();
let error = create_or_update_pr(
repo.path(),
&PrOptions {
title: Some("known draft".to_string()),
body: Some("readiness preservation proof".to_string()),
agent: None,
draft: false,
},
&NullProgress,
)
.unwrap_err();
assert!(
error.to_string().contains("fixture readiness failed"),
"{error}"
);
let pr = runtime
.block_on(task.store.active_task_pr(&task.task.id))
.unwrap()
.unwrap();
assert_eq!(pr.id, task.pr.id);
let github = pr.github().unwrap();
assert_eq!(github.number, 7);
assert_eq!(github.url, "https://example.com/pr/7");
assert_eq!(github.head_sha.as_deref(), Some(head.as_str()));
assert!(
pr.merge_request().is_none(),
"the previous head cannot retain merge intent"
);
let log = fs::read_to_string(home.path().join("gh.log")).unwrap();
assert!(!log.lines().any(|line| line.starts_with("pr create ")));
}
#[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 {
draft: false,
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 {
draft: false,
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 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 manual merging, 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 manual merging, got log:\n{log}"
);
assert!(
!log.contains("merge --auto"),
"submit must never arm auto-merge, got log:\n{log}"
);
}