use std::process::Command;
use loopflow::engine::git::{
commit, create_branch, current_branch, get_default_branch, is_ancestor, is_clean, merge,
sync_main,
};
use loopflow_test_support::TestRepo;
fn git(dir: &std::path::Path, args: &[&str]) -> String {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("run git");
assert!(
output.status.success(),
"git {:?} failed in {:?}: {}",
args,
dir,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[test]
fn current_branch_returns_branch_name() {
let repo = TestRepo::new();
let branch = current_branch(repo.path()).unwrap();
assert!(branch.is_some());
let name = branch.unwrap();
assert_eq!(name, "main");
}
#[test]
fn create_branch_and_switch() {
let repo = TestRepo::new();
create_branch(repo.path(), "feature-x").unwrap();
let branch = current_branch(repo.path()).unwrap();
assert_eq!(branch.as_deref(), Some("feature-x"));
}
#[test]
fn get_default_branch_returns_main_or_master() {
let repo = TestRepo::new();
let default = get_default_branch(repo.path()).unwrap();
assert_eq!(default, "main");
}
#[test]
fn clean_repo_reports_clean() {
let repo = TestRepo::new();
assert!(is_clean(repo.path()).unwrap());
}
#[test]
fn dirty_repo_reports_dirty() {
let repo = TestRepo::new();
repo.create_file("dirty.txt", "uncommitted");
assert!(!is_clean(repo.path()).unwrap());
}
#[test]
fn commit_creates_commit_with_staged_changes() {
let repo = TestRepo::new();
repo.create_file("new.txt", "content");
Command::new("git")
.args(["add", "new.txt"])
.current_dir(repo.path())
.output()
.expect("git add");
commit(repo.path(), "add new file").unwrap();
assert!(is_clean(repo.path()).unwrap());
}
#[test]
fn is_ancestor_detects_linear_history() {
let repo = TestRepo::new();
repo.create_file("first.txt", "first");
repo.stage_all();
repo.commit("first");
let first_sha = repo.head_sha();
repo.create_file("second.txt", "second");
repo.stage_all();
repo.commit("second");
let second_sha = repo.head_sha();
assert!(is_ancestor(repo.path(), &first_sha, &second_sha).unwrap());
assert!(!is_ancestor(repo.path(), &second_sha, &first_sha).unwrap());
}
#[test]
fn is_ancestor_same_commit() {
let repo = TestRepo::new();
let sha = repo.head_sha();
assert!(is_ancestor(repo.path(), &sha, &sha).unwrap());
}
#[test]
fn sync_onto_same_branch_succeeds() {
let repo = TestRepo::new();
let default = get_default_branch(repo.path()).unwrap();
let result = merge(repo.path(), &default, None).unwrap();
assert!(result.success);
}
#[test]
fn sync_feature_onto_main() {
let repo = TestRepo::new();
let default = get_default_branch(repo.path()).unwrap();
let initial_sha = repo.head_sha();
create_branch(repo.path(), "feature").unwrap();
repo.create_file("feature.txt", "feature work");
repo.stage_all();
repo.commit("feature work");
repo.checkout(&default);
repo.create_file("main.txt", "main work");
repo.stage_all();
repo.commit("main work");
let main_sha = repo.head_sha();
repo.checkout("feature");
let result = merge(repo.path(), &default, None).unwrap();
assert!(result.success);
assert!(is_ancestor(repo.path(), &main_sha, &repo.head_sha()).unwrap());
assert!(is_ancestor(repo.path(), &initial_sha, &repo.head_sha()).unwrap());
}
#[test]
fn sync_would_fail_on_dirty_tree() {
let repo = TestRepo::new();
repo.create_file("dirty.txt", "uncommitted");
assert!(!is_clean(repo.path()).unwrap());
}
#[test]
fn sync_main_from_feature_worktree_keeps_sibling_main_clean() {
let repo = TestRepo::new();
let bare = repo.bare_path().to_path_buf();
let pusher = tempfile::TempDir::new().unwrap();
git(pusher.path(), &["clone", bare.to_str().unwrap(), "."]);
git(pusher.path(), &["config", "user.email", "t@t"]);
git(pusher.path(), &["config", "user.name", "t"]);
std::fs::write(pusher.path().join("upstream.txt"), "from upstream").unwrap();
git(pusher.path(), &["add", "."]);
git(pusher.path(), &["commit", "-m", "upstream change"]);
git(pusher.path(), &["push", "origin", "main"]);
let upstream_sha = git(pusher.path(), &["rev-parse", "HEAD"]);
let feature = repo.create_named_worktree("myfeature");
sync_main(&feature, "main").unwrap();
let status = git(repo.path(), &["status", "--porcelain"]);
assert!(
status.is_empty(),
"main worktree should be clean after sync_main from feature worktree, got:\n{status}"
);
let main_head = git(repo.path(), &["rev-parse", "HEAD"]);
assert_eq!(
main_head, upstream_sha,
"main worktree HEAD should match origin/main after sync"
);
}
#[test]
fn sync_main_from_feature_preserves_dirty_work_on_main() {
let repo = TestRepo::new();
let bare = repo.bare_path().to_path_buf();
let pusher = tempfile::TempDir::new().unwrap();
git(pusher.path(), &["clone", bare.to_str().unwrap(), "."]);
git(pusher.path(), &["config", "user.email", "t@t"]);
git(pusher.path(), &["config", "user.name", "t"]);
std::fs::write(pusher.path().join("upstream.txt"), "upstream").unwrap();
git(pusher.path(), &["add", "."]);
git(pusher.path(), &["commit", "-m", "upstream"]);
git(pusher.path(), &["push", "origin", "main"]);
std::fs::write(repo.path().join("scratch_note.txt"), "wip").unwrap();
let feature = repo.create_named_worktree("myfeature");
sync_main(&feature, "main").unwrap();
assert_eq!(
std::fs::read_to_string(repo.path().join("scratch_note.txt")).unwrap(),
"wip",
"uncommitted file on main should be preserved across sync_main"
);
assert!(
repo.path().join("upstream.txt").exists(),
"main worktree should have fast-forwarded to origin/main"
);
}
#[test]
fn sync_main_does_not_revert_rewritten_paths() {
let repo = TestRepo::new();
let bare = repo.bare_path().to_path_buf();
std::fs::write(repo.path().join("old.txt"), "v1").unwrap();
git(repo.path(), &["add", "."]);
git(repo.path(), &["commit", "-m", "add old.txt"]);
git(repo.path(), &["push", "origin", "main"]);
let pusher = tempfile::TempDir::new().unwrap();
git(pusher.path(), &["clone", bare.to_str().unwrap(), "."]);
git(pusher.path(), &["config", "user.email", "t@t"]);
git(pusher.path(), &["config", "user.name", "t"]);
std::fs::remove_file(pusher.path().join("old.txt")).unwrap();
std::fs::write(pusher.path().join("new.txt"), "v2").unwrap();
git(pusher.path(), &["add", "-A"]);
git(
pusher.path(),
&["commit", "-m", "recut: old.txt -> new.txt"],
);
git(pusher.path(), &["push", "origin", "main"]);
std::fs::write(repo.path().join("old.txt"), "local wip").unwrap();
let feature = repo.create_named_worktree("myfeature");
sync_main(&feature, "main").unwrap();
assert!(
!repo.path().join("old.txt").exists(),
"old.txt must stay deleted; the stash must not be popped over the recut"
);
assert!(
repo.path().join("new.txt").exists(),
"main worktree should have synced to origin/main"
);
let stash_list = git(repo.path(), &["stash", "list"]);
assert!(
stash_list.contains("sync_main: auto-stash"),
"local edit should be preserved in a stash, got: {stash_list:?}"
);
}