Files
vibe-kanban/crates/services/tests/git_workflow.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

647 lines
21 KiB
Rust

use std::{
fs,
io::Write,
path::{Path, PathBuf},
};
use services::services::{
git::{DiffTarget, GitService},
github_service::{GitHubRepoInfo, GitHubServiceError},
};
use tempfile::TempDir;
use utils::diff::DiffChangeKind;
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 init_repo_main(root: &TempDir) -> PathBuf {
let path = root.path().join("repo");
let s = GitService::new();
s.initialize_repo_with_main_branch(&path).unwrap();
s.configure_user(&path, "Test User", "test@example.com")
.unwrap();
s.checkout_branch(&path, "main").unwrap();
path
}
#[test]
fn commit_empty_message_behaviour() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
write_file(&repo_path, "x.txt", "x\n");
let s = GitService::new();
let res = s.commit(&repo_path, "");
// Some environments disallow empty commit messages by default.
// Accept either success or a clear error.
if let Err(e) = &res {
let msg = format!("{e}");
assert!(msg.contains("empty commit message") || msg.contains("git commit failed"));
}
}
fn has_global_git_identity() -> bool {
if let Ok(cfg) = git2::Config::open_default() {
let has_name = cfg.get_string("user.name").is_ok();
let has_email = cfg.get_string("user.email").is_ok();
return has_name && has_email;
}
false
}
#[test]
fn initialize_repo_without_user_creates_initial_commit() {
let td = TempDir::new().unwrap();
let repo_path = td.path().join("repo_no_user_init");
let s = GitService::new();
// No configure_user call; rely on fallback signature for initial commit
s.initialize_repo_with_main_branch(&repo_path).unwrap();
let head = s.get_head_info(&repo_path).unwrap();
assert_eq!(head.branch, "main");
assert!(!head.oid.is_empty());
// Verify author is set: either global identity (if configured) or fallback
let (name, email) = s.get_head_author(&repo_path).unwrap();
if has_global_git_identity() {
assert!(name.is_some() && email.is_some());
} else {
assert_eq!(name.as_deref(), Some("Vibe Kanban"));
assert_eq!(email.as_deref(), Some("noreply@vibekanban.com"));
}
}
#[test]
fn commit_without_user_config_succeeds() {
let td = TempDir::new().unwrap();
let repo_path = td.path().join("repo_no_user");
let s = GitService::new();
s.initialize_repo_with_main_branch(&repo_path).unwrap();
write_file(&repo_path, "f.txt", "x\n");
// No configure_user call here
let res = s.commit(&repo_path, "no user config");
assert!(res.is_ok());
}
#[test]
fn commit_fails_when_index_locked() {
use std::fs::File;
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
write_file(&repo_path, "y.txt", "y\n");
// Simulate index lock
let git_dir = repo_path.join(".git");
let _lock = File::create(git_dir.join("index.lock")).unwrap();
let s = GitService::new();
let res = s.commit(&repo_path, "should fail");
assert!(res.is_err());
}
#[test]
fn staged_but_uncommitted_changes_is_dirty() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
let s = GitService::new();
// seed tracked file
write_file(&repo_path, "t1.txt", "a\n");
let _ = s.commit(&repo_path, "seed").unwrap();
// modify and stage
write_file(&repo_path, "t1.txt", "b\n");
s.add_path(&repo_path, "t1.txt").unwrap();
assert!(!s.is_worktree_clean(&repo_path).unwrap());
}
#[test]
fn delete_nonexistent_file_creates_noop_commit() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
// baseline commit first so we have HEAD
write_file(&repo_path, "seed.txt", "s\n");
let s = GitService::new();
let _ = s.commit(&repo_path, "seed").unwrap();
let before = s.get_head_info(&repo_path).unwrap().oid;
let res = s.delete_file_and_commit(&repo_path, "nope.txt").unwrap();
let after = s.get_head_info(&repo_path).unwrap().oid;
assert_ne!(before, after);
assert_eq!(after, res);
}
#[test]
fn delete_directory_path_errors() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
// create and commit a file so repo has history
write_file(&repo_path, "dir/file.txt", "z\n");
let s = GitService::new();
let _ = s.commit(&repo_path, "add file").unwrap();
// directory path should cause an error
let s = GitService::new();
let res = s.delete_file_and_commit(&repo_path, "dir");
assert!(res.is_err());
}
#[test]
fn worktree_clean_detects_staged_deleted_and_renamed() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
write_file(&repo_path, "t1.txt", "1\n");
write_file(&repo_path, "t2.txt", "2\n");
let s = GitService::new();
let _ = s.commit(&repo_path, "seed").unwrap();
// delete tracked file
std::fs::remove_file(repo_path.join("t2.txt")).unwrap();
assert!(!s.is_worktree_clean(&repo_path).unwrap());
// restore and test rename
write_file(&repo_path, "t2.txt", "2\n");
let _ = s.commit(&repo_path, "restore t2").unwrap();
std::fs::rename(repo_path.join("t2.txt"), repo_path.join("t2-renamed.txt")).unwrap();
assert!(!s.is_worktree_clean(&repo_path).unwrap());
}
#[test]
fn diff_added_binary_file_has_no_content() {
// ensure binary file content is not loaded (null byte guard)
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
// base
let s = GitService::new();
let _ = s.commit(&repo_path, "base").unwrap();
// branch with binary file
s.create_branch(&repo_path, "feature").unwrap();
s.checkout_branch(&repo_path, "feature").unwrap();
// write binary with null byte
let mut f = fs::File::create(repo_path.join("bin.dat")).unwrap();
f.write_all(&[0u8, 1, 2, 3]).unwrap();
let _ = s.commit(&repo_path, "add binary").unwrap();
let s = GitService::new();
let diffs = s
.get_diffs(
DiffTarget::Branch {
repo_path: Path::new(&repo_path),
branch_name: "feature",
base_branch: "main",
},
None,
)
.unwrap();
let bin = diffs
.iter()
.find(|d| d.new_path.as_deref() == Some("bin.dat"))
.expect("binary diff present");
assert!(bin.new_content.is_none());
}
#[test]
fn initialize_and_default_branch_and_head_info() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
// Default branch should be main
let s = GitService::new();
let def = s.get_default_branch_name(&repo_path).unwrap();
assert_eq!(def, "main");
// Head info branch should be main
let head = s.get_head_info(&repo_path).unwrap();
assert_eq!(head.branch, "main");
// Repo has an initial commit (OID parsable)
assert!(!head.oid.is_empty());
}
#[test]
fn commit_and_is_worktree_clean() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
write_file(&repo_path, "foo.txt", "hello\n");
let s = GitService::new();
let committed = s.commit(&repo_path, "add foo").unwrap();
assert!(committed);
assert!(s.is_worktree_clean(&repo_path).unwrap());
// Verify commit contains file
let diffs = s
.get_diffs(
DiffTarget::Commit {
repo_path: Path::new(&repo_path),
commit_sha: &s.get_head_info(&repo_path).unwrap().oid,
},
None,
)
.unwrap();
assert!(
diffs
.iter()
.any(|d| d.new_path.as_deref() == Some("foo.txt"))
);
}
#[test]
fn commit_in_detached_head_succeeds_via_service() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
// initial parent
write_file(&repo_path, "a.txt", "a\n");
let s = GitService::new();
let _ = s.commit(&repo_path, "add a").unwrap();
// detach via service
s.detach_head_current(&repo_path).unwrap();
// commit while detached
write_file(&repo_path, "b.txt", "b\n");
let ok = s.commit(&repo_path, "detached commit").unwrap();
assert!(ok);
}
#[test]
fn branch_status_ahead_and_behind() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
let s = GitService::new();
// main: initial commit
write_file(&repo_path, "base.txt", "base\n");
let _ = s.commit(&repo_path, "base").unwrap();
// create feature from main
s.create_branch(&repo_path, "feature").unwrap();
// advance feature by 1
s.checkout_branch(&repo_path, "feature").unwrap();
write_file(&repo_path, "feature.txt", "f1\n");
let _ = s.commit(&repo_path, "f1").unwrap();
// advance main by 1
s.checkout_branch(&repo_path, "main").unwrap();
write_file(&repo_path, "main.txt", "m1\n");
let _ = s.commit(&repo_path, "m1").unwrap();
let s = GitService::new();
let (ahead, behind) = s.get_branch_status(&repo_path, "feature", "main").unwrap();
assert_eq!((ahead, behind), (1, 1));
// advance feature by one more (ahead 2, behind 1)
s.checkout_branch(&repo_path, "feature").unwrap();
write_file(&repo_path, "feature2.txt", "f2\n");
let _ = s.commit(&repo_path, "f2").unwrap();
let (ahead2, behind2) = s.get_branch_status(&repo_path, "feature", "main").unwrap();
assert_eq!((ahead2, behind2), (2, 1));
}
#[test]
fn get_all_branches_lists_current_and_others() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
let s = GitService::new();
s.create_branch(&repo_path, "feature").unwrap();
let s = GitService::new();
let branches = s.get_all_branches(&repo_path).unwrap();
let names: Vec<_> = branches.iter().map(|b| b.name.as_str()).collect();
assert!(names.contains(&"main"));
assert!(names.contains(&"feature"));
// current should be main
let main_entry = branches.iter().find(|b| b.name == "main").unwrap();
assert!(main_entry.is_current);
}
#[test]
fn delete_file_and_commit_creates_new_commit() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
write_file(&repo_path, "to_delete.txt", "bye\n");
let s = GitService::new();
let _ = s.commit(&repo_path, "add to_delete").unwrap();
let before = s.get_head_info(&repo_path).unwrap().oid;
let new_commit = s
.delete_file_and_commit(&repo_path, "to_delete.txt")
.unwrap();
let after = s.get_head_info(&repo_path).unwrap().oid;
assert_ne!(before, after);
assert_eq!(after, new_commit);
assert!(!repo_path.join("to_delete.txt").exists());
}
#[test]
fn get_github_repo_info_parses_origin() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
let s = GitService::new();
s.set_remote(&repo_path, "origin", "https://github.com/foo/bar.git")
.unwrap();
let info = s.get_github_repo_info(&repo_path).unwrap();
assert_eq!(info.owner, "foo");
assert_eq!(info.repo_name, "bar");
}
#[test]
fn get_branch_diffs_between_branches() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
let s = GitService::new();
// base commit on main
write_file(&repo_path, "a.txt", "a\n");
let _ = s.commit(&repo_path, "add a").unwrap();
// create branch and add new file
s.create_branch(&repo_path, "feature").unwrap();
s.checkout_branch(&repo_path, "feature").unwrap();
write_file(&repo_path, "b.txt", "b\n");
let _ = s.commit(&repo_path, "add b").unwrap();
let s = GitService::new();
let diffs = s
.get_diffs(
DiffTarget::Branch {
repo_path: Path::new(&repo_path),
branch_name: "feature",
base_branch: "main",
},
None,
)
.unwrap();
assert!(diffs.iter().any(|d| d.new_path.as_deref() == Some("b.txt")));
}
#[test]
fn worktree_diff_respects_path_filter() {
// Use git CLI status diff under the hood
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
// main baseline
write_file(&repo_path, "src/keep.txt", "k\n");
write_file(&repo_path, "other/skip.txt", "s\n");
let s = GitService::new();
let _ = s.commit(&repo_path, "baseline").unwrap();
// create feature and work in place (worktree is repo_path)
s.create_branch(&repo_path, "feature").unwrap();
// modify files without committing
write_file(&repo_path, "src/only.txt", "only\n");
write_file(&repo_path, "other/skip2.txt", "skip\n");
let s = GitService::new();
let diffs = s
.get_diffs(
DiffTarget::Worktree {
worktree_path: Path::new(&repo_path),
branch_name: "feature",
base_branch: "main",
},
Some(&["src"]),
)
.unwrap();
assert!(
diffs
.iter()
.any(|d| d.new_path.as_deref() == Some("src/only.txt"))
);
assert!(
!diffs
.iter()
.any(|d| d.new_path.as_deref() == Some("other/skip2.txt"))
);
}
#[test]
fn get_branch_oid_nonexistent_errors() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
let s = GitService::new();
let res = s.get_branch_oid(&repo_path, "no-such-branch");
assert!(res.is_err());
}
#[test]
fn create_unicode_branch_and_list() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
let s = GitService::new();
// base commit
write_file(&repo_path, "file.txt", "ok\n");
let _ = s.commit(&repo_path, "base");
// unicode/slash branch name (valid ref)
let bname = "feature/ünicode";
s.create_branch(&repo_path, bname).unwrap();
let names: Vec<_> = s
.get_all_branches(&repo_path)
.unwrap()
.into_iter()
.map(|b| b.name)
.collect();
assert!(names.iter().any(|n| n == bname));
}
#[cfg(unix)]
#[test]
fn worktree_diff_permission_only_change() {
use std::os::unix::fs::PermissionsExt;
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
let s = GitService::new();
// baseline commit
write_file(&repo_path, "p.sh", "echo hi\n");
let _ = s.commit(&repo_path, "add p.sh").unwrap();
// create a feature branch baseline at HEAD
s.create_branch(&repo_path, "feature").unwrap();
// change only the permission (chmod +x)
let mut perms = std::fs::metadata(repo_path.join("p.sh"))
.unwrap()
.permissions();
perms.set_mode(perms.mode() | 0o111);
std::fs::set_permissions(repo_path.join("p.sh"), perms).unwrap();
// Compute worktree diff vs main on feature
let diffs = s
.get_diffs(
DiffTarget::Worktree {
worktree_path: Path::new(&repo_path),
branch_name: "feature",
base_branch: "main",
},
None,
)
.unwrap();
let d = diffs
.into_iter()
.find(|d| d.new_path.as_deref() == Some("p.sh"))
.expect("p.sh diff present");
assert!(matches!(d.change, DiffChangeKind::PermissionChange));
assert_eq!(d.old_content, d.new_content);
}
#[test]
fn delete_with_uncommitted_changes_succeeds() {
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
let s = GitService::new();
// baseline file and commit
write_file(&repo_path, "d.txt", "v1\n");
let _ = s.commit(&repo_path, "add d").unwrap();
let before = s.get_head_info(&repo_path).unwrap().oid;
// uncommitted change
write_file(&repo_path, "d.txt", "v2\n");
// delete and commit
let new_sha = s.delete_file_and_commit(&repo_path, "d.txt").unwrap();
assert_eq!(s.get_head_info(&repo_path).unwrap().oid, new_sha);
assert!(!repo_path.join("d.txt").exists());
assert_ne!(before, new_sha);
}
#[cfg(unix)]
#[test]
fn delete_symlink_and_commit() {
use std::os::unix::fs::symlink;
let td = TempDir::new().unwrap();
let repo_path = init_repo_main(&td);
let s = GitService::new();
// Create target and symlink, commit
write_file(&repo_path, "target.txt", "t\n");
let _ = s.commit(&repo_path, "add target").unwrap();
symlink(repo_path.join("target.txt"), repo_path.join("link.txt")).unwrap();
let _ = s.commit(&repo_path, "add symlink").unwrap();
let before = s.get_head_info(&repo_path).unwrap().oid;
// Delete symlink
let new_sha = s.delete_file_and_commit(&repo_path, "link.txt").unwrap();
assert_eq!(s.get_head_info(&repo_path).unwrap().oid, new_sha);
assert!(!repo_path.join("link.txt").exists());
assert_ne!(before, new_sha);
}
#[test]
fn delete_file_commit_has_author_without_user() {
// Verify libgit2 path uses fallback author when no config exists
let td = TempDir::new().unwrap();
let repo_path = td.path().join("repo_fallback_delete");
let s = GitService::new();
// No configure_user call; initial commit uses fallback signature too
s.initialize_repo_with_main_branch(&repo_path).unwrap();
// Create then delete an untracked file via service
write_file(&repo_path, "q.txt", "temp\n");
let sha = s.delete_file_and_commit(&repo_path, "q.txt").unwrap();
// Author should be present: either global identity or fallback
let (name, email) = s.get_commit_author(&repo_path, &sha).unwrap();
if has_global_git_identity() {
assert!(name.is_some() && email.is_some());
} else {
assert_eq!(name.as_deref(), Some("Vibe Kanban"));
assert_eq!(email.as_deref(), Some("noreply@vibekanban.com"));
}
}
#[test]
fn convert_to_https_url_handles_common_git_forms() {
let svc = GitService::new();
let ssh_url = "git@github.com:owner/repo.git";
assert_eq!(
svc.convert_to_https_url(ssh_url),
"https://github.com/owner/repo.git"
);
let ssh_scheme_url = "ssh://git@github.com/owner/repo";
assert_eq!(
svc.convert_to_https_url(ssh_scheme_url),
"https://github.com/owner/repo.git"
);
let https_without_suffix = "https://github.com/owner/repo";
assert_eq!(
svc.convert_to_https_url(https_without_suffix),
"https://github.com/owner/repo.git"
);
let converted = svc.convert_to_https_url("https://github.com/owner/repo/");
assert_eq!(converted, "https://github.com/owner/repo.git");
}
#[test]
fn github_repo_info_parses_https_and_ssh_urls() {
let info = GitHubRepoInfo::from_remote_url("https://github.com/owner/repo.git").unwrap();
assert_eq!(info.owner, "owner");
assert_eq!(info.repo_name, "repo");
let info = GitHubRepoInfo::from_remote_url("git@github.com:owner/repo.git").unwrap();
assert_eq!(info.owner, "owner");
assert_eq!(info.repo_name, "repo");
let info = GitHubRepoInfo::from_remote_url("https://github.com/owner/repo/pull/123").unwrap();
assert_eq!(info.owner, "owner");
assert_eq!(info.repo_name, "repo");
let err = GitHubRepoInfo::from_remote_url("https://example.com/not/github").unwrap_err();
match err {
GitHubServiceError::Repository(msg) => assert!(msg.contains("Invalid GitHub URL")),
other => panic!("unexpected error variant: {other:?}"),
}
}
#[test]
fn squash_merge_libgit2_sets_author_without_user() {
// Verify merge_changes (libgit2 path) uses fallback author when no config exists
use git2::Repository;
let td = TempDir::new().unwrap();
let repo_path = td.path().join("repo_fallback_merge");
let worktree_path = td.path().join("wt_feature");
let s = GitService::new();
// Init repo without user config
s.initialize_repo_with_main_branch(&repo_path).unwrap();
// Create feature branch and worktree
s.create_branch(&repo_path, "feature").unwrap();
s.add_worktree(&repo_path, &worktree_path, "feature", false)
.unwrap();
// Make a feature commit in the worktree via libgit2 using an explicit signature
write_file(&worktree_path, "f.txt", "feat\n");
{
let repo = Repository::open(&worktree_path).unwrap();
// stage all
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 = git2::Signature::now("Other Author", "other@example.com").unwrap();
let parent = repo.head().unwrap().peel_to_commit().unwrap();
let _cid = repo
.commit(Some("HEAD"), &sig, &sig, "feat", &tree, &[&parent])
.unwrap();
}
// Ensure main repo is NOT on base branch so merge_changes takes libgit2 path
s.create_branch(&repo_path, "dev").unwrap();
s.checkout_branch(&repo_path, "dev").unwrap();
// Merge feature -> main (libgit2 squash)
let merge_sha = s
.merge_changes(&repo_path, &worktree_path, "feature", "main", "squash")
.unwrap();
// The squash commit author should not be the feature commit's author, and must be present.
let (name, email) = s.get_commit_author(&repo_path, &merge_sha).unwrap();
assert_ne!(name.as_deref(), Some("Other Author"));
assert_ne!(email.as_deref(), Some("other@example.com"));
if has_global_git_identity() {
assert!(name.is_some() && email.is_some());
} else {
assert_eq!(name.as_deref(), Some("Vibe Kanban"));
assert_eq!(email.as_deref(), Some("noreply@vibekanban.com"));
}
}