use std::path::Path;
use crate::core::worktree_landed_content::{
LandedContent, LandingAdmission, landing_admission, landing_admission_on_fetched_refs,
};
use crate::core::worktree_landed_history::ContentOnBase;
use crate::session_manager::worktree_landing_refresh::refresh_landing_refs_within;
use crate::session_manager::worktree_reclaim_gh::{
GH_TIMEOUT, gh_pr_list_command, resolve_daemon_gh_env,
};
use crate::session_manager::worktree_repo_slug::{
first_merged, merged_pr_search_repos, repo_slug_for,
};
use crate::session_manager::worktree_safety::{count_dirty_files, git_stdout};
const MERGED_PR_ARGS: &[&str] = &[
"--state",
"merged",
"--json",
"number,baseRefName,headRefOid,mergeCommit",
"--limit",
"1",
];
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct MergedPrLookup {
pub count: usize,
pub repo: String,
pub base_ref: String,
pub head_sha: String,
pub merge_commit: String,
}
impl MergedPrLookup {
#[must_use]
pub fn new(count: usize, repo: impl Into<String>, base_ref: impl Into<String>) -> Self {
Self {
count,
repo: repo.into(),
base_ref: base_ref.into(),
head_sha: String::new(),
merge_commit: String::new(),
}
}
#[must_use]
pub fn with_merge_commit(mut self, merge_commit: impl Into<String>) -> Self {
self.merge_commit = merge_commit.into();
self
}
#[must_use]
pub fn with_head_sha(mut self, head_sha: impl Into<String>) -> Self {
self.head_sha = head_sha.into();
self
}
}
const DETACHED_HEAD: &str = "HEAD";
const NOT_IMPLEMENTED: &str =
"this `WorktreeRemovalProbe` implementation does not answer that question";
pub(crate) const LOCAL_ONLY_COMMITS_ARGS: &[&str] =
&["rev-list", "--count", "HEAD", "--not", "--remotes=origin"];
const ADMISSION_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum UpstreamComparison {
Ahead(usize),
NoUpstream,
}
pub trait WorktreeRemovalProbe {
fn dirty_entries(&self, dir: &Path) -> Result<usize, String>;
fn unpushed_commits(&self, dir: &Path) -> Result<UpstreamComparison, String>;
fn branch(&self, dir: &Path) -> Result<String, String>;
fn head_sha(&self, _dir: &Path) -> Result<String, String> {
Err(NOT_IMPLEMENTED.to_string())
}
fn merged_pull_request_for_commit(
&self,
_dir: &Path,
_sha: &str,
) -> Result<MergedPrLookup, String> {
Err(NOT_IMPLEMENTED.to_string())
}
fn local_only_commits(&self, dir: &Path) -> Result<usize, String>;
fn commits_after_merged_head(
&self,
_dir: &Path,
_pr_head: &str,
) -> Result<Vec<String>, String> {
Err(NOT_IMPLEMENTED.to_string())
}
fn is_ancestor(&self, _dir: &Path, _ancestor: &str, _descendant: &str) -> Result<bool, String> {
Err(NOT_IMPLEMENTED.to_string())
}
fn merged_pull_requests(&self, dir: &Path, branch: &str) -> Result<MergedPrLookup, String>;
fn content_on_base(&self, dir: &Path, base_ref: &str) -> Result<ContentOnBase, String>;
fn landing_admission(&self, _dir: &Path) -> LandingAdmission {
LandedContent::unavailable(NOT_IMPLEMENTED).into()
}
fn landing_admission_on_fetched_refs(&self, dir: &Path) -> LandingAdmission {
self.landing_admission(dir)
}
fn nested_dirt(&self, _dir: &Path) -> Result<Option<String>, String> {
Err(NOT_IMPLEMENTED.to_string())
}
}
fn pr_opened_from_commit<'a>(
rows: &'a [crate::session_manager::worktree_reclaim_pr_match::MergedPrHead],
sha: &str,
) -> Option<&'a crate::session_manager::worktree_reclaim_pr_match::MergedPrHead> {
let sha = sha.trim();
if sha.is_empty() {
return None;
}
rows.iter()
.find(|row| !row.is_cross_repository && row.head_ref_oid.trim().eq_ignore_ascii_case(sha))
}
#[derive(Debug, Default, Clone, Copy)]
pub struct GitAndGhProbe;
impl WorktreeRemovalProbe for GitAndGhProbe {
fn dirty_entries(&self, dir: &Path) -> Result<usize, String> {
count_dirty_files(dir)
}
fn unpushed_commits(&self, dir: &Path) -> Result<UpstreamComparison, String> {
if !upstream_resolves(dir)? {
return Ok(UpstreamComparison::NoUpstream);
}
let out = git_stdout(dir, &["rev-list", "--count", "@{upstream}..HEAD"])?;
out.trim()
.parse::<usize>()
.map(UpstreamComparison::Ahead)
.map_err(|e| format!("`git rev-list --count` printed {:?}: {e}", out.trim()))
}
fn branch(&self, dir: &Path) -> Result<String, String> {
let name = git_stdout(dir, &["rev-parse", "--abbrev-ref", "HEAD"])?
.trim()
.to_string();
if name.is_empty() || name == DETACHED_HEAD {
return Err("HEAD is detached — the worktree has no branch to look a \
pull request up by"
.to_string());
}
Ok(name)
}
fn head_sha(&self, dir: &Path) -> Result<String, String> {
let sha = git_stdout(dir, &["rev-parse", "HEAD"])?.trim().to_string();
if sha.is_empty() {
return Err("`git rev-parse HEAD` named no commit".to_string());
}
Ok(sha)
}
fn merged_pull_request_for_commit(
&self,
dir: &Path,
sha: &str,
) -> Result<MergedPrLookup, String> {
let repo = repo_slug_for(dir)?;
let rows = crate::session_manager::worktree_reclaim_pr_match::merged_prs_containing_in(
dir, &repo, sha,
)?;
let matched = pr_opened_from_commit(&rows, sha);
Ok(
MergedPrLookup::new(usize::from(matched.is_some()), repo, "")
.with_head_sha(matched.map(|row| row.head_ref_oid.trim()).unwrap_or("")),
)
}
fn local_only_commits(&self, dir: &Path) -> Result<usize, String> {
refresh_landing_refs_within(dir, ADMISSION_FETCH_TIMEOUT).map_err(|e| {
format!(
"`origin` could not be refreshed, so the remote-tracking refs cannot be trusted \
to vouch for this worktree's commits: {e}"
)
})?;
let out = git_stdout(dir, LOCAL_ONLY_COMMITS_ARGS)?;
out.trim().parse::<usize>().map_err(|e| {
format!(
"`git {}` printed {:?}: {e}",
LOCAL_ONLY_COMMITS_ARGS.join(" "),
out.trim()
)
})
}
fn commits_after_merged_head(&self, dir: &Path, pr_head: &str) -> Result<Vec<String>, String> {
let pr_head = pr_head.trim();
if pr_head.is_empty() || !pr_head.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!(
"the merged pull request's head `{pr_head}` is not a commit id git can be asked \
about"
));
}
let args = [
"rev-list",
"--abbrev-commit",
"HEAD",
"--not",
pr_head,
"--remotes=origin",
];
let out = git_stdout(dir, &args)?;
Ok(out
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect())
}
fn is_ancestor(&self, dir: &Path, ancestor: &str, descendant: &str) -> Result<bool, String> {
for arg in [ancestor, descendant] {
if arg.is_empty() || arg.starts_with('-') || arg.contains(char::is_whitespace) {
return Err(format!("`{arg}` is not a commit git can be asked about"));
}
}
crate::session_manager::worktree_reclaim_pr_match::LandingProbe::is_ancestor(
&crate::session_manager::worktree_reclaim_pr_match::GhLandingProbe,
dir,
ancestor,
descendant,
)
}
fn merged_pull_requests(&self, dir: &Path, branch: &str) -> Result<MergedPrLookup, String> {
let repos = merged_pr_search_repos(dir, branch)?;
let pin_origin = repos.first().cloned().unwrap_or_default();
first_merged(
&repos,
|repo| merged_pull_requests_in(dir, branch, repo, &pin_origin),
|lookup: &MergedPrLookup| lookup.count > 0,
)
}
fn content_on_base(&self, dir: &Path, base_ref: &str) -> Result<ContentOnBase, String> {
crate::core::worktree_landed_history::content_on_base(dir, base_ref)
}
fn landing_admission(&self, dir: &Path) -> LandingAdmission {
landing_admission(
dir,
ADMISSION_FETCH_TIMEOUT,
&crate::session_manager::worktree_reclaim_pr_match::GhLandingProbe,
)
}
fn nested_dirt(&self, dir: &Path) -> Result<Option<String>, String> {
Ok(crate::session_manager::worktree_nested::nested_dirt(dir).map(|d| d.reason))
}
fn landing_admission_on_fetched_refs(&self, dir: &Path) -> LandingAdmission {
landing_admission_on_fetched_refs(
dir,
&crate::session_manager::worktree_reclaim_pr_match::GhLandingProbe,
)
}
}
fn merged_pull_requests_in(
dir: &Path,
branch: &str,
repo: &str,
pin_origin: &str,
) -> Result<MergedPrLookup, String> {
let repo = repo.to_string();
let stdout = crate::session_manager::worktree_reclaim_gh_gate::shared()
.poll(dir, &format!("merged-count:{repo}:{branch}"), || {
let gh_env = resolve_daemon_gh_env(dir, pin_origin)?;
let mut cmd = gh_pr_list_command(dir, &gh_env, &repo);
cmd.arg("--head").arg(branch);
cmd.args(MERGED_PR_ARGS);
crate::session_manager::worktree_reclaim_gh::run_with_timeout(cmd, GH_TIMEOUT)
})
.map_err(|f| format!("{f} (repository searched: {repo})"))?;
let rows: Vec<serde_json::Value> = serde_json::from_str(&stdout).map_err(|e| {
format!("`gh pr list --repo {repo} --head {branch}` JSON did not parse: {e}")
})?;
let base_ref = rows
.first()
.and_then(|r| r.get("baseRefName"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
let head_sha = rows
.first()
.and_then(|r| r.get("headRefOid"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
let merge_commit = rows
.first()
.and_then(|r| r.get("mergeCommit"))
.and_then(|m| m.get("oid"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
Ok(MergedPrLookup {
count: rows.len(),
repo,
base_ref,
head_sha,
merge_commit,
})
}
fn upstream_resolves(dir: &Path) -> Result<bool, String> {
if git_stdout(dir, &["rev-parse", "--symbolic-full-name", "@{upstream}"]).is_ok() {
return Ok(true);
}
git_stdout(dir, &["rev-parse", "--verify", "HEAD"]).map_err(|e| {
format!(
"`@{{upstream}}` did not resolve and neither did HEAD, so whether this \
worktree's commits reached a remote could not be established: {e}"
)
})?;
Ok(false)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::session_manager::decommission::WORKTREE_SENTINEL_FILE;
use crate::session_manager::worktree_reclaim_pr_match::MergedPrHead;
#[test]
fn merged_pull_request_argv_asks_github_for_the_branch() {
assert!(MERGED_PR_ARGS.contains(&"merged"));
assert!(MERGED_PR_ARGS.contains(&"--json"));
assert!(!MERGED_PR_ARGS.contains(&"all"));
assert!(
MERGED_PR_ARGS
.iter()
.any(|a| a.contains("headRefOid") && a.contains("baseRefName")),
"{MERGED_PR_ARGS:?}"
);
assert!(
MERGED_PR_ARGS.iter().any(|a| a.contains("mergeCommit")),
"{MERGED_PR_ARGS:?}"
);
}
#[test]
fn detached_head_is_not_a_branch() {
assert_eq!(DETACHED_HEAD, "HEAD");
}
fn row(oid: &str, fork: bool) -> MergedPrHead {
MergedPrHead {
number: 7794,
head_ref_oid: oid.to_string(),
is_cross_repository: fork,
}
}
#[test]
fn only_a_non_fork_row_whose_own_head_is_this_commit_matches() {
let head = "02a83032d1f0b4c9e7a6d5c4b3a291807f6e5d4c";
let other = "9c8699fe0a1b2c3d4e5f60718293a4b5c6d7e8f9";
for (label, rows, expected) in [
("no rows at all", vec![], false),
("this repository's own head", vec![row(head, false)], true),
(
"the same oid upper-cased",
vec![row(&head.to_uppercase(), false)],
true,
),
("a fork's row carrying it", vec![row(head, true)], false),
("a row with no head oid", vec![row("", false)], false),
("a mention-only row", vec![row(other, false)], false),
(
"a fork's row before the real one",
vec![row(head, true), row(head, false)],
true,
),
] {
assert_eq!(
pr_opened_from_commit(&rows, head).is_some(),
expected,
"{label}"
);
}
assert!(pr_opened_from_commit(&[row("", false)], " ").is_none());
}
#[test]
fn head_sha_reads_the_full_oid_of_a_detached_checkout() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = checkout(tmp.path());
let on_branch = GitAndGhProbe
.head_sha(&repo)
.expect("HEAD resolvable on a branch");
assert_eq!(on_branch.len(), 40, "{on_branch}");
assert!(
on_branch.chars().all(|c| c.is_ascii_hexdigit()),
"{on_branch}"
);
git_ok(&repo, &["checkout", "--detach", "HEAD"]);
assert!(
GitAndGhProbe.branch(&repo).is_err(),
"the fixture must actually be detached for this to mean anything"
);
assert_eq!(
GitAndGhProbe
.head_sha(&repo)
.expect("HEAD still resolvable"),
on_branch,
"detaching moves no commit, so the object id is unchanged"
);
}
struct UnoverriddenProbe;
impl WorktreeRemovalProbe for UnoverriddenProbe {
fn dirty_entries(&self, _dir: &Path) -> Result<usize, String> {
Ok(0)
}
fn unpushed_commits(&self, _dir: &Path) -> Result<UpstreamComparison, String> {
Ok(UpstreamComparison::NoUpstream)
}
fn branch(&self, _dir: &Path) -> Result<String, String> {
Ok("main".to_string())
}
fn local_only_commits(&self, _dir: &Path) -> Result<usize, String> {
Ok(0)
}
fn merged_pull_requests(&self, _dir: &Path, _b: &str) -> Result<MergedPrLookup, String> {
Ok(MergedPrLookup::new(0, "o/r", ""))
}
fn content_on_base(&self, _dir: &Path, _base: &str) -> Result<ContentOnBase, String> {
Ok(ContentOnBase::Residual {
paths: vec!["x".into()],
searched: 0,
candidates: 0,
})
}
}
#[test]
fn an_unoverridden_probe_establishes_neither_new_fact() {
let dir = Path::new("/nonexistent");
assert!(UnoverriddenProbe.head_sha(dir).is_err());
assert!(
UnoverriddenProbe
.merged_pull_request_for_commit(dir, "deadbeef")
.is_err()
);
assert!(!UnoverriddenProbe.landing_admission(dir).admits());
assert!(UnoverriddenProbe.nested_dirt(dir).is_err());
assert!(
UnoverriddenProbe
.commits_after_merged_head(dir, "deadbeef")
.is_err()
);
assert!(UnoverriddenProbe.is_ancestor(dir, "a", "b").is_err());
}
#[test]
fn is_ancestor_refuses_an_option_shaped_argument() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = checkout(tmp.path());
for (a, d) in [
("--all", "HEAD"),
("HEAD", "-x"),
("", "HEAD"),
("HEAD", "a b"),
] {
let err = GitAndGhProbe
.is_ancestor(&repo, a, d)
.expect_err("an unaskable argument must be refused");
assert!(err.contains("is not a commit"), "{err}");
}
assert_eq!(GitAndGhProbe.is_ancestor(&repo, "HEAD", "HEAD"), Ok(true));
}
#[test]
fn commits_after_merged_head_refuses_a_head_that_is_not_hex() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = checkout(tmp.path());
let err = GitAndGhProbe
.commits_after_merged_head(&repo, "--all")
.expect_err("an option-shaped head must never reach git");
assert!(err.contains("--all"), "{err}");
}
fn git_ok(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap_or_else(|e| panic!("fixture: `git {}` could not be run: {e}", args.join(" ")));
assert!(
out.status.success(),
"fixture: `git {}` failed in {}: {}",
args.join(" "),
dir.display(),
String::from_utf8_lossy(&out.stderr)
);
}
fn checkout(tmp: &Path) -> PathBuf {
let repo = tmp.join("tree");
std::fs::create_dir_all(&repo).expect("fixture: create repo dir");
git_ok(&repo, &["init", "--initial-branch=main"]);
git_ok(&repo, &["config", "user.email", "ci@test.invalid"]);
git_ok(&repo, &["config", "user.name", "CI"]);
git_ok(&repo, &["config", "commit.gpgsign", "false"]);
std::fs::write(repo.join("README.md"), "base\n").expect("fixture: write README");
git_ok(&repo, &["add", "README.md"]);
git_ok(&repo, &["commit", "-m", "base"]);
repo
}
#[test]
fn the_harness_ownership_marker_alone_leaves_the_tree_clean() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = checkout(tmp.path());
std::fs::write(repo.join(WORKTREE_SENTINEL_FILE), b"").expect("write marker");
assert_eq!(
GitAndGhProbe.dirty_entries(&repo).expect("status readable"),
0,
"the harness's own ownership marker is not the agent's unsaved work"
);
}
#[test]
fn a_real_untracked_file_beside_the_marker_still_counts() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = checkout(tmp.path());
std::fs::write(repo.join(WORKTREE_SENTINEL_FILE), b"").expect("write marker");
std::fs::write(repo.join("rescued.rs"), "fn main() {}\n").expect("write work");
assert_eq!(
GitAndGhProbe.dirty_entries(&repo).expect("status readable"),
1,
"unsaved work beside the marker must still deny removal"
);
}
#[test]
fn a_branch_with_no_upstream_reports_no_upstream_not_an_error() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = checkout(tmp.path());
assert_eq!(
GitAndGhProbe
.unpushed_commits(&repo)
.expect("a missing upstream is a fact, not a probe failure"),
UpstreamComparison::NoUpstream
);
}
#[test]
fn a_branch_with_an_upstream_counts_the_commits_it_is_ahead_by() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = checkout(tmp.path());
let remote = tmp.path().join("remote.git");
std::fs::create_dir_all(&remote).expect("fixture: create remote dir");
git_ok(&remote, &["init", "--bare", "--initial-branch=main"]);
git_ok(
&repo,
&["remote", "add", "origin", &remote.to_string_lossy()],
);
git_ok(&repo, &["push", "-u", "origin", "main"]);
assert_eq!(
GitAndGhProbe.unpushed_commits(&repo).expect("upstream set"),
UpstreamComparison::Ahead(0)
);
std::fs::write(repo.join("later.txt"), "more\n").expect("fixture: write");
git_ok(&repo, &["add", "later.txt"]);
git_ok(&repo, &["commit", "-m", "later"]);
assert_eq!(
GitAndGhProbe.unpushed_commits(&repo).expect("upstream set"),
UpstreamComparison::Ahead(1),
"an unpushed commit must still be counted"
);
}
#[test]
fn a_modified_tracked_file_counts_even_with_the_marker_present() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = checkout(tmp.path());
std::fs::write(repo.join(WORKTREE_SENTINEL_FILE), b"").expect("write marker");
std::fs::write(repo.join("README.md"), "edited\n").expect("edit README");
assert_eq!(
GitAndGhProbe.dirty_entries(&repo).expect("status readable"),
1
);
}
#[test]
fn nested_dirt_finds_an_ignored_clone_with_an_unpushed_commit() {
let fx = crate::session_manager::worktree_git_fixture::GitWorktreeFixture::new();
let wt = fx.add_worktree("nested-clone-7889");
assert_eq!(
GitAndGhProbe.nested_dirt(&wt).expect("scan runs"),
None,
"premise: a fresh tree holds no nested work"
);
let exclude = fx.repo.join(".git").join("info").join("exclude");
std::fs::create_dir_all(exclude.parent().expect("parent")).expect("mkdir info");
std::fs::write(&exclude, "scratch/\n").expect("write exclude");
let inner = wt.join("scratch").join("side-project");
std::fs::create_dir_all(&inner).expect("mkdir inner");
git_ok(&inner, &["init", "--initial-branch=main"]);
git_ok(&inner, &["config", "user.email", "ci@test.invalid"]);
git_ok(&inner, &["config", "user.name", "CI"]);
git_ok(&inner, &["config", "commit.gpgsign", "false"]);
std::fs::write(inner.join("work.rs"), "// only copy\n").expect("write work");
git_ok(&inner, &["add", "work.rs"]);
git_ok(&inner, &["commit", "-m", "only copy"]);
assert_eq!(
GitAndGhProbe.dirty_entries(&wt).expect("status readable"),
0,
"premise: git status cannot see the ignored clone"
);
let reason = GitAndGhProbe
.nested_dirt(&wt)
.expect("scan runs")
.expect("the ignored clone's unpushed commit must be reported");
assert!(reason.contains("scratch/side-project"), "{reason}");
}
}