Files
vibe-kanban/crates/services/tests/git_ops_safety.rs
Alex Netsch c60c1a8f77 Alex/refactor create pr (#746)
* Remvoe duplicate github url regex

* Better error prop

* Fix leaky auth

* Fix branch status not working or remote base branches

Make PR creation fire and forget

Fix url regex; fix error msg parsing

fmt, clippy

Revert "Make PR creation fire and forget"

This reverts commit 1a99ceb06b5534cc22fcb88c484b068292e90edb.

* Re-add open from backend

* Add creating indicator

* Remove duplication

* Add remote tests

* Fmt, clippy

* Fix https conversion edge case, fix PushRejected detection

* Add push rejected test

* Refactor githubservice

* add local fetch/push tests, ignore network test

* stop retry on reponotfound, add comment for url regex
2025-09-18 15:05:34 +01:00

1362 lines
48 KiB
Rust

use std::{
fs,
io::Write,
path::{Path, PathBuf},
};
use git2::{PushOptions, Repository, build::CheckoutBuilder};
use services::services::{
git::GitService,
git_cli::{GitCli, GitCliError},
};
use tempfile::TempDir;
// Avoid direct git CLI usage in tests; exercise GitService instead.
fn write_file<P: AsRef<Path>>(base: P, rel: &str, content: &str) {
let path = base.as_ref().join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
let mut f = fs::File::create(&path).unwrap();
f.write_all(content.as_bytes()).unwrap();
}
fn commit_all(repo: &Repository, message: &str) {
let mut index = repo.index().unwrap();
index
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
.unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = repo.signature().unwrap();
let parents: Vec<git2::Commit> = match repo.head() {
Ok(h) => vec![h.peel_to_commit().unwrap()],
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => vec![],
Err(e) => panic!("failed to read HEAD: {e}"),
};
let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
let update_ref = if repo.head().is_ok() {
Some("HEAD")
} else {
None
};
repo.commit(update_ref, &sig, &sig, message, &tree, &parent_refs)
.unwrap();
}
fn checkout_branch(repo: &Repository, name: &str) {
repo.set_head(&format!("refs/heads/{name}")).unwrap();
let mut co = CheckoutBuilder::new();
co.force();
repo.checkout_head(Some(&mut co)).unwrap();
}
fn create_branch_from_head(repo: &Repository, name: &str) {
let head = repo.head().unwrap().peel_to_commit().unwrap();
let _ = repo.branch(name, &head, true).unwrap();
}
fn configure_user(repo: &Repository) {
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test User").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
}
fn push_ref(repo: &Repository, local: &str, remote: &str) {
let mut remote_handle = repo.find_remote("origin").unwrap();
let mut opts = PushOptions::new();
let spec = format!("+{local}:{remote}");
remote_handle
.push(&[spec.as_str()], Some(&mut opts))
.unwrap();
}
use services::services::git::DiffTarget;
// Non-conflicting setup used by several tests
fn setup_repo_with_worktree(root: &TempDir) -> (PathBuf, PathBuf) {
let repo_path = root.path().join("repo");
let worktree_path = root.path().join("wt-feature");
let service = GitService::new();
service
.initialize_repo_with_main_branch(&repo_path)
.expect("init repo");
let repo = Repository::open(&repo_path).unwrap();
configure_user(&repo);
checkout_branch(&repo, "main");
write_file(&repo_path, "common.txt", "base\n");
commit_all(&repo, "initial main commit");
create_branch_from_head(&repo, "old-base");
checkout_branch(&repo, "old-base");
write_file(&repo_path, "base.txt", "from old-base\n");
commit_all(&repo, "old-base commit");
checkout_branch(&repo, "main");
create_branch_from_head(&repo, "new-base");
checkout_branch(&repo, "new-base");
write_file(&repo_path, "base.txt", "from new-base\n");
commit_all(&repo, "new-base commit");
checkout_branch(&repo, "old-base");
create_branch_from_head(&repo, "feature");
let svc = GitService::new();
svc.add_worktree(&repo_path, &worktree_path, "feature", false)
.expect("create worktree");
write_file(&worktree_path, "feat.txt", "feat change\n");
let wt_repo = Repository::open(&worktree_path).unwrap();
commit_all(&wt_repo, "feature commit");
(repo_path, worktree_path)
}
// Conflicting setup to simulate interactive rebase interruption
fn setup_conflict_repo_with_worktree(root: &TempDir) -> (PathBuf, PathBuf) {
let repo_path = root.path().join("repo");
let worktree_path = root.path().join("wt-feature");
let service = GitService::new();
service
.initialize_repo_with_main_branch(&repo_path)
.expect("init repo");
let repo = Repository::open(&repo_path).unwrap();
configure_user(&repo);
checkout_branch(&repo, "main");
write_file(&repo_path, "conflict.txt", "base\n");
commit_all(&repo, "initial main commit");
// old-base modifies conflict.txt one way
create_branch_from_head(&repo, "old-base");
checkout_branch(&repo, "old-base");
write_file(&repo_path, "conflict.txt", "old-base version\n");
commit_all(&repo, "old-base change");
// feature builds on old-base and modifies same lines differently
create_branch_from_head(&repo, "feature");
// new-base modifies in a conflicting way
checkout_branch(&repo, "main");
create_branch_from_head(&repo, "new-base");
checkout_branch(&repo, "new-base");
write_file(&repo_path, "conflict.txt", "new-base version\n");
commit_all(&repo, "new-base change");
// add a worktree for feature and create the conflicting commit
let svc = GitService::new();
svc.add_worktree(&repo_path, &worktree_path, "feature", false)
.expect("create worktree");
let wt_repo = Repository::open(&worktree_path).unwrap();
write_file(&worktree_path, "conflict.txt", "feature version\n");
commit_all(&wt_repo, "feature conflicting change");
(repo_path, worktree_path)
}
// Setup where feature has no unique commits (feature == old-base)
fn setup_no_unique_feature_repo(root: &TempDir) -> (PathBuf, PathBuf) {
let repo_path = root.path().join("repo");
let worktree_path = root.path().join("wt-feature");
let service = GitService::new();
service
.initialize_repo_with_main_branch(&repo_path)
.expect("init repo");
let repo = Repository::open(&repo_path).unwrap();
configure_user(&repo);
checkout_branch(&repo, "main");
write_file(&repo_path, "base.txt", "main base\n");
commit_all(&repo, "initial main commit");
// Create old-base at this point
create_branch_from_head(&repo, "old-base");
// Create new-base diverging
checkout_branch(&repo, "main");
create_branch_from_head(&repo, "new-base");
checkout_branch(&repo, "new-base");
write_file(&repo_path, "advance.txt", "new base\n");
commit_all(&repo, "advance new-base");
// Create feature equal to old-base (no unique commits)
checkout_branch(&repo, "old-base");
create_branch_from_head(&repo, "feature");
let svc = GitService::new();
svc.add_worktree(&repo_path, &worktree_path, "feature", false)
.expect("create worktree");
(repo_path, worktree_path)
}
// Simple two-way conflict between main and feature on the same file
fn setup_direct_conflict_repo(root: &TempDir) -> (PathBuf, PathBuf) {
let repo_path = root.path().join("repo");
let worktree_path = root.path().join("wt-feature");
let service = GitService::new();
service
.initialize_repo_with_main_branch(&repo_path)
.expect("init repo");
let repo = Repository::open(&repo_path).unwrap();
configure_user(&repo);
checkout_branch(&repo, "main");
write_file(&repo_path, "conflict.txt", "base\n");
commit_all(&repo, "initial main commit");
// Create feature and commit conflicting change
create_branch_from_head(&repo, "feature");
let svc = GitService::new();
svc.add_worktree(&repo_path, &worktree_path, "feature", false)
.expect("create worktree");
let wt_repo = Repository::open(&worktree_path).unwrap();
write_file(&worktree_path, "conflict.txt", "feature change\n");
commit_all(&wt_repo, "feature change");
// Change main in a conflicting way
checkout_branch(&repo, "main");
write_file(&repo_path, "conflict.txt", "main change\n");
commit_all(&repo, "main change");
(repo_path, worktree_path)
}
#[test]
fn push_with_token_reports_non_fast_forward() {
let temp_dir = TempDir::new().unwrap();
let remote_path = temp_dir.path().join("remote.git");
Repository::init_bare(&remote_path).expect("init bare remote");
let remote_url = remote_path.to_str().expect("remote path str");
// Seed the bare repo with an initial main branch commit
let seed_path = temp_dir.path().join("seed");
let service = GitService::new();
service
.initialize_repo_with_main_branch(&seed_path)
.expect("init seed repo");
let seed_repo = Repository::open(&seed_path).expect("open seed repo");
configure_user(&seed_repo);
seed_repo.remote("origin", remote_url).expect("add remote");
push_ref(&seed_repo, "refs/heads/main", "refs/heads/main");
Repository::open_bare(&remote_path)
.expect("open bare remote")
.set_head("refs/heads/main")
.expect("set remote HEAD");
// Local clone that will attempt the push later
let local_path = temp_dir.path().join("local");
let local_repo = Repository::clone(remote_url, &local_path).expect("clone local");
configure_user(&local_repo);
checkout_branch(&local_repo, "main");
write_file(&local_path, "file.txt", "initial local\n");
commit_all(&local_repo, "initial local commit");
push_ref(&local_repo, "refs/heads/main", "refs/heads/main");
// Separate clone simulates someone else pushing first
let updater_path = temp_dir.path().join("updater");
let updater_repo = Repository::clone(remote_url, &updater_path).expect("clone updater");
configure_user(&updater_repo);
checkout_branch(&updater_repo, "main");
write_file(&updater_path, "file.txt", "upstream change\n");
commit_all(&updater_repo, "upstream commit");
push_ref(&updater_repo, "refs/heads/main", "refs/heads/main");
// Local branch diverges but has not fetched the updater's commit
write_file(&local_path, "file.txt", "local change\n");
commit_all(&local_repo, "local commit");
let remote = local_repo.find_remote("origin").expect("origin remote");
let remote_url_string = remote.url().expect("origin url").to_string();
let git_cli = GitCli::new();
let result = git_cli.push_with_token(&local_path, &remote_url_string, "main", "dummy-token");
match result {
Err(GitCliError::PushRejected(msg)) => {
let lower = msg.to_ascii_lowercase();
assert!(
lower.contains("failed to push some refs") || lower.contains("fetch first"),
"unexpected stderr: {msg}"
);
}
Err(other) => panic!("expected push rejected, got {other:?}"),
Ok(_) => panic!("push unexpectedly succeeded"),
}
}
#[test]
fn fetch_with_token_missing_ref_returns_error() {
let temp_dir = TempDir::new().unwrap();
let remote_path = temp_dir.path().join("remote.git");
Repository::init_bare(&remote_path).expect("init bare remote");
let remote_url = remote_path.to_str().expect("remote path str");
let seed_path = temp_dir.path().join("seed");
let service = GitService::new();
service
.initialize_repo_with_main_branch(&seed_path)
.expect("init seed repo");
let seed_repo = Repository::open(&seed_path).expect("open seed repo");
configure_user(&seed_repo);
seed_repo.remote("origin", remote_url).expect("add remote");
push_ref(&seed_repo, "refs/heads/main", "refs/heads/main");
Repository::open_bare(&remote_path)
.expect("open bare remote")
.set_head("refs/heads/main")
.expect("set remote HEAD");
let local_path = temp_dir.path().join("local");
Repository::clone(remote_url, &local_path).expect("clone local");
let git_cli = GitCli::new();
let refspec = "+refs/heads/missing:refs/remotes/origin/missing";
let result =
git_cli.fetch_with_token_and_refspec(&local_path, remote_url, refspec, "dummy-token");
match result {
Err(GitCliError::CommandFailed(msg)) => {
assert!(
msg.to_ascii_lowercase()
.contains("couldn't find remote ref"),
"unexpected stderr: {msg}"
);
}
Err(other) => panic!("expected command failed, got {other:?}"),
Ok(_) => panic!("fetch unexpectedly succeeded"),
}
}
#[test]
fn push_and_fetch_roundtrip_updates_tracking_branch() {
let temp_dir = TempDir::new().unwrap();
let remote_path = temp_dir.path().join("remote.git");
Repository::init_bare(&remote_path).expect("init bare remote");
let remote_url = remote_path.to_str().expect("remote path str");
let seed_path = temp_dir.path().join("seed");
let service = GitService::new();
service
.initialize_repo_with_main_branch(&seed_path)
.expect("init seed repo");
let seed_repo = Repository::open(&seed_path).expect("open seed repo");
configure_user(&seed_repo);
seed_repo.remote("origin", remote_url).expect("add remote");
push_ref(&seed_repo, "refs/heads/main", "refs/heads/main");
Repository::open_bare(&remote_path)
.expect("open bare remote")
.set_head("refs/heads/main")
.expect("set remote HEAD");
let producer_path = temp_dir.path().join("producer");
let producer_repo = Repository::clone(remote_url, &producer_path).expect("clone producer");
configure_user(&producer_repo);
checkout_branch(&producer_repo, "main");
let consumer_path = temp_dir.path().join("consumer");
let consumer_repo = Repository::clone(remote_url, &consumer_path).expect("clone consumer");
configure_user(&consumer_repo);
checkout_branch(&consumer_repo, "main");
let old_oid = consumer_repo
.find_reference("refs/remotes/origin/main")
.expect("consumer tracking ref")
.target()
.expect("consumer tracking ref");
write_file(&producer_path, "file.txt", "new work\n");
commit_all(&producer_repo, "producer commit");
let remote = producer_repo.find_remote("origin").expect("origin remote");
let remote_url_string = remote.url().expect("origin url").to_string();
let git_cli = GitCli::new();
git_cli
.push_with_token(&producer_path, &remote_url_string, "main", "dummy-token")
.expect("push succeeded");
let new_oid = producer_repo
.head()
.expect("producer head")
.target()
.expect("producer head oid");
assert_ne!(old_oid, new_oid, "producer created new commit");
git_cli
.fetch_with_token_and_refspec(
&consumer_path,
&remote_url_string,
"+refs/heads/main:refs/remotes/origin/main",
"dummy-token",
)
.expect("fetch succeeded");
let updated_oid = consumer_repo
.find_reference("refs/remotes/origin/main")
.expect("updated tracking ref")
.target()
.expect("updated tracking ref");
assert_eq!(
updated_oid, new_oid,
"tracking branch advanced to remote head"
);
}
#[test]
fn rebase_preserves_untracked_files() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
write_file(&worktree_path, "scratch/untracked.txt", "temporary note\n");
let service = GitService::new();
let res = service.rebase_branch(
&repo_path,
&worktree_path,
Some("new-base"),
"old-base",
None,
);
assert!(res.is_ok(), "rebase should succeed: {res:?}");
let scratch = worktree_path.join("scratch/untracked.txt");
let content = fs::read_to_string(&scratch).expect("untracked file exists");
assert_eq!(content, "temporary note\n");
}
#[test]
fn rebase_aborts_on_uncommitted_tracked_changes() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
write_file(&worktree_path, "feat.txt", "feat change (edited)\n");
let service = GitService::new();
let res = service.rebase_branch(
&repo_path,
&worktree_path,
Some("new-base"),
"old-base",
None,
);
assert!(res.is_err(), "rebase should fail on dirty worktree");
let edited = fs::read_to_string(worktree_path.join("feat.txt")).unwrap();
assert_eq!(edited, "feat change (edited)\n");
}
#[test]
fn rebase_aborts_if_untracked_would_be_overwritten_by_base() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
write_file(&worktree_path, "base.txt", "my scratch note\n");
let service = GitService::new();
let res = service.rebase_branch(
&repo_path,
&worktree_path,
Some("new-base"),
"old-base",
None,
);
assert!(
res.is_err(),
"rebase should fail due to untracked overwrite risk"
);
let content = std::fs::read_to_string(worktree_path.join("base.txt")).unwrap();
assert_eq!(content, "my scratch note\n");
}
#[test]
fn merge_does_not_overwrite_main_repo_untracked_files() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
write_file(&worktree_path, "danger.txt", "tracked from feature\n");
let wt_repo = Repository::open(&worktree_path).unwrap();
commit_all(&wt_repo, "add danger.txt in feature");
write_file(&repo_path, "danger.txt", "my untracked data\n");
let main_repo = Repository::open(&repo_path).unwrap();
checkout_branch(&main_repo, "main");
let service = GitService::new();
let res = service.merge_changes(
&repo_path,
&worktree_path,
"feature",
"main",
"squash merge",
);
assert!(
res.is_err(),
"merge should refuse due to untracked conflict"
);
// Untracked file remains untouched
let content = std::fs::read_to_string(repo_path.join("danger.txt")).unwrap();
assert_eq!(content, "my untracked data\n");
}
#[test]
fn merge_does_not_touch_tracked_uncommitted_changes_in_base_worktree() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
// Prepare: modify a tracked file in the base worktree (main) without committing
let _main_repo = Repository::open(&repo_path).unwrap();
// Base branch commits will be advanced by the merge operation; record before via service
let g = GitService::new();
let before_oid = g.get_branch_oid(&repo_path, "main").unwrap();
// Create a tracked file that will also be added by feature branch to simulate overlap
write_file(&repo_path, "danger2.txt", "my staged change\n");
{
// stage and then unstage to leave WT_MODIFIED? Simpler: just modify an existing tracked file
// Use common.txt which is tracked
write_file(&repo_path, "common.txt", "edited locally\n");
}
// Feature adds a change and is committed in worktree
write_file(&worktree_path, "danger2.txt", "feature tracked\n");
let wt_repo = Repository::open(&worktree_path).unwrap();
commit_all(&wt_repo, "feature adds danger2.txt");
// Merge via service (squash into main) should not modify files in the main worktree
let service = GitService::new();
let res = service.merge_changes(
&repo_path,
&worktree_path,
"feature",
"main",
"squash merge",
);
assert!(
res.is_ok(),
"merge should succeed without touching worktree"
);
// Confirm the local edit to tracked file remains
let content = std::fs::read_to_string(repo_path.join("common.txt")).unwrap();
assert_eq!(content, "edited locally\n");
// Confirm the main branch ref advanced
let after_oid = g.get_branch_oid(&repo_path, "main").unwrap();
assert_ne!(before_oid, after_oid, "main ref should be updated by merge");
}
#[test]
fn merge_refuses_with_staged_changes_on_base() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
let s = GitService::new();
// ensure main is checked out
s.checkout_branch(&repo_path, "main").unwrap();
// feature adds change and commits
write_file(&worktree_path, "m.txt", "feature\n");
let wt_repo = Repository::open(&worktree_path).unwrap();
commit_all(&wt_repo, "feat change");
// main has staged change
write_file(&repo_path, "staged.txt", "staged\n");
s.add_path(&repo_path, "staged.txt").unwrap();
let res = s.merge_changes(&repo_path, &worktree_path, "feature", "main", "squash");
assert!(res.is_err(), "should refuse merge due to staged changes");
// staged file remains
let content = std::fs::read_to_string(repo_path.join("staged.txt")).unwrap();
assert_eq!(content, "staged\n");
}
#[test]
fn merge_preserves_unstaged_changes_on_base() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
let s = GitService::new();
s.checkout_branch(&repo_path, "main").unwrap();
// modify unstaged
write_file(&repo_path, "common.txt", "local edited\n");
// feature modifies a different file
write_file(&worktree_path, "merged.txt", "merged content\n");
let wt_repo = Repository::open(&worktree_path).unwrap();
commit_all(&wt_repo, "feature merged");
let _sha = s
.merge_changes(&repo_path, &worktree_path, "feature", "main", "squash")
.unwrap();
// local edit preserved
let loc = std::fs::read_to_string(repo_path.join("common.txt")).unwrap();
assert_eq!(loc, "local edited\n");
// merged file updated
let m = std::fs::read_to_string(repo_path.join("merged.txt")).unwrap();
assert_eq!(m, "merged content\n");
}
#[test]
fn update_ref_does_not_destroy_feature_worktree_dirty_state() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
let s = GitService::new();
// ensure main is checked out
s.checkout_branch(&repo_path, "main").unwrap();
// feature makes an initial change and commits
write_file(&worktree_path, "f.txt", "feat\n");
let wt_repo = Repository::open(&worktree_path).unwrap();
commit_all(&wt_repo, "feat commit");
// dirty change in feature worktree (uncommitted)
write_file(&worktree_path, "dirty.txt", "unstaged\n");
// merge from feature into main (CLI path updates task ref via update-ref)
let sha = s
.merge_changes(&repo_path, &worktree_path, "feature", "main", "squash")
.unwrap();
// uncommitted change in feature worktree preserved
let dirty = std::fs::read_to_string(worktree_path.join("dirty.txt")).unwrap();
assert_eq!(dirty, "unstaged\n");
// feature branch ref updated to the squash commit in main repo
let feature_oid = s.get_branch_oid(&repo_path, "feature").unwrap();
assert_eq!(feature_oid, sha);
// and the feature worktree HEAD now points to that commit
let head = s.get_head_info(&worktree_path).unwrap();
assert_eq!(head.branch, "feature");
assert_eq!(head.oid, sha);
}
#[test]
fn libgit2_merge_updates_base_ref_in_both_repos() {
// Ensure we hit the libgit2 path by NOT checking out the base branch in main repo
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
let s = GitService::new();
// Record current main OID from both main repo and worktree repo; they should match pre-merge
let before_main_repo = s.get_branch_oid(&repo_path, "main").unwrap();
let before_main_wt = s.get_branch_oid(&worktree_path, "main").unwrap();
assert_eq!(before_main_repo, before_main_wt);
// Perform merge (squash) while main repo is NOT on base branch (libgit2 path)
let sha = s
.merge_changes(&repo_path, &worktree_path, "feature", "main", "squash")
.expect("merge should succeed via libgit2 path");
// Base branch ref advanced in both main and worktree repositories
let after_main_repo = s.get_branch_oid(&repo_path, "main").unwrap();
let after_main_wt = s.get_branch_oid(&worktree_path, "main").unwrap();
assert_eq!(after_main_repo, sha);
assert_eq!(after_main_wt, sha);
}
#[test]
fn libgit2_merge_updates_task_ref_and_feature_head_preserves_dirty() {
// Hit libgit2 path (main repo not on base) and verify task ref + HEAD update safely
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
let s = GitService::new();
// Make an uncommitted change in the feature worktree to ensure it's preserved
write_file(&worktree_path, "dirty2.txt", "keep me\n");
// Perform merge (squash) from feature into main; this path uses libgit2
let sha = s
.merge_changes(&repo_path, &worktree_path, "feature", "main", "squash")
.expect("merge should succeed via libgit2 path");
// Dirty file preserved in worktree
let dirty = std::fs::read_to_string(worktree_path.join("dirty2.txt")).unwrap();
assert_eq!(dirty, "keep me\n");
// Task branch (feature) updated to squash commit in both repos
let feat_main_repo = s.get_branch_oid(&repo_path, "feature").unwrap();
let feat_worktree = s.get_branch_oid(&worktree_path, "feature").unwrap();
assert_eq!(feat_main_repo, sha);
assert_eq!(feat_worktree, sha);
// Feature worktree HEAD points to the new squash commit
let head = s.get_head_info(&worktree_path).unwrap();
assert_eq!(head.branch, "feature");
assert_eq!(head.oid, sha);
}
#[test]
fn rebase_refuses_to_abort_existing_rebase() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_conflict_repo_with_worktree(&td);
// Start a rebase via GitService that will pause/conflict
let svc = GitService::new();
let _ = svc
.rebase_branch(
&repo_path,
&worktree_path,
Some("new-base"),
"old-base",
None,
)
.expect_err("first rebase should error and leave in-progress state");
// Our service should refuse to proceed and not abort the user's rebase
let service = GitService::new();
let res = service.rebase_branch(
&repo_path,
&worktree_path,
Some("new-base"),
"old-base",
None,
);
assert!(res.is_err(), "should error because rebase is in progress");
// Note: We do not auto-abort; user should resolve or abort explicitly
}
#[test]
fn rebase_fast_forwards_when_no_unique_commits() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_no_unique_feature_repo(&td);
let g = GitService::new();
let before = g.get_head_info(&worktree_path).unwrap().oid;
let new_base_oid = g.get_branch_oid(&repo_path, "new-base").unwrap();
let _res = g
.rebase_branch(
&repo_path,
&worktree_path,
Some("new-base"),
"old-base",
None,
)
.expect("rebase should succeed");
let after_oid = g.get_head_info(&worktree_path).unwrap().oid;
assert_ne!(before, after_oid, "HEAD should move after rebase");
assert_eq!(after_oid, new_base_oid, "fast-forward onto new-base");
}
#[test]
fn rebase_applies_multiple_commits_onto_ahead_base() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
let repo = Repository::open(&repo_path).unwrap();
// Advance new-base further
checkout_branch(&repo, "new-base");
write_file(&repo_path, "base_more.txt", "nb more\n");
commit_all(&repo, "advance new-base more");
// Add another commit to feature
write_file(&worktree_path, "feat2.txt", "second change\n");
let wt_repo = Repository::open(&worktree_path).unwrap();
commit_all(&wt_repo, "feature second commit");
// Rebase feature onto new-base
let service = GitService::new();
let _ = service
.rebase_branch(
&repo_path,
&worktree_path,
Some("new-base"),
"old-base",
None,
)
.expect("rebase should succeed");
// Verify both files exist with expected content in the rebased worktree
let feat = std::fs::read_to_string(worktree_path.join("feat.txt")).unwrap();
let feat2 = std::fs::read_to_string(worktree_path.join("feat2.txt")).unwrap();
assert_eq!(feat, "feat change\n");
assert_eq!(feat2, "second change\n");
}
#[test]
fn merge_when_base_ahead_and_feature_ahead_fails() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
let repo = Repository::open(&repo_path).unwrap();
// Advance base (main) after feature was created
checkout_branch(&repo, "main");
write_file(&repo_path, "base_ahead.txt", "base ahead\n");
commit_all(&repo, "base ahead commit");
// Feature adds its own file (already has feat.txt from setup) and commit another
write_file(&worktree_path, "another.txt", "feature ahead\n");
let wt_repo = Repository::open(&worktree_path).unwrap();
commit_all(&wt_repo, "feature ahead extra");
let g = GitService::new();
let before_main = g.get_branch_oid(&repo_path, "main").unwrap();
// Attempt to merge (squash) into main - should fail because base is ahead
let service = GitService::new();
let res = service.merge_changes(
&repo_path,
&worktree_path,
"feature",
"main",
"squash merge",
);
assert!(
res.is_err(),
"merge should fail when base branch is ahead of task branch"
);
// Verify main branch was not modified
let after_main = g.get_branch_oid(&repo_path, "main").unwrap();
assert_eq!(
before_main, after_main,
"main ref should remain unchanged when merge fails"
);
}
#[test]
fn merge_conflict_does_not_move_base_ref() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_direct_conflict_repo(&td);
// Record main ref before
let _repo = Repository::open(&repo_path).unwrap();
let g = GitService::new();
let before = g.get_branch_oid(&repo_path, "main").unwrap();
let service = GitService::new();
let res = service.merge_changes(
&repo_path,
&worktree_path,
"feature",
"main",
"squash merge",
);
assert!(res.is_err(), "conflicting merge should fail");
let after = g.get_branch_oid(&repo_path, "main").unwrap();
assert_eq!(before, after, "main ref must remain unchanged on conflict");
}
#[test]
fn merge_delete_vs_modify_conflict_behaves_safely() {
// main modifies file, feature deletes it -> but now blocked by branch ahead check
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
let repo = Repository::open(&repo_path).unwrap();
// start from main with a file
checkout_branch(&repo, "main");
write_file(&repo_path, "conflict_dm.txt", "base\n");
commit_all(&repo, "add conflict file");
// feature deletes it and commits
let wt_repo = Repository::open(&worktree_path).unwrap();
let path = worktree_path.join("conflict_dm.txt");
if path.exists() {
std::fs::remove_file(&path).unwrap();
}
commit_all(&wt_repo, "delete in feature");
// main modifies same file (this puts main ahead of feature)
write_file(&repo_path, "conflict_dm.txt", "main modify\n");
commit_all(&repo, "modify in main");
// Capture main state AFTER all setup commits
let g = GitService::new();
let before = g.get_branch_oid(&repo_path, "main").unwrap();
let service = GitService::new();
let res = service.merge_changes(
&repo_path,
&worktree_path,
"feature",
"main",
"squash merge",
);
// Should now fail due to base branch being ahead, not due to merge conflicts
assert!(res.is_err(), "merge should fail when base branch is ahead");
// Ensure base ref unchanged on failure
let after = g.get_branch_oid(&repo_path, "main").unwrap();
assert_eq!(before, after, "main ref must remain unchanged on failure");
}
#[test]
fn rebase_preserves_rename_changes() {
// feature renames a file; rebase onto new-base preserves rename
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
// feature: rename feat.txt -> feat_renamed.txt
std::fs::rename(
worktree_path.join("feat.txt"),
worktree_path.join("feat_renamed.txt"),
)
.unwrap();
let wt_repo = Repository::open(&worktree_path).unwrap();
commit_all(&wt_repo, "rename feat");
// rebase onto new-base
let service = GitService::new();
let _ = service
.rebase_branch(
&repo_path,
&worktree_path,
Some("new-base"),
"old-base",
None,
)
.expect("rebase should succeed");
// after rebase, renamed file present; original absent
assert!(worktree_path.join("feat_renamed.txt").exists());
assert!(!worktree_path.join("feat.txt").exists());
}
#[test]
fn merge_refreshes_main_worktree_when_on_base() {
let td = TempDir::new().unwrap();
// Initialize repo and ensure main is checked out
let repo_path = td.path().join("repo_refresh");
let s = GitService::new();
s.initialize_repo_with_main_branch(&repo_path).unwrap();
s.configure_user(&repo_path, "Test User", "test@example.com")
.unwrap();
s.checkout_branch(&repo_path, "main").unwrap();
// Baseline file
write_file(&repo_path, "file.txt", "base\n");
let _ = s.commit(&repo_path, "add base").unwrap();
// Create feature branch and worktree
s.create_branch(&repo_path, "feature").unwrap();
let wt = td.path().join("wt_refresh");
s.add_worktree(&repo_path, &wt, "feature", false).unwrap();
// Modify file in worktree and commit
write_file(&wt, "file.txt", "feature change\n");
let _ = s.commit(&wt, "feature change").unwrap();
// Merge into main (squash) and ensure main worktree is updated since it is on base
let merge_sha = s
.merge_changes(&repo_path, &wt, "feature", "main", "squash")
.unwrap();
// Since main is on base branch and we use safe CLI merge, both working tree
// and ref should reflect the merged content.
let content = std::fs::read_to_string(repo_path.join("file.txt")).unwrap();
assert_eq!(content, "feature change\n");
let oid = s.get_branch_oid(&repo_path, "main").unwrap();
assert_eq!(oid, merge_sha);
}
#[test]
fn sparse_checkout_respected_in_worktree_diffs_and_commit() {
let td = TempDir::new().unwrap();
let repo_path = td.path().join("repo_sparse");
let s = GitService::new();
s.initialize_repo_with_main_branch(&repo_path).unwrap();
s.configure_user(&repo_path, "Test User", "test@example.com")
.unwrap();
s.checkout_branch(&repo_path, "main").unwrap();
// baseline content
write_file(&repo_path, "included/a.txt", "A\n");
write_file(&repo_path, "excluded/b.txt", "B\n");
let _ = s.commit(&repo_path, "baseline").unwrap();
// enable sparse-checkout for 'included' only
let cli = GitCli::new();
cli.git(&repo_path, ["sparse-checkout", "init", "--cone"])
.unwrap();
cli.git(&repo_path, ["sparse-checkout", "set", "included"])
.unwrap();
// create feature branch and worktree
s.create_branch(&repo_path, "feature").unwrap();
let wt = td.path().join("wt_sparse");
s.add_worktree(&repo_path, &wt, "feature", false).unwrap();
// materialization check: included exists, excluded does not
assert!(wt.join("included/a.txt").exists());
assert!(!wt.join("excluded/b.txt").exists());
// modify included file
write_file(&wt, "included/a.txt", "A-mod\n");
// get worktree diffs vs main, ensure excluded/b.txt is NOT reported deleted
let diffs = s
.get_diffs(
DiffTarget::Worktree {
worktree_path: Path::new(&wt),
branch_name: "feature",
base_branch: "main",
},
None,
)
.unwrap();
assert!(
diffs
.iter()
.any(|d| d.new_path.as_deref() == Some("included/a.txt"))
);
assert!(
!diffs
.iter()
.any(|d| d.old_path.as_deref() == Some("excluded/b.txt")
|| d.new_path.as_deref() == Some("excluded/b.txt"))
);
// commit and verify commit diffs also only include included/ changes
let _ = s.commit(&wt, "modify included").unwrap();
let head_sha = s.get_head_info(&wt).unwrap().oid;
let commit_diffs = s
.get_diffs(
DiffTarget::Commit {
repo_path: Path::new(&wt),
commit_sha: &head_sha,
},
None,
)
.unwrap();
assert!(
commit_diffs
.iter()
.any(|d| d.new_path.as_deref() == Some("included/a.txt"))
);
assert!(
commit_diffs
.iter()
.all(|d| d.new_path.as_deref() != Some("excluded/b.txt")
&& d.old_path.as_deref() != Some("excluded/b.txt"))
);
}
// Helper: initialize a repo with main, configure user via service
fn init_repo_only_service(root: &TempDir) -> PathBuf {
let repo_path = root.path().join("repo_svc");
let s = GitService::new();
s.initialize_repo_with_main_branch(&repo_path).unwrap();
s.configure_user(&repo_path, "Test User", "test@example.com")
.unwrap();
s.checkout_branch(&repo_path, "main").unwrap();
repo_path
}
#[test]
fn merge_binary_conflict_does_not_move_ref() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_only_service(&td);
let s = GitService::new();
// seed
let _ = s.commit(&repo_path, "seed").unwrap();
// create feature branch and worktree
s.create_branch(&repo_path, "feature").unwrap();
let worktree_path = td.path().join("wt_bin");
s.add_worktree(&repo_path, &worktree_path, "feature", false)
.unwrap();
// feature adds/commits binary file
let mut f = fs::File::create(worktree_path.join("bin.dat")).unwrap();
f.write_all(&[0, 1, 2, 3]).unwrap();
let _ = s.commit(&worktree_path, "feature bin").unwrap();
// main adds conflicting binary content
let mut f2 = fs::File::create(repo_path.join("bin.dat")).unwrap();
f2.write_all(&[9, 8, 7, 6]).unwrap();
let _ = s.commit(&repo_path, "main bin").unwrap();
let before = s.get_branch_oid(&repo_path, "main").unwrap();
let res = s.merge_changes(&repo_path, &worktree_path, "feature", "main", "merge bin");
assert!(res.is_err(), "binary conflict should fail");
let after = s.get_branch_oid(&repo_path, "main").unwrap();
assert_eq!(before, after, "main ref unchanged on conflict");
}
#[test]
fn merge_rename_vs_modify_conflict_does_not_move_ref() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_only_service(&td);
let s = GitService::new();
// base file
fs::write(repo_path.join("conflict.txt"), b"base\n").unwrap();
let _ = s.commit(&repo_path, "base").unwrap();
s.create_branch(&repo_path, "feature").unwrap();
let worktree_path = td.path().join("wt_ren");
s.add_worktree(&repo_path, &worktree_path, "feature", false)
.unwrap();
// feature renames file
std::fs::rename(
worktree_path.join("conflict.txt"),
worktree_path.join("conflict_renamed.txt"),
)
.unwrap();
let _ = s.commit(&worktree_path, "rename").unwrap();
// main modifies original path
fs::write(repo_path.join("conflict.txt"), b"main change\n").unwrap();
let _ = s.commit(&repo_path, "modify main").unwrap();
let before = s.get_branch_oid(&repo_path, "main").unwrap();
let res = s.merge_changes(
&repo_path,
&worktree_path,
"feature",
"main",
"merge rename",
);
match res {
Err(_) => {
let after = s.get_branch_oid(&repo_path, "main").unwrap();
assert_eq!(before, after, "main unchanged on conflict");
}
Ok(sha) => {
// ensure main advanced and result contains either renamed or modified content
let after = s.get_branch_oid(&repo_path, "main").unwrap();
assert_eq!(after, sha);
let diffs = s
.get_diffs(
DiffTarget::Commit {
repo_path: Path::new(&repo_path),
commit_sha: &after,
},
None,
)
.unwrap();
let has_renamed = diffs
.iter()
.any(|d| d.new_path.as_deref() == Some("conflict_renamed.txt"));
let has_modified = diffs.iter().any(|d| {
d.new_path.as_deref() == Some("conflict.txt")
&& d.new_content.as_deref() == Some("main change\n")
});
assert!(has_renamed || has_modified);
}
}
}
#[test]
fn merge_leaves_no_staged_changes_on_target_branch() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
// Ensure main repo is on the base branch (triggers CLI merge path)
let s = GitService::new();
s.checkout_branch(&repo_path, "main").unwrap();
// Feature branch makes some changes
write_file(&worktree_path, "feature_file.txt", "feature content\n");
write_file(&worktree_path, "common.txt", "modified by feature\n");
let wt_repo = Repository::open(&worktree_path).unwrap();
commit_all(&wt_repo, "feature changes");
// Perform the merge
let _merge_sha = s
.merge_changes(
&repo_path,
&worktree_path,
"feature",
"main",
"merge feature",
)
.expect("merge should succeed");
// THE KEY CHECK: Verify no staged changes remain on target branch
let git_cli = GitCli::new();
let has_staged = git_cli
.has_staged_changes(&repo_path)
.expect("should be able to check staged changes");
assert!(
!has_staged,
"Target branch should have no staged changes after merge"
);
// Debug info if test fails
if has_staged {
let status_output = git_cli.git(&repo_path, ["status", "--porcelain"]).unwrap();
panic!("Found staged changes after merge:\n{status_output}");
}
}
#[test]
fn worktree_to_worktree_merge_leaves_no_staged_changes() {
let td = TempDir::new().unwrap();
let repo_path = td.path().join("repo");
let worktree_a_path = td.path().join("wt-feature-a");
let worktree_b_path = td.path().join("wt-feature-b");
// Setup: Initialize repo with main branch
let service = GitService::new();
service
.initialize_repo_with_main_branch(&repo_path)
.expect("init repo");
let repo = Repository::open(&repo_path).unwrap();
configure_user(&repo);
checkout_branch(&repo, "main");
write_file(&repo_path, "base.txt", "base content\n");
commit_all(&repo, "initial commit");
// Create two feature branches
create_branch_from_head(&repo, "feature-a");
create_branch_from_head(&repo, "feature-b");
// Create worktrees for both feature branches
service
.add_worktree(&repo_path, &worktree_a_path, "feature-a", false)
.expect("create worktree A");
service
.add_worktree(&repo_path, &worktree_b_path, "feature-b", false)
.expect("create worktree B");
// Make changes in worktree A
write_file(
&worktree_a_path,
"feature_a.txt",
"content from feature A\n",
);
write_file(&worktree_a_path, "base.txt", "modified by feature A\n");
let wt_a_repo = Repository::open(&worktree_a_path).unwrap();
commit_all(&wt_a_repo, "feature A changes");
// Ensure main repo is on different branch (neither feature-a nor feature-b)
checkout_branch(&repo, "main");
let _sha = service.merge_changes(
&repo_path,
&worktree_a_path,
"feature-a",
"feature-b",
"merge feature-a into feature-b",
);
// Verify no staged changes were introduced
let git_cli = GitCli::new();
let has_staged_main = git_cli
.has_staged_changes(&repo_path)
.expect("should be able to check staged changes in main repo");
let has_staged_target = git_cli
.has_staged_changes(&worktree_b_path)
.expect("should be able to check staged changes in target worktree");
assert!(
!has_staged_main,
"Main repo should have no staged changes after failed merge"
);
assert!(
!has_staged_target,
"Target worktree should have no staged changes after failed merge"
);
}
#[test]
fn merge_into_orphaned_branch_uses_libgit2_fallback() {
let td = TempDir::new().unwrap();
let (repo_path, worktree_path) = setup_repo_with_worktree(&td);
// Create an "orphaned" target branch that exists as ref but isn't checked out anywhere
let service = GitService::new();
let repo = Repository::open(&repo_path).unwrap();
// Create orphaned-feature branch from current main HEAD but don't check it out
let main_commit = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("orphaned-feature", &main_commit, false)
.unwrap();
// Ensure main repo is on different branch and no worktree has orphaned-feature
service.checkout_branch(&repo_path, "main").unwrap();
// Make changes in source worktree
write_file(
&worktree_path,
"feature_content.txt",
"content from feature\n",
);
let wt_repo = Repository::open(&worktree_path).unwrap();
commit_all(&wt_repo, "feature changes");
// orphaned-feature is not checked out anywhere, so should trigger libgit2 path
// Perform merge into orphaned branch (should use libgit2 fallback)
let merge_sha = service
.merge_changes(
&repo_path,
&worktree_path,
"feature",
"orphaned-feature",
"merge into orphaned branch",
)
.expect("libgit2 merge into orphaned branch should succeed");
// Verify merge worked - orphaned-feature branch should now point to merge commit
let orphaned_branch_oid = service
.get_branch_oid(&repo_path, "orphaned-feature")
.unwrap();
assert_eq!(
orphaned_branch_oid, merge_sha,
"orphaned-feature branch should point to merge commit"
);
// Verify no working tree was affected (since branch wasn't checked out anywhere)
let main_git_cli = GitCli::new();
let main_has_staged = main_git_cli.has_staged_changes(&repo_path).unwrap();
let worktree_has_staged = main_git_cli.has_staged_changes(&worktree_path).unwrap();
assert!(
!main_has_staged,
"Main repo should remain clean after libgit2 merge"
);
assert!(
!worktree_has_staged,
"Source worktree should remain clean after libgit2 merge"
);
}
#[test]
fn merge_base_ahead_of_task_should_error() {
let td = TempDir::new().unwrap();
let repo_path = td.path().join("repo");
let worktree_path = td.path().join("wt-feature");
// Setup: Initialize repo with main branch
let service = GitService::new();
service
.initialize_repo_with_main_branch(&repo_path)
.expect("init repo");
let repo = Repository::open(&repo_path).unwrap();
configure_user(&repo);
checkout_branch(&repo, "main");
// Initial commit on main
write_file(&repo_path, "base.txt", "initial content\n");
commit_all(&repo, "initial commit");
// Create feature branch from this point
create_branch_from_head(&repo, "feature");
service
.add_worktree(&repo_path, &worktree_path, "feature", false)
.expect("create worktree");
// Feature makes a change and commits
write_file(&worktree_path, "feature.txt", "feature content\n");
let wt_repo = Repository::open(&worktree_path).unwrap();
commit_all(&wt_repo, "feature change");
// Main branch advances ahead of feature (this is the key scenario)
checkout_branch(&repo, "main");
write_file(&repo_path, "main_advance.txt", "main advanced\n");
commit_all(&repo, "main advances ahead");
write_file(&repo_path, "main_advance2.txt", "main advanced more\n");
commit_all(&repo, "main advances further");
// Attempt to merge feature into main when main is ahead
// This should error because base branch has moved ahead of task branch
let res = service.merge_changes(
&repo_path,
&worktree_path,
"feature",
"main",
"attempt merge when base ahead",
);
// TDD: This test will initially fail because merge currently succeeds
// Later we'll fix the merge logic to detect this scenario and error
assert!(
res.is_err(),
"Merge should error when base branch is ahead of task branch"
);
}