use anyhow::{bail, Context, Result};
use crate::config::Config;
use crate::worktree::{find_worktree_for_branch, ensure_worktree};
use std::path::{Path, PathBuf};
use std::process::Command;
pub(crate) fn run(dir: &Path, args: &[&str]) -> Result<String> {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.context("git not found")?;
if !out.status.success() {
anyhow::bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(String::from_utf8(out.stdout)?.trim().to_string())
}
pub fn current_branch(root: &Path) -> Result<String> {
run(root, &["branch", "--show-current"])
}
pub fn has_commits(root: &Path) -> bool {
run(root, &["rev-parse", "HEAD"]).is_ok()
}
pub fn fetch_all(root: &Path) -> Result<()> {
run(root, &["fetch", "--all", "--quiet"]).map(|_| ())
}
pub fn read_from_branch(root: &Path, branch: &str, rel_path: &str) -> Result<String> {
run(root, &["show", &format!("{branch}:{rel_path}")])
.or_else(|_| run(root, &["show", &format!("origin/{branch}:{rel_path}")]))
}
pub fn ticket_branches(root: &Path) -> Result<Vec<String>> {
let mut seen = std::collections::HashSet::new();
let mut branches = Vec::new();
let local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
for b in local.lines()
.map(|l| l.trim().trim_start_matches(['*', '+']).trim())
.filter(|l| !l.is_empty())
{
if seen.insert(b.to_string()) {
branches.push(b.to_string());
}
}
let remote = run(root, &["branch", "-r", "--list", "origin/ticket/*"]).unwrap_or_default();
for b in remote.lines()
.map(|l| l.trim().trim_start_matches("origin/").to_string())
.filter(|l| !l.is_empty())
{
if seen.insert(b.clone()) {
branches.push(b);
}
}
Ok(branches)
}
pub fn merged_into_main(root: &Path, default_branch: &str) -> Result<Vec<String>> {
let remote_ref = format!("refs/remotes/origin/{default_branch}");
let remote_merged = format!("origin/{default_branch}");
if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
let regular_out = run(
root,
&["branch", "-r", "--merged", &remote_merged, "--list", "origin/ticket/*"],
)
.unwrap_or_default();
let mut merged: Vec<String> = regular_out
.lines()
.map(|l| l.trim().trim_start_matches("origin/").to_string())
.filter(|l| !l.is_empty())
.collect();
let merged_set: std::collections::HashSet<String> = merged.iter().cloned().collect();
let all_remote = run(root, &["branch", "-r", "--list", "origin/ticket/*"])
.unwrap_or_default();
let remote_candidates: Vec<String> = all_remote
.lines()
.map(|l| l.trim().to_string())
.filter(|l| {
let stripped = l.strip_prefix("origin/").unwrap_or(l.as_str());
!l.is_empty() && !merged_set.contains(stripped)
})
.collect();
let remote_squashed = squash_merged(root, &remote_merged, remote_candidates)?;
merged.extend(remote_squashed.into_iter().map(|b| {
b.strip_prefix("origin/").unwrap_or(&b).to_string()
}));
let remote_stripped: std::collections::HashSet<String> = all_remote
.lines()
.map(|l| l.trim().trim_start_matches("origin/").to_string())
.filter(|l| !l.is_empty())
.collect();
let merged_now: std::collections::HashSet<String> = merged.iter().cloned().collect();
let all_local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
let local_only: Vec<String> = all_local
.lines()
.map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
.filter(|l| {
!l.is_empty()
&& !remote_stripped.contains(l)
&& !merged_now.contains(l)
})
.collect();
merged.extend(squash_merged(root, &remote_merged, local_only)?);
let local_default_ref = format!("refs/heads/{default_branch}");
if run(root, &["rev-parse", "--verify", &local_default_ref]).is_ok() {
let already: std::collections::HashSet<String> = merged.iter().cloned().collect();
let local_regular = run(
root,
&["branch", "--merged", default_branch, "--list", "ticket/*"],
)
.unwrap_or_default();
for line in local_regular.lines() {
let b = line.trim().trim_start_matches(['*', '+']).trim().to_string();
if !b.is_empty() && !already.contains(&b) {
merged.push(b);
}
}
}
return Ok(merged);
}
let local_ref = format!("refs/heads/{default_branch}");
if run(root, &["rev-parse", "--verify", &local_ref]).is_err() {
return Ok(vec![]);
}
let regular_out = run(
root,
&["branch", "--merged", default_branch, "--list", "ticket/*"],
)
.unwrap_or_default();
let mut merged: Vec<String> = regular_out
.lines()
.map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
.filter(|l| !l.is_empty())
.collect();
let merged_set: std::collections::HashSet<&str> = merged.iter().map(|s| s.as_str()).collect();
let all_local = run(root, &["branch", "--list", "ticket/*"]).unwrap_or_default();
let candidates: Vec<String> = all_local
.lines()
.map(|l| l.trim().trim_start_matches(['*', '+']).trim().to_string())
.filter(|l| !l.is_empty() && !merged_set.contains(l.as_str()))
.collect();
merged.extend(squash_merged(root, default_branch, candidates)?);
Ok(merged)
}
fn squash_merged(root: &Path, main_ref: &str, candidates: Vec<String>) -> Result<Vec<String>> {
let mut result = Vec::new();
for branch in candidates {
let merge_base = match run(root, &["merge-base", main_ref, &branch]) {
Ok(mb) => mb,
Err(_) => continue,
};
let branch_tip = match run(root, &["rev-parse", &format!("{branch}^{{commit}}")]) {
Ok(t) => t,
Err(_) => continue,
};
if branch_tip == merge_base {
continue;
}
let squash_commit = match run(root, &[
"commit-tree", &format!("{branch}^{{tree}}"),
"-p", &merge_base,
"-m", "squash",
]) {
Ok(c) => c,
Err(_) => continue,
};
let cherry_out = match run(root, &["cherry", main_ref, &squash_commit]) {
Ok(o) => o,
Err(_) => continue,
};
if cherry_out.trim().starts_with('-') {
result.push(branch);
}
}
Ok(result)
}
pub fn content_merged_into_main(
root: &Path,
main_ref: &str,
branch: &str,
tickets_dir: &str,
) -> Result<bool> {
let merge_base = match run(root, &["merge-base", main_ref, branch]) {
Ok(mb) => mb,
Err(_) => return Ok(false),
};
let branch_tip = match run(root, &["rev-parse", &format!("{branch}^{{commit}}")]) {
Ok(t) => t,
Err(_) => return Ok(false),
};
if branch_tip == merge_base {
return Ok(false);
}
let log_out = match run(root, &["log", "--pretty=%H", branch, &format!("^{merge_base}")]) {
Ok(o) => o,
Err(_) => return Ok(false),
};
let tickets_prefix = format!("{tickets_dir}/");
let mut content_tip: Option<String> = None;
for sha in log_out.lines() {
let diff_out = match run(root, &["diff-tree", "--no-commit-id", "-r", "--name-only", sha]) {
Ok(o) => o,
Err(_) => continue,
};
let has_non_ticket = diff_out.lines().any(|path| !path.starts_with(&tickets_prefix));
if has_non_ticket {
content_tip = Some(sha.to_string());
break;
}
}
if content_tip.is_none() {
let parent_spec = format!("{merge_base}^1");
if let Ok(fp_log) = run(root, &[
"rev-list", "--first-parent", main_ref, &format!("^{parent_spec}"),
]) {
let oldest = fp_log.lines().last().unwrap_or("").trim();
if !oldest.is_empty() && oldest != merge_base {
return Ok(true);
}
}
return Ok(false);
}
let content_tip = content_tip.unwrap();
if content_tip == branch_tip {
return Ok(false);
}
let squash_commit = match run(root, &[
"commit-tree", &format!("{content_tip}^{{tree}}"),
"-p", &merge_base,
"-m", "squash",
]) {
Ok(c) => c,
Err(_) => return Ok(false),
};
let cherry_out = match run(root, &["cherry", main_ref, &squash_commit]) {
Ok(o) => o,
Err(_) => return Ok(false),
};
Ok(cherry_out.trim().starts_with('-'))
}
pub fn commit_to_branch(
root: &Path,
branch: &str,
rel_path: &str,
content: &str,
message: &str,
) -> Result<()> {
if !has_commits(root) {
let local_path = root.join(rel_path);
if let Some(parent) = local_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&local_path, content)?;
return Ok(());
}
if let Some(wt_path) = find_worktree_for_branch(root, branch) {
let remote_ref = format!("origin/{branch}");
if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
}
let full_path = wt_path.join(rel_path);
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&full_path, content)?;
let _ = run(&wt_path, &["add", rel_path]);
let _ = run(&wt_path, &["commit", "-m", message]);
crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
return Ok(());
}
if current_branch(root).ok().as_deref() == Some(branch) {
let local_path = root.join(rel_path);
if let Some(parent) = local_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&local_path, content)?;
let _ = run(root, &["add", rel_path]);
let _ = run(root, &["commit", "-m", message]);
crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
return Ok(());
}
let result = try_worktree_commit(root, branch, rel_path, content, message);
if result.is_ok() {
crate::logger::log("commit_to_branch", &format!("{branch} {message}"));
}
result
}
fn try_worktree_commit(
root: &Path,
branch: &str,
rel_path: &str,
content: &str,
message: &str,
) -> Result<()> {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let wt_path = std::env::temp_dir().join(format!(
"apm-{}-{}-{}",
std::process::id(),
seq,
branch.replace('/', "-"),
));
let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
if has_remote {
run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
let _ = run(&wt_path, &["checkout", "-B", branch]);
} else if has_local {
let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
let _ = run(&wt_path, &["checkout", "-B", branch]);
} else {
run(root, &["worktree", "add", "-b", branch, &wt_path.to_string_lossy(), "HEAD"])?;
}
let result = (|| -> Result<()> {
let full_path = wt_path.join(rel_path);
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&full_path, content)?;
run(&wt_path, &["add", rel_path])?;
run(&wt_path, &["commit", "-m", message])?;
Ok(())
})();
let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
let _ = std::fs::remove_dir_all(&wt_path);
result
}
pub fn push_ticket_branches(root: &Path, warnings: &mut Vec<String>) {
if run(root, &["remote", "get-url", "origin"]).is_err() {
return;
}
let out = match run(root, &["branch", "--list", "ticket/*"]) {
Ok(o) => o,
Err(_) => return,
};
for branch in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
let range = format!("origin/{branch}..{branch}");
let count = run(root, &["rev-list", "--count", &range])
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
if count > 0 {
if let Err(e) = run(root, &["push", "origin", branch]) {
warnings.push(format!("warning: push {branch} failed: {e:#}"));
}
}
}
}
pub fn sync_non_checked_out_refs(root: &Path, warnings: &mut Vec<String>) -> Vec<String> {
let checked_out: std::collections::HashSet<String> = {
let mut set = std::collections::HashSet::new();
if let Ok(out) = run(root, &["worktree", "list", "--porcelain"]) {
for line in out.lines() {
if let Some(b) = line.strip_prefix("branch refs/heads/") {
set.insert(b.to_string());
}
}
}
set
};
const MANAGED_NAMESPACES: &[&str] = &["ticket", "epic"];
let mut remote_refs: Vec<String> = Vec::new();
for ns in MANAGED_NAMESPACES {
let pattern = format!("refs/remotes/origin/{ns}/");
if let Ok(out) = run(root, &["for-each-ref", "--format=%(refname:short)", &pattern]) {
for line in out.lines().filter(|l| !l.is_empty()) {
remote_refs.push(line.to_string());
}
}
}
let mut ahead_branches: Vec<String> = Vec::new();
for remote_name in remote_refs {
let branch = match remote_name.strip_prefix("origin/") {
Some(b) => b.to_string(),
None => continue,
};
if checked_out.contains(&branch) {
continue;
}
let local_ref = format!("refs/heads/{branch}");
let remote_ref_full = format!("refs/remotes/{remote_name}");
match classify_branch(root, &local_ref, &remote_name) {
BranchClass::RemoteOnly => {
let sha = match run(root, &["rev-parse", &remote_ref_full]) {
Ok(s) => s,
Err(_) => continue,
};
if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
warnings.push(format!("warning: could not create local ref {branch}: {e:#}"));
}
}
BranchClass::Equal => {
}
BranchClass::Behind => {
let sha = match run(root, &["rev-parse", &remote_ref_full]) {
Ok(s) => s,
Err(_) => continue,
};
if let Err(e) = run(root, &["update-ref", &local_ref, &sha]) {
warnings.push(format!("warning: could not fast-forward {branch}: {e:#}"));
}
}
BranchClass::Ahead => {
warnings.push(crate::sync_guidance::TICKET_OR_EPIC_AHEAD.replace("<slug>", &branch));
ahead_branches.push(branch);
}
BranchClass::Diverged => {
let msg = crate::sync_guidance::TICKET_OR_EPIC_DIVERGED
.replace("<slug>", &branch);
warnings.push(msg);
}
BranchClass::NoRemote => {
}
}
}
ahead_branches
}
pub fn list_files_on_branch(root: &Path, branch: &str, dir: &str) -> Result<Vec<String>> {
let tree_ref = format!("{branch}:{dir}");
let out = run(root, &["ls-tree", "--name-only", &tree_ref])
.or_else(|_| run(root, &["ls-tree", "--name-only", &format!("origin/{branch}:{dir}")]))?;
Ok(out.lines()
.filter(|l| !l.is_empty())
.map(|l| format!("{dir}/{l}"))
.collect())
}
pub fn commit_files_to_branch(
root: &Path,
branch: &str,
files: &[(&str, String)],
message: &str,
) -> Result<()> {
if !has_commits(root) {
for (rel_path, content) in files {
let local_path = root.join(rel_path);
if let Some(parent) = local_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&local_path, content)?;
}
return Ok(());
}
if let Some(wt_path) = find_worktree_for_branch(root, branch) {
for (rel_path, content) in files {
let full_path = wt_path.join(rel_path);
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&full_path, content)?;
let _ = run(&wt_path, &["add", rel_path]);
}
run(&wt_path, &["commit", "-m", message])?;
crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
return Ok(());
}
if current_branch(root).ok().as_deref() == Some(branch) {
for (rel_path, content) in files {
let local_path = root.join(rel_path);
if let Some(parent) = local_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&local_path, content)?;
let _ = run(root, &["add", rel_path]);
}
run(root, &["commit", "-m", message])?;
crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
return Ok(());
}
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let wt_path = std::env::temp_dir().join(format!(
"apm-{}-{}-{}",
std::process::id(),
unique,
branch.replace('/', "-"),
));
let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
if has_remote {
run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
let _ = run(&wt_path, &["checkout", "-B", branch]);
} else if has_local {
let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
let _ = run(&wt_path, &["checkout", "-B", branch]);
} else {
run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
}
let result = (|| -> Result<()> {
for (rel_path, content) in files {
let full_path = wt_path.join(rel_path);
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&full_path, content)?;
run(&wt_path, &["add", rel_path])?;
}
run(&wt_path, &["commit", "-m", message])?;
Ok(())
})();
let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
let _ = std::fs::remove_dir_all(&wt_path);
if result.is_ok() {
crate::logger::log("commit_files_to_branch", &format!("{branch} {message}"));
}
result
}
pub fn branch_tip(root: &Path, branch: &str) -> Option<String> {
run(root, &["rev-parse", &format!("refs/heads/{branch}")]).ok()
}
pub fn resolve_branch_sha(root: &Path, branch: &str) -> Result<String> {
run(root, &["rev-parse", &format!("origin/{branch}")])
.or_else(|_| run(root, &["rev-parse", branch]))
.with_context(|| format!("branch '{branch}' not found locally or on origin"))
}
pub fn create_branch_at(root: &Path, branch: &str, sha: &str) -> Result<()> {
run(root, &["branch", branch, sha]).map(|_| ())
}
pub fn remote_branch_tip(root: &Path, branch: &str) -> Option<String> {
run(root, &["rev-parse", &format!("refs/remotes/origin/{branch}")]).ok()
}
pub fn is_ancestor(root: &Path, commit: &str, of_ref: &str) -> bool {
Command::new("git")
.current_dir(root)
.args(["merge-base", "--is-ancestor", commit, of_ref])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub enum BranchClass {
Equal,
Behind,
Ahead,
Diverged,
RemoteOnly,
NoRemote,
}
pub fn classify_branch(root: &Path, local: &str, remote: &str) -> BranchClass {
let local_sha = match run(root, &["rev-parse", local]) {
Ok(s) => s,
Err(_) => {
return if run(root, &["rev-parse", remote]).is_ok() {
BranchClass::RemoteOnly
} else {
BranchClass::NoRemote
};
}
};
let remote_sha = match run(root, &["rev-parse", remote]) {
Ok(s) => s,
Err(_) => return BranchClass::NoRemote,
};
if local_sha == remote_sha {
return BranchClass::Equal;
}
let local_is_ancestor_of_remote = is_ancestor(root, local, remote);
let remote_is_ancestor_of_local = is_ancestor(root, remote, local);
match (local_is_ancestor_of_remote, remote_is_ancestor_of_local) {
(true, false) => BranchClass::Behind, (false, true) => BranchClass::Ahead, (false, false) => BranchClass::Diverged, (true, true) => BranchClass::Equal, }
}
pub fn sync_default_branch(root: &Path, default: &str, warnings: &mut Vec<String>) -> bool {
let remote = format!("origin/{default}");
match classify_branch(root, default, &remote) {
BranchClass::Equal => {
}
BranchClass::Behind => {
let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
if run(&wt, &["merge", "--ff-only", &remote]).is_err() {
let msg = crate::sync_guidance::MAIN_BEHIND_DIRTY_OVERLAP
.replace("<default>", default);
warnings.push(msg);
}
}
BranchClass::Ahead => {
let count = run(root, &["rev-list", "--count", &format!("{remote}..{default}")])
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.unwrap_or(0);
let msg = crate::sync_guidance::MAIN_AHEAD
.replace("<default>", default)
.replace("<remote>", &remote)
.replace("<count>", &count.to_string())
.replace("<commits>", if count == 1 { "commit" } else { "commits" });
warnings.push(msg);
return true;
}
BranchClass::Diverged => {
let wt = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
let guidance = if is_worktree_dirty(&wt) {
crate::sync_guidance::MAIN_DIVERGED_DIRTY.replace("<default>", default)
} else {
crate::sync_guidance::MAIN_DIVERGED_CLEAN.replace("<default>", default)
};
warnings.push(guidance);
}
BranchClass::RemoteOnly => {
}
BranchClass::NoRemote => {
}
}
false
}
pub fn fetch_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
let status = std::process::Command::new("git")
.args(["fetch", "origin", branch])
.current_dir(root)
.status()?;
if !status.success() {
anyhow::bail!("git fetch failed");
}
Ok(())
}
pub fn push_branch(root: &Path, branch: &str) -> anyhow::Result<()> {
let status = std::process::Command::new("git")
.args(["push", "origin", &format!("{branch}:{branch}")])
.current_dir(root)
.status()?;
if !status.success() {
anyhow::bail!("git push failed");
}
Ok(())
}
pub fn push_branch_tracking(root: &Path, branch: &str) -> anyhow::Result<()> {
let out = std::process::Command::new("git")
.args(["push", "--set-upstream", "origin", &format!("{branch}:{branch}")])
.current_dir(root)
.output()?;
if !out.status.success() {
anyhow::bail!("git push failed: {}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(())
}
pub fn has_remote(root: &Path) -> bool {
run(root, &["remote", "get-url", "origin"]).is_ok()
}
pub fn remote_ticket_branches_with_dates(
root: &Path,
) -> Result<Vec<(String, chrono::DateTime<chrono::Utc>)>> {
use chrono::{TimeZone, Utc};
let out = Command::new("git")
.current_dir(root)
.args([
"for-each-ref",
"refs/remotes/origin/ticket/",
"--format=%(refname:short) %(creatordate:unix)",
])
.output()
.context("git for-each-ref failed")?;
let stdout = String::from_utf8_lossy(&out.stdout);
let mut result = Vec::new();
for line in stdout.lines() {
let mut parts = line.splitn(2, ' ');
let refname = parts.next().unwrap_or("").trim();
let ts_str = parts.next().unwrap_or("").trim();
let branch = refname.trim_start_matches("origin/");
if branch.is_empty() {
continue;
}
if let Ok(ts) = ts_str.parse::<i64>() {
if let Some(dt) = Utc.timestamp_opt(ts, 0).single() {
result.push((branch.to_string(), dt));
}
}
}
Ok(result)
}
pub fn list_remote_ticket_branches(root: &Path) -> std::collections::HashSet<String> {
let mut set = std::collections::HashSet::new();
let out = match run(root, &["ls-remote", "--heads", "origin", "ticket/*"]) {
Ok(o) => o,
Err(_) => return set,
};
for line in out.lines() {
if let Some(refname) = line.split('\t').nth(1) {
if let Some(branch) = refname.trim().strip_prefix("refs/heads/") {
set.insert(branch.to_string());
}
}
}
set
}
pub fn delete_remote_branch(root: &Path, branch: &str) -> Result<()> {
let status = Command::new("git")
.current_dir(root)
.args(["push", "origin", "--delete", branch])
.status()
.context("git push origin --delete failed")?;
if !status.success() {
anyhow::bail!("git push origin --delete {branch} failed");
}
Ok(())
}
pub fn move_files_on_branch(
root: &Path,
branch: &str,
moves: &[(&str, &str, &str)],
message: &str,
) -> Result<()> {
if !has_commits(root) {
for (old, new, content) in moves {
let new_path = root.join(new);
if let Some(parent) = new_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&new_path, content)?;
let old_path = root.join(old);
let _ = std::fs::remove_file(&old_path);
}
return Ok(());
}
let do_moves = |wt: &Path| -> Result<()> {
for (old, new, content) in moves {
let new_path = wt.join(new);
if let Some(parent) = new_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&new_path, content)?;
run(wt, &["add", new])?;
run(wt, &["rm", "--force", "--quiet", old])?;
}
run(wt, &["commit", "-m", message])?;
Ok(())
};
if let Some(wt_path) = find_worktree_for_branch(root, branch) {
let remote_ref = format!("origin/{branch}");
if run(root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
let _ = run(&wt_path, &["merge", "--ff-only", &remote_ref]);
}
let result = do_moves(&wt_path);
if result.is_ok() {
crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
}
return result;
}
if current_branch(root).ok().as_deref() == Some(branch) {
let result = do_moves(root);
if result.is_ok() {
crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
}
return result;
}
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let wt_path = std::env::temp_dir().join(format!(
"apm-{}-{}-{}",
std::process::id(),
unique,
branch.replace('/', "-"),
));
let has_remote = run(root, &["rev-parse", "--verify", &format!("refs/remotes/origin/{branch}")]).is_ok();
let has_local = run(root, &["rev-parse", "--verify", &format!("refs/heads/{branch}")]).is_ok();
if has_remote {
run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &format!("origin/{branch}")])?;
let _ = run(&wt_path, &["checkout", "-B", branch]);
} else if has_local {
let sha = run(root, &["rev-parse", &format!("refs/heads/{branch}")])?;
run(root, &["worktree", "add", "--detach", &wt_path.to_string_lossy(), &sha])?;
let _ = run(&wt_path, &["checkout", "-B", branch]);
} else {
run(root, &["worktree", "add", &wt_path.to_string_lossy(), branch])?;
}
let result = do_moves(&wt_path);
let _ = run(root, &["worktree", "remove", "--force", &wt_path.to_string_lossy()]);
let _ = std::fs::remove_dir_all(&wt_path);
if result.is_ok() {
crate::logger::log("move_files_on_branch", &format!("{branch} {message}"));
}
result
}
pub fn merge_branch_into_default(root: &Path, branch: &str, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
let _ = run(root, &["fetch", "origin", default_branch]);
let merge_dir = if current_branch(root).ok().as_deref() == Some(default_branch) {
root.to_path_buf()
} else {
find_worktree_for_branch(root, default_branch).unwrap_or_else(|| root.to_path_buf())
};
if let Err(e) = run(&merge_dir, &["merge", "--no-ff", branch, "--no-edit"]) {
let _ = run(&merge_dir, &["merge", "--abort"]);
anyhow::bail!("merge failed: {e:#}");
}
if has_remote(root) {
if let Err(e) = run(&merge_dir, &["push", "origin", default_branch]) {
warnings.push(format!("warning: push {default_branch} failed: {e:#}"));
}
}
Ok(())
}
pub fn merge_into_default(root: &Path, config: &Config, branch: &str, default_branch: &str, skip_push: bool, messages: &mut Vec<String>, _warnings: &mut Vec<String>) -> Result<()> {
let _ = std::process::Command::new("git")
.args(["fetch", "origin", default_branch])
.current_dir(root)
.status();
let current = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(root)
.output()?;
let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
let merge_dir = if current_branch == default_branch {
root.to_path_buf()
} else {
let main_root = main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
let worktrees_base = main_root.join(&config.worktrees.dir);
ensure_worktree(root, &worktrees_base, default_branch)?
};
let out = std::process::Command::new("git")
.args(["merge", "--no-ff", branch, "--no-edit"])
.current_dir(&merge_dir)
.output()?;
if !out.status.success() {
let _ = std::process::Command::new("git")
.args(["merge", "--abort"])
.current_dir(&merge_dir)
.status();
bail!(
"merge conflict — resolve manually and push: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
if skip_push {
messages.push(format!("Merged {branch} into {default_branch} (local only)."));
} else {
push_branch(&merge_dir, default_branch)?;
messages.push(format!("Merged {branch} into {default_branch} and pushed to origin."));
}
Ok(())
}
pub fn pull_default(root: &Path, default_branch: &str, warnings: &mut Vec<String>) -> Result<()> {
let fetch = std::process::Command::new("git")
.args(["fetch", "origin", default_branch])
.current_dir(root)
.output();
match fetch {
Err(e) => {
warnings.push(format!("warning: fetch failed: {e:#}"));
return Ok(());
}
Ok(out) if !out.status.success() => {
warnings.push(format!(
"warning: fetch failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
return Ok(());
}
_ => {}
}
let current = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(root)
.output()?;
let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
let merge_dir = if current_branch == default_branch {
root.to_path_buf()
} else {
find_worktree_for_branch(root, default_branch)
.unwrap_or_else(|| root.to_path_buf())
};
let remote_ref = format!("origin/{default_branch}");
let out = std::process::Command::new("git")
.args(["merge", "--ff-only", &remote_ref])
.current_dir(&merge_dir)
.output()?;
if !out.status.success() {
warnings.push(format!("warning: could not fast-forward {default_branch} — pull manually"));
}
Ok(())
}
pub fn is_worktree_dirty(path: &Path) -> bool {
let Ok(out) = Command::new("git")
.args(["-C", &path.to_string_lossy(), "status", "--porcelain"])
.output()
else {
return false;
};
!out.stdout.is_empty()
}
pub fn local_branch_exists(root: &Path, branch: &str) -> bool {
Command::new("git")
.args(["-C", &root.to_string_lossy(), "rev-parse", "--verify", &format!("refs/heads/{branch}")])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn delete_local_branch(root: &Path, branch: &str, warnings: &mut Vec<String>) {
let Ok(out) = Command::new("git")
.args(["-C", &root.to_string_lossy(), "branch", "-D", branch])
.output()
else {
warnings.push(format!("warning: could not delete branch {branch}: command failed"));
return;
};
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
warnings.push(format!("warning: could not delete branch {branch}: {stderr}"));
}
}
pub fn prune_remote_tracking(root: &Path, branch: &str) {
let _ = Command::new("git")
.args(["-C", &root.to_string_lossy(), "branch", "-dr", &format!("origin/{branch}")])
.output();
}
pub fn stage_files(root: &Path, files: &[&str]) -> Result<()> {
let mut args = vec!["add"];
args.extend_from_slice(files);
run(root, &args).map(|_| ())
}
pub fn commit(root: &Path, message: &str) -> Result<()> {
run(root, &["commit", "-m", message]).map(|_| ())
}
pub fn git_config_get(root: &Path, key: &str) -> Option<String> {
let out = Command::new("git")
.args(["-C", &root.to_string_lossy(), "config", key])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
if value.is_empty() { None } else { Some(value) }
}
pub fn merge_ref(dir: &Path, refname: &str, warnings: &mut Vec<String>) -> Option<String> {
let out = match Command::new("git")
.args(["-C", &dir.to_string_lossy(), "merge", refname, "--no-edit"])
.output()
{
Ok(o) => o,
Err(e) => {
warnings.push(format!("warning: merge {refname} failed: {e}"));
return None;
}
};
if out.status.success() {
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.contains("Already up to date") {
None
} else {
Some(format!("Merged {refname} into branch."))
}
} else {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
warnings.push(format!("warning: merge {refname} failed: {stderr}"));
None
}
}
pub fn is_file_tracked(root: &Path, path: &str) -> bool {
Command::new("git")
.args(["ls-files", "--error-unmatch", path])
.current_dir(root)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub enum MidMergeState {
Merge,
RebaseMerge,
RebaseApply,
CherryPick,
}
pub fn detect_mid_merge_state(root: &Path) -> Option<MidMergeState> {
let git_dir = root.join(".git");
if git_dir.join("MERGE_HEAD").exists() {
return Some(MidMergeState::Merge);
}
if git_dir.join("rebase-merge").is_dir() {
return Some(MidMergeState::RebaseMerge);
}
if git_dir.join("rebase-apply").is_dir() {
return Some(MidMergeState::RebaseApply);
}
if git_dir.join("CHERRY_PICK_HEAD").exists() {
return Some(MidMergeState::CherryPick);
}
None
}
pub fn merge_base(root: &Path, ref1: &str, ref2: &str) -> Result<String> {
run(root, &["merge-base", ref1, ref2])
}
pub fn main_worktree_root(root: &Path) -> Option<PathBuf> {
let out = run(root, &["worktree", "list", "--porcelain"]).ok()?;
out.lines()
.next()
.and_then(|line| line.strip_prefix("worktree "))
.map(PathBuf::from)
}
pub fn check_leaked_files(
root: &Path,
ticket_branch: &str,
target_branch: &str,
) -> Result<Vec<String>> {
let current = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(root)
.output()?;
let current_branch = String::from_utf8_lossy(¤t.stdout).trim().to_string();
let merge_dir = if current_branch == target_branch {
root.to_path_buf()
} else {
match crate::worktree::find_worktree_for_branch(root, target_branch) {
Some(p) => p,
None => return Ok(vec![]), }
};
let base = match merge_base(root, target_branch, ticket_branch) {
Ok(s) => s.trim().to_string(),
Err(_) => return Ok(vec![]), };
if base.is_empty() {
return Ok(vec![]);
}
let diff_out = Command::new("git")
.args(["diff", "--name-only", &base, ticket_branch])
.current_dir(root)
.output()?;
let ticket_files: std::collections::HashSet<String> =
String::from_utf8_lossy(&diff_out.stdout)
.lines()
.map(|s| s.to_string())
.collect();
let status_out = Command::new("git")
.args(["status", "--porcelain", "--untracked-files=all"])
.current_dir(&merge_dir)
.output()?;
let dirty_files: std::collections::HashSet<String> =
String::from_utf8_lossy(&status_out.stdout)
.lines()
.filter_map(|line| {
if line.len() < 3 {
return None;
}
let x = line.as_bytes()[0] as char;
let y = line.as_bytes()[1] as char;
if x == 'R' || x == 'C' || y == 'R' || y == 'C' {
return None;
}
Some(line[3..].to_string())
})
.collect();
let mut overlap: Vec<String> = ticket_files
.intersection(&dirty_files)
.cloned()
.collect();
overlap.sort();
Ok(overlap)
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command as Cmd;
use tempfile::TempDir;
fn git_init() -> TempDir {
let dir = tempfile::tempdir().unwrap();
let p = dir.path();
Cmd::new("git").args(["init", "-q", "-b", "main"]).current_dir(p).status().unwrap();
Cmd::new("git").args(["config", "user.email", "t@t.com"]).current_dir(p).status().unwrap();
Cmd::new("git").args(["config", "user.name", "test"]).current_dir(p).status().unwrap();
dir
}
fn git_cmd(dir: &Path, args: &[&str]) {
Cmd::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "t@t.com")
.status()
.unwrap();
}
fn make_commit(dir: &Path, filename: &str, content: &str) {
std::fs::write(dir.join(filename), content).unwrap();
git_cmd(dir, &["add", filename]);
git_cmd(dir, &["commit", "-m", "init"]);
}
#[test]
fn is_worktree_dirty_clean() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
assert!(!is_worktree_dirty(dir.path()));
}
#[test]
fn is_worktree_dirty_dirty() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
std::fs::write(dir.path().join("f.txt"), "changed").unwrap();
assert!(is_worktree_dirty(dir.path()));
}
#[test]
fn local_branch_exists_present_and_absent() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
let on_main = local_branch_exists(dir.path(), "main");
let on_master = local_branch_exists(dir.path(), "master");
assert!(on_main || on_master);
assert!(!local_branch_exists(dir.path(), "no-such-branch"));
}
#[test]
fn delete_local_branch_success() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
git_cmd(dir.path(), &["branch", "to-delete"]);
let mut warnings = Vec::new();
delete_local_branch(dir.path(), "to-delete", &mut warnings);
assert!(warnings.is_empty());
assert!(!local_branch_exists(dir.path(), "to-delete"));
}
#[test]
fn delete_local_branch_failure_adds_warning() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
let mut warnings = Vec::new();
delete_local_branch(dir.path(), "nonexistent", &mut warnings);
assert!(!warnings.is_empty());
assert!(warnings[0].contains("warning:"));
}
#[test]
fn prune_remote_tracking_no_panic() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
prune_remote_tracking(dir.path(), "nonexistent-branch");
}
#[test]
fn stage_files_ok_and_err() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
std::fs::write(dir.path().join("new.txt"), "new").unwrap();
assert!(stage_files(dir.path(), &["new.txt"]).is_ok());
assert!(stage_files(dir.path(), &["missing.txt"]).is_err());
}
#[test]
fn commit_ok_and_err() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
std::fs::write(dir.path().join("new.txt"), "new").unwrap();
git_cmd(dir.path(), &["add", "new.txt"]);
assert!(commit(dir.path(), "test commit").is_ok());
assert!(commit(dir.path(), "empty commit").is_err());
}
#[test]
fn git_config_get_some_and_none() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
let val = git_config_get(dir.path(), "user.email");
assert_eq!(val, Some("t@t.com".to_string()));
let missing = git_config_get(dir.path(), "no.such.key");
assert!(missing.is_none());
}
#[test]
fn merge_ref_already_up_to_date() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
let branch = {
let out = Cmd::new("git").args(["branch", "--show-current"]).current_dir(dir.path()).output().unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let mut warnings = Vec::new();
let result = merge_ref(dir.path(), &branch, &mut warnings);
assert!(result.is_none());
assert!(warnings.is_empty());
}
#[test]
fn merge_ref_success() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
git_cmd(dir.path(), &["checkout", "-b", "feature"]);
make_commit(dir.path(), "g.txt", "there");
git_cmd(dir.path(), &["checkout", "main"]);
let mut warnings = Vec::new();
let result = merge_ref(dir.path(), "feature", &mut warnings);
assert!(result.is_some());
assert!(warnings.is_empty());
}
#[test]
fn detect_mid_merge_none_on_clean_repo() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
assert!(detect_mid_merge_state(dir.path()).is_none());
}
#[test]
fn detect_mid_merge_on_merge_head() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
std::fs::write(dir.path().join(".git/MERGE_HEAD"), "abc").unwrap();
assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::Merge)));
}
#[test]
fn detect_mid_merge_on_rebase_merge() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
std::fs::create_dir(dir.path().join(".git/rebase-merge")).unwrap();
assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseMerge)));
}
#[test]
fn detect_mid_merge_on_rebase_apply() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
std::fs::create_dir(dir.path().join(".git/rebase-apply")).unwrap();
assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::RebaseApply)));
}
#[test]
fn detect_mid_merge_on_cherry_pick() {
let dir = git_init();
make_commit(dir.path(), "f.txt", "hi");
std::fs::write(dir.path().join(".git/CHERRY_PICK_HEAD"), "abc").unwrap();
assert!(matches!(detect_mid_merge_state(dir.path()), Some(MidMergeState::CherryPick)));
}
#[test]
fn is_file_tracked_tracked_and_untracked() {
let dir = git_init();
make_commit(dir.path(), "tracked.txt", "hi");
assert!(is_file_tracked(dir.path(), "tracked.txt"));
std::fs::write(dir.path().join("untracked.txt"), "new").unwrap();
assert!(!is_file_tracked(dir.path(), "untracked.txt"));
}
#[test]
fn check_leaked_files_detects_overlap() {
let dir = git_init();
let p = dir.path();
std::fs::create_dir_all(p.join("src")).unwrap();
std::fs::write(p.join("src/foo.rs"), "original").unwrap();
git_cmd(p, &["add", "src/foo.rs"]);
git_cmd(p, &["commit", "-m", "add foo"]);
git_cmd(p, &["checkout", "-b", "ticket/overlap-test"]);
std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
git_cmd(p, &["add", "src/foo.rs"]);
git_cmd(p, &["commit", "-m", "ticket: change foo"]);
git_cmd(p, &["checkout", "main"]);
std::fs::write(p.join("src/foo.rs"), "leaked").unwrap();
let leaked = check_leaked_files(p, "ticket/overlap-test", "main").unwrap();
assert_eq!(leaked, vec!["src/foo.rs".to_string()]);
}
#[test]
fn check_leaked_files_no_overlap() {
let dir = git_init();
let p = dir.path();
std::fs::create_dir_all(p.join("src")).unwrap();
std::fs::write(p.join("src/foo.rs"), "original").unwrap();
std::fs::write(p.join("src/bar.rs"), "bar").unwrap();
git_cmd(p, &["add", "src/foo.rs", "src/bar.rs"]);
git_cmd(p, &["commit", "-m", "add foo and bar"]);
git_cmd(p, &["checkout", "-b", "ticket/no-overlap"]);
std::fs::write(p.join("src/foo.rs"), "ticket-change").unwrap();
git_cmd(p, &["add", "src/foo.rs"]);
git_cmd(p, &["commit", "-m", "ticket: change foo"]);
git_cmd(p, &["checkout", "main"]);
std::fs::write(p.join("src/bar.rs"), "dirty").unwrap();
let leaked = check_leaked_files(p, "ticket/no-overlap", "main").unwrap();
assert!(leaked.is_empty(), "no overlap expected; got {leaked:?}");
}
#[test]
fn check_leaked_files_detects_untracked_overlap() {
let dir = git_init();
let p = dir.path();
make_commit(p, "existing.rs", "base");
git_cmd(p, &["checkout", "-b", "ticket/untracked-overlap"]);
std::fs::create_dir_all(p.join("src")).unwrap();
std::fs::write(p.join("src/new.rs"), "new file").unwrap();
git_cmd(p, &["add", "src/new.rs"]);
git_cmd(p, &["commit", "-m", "ticket: add new file"]);
git_cmd(p, &["checkout", "main"]);
std::fs::create_dir_all(p.join("src")).unwrap();
std::fs::write(p.join("src/new.rs"), "leaked untracked").unwrap();
let leaked = check_leaked_files(p, "ticket/untracked-overlap", "main").unwrap();
assert_eq!(leaked, vec!["src/new.rs".to_string()]);
}
fn commit_file(dir: &Path, name: &str, content: &str) {
std::fs::write(dir.join(name), content).unwrap();
git_cmd(dir, &["add", name]);
git_cmd(dir, &["commit", "-m", &format!("add {name}")]);
}
#[test]
fn content_merged_into_main_regular_merge_with_state_commit() {
let dir = git_init();
let p = dir.path();
commit_file(p, "README", "base");
git_cmd(p, &["checkout", "-b", "ticket/foo"]);
std::fs::create_dir_all(p.join("src")).unwrap();
commit_file(p, "src/lib.rs", "impl");
git_cmd(p, &["checkout", "main"]);
git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
git_cmd(p, &["checkout", "ticket/foo"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
commit_file(p, "tickets/foo.md", "state: implemented");
let result = content_merged_into_main(p, "main", "ticket/foo", "tickets").unwrap();
assert!(result, "should detect that content was merged despite trailing state commit");
}
#[test]
fn content_merged_into_main_squash_merge_with_state_commit() {
let dir = git_init();
let p = dir.path();
commit_file(p, "README", "base");
git_cmd(p, &["checkout", "-b", "ticket/bar"]);
std::fs::create_dir_all(p.join("src")).unwrap();
commit_file(p, "src/lib.rs", "impl");
git_cmd(p, &["checkout", "main"]);
git_cmd(p, &["merge", "--squash", "ticket/bar"]);
git_cmd(p, &["commit", "-m", "Squash ticket/bar"]);
git_cmd(p, &["checkout", "ticket/bar"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
commit_file(p, "tickets/bar.md", "state: implemented");
let result = content_merged_into_main(p, "main", "ticket/bar", "tickets").unwrap();
assert!(result, "should detect squash-merged content despite trailing state commit");
}
#[test]
fn content_merged_into_main_returns_false_when_ancestor() {
let dir = git_init();
let p = dir.path();
commit_file(p, "README", "base");
git_cmd(p, &["checkout", "-b", "ticket/anc"]);
git_cmd(p, &["checkout", "main"]);
let result = content_merged_into_main(p, "main", "ticket/anc", "tickets").unwrap();
assert!(!result);
}
#[test]
fn content_merged_into_main_not_detected_when_non_ticket_file_modified_after_merge() {
let dir = git_init();
let p = dir.path();
commit_file(p, "README", "base");
git_cmd(p, &["checkout", "-b", "ticket/extra"]);
std::fs::create_dir_all(p.join("src")).unwrap();
commit_file(p, "src/lib.rs", "impl");
git_cmd(p, &["checkout", "main"]);
git_cmd(p, &["merge", "--squash", "ticket/extra"]);
git_cmd(p, &["commit", "-m", "Squash ticket/extra"]);
git_cmd(p, &["checkout", "ticket/extra"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
commit_file(p, "tickets/extra.md", "state: implemented");
commit_file(p, "src/extra.rs", "extra code");
let result = content_merged_into_main(p, "main", "ticket/extra", "tickets").unwrap();
assert!(!result, "branch with non-ticket changes after merge must not be detected");
}
#[test]
fn content_merged_into_main_all_ticket_only_commits_returns_false() {
let dir = git_init();
let p = dir.path();
commit_file(p, "README", "base");
git_cmd(p, &["checkout", "-b", "ticket/ticketonly"]);
std::fs::create_dir_all(p.join("tickets")).unwrap();
commit_file(p, "tickets/ticketonly.md", "state: new");
git_cmd(p, &["checkout", "main"]);
let result = content_merged_into_main(p, "main", "ticket/ticketonly", "tickets").unwrap();
assert!(!result, "all-ticket-only commits should return false");
}
#[test]
fn merged_into_main_detects_local_regular_merge_when_remote_deleted() {
let dir = git_init();
let p = dir.path();
make_commit(p, "f.txt", "base");
git_cmd(p, &["checkout", "-b", "ticket/foo"]);
std::fs::write(p.join("f.txt"), "ticket-change").unwrap();
git_cmd(p, &["add", "f.txt"]);
git_cmd(p, &["commit", "-m", "ticket: change"]);
git_cmd(p, &["checkout", "main"]);
git_cmd(p, &["merge", "--no-ff", "ticket/foo", "-m", "Merge ticket/foo"]);
let main_sha = run(p, &["rev-parse", "main"]).unwrap();
Cmd::new("git")
.args(["update-ref", "refs/remotes/origin/main", main_sha.trim()])
.current_dir(p)
.status()
.unwrap();
let merged = merged_into_main(p, "main").unwrap();
assert!(
merged.iter().any(|b| b == "ticket/foo"),
"expected ticket/foo in merged set; got {merged:?}"
);
}
}