1- use std:: collections:: HashMap ;
21use std:: path:: { Component , Path , PathBuf } ;
3- use std:: process:: { Command , Stdio } ;
4-
5- use crate :: process_ext:: NoWindowExt ;
6- use std:: sync:: { OnceLock , RwLock } ;
7- use std:: time:: Duration ;
8-
9- use wait_timeout:: ChildExt ;
10-
11- const GIT_TIMEOUT : Duration = Duration :: from_secs ( 5 ) ;
12-
13- static IDENTITY_CACHE : OnceLock < RwLock < HashMap < PathBuf , String > > > = OnceLock :: new ( ) ;
14-
15- fn cache ( ) -> & ' static RwLock < HashMap < PathBuf , String > > {
16- IDENTITY_CACHE . get_or_init ( || RwLock :: new ( HashMap :: new ( ) ) )
17- }
18-
19- /// Whether a `.git` exists at `canonical` (worktree dir or a gitdir file/dir).
20- /// Used to invalidate a cached `dir:` fallback once a repo appears — mirrors the
21- /// TS resolver's `hasGitDir` re-resolve gate.
22- fn has_git_dir ( canonical : & Path ) -> bool {
23- canonical. join ( ".git" ) . exists ( )
24- }
252
263/// Lexically resolve `input` against `cwd`, matching Node's `path.resolve` semantics.
274///
@@ -72,174 +49,6 @@ pub fn logical_absolute(input: &Path, cwd: &Path) -> PathBuf {
7249 base
7350}
7451
75- #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
76- pub enum IdentityErrorClass {
77- NotGitRepo ,
78- /// The directory does not exist / is not reachable. Deterministic: the same
79- /// missing path always yields the same `dir:` fallback, so it is safe to
80- /// cache (mirrors the TS "Unable to access project directory" fallback).
81- PathInaccessible ,
82- GitMissing ,
83- GitTimeout ,
84- PermissionDenied ,
85- Unknown ,
86- }
87-
88- impl IdentityErrorClass {
89- /// Whether this failure is DETERMINISTIC (same input always reproduces it),
90- /// so a `dir:` fallback may be cached. Transient classes (git binary
91- /// missing, timeout, permission, unknown spawn/wait failures) must NOT be
92- /// cached — a retry could resolve the real `git:` identity. This mirrors the
93- /// TS resolver, which only falls back for `not_git_repo` + inaccessible-path
94- /// and never caches transient failures.
95- fn is_deterministic_fallback ( self ) -> bool {
96- matches ! (
97- self ,
98- IdentityErrorClass :: NotGitRepo | IdentityErrorClass :: PathInaccessible
99- )
100- }
101- }
102-
103- pub fn resolve_project_identity_strict ( directory : & Path ) -> Result < String , IdentityErrorClass > {
104- let cwd = std:: env:: current_dir ( ) . unwrap_or_else ( |_| PathBuf :: from ( "." ) ) ;
105- let canonical = logical_absolute ( directory, & cwd) ;
106-
107- // If the cwd itself is missing, the git spawn would also return NotFound; distinguish
108- // that from a missing git binary before classifying the spawn error. Use
109- // metadata() (not exists(), which collapses ALL errors to false): a genuine
110- // NotFound is DETERMINISTIC (cacheable dir: fallback), but a PermissionDenied/
111- // other stat error is TRANSIENT and must NOT be cached (a retry could resolve
112- // the real git: identity once access is restored).
113- match std:: fs:: metadata ( & canonical) {
114- Ok ( _) => { }
115- Err ( error) => {
116- return Err ( match error. kind ( ) {
117- std:: io:: ErrorKind :: NotFound => IdentityErrorClass :: PathInaccessible ,
118- std:: io:: ErrorKind :: PermissionDenied => IdentityErrorClass :: PermissionDenied ,
119- _ => IdentityErrorClass :: Unknown ,
120- } ) ;
121- }
122- }
123-
124- let mut child = Command :: new ( "git" )
125- . args ( [ "rev-list" , "--max-parents=0" , "HEAD" ] )
126- . current_dir ( & canonical)
127- . env ( "LC_ALL" , "C" )
128- . env ( "LANG" , "C" )
129- . stdout ( Stdio :: piped ( ) )
130- . stderr ( Stdio :: piped ( ) )
131- . no_window ( )
132- . spawn ( )
133- . map_err ( |error| match error. kind ( ) {
134- std:: io:: ErrorKind :: NotFound => IdentityErrorClass :: GitMissing ,
135- std:: io:: ErrorKind :: PermissionDenied => IdentityErrorClass :: PermissionDenied ,
136- _ => IdentityErrorClass :: Unknown ,
137- } ) ?;
138-
139- let status = match child
140- . wait_timeout ( GIT_TIMEOUT )
141- . map_err ( |_| IdentityErrorClass :: Unknown ) ?
142- {
143- Some ( status) => status,
144- None => {
145- let _ = child. kill ( ) ;
146- let _ = child. wait ( ) ;
147- return Err ( IdentityErrorClass :: GitTimeout ) ;
148- }
149- } ;
150-
151- let output = child
152- . wait_with_output ( )
153- . map_err ( |_| IdentityErrorClass :: Unknown ) ?;
154-
155- if status. success ( ) {
156- let stdout = String :: from_utf8_lossy ( & output. stdout ) ;
157- let first_line = stdout. lines ( ) . next ( ) . unwrap_or ( "" ) . trim ( ) ;
158- if first_line. len ( ) < 7 {
159- return Err ( IdentityErrorClass :: Unknown ) ;
160- }
161- // TS accepts the complete root hash line. SHA-1 repos produce 40 chars;
162- // SHA-256 repos produce 64. Cap at 64 to avoid accepting accidental noise.
163- let sha = first_line. chars ( ) . take ( 64 ) . collect :: < String > ( ) ;
164- return Ok ( format ! ( "git:{sha}" ) ) ;
165- }
166-
167- let stderr = String :: from_utf8_lossy ( & output. stderr ) . to_ascii_lowercase ( ) ;
168- if stderr. contains ( "not a git repository" ) {
169- Err ( IdentityErrorClass :: NotGitRepo )
170- } else if stderr. contains ( "permission denied" ) {
171- Err ( IdentityErrorClass :: PermissionDenied )
172- } else {
173- Err ( IdentityErrorClass :: Unknown )
174- }
175- }
176-
177- /// Resolve a raw filesystem path to the stable project identity used by the TS plugin.
178- ///
179- /// Mirrors `packages/plugin/src/features/magic-context/memory/project-identity.ts`:
180- /// logical absolute path resolution, `git rev-list --max-parents=0 HEAD`, and
181- /// `dir:<md5-12>` fallback over the resolved UTF-8 path bytes.
182- pub fn resolve_project_identity < P : AsRef < Path > > ( directory : P ) -> String {
183- let directory = directory. as_ref ( ) ;
184- let cwd = std:: env:: current_dir ( ) . unwrap_or_else ( |_| PathBuf :: from ( "." ) ) ;
185- let canonical = logical_absolute ( directory, & cwd) ;
186-
187- if let Ok ( cache) = cache ( ) . read ( ) {
188- if let Some ( identity) = cache. get ( & canonical) {
189- // Serve a cached `git:` identity directly (stable once a repo exists).
190- // A cached `dir:` FALLBACK, however, must be dropped the moment a `.git`
191- // appears, so the identity can flip to the stable `git:<root>` — the
192- // common "scratch dir later `git init` + first commit" case. Without
193- // this re-resolve gate the dashboard pins the wrong `dir:` identity for
194- // the whole process and mis-groups the project (P0: it then reads/
195- // mutates the wrong project's memories). Mirrors TS resolveProjectIdentity.
196- if identity. starts_with ( "git:" ) || !has_git_dir ( & canonical) {
197- return identity. clone ( ) ;
198- }
199- }
200- }
201- // Cached fallback is stale (a repo appeared) — evict before re-resolving.
202- if let Ok ( mut cache) = cache ( ) . write ( ) {
203- if let Some ( identity) = cache. get ( & canonical) {
204- if identity. starts_with ( "dir:" ) && has_git_dir ( & canonical) {
205- cache. remove ( & canonical) ;
206- }
207- }
208- }
209-
210- match resolve_project_identity_strict ( & canonical) {
211- Ok ( identity) => {
212- if let Ok ( mut cache) = cache ( ) . write ( ) {
213- cache. insert ( canonical, identity. clone ( ) ) ;
214- }
215- identity
216- }
217- Err ( error) => {
218- let fallback = directory_fallback ( & canonical) ;
219- // Only cache the fallback for DETERMINISTIC failures (not-git /
220- // inaccessible path). Transient failures (git missing/timeout/
221- // permission/unknown) return the fallback UNCACHED so a later call
222- // can still resolve the real `git:` identity once the transient
223- // condition clears — caching here would pin a wrong `dir:` identity
224- // for the whole process and mis-group the project in the UI. This
225- // matches the TS resolver's fallback/propagation policy (the
226- // dashboard degrades to a fallback instead of throwing because it is
227- // a read-only viewer that must still render something).
228- if error. is_deterministic_fallback ( ) {
229- if let Ok ( mut cache) = cache ( ) . write ( ) {
230- cache. insert ( canonical, fallback. clone ( ) ) ;
231- }
232- } else {
233- eprintln ! (
234- "[dashboard] resolve_project_identity transient error {:?} on {:?} (uncached fallback)" ,
235- error, canonical
236- ) ;
237- }
238- fallback
239- }
240- }
241- }
242-
24352fn directory_fallback ( path : & Path ) -> String {
24453 let digest = md5:: compute ( path. to_string_lossy ( ) . as_bytes ( ) ) ;
24554 let hex = format ! ( "{digest:x}" ) ;
@@ -249,13 +58,16 @@ fn directory_fallback(path: &Path) -> String {
24958/// Normalize a value read from `memories.project_path` / related stored identity columns.
25059///
25160/// Stored DB values may already be identities (`git:*`, `dir:*`). Those must be returned
252- /// unchanged; passing them through `resolve_project_identity` would hash the identity text as
253- /// a filesystem path and corrupt the project_state key.
61+ /// unchanged; hashing the identity text as a filesystem path would produce a wrong project
62+ /// key. Legacy raw paths degrade to the deterministic directory fallback so the dashboard
63+ /// never probes git while reading historical rows.
25464pub fn normalize_stored_project_path ( raw_or_stored : & str ) -> String {
25565 if raw_or_stored. starts_with ( "git:" ) || raw_or_stored. starts_with ( "dir:" ) {
25666 return raw_or_stored. to_string ( ) ;
25767 }
258- resolve_project_identity ( Path :: new ( raw_or_stored) )
68+ let cwd = std:: env:: current_dir ( ) . unwrap_or_else ( |_| PathBuf :: from ( "." ) ) ;
69+ let canonical = logical_absolute ( Path :: new ( raw_or_stored) , & cwd) ;
70+ directory_fallback ( & canonical)
25971}
26072
26173pub fn basename ( path : & str ) -> String {
@@ -266,56 +78,45 @@ pub fn basename(path: &str) -> String {
26678 . unwrap_or_else ( || path. to_string ( ) )
26779}
26880
269- #[ doc( hidden) ]
270- pub fn clear_cache_for_tests ( ) {
271- if let Ok ( mut cache) = cache ( ) . write ( ) {
272- cache. clear ( ) ;
273- }
274- }
275-
27681#[ cfg( test) ]
27782mod tests {
27883 use super :: * ;
27984
85+ fn expected_dir_identity ( path : & Path ) -> String {
86+ let digest = md5:: compute ( path. to_string_lossy ( ) . as_bytes ( ) ) ;
87+ let hex = format ! ( "{digest:x}" ) ;
88+ format ! ( "dir:{}" , & hex[ ..12 ] )
89+ }
90+
28091 #[ test]
281- fn resolves_current_repo_as_git_identity ( ) {
282- clear_cache_for_tests ( ) ;
283- let identity = resolve_project_identity ( "." ) ;
284- assert ! ( identity. starts_with( "git:" ) , "{identity}" ) ;
285- assert ! ( identity. len( ) > 11 ) ;
92+ fn normalize_stored_project_path_preserves_identity_values ( ) {
93+ assert_eq ! ( normalize_stored_project_path( "git:abc123" ) , "git:abc123" ) ;
94+ assert_eq ! (
95+ normalize_stored_project_path( "dir:deadbeef0000" ) ,
96+ "dir:deadbeef0000"
97+ ) ;
28698 }
28799
288100 #[ test]
289- fn non_git_directory_uses_md5_dir_identity ( ) {
290- clear_cache_for_tests ( ) ;
101+ fn normalize_stored_project_path_hashes_raw_paths_without_git ( ) {
291102 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
292- let identity = resolve_project_identity ( dir. path ( ) ) ;
293- assert ! ( identity. starts_with( "dir:" ) , "{identity}" ) ;
294- assert_eq ! ( identity. len( ) , 16 ) ;
103+ let identity = normalize_stored_project_path ( dir. path ( ) . to_str ( ) . unwrap ( ) ) ;
104+ assert_eq ! ( identity, expected_dir_identity( dir. path( ) ) ) ;
295105 }
296106
297107 #[ test]
298- fn only_deterministic_failures_are_cacheable ( ) {
299- // Mirrors the TS resolver: not-git and inaccessible-path are
300- // deterministic (cacheable `dir:` fallback); transient failures
301- // (git missing/timeout/permission/unknown) must NOT be cached so a
302- // retry can still resolve the real `git:` identity.
303- assert ! ( IdentityErrorClass :: NotGitRepo . is_deterministic_fallback( ) ) ;
304- assert ! ( IdentityErrorClass :: PathInaccessible . is_deterministic_fallback( ) ) ;
305- assert ! ( !IdentityErrorClass :: GitMissing . is_deterministic_fallback( ) ) ;
306- assert ! ( !IdentityErrorClass :: GitTimeout . is_deterministic_fallback( ) ) ;
307- assert ! ( !IdentityErrorClass :: PermissionDenied . is_deterministic_fallback( ) ) ;
308- assert ! ( !IdentityErrorClass :: Unknown . is_deterministic_fallback( ) ) ;
108+ fn relative_raw_paths_are_resolved_logically_before_hashing ( ) {
109+ let cwd = std:: env:: current_dir ( ) . unwrap ( ) ;
110+ let resolved = logical_absolute ( Path :: new ( "relative/project" ) , & cwd) ;
111+ assert_eq ! (
112+ normalize_stored_project_path( "relative/project" ) ,
113+ expected_dir_identity( & resolved)
114+ ) ;
309115 }
310116
311117 #[ test]
312- fn inaccessible_path_classifies_deterministically ( ) {
313- let missing = std:: env:: temp_dir ( ) . join ( "mc-nonexistent-проект-xyz-987654321" ) ;
314- let err = resolve_project_identity_strict ( & missing) . unwrap_err ( ) ;
315- assert_eq ! ( err, IdentityErrorClass :: PathInaccessible ) ;
316- // Deterministic → resolve_project_identity returns a cached dir: fallback.
317- clear_cache_for_tests ( ) ;
318- let identity = resolve_project_identity ( & missing) ;
319- assert ! ( identity. starts_with( "dir:" ) , "{identity}" ) ;
118+ fn basename_uses_last_path_component_when_present ( ) {
119+ assert_eq ! ( basename( "/tmp/example" ) , "example" ) ;
120+ assert_eq ! ( basename( "/" ) , "/" ) ;
320121 }
321122}
0 commit comments