use crate::StorageError;
use sha2::{Digest, Sha256};
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct RepoId(String);
impl RepoId {
pub fn for_path(path: &Path) -> Result<Self, StorageError> {
let canonical = path.canonicalize()?;
if let Some(hash) = git_first_commit(&canonical) {
return Ok(Self(hash));
}
Ok(Self(sha256_hex(canonical.to_string_lossy().as_bytes())))
}
#[must_use]
pub fn from_string(s: impl Into<String>) -> Self {
Self(s.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for RepoId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct WorktreeId {
pub repo_id: RepoId,
pub worktree_hash: String,
pub display_name: String,
}
impl WorktreeId {
pub fn for_path(path: &Path) -> Result<Self, StorageError> {
let canonical = path.canonicalize()?;
let worktree_root = git_worktree_root(&canonical).unwrap_or_else(|| canonical.clone());
let repo_id = RepoId::for_path(&worktree_root)?;
let worktree_hash = sha256_hex(worktree_root.to_string_lossy().as_bytes());
let display_name = build_display_name(&worktree_root);
Ok(Self {
repo_id,
worktree_hash,
display_name,
})
}
#[must_use]
pub const fn from_parts(repo_id: RepoId, worktree_hash: String, display_name: String) -> Self {
Self {
repo_id,
worktree_hash,
display_name,
}
}
#[must_use]
pub fn key(&self) -> String {
let prefix_len = self.worktree_hash.len().min(12);
format!("{}-{}", self.repo_id, &self.worktree_hash[..prefix_len])
}
}
impl fmt::Display for WorktreeId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.key())
}
}
fn git_first_commit(dir: &Path) -> Option<String> {
let output = Command::new("git")
.args(["rev-list", "--max-parents=0", "HEAD"])
.current_dir(dir)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8(output.stdout).ok()?;
let hash = s.trim().to_owned();
if hash.is_empty() { None } else { Some(hash) }
}
fn git_worktree_root(dir: &Path) -> Option<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(dir)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8(output.stdout).ok()?;
let path = PathBuf::from(s.trim());
if path.exists() { Some(path) } else { None }
}
fn git_branch(dir: &Path) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(dir)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8(output.stdout).ok()?;
let branch = s.trim().to_owned();
if branch.is_empty() {
None
} else {
Some(branch)
}
}
fn build_display_name(worktree_root: &Path) -> String {
let repo_name = worktree_root
.file_name()
.map_or("unknown", |n| n.to_str().unwrap_or("unknown"));
let branch = git_branch(worktree_root).unwrap_or_else(|| "main".to_owned());
format!("{repo_name}@{branch}")
}
fn sha256_hex(input: &[u8]) -> String {
let hash = Sha256::digest(input);
format!("{hash:x}")
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use std::env;
#[test]
fn repo_id_fallback_is_deterministic() {
let dir = env::temp_dir();
let id1 = RepoId::for_path(&dir).expect("RepoId::for_path failed");
let id2 = RepoId::for_path(&dir).expect("RepoId::for_path failed");
assert_eq!(id1, id2);
}
#[test]
fn worktree_id_key_is_short() {
let dir = env::temp_dir();
let wid = WorktreeId::for_path(&dir).expect("WorktreeId::for_path failed");
assert!(!wid.key().is_empty());
assert!(wid.key().len() < 80);
}
#[test]
fn repo_id_display() {
let id = RepoId::from_string("abc123");
assert_eq!(id.to_string(), "abc123");
}
}