* Remove unused delete file endpoint, move test helpers to test files Fix missing git id in tests * Remove unused gh cli methods * Remove unused replace_process * Fix open editor exports, remove unused struct Fix compile * Remove unused get_tasks endpoint Re-add get tasks, used by mcp * Remove unused get_execution_processes endpoint * Remove unused get tag endpoint
547 lines
18 KiB
Rust
547 lines
18 KiB
Rust
use std::{
|
|
fs,
|
|
io::Write,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use git2::{Repository, build::CheckoutBuilder};
|
|
use services::services::{
|
|
git::{DiffTarget, GitCli, GitService},
|
|
github::{GitHubRepoInfo, GitHubServiceError},
|
|
};
|
|
use tempfile::TempDir;
|
|
use utils::diff::DiffChangeKind;
|
|
|
|
fn add_path(repo_path: &Path, path: &str) {
|
|
let git = GitCli::new();
|
|
git.git(repo_path, ["add", path]).unwrap();
|
|
}
|
|
|
|
fn get_commit_author(repo_path: &Path, commit_sha: &str) -> (Option<String>, Option<String>) {
|
|
let repo = git2::Repository::open(repo_path).unwrap();
|
|
let oid = git2::Oid::from_str(commit_sha).unwrap();
|
|
let commit = repo.find_commit(oid).unwrap();
|
|
let author = commit.author();
|
|
(
|
|
author.name().map(|s| s.to_string()),
|
|
author.email().map(|s| s.to_string()),
|
|
)
|
|
}
|
|
|
|
fn get_head_author(repo_path: &Path) -> (Option<String>, Option<String>) {
|
|
let repo = git2::Repository::open(repo_path).unwrap();
|
|
let head = repo.head().unwrap();
|
|
let oid = head.target().unwrap();
|
|
let commit = repo.find_commit(oid).unwrap();
|
|
let author = commit.author();
|
|
(
|
|
author.name().map(|s| s.to_string()),
|
|
author.email().map(|s| s.to_string()),
|
|
)
|
|
}
|
|
|
|
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 configure_user(repo_path: &Path, name: &str, email: &str) {
|
|
let repo = git2::Repository::open(repo_path).unwrap();
|
|
let mut cfg = repo.config().unwrap();
|
|
cfg.set_str("user.name", name).unwrap();
|
|
cfg.set_str("user.email", email).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();
|
|
configure_user(&path, "Test User", "test@example.com");
|
|
checkout_branch(&path, "main");
|
|
path
|
|
}
|
|
|
|
fn checkout_branch(repo_path: &Path, name: &str) {
|
|
let repo = Repository::open(repo_path).unwrap();
|
|
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(repo_path: &Path, name: &str) {
|
|
let repo = Repository::open(repo_path).unwrap();
|
|
let head = repo.head().unwrap().peel_to_commit().unwrap();
|
|
let _ = repo.branch(name, &head, true).unwrap();
|
|
}
|
|
|
|
#[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) = get_head_author(&repo_path);
|
|
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");
|
|
add_path(&repo_path, "t1.txt");
|
|
assert!(!s.is_worktree_clean(&repo_path).unwrap());
|
|
}
|
|
|
|
#[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
|
|
create_branch(&repo_path, "feature");
|
|
checkout_branch(&repo_path, "feature");
|
|
// 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);
|
|
|
|
let s = GitService::new();
|
|
// 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
|
|
let repo = git2::Repository::open(&repo_path).unwrap();
|
|
let oid = repo.head().unwrap().target().unwrap();
|
|
repo.set_head_detached(oid).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
|
|
create_branch(&repo_path, "feature");
|
|
// advance feature by 1
|
|
checkout_branch(&repo_path, "feature");
|
|
write_file(&repo_path, "feature.txt", "f1\n");
|
|
let _ = s.commit(&repo_path, "f1").unwrap();
|
|
|
|
// advance main by 1
|
|
checkout_branch(&repo_path, "main");
|
|
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)
|
|
checkout_branch(&repo_path, "feature");
|
|
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);
|
|
create_branch(&repo_path, "feature");
|
|
|
|
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 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
|
|
create_branch(&repo_path, "feature");
|
|
checkout_branch(&repo_path, "feature");
|
|
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)
|
|
create_branch(&repo_path, "feature");
|
|
|
|
// 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 base_commit = s.get_base_commit(&repo_path, "feature", "main").unwrap();
|
|
let diffs = s
|
|
.get_diffs(
|
|
DiffTarget::Worktree {
|
|
worktree_path: Path::new(&repo_path),
|
|
base_commit: &base_commit,
|
|
},
|
|
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";
|
|
create_branch(&repo_path, bname);
|
|
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
|
|
create_branch(&repo_path, "feature");
|
|
|
|
// 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();
|
|
|
|
let base_commit = s.get_base_commit(&repo_path, "feature", "main").unwrap();
|
|
// Compute worktree diff vs main on feature
|
|
let diffs = s
|
|
.get_diffs(
|
|
DiffTarget::Worktree {
|
|
worktree_path: Path::new(&repo_path),
|
|
base_commit: &base_commit,
|
|
},
|
|
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 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
|
|
create_branch(&repo_path, "feature");
|
|
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
|
|
create_branch(&repo_path, "dev");
|
|
checkout_branch(&repo_path, "dev");
|
|
|
|
// 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) = get_commit_author(&repo_path, &merge_sha);
|
|
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"));
|
|
}
|
|
}
|