use std::collections::{BTreeSet, HashMap};
use std::io::Write;
use std::path::Path;
use std::time::Duration;
use crate::log_warn;
#[cfg(test)]
use crate::test_helpers::{test_commit_cmd, test_git_cmd};
use anyhow::Result;
use dracon_git::GitService;
use crate::exclude::{
can_restore_entry, handle_large_untracked, is_large_untracked, remove_tracked_excluded_paths,
should_stage_entry,
};
use crate::git::multi_remote::push_mirror_remotes;
use crate::git::origin_url;
use crate::git::{
cli_diff_entries, git_name_status_entries, has_origin_remote, has_tracking_upstream,
is_cherry_pick_in_progress, is_merge_in_progress, is_rebase_in_progress, is_repo_ready,
prune_other_default_branch, push_with_retries, restore_paths, run_git_capture_output,
run_git_with_timeout, unstage_excluded_paths, unstage_oversized_paths,
};
use crate::policy::{debug_enabled, load_repo_override, SyncPolicy};
use crate::visibility::{
get_github_visibility, parse_github_owner_repo, sync_mirror_metadata, sync_mirror_visibility,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncOutcome {
Synced,
NothingToDo,
Blocked,
}
pub(crate) async fn count_ahead_commits(repo: &Path) -> Result<u64> {
let output = crate::policy::tokio_git_command()
.args(["rev-list", "--count", "origin/main..HEAD"])
.current_dir(repo)
.output()
.await?;
if !output.status.success() {
return Ok(0);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.trim().parse::<u64>().unwrap_or(0))
}
pub(crate) fn is_backstop_active(
ahead_since: Option<std::time::Instant>,
now: std::time::Instant,
ahead_count: usize,
threshold: usize,
min_age_secs: u64,
) -> bool {
if threshold == 0 {
return false;
}
if ahead_count <= threshold {
return false;
}
let Some(since) = ahead_since else {
return false;
};
now.duration_since(since).as_secs() >= min_age_secs
}
pub(crate) fn scale_push_timeout(base: u64, ahead: u64) -> u64 {
let multiplier: u64 = if ahead <= 5 {
1
} else if ahead <= 20 {
2
} else if ahead <= 50 {
4
} else {
6
};
(base * multiplier).min(600)
}
impl SyncOutcome {
pub fn has_changes(&self) -> bool {
matches!(self, SyncOutcome::Synced)
}
}
struct SyncContext<'a> {
repo: &'a Path,
policy: &'a SyncPolicy,
excluded_dir_names: &'a BTreeSet<String>,
dry_run: bool,
#[allow(dead_code)]
idle_seconds: u64,
#[allow(dead_code)]
policy_path: Option<&'a Path>,
has_origin: bool,
has_upstream: bool,
#[allow(dead_code)]
auto_bump_versions: bool,
remote_failures: Option<&'a mut HashMap<String, usize>>,
backstop_active: bool,
}
fn notify_webhook_failure(webhook_url: &str, repo: &Path, remote: &str, error: &str) {
let payload = serde_json::json!({
"event": "push_failure",
"repo": repo.display().to_string(),
"remote": remote,
"error": error,
"timestamp": crate::policy::timestamp_secs(),
});
let url = webhook_url.to_string();
std::thread::spawn(move || {
if let Ok(client) = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(5))
.build()
{
if let Err(e) = client.post(&url).json(&payload).send() {
eprintln!("⚠️ webhook notification failed: {}", e);
}
}
});
}
async fn get_bump_info(repo: &Path) -> Option<(String, String, String)> {
let new_ver = crate::release::detect_project_version(repo)?.0;
let version_files = if repo.join("Cargo.toml").exists() {
&["Cargo.toml"][..]
} else if repo.join("package.json").exists() {
&["package.json"][..]
} else if repo.join("pyproject.toml").exists() {
&["pyproject.toml"][..]
} else if repo.join("pubspec.yaml").exists() {
&["pubspec.yaml"][..]
} else if repo.join("version.txt").exists() {
&["version.txt"][..]
} else if repo.join("VERSION").exists() {
&["VERSION"][..]
} else {
&[
"Cargo.toml",
"package.json",
"pyproject.toml",
"pubspec.yaml",
"version.txt",
"VERSION",
][..]
};
let mut old_ver = String::new();
for file in version_files.iter() {
let repo_pb = repo.to_path_buf();
let file_s = file.to_string();
let output = tokio::task::spawn_blocking(move || {
crate::git::git_cmd()
.args(["show", &format!("HEAD~1:{}", file_s)])
.current_dir(&repo_pb)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.ok()
})
.await
.ok()
.flatten();
if let Some(output) = output {
if !output.status.success() {
continue;
}
let content = String::from_utf8_lossy(&output.stdout);
if let Some(v) = match *file {
"Cargo.toml" => content
.lines()
.map(|l| l.trim())
.find(|l| l.starts_with("version") && !l.starts_with("version_prefix"))
.and_then(|l| l.split('=').nth(1))
.map(|v| v.trim().trim_matches('"').trim())
.filter(|v| !v.is_empty() && !v.starts_with("workspace"))
.map(|v| v.to_string()),
"package.json" => content
.lines()
.map(|l| l.trim())
.find(|l| l.starts_with("\"version\""))
.and_then(|l| l.split(':').nth(1))
.map(|v| v.trim().trim_matches('"').trim_matches(',').trim())
.filter(|v| !v.is_empty())
.map(|v| v.to_string()),
"pyproject.toml" => content
.lines()
.map(|l| l.trim())
.find(|l| l.starts_with("version") && !l.starts_with("version_prefix"))
.and_then(|l| l.split('=').nth(1))
.map(|v| v.trim().trim_matches('"').trim_matches(',').trim())
.filter(|v| !v.is_empty())
.map(|v| v.to_string()),
"pubspec.yaml" => content
.lines()
.map(|l| l.trim())
.find(|l| l.starts_with("version:"))
.and_then(|l| l.split(':').nth(1))
.map(|v| v.trim().split('+').next().unwrap_or("").trim())
.filter(|v| !v.is_empty())
.map(|v| v.to_string()),
"version.txt" | "VERSION" => {
let v = content.trim();
if !v.is_empty() && v.contains('.') {
Some(v.to_string())
} else {
None
}
}
_ => None,
} {
old_ver = v;
break;
}
}
}
let level = if old_ver.is_empty() {
"patch"
} else {
let old_parts: Vec<u32> = old_ver.split('.').filter_map(|s| s.parse().ok()).collect();
let new_parts: Vec<u32> = new_ver.split('.').filter_map(|s| s.parse().ok()).collect();
if old_parts.len() >= 3 && new_parts.len() >= 3 {
if new_parts[0] > old_parts[0] {
"major"
} else if new_parts[1] > old_parts[1] {
"minor"
} else {
"patch"
}
} else {
"patch"
}
};
Some((old_ver, new_ver, level.to_string()))
}
fn maybe_sync_visibility_and_metadata(ctx: &SyncContext<'_>) {
if ctx.dry_run || (!ctx.policy.sync_visibility && !ctx.policy.sync_metadata) {
return;
}
if let Some(origin_url) = crate::git::multi_remote::get_remote_url(ctx.repo, "origin") {
if ctx.policy.sync_metadata {
sync_mirror_metadata(
&origin_url,
&ctx.policy.remotes,
ctx.repo,
ctx.policy.sync_visibility_interval_hours,
);
}
if ctx.policy.sync_visibility {
sync_mirror_visibility(
&origin_url,
&ctx.policy.remotes,
ctx.repo,
ctx.policy.sync_visibility_interval_hours,
);
}
}
}
fn check_conflict_state(repo: &Path) -> Option<SyncOutcome> {
if is_rebase_in_progress(repo) {
eprintln!(
"⚠️ {} has rebase in progress, skipping (manual intervention required)",
repo.display()
);
return Some(SyncOutcome::Blocked);
}
if is_merge_in_progress(repo) {
eprintln!(
"⚠️ {} has merge in progress, skipping (manual intervention required)",
repo.display()
);
return Some(SyncOutcome::Blocked);
}
if is_cherry_pick_in_progress(repo) {
eprintln!(
"⚠️ {} has cherry-pick in progress, skipping (manual intervention required)",
repo.display()
);
return Some(SyncOutcome::Blocked);
}
None
}
fn ensure_origin_remote(repo: &Path, policy: &SyncPolicy) -> bool {
let has_origin = has_origin_remote(repo);
if !has_origin && policy.auto_github_private {
let private = if policy.sync_visibility {
if let Some(url) = origin_url(repo) {
if let Some((owner, repo_name)) = parse_github_owner_repo(&url) {
get_github_visibility(&owner, &repo_name)
} else {
true
}
} else {
true
}
} else {
true
};
if let Some(url) = crate::report::create_github_private_remote(
repo,
&policy.auto_github_private_account,
private,
) {
println!("🔗 created remote for {}: {}", repo.display(), url);
true
} else {
eprintln!("⚠️ failed to create GitHub remote for {}", repo.display());
false
}
} else {
has_origin
}
}
async fn auto_pull_merge(
svc: &GitService,
ctx: &SyncContext<'_>,
initial_status: &dracon_git::types::RepoStatus,
) -> Result<()> {
let repo = ctx.repo;
let policy = ctx.policy;
if policy.auto_pull
&& ctx.has_origin
&& ctx.has_upstream
&& initial_status.behind > 0
&& initial_status.is_clean
{
if ctx.dry_run {
println!(
"🔽 Would pull/merge {} commit(s) from upstream in {}",
initial_status.behind,
repo.display()
);
} else {
match tokio::time::timeout(
Duration::from_secs(policy.pull_op_timeout_secs),
svc.pull_merge(),
)
.await
{
Ok(Ok(())) => {}
Ok(Err(dracon_git::error::GitError::MergeConflict)) => {
eprintln!(
"⚠️ pull/merge conflict in {} (manual intervention required)",
repo.display()
);
return Err(anyhow::anyhow!("pull/merge conflict"));
}
Ok(Err(e)) => {
eprintln!(
"⚠️ pull/merge failed for {}: {} - aborting sync pass",
repo.display(),
e
);
return Err(anyhow::anyhow!("pull/merge failed: {}", e));
}
Err(_) => {
eprintln!(
"⚠️ pull/merge timeout for {} after {}s - aborting sync pass",
repo.display(),
policy.pull_op_timeout_secs
);
return Err(anyhow::anyhow!("pull/merge timeout"));
}
}
}
} else if policy.auto_pull && ctx.has_origin && ctx.has_upstream && initial_status.behind == 0 {
if debug_enabled() {
eprintln!(
"🐛 skip pull/merge for {} (branch not behind upstream)",
repo.display()
);
}
} else if policy.auto_pull && ctx.has_origin && ctx.has_upstream && !initial_status.is_clean {
if debug_enabled() {
eprintln!(
"🐛 skip pull/merge for {} (dirty repo, commit first)",
repo.display()
);
}
} else if policy.auto_pull && !ctx.has_origin {
eprintln!(
"ℹ️ skip pull/merge for {} (no origin remote)",
repo.display()
);
} else if policy.auto_pull && ctx.has_origin && !ctx.has_upstream {
eprintln!(
"ℹ️ skip pull/merge for {} (no tracking upstream on current branch)",
repo.display()
);
}
Ok(())
}
async fn clean_staged_paths(ctx: &SyncContext<'_>) -> Result<()> {
let repo = ctx.repo;
let policy = ctx.policy;
let excluded_dir_names = ctx.excluded_dir_names;
let dry_run = ctx.dry_run;
let unstaged = if dry_run {
0
} else {
unstage_excluded_paths(repo, excluded_dir_names).await?
};
if unstaged > 0 {
eprintln!(
"🧹 removed {} staged excluded paths in {}",
unstaged,
repo.display()
);
}
let unstaged_oversized = if dry_run {
0
} else {
unstage_oversized_paths(repo, policy.max_stage_file_bytes).await?
};
if unstaged_oversized > 0 {
eprintln!(
"🧹 removed {} oversized staged paths in {}",
unstaged_oversized,
repo.display()
);
}
if let Some(removed_dirs) = if dry_run {
None
} else {
remove_tracked_excluded_paths(repo, excluded_dir_names)?
} {
if !removed_dirs.is_empty() {
eprintln!(
"🧹 removed {} tracked excluded dir(s) from {}: {:?}",
removed_dirs.len(),
repo.display(),
removed_dirs
);
}
}
Ok(())
}
struct DiffResult {
status: dracon_git::types::RepoStatus,
entries: Vec<dracon_git::types::DiffFile>,
#[allow(dead_code)]
filter_only_cleared: bool,
}
#[cfg(test)]
mod diff_tests {
#[test]
fn test_fallback_entries_recalculate_staged_files() {
use crate::git::staged_paths;
let tmp = tempfile::tempdir().unwrap();
let repo_path = tmp.path().join("test-repo");
std::fs::create_dir_all(&repo_path).unwrap();
let output = crate::git::git_cmd()
.args(["init"])
.current_dir(&repo_path)
.output()
.expect("git init failed");
assert!(output.status.success(), "git init failed: {:?}", output);
crate::git::git_cmd()
.args(["config", "user.email", "test@test.com"])
.current_dir(&repo_path)
.output()
.unwrap();
crate::git::git_cmd()
.args(["config", "user.name", "Test"])
.current_dir(&repo_path)
.output()
.unwrap();
std::fs::write(repo_path.join("README.md"), "initial").unwrap();
crate::git::git_cmd()
.args(["add", "README.md"])
.current_dir(&repo_path)
.output()
.unwrap();
crate::git::git_cmd()
.args(["commit", "--no-verify", "-m", "initial"])
.current_dir(&repo_path)
.output()
.unwrap();
std::fs::write(repo_path.join("new_file.rs"), "fn main() {}").unwrap();
crate::git::git_cmd()
.args(["add", "new_file.rs"])
.current_dir(&repo_path)
.output()
.unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let staged = rt.block_on(staged_paths(&repo_path)).unwrap();
assert_eq!(staged.len(), 1, "expected 1 staged file, got {:?}", staged);
assert!(staged.contains(&std::path::PathBuf::from("new_file.rs")));
let rt = tokio::runtime::Runtime::new().unwrap();
let entries = rt
.block_on(crate::git::cli_diff_entries(&repo_path))
.unwrap();
assert!(
!entries.is_empty(),
"cli_diff_entries should find the staged file"
);
}
}
async fn compute_diff_entries(svc: &GitService, repo: &Path) -> Result<DiffResult> {
let mut status = svc.get_status().await?;
let mut entries = svc.get_diff_entries().await?;
let mut filter_only_cleared = false;
{
let diff_output = crate::git::git_diff_head_files(repo)
.await
.unwrap_or_default();
if diff_output.is_empty() && !entries.is_empty() {
let has_non_modified = entries
.iter()
.any(|e| !matches!(e.status, dracon_git::types::FileStatus::Modified));
if !has_non_modified {
entries.clear();
status.is_clean = true;
filter_only_cleared = true;
}
} else {
entries.retain(|e| {
if !matches!(e.status, dracon_git::types::FileStatus::Modified) {
return true;
}
diff_output.contains(&e.path)
});
}
}
if debug_enabled() {
eprintln!(
"🐛 {} status: clean={} modified={} staged={} entries(libgit2)={}",
repo.display(),
status.is_clean,
status.modified_files,
status.staged_files,
entries.len()
);
}
if entries.is_empty() && !filter_only_cleared {
let fallback_entries = cli_diff_entries(repo).await?;
if !fallback_entries.is_empty() {
status.is_clean = false;
status.modified_files = fallback_entries.len();
if let Ok(staged) = crate::git::staged_paths(repo).await {
status.staged_files = staged.len();
}
entries = fallback_entries;
if debug_enabled() {
eprintln!(
"🐛 {} fallback entries(cli)={} staged={} => forcing dirty",
repo.display(),
status.modified_files,
status.staged_files,
);
}
}
}
Ok(DiffResult {
status,
entries,
filter_only_cleared,
})
}
async fn stage_existing_files(
repo: &Path,
existing: &[String],
dry_run: bool,
stage_timeout_secs: u64,
excluded_dir_names: &std::collections::BTreeSet<String>,
) -> Result<()> {
if existing.is_empty() {
return Ok(());
}
let input: Vec<String> = existing.to_vec();
let mut expanded: Vec<String> = Vec::with_capacity(input.len() * 2);
for p in input {
let full = repo.join(&p);
if !full.exists() {
continue;
}
if full.is_file() {
expanded.push(p);
continue;
}
if full.is_dir() {
let excluded = excluded_dir_names;
if let Some(name) = full.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') || excluded.contains(name) {
continue;
}
}
let mut stack: Vec<std::path::PathBuf> = vec![full.clone()];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(rd) => rd,
Err(_) => continue, };
for child in rd.flatten() {
let cp = child.path();
let meta = match std::fs::symlink_metadata(&cp) {
Ok(m) => m,
Err(_) => continue,
};
if meta.file_type().is_symlink() {
continue;
}
if meta.is_file() {
if let Some(rel) = cp.strip_prefix(repo).ok() {
expanded.push(rel.to_string_lossy().to_string());
}
continue;
}
if meta.is_dir() {
if let Some(name) = cp.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') {
continue;
}
if excluded.contains(name) {
continue;
}
}
stack.push(cp);
}
}
}
}
}
let existing = expanded;
if existing.is_empty() {
return Ok(());
}
if dry_run {
println!(
"📝 Would stage {} file(s) in {}: {:?}",
existing.len(),
repo.display(),
&existing[..existing.len().min(5)]
);
if existing.len() > 5 {
println!(" ... and {} more", existing.len() - 5);
}
} else {
let (force_paths, normal_paths) = partition_gitignored(repo, &existing).await;
if !normal_paths.is_empty() {
let mut add_args = vec!["add", "-A", "--"];
for p in &normal_paths {
add_args.push(p.as_str());
}
if let Err(e) = run_git_with_timeout(repo, &add_args, stage_timeout_secs, "add").await {
eprintln!(
"⚠️ {} git add failed for {} paths: {:?}",
repo.display(),
normal_paths.len(),
&normal_paths[..normal_paths.len().min(5)]
);
return Err(e);
}
}
if !force_paths.is_empty() {
let mut add_args = vec!["add", "-A", "-f", "--"];
for p in &force_paths {
add_args.push(p.as_str());
}
if run_git_with_timeout(repo, &add_args, stage_timeout_secs, "add (force-tracked)")
.await
.is_err()
{
eprintln!(
"⚠️ {} git add -f failed for {} tracked gitignored paths: {:?}",
repo.display(),
force_paths.len(),
&force_paths[..force_paths.len().min(5)]
);
}
}
}
Ok(())
}
async fn partition_gitignored(repo: &Path, paths: &[String]) -> (Vec<String>, Vec<String>) {
if paths.is_empty() {
return (Vec::new(), Vec::new());
}
let tracked: std::collections::HashSet<String> = {
let output = crate::policy::tokio_git_command()
.args(["ls-files"])
.current_dir(repo)
.output()
.await;
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect(),
_ => std::collections::HashSet::new(),
}
};
let ignored: std::collections::HashSet<String> = {
let untracked: Vec<&str> = paths
.iter()
.filter(|p| !tracked.contains(*p))
.map(|p| p.as_str())
.collect();
if untracked.is_empty() {
std::collections::HashSet::new()
} else {
let mut check_args = vec!["check-ignore"];
for p in &untracked {
check_args.push(*p);
}
let output = crate::policy::tokio_git_command()
.args(&check_args)
.current_dir(repo)
.output()
.await;
match output {
Ok(o) if o.status.success() || o.status.code() == Some(1) => {
String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect()
}
_ => std::collections::HashSet::new(),
}
}
};
let mut force_paths = Vec::new();
let mut normal_paths = Vec::new();
for p in paths {
if tracked.contains(p) {
force_paths.push(p.clone());
} else if ignored.contains(p) {
} else {
normal_paths.push(p.clone());
}
}
(force_paths, normal_paths)
}
async fn git_rm_missing(repo: &Path, missing: &[String], dry_run: bool) -> Result<()> {
if missing.is_empty() {
return Ok(());
}
let mut rm_args = vec!["rm", "--ignore-unmatch", "--"];
for p in missing {
rm_args.push(p);
}
if dry_run {
println!(
"🗑️ Would delete (git rm) {} file(s) from {}: {:?}",
missing.len(),
repo.display(),
&missing[..missing.len().min(5)]
);
if missing.len() > 5 {
println!(" ... and {} more", missing.len() - 5);
}
} else if let Err(e) = run_git_with_timeout(repo, &rm_args, 30, "rm").await {
eprintln!(
"⚠️ {} git rm failed for {} paths: {:?}",
repo.display(),
missing.len(),
missing
);
return Err(e);
}
Ok(())
}
async fn post_commit_pull(svc: &GitService, repo: &Path, policy: &SyncPolicy) {
if !policy.auto_pull {
return;
}
let post_commit_status = match svc.get_status().await {
Ok(s) => s,
Err(_) => return,
};
if post_commit_status.behind > 0 && post_commit_status.is_clean {
eprintln!(
"📥 post-commit pull for {} ({} behind)",
repo.display(),
post_commit_status.behind
);
match tokio::time::timeout(
Duration::from_secs(policy.pull_op_timeout_secs),
svc.pull_merge(),
)
.await
{
Ok(Ok(())) => {
eprintln!("✅ post-commit pull succeeded for {}", repo.display());
}
Ok(Err(dracon_git::error::GitError::MergeConflict)) => {
eprintln!(
"⚠️ post-commit pull conflict in {} (manual intervention required)",
repo.display()
);
}
Ok(Err(e)) => {
eprintln!(
"⚠️ post-commit pull failed for {}: {} - will still attempt push",
repo.display(),
e
);
}
Err(_) => {
eprintln!(
"⚠️ post-commit pull timeout for {} after {}s - will still attempt push",
repo.display(),
policy.pull_op_timeout_secs
);
}
}
}
}
async fn restore_excluded_paths(
ctx: &SyncContext<'_>,
to_restore: &[dracon_git::types::DiffFile],
) -> Result<()> {
let repo = ctx.repo;
let policy = ctx.policy;
let restorable: Vec<_> = to_restore
.iter()
.filter(|e| can_restore_entry(repo, e))
.filter(|e| {
!repo.join(&e.path).is_dir() || !crate::exclude::is_gitlink_unchanged(repo, &e.path)
})
.collect();
handle_large_untracked(repo, to_restore, policy)?;
let other_untracked: Vec<_> = to_restore
.iter()
.filter(|e| {
!can_restore_entry(repo, e) && !is_large_untracked(e, repo, policy.max_stage_file_bytes)
})
.collect();
if !other_untracked.is_empty() {
eprintln!(
"ℹ️ {} has {} small untracked excluded file(s)",
repo.display(),
other_untracked.len()
);
}
if !restorable.is_empty() {
let excluded_paths: Vec<String> = restorable
.iter()
.map(|e| e.path.to_string_lossy().to_string())
.collect();
eprintln!(
"🧹 restoring {} excluded path(s) in {} after commit",
excluded_paths.len(),
repo.display()
);
restore_paths(repo, &excluded_paths).await?;
}
Ok(())
}
async fn run_release_pipeline_if_bumped(repo: &Path, policy: &SyncPolicy, version_bumped: bool) {
if !version_bumped {
return;
}
if let Some((old_ver, new_ver, level)) = get_bump_info(repo).await {
let repo_override = crate::policy::load_repo_override(repo);
let repo_auto_tag = repo_override.auto_tag.unwrap_or(policy.auto_tag);
let repo_auto_release = repo_override.auto_release.unwrap_or(policy.auto_release);
let repo_publish_targets = repo_override.auto_publish;
let repo_nix_auto_update = repo_override
.nix_auto_update
.unwrap_or(policy.nix_auto_update);
let steps = crate::release::run_release_pipeline(
repo,
&old_ver,
&new_ver,
level.as_str(),
policy,
repo_auto_tag,
repo_auto_release,
&repo_publish_targets,
repo_nix_auto_update,
)
.await;
for step in &steps {
match step {
crate::release::ReleaseStep::TagCreated(tag) => eprintln!("🏷️ {tag}"),
crate::release::ReleaseStep::GitHubReleaseCreated(tag) => eprintln!("🚀 {tag}"),
crate::release::ReleaseStep::Published { registry, version } => {
eprintln!("📦 published to {registry} v{version}")
}
crate::release::ReleaseStep::NixFlakePRCreated(url) => {
eprintln!("📄 flake PR created: {url}")
}
crate::release::ReleaseStep::Skipped(reason) => {
if debug_enabled() {
eprintln!("🐛 release skipped: {reason}");
}
}
crate::release::ReleaseStep::Failed { step: s, error } => {
eprintln!("⚠️ release failed: {s} — {error}")
}
}
}
}
}
async fn push_background(
repo: &std::path::Path,
policy: &SyncPolicy,
mut remote_failures: Option<&mut HashMap<String, usize>>,
) -> Result<bool> {
let ahead_count = count_ahead_commits(repo).await.unwrap_or(0);
let scaled_timeout = scale_push_timeout(policy.push_op_timeout_secs, ahead_count);
if scaled_timeout != policy.push_op_timeout_secs {
eprintln!(
"⏫ {} scaling push timeout {}s → {}s ({} commits ahead)",
repo.display(),
policy.push_op_timeout_secs,
scaled_timeout,
ahead_count
);
}
match push_with_retries(
repo,
scaled_timeout,
policy.push_retries,
"push",
)
.await
{
Ok(()) => {}
Err(e) => {
eprintln!(
"⚠️ background push to origin failed for {}: {}",
repo.display(),
e
);
return Ok(false);
}
}
if !policy.remotes.is_empty() {
let private = true;
let push_results = push_mirror_remotes(
repo,
&policy.remotes,
policy.push_op_timeout_secs,
policy.push_retries,
private,
)
.await;
let all_ok = push_results.iter().all(|(_, r)| r.is_ok());
if !all_ok {
for (name, result) in &push_results {
if let Err(e) = result {
log_warn!("push to {} failed for {}: {}", name, repo.display(), e);
if let Some(ref url) = policy.webhook_url {
notify_webhook_failure(url, repo, name, &e.to_string());
}
if let Some(rf) = remote_failures.as_deref_mut() {
*rf.entry(name.clone()).or_insert(0) += 1;
}
}
}
return Ok(false);
} else if let Some(rf) = remote_failures {
for name in policy.remotes.iter().map(|r| r.name.clone()) {
rf.remove(&name);
}
}
}
Ok(true)
}
#[derive(Debug, Default)]
struct TaskTransitions {
closed: Vec<String>,
progress: Vec<String>,
goal_metadata: Option<GoalMetadata>,
}
#[derive(Debug, Default)]
struct GoalMetadata {
status: Option<String>,
pause_reason: Option<String>,
tokens_used: Option<u64>,
active_seconds: Option<u64>,
task_details: Vec<TaskDetail>,
}
#[derive(Debug, Default)]
struct TaskDetail {
id: String,
status: String,
evidence: Option<String>,
skip_reason: Option<String>,
}
fn extract_task_transitions(repo: &Path) -> TaskTransitions {
let output =
match run_git_capture_output(repo, &["diff", "--cached", "--unified=0"], "task-diff") {
Ok(o) => o,
Err(_) => return TaskTransitions::default(),
};
let mut transitions = TaskTransitions::default();
for line in output.lines() {
if !line.starts_with('+') || line.starts_with("+++") {
continue;
}
let content = &line[1..]; let trimmed = content.trim();
if let Some(rest) =
extract_checkbox_text(trimmed, 'x').or_else(|| extract_checkbox_text(trimmed, 'X'))
{
let task = sanitize_task_name(rest);
if !task.is_empty() {
transitions.closed.push(task);
}
}
else if let Some(rest) = extract_checkbox_text(trimmed, '~') {
let task = sanitize_task_name(rest);
if !task.is_empty() {
transitions.progress.push(task);
}
}
}
transitions.goal_metadata = extract_goal_metadata(repo);
transitions
}
fn extract_checkbox_text(line: &str, marker: char) -> Option<&str> {
let pattern = format!("[{}]", marker);
let prefixes = ["- ", "* ", ""];
for prefix in &prefixes {
let full_prefix = format!("{}{}", prefix, pattern);
if let Some(rest) = line.strip_prefix(&full_prefix) {
return Some(rest.trim());
}
}
None
}
fn sanitize_task_name(name: &str) -> String {
if name.starts_with("**") {
if let Some(end) = name.find("**") {
if end >= 2 {
let identifier = &name[2..end];
let rest = &name[end + 2..];
if rest.is_empty() {
return compact_task_phrase(identifier);
}
let first_word = rest.split_whitespace().next().unwrap_or("");
if first_word.is_empty() {
return compact_task_phrase(identifier);
}
return compact_task_phrase(&format!("{} {}", identifier, first_word));
}
}
}
let sanitized = name
.replace('|', "/")
.replace("**", "")
.replace("__", "")
.replace('*', "")
.replace('[', "(")
.replace(']', ")")
.replace('`', "") .trim()
.to_string();
compact_task_phrase(&sanitized)
}
fn compact_task_phrase(name: &str) -> String {
let clause = name
.split([':', ';', '—', '–'])
.next()
.unwrap_or(name)
.trim();
let clause = if clause.is_empty() { name } else { clause };
let words: Vec<&str> = clause.split_whitespace().collect();
let compact = if words.len() > 3 {
words[..3].join(" ")
} else {
clause.to_string()
};
truncate_task(&compact)
}
fn truncate_task(name: &str) -> String {
const MAX_LEN: usize = 60;
let truncated_at_max = if name.len() <= MAX_LEN {
return name.to_string();
} else {
let mut last_boundary = 0;
for (i, _) in name.char_indices() {
if i > MAX_LEN {
break;
}
last_boundary = i;
}
last_boundary
};
let mut last_boundary_pos = None;
for (i, c) in name.char_indices() {
if i >= truncated_at_max {
break;
}
if c == '.' || c == '—' || c == '–' {
last_boundary_pos = Some(i + c.len_utf8());
}
}
if let Some(pos) = last_boundary_pos {
let truncated = &name[..pos];
if truncated.len() >= 10 {
return truncated.to_string();
}
}
format!("{}...", &name[..truncated_at_max])
}
fn extract_goal_metadata(repo: &Path) -> Option<GoalMetadata> {
let files_output =
run_git_capture_output(repo, &["diff", "--cached", "--name-only"], "goal-files").ok()?;
let goal_files: Vec<&str> = files_output
.lines()
.filter(|f| f.starts_with(".pi/goals/") && f.ends_with(".md"))
.collect();
if goal_files.is_empty() {
return None;
}
let goal_path = repo.join(goal_files[0]);
let content = std::fs::read_to_string(&goal_path).ok()?;
let mut depth = 0;
let mut json_end = 0;
for (i, c) in content.chars().enumerate() {
if c == '{' {
depth += 1;
} else if c == '}' {
depth -= 1;
}
if depth == 0 && i > 0 {
json_end = i + 1;
break;
}
}
if json_end == 0 {
return None;
}
let json_str = &content[..json_end];
let value: serde_json::Value = serde_json::from_str(json_str).ok()?;
let mut metadata = GoalMetadata {
status: value["status"].as_str().map(String::from),
pause_reason: value["pauseReason"].as_str().map(String::from),
..Default::default()
};
if let Some(usage) = value["usage"].as_object() {
metadata.tokens_used = usage["tokensUsed"].as_u64();
metadata.active_seconds = usage["activeSeconds"].as_u64();
}
if let Some(tasks) = value["taskList"]["tasks"].as_array() {
for task in tasks {
let detail = TaskDetail {
id: task["id"].as_str().unwrap_or("").to_string(),
status: task["status"].as_str().unwrap_or("").to_string(),
evidence: task["evidence"].as_str().map(String::from),
skip_reason: task["skipReason"].as_str().map(String::from),
};
metadata.task_details.push(detail);
}
}
Some(metadata)
}
fn detect_dependency_changes(repo: &Path) -> Option<String> {
let dep_files: &[(&str, &str)] = &[
("Cargo.toml", "toml"),
("package.json", "json"),
("requirements.txt", "txt"),
("go.mod", "gomod"),
];
let mut added_deps = Vec::new();
let mut removed_deps = Vec::new();
let mut any_changed = false;
for (file, format) in dep_files {
let output = match run_git_capture_output(
repo,
&["diff", "--cached", "--unified=0", "--", file],
&format!("dep-diff-{}", file),
) {
Ok(o) => o,
Err(_) => continue,
};
if output.trim().is_empty() {
continue;
}
any_changed = true;
for line in output.lines() {
if !line.starts_with('+') && !line.starts_with('-') {
continue;
}
if line.starts_with("+++") || line.starts_with("---") {
continue;
}
let is_add = line.starts_with('+');
let content = &line[1..].trim();
let dep_name = match *format {
"toml" => parse_cargo_dep(content),
"json" => parse_npm_dep(content),
"txt" => parse_pip_dep(content),
"gomod" => parse_go_dep(content),
_ => None,
};
if let Some(name) = dep_name {
if is_add {
added_deps.push(name);
} else {
removed_deps.push(name);
}
}
}
}
if !any_changed {
return None;
}
if added_deps.is_empty() && removed_deps.is_empty() {
return None;
}
let mut parts = Vec::new();
for dep in &added_deps {
parts.push(format!("+{}", dep));
}
for dep in &removed_deps {
parts.push(format!("-{}", dep));
}
if parts.is_empty() {
Some("changed".to_string())
} else {
let display: Vec<String> = parts.iter().take(5).cloned().collect();
let suffix = if parts.len() > 5 {
format!("+{}more", parts.len() - 5)
} else {
String::new()
};
Some(format!("{}{}", display.join(","), suffix))
}
}
fn parse_cargo_dep(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.starts_with('[') || trimmed.starts_with('#') || trimmed.is_empty() {
return None;
}
if let Some(eq_pos) = trimmed.find('=') {
let name = trimmed[..eq_pos].trim();
if !name.is_empty()
&& name
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
{
return Some(name.to_string());
}
}
None
}
fn parse_npm_dep(line: &str) -> Option<String> {
let trimmed = line.trim();
if let Some(colon_pos) = trimmed.find(':') {
let key_part = trimmed[..colon_pos].trim();
if key_part.starts_with('"') && key_part.ends_with('"') {
let name = &key_part[1..key_part.len() - 1];
if !name.is_empty() && !name.starts_with('_') && name != "name" && name != "version" {
return Some(name.to_string());
}
}
}
None
}
fn parse_pip_dep(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.is_empty() {
return None;
}
let name = trimmed
.split(&['=', '>', '<', '!', '~', ';', '['][..])
.next()
.unwrap_or("")
.trim();
if !name.is_empty() {
return Some(name.to_string());
}
None
}
fn parse_go_dep(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.is_empty() || trimmed == "require" || trimmed == ")" {
return None;
}
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if !parts.is_empty() && parts[0].contains('.') {
let module = parts[0];
let name = module.rsplit('/').next().unwrap_or(module);
return Some(name.to_string());
}
None
}
fn extract_new_deleted_files(repo: &Path) -> (Vec<String>, Vec<String>) {
let output =
match run_git_capture_output(repo, &["diff", "--cached", "--name-status"], "name-status") {
Ok(o) => o,
Err(_) => return (Vec::new(), Vec::new()),
};
let mut new_files = Vec::new();
let mut deleted_files = Vec::new();
for line in output.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 2 {
continue;
}
let status = parts[0];
let path = parts[1];
let should_track = !path.ends_with(".lock")
&& !path.ends_with(".sum")
&& !path.contains("/target/")
&& !path.contains("/node_modules/")
&& !path.contains("/__pycache__/");
if !should_track {
continue;
}
match status {
"A" => new_files.push(path.to_string()),
"D" => deleted_files.push(path.to_string()),
_ => {} }
}
(new_files, deleted_files)
}
fn is_test_file(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
let basename = path.rsplit('/').next().unwrap_or(path);
let basename_lower = basename.to_ascii_lowercase();
if lower.contains("/test") || lower.contains("/tests") || lower.contains("/__tests__") {
return true;
}
if basename_lower.contains("_test.")
|| basename_lower.contains("_tests.")
|| basename_lower.contains(".test.")
|| basename_lower.contains(".spec.")
{
return true;
}
if basename_lower.starts_with("test_") {
return true;
}
false
}
fn get_current_tag(repo: &Path) -> Option<String> {
let exact = run_git_capture_output(
repo,
&["describe", "--tags", "--always", "--exact-match"],
"tag-exact",
)
.ok()?;
let tag = exact.trim();
if !tag.is_empty() && !tag.contains('-') {
return Some(tag.to_string());
}
let all_tags =
run_git_capture_output(repo, &["tag", "--points-at", "HEAD"], "tag-points-at").ok()?;
for line in all_tags.lines() {
let tag = line.trim();
if !tag.is_empty() && !tag.contains('^') {
return Some(tag.to_string());
}
}
None
}
fn has_env_changes(repo: &Path) -> bool {
let env_patterns = [
".env", ".env.", ".envrc", ".secrets", "secrets.",
];
let output =
match run_git_capture_output(repo, &["diff", "--cached", "--name-only"], "env-check") {
Ok(o) => o,
Err(_) => return false,
};
for line in output.lines() {
let path = line.trim();
let basename = path.rsplit('/').next().unwrap_or(path);
for pattern in &env_patterns {
if basename.starts_with(pattern) || basename == *pattern {
return true;
}
}
}
false
}
fn compute_blast_radius(repo: &Path) -> String {
let output = match run_git_capture_output(repo, &["diff", "--cached", "--numstat"], "numstat") {
Ok(o) => o,
Err(_) => return "0 file(s) DELTA:+0/-0".to_string(),
};
let mut files = 0usize;
let mut added = 0i64;
let mut removed = 0i64;
let mut dirs: BTreeSet<String> = BTreeSet::new();
let mut file_changes: Vec<(i64, String)> = Vec::new();
let mut test_lines = 0i64;
let mut binary_count = 0usize;
for line in output.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 3 {
continue;
}
let a = parts[0];
let r = parts[1];
let path = parts[2];
if a == "-" || r == "-" {
binary_count += 1;
files += 1;
if let Some(first_component) = path.split('/').next() {
if !first_component.is_empty() && first_component != "." {
dirs.insert(first_component.to_string());
}
}
continue;
}
let a_val = a.parse::<i64>().unwrap_or(0);
let r_val = r.parse::<i64>().unwrap_or(0);
added += a_val;
removed += r_val;
files += 1;
if is_test_file(path) {
test_lines += a_val + r_val;
}
if path.contains('/') {
if let Some(first_component) = path.split('/').next() {
if !first_component.is_empty() && first_component != "." {
dirs.insert(first_component.to_string());
}
}
}
file_changes.push((a_val + r_val, path.to_string()));
}
file_changes.sort_by_key(|b| std::cmp::Reverse(b.0));
let top_files: Vec<String> = file_changes
.iter()
.take(3)
.map(|(_, path)| path.clone())
.collect();
let transitions = extract_task_transitions(repo);
let is_merge = repo.join(".git/MERGE_HEAD").exists();
let is_revert = repo.join(".git/REVERT_HEAD").exists();
let intent_prefix = if is_merge {
"MERGE: | ".to_string()
} else if is_revert {
"REVERT: | ".to_string()
} else {
const MAX_TASKS: usize = 10;
let mut parts = Vec::new();
if !transitions.closed.is_empty() {
let shown: Vec<&str> = transitions
.closed
.iter()
.take(MAX_TASKS)
.map(|s| s.as_str())
.collect();
let suffix = if transitions.closed.len() > MAX_TASKS {
format!(" +{}more", transitions.closed.len() - MAX_TASKS)
} else {
String::new()
};
parts.push(format!("CLOSED: {}{}", shown.join(", "), suffix));
}
if !transitions.progress.is_empty() {
let shown: Vec<&str> = transitions
.progress
.iter()
.take(MAX_TASKS)
.map(|s| s.as_str())
.collect();
let suffix = if transitions.progress.len() > MAX_TASKS {
format!(" +{}more", transitions.progress.len() - MAX_TASKS)
} else {
String::new()
};
parts.push(format!("WIP: {}{}", shown.join(", "), suffix));
}
if parts.is_empty() {
String::new()
} else {
format!("{} | ", parts.join(" | "))
}
};
let dirs_str = if dirs.is_empty() {
String::new()
} else {
format!(
" in {}",
dirs.iter().take(3).cloned().collect::<Vec<_>>().join(",")
)
};
let files_str = if top_files.is_empty() {
String::new()
} else {
format!(" [{}]", top_files.join(", "))
};
let mut metrics = Vec::new();
if test_lines > 0 {
metrics.push(format!("TEST:{}", test_lines));
}
if binary_count > 0 {
metrics.push(format!("BIN:{}", binary_count));
}
let (new_files, deleted_files) = extract_new_deleted_files(repo);
if !new_files.is_empty() {
let display: Vec<String> = new_files
.iter()
.take(10)
.map(|f| {
let parts: Vec<&str> = f.split('/').collect();
if parts.len() > 2 {
parts[parts.len() - 2..].join("/")
} else {
f.clone()
}
})
.collect();
let suffix = if new_files.len() > 10 {
format!("+{}more", new_files.len() - 10)
} else {
String::new()
};
metrics.push(format!("NEW:{}{}", display.join(","), suffix));
}
if !deleted_files.is_empty() {
let display: Vec<String> = deleted_files
.iter()
.take(10)
.map(|f| {
let parts: Vec<&str> = f.split('/').collect();
if parts.len() > 2 {
parts[parts.len() - 2..].join("/")
} else {
f.clone()
}
})
.collect();
let suffix = if deleted_files.len() > 10 {
format!("+{}more", deleted_files.len() - 10)
} else {
String::new()
};
metrics.push(format!("DEL:{}{}", display.join(","), suffix));
}
if let Some(dep_info) = detect_dependency_changes(repo) {
metrics.push(format!("DEPS:{}", dep_info));
}
if !is_merge && repo.join(".git/MERGE_HEAD").exists() {
metrics.push("MERGE:".to_string());
}
if !is_revert && repo.join(".git/REVERT_HEAD").exists() {
metrics.push("REVERT:".to_string());
}
if let Some(tag) = get_current_tag(repo) {
metrics.push(format!("TAG:{}", tag));
}
if !file_changes.is_empty() && file_changes.iter().all(|(_, path)| is_test_file(path)) {
let test_files: Vec<String> = file_changes
.iter()
.take(5)
.map(|(_, p)| {
let parts: Vec<&str> = p.split('/').collect();
if parts.len() > 2 {
parts[parts.len() - 2..].join("/")
} else {
p.clone()
}
})
.collect();
let suffix = if file_changes.len() > 5 {
format!("+{}more", file_changes.len() - 5)
} else {
String::new()
};
metrics.push(format!("TESTONLY:{}{}", test_files.join(","), suffix));
}
if has_env_changes(repo) {
metrics.push("ENV:".to_string());
}
if let Some(ref goal_meta) = transitions.goal_metadata {
if let Some(ref status) = goal_meta.status {
if status == "complete" {
metrics.push("GOAL:complete".to_string());
} else if status == "paused" {
metrics.push("GOAL:paused".to_string());
}
}
if let Some(ref reason) = goal_meta.pause_reason {
let short_reason = if reason.len() > 50 {
format!("{}...", &reason[..47])
} else {
reason.clone()
};
metrics.push(format!("PAUSE:{}", short_reason));
}
if let Some(tokens) = goal_meta.tokens_used {
if tokens > 100_000 {
metrics.push(format!("TOKENS:{}K", tokens / 1000));
}
}
if let Some(seconds) = goal_meta.active_seconds {
if seconds > 60 {
metrics.push(format!("TIME:{}m", seconds / 60));
}
}
let tasks_with_evidence: Vec<&TaskDetail> = goal_meta
.task_details
.iter()
.filter(|t| t.status == "complete" && t.evidence.is_some())
.collect();
if !tasks_with_evidence.is_empty() {
let evidence_summary: Vec<String> = tasks_with_evidence
.iter()
.take(3)
.map(|t| {
let ev = t.evidence.as_ref().expect("filter guarantees Some");
if ev.len() > 40 {
format!("{}:{}", t.id, &ev[..37])
} else {
format!("{}:{}", t.id, ev)
}
})
.collect();
let suffix = if tasks_with_evidence.len() > 3 {
format!("+{}more", tasks_with_evidence.len() - 3)
} else {
String::new()
};
metrics.push(format!("EVIDENCE:{}{}", evidence_summary.join("|"), suffix));
}
let skipped_tasks: Vec<&TaskDetail> = goal_meta
.task_details
.iter()
.filter(|t| t.status == "skipped" && t.skip_reason.is_some())
.collect();
if !skipped_tasks.is_empty() {
let skip_summary: Vec<String> = skipped_tasks
.iter()
.take(3)
.map(|t| {
let reason = t.skip_reason.as_ref().expect("filter guarantees Some");
if reason.len() > 40 {
format!("{}:{}", t.id, &reason[..37])
} else {
format!("{}:{}", t.id, reason)
}
})
.collect();
let suffix = if skipped_tasks.len() > 3 {
format!("+{}more", skipped_tasks.len() - 3)
} else {
String::new()
};
metrics.push(format!("SKIPPED:{}{}", skip_summary.join("|"), suffix));
}
}
let metrics_str = if metrics.is_empty() {
String::new()
} else {
format!(" | {}", metrics.join(" "))
};
format!(
"{}{} file(s){}{} DELTA:+{}/-{}{}",
intent_prefix, files, dirs_str, files_str, added, removed, metrics_str
)
}
async fn stage_commit_and_push(
svc: &GitService,
ctx: &mut SyncContext<'_>,
_status: &dracon_git::types::RepoStatus,
to_stage: &[dracon_git::types::DiffFile],
to_restore: &[dracon_git::types::DiffFile],
) -> Result<Option<SyncOutcome>> {
let repo = ctx.repo;
let policy = ctx.policy;
let dry_run = ctx.dry_run;
let has_origin = ctx.has_origin;
let _idle_seconds = ctx.idle_seconds;
let stage_paths: Vec<String> = to_stage
.iter()
.map(|e| e.path.to_string_lossy().to_string())
.collect();
let (existing, missing): (Vec<_>, Vec<_>) =
stage_paths.into_iter().partition(|p| repo.join(p).exists());
stage_existing_files(
repo,
&existing,
dry_run,
ctx.policy.stage_op_timeout_secs,
ctx.excluded_dir_names,
)
.await?;
if !missing.is_empty() {
git_rm_missing(repo, &missing, dry_run).await?;
}
let staged = git_name_status_entries(repo, &["diff", "--cached", "--name-status"]).await?;
let committed_entries: Vec<dracon_git::types::DiffFile> = staged
.into_iter()
.map(|(path, status)| dracon_git::types::DiffFile::new(path, status))
.collect();
let blast_radius = compute_blast_radius(repo);
let version_bumped = false;
if committed_entries.is_empty() {
if let Err(e) = run_git_with_timeout(repo, &["reset", "HEAD", "--"], 10, "reset").await {
eprintln!(
"⚠️ {} filter-only commit: git reset failed (non-fatal, will retry next cycle): {}",
repo.display(),
e
);
}
if debug_enabled() {
eprintln!(
"🐛 {} skipped commit: all changes were filter-only (smudge/clean)",
repo.display()
);
}
maybe_sync_visibility_and_metadata(ctx);
return Ok(Some(SyncOutcome::NothingToDo));
}
let msg = blast_radius;
if dry_run {
println!(
"📝 Would commit {} file(s) in {}:",
committed_entries.len(),
repo.display()
);
for entry in committed_entries.iter().take(10) {
println!(" {:?}: {}", entry.status, entry.path.display());
}
if committed_entries.len() > 10 {
println!(" ... and {} more", committed_entries.len() - 10);
}
println!(" message: {}", msg.lines().next().unwrap_or("(empty)"));
} else {
svc.commit(&msg).await?;
eprintln!(
"📝 committed {} file(s) in {}",
committed_entries.len(),
repo.display()
);
let _ = std::io::stderr().flush();
if let Some(pp) = ctx.policy_path {
crate::report::log_incident(
pp,
"sync",
repo.display().to_string(),
format!("COMMITTED:{} files", committed_entries.len()),
"sync_commit",
None,
"ok",
Some(msg.lines().next().unwrap_or("").to_string()),
);
}
}
prune_other_default_branch(repo).await;
post_commit_pull(svc, repo, policy).await;
let alert_status = svc.get_status().await?;
if alert_status.ahead > policy.alert_unpushed_threshold {
eprintln!(
"🚨 ALERT: {} has {} unpushed commits (threshold: {}). Something may be wrong with push.",
repo.display(),
alert_status.ahead,
policy.alert_unpushed_threshold
);
}
restore_excluded_paths(ctx, to_restore).await?;
if policy.auto_push && has_origin {
match push_background(repo, policy, ctx.remote_failures.as_deref_mut()).await {
Ok(true) => {
crate::daemon::record_push_success(repo);
}
Ok(false) => {
eprintln!("⚠️ push failed for {}", repo.display());
crate::daemon::record_push_failure(
repo,
&format!("git push returned non-zero (see daemon log)"),
);
}
Err(e) => {
eprintln!("⚠️ push error for {}: {}", repo.display(), e);
crate::daemon::record_push_failure(repo, &e.to_string());
}
}
}
run_release_pipeline_if_bumped(repo, policy, version_bumped).await;
Ok(None)
}
pub(crate) async fn sync_repo(
repo: &Path,
policy: &SyncPolicy,
excluded_dir_names: &BTreeSet<String>,
idle_seconds: u64,
remote_failures: Option<&mut HashMap<String, usize>>,
dry_run: bool,
policy_path: Option<&Path>,
) -> Result<SyncOutcome> {
sync_repo_with_ahead_since(repo, policy, excluded_dir_names, idle_seconds, remote_failures, dry_run, policy_path, None).await
}
pub(crate) async fn sync_repo_with_ahead_since(
repo: &Path,
policy: &SyncPolicy,
excluded_dir_names: &BTreeSet<String>,
idle_seconds: u64,
remote_failures: Option<&mut HashMap<String, usize>>,
dry_run: bool,
policy_path: Option<&Path>,
ahead_since: Option<std::time::Instant>,
) -> Result<SyncOutcome> {
let svc = GitService::new(repo)?;
if !svc.is_git_repo().await? {
if debug_enabled() {
eprintln!("🐛 {} is not recognized as git repo", repo.display());
}
let ctx = SyncContext {
repo,
policy,
excluded_dir_names,
dry_run,
idle_seconds,
policy_path,
has_origin: false,
has_upstream: false,
auto_bump_versions: false,
remote_failures: None,
backstop_active: false,
};
maybe_sync_visibility_and_metadata(&ctx);
return Ok(SyncOutcome::NothingToDo);
}
if let Some(blocked) = check_conflict_state(repo) {
let ctx = SyncContext {
repo,
policy,
excluded_dir_names,
dry_run,
idle_seconds,
policy_path,
has_origin: false,
has_upstream: false,
auto_bump_versions: false,
remote_failures: None,
backstop_active: false,
};
maybe_sync_visibility_and_metadata(&ctx);
return Ok(blocked);
}
if !is_repo_ready(repo) {
if !dry_run {
let git_bin = crate::policy::git_binary();
let has_files = std::fs::read_dir(repo)
.map(|mut dirs| dirs.any(|e| e.ok().is_some_and(|e| e.file_name() != ".git")))
.unwrap_or(false);
if has_files {
let _ = std::process::Command::new(&git_bin)
.args(["add", "-A"])
.current_dir(repo)
.output();
let _ = std::process::Command::new(&git_bin)
.args(["commit", "--no-verify", "-m", "initial"])
.current_dir(repo)
.output();
eprintln!("📝 {} created initial commit (empty repo)", repo.display());
} else {
eprintln!(
"⏳ {} not ready (mid-clone or empty repo), skipping",
repo.display()
);
return Ok(SyncOutcome::NothingToDo);
}
} else {
return Ok(SyncOutcome::NothingToDo);
}
}
let has_origin = ensure_origin_remote(repo, policy);
let has_upstream = has_tracking_upstream(repo);
let initial_status = svc.get_status().await?;
let repo_override = load_repo_override(repo);
let auto_bump_versions = repo_override
.auto_bump_versions
.unwrap_or(policy.auto_bump_versions);
let backstop_active = is_backstop_active(
ahead_since,
std::time::Instant::now(),
initial_status.ahead,
policy.auto_commit_backstop_threshold,
policy.auto_commit_backstop_min_age_secs,
);
if backstop_active {
eprintln!(
"⏸️ daemon backstop: {} unpushed commits pending push >{}s, skipping auto-commit for {}",
initial_status.ahead,
policy.auto_commit_backstop_min_age_secs,
repo.display(),
);
}
let mut ctx = SyncContext {
repo,
policy,
excluded_dir_names,
dry_run,
idle_seconds,
policy_path,
has_origin,
has_upstream,
auto_bump_versions,
remote_failures,
backstop_active,
};
let copied_standard_files = if policy.standard_files_auto {
match crate::git::IndexLock::acquire(repo) {
Ok(_lock) => crate::standard_files::ensure_standard_files(
repo,
policy,
&repo_override,
policy_path.map(|p| p.parent().unwrap_or(p)),
dry_run,
)?,
Err(e) => {
if crate::policy::debug_enabled() {
eprintln!("⏳ {}", e);
}
vec![] }
}
} else {
vec![]
};
if !copied_standard_files.is_empty() && !dry_run {
let paths: Vec<String> = copied_standard_files
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
stage_existing_files(
repo,
&paths,
dry_run,
ctx.policy.stage_op_timeout_secs,
ctx.excluded_dir_names,
)
.await?;
}
auto_pull_merge(&svc, &ctx, &initial_status).await?;
clean_staged_paths(&ctx).await?;
let DiffResult {
status,
entries,
filter_only_cleared,
} = compute_diff_entries(&svc, repo).await?;
if filter_only_cleared {
if debug_enabled() {
eprintln!(
"🐛 {} filter-only dirty, returning NothingToDo for cooldown",
repo.display(),
);
}
return Ok(SyncOutcome::NothingToDo);
}
if !status.is_clean && policy.auto_commit {
if ctx.backstop_active {
return Ok(SyncOutcome::NothingToDo);
}
let tracked_paths: std::collections::HashSet<std::path::PathBuf> =
if policy.auto_stage_untracked {
std::collections::HashSet::new()
} else {
crate::git::tracked_paths(repo).await.unwrap_or_default()
};
let (to_stage, to_restore): (Vec<_>, Vec<_>) = entries
.into_iter()
.filter(|e| {
if repo.join(&e.path).is_dir()
&& crate::exclude::is_gitlink_unchanged(repo, &e.path)
{
return false;
}
if matches!(e.status, dracon_git::types::FileStatus::Added)
&& !tracked_paths.contains(&e.path)
{
if !policy.auto_stage_untracked {
if debug_enabled() {
eprintln!(
"⏭️ {} skipping untracked {} (auto_stage_untracked = false)",
repo.display(),
e.path.display()
);
}
return false;
}
if crate::exclude::matches_untracked_exclude(
repo,
&e.path,
&policy.untracked_exclude_patterns,
) {
if debug_enabled() {
eprintln!(
"⏭️ {} skipping untracked {} (untracked_exclude_patterns)",
repo.display(),
e.path.display()
);
}
return false;
}
}
true
})
.partition(|e| {
should_stage_entry(
repo,
e,
excluded_dir_names,
&policy.exclude_file_patterns,
policy.max_stage_file_bytes,
repo_override
.auto_commit_exclude_patterns
.as_deref()
.unwrap_or(&policy.auto_commit_exclude_patterns),
)
});
if debug_enabled() {
eprintln!(
"🐛 {} to_stage={} to_restore={}",
repo.display(),
to_stage.len(),
to_restore.len()
);
}
if !to_stage.is_empty() {
if let Some(outcome) =
stage_commit_and_push(&svc, &mut ctx, &status, &to_stage, &to_restore).await?
{
return Ok(outcome);
}
} else if policy.auto_push && !has_origin {
eprintln!("ℹ️ skip push for {} (no origin remote)", repo.display());
}
return Ok(SyncOutcome::Synced);
}
maybe_sync_visibility_and_metadata(&ctx);
handle_ahead_push(&mut ctx, &svc).await?;
maybe_sync_visibility_and_metadata(&ctx);
Ok(SyncOutcome::NothingToDo)
}
async fn handle_ahead_push(ctx: &mut SyncContext<'_>, svc: &GitService) -> Result<()> {
let current_status = svc.get_status().await?;
let branch_has_upstream = super::git::has_tracking_upstream(ctx.repo);
let should_push = current_status.ahead > 0 || !branch_has_upstream;
if ctx.policy.auto_push && should_push && ctx.has_origin {
match push_background(ctx.repo, ctx.policy, ctx.remote_failures.as_deref_mut()).await {
Ok(true) => {
crate::daemon::record_push_success(ctx.repo);
}
Ok(false) => {
eprintln!("⚠️ push failed for {}", ctx.repo.display());
crate::daemon::record_push_failure(
ctx.repo,
&format!("git push returned non-zero (see daemon log)"),
);
}
Err(e) => {
eprintln!("⚠️ push error for {}: {}", ctx.repo.display(), e);
crate::daemon::record_push_failure(ctx.repo, &e.to_string());
}
}
} else if ctx.policy.auto_push && should_push && !ctx.has_origin {
eprintln!("ℹ️ skip push for {} (no origin remote)", ctx.repo.display());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_task_name_drops_explanatory_clauses() {
assert_eq!(
sanitize_task_name("Fix A: Added .dracon/ and .pub to NOISE_PATTERNS in bump.rs"),
"Fix A"
);
assert_eq!(
sanitize_task_name("Stale focus detection — details were noisy"),
"Stale focus detection"
);
assert_eq!(
sanitize_task_name("merge: resolve conflicts from parallel sessions"),
"merge"
);
}
#[test]
fn test_sanitize_task_name_limits_plain_text() {
assert_eq!(
sanitize_task_name("Added .dracon/ and .pub to NOISE_PATTERNS in bump.rs"),
"Added .dracon/ and"
);
}
#[test]
fn test_compute_blast_radius_merge_prefix() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
crate::git::git_cmd()
.args(["init", "-q", "-b", "main"])
.arg(repo)
.status()
.unwrap();
std::fs::write(repo.join("file.txt"), "change").unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "add", "file.txt"])
.status()
.unwrap();
std::fs::create_dir(repo.join(".git")).ok();
std::fs::write(repo.join(".git/MERGE_HEAD"), "abc123").unwrap();
let msg = compute_blast_radius(repo);
assert!(msg.starts_with("MERGE: | "));
}
#[test]
fn test_truncate_task_multibyte_utf8() {
let input = "T-001 Set Tauri CSP — Replaced \"csp\": null with strict policy including wasm-unsafe-eval";
let result = truncate_task(input);
assert!(result.len() <= 64); assert!(!result.ends_with('\u{200B}')); assert!(result.contains('—') || result.ends_with("..."));
}
#[test]
fn test_truncate_task_short() {
let input = "short task";
assert_eq!(truncate_task(input), "short task");
}
#[test]
fn test_truncate_task_at_sentence_boundary() {
let input = "Fix the bug. Now add tests for the fix and update documentation.";
let result = truncate_task(input);
assert!(result.ends_with('.'));
assert!(result.len() <= 64);
}
#[test]
fn test_truncate_task_hard_cutoff() {
let input = "This is a very long task name that has no sentence boundaries and just keeps going and going and going";
let result = truncate_task(input);
assert!(result.ends_with("..."));
assert!(result.len() <= 64);
}
#[tokio::test]
async fn test_sync_repo_auto_github_private_graceful_on_no_gh() {
let state_dir = tempfile::tempdir().unwrap();
let _state_guard = crate::test_helpers::EnvRestorer::new(
"DRACON_SYNC_STATE_DIR",
state_dir.path().to_string_lossy().as_ref(),
);
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
let toml_str = r#"
auto_github_private = true
auto_github_private_account = "TestAccount"
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(
result.is_ok(),
"sync_repo should handle missing gh gracefully: {:?}",
result
);
}
#[tokio::test]
async fn test_sync_repo_auto_commit_creates_commit() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
let file_path = repo.join("test.txt");
std::fs::write(&file_path, "hello world").unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "add", "test.txt"])
.status()
.unwrap();
let commits_before = crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "rev-list", "--count", "HEAD"])
.output()
.unwrap()
.stdout;
let count_before: usize = String::from_utf8_lossy(&commits_before)
.trim()
.parse()
.unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed: {:?}", result);
let commits_after = crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "rev-list", "--count", "HEAD"])
.output()
.unwrap()
.stdout;
let count_after: usize = String::from_utf8_lossy(&commits_after)
.trim()
.parse()
.unwrap();
assert_eq!(
count_after,
count_before + 1,
"sync_repo should have created one new commit (before={}, after={})",
count_before,
count_after
);
}
#[tokio::test]
async fn test_sync_repo_skips_rebase_in_progress() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::create_dir_all(repo.join(".git/rebase-merge")).unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(
result.is_ok(),
"sync_repo should succeed even during rebase"
);
assert!(
matches!(result, Ok(SyncOutcome::Blocked)),
"rebase should cause early return (nothing synced)"
);
}
#[tokio::test]
async fn test_sync_repo_skips_merge_in_progress() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::write(repo.join(".git/MERGE_HEAD"), "abc123\n").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed even during merge");
assert!(
matches!(result, Ok(SyncOutcome::Blocked)),
"merge should cause early return (nothing synced)"
);
}
#[tokio::test]
async fn test_sync_repo_skips_cherry_pick_in_progress() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::write(repo.join(".git/CHERRY_PICK_HEAD"), "abc123\n").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(
result.is_ok(),
"sync_repo should succeed even during cherry-pick"
);
assert!(
matches!(result, Ok(SyncOutcome::Blocked)),
"cherry-pick should cause early return (nothing synced)"
);
}
#[tokio::test]
async fn test_sync_repo_auto_commit_creates_commit_for_dirty_repo() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::write(repo.join("dirty.txt"), "modified content\n").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed: {:?}", result);
assert!(
matches!(result, Ok(SyncOutcome::Synced)),
"dirty repo with auto_commit should sync"
);
let output = crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "log", "--oneline"])
.output()
.unwrap();
let log = String::from_utf8_lossy(&output.stdout);
assert!(
log.lines().count() >= 2,
"should have at least 2 commits (init + auto-commit)"
);
}
#[tokio::test]
async fn test_sync_repo_clean_repo_returns_false() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed");
assert!(
matches!(result, Ok(SyncOutcome::NothingToDo)),
"clean repo should return false (nothing to sync)"
);
}
#[tokio::test]
async fn test_sync_repo_stages_and_commits_untracked_file() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::write(repo.join("newfile.txt"), "new content\n").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed: {:?}", result);
assert!(
matches!(result, Ok(SyncOutcome::Synced)),
"untracked file should be staged and committed"
);
let output = crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "ls-files"])
.output()
.unwrap();
let tracked = String::from_utf8_lossy(&output.stdout);
assert!(
tracked.contains("newfile.txt"),
"newfile.txt should be tracked"
);
}
#[tokio::test]
async fn test_sync_repo_auto_stage_untracked_false_skips_untracked() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::write(repo.join("scratch.md"), "scratch content\n").unwrap();
std::fs::write(repo.join("normal.txt"), "normal content\n").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
auto_stage_untracked = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed: {:?}", result);
let output = crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "ls-files"])
.output()
.unwrap();
let tracked = String::from_utf8_lossy(&output.stdout);
assert!(
!tracked.contains("scratch.md"),
"scratch.md should NOT be tracked when auto_stage_untracked=false"
);
}
#[tokio::test]
async fn test_sync_repo_untracked_exclude_patterns_keeps_scratch_untracked() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::write(repo.join("real-doc.md"), "real doc\n").unwrap();
std::fs::create_dir_all(repo.join("scratch")).unwrap();
std::fs::write(repo.join("scratch").join("notes.md"), "scratch notes\n").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
auto_stage_untracked = true
untracked_exclude_patterns = ["**/scratch/**"]
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed: {:?}", result);
let output = crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "ls-files"])
.output()
.unwrap();
let tracked = String::from_utf8_lossy(&output.stdout);
assert!(
tracked.contains("real-doc.md"),
"real-doc.md should be tracked"
);
assert!(
!tracked.contains("scratch/notes.md"),
"scratch/notes.md should NOT be tracked (untracked_exclude_patterns)"
);
}
#[tokio::test]
async fn test_sync_repo_skip_pull_when_not_behind() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = false
auto_pull = true
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed");
assert!(
matches!(result, Ok(SyncOutcome::NothingToDo)),
"not behind should return false (nothing to pull)"
);
}
#[tokio::test]
async fn test_sync_repo_skip_pull_when_dirty() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::write(repo.join("dirty.txt"), "modified\n").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = false
auto_pull = true
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed with dirty repo");
assert!(
matches!(result, Ok(SyncOutcome::NothingToDo)),
"dirty repo should skip pull and return false"
);
}
#[tokio::test]
async fn test_sync_repo_skip_push_when_no_origin() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = false
auto_pull = false
auto_push = true
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed without origin");
}
#[tokio::test]
async fn test_sync_repo_skip_push_when_no_upstream() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = false
auto_pull = false
auto_push = true
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed without upstream");
}
#[tokio::test]
async fn test_sync_repo_mirror_push_failure_returns_false() {
let state_dir = tempfile::tempdir().unwrap();
let _state_guard = crate::test_helpers::EnvRestorer::new(
"DRACON_SYNC_STATE_DIR",
state_dir.path().to_string_lossy().as_ref(),
);
let tmp = tempfile::tempdir().unwrap();
let origin_bare = tmp.path().join("origin.git");
crate::git::git_cmd()
.args(["init", "--bare", "-q", "-b", "master"])
.arg(&origin_bare)
.status()
.unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"remote",
"add",
"origin",
&origin_bare.to_string_lossy(),
])
.status()
.unwrap();
let bad_mirror = tmp.path().join("nonexistent-mirror.git");
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"remote",
"add",
"mirror",
&bad_mirror.to_string_lossy(),
])
.status()
.unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = false
auto_pull = false
auto_push = true
auto_bump_versions = false
[[remotes]]
name = "mirror"
push_url = "git@nonexistent.example.com:repo.git"
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should not error");
assert!(
matches!(result, Ok(SyncOutcome::NothingToDo)),
"mirror push failure should return false (hard fail)"
);
}
#[tokio::test]
async fn test_sync_repo_mirror_failure_tracks_remote_failures() {
let state_dir = tempfile::tempdir().unwrap();
let _state_guard = crate::test_helpers::EnvRestorer::new(
"DRACON_SYNC_STATE_DIR",
state_dir.path().to_string_lossy().as_ref(),
);
let tmp = tempfile::tempdir().unwrap();
let origin_bare = tmp.path().join("origin.git");
crate::git::git_cmd()
.args(["init", "--bare", "-q", "-b", "master"])
.arg(&origin_bare)
.status()
.unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"remote",
"add",
"origin",
&origin_bare.to_string_lossy(),
])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"push",
"-u",
"origin",
"master",
])
.status()
.unwrap();
std::fs::write(repo.join("change.txt"), "changed\n").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = true
auto_bump_versions = false
[[remotes]]
name = "bad-mirror"
push_url = "git@nonexistent.example.com:repo.git"
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let mut remote_failures = HashMap::new();
let result = sync_repo(
&repo,
&policy,
&BTreeSet::new(),
0,
Some(&mut remote_failures),
false,
None,
)
.await;
assert!(result.is_ok());
assert!(
matches!(result.unwrap(), SyncOutcome::Synced),
"mirror push failure should still return Synced (origin push succeeded)"
);
assert_eq!(
remote_failures.get("bad-mirror"),
Some(&1),
"bad-mirror failure should be tracked"
);
}
#[tokio::test]
async fn test_sync_repo_mirror_push_success_returns_true() {
let state_dir = tempfile::tempdir().unwrap();
let _state_guard = crate::test_helpers::EnvRestorer::new(
"DRACON_SYNC_STATE_DIR",
state_dir.path().to_string_lossy().as_ref(),
);
let tmp = tempfile::tempdir().unwrap();
let origin_bare = tmp.path().join("origin.git");
let mirror_bare = tmp.path().join("mirror.git");
crate::git::git_cmd()
.args(["init", "--bare", "-q", "-b", "master"])
.arg(&origin_bare)
.status()
.unwrap();
crate::git::git_cmd()
.args(["init", "--bare", "-q", "-b", "master"])
.arg(&mirror_bare)
.status()
.unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"remote",
"add",
"origin",
&origin_bare.to_string_lossy(),
])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"push",
"-u",
"origin",
"master",
])
.status()
.unwrap();
std::fs::write(repo.join("change.txt"), "changed\n").unwrap();
let toml_str = format!(
r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = true
auto_bump_versions = false
[[remotes]]
name = "mirror"
push_url = "{}"
"#,
mirror_bare.to_string_lossy().replace("\\", "/")
);
let policy: SyncPolicy = toml::from_str(&toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should not error: {:?}", result);
assert!(
matches!(result, Ok(SyncOutcome::Synced)),
"mirror push success should return true"
);
}
fn init_test_repo(tmp: &tempfile::TempDir, name: &str) -> std::path::PathBuf {
let repo = tmp.path().join(name);
crate::git::git_cmd()
.args(["init", "-q", "-b", "master"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
repo
}
fn git_cmd(repo: &Path, args: &[&str]) -> std::process::Output {
let repo_str = repo.to_string_lossy().to_string();
let mut cmd = crate::git::git_cmd();
cmd.arg("-C").arg(&repo_str);
for a in args {
cmd.arg(a);
}
cmd.output().unwrap()
}
#[tokio::test]
async fn test_sync_repo_not_git_repo_returns_false() {
let tmp = tempfile::tempdir().unwrap();
let not_repo = tmp.path().join("not-a-repo");
std::fs::create_dir_all(¬_repo).unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(¬_repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should not error on non-git dir");
assert!(
matches!(result, Ok(SyncOutcome::NothingToDo)),
"non-git dir should return false"
);
}
#[tokio::test]
async fn test_sync_repo_single_deleted_file_committed() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "single-del-repo");
std::fs::write(repo.join("keep.txt"), "keep\n").unwrap();
std::fs::write(repo.join("remove.txt"), "remove\n").unwrap();
git_cmd(&repo, &["add", "-A"]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "add files"]);
std::fs::remove_file(repo.join("remove.txt")).unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed");
assert!(
matches!(result, Ok(SyncOutcome::Synced)),
"single deletion should be committed"
);
let output = git_cmd(&repo, &["ls-files"]);
let tracked = String::from_utf8_lossy(&output.stdout);
assert!(
tracked.contains("keep.txt"),
"keep.txt should still be tracked"
);
assert!(
!tracked.contains("remove.txt"),
"remove.txt should be removed from index"
);
}
#[tokio::test]
async fn test_sync_repo_partial_deletion_allowed() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "partial-del-repo");
std::fs::write(repo.join("a.txt"), "a\n").unwrap();
std::fs::write(repo.join("b.txt"), "b\n").unwrap();
std::fs::write(repo.join("c.txt"), "c\n").unwrap();
git_cmd(&repo, &["add", "-A"]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "add files"]);
std::fs::remove_file(repo.join("a.txt")).unwrap();
std::fs::remove_file(repo.join("b.txt")).unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed");
assert!(
matches!(result, Ok(SyncOutcome::Synced)),
"partial deletion should be committed (not blocked)"
);
let output = git_cmd(&repo, &["ls-files"]);
let tracked = String::from_utf8_lossy(&output.stdout);
assert!(
!tracked.contains("a.txt"),
"a.txt should be removed after partial deletion commit"
);
assert!(
!tracked.contains("b.txt"),
"b.txt should be removed after partial deletion commit"
);
assert!(tracked.contains("c.txt"), "c.txt should still be tracked");
}
#[tokio::test]
async fn test_sync_repo_exactly_50_percent_deletion_allowed() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "exact-50-del-repo");
std::fs::write(repo.join("a.txt"), "a\n").unwrap();
std::fs::write(repo.join("b.txt"), "b\n").unwrap();
git_cmd(&repo, &["add", "-A"]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "add files"]);
std::fs::remove_file(repo.join("a.txt")).unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed");
assert!(
matches!(result, Ok(SyncOutcome::Synced)),
"exactly 50% deletion should be committed (not blocked)"
);
let output = git_cmd(&repo, &["ls-files"]);
let tracked = String::from_utf8_lossy(&output.stdout);
assert!(
!tracked.contains("a.txt"),
"a.txt should be removed after 50% deletion commit"
);
assert!(tracked.contains("b.txt"), "b.txt should still be tracked");
}
#[tokio::test]
async fn test_sync_repo_empty_repo_no_panic() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "empty-repo");
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should not panic on empty repo");
}
#[tokio::test]
async fn test_sync_repo_unstages_excluded_dir_paths() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "exclude-dir-repo");
std::fs::create_dir_all(repo.join("node_modules/pkg")).unwrap();
std::fs::write(
repo.join("node_modules/pkg/index.js"),
"module.exports = {};\n",
)
.unwrap();
std::fs::create_dir_all(repo.join("src")).unwrap();
std::fs::write(repo.join("src/main.rs"), "fn main() {}\n").unwrap();
git_cmd(&repo, &["add", "-A"]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "initial"]);
std::fs::write(repo.join("node_modules/pkg/index.js"), "updated\n").unwrap();
std::fs::write(
repo.join("src/main.rs"),
"fn main() { println!(\"hello\"); }\n",
)
.unwrap();
git_cmd(&repo, &["add", "-A"]);
let mut excluded = BTreeSet::new();
excluded.insert("node_modules".to_string());
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &excluded, 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed");
let output = git_cmd(&repo, &["log", "--oneline", "-1"]);
let last_commit = String::from_utf8_lossy(&output.stdout);
assert!(
!last_commit.is_empty(),
"should have committed the non-excluded change"
);
}
#[tokio::test]
async fn test_sync_repo_unstages_oversized_file() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "oversized-repo");
std::fs::write(repo.join("small.txt"), "small content\n").unwrap();
git_cmd(&repo, &["add", "-A"]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "initial"]);
let big_content = vec![b'X'; 1024];
std::fs::write(repo.join("bigfile.bin"), &big_content).unwrap();
std::fs::write(repo.join("small2.txt"), "another small\n").unwrap();
git_cmd(&repo, &["add", "-A"]);
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
max_stage_file_bytes = 512
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(
result.is_ok(),
"sync_repo should succeed with oversized file"
);
let output = git_cmd(&repo, &["ls-files"]);
let tracked = String::from_utf8_lossy(&output.stdout);
assert!(
tracked.contains("small2.txt"),
"small file should be tracked"
);
}
#[tokio::test]
async fn test_sync_repo_mixed_tracked_and_untracked() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "mixed-repo");
std::fs::write(repo.join("existing.txt"), "original\n").unwrap();
git_cmd(&repo, &["add", "-A"]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "initial"]);
std::fs::write(repo.join("existing.txt"), "modified\n").unwrap();
std::fs::write(repo.join("brand_new.txt"), "new file\n").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed");
assert!(
matches!(result, Ok(SyncOutcome::Synced)),
"mixed changes should be committed"
);
let output = git_cmd(&repo, &["ls-files"]);
let tracked = String::from_utf8_lossy(&output.stdout);
assert!(
tracked.contains("existing.txt"),
"existing.txt should be tracked"
);
assert!(
tracked.contains("brand_new.txt"),
"brand_new.txt should be tracked"
);
let show = git_cmd(&repo, &["show", "HEAD:existing.txt"]);
assert_eq!(String::from_utf8_lossy(&show.stdout), "modified\n");
}
#[tokio::test]
async fn test_sync_repo_pull_skip_when_no_origin() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "no-origin-pull-repo");
let toml_str = r#"
auto_github_private = false
auto_commit = false
auto_pull = true
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed without origin");
assert!(
matches!(result, Ok(SyncOutcome::NothingToDo)),
"no origin should skip pull and return false"
);
}
#[tokio::test]
async fn test_sync_repo_auto_commit_disabled_skips_commit() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "no-autocommit-repo");
std::fs::write(repo.join("dirty.txt"), "dirty content\n").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = false
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed");
assert!(
matches!(result, Ok(SyncOutcome::NothingToDo)),
"auto_commit=false should not commit dirty files"
);
let output = git_cmd(&repo, &["status", "--porcelain"]);
let status = String::from_utf8_lossy(&output.stdout);
assert!(
status.contains("dirty.txt"),
"file should still be untracked/unstaged"
);
}
#[tokio::test]
async fn test_sync_repo_dry_run_does_not_commit() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "dry-run-test");
std::fs::write(repo.join("new_file.txt"), "new content\n").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let commits_before = git_cmd(&repo, &["rev-list", "--count", "HEAD"]);
let commits_count_before: usize = String::from_utf8_lossy(&commits_before.stdout)
.trim()
.parse()
.unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, true, None).await;
assert!(result.is_ok(), "dry-run should succeed");
let commits_after = git_cmd(&repo, &["rev-list", "--count", "HEAD"]);
let commits_count_after: usize = String::from_utf8_lossy(&commits_after.stdout)
.trim()
.parse()
.unwrap();
assert_eq!(
commits_count_before, commits_count_after,
"dry-run should not create any commits"
);
let status = git_cmd(&repo, &["status", "--porcelain"]);
let status_output = String::from_utf8_lossy(&status.stdout);
assert!(
status_output.contains("new_file.txt"),
"file should still appear as untracked in working tree"
);
}
#[tokio::test]
async fn test_sync_repo_dry_run_does_not_push() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "dry-run-push-test");
std::fs::write(repo.join("file.txt"), "change\n").unwrap();
git_cmd(&repo, &["add", "."]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "add file"]);
let commits_before = git_cmd(&repo, &["rev-list", "--count", "HEAD"]);
let count_before: usize = String::from_utf8_lossy(&commits_before.stdout)
.trim()
.parse()
.unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = false
auto_pull = false
auto_push = true
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, true, None).await;
assert!(result.is_ok(), "dry-run should succeed");
let commits_after = git_cmd(&repo, &["rev-list", "--count", "HEAD"]);
let count_after: usize = String::from_utf8_lossy(&commits_after.stdout)
.trim()
.parse()
.unwrap();
assert_eq!(
count_before, count_after,
"dry-run should not change commit count"
);
}
#[tokio::test]
async fn test_sync_repo_dry_run_does_not_modify_working_tree() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "dry-run-wt-test");
std::fs::write(repo.join("tracked.txt"), "tracked\n").unwrap();
git_cmd(&repo, &["add", "tracked.txt"]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "add tracked"]);
std::fs::write(repo.join("modified.txt"), "modified\n").unwrap();
std::fs::write(repo.join("untracked.txt"), "untracked\n").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, true, None).await;
assert!(result.is_ok(), "dry-run should succeed");
let output = git_cmd(&repo, &["status", "--porcelain"]);
let status = String::from_utf8_lossy(&output.stdout);
assert!(
status.contains("modified.txt"),
"modified.txt should still be modified"
);
assert!(
status.contains("untracked.txt"),
"untracked.txt should still be untracked"
);
}
#[tokio::test]
async fn test_alert_unpushed_threshold() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "alert-threshold-repo");
for i in 0..3 {
let fname = format!("file{}.txt", i);
std::fs::write(repo.join(&fname), format!("content{}\n", i)).unwrap();
git_cmd(&repo, &["add", &fname]);
git_cmd(
&repo,
&["commit", "--no-verify", "-m", &format!("add {}", fname)],
);
}
let toml_str = r#"
auto_github_private = false
auto_commit = false
auto_pull = false
auto_push = false
auto_bump_versions = false
alert_unpushed_threshold = 2
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed");
}
#[tokio::test]
async fn test_alert_unpushed_threshold_not_triggered() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "alert-threshold-ok-repo");
std::fs::write(repo.join("file.txt"), "content\n").unwrap();
git_cmd(&repo, &["add", "file.txt"]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "add file"]);
let toml_str = r#"
auto_github_private = false
auto_commit = false
auto_pull = false
auto_push = false
auto_bump_versions = false
alert_unpushed_threshold = 5
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(result.is_ok(), "sync_repo should succeed");
}
#[tokio::test]
async fn test_deletions_committed_when_intentional() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "deletion-commit-repo");
for i in 0..7 {
std::fs::write(repo.join(format!("file{i}.txt")), format!("content{i}")).unwrap();
}
git_cmd(&repo, &["add", "."]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "init"]);
std::fs::write(repo.join("file0.txt"), "modified").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
for i in 1..7 {
std::fs::remove_file(repo.join(format!("file{i}.txt"))).unwrap();
}
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(
matches!(result, Ok(SyncOutcome::Synced)),
"deletions should be committed, not blocked"
);
let ls = git_cmd(&repo, &["ls-files"]);
let ls_str = String::from_utf8_lossy(&ls.stdout);
assert!(
ls_str.trim() == "file0.txt",
"only file0 should remain, got: {:?}",
ls_str
);
}
#[tokio::test]
async fn test_filter_only_skips_cli_diff_fallback() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "filter-only-repo");
std::fs::write(repo.join("secret.txt"), "plaintext").unwrap();
git_cmd(&repo, &["add", "."]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "init"]);
std::fs::write(repo.join("secret.txt"), "plaintext\r\n").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = false
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(
matches!(
result,
Ok(SyncOutcome::NothingToDo) | Ok(SyncOutcome::Synced)
),
"filter-only repo should produce NothingToDo or Synced without changes, got {:?}",
result
);
let staged = git_cmd(&repo, &["diff", "--cached", "--name-only"]);
let staged_str = String::from_utf8_lossy(&staged.stdout);
assert!(
staged_str.trim().is_empty(),
"nothing should be staged for filter-only repo"
);
}
#[tokio::test]
async fn test_filter_only_reset_failure_is_non_fatal() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "filter-only-reset-fail-repo");
std::fs::write(repo.join(".gitignore"), "*.log\n").unwrap();
git_cmd(&repo, &["add", ".gitignore"]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "add gitignore"]);
std::fs::write(repo.join("debug.log"), "v1").unwrap();
git_cmd(&repo, &["add", "-f", "debug.log"]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "add debug.log"]);
std::fs::write(repo.join("debug.log"), "v2").unwrap();
std::fs::write(repo.join(".git").join("index.lock"), "concurrent").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(
matches!(
result,
Ok(SyncOutcome::NothingToDo) | Ok(SyncOutcome::Synced)
),
"filter-only reset failure should be non-fatal, got {:?}",
result
);
let _ = std::fs::remove_file(repo.join(".git").join("index.lock"));
}
#[tokio::test]
async fn test_sync_repo_with_duplicate_subjects_succeeds() {
let tmp = tempfile::tempdir().unwrap();
let repo = init_test_repo(&tmp, "stale-focus-repo");
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = false
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
std::fs::write(repo.join("a.txt"), "a").unwrap();
git_cmd(&repo, &["add", "."]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "duplicate subject"]);
std::fs::write(repo.join("b.txt"), "b").unwrap();
git_cmd(&repo, &["add", "."]);
git_cmd(&repo, &["commit", "--no-verify", "-m", "duplicate subject"]);
std::fs::write(repo.join("c.txt"), "c").unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(
result.is_ok(),
"sync_repo should succeed with duplicate subjects in history"
);
}
#[tokio::test]
async fn test_sync_repo_new_branch_auto_push_attempted() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("new-branch-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "main"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"config",
"user.email",
"test@test",
])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "test"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::write(repo.join("test.txt"), "content").unwrap();
let toml_str = r#"
auto_github_private = false
auto_commit = true
auto_pull = false
auto_push = true
auto_bump_versions = false
"#;
let policy: SyncPolicy = toml::from_str(toml_str).unwrap();
let result = sync_repo(&repo, &policy, &BTreeSet::new(), 0, None, false, None).await;
assert!(
result.is_ok(),
"sync_repo should succeed for new branch without upstream"
);
let output = crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "log", "--oneline", "-1"])
.output()
.unwrap();
let log = String::from_utf8_lossy(&output.stdout);
assert!(
!log.contains("init") || log.contains("update"),
"should have new commit after sync"
);
}
#[tokio::test]
async fn test_partition_gitignored_tracked_file_with_gitignore_rule() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
test_git_cmd()
.args(["init", "-q", &repo.to_string_lossy()])
.output()
.unwrap();
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.email", "t@t"])
.output()
.unwrap();
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "t"])
.output()
.unwrap();
test_git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"checkout",
"-q",
"-b",
"main",
])
.output()
.unwrap();
std::fs::create_dir_all(repo.join("subdir/types")).unwrap();
let tracked_path = repo.join("subdir/types/imports.d.ts");
std::fs::write(&tracked_path, "original\n").unwrap();
test_git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"add",
"subdir/types/imports.d.ts",
])
.output()
.unwrap();
test_commit_cmd()
.args(["-C", &repo.to_string_lossy(), "-m", "init"])
.output()
.unwrap();
std::fs::write(repo.join(".gitignore"), "**/types/\n").unwrap();
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "add", ".gitignore"])
.output()
.unwrap();
test_commit_cmd()
.args(["-C", &repo.to_string_lossy(), "-m", "add gitignore"])
.output()
.unwrap();
std::fs::write(&tracked_path, "modified\n").unwrap();
let plain_add = test_git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"add",
"subdir/types/imports.d.ts",
])
.output()
.unwrap();
assert!(
!plain_add.status.success(),
"precondition: plain `git add` should refuse tracked file whose parent dir matches gitignore; stderr: {}",
String::from_utf8_lossy(&plain_add.stderr)
);
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "reset", "-q", "HEAD"])
.output()
.unwrap();
let paths = vec!["subdir/types/imports.d.ts".to_string()];
let (force, normal) = partition_gitignored(&repo, &paths).await;
assert!(
force.contains(&"subdir/types/imports.d.ts".to_string()),
"tracked file (parent dir gitignored) must be in force_paths, got force={:?} normal={:?}",
force,
normal
);
assert!(
!normal.contains(&"subdir/types/imports.d.ts".to_string()),
"tracked file (parent dir gitignored) must NOT be in normal_paths, got force={:?} normal={:?}",
force,
normal
);
}
#[tokio::test]
async fn test_partition_gitignored_untracked_gitignored_is_skipped() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
test_git_cmd()
.args(["init", "-q", &repo.to_string_lossy()])
.output()
.unwrap();
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.email", "t@t"])
.output()
.unwrap();
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "t"])
.output()
.unwrap();
test_git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"checkout",
"-q",
"-b",
"main",
])
.output()
.unwrap();
std::fs::write(repo.join("README"), "x").unwrap();
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "add", "."])
.output()
.unwrap();
test_commit_cmd()
.args(["-C", &repo.to_string_lossy(), "-m", "init"])
.output()
.unwrap();
std::fs::write(repo.join(".gitignore"), "ignored.log\n").unwrap();
std::fs::write(repo.join("ignored.log"), "x").unwrap();
let paths = vec!["ignored.log".to_string()];
let (force, normal) = partition_gitignored(&repo, &paths).await;
assert!(
force.is_empty(),
"untracked+ignored should not be in force_paths, got {:?}",
force
);
assert!(
normal.is_empty(),
"untracked+ignored should not be in normal_paths, got {:?}",
normal
);
}
#[tokio::test]
async fn test_push_with_retries_fetches_first_on_rejection() {
let tmp = tempfile::tempdir().unwrap();
let origin_bare = tmp.path().join("origin.git");
let origin_bare_str = origin_bare.canonicalize().unwrap_or(origin_bare.clone());
test_git_cmd()
.args([
"init",
"--bare",
"-b",
"main",
&origin_bare_str.to_string_lossy(),
])
.output()
.unwrap();
let repo = tmp.path().join("repo");
test_git_cmd()
.args(["init", "-q", "-b", "main", &repo.to_string_lossy()])
.output()
.unwrap();
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.email", "t@t"])
.output()
.unwrap();
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "t"])
.output()
.unwrap();
test_git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.output()
.unwrap();
test_git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"remote",
"add",
"origin",
&origin_bare_str.to_string_lossy(),
])
.output()
.unwrap();
test_git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"push",
"-u",
"origin",
"main",
])
.output()
.unwrap();
std::fs::write(repo.join("extra.txt"), "extra\n").unwrap();
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "add", "extra.txt"])
.output()
.unwrap();
test_git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"-m",
"extra",
])
.output()
.unwrap();
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "push", "origin", "main"])
.output()
.unwrap();
let local_log_after_extra = test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "log", "--oneline"])
.output()
.unwrap();
let local_log_str = String::from_utf8_lossy(&local_log_after_extra.stdout).to_string();
assert!(
local_log_str.contains("extra"),
"local should contain 'extra' commit, got:\n{}\nstderr: {}",
local_log_str,
String::from_utf8_lossy(&local_log_after_extra.stderr)
);
let origin_log = test_git_cmd()
.args([
"--git-dir",
&origin_bare_str.to_string_lossy(),
"log",
"--oneline",
"--all",
])
.output()
.unwrap();
let origin_log_str = String::from_utf8_lossy(&origin_log.stdout).to_string();
let origin_log_lines: Vec<&str> = origin_log_str.trim().lines().collect();
assert!(
origin_log_lines.len() >= 2,
"origin should have >=2 commits after extra push, got {}:\n{}",
origin_log_lines.len(),
origin_log_str
);
let reset_out = test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "reset", "--hard", "HEAD~1"])
.output()
.unwrap();
assert!(
reset_out.status.success(),
"reset failed: {}",
String::from_utf8_lossy(&reset_out.stderr)
);
let local_log_after_reset = test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "log", "--oneline"])
.output()
.unwrap();
let local_log_str = String::from_utf8_lossy(&local_log_after_reset.stdout).to_string();
let local_lines: Vec<&str> = local_log_str.trim().lines().collect();
assert_eq!(
local_lines.len(),
1,
"local should have 1 commit after reset, got {}:\n{}",
local_lines.len(),
local_log_str
);
std::fs::write(repo.join("local.txt"), "local change\n").unwrap();
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "add", "local.txt"])
.output()
.unwrap();
test_git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"-m",
"local",
])
.output()
.unwrap();
let local_log_final = test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "log", "--oneline"])
.output()
.unwrap();
let local_final_str = String::from_utf8_lossy(&local_log_final.stdout).to_string();
assert!(
local_final_str.contains("local"),
"local should contain 'local' commit, got:\n{}",
local_final_str
);
let plain_push = test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "push", "origin", "HEAD"])
.output()
.unwrap();
let plain_stderr = String::from_utf8_lossy(&plain_push.stderr).to_string();
assert!(
!plain_push.status.success()
|| plain_stderr.contains("rejected")
|| plain_stderr.contains("fetch first"),
"precondition: plain push should fail with fetch-first; status={} stderr={}",
plain_push.status,
plain_stderr
);
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "reset", "--hard", "HEAD~1"])
.output()
.unwrap();
std::fs::write(repo.join("local.txt"), "local change\n").unwrap();
test_git_cmd()
.args(["-C", &repo.to_string_lossy(), "add", "local.txt"])
.output()
.unwrap();
test_commit_cmd()
.args(["-C", &repo.to_string_lossy(), "-m", "local"])
.output()
.unwrap();
let result = push_with_retries(&repo, 30, 1, "test").await;
assert!(
result.is_ok(),
"push_with_retries should auto-pull + succeed: {:?}",
result
);
}
#[test]
fn test_scale_push_timeout_small_push_uses_base() {
assert_eq!(scale_push_timeout(60, 0), 60);
assert_eq!(scale_push_timeout(60, 1), 60);
assert_eq!(scale_push_timeout(60, 5), 60);
}
#[test]
fn test_scale_push_timeout_medium_push_doubles() {
assert_eq!(scale_push_timeout(60, 6), 120);
assert_eq!(scale_push_timeout(60, 10), 120);
assert_eq!(scale_push_timeout(60, 20), 120);
}
#[test]
fn test_scale_push_timeout_large_push_quadruples() {
assert_eq!(scale_push_timeout(60, 21), 240);
assert_eq!(scale_push_timeout(60, 28), 240);
assert_eq!(scale_push_timeout(60, 50), 240);
}
#[test]
fn test_scale_push_timeout_huge_push_sextuples_capped() {
assert_eq!(scale_push_timeout(60, 51), 360);
assert_eq!(scale_push_timeout(60, 100), 360);
assert_eq!(scale_push_timeout(300, 100), 600);
assert_eq!(scale_push_timeout(500, 200), 600);
}
#[test]
fn test_scale_push_timeout_zero_base_stays_zero() {
assert_eq!(scale_push_timeout(0, 28), 0);
}
#[tokio::test]
async fn test_count_ahead_commits_no_origin() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "main"])
.arg(&repo)
.status()
.unwrap();
let count = count_ahead_commits(&repo).await.unwrap();
assert_eq!(count, 0, "no-origin repo should report 0 ahead");
}
#[tokio::test]
async fn test_count_ahead_commits_returns_zero_when_synced() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "main"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.email", "t@t"])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "t"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
let count = count_ahead_commits(&repo).await.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_stage_existing_files_skips_vanished_files() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "main"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.email", "t@t"])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "t"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::write(repo.join("real.txt"), "real content\n").unwrap();
let phantom = "vite.config.ts.timestamp-1781483278562-7a994a6fc1011.mjs";
assert!(!repo.join(phantom).exists(), "phantom should not exist on disk");
let paths = vec!["real.txt".to_string(), phantom.to_string()];
let result =
stage_existing_files(&repo, &paths, false, 30, &BTreeSet::new()).await;
assert!(
result.is_ok(),
"stage_existing_files should filter out vanished files: {:?}",
result
);
let output = crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "ls-files", "--stage"])
.output()
.unwrap();
let staged = String::from_utf8_lossy(&output.stdout);
assert!(
staged.contains("real.txt"),
"real.txt should be staged, got: {}",
staged
);
assert!(
!staged.contains("vite.config.ts.timestamp"),
"phantom should not be staged, got: {}",
staged
);
}
#[tokio::test]
async fn test_stage_existing_files_skips_directory_entries() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "main"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.email", "t@t"])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "t"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::write(repo.join("real.txt"), "content\n").unwrap();
std::fs::create_dir(repo.join("subdir")).unwrap();
let paths = vec!["real.txt".to_string(), "subdir".to_string()];
let result =
stage_existing_files(&repo, &paths, false, 30, &BTreeSet::new()).await;
assert!(
result.is_ok(),
"stage_existing_files should skip directory entries: {:?}",
result
);
}
#[tokio::test]
async fn test_stage_existing_files_expands_directory_with_files() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "main"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.email", "t@t"])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "t"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::write(repo.join("tracked.txt"), "init\n").unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"add",
"tracked.txt",
])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"-m",
"add tracked",
])
.status()
.unwrap();
std::fs::create_dir_all(repo.join("docs/research")).unwrap();
std::fs::write(repo.join("docs/research/readme.md"), "# readme\n").unwrap();
std::fs::write(repo.join("docs/research/notes.md"), "# notes\n").unwrap();
let paths = vec!["docs/research".to_string()];
let result =
stage_existing_files(&repo, &paths, false, 30, &BTreeSet::new()).await;
assert!(result.is_ok(), "stage_existing_files failed: {:?}", result);
let output = crate::git::tokio_git_cmd()
.args(["-C", &repo.to_string_lossy(), "diff", "--cached", "--name-only"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await
.unwrap();
let staged = String::from_utf8_lossy(&output.stdout);
assert!(
staged.contains("docs/research/readme.md"),
"expected readme.md to be staged, got: {}",
staged
);
assert!(
staged.contains("docs/research/notes.md"),
"expected notes.md to be staged, got: {}",
staged
);
}
#[tokio::test]
async fn test_stage_existing_files_recurses_deeply() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "main"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.email", "t@t"])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "t"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::create_dir_all(repo.join("a/b/c")).unwrap();
std::fs::write(repo.join("a/b/c/deep1.svelte"), "<div/>\n").unwrap();
std::fs::write(repo.join("a/b/c/deep2.svelte"), "<div/>\n").unwrap();
std::fs::create_dir_all(repo.join("a/b/c/d/e")).unwrap();
std::fs::write(repo.join("a/b/c/d/e/really_deep.css"), "x{}\n").unwrap();
let paths = vec!["a".to_string()];
let result =
stage_existing_files(&repo, &paths, false, 30, &BTreeSet::new()).await;
assert!(result.is_ok(), "stage_existing_files failed: {:?}", result);
let output = crate::git::tokio_git_cmd()
.args(["-C", &repo.to_string_lossy(), "diff", "--cached", "--name-only"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await
.unwrap();
let staged = String::from_utf8_lossy(&output.stdout);
assert!(
staged.contains("a/b/c/deep1.svelte"),
"3-level file missed: {}",
staged
);
assert!(
staged.contains("a/b/c/deep2.svelte"),
"3-level file missed: {}",
staged
);
assert!(
staged.contains("a/b/c/d/e/really_deep.css"),
"4-level file missed: {}",
staged
);
}
#[tokio::test]
async fn test_stage_existing_files_skips_node_modules_and_dotdirs() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "main"])
.arg(&repo)
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.email", "t@t"])
.status()
.unwrap();
crate::git::git_cmd()
.args(["-C", &repo.to_string_lossy(), "config", "user.name", "t"])
.status()
.unwrap();
crate::git::git_cmd()
.args([
"-C",
&repo.to_string_lossy(),
"commit",
"--no-verify",
"--allow-empty",
"-m",
"init",
])
.status()
.unwrap();
std::fs::create_dir_all(repo.join("node_modules/foo")).unwrap();
std::fs::write(repo.join("node_modules/foo/should_not_stage.js"), "x\n").unwrap();
std::fs::create_dir_all(repo.join("target/build")).unwrap();
std::fs::write(repo.join("target/build/should_not_stage.bin"), "x\n").unwrap();
std::fs::create_dir_all(repo.join(".cache/data")).unwrap();
std::fs::write(repo.join(".cache/data/should_not_stage.dat"), "x\n").unwrap();
std::fs::create_dir_all(repo.join("src")).unwrap();
std::fs::write(repo.join("src/keep.ts"), "export {}\n").unwrap();
let excluded: std::collections::BTreeSet<String> = crate::policy::default_exclude_dir_names()
.into_iter()
.collect();
let paths = vec![
"node_modules".to_string(),
"target".to_string(),
".cache".to_string(),
"src".to_string(),
];
let result = stage_existing_files(&repo, &paths, false, 30, &excluded).await;
assert!(result.is_ok());
let output = crate::git::tokio_git_cmd()
.args(["-C", &repo.to_string_lossy(), "diff", "--cached", "--name-only"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.await
.unwrap();
let staged = String::from_utf8_lossy(&output.stdout);
assert!(
staged.contains("src/keep.ts"),
"src/keep.ts should be staged, got: {}",
staged
);
assert!(
!staged.contains("node_modules"),
"node_modules contents should NOT be staged, got: {}",
staged
);
assert!(
!staged.contains("target"),
"target contents should NOT be staged, got: {}",
staged
);
assert!(
!staged.contains(".cache"),
".cache contents should NOT be staged, got: {}",
staged
);
}
#[tokio::test]
async fn test_stage_existing_files_empty_after_filter_returns_ok() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("test-repo");
crate::git::git_cmd()
.args(["init", "-q", "-b", "main"])
.arg(&repo)
.status()
.unwrap();
let paths = vec!["nonexistent1.txt".to_string(), "nonexistent2.txt".to_string()];
let result =
stage_existing_files(&repo, &paths, false, 30, &BTreeSet::new()).await;
assert!(result.is_ok(), "all-vanished list should be a no-op");
}
#[test]
fn test_is_backstop_active_below_threshold() {
let now = std::time::Instant::now();
let since = now - std::time::Duration::from_secs(600);
assert!(!is_backstop_active(Some(since), now, 0, 20, 300));
assert!(!is_backstop_active(Some(since), now, 20, 20, 300));
}
#[test]
fn test_is_backstop_active_above_threshold_but_recent() {
let now = std::time::Instant::now();
let since = now - std::time::Duration::from_secs(60);
assert!(!is_backstop_active(Some(since), now, 21, 20, 300));
}
#[test]
fn test_is_backstop_active_above_threshold_and_old() {
let now = std::time::Instant::now();
let since = now - std::time::Duration::from_secs(600);
assert!(is_backstop_active(Some(since), now, 28, 20, 300));
assert!(is_backstop_active(Some(since), now, 100, 20, 300));
}
#[test]
fn test_is_backstop_active_no_ahead_since() {
let now = std::time::Instant::now();
assert!(!is_backstop_active(None, now, 50, 20, 300));
}
#[test]
fn test_is_backstop_active_threshold_zero_disables() {
let now = std::time::Instant::now();
let since = now - std::time::Duration::from_secs(3600);
assert!(!is_backstop_active(Some(since), now, 100, 0, 300));
}
}