Skip to main content

synwire_storage/
identity.rs

1//! Two-level project identity: [`RepoId`] (repository family) and [`WorktreeId`]
2//! (specific working copy).
3//!
4//! `RepoId` is the SHA-1 of the Git repository's first commit, so it is stable
5//! across clones and worktrees of the same repository.  When Git is unavailable
6//! the SHA-256 of the canonical directory path is used as a fallback.
7//!
8//! `WorktreeId` uniquely identifies a specific working copy within a repository
9//! family.  It combines the `RepoId` with a SHA-256 of the canonicalised
10//! worktree root path.
11
12use crate::StorageError;
13use sha2::{Digest, Sha256};
14use std::fmt;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18/// Stable identifier for a repository *family* — shared across all worktrees
19/// and clones of the same repository.
20///
21/// Derived from the SHA-1 of the first (root) commit, or a SHA-256 hash of the
22/// canonical directory path when Git is unavailable.
23#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
24pub struct RepoId(String);
25
26impl RepoId {
27    /// Compute the `RepoId` for the repository that contains `path`.
28    ///
29    /// Runs `git rev-list --max-parents=0 HEAD` in the directory.  Falls back
30    /// to `sha256(canonical_path)` if Git is not installed or the directory is
31    /// not a Git repository.
32    ///
33    /// # Errors
34    ///
35    /// Returns [`StorageError::Io`] if the path cannot be canonicalised.
36    pub fn for_path(path: &Path) -> Result<Self, StorageError> {
37        let canonical = path.canonicalize()?;
38
39        // Try git first-commit hash.
40        if let Some(hash) = git_first_commit(&canonical) {
41            return Ok(Self(hash));
42        }
43
44        // Fallback: SHA-256 of the canonical path string.
45        Ok(Self(sha256_hex(canonical.to_string_lossy().as_bytes())))
46    }
47
48    /// Create a `RepoId` from a pre-computed string value.
49    ///
50    /// Useful for deserialising stored identifiers.
51    #[must_use]
52    pub fn from_string(s: impl Into<String>) -> Self {
53        Self(s.into())
54    }
55
56    /// Return the underlying string representation.
57    #[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/// Stable identifier for a specific *working copy* (worktree) within a
70/// repository family.
71///
72/// Combines the [`RepoId`] with a SHA-256 of the canonicalised worktree root.
73#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
74pub struct WorktreeId {
75    /// Repository-level identity (shared across worktrees).
76    pub repo_id: RepoId,
77    /// Per-worktree discriminator derived from the canonicalised root path.
78    pub worktree_hash: String,
79    /// Human-readable display name (`<repo_name>@<branch>`).
80    pub display_name: String,
81}
82
83impl WorktreeId {
84    /// Compute the `WorktreeId` for the worktree that contains `path`.
85    ///
86    /// Runs `git rev-parse --show-toplevel` to find the worktree root, then
87    /// computes the repo identity and the per-worktree hash.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`StorageError::Io`] if the path cannot be canonicalised.
92    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    /// Create a `WorktreeId` from pre-computed components.
106    #[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    /// Return a compact string key suitable for use in directory names.
116    #[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
129// ---------------------------------------------------------------------------
130// Internal helpers
131// ---------------------------------------------------------------------------
132
133fn 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        // key should be non-empty and reasonably compact
211        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}