Update agent skill documentation to prohibit merging on CI failure
Intent: Strengthen the written guidance for AI agents using the land skill so they never merge when CI is red, unless the user explicitly overrides the guardrail.
Affected files: .agents/skills/land/SKILL.md
Evidence
@@ -65,7 +65,7 @@ If CI fails:
-Fix the issue, push again, and repeat.
+Do not merge. Fix the issue, push again, and repeat.
@@ -126,7 +126,8 @@ Focus on:
-instead of pretending the landing path is healthy.
+instead of pretending the landing path is healthy. Do not merge with failing CI unless the user
+explicitly instructs you to override that guardrail.
Two documentation changes in SKILL.md make the policy unambiguous:
- The "If CI fails" section now begins with "Do not merge." before the instruction to fix and retry.
- The closing guidance paragraph adds an explicit statement: merging with failing CI is forbidden unless the user explicitly instructs an override.
These changes ensure that any agent consuming this skill file treats red CI as a hard stop rather than a suggestion.
Add `--force` flag to the `Merge` CLI subcommand
Intent: Expose a `--force` boolean flag on the `ph merge` subcommand so users can explicitly opt in to merging despite CI failures.
Affected files: crates/ph/src/lib.rs
Evidence
@@ -75,6 +78,8 @@ enum PhCommand {
Merge {
branch_or_id: Option<String>,
+ #[arg(long)]
+ force: bool,
},
@@ -28,7 +28,10 @@ pub fn run() -> anyhow::Result<()> {
- PhCommand::Merge { branch_or_id } => commands::cmd_merge(&cli, branch_or_id.as_deref()),
+ PhCommand::Merge {
+ branch_or_id,
+ force,
+ } => commands::cmd_merge(&cli, branch_or_id.as_deref(), *force),
The PhCommand::Merge variant gains a new force: bool field annotated with #[arg(long)], making it available as ph merge --force <branch>. The dispatch in run() is updated to destructure and forward the flag to cmd_merge.
Implement CI status guard in `cmd_merge`
Intent: Fetch branch details before merging and block the operation when CI has failed, unless `--force` is passed.
Affected files: crates/ph/src/commands.rs
Evidence
@@ -111,11 +111,13 @@ pub(crate) fn cmd_merge(
-pub(crate) fn cmd_merge(cli: &Cli, branch_or_id: Option<&str>) -> anyhow::Result<()> {
+pub(crate) fn cmd_merge(cli: &Cli, branch_or_id: Option<&str>, force: bool) -> anyhow::Result<()> {
let resolved = resolve_branch_ref(&api, branch_or_id)?;
+ let branch = api.branch_detail(resolved.branch_id)?;
+ ensure_merge_ci_guard(&branch, force)?;
let response = api.merge_branch(resolved.branch_id)?;
@@ -282,6 +284,17 @@ pub(crate) fn branch_ci_active(branch: &BranchDetailResponse) -> bool {
+fn ensure_merge_ci_guard(branch: &BranchDetailResponse, force: bool) -> anyhow::Result<()> {
+ if force || branch.branch.ci_status.is_success() || branch_ci_active(branch) {
+ return Ok(());
+ }
+ Err(anyhow!(
+ "refusing to merge branch #{} because ci is {}. Rerun or fix CI, or pass --force if absolutely necessary; --force is discouraged under normal circumstances.",
+ branch.branch.branch_id,
+ branch.branch.ci_status
+ ))
+}
The core logic lives in the new ensure_merge_ci_guard function at crates/ph/src/commands.rs:287. It allows the merge to proceed in exactly three situations:
force is true — the user explicitly opted in.
- CI status is success — checked via
ci_status.is_success().
- CI is still running — checked via the existing
branch_ci_active() helper, which looks for any lane in a non-terminal state.
In all other cases (failed, errored, etc.) the function returns an anyhow::Error with a message that names the branch, shows the current CI status, and tells the user about --force.
cmd_merge itself now calls api.branch_detail(resolved.branch_id) immediately after resolving the branch reference, then passes the response through the guard before issuing the actual merge request. This means no merge HTTP call is made when the guard rejects.
Update existing merge test to supply branch detail endpoint
Intent: The existing `merge_and_close_use_authenticated_json_endpoints` test must now serve a `/git/api/forge/branch/11` route (the detail endpoint) and pass the new `force` parameter to `cmd_merge`.
Affected files: crates/ph/src/tests.rs
Evidence
@@ -406,6 +406,53 @@ fn merge_and_close_use_authenticated_json_endpoints() {
+ .route(
+ "/git/api/forge/branch/11",
+ get(|| async {
+ Json(serde_json::json!({
+ ...
+ "ci_status": "success",
@@ -453,7 +500,7 @@ fn merge_and_close_use_authenticated_json_endpoints() {
- cmd_merge(&cli, Some("feature/merge"), false).expect("merge");
+ cmd_merge(&cli, Some("feature/merge"), false).expect("merge");
Because cmd_merge now fetches branch details, the mock server in the existing test needs to serve that endpoint. A new route for /git/api/forge/branch/11 returns a branch with ci_status: "success" and a single successful CI run, keeping the test green. The call site is updated to pass false for the force parameter.
Add test: merge is blocked when CI has failed
Intent: Verify that `cmd_merge` returns an error containing `--force` when CI status is `failed` and `force` is `false`, and that no merge HTTP call is made.
Affected files: crates/ph/src/tests.rs
Evidence
@@ +513,226 @@
+#[test]
+fn merge_refuses_failed_ci_without_force() {
+ ...
+ let err = cmd_merge(&cli, Some("feature/failing-merge"), false).expect_err("merge blocked");
+ assert!(err.to_string().contains("--force"));
+ assert_eq!(merge_auth.load(Ordering::SeqCst), 0);
+}
The merge_refuses_failed_ci_without_force test stands up a mock server with branch 12 whose ci_status is "failed". It calls cmd_merge with force: false and asserts:
- The call returns an error.
- The error message contains
"--force" so the user knows how to override.
- The
merge_auth counter remains at 0, confirming the merge endpoint was never hit.
This directly validates the happy path of the guardrail.
Add test: `--force` bypasses the CI guard
Intent: Verify that passing `force: true` allows a merge to proceed even when CI status is `failed`.
Affected files: crates/ph/src/tests.rs
Evidence
@@ +513,226 @@
+#[test]
+fn merge_force_overrides_failed_ci_guard() {
+ ...
+ cmd_merge(&cli, Some("feature/forced-merge"), true).expect("forced merge");
+ assert_eq!(merge_auth.load(Ordering::SeqCst), 1);
+}
The merge_force_overrides_failed_ci_guard test uses branch 13 with ci_status: "failed" but calls cmd_merge with force: true. It asserts:
- The call succeeds (no error).
- The merge endpoint was hit exactly once (
merge_auth counter is 1).
Together with the previous test, this provides full coverage of both the block and override paths of ensure_merge_ci_guard.