From 820fd706a24be7a58554a27e411e120cfa28d9a6 Mon Sep 17 00:00:00 2001 From: m0wer Date: Sun, 29 Mar 2026 16:45:52 +0200 Subject: feat: git worktree support Git worktrees don't have a .git directory with a parent, so we need to look for the git dir via git2's Repository::discover() and then look for the cache database there. This allows the client to work correctly when run from a worktree, and also allows the cache database to be shared between the main repo and its worktrees (since they share the same git dir and thus the same cache path). --- src/lib/client.rs | 14 ++++-- src/lib/git/mod.rs | 133 ++++++++++++++++++++++++++++++++++++++++++++++++-- test_utils/src/git.rs | 43 ++++++++++++++++ test_utils/src/lib.rs | 8 ++- 4 files changed, 189 insertions(+), 9 deletions(-) diff --git a/src/lib/client.rs b/src/lib/client.rs index 01662cd..86e4097 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs @@ -1325,14 +1325,22 @@ fn pb_after_style(succeed: bool) -> indicatif::ProgressStyle { } async fn get_local_cache_database(git_repo_path: &Path) -> Result { - NostrLMDB::open(git_repo_path.join(".git/nostr-cache.lmdb")) - .context("failed to open or create nostr cache database at .git/nostr-cache.lmdb") + let git_dir = git2::Repository::discover(git_repo_path) + .context("failed to discover git repository")? + .commondir() + .to_path_buf(); + NostrLMDB::open(git_dir.join("nostr-cache.lmdb")) + .context("failed to open or create nostr cache database at /nostr-cache.lmdb") } async fn get_global_cache_database(git_repo_path: Option<&Path>) -> Result { let path = if std::env::var("NGITTEST").is_ok() { if let Some(git_repo_path) = git_repo_path { - git_repo_path.join(".git/test-global-cache.lmdb") + let git_dir = git2::Repository::discover(git_repo_path) + .context("failed to discover git repository")? + .commondir() + .to_path_buf(); + git_dir.join("test-global-cache.lmdb") } else { bail!("git_repo must be supplied to get_global_cache_database during integration tests") } diff --git a/src/lib/git/mod.rs b/src/lib/git/mod.rs index 641349c..0001ca1 100644 --- a/src/lib/git/mod.rs +++ b/src/lib/git/mod.rs @@ -105,10 +105,9 @@ pub trait RepoActions { impl RepoActions for Repo { fn get_path(&self) -> Result<&Path> { - self.git_repo - .path() - .parent() - .context("failed to find repositiory path as .git has no parent") + self.git_repo.workdir().context( + "failed to find repository working directory (bare repositories are not supported)", + ) } fn get_origin_url(&self) -> Result { @@ -2848,4 +2847,130 @@ index ce01362..a21e91c 100644\n\ } } + mod worktree { + use super::*; + + #[test] + fn get_path_returns_worktree_working_dir() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + + let worktree_repo = test_repo.create_worktree("wt-branch")?; + let git_repo = Repo::from_path(&worktree_repo.dir)?; + + let path = git_repo.get_path()?; + // get_path() should return the worktree's working directory, not + // somewhere inside the main repo's .git/worktrees/ + assert_eq!(path.canonicalize()?, worktree_repo.dir.canonicalize()?,); + Ok(()) + } + + #[test] + fn get_path_returns_normal_repo_working_dir() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + let path = git_repo.get_path()?; + assert_eq!(path.canonicalize()?, test_repo.dir.canonicalize()?,); + Ok(()) + } + + #[test] + fn from_path_works_with_worktree_dir() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + + let worktree_repo = test_repo.create_worktree("wt-open")?; + // Opening from the worktree's working directory should succeed + let git_repo = Repo::from_path(&worktree_repo.dir)?; + // And get_path() should return the worktree dir + assert_eq!( + git_repo.get_path()?.canonicalize()?, + worktree_repo.dir.canonicalize()?, + ); + Ok(()) + } + + #[test] + fn worktree_can_read_branches() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + + let worktree_repo = test_repo.create_worktree("wt-branches")?; + let git_repo = Repo::from_path(&worktree_repo.dir)?; + + let branches = git_repo.get_local_branch_names()?; + assert!(branches.contains(&"main".to_string())); + assert!(branches.contains(&"wt-branches".to_string())); + Ok(()) + } + + #[test] + fn worktree_can_read_git_config() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + + let worktree_repo = test_repo.create_worktree("wt-config")?; + let git_repo = Repo::from_path(&worktree_repo.dir)?; + + // nostr.repo is set by GitTestRepo::default() on the main repo + // and should be readable from the worktree since config is shared + let nostr_repo = git_repo.get_git_config_item("nostr.repo", None)?; + assert!(nostr_repo.is_some()); + Ok(()) + } + + #[test] + fn worktree_get_head_commit_works() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + + let worktree_repo = test_repo.create_worktree("wt-head")?; + let git_repo = Repo::from_path(&worktree_repo.dir)?; + + // Should not error - worktree has its own HEAD + let _head = git_repo.get_head_commit()?; + Ok(()) + } + + #[test] + fn worktree_files_accessible_from_get_path() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + + let worktree_repo = test_repo.create_worktree("wt-files")?; + let git_repo = Repo::from_path(&worktree_repo.dir)?; + + // Create a file in the worktree + let test_file = worktree_repo.dir.join("worktree-test.txt"); + fs::write(&test_file, "hello from worktree")?; + + // get_path() should point to the worktree dir where the file lives + let path = git_repo.get_path()?; + assert!(path.join("worktree-test.txt").exists()); + Ok(()) + } + + #[test] + fn worktree_opened_via_git_dir_env_works() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + + let worktree_repo = test_repo.create_worktree("wt-gitdir")?; + + // In a worktree, GIT_DIR points to the worktree-specific git dir + // (e.g., .git/worktrees/). Simulate what git does when + // calling a remote helper. + let git_dir = worktree_repo.git_repo.path().to_path_buf(); + let git_repo = Repo::from_path(&git_dir)?; + + // get_path() should still return the worktree working directory + assert_eq!( + git_repo.get_path()?.canonicalize()?, + worktree_repo.dir.canonicalize()?, + ); + Ok(()) + } + } } diff --git a/test_utils/src/git.rs b/test_utils/src/git.rs index a18f81c..2668b3f 100644 --- a/test_utils/src/git.rs +++ b/test_utils/src/git.rs @@ -276,6 +276,49 @@ impl GitTestRepo { Ok(()) } + /// Creates a git worktree linked to this repository. + /// Returns a `GitTestRepo` whose `dir` points to the worktree working + /// directory. + pub fn create_worktree(&self, branch_name: &str) -> Result { + let worktree_path = self + .dir + .parent() + .unwrap() + .join(format!("tmpgit-worktree-{}", rand::random::())); + + // Create the branch at the current HEAD + let head_commit = self.git_repo.head()?.peel_to_commit()?; + self.git_repo + .branch(branch_name, &head_commit, false) + .context("failed to create branch for worktree")?; + + // Add worktree via git2 + let worktree = self + .git_repo + .worktree( + branch_name, + &worktree_path, + Some( + git2::WorktreeAddOptions::new().reference(Some( + &self + .git_repo + .find_branch(branch_name, git2::BranchType::Local)? + .into_reference(), + )), + ), + ) + .context("failed to create worktree")?; + + let worktree_repo = git2::Repository::open_from_worktree(&worktree) + .context("failed to open repo from worktree")?; + + Ok(GitTestRepo { + dir: worktree_path, + git_repo: worktree_repo, + delete_dir_on_drop: true, + }) + } + pub fn checkout_remote_branch(&self, branch_name: &str) -> Result { self.checkout(&format!("remotes/origin/{branch_name}"))?; let mut branch = self.create_branch(branch_name)?; diff --git a/test_utils/src/lib.rs b/test_utils/src/lib.rs index 48273e8..89cbaa9 100644 --- a/test_utils/src/lib.rs +++ b/test_utils/src/lib.rs @@ -1208,8 +1208,12 @@ where /** copied from client.rs */ async fn get_local_cache_database(git_repo_path: &Path) -> Result { - NostrLMDB::open(git_repo_path.join(".git/nostr-cache.lmdb")) - .context("failed to open or create nostr cache database at .git/nostr-cache.lmdb") + let git_dir = git2::Repository::discover(git_repo_path) + .context("failed to discover git repository")? + .commondir() + .to_path_buf(); + NostrLMDB::open(git_dir.join("nostr-cache.lmdb")) + .context("failed to open or create nostr cache database at /nostr-cache.lmdb") } /** copied from client.rs */ -- cgit v1.2.3