use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use crate::error::{Result, ShoreError};
#[cfg(test)]
pub(crate) use crate::git::backend::subprocess::git_info_exclude_path;
pub(crate) use crate::git::backend::subprocess::{GitOutput, run_git, run_git_allowing_statuses};
use crate::git::backend::{BackendClass, GitBackend, dispatch, subprocess_backend};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GitWorktree {
pub path: PathBuf,
pub head: Option<String>,
pub branch: Option<String>,
pub detached: bool,
pub bare: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Ancestry {
Ancestor,
NotAncestor,
MissingObject,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct RefEntry {
pub name: String,
pub oid: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct GitInventoryPath {
bytes: Vec<u8>,
}
impl GitInventoryPath {
pub(crate) fn new(bytes: &[u8]) -> Self {
Self {
bytes: bytes.to_vec(),
}
}
pub(crate) fn into_utf8_string(self, description: &str) -> Result<String> {
String::from_utf8(self.bytes)
.map_err(|error| ShoreError::Message(format!("{description} is not utf-8: {error}")))
}
}
pub fn git_worktree_root(repo: &Path) -> Result<PathBuf> {
dispatch(BackendClass::IdentityScalars)?.worktree_root(repo)
}
pub(crate) fn git_common_dir(repo: &Path) -> Result<PathBuf> {
dispatch(BackendClass::ReadRepoDiscovery)?.common_dir(repo)
}
pub(crate) fn git_paths_are_ignored(repo: &Path, pathspecs: &[&str]) -> Result<Vec<bool>> {
dispatch(BackendClass::ReadIgnore)?.paths_are_ignored(repo, pathspecs)
}
pub(crate) fn git_config_get(repo: &Path, key: &str) -> Option<String> {
dispatch(BackendClass::IdentityScalars)
.ok()?
.config_get(repo, key)
}
pub(crate) fn git_config_path_get(repo: &Path, key: &str) -> Option<String> {
dispatch(BackendClass::ReadConfigDiscovery)
.ok()?
.config_path_get(repo, key)
}
pub(crate) fn git_untracked_inventory(repo: &Path) -> Result<Vec<GitInventoryPath>> {
dispatch(BackendClass::ReadInventory)?.untracked_inventory(repo)
}
pub(crate) fn git_tracked_and_untracked_inventory(repo: &Path) -> Result<Vec<GitInventoryPath>> {
dispatch(BackendClass::ReadInventory)?.tracked_and_untracked_inventory(repo)
}
pub(crate) fn git_path_is_untracked(repo: &Path, relative_path: &str) -> Result<bool> {
dispatch(BackendClass::ReadInventory)?.path_is_untracked(repo, relative_path)
}
pub(crate) fn git_is_ancestor(
repo: &Path,
ancestor_oid: &str,
descendant_oid: &str,
) -> Result<Ancestry> {
dispatch(BackendClass::ReadGraphRefs)?.is_ancestor(repo, ancestor_oid, descendant_oid)
}
pub(crate) fn git_independent_commits(repo: &Path, oids: &[String]) -> Result<Vec<String>> {
dispatch(BackendClass::ReadGraphRefs)?.independent_commits(repo, oids)
}
pub(crate) fn git_commit_changed_paths(repo: &Path, commit_oid: &str) -> Result<Vec<String>> {
dispatch(BackendClass::ReadGraphRefs)?.commit_changed_paths(repo, commit_oid)
}
pub fn git_commit_subjects(
repo: &Path,
commit_oids: &BTreeSet<String>,
) -> Result<BTreeMap<String, String>> {
dispatch(BackendClass::ReadGraphRefs)?.commit_subjects(repo, commit_oids)
}
pub(crate) fn git_for_each_ref(repo: &Path, patterns: &[&str]) -> Result<Vec<RefEntry>> {
dispatch(BackendClass::ReadGraphRefs)?.for_each_ref(repo, patterns)
}
pub(crate) fn git_ref_state_lines(repo: &Path) -> Result<String> {
dispatch(BackendClass::ReadGraphRefs)?.ref_state_lines(repo)
}
pub(crate) fn git_object_exists(repo: &Path, oid: &str) -> Result<bool> {
dispatch(BackendClass::ReadGraphRefs)?.object_exists(repo, oid)
}
pub(crate) fn git_head_ref(repo: &Path) -> Result<Option<String>> {
dispatch(BackendClass::IdentityScalars)?.head_ref(repo)
}
pub fn git_head_oid(repo: &Path) -> Result<String> {
dispatch(BackendClass::IdentityScalars)?.head_oid(repo)
}
pub(crate) fn git_default_branch_ref(repo: &Path) -> Result<Option<String>> {
dispatch(BackendClass::ReadGraphRefs)?.default_branch_ref(repo)
}
pub(crate) fn git_head_commit_oid_optional(repo: &Path) -> Result<Option<String>> {
dispatch(BackendClass::IdentityScalars)?.head_commit_oid_optional(repo)
}
pub(crate) fn git_rev_parse_commit_oid(repo: &Path, rev: &str) -> Result<String> {
dispatch(BackendClass::IdentityScalars)?.rev_parse_commit_oid(repo, rev)
}
pub(crate) fn git_commit_tree_oid(repo: &Path, commit_oid: &str) -> Result<String> {
dispatch(BackendClass::IdentityScalars)?.commit_tree_oid(repo, commit_oid)
}
pub(crate) fn git_empty_tree_oid(repo: &Path) -> Result<String> {
dispatch(BackendClass::IdentityScalars)?.empty_tree_oid(repo)
}
pub(crate) fn git_write_index_tree_oid(repo: &Path) -> Result<String> {
subprocess_backend().write_index_tree_oid_direct(repo)
}
pub(crate) fn git_rev_list_reachable(repo: &Path, tips: &[String]) -> Result<HashSet<String>> {
dispatch(BackendClass::ReadGraphRefs)?.rev_list_reachable(repo, tips)
}
pub(crate) fn git_rev_list_reflog_reachable(repo: &Path) -> Result<HashSet<String>> {
dispatch(BackendClass::ReadGraphRefs)?.rev_list_reflog_reachable(repo)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct GitReflogEntry {
pub new_oid: String,
pub subject: String,
}
pub(crate) fn git_reflog_entries(repo: &Path, ref_name: &str) -> Result<Vec<GitReflogEntry>> {
dispatch(BackendClass::ReadGraphRefs)?.reflog_entries(repo, ref_name)
}
pub(crate) fn git_rev_list_range(repo: &Path, range: &str) -> Result<Vec<String>> {
dispatch(BackendClass::ReadGraphRefs)?.rev_list_range(repo, range)
}
pub(crate) fn git_worktree_list(repo: &Path) -> Result<Vec<GitWorktree>> {
dispatch(BackendClass::ReadGraphRefs)?.worktree_list(repo)
}
#[cfg(test)]
mod tests {
use std::ffi::{OsStr, OsString};
use std::fs;
use tempfile::TempDir;
use super::*;
use crate::git::backend::subprocess::{
BackendTag, git_spawn_count, last_backend_tag, reset_backend_tag, reset_git_spawn_count,
};
use crate::git::ingest::{diff_funnel_spawns, reset_diff_funnel_spawns};
use crate::git::ingest_tracked_diff;
#[test]
fn routable_helpers_dispatch_through_a_backend() {
use crate::git::backend::{BackendSelector, inject_selector, reset_selector};
inject_selector(BackendSelector::ForceSubprocess);
let repo = TwoCommitRepo::new();
reset_backend_tag();
let _ = git_paths_are_ignored(repo.path(), &["x"]).unwrap();
assert_eq!(last_backend_tag(), Some(BackendTag::Subprocess));
reset_selector();
}
#[test]
fn write_tree_and_diff_are_direct_subprocess_not_dispatched() {
let repo = TwoCommitRepo::new();
fs::write(repo.path().join("file.txt"), "changed\n").unwrap();
reset_backend_tag();
let _ = git_write_index_tree_oid(repo.path()).unwrap();
assert_eq!(
last_backend_tag(),
None,
"write-tree must not go through routable dispatch"
);
reset_diff_funnel_spawns();
let _ = ingest_tracked_diff(repo.path()).unwrap();
assert!(
diff_funnel_spawns() > 0,
"the capture diff funnel ran on the direct subprocess path"
);
}
#[cfg(feature = "gix")]
#[test]
fn write_tree_and_diff_stay_subprocess_under_force_gix() {
use crate::git::backend::{BackendSelector, inject_selector, reset_selector};
inject_selector(BackendSelector::ForceGix);
let repo = TwoCommitRepo::new();
fs::write(repo.path().join("file.txt"), "changed\n").unwrap();
reset_backend_tag();
let head_first = git_head_oid(repo.path()).unwrap();
assert_eq!(
last_backend_tag(),
Some(BackendTag::Gix),
"an identity scalar routes to gix under ForceGix"
);
reset_backend_tag();
let tree_first = git_write_index_tree_oid(repo.path()).unwrap();
assert_eq!(
last_backend_tag(),
None,
"write-tree is non-routable — never dispatched, even under ForceGix (LB-6)"
);
reset_diff_funnel_spawns();
let _ = ingest_tracked_diff(repo.path()).unwrap();
assert!(
diff_funnel_spawns() > 0,
"the capture diff funnel ran on the direct subprocess path under ForceGix (LB-6)"
);
assert_eq!(git_head_oid(repo.path()).unwrap(), head_first);
assert_eq!(git_write_index_tree_oid(repo.path()).unwrap(), tree_first);
reset_selector();
}
#[test]
fn subprocess_spawn_counter_tracks_git_invocations() {
use crate::git::backend::{BackendSelector, inject_selector, reset_selector};
inject_selector(BackendSelector::ForceSubprocess);
let repo = TwoCommitRepo::new();
reset_git_spawn_count();
let _ = git_head_oid(repo.path()).unwrap();
assert!(
git_spawn_count() > 0,
"a git helper spawns at least one subprocess"
);
reset_selector();
}
#[cfg(feature = "gix")]
#[test]
fn force_gix_routes_reads_and_scalars_to_native_gix() {
use crate::git::backend::{BackendSelector, inject_selector, reset_selector};
let repo = TwoCommitRepo::new();
inject_selector(BackendSelector::ForceSubprocess);
let subprocess_refs = git_for_each_ref(repo.path(), &["refs/heads/"]).unwrap();
let subprocess_head = git_head_oid(repo.path()).unwrap();
inject_selector(BackendSelector::ForceGix);
reset_backend_tag();
let gix_refs = git_for_each_ref(repo.path(), &["refs/heads/"]).unwrap();
let gix_head = git_head_oid(repo.path()).unwrap();
assert_eq!(
last_backend_tag(),
Some(BackendTag::Gix),
"ForceGix dispatches through the gix backend"
);
assert_eq!(subprocess_refs, gix_refs, "reads agree across backends");
assert_eq!(
subprocess_head, gix_head,
"identity scalars agree across backends"
);
reset_git_spawn_count();
let _ = git_head_oid(repo.path()).unwrap();
assert_eq!(
git_spawn_count(),
0,
"identity scalars are native gix under ForceGix"
);
reset_selector();
}
#[test]
fn git_common_dir_is_shared_across_worktrees() {
let fixture = LinkedWorktreeFixture::new();
let main_common_dir = git_common_dir(fixture.main.path()).unwrap();
let linked_common_dir = git_common_dir(&fixture.linked_path).unwrap();
assert_eq!(
canonicalize(&main_common_dir),
canonicalize(&linked_common_dir)
);
let worktrees = git_worktree_list(fixture.main.path()).unwrap();
let worktree_paths = worktrees
.iter()
.map(|worktree| canonicalize(&worktree.path))
.collect::<Vec<_>>();
assert!(worktree_paths.contains(&canonicalize(fixture.main.path())));
assert!(worktree_paths.contains(&canonicalize(&fixture.linked_path)));
}
#[test]
fn rev_parse_commit_oid_resolves_branches_relative_revs_and_annotated_tags() {
let repo = TwoCommitRepo::new();
let first_via_helper = git_rev_parse_commit_oid(repo.path(), "HEAD~1").unwrap();
let first_expected = rev_parse(repo.path(), "HEAD~1");
assert_eq!(first_via_helper, first_expected);
let first_via_tag = git_rev_parse_commit_oid(repo.path(), "v1").unwrap();
assert_eq!(
first_via_tag, first_expected,
"annotated tag must peel to its commit"
);
assert_eq!(first_via_helper, rev_parse(repo.path(), "HEAD~1"));
assert!(!first_via_helper.is_empty());
}
#[test]
fn rev_parse_commit_oid_rejects_unknown_rev_with_honest_error() {
let repo = TwoCommitRepo::new();
let error = git_rev_parse_commit_oid(repo.path(), "no-such-rev").unwrap_err();
let message = error.to_string();
assert!(message.contains("no-such-rev"), "message: {message}");
assert!(message.contains("commit"), "message: {message}");
}
#[test]
fn rev_parse_commit_oid_rejects_non_commit_object() {
let repo = TwoCommitRepo::new();
let error = git_rev_parse_commit_oid(repo.path(), "HEAD:file.txt").unwrap_err();
let message = error.to_string();
assert!(message.contains("HEAD:file.txt"), "message: {message}");
}
#[test]
fn commit_subjects_batch_is_deterministic_and_omits_unreadable_oids() {
let repo = TwoCommitRepo::new();
let first = rev_parse(repo.path(), "HEAD~1");
let second = rev_parse(repo.path(), "HEAD");
let missing = "0".repeat(second.len());
let requested = BTreeSet::from([second.clone(), missing.clone(), first.clone()]);
let subjects = git_commit_subjects(repo.path(), &requested).unwrap();
assert_eq!(
subjects,
BTreeMap::from([(first, "first".to_owned()), (second, "second".to_owned())])
);
assert!(
!git_commit_subjects(repo.path(), &BTreeSet::new())
.unwrap()
.contains_key(&missing)
);
}
fn rev_parse(repo: &Path, rev: &str) -> String {
let output = run_git(repo, ["rev-parse", rev]).unwrap();
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
#[test]
fn rev_list_range_lists_commits_in_the_range() {
let repo = TwoCommitRepo::new();
let head = rev_parse(repo.path(), "HEAD");
let base = rev_parse(repo.path(), "HEAD~1");
let range = git_rev_list_range(repo.path(), &format!("{base}..{head}")).unwrap();
assert_eq!(range, vec![head.clone()]);
let empty = git_rev_list_range(repo.path(), &format!("{head}..{base}")).unwrap();
assert!(empty.is_empty());
}
#[test]
fn rev_list_range_rejects_an_unresolvable_range_with_honest_error() {
let repo = TwoCommitRepo::new();
let error = git_rev_list_range(repo.path(), "no-such-rev..HEAD").unwrap_err();
let message = error.to_string();
assert!(message.contains("no-such-rev..HEAD"), "message: {message}");
}
#[test]
fn rev_list_range_rejects_a_bare_rev_that_is_not_a_range() {
let repo = TwoCommitRepo::new();
let error = git_rev_list_range(repo.path(), "HEAD").unwrap_err();
let message = error.to_string();
assert!(message.contains("HEAD"), "message: {message}");
assert!(
message.contains(".."),
"message names the expected range form: {message}"
);
}
#[test]
fn is_ancestor_is_three_valued() {
let repo = TwoCommitRepo::new();
let base = rev_parse(repo.path(), "HEAD~1");
let tip = rev_parse(repo.path(), "HEAD");
assert_eq!(
git_is_ancestor(repo.path(), &base, &tip).unwrap(),
Ancestry::Ancestor
);
assert_eq!(
git_is_ancestor(repo.path(), &tip, &base).unwrap(),
Ancestry::NotAncestor
);
let absent = "0".repeat(tip.len());
assert_eq!(
git_is_ancestor(repo.path(), &absent, &tip).unwrap(),
Ancestry::MissingObject
);
}
#[test]
fn for_each_ref_lists_tips_including_nested_branches() {
let repo = TwoCommitRepo::new();
git(repo.path(), ["branch", "feat/x"]);
let entries = git_for_each_ref(repo.path(), &["refs/heads/"]).unwrap();
let tip = rev_parse(repo.path(), "HEAD");
assert!(
entries
.iter()
.any(|entry| entry.name == "refs/heads/feat/x"),
"for-each-ref must list the nested branch: {entries:?}"
);
assert!(entries.iter().any(|entry| entry.oid == tip));
}
#[test]
fn object_exists_and_head_ref() {
let repo = TwoCommitRepo::new();
let head_oid = rev_parse(repo.path(), "HEAD");
assert!(git_object_exists(repo.path(), &head_oid).unwrap());
assert!(!git_object_exists(repo.path(), &"0".repeat(head_oid.len())).unwrap());
let head_ref = git_head_ref(repo.path()).unwrap();
assert!(
head_ref
.as_deref()
.is_some_and(|name| name.starts_with("refs/heads/")),
"attached HEAD must resolve to a full ref, got {head_ref:?}"
);
git(repo.path(), ["checkout", "--detach"]);
assert_eq!(git_head_ref(repo.path()).unwrap(), None);
}
#[test]
fn default_branch_ref_prefers_origin_head_then_local_main_then_master() {
let repo = TempDir::new().expect("create temp repository directory");
git(repo.path(), ["init"]);
git(repo.path(), ["symbolic-ref", "HEAD", "refs/heads/master"]);
git(repo.path(), ["config", "user.name", "Shore Tests"]);
git(
repo.path(),
["config", "user.email", "shore-tests@example.com"],
);
git(repo.path(), ["config", "commit.gpgsign", "false"]);
fs::write(repo.path().join("file.txt"), "one\n").unwrap();
git(repo.path(), ["add", "--all"]);
git(repo.path(), ["commit", "-m", "first"]);
assert_eq!(
git_default_branch_ref(repo.path()).unwrap().as_deref(),
Some("refs/heads/master"),
"a repo whose only conventional default is master detects master"
);
git(repo.path(), ["branch", "main"]);
assert_eq!(
git_default_branch_ref(repo.path()).unwrap().as_deref(),
Some("refs/heads/main"),
"main is preferred over master when both exist"
);
git(
repo.path(),
["update-ref", "refs/remotes/origin/trunk", "refs/heads/main"],
);
git(
repo.path(),
[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/trunk",
],
);
assert_eq!(
git_default_branch_ref(repo.path()).unwrap().as_deref(),
Some("refs/remotes/origin/trunk"),
"a resolvable origin/HEAD wins over the local fallback"
);
}
#[test]
fn default_branch_ref_skips_a_dangling_origin_head() {
let repo = TempDir::new().expect("create temp repository directory");
git(repo.path(), ["init"]);
git(repo.path(), ["symbolic-ref", "HEAD", "refs/heads/main"]);
git(repo.path(), ["config", "user.name", "Shore Tests"]);
git(
repo.path(),
["config", "user.email", "shore-tests@example.com"],
);
git(repo.path(), ["config", "commit.gpgsign", "false"]);
fs::write(repo.path().join("file.txt"), "one\n").unwrap();
git(repo.path(), ["add", "--all"]);
git(repo.path(), ["commit", "-m", "first"]);
git(
repo.path(),
[
"symbolic-ref",
"refs/remotes/origin/HEAD",
"refs/remotes/origin/missing",
],
);
assert_eq!(
git_default_branch_ref(repo.path()).unwrap().as_deref(),
Some("refs/heads/main"),
"a dangling origin/HEAD falls through to the valid local main"
);
}
#[test]
fn default_branch_ref_is_none_without_a_conventional_default() {
let repo = TempDir::new().expect("create temp repository directory");
git(repo.path(), ["init"]);
git(repo.path(), ["symbolic-ref", "HEAD", "refs/heads/trunk"]);
git(repo.path(), ["config", "user.name", "Shore Tests"]);
git(
repo.path(),
["config", "user.email", "shore-tests@example.com"],
);
git(repo.path(), ["config", "commit.gpgsign", "false"]);
fs::write(repo.path().join("file.txt"), "one\n").unwrap();
git(repo.path(), ["add", "--all"]);
git(repo.path(), ["commit", "-m", "first"]);
assert_eq!(git_default_branch_ref(repo.path()).unwrap(), None);
}
#[test]
fn git_paths_are_ignored_reports_each_path_in_input_order() {
let repo = TwoCommitRepo::new();
let exclude = repo.path().join(".git/info/exclude");
fs::create_dir_all(exclude.parent().unwrap()).unwrap();
fs::write(&exclude, ".pointbreak/data/\n").unwrap();
let verdicts = git_paths_are_ignored(
repo.path(),
&[
".pointbreak/data/state.json", ".pointbreak/delegates.local.json", ],
)
.unwrap();
assert_eq!(verdicts, vec![true, false]);
}
#[test]
fn git_config_get_returns_values_needed_for_writer_fallback() {
let repo = TempDir::new().expect("create temp git repository directory");
git(repo.path(), ["init"]);
git(repo.path(), ["config", "user.email", ""]);
assert_eq!(git_config_get(repo.path(), "user.email"), None);
git(
repo.path(),
["config", "user.email", "reviewer@example.com"],
);
assert_eq!(
git_config_get(repo.path(), "user.email"),
Some("reviewer@example.com".to_owned())
);
git(repo.path(), ["config", "user.name", ""]);
assert_eq!(git_config_get(repo.path(), "user.name"), None);
}
#[test]
fn untracked_inventory_lists_unignored_untracked_paths_in_git_order() {
let repo = TwoCommitRepo::new();
fs::create_dir_all(repo.path().join("notes")).unwrap();
fs::write(repo.path().join("b.txt"), "b\n").unwrap();
fs::write(repo.path().join("notes/a.txt"), "a\n").unwrap();
fs::write(repo.path().join("ignored.log"), "ignored\n").unwrap();
fs::write(repo.path().join(".git/info/exclude"), "ignored.log\n").unwrap();
let paths = inventory_path_strings(git_untracked_inventory(repo.path()).unwrap());
assert_eq!(paths, vec!["b.txt", "notes/a.txt"]);
}
fn inventory_path_strings(paths: Vec<GitInventoryPath>) -> Vec<String> {
paths
.into_iter()
.map(|path| path.into_utf8_string("test inventory path").unwrap())
.collect()
}
#[test]
fn git_path_is_untracked_distinguishes_untracked_tracked_and_absent() {
let repo = TwoCommitRepo::new();
assert!(!git_path_is_untracked(repo.path(), "nope.txt").unwrap());
assert!(!git_path_is_untracked(repo.path(), "file.txt").unwrap());
fs::write(repo.path().join("file.txt"), "three\n").unwrap();
assert!(!git_path_is_untracked(repo.path(), "file.txt").unwrap());
fs::write(repo.path().join("new.txt"), "x\n").unwrap();
assert!(git_path_is_untracked(repo.path(), "new.txt").unwrap());
fs::write(repo.path().join(".git/info/exclude"), "ignored.txt\n").unwrap();
fs::write(repo.path().join("ignored.txt"), "y\n").unwrap();
assert!(!git_path_is_untracked(repo.path(), "ignored.txt").unwrap());
}
struct TwoCommitRepo {
root: TempDir,
}
impl TwoCommitRepo {
fn new() -> Self {
let root = TempDir::new().expect("create temp git repository directory");
let repo = Self { root };
git(repo.path(), ["init"]);
git(repo.path(), ["config", "user.name", "Shore Tests"]);
git(
repo.path(),
["config", "user.email", "shore-tests@example.com"],
);
git(repo.path(), ["config", "commit.gpgsign", "false"]);
fs::write(repo.path().join("file.txt"), "one\n").expect("write first file");
git(repo.path(), ["add", "--all"]);
git(repo.path(), ["commit", "-m", "first"]);
git(repo.path(), ["tag", "-a", "v1", "-m", "v1", "HEAD"]);
fs::write(repo.path().join("file.txt"), "two\n").expect("write second file");
git(repo.path(), ["add", "--all"]);
git(repo.path(), ["commit", "-m", "second"]);
repo
}
fn path(&self) -> &Path {
self.root.path()
}
}
struct LinkedWorktreeFixture {
main: TempDir,
_linked_parent: TempDir,
linked_path: PathBuf,
}
impl LinkedWorktreeFixture {
fn new() -> Self {
let main = TempDir::new().expect("create main repository directory");
git(main.path(), ["init"]);
git(main.path(), ["config", "user.name", "Shore Tests"]);
git(
main.path(),
["config", "user.email", "shore-tests@example.com"],
);
git(main.path(), ["config", "commit.gpgsign", "false"]);
fs::write(main.path().join("README.md"), "base\n").expect("write base file");
git(main.path(), ["add", "--all"]);
git(main.path(), ["commit", "-m", "base"]);
let linked_parent = TempDir::new().expect("create linked worktree parent");
let linked_path = linked_parent.path().join("linked");
git_os(
main.path(),
[
OsString::from("worktree"),
OsString::from("add"),
OsString::from("-b"),
OsString::from("linked"),
linked_path.as_os_str().to_owned(),
],
);
Self {
main,
_linked_parent: linked_parent,
linked_path,
}
}
}
fn git<I, S>(cwd: &Path, args: I)
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
run_git(cwd, args).unwrap();
}
fn git_os<I>(cwd: &Path, args: I)
where
I: IntoIterator<Item = OsString>,
{
run_git(cwd, args).unwrap();
}
fn canonicalize(path: &Path) -> PathBuf {
path.canonicalize()
.unwrap_or_else(|error| panic!("canonicalize {}: {error}", path.display()))
}
}