use std::process::{Command, Stdio};
use serde_json::Value;
use crate::supervise::cleanup::VIA_MERGE_RECONCILED;
use octl_core::VIA_EXPLICIT_MERGE;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LandedMethod {
GitVerified,
ReportMarker,
Unverified,
}
impl LandedMethod {
pub(crate) fn wire(self) -> &'static str {
match self {
LandedMethod::GitVerified => "git-verified",
LandedMethod::ReportMarker => "report-marker",
LandedMethod::Unverified => "unverified",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct LandingSignal {
pub landed: bool,
pub method: LandedMethod,
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct LandingInputs<'a> {
pub source_repo: Option<&'a str>,
pub source_branch: Option<&'a str>,
pub worktree_path: Option<&'a str>,
pub branch: Option<&'a str>,
pub base_sha: Option<&'a str>,
pub report: Option<&'a Value>,
}
pub(crate) fn landing_signal(inputs: &LandingInputs<'_>, git: &str) -> LandingSignal {
match git_verify_landed(inputs, git) {
Some(true) => LandingSignal {
landed: true,
method: LandedMethod::GitVerified,
},
Some(false) => LandingSignal {
landed: false,
method: LandedMethod::GitVerified,
},
None if report_has_merge_marker(inputs.report) => LandingSignal {
landed: true,
method: LandedMethod::ReportMarker,
},
None => LandingSignal {
landed: false,
method: LandedMethod::Unverified,
},
}
}
fn report_has_merge_marker(report: Option<&Value>) -> bool {
let Some(report) = report else {
return false;
};
let success = report.get("success").and_then(Value::as_bool) == Some(true);
success
&& matches!(
report.get("via").and_then(Value::as_str),
Some(VIA_EXPLICIT_MERGE | VIA_MERGE_RECONCILED)
)
}
fn git_verify_landed(inputs: &LandingInputs<'_>, git: &str) -> Option<bool> {
let repo = safe_arg(inputs.source_repo).or_else(|| safe_arg(inputs.worktree_path))?;
let source = safe_arg(inputs.source_branch)?;
let branch = safe_arg(inputs.branch)?;
let base = safe_arg(inputs.base_sha);
let mut cmd = Command::new(git);
cmd.arg("-C").arg(repo).args(["cherry", source, branch]);
if let Some(base) = base {
cmd.arg(base);
}
let out = cmd.stderr(Stdio::null()).output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let mut saw_commit = false;
let mut all_present = true;
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
saw_commit = true;
if line.starts_with('+') {
all_present = false;
} else if line.starts_with('-') {
} else {
return None;
}
}
if saw_commit && all_present {
return Some(true);
}
if git_is_ancestor(git, repo, branch, source)
&& branch_advanced_past_base(git, repo, base, branch)
{
return Some(true);
}
if saw_commit {
Some(false)
} else {
None
}
}
fn git_is_ancestor(git: &str, repo: &str, ancestor: &str, descendant: &str) -> bool {
Command::new(git)
.arg("-C")
.arg(repo)
.args(["merge-base", "--is-ancestor", ancestor, descendant])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn branch_advanced_past_base(git: &str, repo: &str, base: Option<&str>, branch: &str) -> bool {
let Some(base) = base else {
return true;
};
let out = Command::new(git)
.arg("-C")
.arg(repo)
.args(["rev-list", "--count", &format!("{base}..{branch}")])
.stderr(Stdio::null())
.output();
match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.trim()
.parse::<u64>()
.is_ok_and(|count| count > 0),
_ => false,
}
}
fn safe_arg(s: Option<&str>) -> Option<&str> {
s.map(str::trim)
.filter(|s| !s.is_empty() && !s.starts_with('-'))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::path::Path;
use std::process::Command;
use crate::supervise::cleanup::git_bin;
fn git(repo: &Path, args: &[&str]) -> String {
let out = Command::new(git_bin())
.arg("-C")
.arg(repo)
.args(args)
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn repo_with_rebase_replayed_merge() -> (tempfile::TempDir, String, String) {
let dir = tempfile::TempDir::new().unwrap();
let repo = dir.path();
git(repo, &["init", "-q", "-b", "main"]);
git(repo, &["config", "user.email", "t@t"]);
git(repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("f"), "base\n").unwrap();
git(repo, &["add", "f"]);
git(repo, &["commit", "-qm", "base"]);
let base = git(repo, &["rev-parse", "HEAD"]);
git(repo, &["checkout", "-q", "-b", "wt/worker"]);
std::fs::write(repo.join("f"), "base\nwork\n").unwrap();
git(repo, &["commit", "-qam", "worker change"]);
git(repo, &["checkout", "-q", "main"]);
std::fs::write(repo.join("g"), "other\n").unwrap();
git(repo, &["add", "g"]);
git(repo, &["commit", "-qm", "other session"]);
let worker_tip = git(repo, &["rev-parse", "wt/worker"]);
git(repo, &["checkout", "-q", "-b", "replay", &worker_tip]);
git(repo, &["rebase", "-q", "main"]);
git(repo, &["checkout", "-q", "main"]);
git(repo, &["merge", "-q", "--ff-only", "replay"]);
git(repo, &["branch", "-q", "-D", "replay"]);
git(repo, &["checkout", "-q", "-b", "tmp", &base]);
std::fs::write(repo.join("h"), "upstream\n").unwrap();
git(repo, &["add", "h"]);
git(repo, &["commit", "-qm", "origin moved"]);
git(repo, &["checkout", "-q", "main"]);
git(repo, &["rebase", "-q", "tmp"]);
(dir, base, "wt/worker".to_string())
}
#[test]
fn git_verified_landed_survives_caller_rebase() {
let (dir, base, branch) = repo_with_rebase_replayed_merge();
let repo = dir.path().to_str().unwrap();
let is_ancestor = Command::new(git_bin())
.arg("-C")
.arg(repo)
.args(["merge-base", "--is-ancestor", &branch, "main"])
.status()
.unwrap()
.success();
assert!(
!is_ancestor,
"the rebase-replay case must make --is-ancestor lie (return false)"
);
let inputs = LandingInputs {
source_repo: Some(repo),
source_branch: Some("main"),
branch: Some(&branch),
base_sha: Some(&base),
..Default::default()
};
let sig = landing_signal(&inputs, &git_bin());
assert_eq!(
sig,
LandingSignal {
landed: true,
method: LandedMethod::GitVerified
},
"patch-id equivalence must confirm the landing after a caller rebase"
);
}
#[test]
fn git_verified_not_landed_for_unmerged_branch() {
let dir = tempfile::TempDir::new().unwrap();
let repo = dir.path();
git(repo, &["init", "-q", "-b", "main"]);
git(repo, &["config", "user.email", "t@t"]);
git(repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("f"), "base\n").unwrap();
git(repo, &["add", "f"]);
git(repo, &["commit", "-qm", "base"]);
let base = git(repo, &["rev-parse", "HEAD"]);
git(repo, &["checkout", "-q", "-b", "wt/worker"]);
std::fs::write(repo.join("f"), "base\nwork\n").unwrap();
git(repo, &["commit", "-qam", "unmerged work"]);
git(repo, &["checkout", "-q", "main"]);
let repo_s = repo.to_str().unwrap();
let inputs = LandingInputs {
source_repo: Some(repo_s),
source_branch: Some("main"),
branch: Some("wt/worker"),
base_sha: Some(&base),
..Default::default()
};
let sig = landing_signal(&inputs, &git_bin());
assert_eq!(
sig,
LandingSignal {
landed: false,
method: LandedMethod::GitVerified
},
"a branch with a commit absent from the target is not landed"
);
}
#[test]
fn falls_back_to_report_marker_when_branch_gone() {
let report = json!({ "success": true, "via": "explicit-merge" });
let inputs = LandingInputs {
report: Some(&report),
..Default::default()
};
let sig = landing_signal(&inputs, "git");
assert_eq!(
sig,
LandingSignal {
landed: true,
method: LandedMethod::ReportMarker
}
);
let reconciled = json!({ "success": true, "via": "merge-reconciled" });
let inputs = LandingInputs {
report: Some(&reconciled),
..Default::default()
};
assert_eq!(
landing_signal(&inputs, "git").method,
LandedMethod::ReportMarker
);
}
#[test]
fn unverified_when_nothing_confirms() {
let report = json!({ "success": false, "summary": "blocked on X" });
let inputs = LandingInputs {
report: Some(&report),
..Default::default()
};
let sig = landing_signal(&inputs, "git");
assert_eq!(
sig,
LandingSignal {
landed: false,
method: LandedMethod::Unverified
}
);
}
#[test]
fn git_positive_beats_absent_marker_and_marker_beats_git_none() {
let (dir, base, branch) = repo_with_rebase_replayed_merge();
let repo = dir.path().to_str().unwrap();
let inputs = LandingInputs {
source_repo: Some(repo),
source_branch: Some("main"),
branch: Some(&branch),
base_sha: Some(&base),
worktree_path: None,
report: None,
};
assert_eq!(
landing_signal(&inputs, &git_bin()).method,
LandedMethod::GitVerified
);
let report = json!({ "success": true, "via": "explicit-merge" });
let inputs = LandingInputs {
source_repo: Some(repo),
source_branch: Some("main"),
branch: Some("does/not/exist"),
base_sha: Some(&base),
worktree_path: None,
report: Some(&report),
};
assert_eq!(
landing_signal(&inputs, &git_bin()),
LandingSignal {
landed: true,
method: LandedMethod::ReportMarker
}
);
}
#[test]
fn ancestry_net_confirms_fast_forward_landing_without_base_or_marker() {
let dir = tempfile::TempDir::new().unwrap();
let repo = dir.path();
git(repo, &["init", "-q", "-b", "main"]);
git(repo, &["config", "user.email", "t@t"]);
git(repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("f"), "base\n").unwrap();
git(repo, &["add", "f"]);
git(repo, &["commit", "-qm", "base"]);
git(repo, &["checkout", "-q", "-b", "wt/worker"]);
std::fs::write(repo.join("f"), "base\nwork\n").unwrap();
git(repo, &["commit", "-qam", "worker change"]);
git(repo, &["checkout", "-q", "main"]);
git(repo, &["merge", "-q", "--ff-only", "wt/worker"]);
let repo_s = repo.to_str().unwrap();
let inputs = LandingInputs {
source_repo: Some(repo_s),
source_branch: Some("main"),
branch: Some("wt/worker"),
base_sha: None,
report: None,
..Default::default()
};
assert_eq!(
landing_signal(&inputs, &git_bin()),
LandingSignal {
landed: true,
method: LandedMethod::GitVerified
},
"the ancestry net must confirm a fast-forward landing cherry can't range"
);
}
#[test]
fn ancestry_net_declines_never_advanced_branch() {
let dir = tempfile::TempDir::new().unwrap();
let repo = dir.path();
git(repo, &["init", "-q", "-b", "main"]);
git(repo, &["config", "user.email", "t@t"]);
git(repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("f"), "base\n").unwrap();
git(repo, &["add", "f"]);
git(repo, &["commit", "-qm", "base"]);
let base = git(repo, &["rev-parse", "HEAD"]);
git(repo, &["branch", "wt/worker"]);
std::fs::write(repo.join("g"), "more\n").unwrap();
git(repo, &["add", "g"]);
git(repo, &["commit", "-qm", "main advances"]);
let repo_s = repo.to_str().unwrap();
let inputs = LandingInputs {
source_repo: Some(repo_s),
source_branch: Some("main"),
branch: Some("wt/worker"),
base_sha: Some(&base),
report: None,
..Default::default()
};
assert_eq!(
landing_signal(&inputs, &git_bin()),
LandingSignal {
landed: false,
method: LandedMethod::Unverified
},
"a never-advanced branch that merged nothing must not read as landed"
);
}
#[test]
fn git_negative_overrides_stale_marker_on_post_merge_work() {
let dir = tempfile::TempDir::new().unwrap();
let repo = dir.path();
git(repo, &["init", "-q", "-b", "main"]);
git(repo, &["config", "user.email", "t@t"]);
git(repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("f"), "base\n").unwrap();
git(repo, &["add", "f"]);
git(repo, &["commit", "-qm", "base"]);
let base = git(repo, &["rev-parse", "HEAD"]);
git(repo, &["checkout", "-q", "-b", "wt/worker"]);
std::fs::write(repo.join("f"), "base\nA\n").unwrap();
git(repo, &["commit", "-qam", "commit A"]);
git(repo, &["checkout", "-q", "main"]);
git(repo, &["merge", "-q", "--ff-only", "wt/worker"]);
git(repo, &["checkout", "-q", "wt/worker"]);
std::fs::write(repo.join("f"), "base\nA\nB\n").unwrap();
git(repo, &["commit", "-qam", "commit B (unmerged)"]);
git(repo, &["checkout", "-q", "main"]);
let repo_s = repo.to_str().unwrap();
let report = json!({ "success": true, "via": "explicit-merge" });
let inputs = LandingInputs {
source_repo: Some(repo_s),
source_branch: Some("main"),
branch: Some("wt/worker"),
base_sha: Some(&base),
report: Some(&report),
..Default::default()
};
assert_eq!(
landing_signal(&inputs, &git_bin()),
LandingSignal {
landed: false,
method: LandedMethod::GitVerified
},
"git's live view of unlanded post-merge work must override a stale marker"
);
}
#[test]
fn report_marker_requires_success_true() {
assert!(!report_has_merge_marker(Some(&json!({
"success": false, "via": "explicit-merge"
}))));
assert!(!report_has_merge_marker(Some(
&json!({ "via": "explicit-merge" })
)));
assert!(report_has_merge_marker(Some(&json!({
"success": true, "via": "merge-reconciled"
}))));
assert!(!report_has_merge_marker(Some(&json!({
"success": true, "via": "watchdog"
}))));
}
#[test]
fn safe_arg_rejects_option_injection() {
assert_eq!(safe_arg(Some("wt/worker")), Some("wt/worker"));
assert_eq!(safe_arg(Some(" main ")), Some("main"));
assert_eq!(safe_arg(Some("-v")), None);
assert_eq!(safe_arg(Some("--abbrev=1")), None);
assert_eq!(safe_arg(Some("")), None);
assert_eq!(safe_arg(None), None);
}
}