use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::error::Result;
use crate::git::{git_common_dir, git_worktree_root};
use crate::session::store::resolution::resolve_store;
const REPOSITORY_FLOOR: &str = "repository";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StoreIdentityOptions {
repo: PathBuf,
}
impl StoreIdentityOptions {
pub fn new(repo: impl AsRef<Path>) -> Self {
Self {
repo: repo.as_ref().to_path_buf(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StoreIdentity {
pub repository: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree: Option<String>,
pub placement: StorePlacement,
#[serde(skip_serializing_if = "Option::is_none")]
pub family: Option<StoreFamily>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StorePlacement {
pub tier: &'static str,
pub label: &'static str,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StoreFamily {
pub id: String,
}
pub fn store_identity(options: StoreIdentityOptions) -> Result<StoreIdentity> {
let resolution = resolve_store(&options.repo)?;
let view = resolution.command_view();
let placement = placement_for(view.mode);
let family = view.repository_family_ref.map(|id| StoreFamily { id });
let current = current_worktree_basename(&options.repo);
let repository = main_worktree_basename(&options.repo).unwrap_or_else(|| current.clone());
let worktree = (current != repository).then_some(current);
Ok(StoreIdentity {
repository,
worktree,
placement,
family,
})
}
fn placement_for(mode: &str) -> StorePlacement {
match mode {
"user-level" => StorePlacement {
tier: "family",
label: "family store",
},
"ephemeral" => StorePlacement {
tier: "ephemeral",
label: "ephemeral store",
},
_ => StorePlacement {
tier: "clone",
label: "clone store",
},
}
}
fn main_worktree_basename(repo: &Path) -> Option<String> {
let common = git_common_dir(repo).ok()?; basename(common.parent()?) }
fn current_worktree_basename(repo: &Path) -> String {
git_worktree_root(repo)
.ok()
.as_deref()
.and_then(basename)
.unwrap_or_else(|| REPOSITORY_FLOOR.to_owned())
}
fn basename(path: &Path) -> Option<String> {
path.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.map(str::to_owned)
}
#[cfg(test)]
mod tests {
use std::ffi::{OsStr, OsString};
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
use super::*;
use crate::session::store::store_config::{StoreMode, write_store_config};
use crate::session::{StoreLinkOptions, link_store_to_family};
struct TestRepo {
root: TempDir,
}
impl TestRepo {
fn new() -> Self {
let root = TempDir::new().expect("create temp git repository directory");
let repo = Self { root };
repo.git(["init"]);
repo.git(["config", "user.name", "Shore Tests"]);
repo.git(["config", "user.email", "shore-tests@example.com"]);
repo.git(["config", "commit.gpgsign", "false"]);
repo
}
fn path(&self) -> &Path {
self.root.path()
}
fn write(&self, path: &str, contents: &str) {
let path = self.root.path().join(path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, contents).unwrap();
}
fn commit_all(&self, message: &str) {
self.git(["add", "--all"]);
self.git(["commit", "-m", message]);
}
fn git<I, S>(&self, args: I)
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
run_git(self.root.path(), args);
}
}
fn run_git<I, S>(cwd: &Path, args: I)
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.expect("run git");
assert!(
output.status.success(),
"git failed in {}\nstderr:\n{}",
cwd.display(),
String::from_utf8_lossy(&output.stderr)
);
}
struct LinkedWorktreeFixture {
main: TestRepo,
_parent: TempDir,
linked_path: PathBuf,
}
impl LinkedWorktreeFixture {
fn new(dir_name: &str, branch: &str) -> Self {
let main = TestRepo::new();
main.write("README.md", "base\n");
main.commit_all("base");
let parent = TempDir::new().expect("worktree parent");
let linked_path = parent.path().join(dir_name);
main.git([
OsString::from("worktree"),
OsString::from("add"),
OsString::from("-b"),
OsString::from(branch),
linked_path.clone().into_os_string(),
]);
Self {
main,
_parent: parent,
linked_path,
}
}
}
fn with_shore_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
unsafe {
std::env::set_var("SHORE_HOME", home);
}
let out = f();
unsafe {
std::env::remove_var("SHORE_HOME");
}
out
}
#[test]
fn clone_local_identity_reports_clone_placement_and_no_family() {
let repo = TestRepo::new();
repo.write("README.md", "base\n");
repo.commit_all("base");
let id = store_identity(StoreIdentityOptions::new(repo.path())).unwrap();
assert_eq!(id.placement.tier, "clone");
assert_eq!(id.placement.label, "clone store");
assert!(id.family.is_none());
assert!(id.worktree.is_none());
assert!(!id.repository.is_empty());
assert!(!id.repository.contains(std::path::MAIN_SEPARATOR));
let expected = repo.path().file_name().unwrap().to_str().unwrap();
assert_eq!(id.repository, expected);
}
#[test]
fn ephemeral_mode_reports_ephemeral_placement() {
let repo = TestRepo::new();
repo.write("README.md", "base\n");
repo.commit_all("base");
write_store_config(repo.path(), StoreMode::Ephemeral).unwrap();
let id = store_identity(StoreIdentityOptions::new(repo.path())).unwrap();
assert_eq!(id.placement.tier, "ephemeral");
assert_eq!(id.placement.label, "ephemeral store");
assert!(id.family.is_none());
}
#[test]
fn user_level_identity_reports_family_placement_and_slug() {
let repo = TestRepo::new();
repo.write("README.md", "base\n");
repo.commit_all("base");
let home = TempDir::new().unwrap();
let id = with_shore_home(home.path(), || {
link_store_to_family(StoreLinkOptions::new(
repo.path(),
Some("acme-web".to_owned()),
))
.expect("link a clean, non-ephemeral, non-sensitive worktree");
store_identity(StoreIdentityOptions::new(repo.path()))
})
.unwrap();
assert_eq!(id.placement.tier, "family");
assert_eq!(id.placement.label, "family store");
assert_eq!(id.family.as_ref().map(|f| f.id.as_str()), Some("acme-web"));
}
#[test]
fn linked_worktree_surfaces_the_worktree_basename_distinct_from_repository() {
let fixture = LinkedWorktreeFixture::new("feat-foo", "feat/foo");
let id = store_identity(StoreIdentityOptions::new(&fixture.linked_path)).unwrap();
let main_basename = fixture.main.path().file_name().unwrap().to_str().unwrap();
assert_eq!(id.repository, main_basename);
assert_eq!(id.worktree.as_deref(), Some("feat-foo"));
assert_ne!(id.repository, id.worktree.clone().unwrap());
}
#[test]
fn serialized_identity_carries_no_absolute_path() {
let repo = TestRepo::new();
repo.write("README.md", "base\n");
repo.commit_all("base");
let id = store_identity(StoreIdentityOptions::new(repo.path())).unwrap();
let json = serde_json::to_string(&id).unwrap();
let abs = repo.path().to_str().unwrap();
assert!(
!json.contains(abs),
"identity JSON leaked an absolute path: {json}"
);
assert!(json.contains("\"placement\""));
assert!(json.contains("\"tier\":\"clone\""));
}
}