use anyhow::{bail, Context, Result};
use atlassian_cli_api::pagination::{fetch_paged, BitbucketPage, PageLimits};
use serde::{Deserialize, Serialize};
use super::utils::{encode_path_segment, encode_ref_path, BitbucketContext};
use crate::commands::common::confirm_destructive;
#[derive(Deserialize)]
struct Repository {
slug: String,
name: String,
#[serde(default)]
updated_on: Option<String>,
}
#[derive(Deserialize)]
struct Branch {
name: String,
#[serde(default)]
target: Option<Target>,
}
#[derive(Deserialize)]
struct Target {
#[serde(default)]
date: Option<String>,
}
pub(crate) fn is_stale(
updated_on: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
days: i64,
) -> bool {
let Some(updated) = updated_on else {
return false;
};
let Ok(updated_date) = chrono::DateTime::parse_from_rfc3339(updated) else {
tracing::warn!(
updated_on = updated,
"Ignoring repository: last-updated timestamp is not valid RFC 3339"
);
return false;
};
let Some(threshold) = chrono::Duration::try_days(days) else {
tracing::warn!(
days,
"Staleness threshold is out of range; treating nothing as stale"
);
return false;
};
now.signed_duration_since(updated_date) > threshold
}
pub async fn disable_features_on_stale_repos(
ctx: &BitbucketContext<'_>,
workspace: &str,
days_threshold: i64,
execute: bool,
assume_yes: bool,
) -> Result<()> {
let path = format!("/2.0/repositories/{workspace}?pagelen=100");
let (repositories, page) =
fetch_paged::<BitbucketPage<Repository>>(&ctx.client, &path, PageLimits::new(None))
.await
.with_context(|| format!("Failed to list repositories in workspace {workspace}"))?;
if page.truncated {
bail!(
"Refusing to continue: only {} repositories could be listed before the request \
budget ran out, so the set shown would be incomplete.",
repositories.len()
);
}
let now = chrono::Utc::now();
#[derive(Serialize)]
struct StaleRepo<'a> {
slug: &'a str,
name: &'a str,
last_updated: &'a str,
action: &'a str,
}
let targets: Vec<&Repository> = repositories
.iter()
.filter(|repo| is_stale(repo.updated_on.as_deref(), now, days_threshold))
.collect();
if execute && !assume_yes && !targets.is_empty() {
confirm_destructive(
workspace,
&format!(
"About to disable the issue tracker and wiki on {} repositor(ies) in {workspace}.\n\
This is not archiving: existing issues and wiki pages become inaccessible.\n\
Re-run without --execute to list them first.",
targets.len()
),
)?;
}
let mut rows = Vec::with_capacity(targets.len());
let mut failure: Option<anyhow::Error> = None;
for repo in targets {
if execute {
let update_path = format!("/2.0/repositories/{workspace}/{}", repo.slug);
let payload = serde_json::json!({
"has_issues": false,
"has_wiki": false,
});
let result: Result<serde_json::Value> = ctx
.client
.put(&update_path, &payload)
.await
.with_context(|| format!("Failed to disable features on repository {}", repo.slug));
if let Err(err) = result {
failure = Some(err);
break;
}
tracing::info!(
repo_slug = repo.slug.as_str(),
workspace,
"Issue tracker and wiki disabled"
);
}
rows.push(StaleRepo {
slug: repo.slug.as_str(),
name: repo.name.as_str(),
last_updated: repo.updated_on.as_deref().unwrap_or(""),
action: if execute {
"issues and wiki disabled"
} else {
"would disable issues and wiki"
},
});
}
if let Some(err) = failure {
if !rows.is_empty() {
eprintln!(
"Stopped after an error. {} repositor(ies) were already changed:",
rows.len()
);
ctx.renderer.render_list(&rows)?;
}
return Err(err);
}
if !execute && !rows.is_empty() {
eprintln!(
"Listing only. {} repositor(ies) match. Pass --execute to disable \
their issue tracker and wiki.",
rows.len()
);
}
ctx.renderer.render_list_or_empty(
&rows,
&format!("No stale repositories found (threshold: {days_threshold} days)"),
)
}
const PROTECTED_BRANCHES: [&str; 4] = ["main", "master", "develop", "development"];
pub(crate) fn is_protected(name: &str, exclude_patterns: &[String]) -> bool {
PROTECTED_BRANCHES.contains(&name)
|| exclude_patterns
.iter()
.any(|pattern| name.contains(pattern))
}
pub async fn delete_branches(
ctx: &BitbucketContext<'_>,
workspace: &str,
repo_slug: &str,
exclude_patterns: Vec<String>,
execute: bool,
assume_yes: bool,
) -> Result<()> {
let path = format!("/2.0/repositories/{workspace}/{repo_slug}/refs/branches?pagelen=100");
let (branches, page) =
fetch_paged::<BitbucketPage<Branch>>(&ctx.client, &path, PageLimits::new(None))
.await
.with_context(|| format!("Failed to list branches for {workspace}/{repo_slug}"))?;
if page.truncated {
bail!(
"Refusing to continue: only {} branches could be listed before the request \
budget ran out, so the set shown would be incomplete.",
branches.len()
);
}
#[derive(Serialize)]
struct DeletableBranch<'a> {
name: &'a str,
last_commit: &'a str,
action: &'a str,
}
let targets: Vec<&Branch> = branches
.iter()
.filter(|branch| !is_protected(&branch.name, &exclude_patterns))
.collect();
if execute && !assume_yes && !targets.is_empty() {
confirm_destructive(
repo_slug,
&format!(
"About to delete {} branch(es) from {workspace}/{repo_slug}.\n\
Merge status is NOT checked: unmerged branches will be deleted.\n\
Re-run without --execute to list them first.",
targets.len()
),
)?;
}
let mut rows = Vec::with_capacity(targets.len());
let mut failure: Option<anyhow::Error> = None;
for branch in targets {
if execute {
let encoded = match encode_ref_path(&branch.name) {
Ok(encoded) => encoded,
Err(err) => {
failure = Some(err);
break;
}
};
let delete_path = format!(
"/2.0/repositories/{}/{}/refs/branches/{encoded}",
encode_path_segment(workspace)?,
encode_path_segment(repo_slug)?
);
let result: Result<serde_json::Value> =
ctx.client.delete(&delete_path).await.with_context(|| {
format!(
"Failed to delete branch {} from {workspace}/{repo_slug}",
branch.name
)
});
if let Err(err) = result {
failure = Some(err);
break;
}
tracing::info!(
branch_name = branch.name.as_str(),
workspace,
repo_slug,
"Branch deleted"
);
}
rows.push(DeletableBranch {
name: branch.name.as_str(),
last_commit: branch
.target
.as_ref()
.and_then(|t| t.date.as_deref())
.unwrap_or(""),
action: if execute { "deleted" } else { "would delete" },
});
}
if let Some(err) = failure {
if !rows.is_empty() {
eprintln!(
"Stopped after an error. {} branch(es) were already deleted:",
rows.len()
);
ctx.renderer.render_list(&rows)?;
}
return Err(err);
}
if !execute && !rows.is_empty() {
eprintln!(
"Listing only. {} branch(es) match; merge status was not checked. \
Pass --execute to delete them.",
rows.len()
);
}
ctx.renderer
.render_list_or_empty(&rows, "No branches matched (excluding protected patterns)")
}
#[cfg(test)]
mod tests {
use super::*;
fn patterns(values: &[&str]) -> Vec<String> {
values.iter().map(|v| v.to_string()).collect()
}
#[test]
fn protected_branches_are_never_selected() {
for name in PROTECTED_BRANCHES {
assert!(is_protected(name, &[]), "{name} should be protected");
}
}
#[test]
fn exclude_patterns_match_as_substrings() {
let excludes = patterns(&["release/"]);
assert!(is_protected("release/2026-09", &excludes));
assert!(!is_protected("feature/login", &excludes));
}
#[test]
fn unmerged_feature_branches_are_still_selected() {
assert!(!is_protected("feature/work-in-progress", &[]));
assert!(!is_protected("hotfix/urgent", &patterns(&["release/"])));
}
use atlassian_cli_api::ApiClient;
use atlassian_cli_output::{OutputFormat, OutputRenderer};
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn server_with_branches(names: &[&str]) -> MockServer {
let server = MockServer::start().await;
let values: Vec<_> = names
.iter()
.map(|n| serde_json::json!({ "name": n, "target": { "date": "2026-01-01" } }))
.collect();
Mock::given(method("GET"))
.and(path_regex(r".*/refs/branches$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": values
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
server
}
fn ctx_for(server: &MockServer, renderer: &OutputRenderer) -> BitbucketContext<'static> {
let renderer: &'static OutputRenderer =
Box::leak(Box::new(OutputRenderer::new(renderer.format())));
BitbucketContext {
client: ApiClient::new(server.uri()).unwrap(),
renderer,
is_bearer: false,
}
}
async fn delete_paths(server: &MockServer) -> Vec<String> {
server
.received_requests()
.await
.unwrap_or_default()
.into_iter()
.filter(|r| r.method == wiremock::http::Method::DELETE)
.map(|r| r.url.path().to_string())
.collect()
}
#[test]
fn missing_updated_on_is_never_stale() {
let now = chrono::Utc::now();
assert!(!is_stale(None, now, 180));
assert!(!is_stale(Some("not-a-date"), now, 180));
}
#[test]
fn the_threshold_boundary_is_exclusive() {
let now = chrono::Utc::now();
let exactly = (now - chrono::Duration::days(180)).to_rfc3339();
assert!(!is_stale(Some(&exactly), now, 180));
let a_moment_older =
(now - chrono::Duration::days(180) - chrono::Duration::seconds(1)).to_rfc3339();
assert!(is_stale(Some(&a_moment_older), now, 180));
}
#[test]
fn an_absurd_threshold_does_not_panic() {
let now = chrono::Utc::now();
let old = (now - chrono::Duration::days(500)).to_rfc3339();
assert!(!is_stale(Some(&old), now, i64::MAX));
}
#[test]
fn staleness_compares_against_the_threshold() {
let now = chrono::Utc::now();
let old = (now - chrono::Duration::days(200)).to_rfc3339();
let recent = (now - chrono::Duration::days(10)).to_rfc3339();
assert!(is_stale(Some(&old), now, 180));
assert!(!is_stale(Some(&recent), now, 180));
}
async fn server_with_repos(slugs_and_dates: &[(&str, String)]) -> MockServer {
let server = MockServer::start().await;
let values: Vec<_> = slugs_and_dates
.iter()
.map(|(slug, date)| {
serde_json::json!({ "slug": slug, "name": slug, "updated_on": date })
})
.collect();
Mock::given(method("GET"))
.and(path_regex(r"^/2\.0/repositories/[^/]+$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": values
})))
.mount(&server)
.await;
Mock::given(method("PUT"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
server
}
async fn put_count(server: &MockServer) -> usize {
server
.received_requests()
.await
.unwrap_or_default()
.into_iter()
.filter(|r| r.method == wiremock::http::Method::PUT)
.count()
}
#[tokio::test]
async fn stale_repo_listing_mutates_nothing() {
let old = (chrono::Utc::now() - chrono::Duration::days(400)).to_rfc3339();
let server = server_with_repos(&[("dusty", old)]).await;
let renderer = OutputRenderer::new(OutputFormat::Json);
let ctx = ctx_for(&server, &renderer);
disable_features_on_stale_repos(&ctx, "ws", 180, false, false)
.await
.unwrap();
assert_eq!(put_count(&server).await, 0, "listing must not PUT");
}
#[tokio::test]
async fn execute_disables_features_only_on_stale_repos() {
let now = chrono::Utc::now();
let old = (now - chrono::Duration::days(400)).to_rfc3339();
let fresh = (now - chrono::Duration::days(3)).to_rfc3339();
let server = server_with_repos(&[("dusty", old), ("busy", fresh)]).await;
let renderer = OutputRenderer::new(OutputFormat::Json);
let ctx = ctx_for(&server, &renderer);
disable_features_on_stale_repos(&ctx, "ws", 180, true, true)
.await
.unwrap();
assert_eq!(put_count(&server).await, 1, "only the stale repository");
}
#[tokio::test]
async fn every_page_of_branches_is_listed() {
let server = MockServer::start().await;
let page_two = format!(
"{}/2.0/repositories/ws/repo/refs/branches?page=2",
server.uri()
);
Mock::given(method("GET"))
.and(path_regex(r".*/refs/branches$"))
.and(wiremock::matchers::query_param("page", "2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{"name": "second-page", "target": {"date": "2026-01-01"}}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(r".*/refs/branches$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{"name": "first-page", "target": {"date": "2026-01-01"}}],
"next": page_two
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let renderer = OutputRenderer::new(OutputFormat::Json);
let ctx = ctx_for(&server, &renderer);
delete_branches(&ctx, "ws", "repo", vec![], true, true)
.await
.unwrap();
let deleted = delete_paths(&server).await;
assert_eq!(deleted.len(), 2, "both pages must be acted on: {deleted:?}");
assert!(deleted.iter().any(|p| p.ends_with("/second-page")));
}
#[tokio::test]
async fn an_incompletable_branch_list_refuses_to_delete() {
let server = MockServer::start().await;
let forever = format!(
"{}/2.0/repositories/ws/repo/refs/branches?page=next",
server.uri()
);
Mock::given(method("GET"))
.and(path_regex(r".*/refs/branches$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [{"name": "a", "target": {"date": "2026-01-01"}}],
"next": forever
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let renderer = OutputRenderer::new(OutputFormat::Json);
let ctx = ctx_for(&server, &renderer);
let err = delete_branches(&ctx, "ws", "repo", vec![], true, true)
.await
.expect_err("an incomplete listing must not proceed to deletion");
assert!(
format!("{err:#}").contains("Refusing to continue"),
"unexpected error: {err:#}"
);
assert!(
delete_paths(&server).await.is_empty(),
"nothing may be deleted from an incomplete list"
);
}
#[tokio::test]
async fn listing_is_the_default_and_deletes_nothing() {
let server = server_with_branches(&["main", "feature/a", "feature/b"]).await;
let renderer = OutputRenderer::new(OutputFormat::Json);
let ctx = ctx_for(&server, &renderer);
delete_branches(&ctx, "ws", "repo", vec![], false, false)
.await
.unwrap();
assert!(
delete_paths(&server).await.is_empty(),
"listing must not issue any DELETE"
);
}
#[tokio::test]
async fn execute_with_yes_deletes_only_unprotected_branches() {
let server = server_with_branches(&["main", "develop", "feature/a"]).await;
let renderer = OutputRenderer::new(OutputFormat::Json);
let ctx = ctx_for(&server, &renderer);
delete_branches(&ctx, "ws", "repo", vec![], true, true)
.await
.unwrap();
let deleted = delete_paths(&server).await;
assert_eq!(deleted.len(), 1, "only the unprotected branch: {deleted:?}");
assert!(deleted[0].ends_with("/refs/branches/feature/a"));
}
#[tokio::test]
async fn a_hash_in_a_branch_name_cannot_delete_the_protected_ref() {
let server = server_with_branches(&["main", "main#old"]).await;
let renderer = OutputRenderer::new(OutputFormat::Json);
let ctx = ctx_for(&server, &renderer);
delete_branches(&ctx, "ws", "repo", vec![], true, true)
.await
.unwrap();
let deleted = delete_paths(&server).await;
assert_eq!(deleted.len(), 1, "exactly one delete: {deleted:?}");
assert!(
!deleted[0].ends_with("/refs/branches/main"),
"must not have deleted the protected ref: {deleted:?}"
);
assert!(
deleted[0].contains("main%23old"),
"expected the encoded ref, got: {deleted:?}"
);
}
#[tokio::test]
async fn a_traversal_branch_name_aborts_without_deleting() {
let server = server_with_branches(&["a/../../../../../../repositories/w2/r2"]).await;
let renderer = OutputRenderer::new(OutputFormat::Json);
let ctx = ctx_for(&server, &renderer);
let err = delete_branches(&ctx, "ws", "repo", vec![], true, true)
.await
.expect_err("a dot-segment ref name must be refused");
assert!(
format!("{err:#}").contains("would change which resource"),
"unexpected error: {err:#}"
);
assert!(
delete_paths(&server).await.is_empty(),
"nothing may be deleted when a name is refused"
);
}
#[tokio::test]
async fn a_late_traversal_name_still_reports_earlier_deletions() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r".*/refs/branches$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [
{"name": "aaa", "target": {"date": "2026-01-01"}},
{"name": "x/../../../../../../repositories/w2/r2",
"target": {"date": "2026-01-01"}}
]
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let renderer = OutputRenderer::new(OutputFormat::Json);
let ctx = ctx_for(&server, &renderer);
let err = delete_branches(&ctx, "ws", "repo", vec![], true, true)
.await
.expect_err("the hostile name must abort the run");
assert!(
format!("{err:#}").contains("would change which resource"),
"unexpected error: {err:#}"
);
let deleted = delete_paths(&server).await;
assert_eq!(deleted.len(), 1, "only the safe branch: {deleted:?}");
assert!(deleted[0].ends_with("/aaa"));
assert!(
!deleted.iter().any(|p| p.contains("/repositories/w2/r2")),
"the retargeted path must never be requested: {deleted:?}"
);
}
#[tokio::test]
async fn a_partial_failure_reports_what_was_deleted() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path_regex(r".*/refs/branches$"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [
{"name": "aaa", "target": {"date": "2026-01-01"}},
{"name": "bbb", "target": {"date": "2026-01-01"}}
]
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path_regex(r".*/refs/branches/aaa$"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path_regex(r".*/refs/branches/bbb$"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let renderer = OutputRenderer::new(OutputFormat::Json);
let ctx = ctx_for(&server, &renderer);
delete_branches(&ctx, "ws", "repo", vec![], true, true)
.await
.expect_err("the second delete fails");
let deleted = delete_paths(&server).await;
assert!(
deleted.iter().any(|p| p.ends_with("/aaa")),
"the first delete really happened: {deleted:?}"
);
assert!(
deleted.iter().any(|p| p.ends_with("/bbb")),
"the failing delete was attempted: {deleted:?}"
);
let mut distinct: Vec<&str> = deleted
.iter()
.filter_map(|p| p.rsplit('/').next())
.collect();
distinct.sort_unstable();
distinct.dedup();
assert_eq!(
distinct,
vec!["aaa", "bbb"],
"must stop at the failure rather than continuing: {deleted:?}"
);
}
#[tokio::test]
async fn exclude_patterns_are_honoured_under_execute() {
let server = server_with_branches(&["release/1", "feature/a"]).await;
let renderer = OutputRenderer::new(OutputFormat::Json);
let ctx = ctx_for(&server, &renderer);
delete_branches(&ctx, "ws", "repo", vec!["release/".to_string()], true, true)
.await
.unwrap();
let deleted = delete_paths(&server).await;
assert_eq!(deleted.len(), 1, "{deleted:?}");
assert!(deleted[0].ends_with("/refs/branches/feature/a"));
}
#[test]
fn protection_is_exact_for_names_and_substring_for_patterns() {
assert!(is_protected("main", &[]));
assert!(!is_protected("maintenance", &[]));
assert!(is_protected("maintenance", &patterns(&["main"])));
}
}