@@ -779,6 +779,43 @@ async fn session_log_content_async(log: &agents::SessionLog) -> Option<String> {
779779 }
780780}
781781
782+ /// Maximum number of candidate directories to try when resolving a repo
783+ /// from transcript content. Prevents excessive `git rev-parse` calls when
784+ /// a transcript references many unique directories.
785+ const TRANSCRIPT_CWD_MAX_CANDIDATES : usize = 20 ;
786+
787+ /// Attempt to resolve a session's repo root by scanning transcript content
788+ /// for file paths when the recorded `cwd` doesn't resolve to a git repo.
789+ ///
790+ /// This is a fallback for sessions (typically Claude desktop app) where the
791+ /// manifest's `cwd` points to a parent directory rather than a specific repo.
792+ /// Reads the transcript, extracts absolute file paths from tool-call inputs,
793+ /// and tries to resolve each to a git repo root.
794+ ///
795+ /// Caps the number of candidates tried at [`TRANSCRIPT_CWD_MAX_CANDIDATES`]
796+ /// to bound the cost of git subprocess calls.
797+ async fn resolve_repo_from_transcript (
798+ log : & agents:: SessionLog ,
799+ repo_root_cache : & std:: collections:: HashMap < String , git:: RepoRootResolution > ,
800+ ) -> Option < git:: RepoRootResolution > {
801+ let content = session_log_content_async ( log) . await ?;
802+ let candidate_cwds = scanner:: extract_candidate_cwds_from_transcript ( & content) ;
803+
804+ for candidate_cwd in candidate_cwds. iter ( ) . take ( TRANSCRIPT_CWD_MAX_CANDIDATES ) {
805+ // Check cache first.
806+ if let Some ( cached) = repo_root_cache. get ( candidate_cwd. as_str ( ) ) {
807+ return Some ( cached. clone ( ) ) ;
808+ }
809+
810+ let cwd_path = std:: path:: Path :: new ( & candidate_cwd) ;
811+ if let Ok ( resolution) = git:: resolve_repo_root_with_fallbacks ( cwd_path) . await {
812+ return Some ( resolution) ;
813+ }
814+ }
815+
816+ None
817+ }
818+
782819/// Parse a duration string like "7d", "30d", "1d" into seconds.
783820///
784821/// Currently only supports the `<N>d` format (number of days).
@@ -1437,36 +1474,55 @@ async fn run_backfill_inner_with_invocation(
14371474 let resolved = match git:: resolve_repo_root_with_fallbacks ( cwd_path) . await {
14381475 Ok ( resolution) => resolution,
14391476 Err ( diagnostics) => {
1440- :: tracing:: warn!(
1441- event = "session_discovery_skipped" ,
1442- file = file_path. as_str( ) ,
1443- session_id = ?metadata. session_id,
1444- cwd = cwd. as_str( ) ,
1445- requested_cwd = diagnostics. requested_cwd. to_string_lossy( ) . to_string( ) ,
1446- reason = "repo_root_lookup_failed" ,
1447- error = ?diagnostics. direct_error,
1448- cwd_exists = diagnostics. cwd_exists,
1449- nearest_existing_ancestor = ?diagnostics
1450- . nearest_existing_ancestor
1451- . map( |path| path. to_string_lossy( ) . to_string( ) ) ,
1452- ancestor_error = ?diagnostics. ancestor_error,
1453- candidate_repo_names = ?diagnostics. candidate_repo_names,
1454- candidate_owner_repo_roots = ?diagnostics
1455- . candidate_owner_repo_roots
1456- . into_iter( )
1457- . map( |path| path. to_string_lossy( ) . to_string( ) )
1458- . collect:: <Vec <_>>( ) ,
1459- matched_worktree_owner_repo_root = ?diagnostics
1460- . matched_worktree_owner_repo_root
1461- . map( |path| path. to_string_lossy( ) . to_string( ) ) ,
1462- matched_worktree_path = ?diagnostics
1463- . matched_worktree_path
1464- . map( |path| path. to_string_lossy( ) . to_string( ) ) ,
1465- ) ;
1466- if let Some ( ref pb) = progress {
1467- pb. inc ( 1 ) ;
1477+ // Fallback: scan transcript content for file paths that
1478+ // reveal the actual working directory. This handles cases
1479+ // like Claude desktop sessions where the manifest's `cwd`
1480+ // points to a parent directory (e.g. ~/Documents/GitHub)
1481+ // rather than the specific repo.
1482+ let transcript_resolution =
1483+ resolve_repo_from_transcript ( log, & repo_root_cache) . await ;
1484+
1485+ if let Some ( resolution) = transcript_resolution {
1486+ :: tracing:: info!(
1487+ event = "session_cwd_resolved_from_transcript" ,
1488+ file = file_path. as_str( ) ,
1489+ session_id = ?metadata. session_id,
1490+ original_cwd = cwd. as_str( ) ,
1491+ resolved_repo = resolution. repo_root. to_string_lossy( ) . to_string( ) ,
1492+ ) ;
1493+ resolution
1494+ } else {
1495+ :: tracing:: warn!(
1496+ event = "session_discovery_skipped" ,
1497+ file = file_path. as_str( ) ,
1498+ session_id = ?metadata. session_id,
1499+ cwd = cwd. as_str( ) ,
1500+ requested_cwd = diagnostics. requested_cwd. to_string_lossy( ) . to_string( ) ,
1501+ reason = "repo_root_lookup_failed" ,
1502+ error = ?diagnostics. direct_error,
1503+ cwd_exists = diagnostics. cwd_exists,
1504+ nearest_existing_ancestor = ?diagnostics
1505+ . nearest_existing_ancestor
1506+ . map( |path| path. to_string_lossy( ) . to_string( ) ) ,
1507+ ancestor_error = ?diagnostics. ancestor_error,
1508+ candidate_repo_names = ?diagnostics. candidate_repo_names,
1509+ candidate_owner_repo_roots = ?diagnostics
1510+ . candidate_owner_repo_roots
1511+ . into_iter( )
1512+ . map( |path| path. to_string_lossy( ) . to_string( ) )
1513+ . collect:: <Vec <_>>( ) ,
1514+ matched_worktree_owner_repo_root = ?diagnostics
1515+ . matched_worktree_owner_repo_root
1516+ . map( |path| path. to_string_lossy( ) . to_string( ) ) ,
1517+ matched_worktree_path = ?diagnostics
1518+ . matched_worktree_path
1519+ . map( |path| path. to_string_lossy( ) . to_string( ) ) ,
1520+ ) ;
1521+ if let Some ( ref pb) = progress {
1522+ pb. inc ( 1 ) ;
1523+ }
1524+ continue ;
14681525 }
1469- continue ;
14701526 }
14711527 } ;
14721528 repo_root_cache. insert ( cwd. clone ( ) , resolved. clone ( ) ) ;
@@ -1801,10 +1857,26 @@ async fn upload_incremental_sessions_globally(
18011857 let resolved_repo = if let Some ( cached) = repo_root_cache. get ( & cwd) {
18021858 cached. clone ( )
18031859 } else {
1804- let resolved = git:: resolve_repo_root_with_fallbacks ( std:: path:: Path :: new ( & cwd) )
1860+ let mut resolved = git:: resolve_repo_root_with_fallbacks ( std:: path:: Path :: new ( & cwd) )
18051861 . await
18061862 . ok ( )
18071863 . map ( |resolution| resolution. repo_root ) ;
1864+
1865+ // Fallback: scan transcript for file paths when CWD doesn't
1866+ // resolve to a repo (e.g. Claude desktop app parent-dir CWD).
1867+ if resolved. is_none ( )
1868+ && let Some ( content) = session_log_content_async ( & parsed. log ) . await
1869+ {
1870+ let candidates = scanner:: extract_candidate_cwds_from_transcript ( & content) ;
1871+ for candidate_cwd in candidates. iter ( ) . take ( TRANSCRIPT_CWD_MAX_CANDIDATES ) {
1872+ let cwd_path = std:: path:: Path :: new ( & candidate_cwd) ;
1873+ if let Ok ( resolution) = git:: resolve_repo_root_with_fallbacks ( cwd_path) . await {
1874+ resolved = Some ( resolution. repo_root ) ;
1875+ break ;
1876+ }
1877+ }
1878+ }
1879+
18081880 repo_root_cache. insert ( cwd. clone ( ) , resolved. clone ( ) ) ;
18091881 resolved
18101882 } ;
0 commit comments