synwire_storage/
identity.rs1use crate::StorageError;
13use sha2::{Digest, Sha256};
14use std::fmt;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
24pub struct RepoId(String);
25
26impl RepoId {
27 pub fn for_path(path: &Path) -> Result<Self, StorageError> {
37 let canonical = path.canonicalize()?;
38
39 if let Some(hash) = git_first_commit(&canonical) {
41 return Ok(Self(hash));
42 }
43
44 Ok(Self(sha256_hex(canonical.to_string_lossy().as_bytes())))
46 }
47
48 #[must_use]
52 pub fn from_string(s: impl Into<String>) -> Self {
53 Self(s.into())
54 }
55
56 #[must_use]
58 pub fn as_str(&self) -> &str {
59 &self.0
60 }
61}
62
63impl fmt::Display for RepoId {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 f.write_str(&self.0)
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
74pub struct WorktreeId {
75 pub repo_id: RepoId,
77 pub worktree_hash: String,
79 pub display_name: String,
81}
82
83impl WorktreeId {
84 pub fn for_path(path: &Path) -> Result<Self, StorageError> {
93 let canonical = path.canonicalize()?;
94 let worktree_root = git_worktree_root(&canonical).unwrap_or_else(|| canonical.clone());
95 let repo_id = RepoId::for_path(&worktree_root)?;
96 let worktree_hash = sha256_hex(worktree_root.to_string_lossy().as_bytes());
97 let display_name = build_display_name(&worktree_root);
98 Ok(Self {
99 repo_id,
100 worktree_hash,
101 display_name,
102 })
103 }
104
105 #[must_use]
107 pub const fn from_parts(repo_id: RepoId, worktree_hash: String, display_name: String) -> Self {
108 Self {
109 repo_id,
110 worktree_hash,
111 display_name,
112 }
113 }
114
115 #[must_use]
117 pub fn key(&self) -> String {
118 let prefix_len = self.worktree_hash.len().min(12);
119 format!("{}-{}", self.repo_id, &self.worktree_hash[..prefix_len])
120 }
121}
122
123impl fmt::Display for WorktreeId {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 write!(f, "{}", self.key())
126 }
127}
128
129fn git_first_commit(dir: &Path) -> Option<String> {
134 let output = Command::new("git")
135 .args(["rev-list", "--max-parents=0", "HEAD"])
136 .current_dir(dir)
137 .output()
138 .ok()?;
139 if !output.status.success() {
140 return None;
141 }
142 let s = String::from_utf8(output.stdout).ok()?;
143 let hash = s.trim().to_owned();
144 if hash.is_empty() { None } else { Some(hash) }
145}
146
147fn git_worktree_root(dir: &Path) -> Option<PathBuf> {
148 let output = Command::new("git")
149 .args(["rev-parse", "--show-toplevel"])
150 .current_dir(dir)
151 .output()
152 .ok()?;
153 if !output.status.success() {
154 return None;
155 }
156 let s = String::from_utf8(output.stdout).ok()?;
157 let path = PathBuf::from(s.trim());
158 if path.exists() { Some(path) } else { None }
159}
160
161fn git_branch(dir: &Path) -> Option<String> {
162 let output = Command::new("git")
163 .args(["rev-parse", "--abbrev-ref", "HEAD"])
164 .current_dir(dir)
165 .output()
166 .ok()?;
167 if !output.status.success() {
168 return None;
169 }
170 let s = String::from_utf8(output.stdout).ok()?;
171 let branch = s.trim().to_owned();
172 if branch.is_empty() {
173 None
174 } else {
175 Some(branch)
176 }
177}
178
179fn build_display_name(worktree_root: &Path) -> String {
180 let repo_name = worktree_root
181 .file_name()
182 .map_or("unknown", |n| n.to_str().unwrap_or("unknown"));
183 let branch = git_branch(worktree_root).unwrap_or_else(|| "main".to_owned());
184 format!("{repo_name}@{branch}")
185}
186
187fn sha256_hex(input: &[u8]) -> String {
188 let hash = Sha256::digest(input);
189 format!("{hash:x}")
190}
191
192#[cfg(test)]
193#[allow(clippy::expect_used, clippy::unwrap_used)]
194mod tests {
195 use super::*;
196 use std::env;
197
198 #[test]
199 fn repo_id_fallback_is_deterministic() {
200 let dir = env::temp_dir();
201 let id1 = RepoId::for_path(&dir).expect("RepoId::for_path failed");
202 let id2 = RepoId::for_path(&dir).expect("RepoId::for_path failed");
203 assert_eq!(id1, id2);
204 }
205
206 #[test]
207 fn worktree_id_key_is_short() {
208 let dir = env::temp_dir();
209 let wid = WorktreeId::for_path(&dir).expect("WorktreeId::for_path failed");
210 assert!(!wid.key().is_empty());
212 assert!(wid.key().len() < 80);
213 }
214
215 #[test]
216 fn repo_id_display() {
217 let id = RepoId::from_string("abc123");
218 assert_eq!(id.to_string(), "abc123");
219 }
220}