Skip to content

Commit bd73036

Browse files
fix(sessions): follow store remote fallback for renamed checkouts (#269)
When a checkout directory is renamed or moved on disk, both its path alias and git_common_dir change, so registry identity resolution no longer matches. The graph store already recovers via `resolve_store_layout_for_project`, which falls back to a unique git-remote match and reopens the originally registered store. Session-DB routing did not: `registry_profile_session_db_path` was identity-only, so after a rename the checkout reopened its graph store correctly but started a fresh session history at a new default path, silently splitting session continuity from graph continuity. Make the session-DB resolver mirror the same identity-then-unique-remote fallback, with the same remote-uniqueness gating. To preserve the clone-borrow guard (a second same-remote clone must not inherit another checkout's session store), the remote fallback is only accepted when the matched project's registered checkout no longer exists on disk: a rename leaves neither the canonical root nor the git common dir behind, whereas a live clone leaves the original checkout in place. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 80a9e16 commit bd73036

4 files changed

Lines changed: 176 additions & 3 deletions

File tree

src/sessions/cursor.rs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,31 @@ async fn registry_profile_session_db_path(project_root: &Path) -> Option<PathBuf
7878
let profile_root = crate::storage::default_profile_root().ok()?;
7979
let global = GlobalDb::open().await?;
8080
let git_common_dir = crate::worktree::git_common_dir(project_root);
81-
let resolution = global
81+
// Mirror the graph store's identity-then-unique-remote fallback
82+
// (`resolve_store_layout_for_project` in tracedecay/lifecycle.rs) so a
83+
// renamed/moved checkout keeps routing its session history to the store it
84+
// was originally registered under, instead of silently forking a fresh
85+
// session DB at a new default path.
86+
let resolution = if let Some(resolution) = global
8287
.resolve_project_store_by_identity(project_root, git_common_dir.as_deref())
83-
.await?;
88+
.await
89+
{
90+
resolution
91+
} else {
92+
let remote = crate::tracedecay::git_remote_url(project_root)?;
93+
let resolution = global
94+
.resolve_unique_project_store_by_git_remote(&remote)
95+
.await?;
96+
// Remote uniqueness alone cannot tell a renamed checkout (whose
97+
// original registered location no longer exists on disk) apart from
98+
// a second, still-present clone of the same remote. Only borrow the
99+
// registered store when the original checkout is gone, so a live
100+
// clone never inherits another checkout's session history.
101+
if registered_checkout_present(&resolution.project) {
102+
return None;
103+
}
104+
resolution
105+
};
84106
if resolution.store.storage_mode != "profile_sharded" {
85107
return None;
86108
}
@@ -91,6 +113,23 @@ async fn registry_profile_session_db_path(project_root: &Path) -> Option<PathBuf
91113
)
92114
}
93115

116+
/// Returns `true` when the checkout a registered project was recorded at still
117+
/// exists on disk. A renamed/moved checkout leaves neither its canonical root
118+
/// nor its git common dir behind, whereas a separate clone of the same remote
119+
/// leaves the original checkout in place.
120+
fn registered_checkout_present(project: &crate::global_db::CodeProjectRecord) -> bool {
121+
let roots = [
122+
Some(project.canonical_root.as_str()),
123+
Some(project.display_root.as_str()),
124+
project.git_common_dir.as_deref(),
125+
];
126+
roots
127+
.into_iter()
128+
.flatten()
129+
.filter(|root| !root.is_empty())
130+
.any(|root| Path::new(root).exists())
131+
}
132+
94133
fn is_hermes_profile_home(path: &Path) -> bool {
95134
path.join("config.yaml").is_file() || path.join("state.db").is_file()
96135
}

src/tracedecay.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ pub use diagnostics::{BranchDiagnostics, TrackedBranchDiagnostic};
3030
#[doc(hidden)]
3131
pub use locking::{try_acquire_sync_lock, SyncLockGuard};
3232

33+
pub(crate) use lifecycle::git_remote_url;
34+
3335
/// Central orchestrator that coordinates all subsystems of the code graph.
3436
///
3537
/// Provides a high-level API for initializing, indexing, querying, and

src/tracedecay/lifecycle.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -838,7 +838,7 @@ fn profile_store_id(project_id: &str) -> String {
838838
format!("store:{project_id}:profile_sharded")
839839
}
840840

841-
fn git_remote_url(project_root: &Path) -> Option<String> {
841+
pub(crate) fn git_remote_url(project_root: &Path) -> Option<String> {
842842
// gix reads the same config `git config --get` would (repo-local +
843843
// global) without a subprocess spawn.
844844
if let Ok(repo) = gix::discover(project_root) {

tests/storage_suite/storage_resolver_test.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,138 @@ async fn same_remote_clone_is_not_considered_initialized_without_local_identity(
782782
);
783783
}
784784

785+
#[tokio::test]
786+
async fn renamed_checkout_session_db_follows_registered_store() {
787+
let _guard = HOME_ENV_LOCK.lock().await;
788+
let dir = TempDir::new().unwrap();
789+
let remote = dir.path().join("remote.git");
790+
let original = dir.path().join("repo");
791+
let renamed = dir.path().join("repo-renamed");
792+
let home = test_home(&dir);
793+
let _home_guard = HomeGuard::set(&home);
794+
795+
git(dir.path(), &["init", "--bare", remote.to_str().unwrap()]);
796+
git(
797+
dir.path(),
798+
&[
799+
"clone",
800+
remote.to_str().unwrap(),
801+
original.to_str().unwrap(),
802+
],
803+
);
804+
fs::create_dir_all(original.join("src")).unwrap();
805+
fs::write(original.join("src/lib.rs"), "pub fn main_only() {}\n").unwrap();
806+
git(&original, &["config", "user.email", "test@example.com"]);
807+
git(&original, &["config", "user.name", "TraceDecay Test"]);
808+
git(&original, &["add", "."]);
809+
git(&original, &["commit", "-m", "initial"]);
810+
git(&original, &["push", "origin", "HEAD:master"]);
811+
812+
let cg = TraceDecay::init(&original).await.unwrap();
813+
let registered_session_db = cg.store_layout().sessions_db_path.clone();
814+
drop(cg);
815+
816+
// Move the whole checkout on disk; both its canonical root and git common
817+
// dir change, so registry identity resolution can no longer match by path.
818+
fs::rename(&original, &renamed).unwrap();
819+
820+
let resolved = resolved_project_session_db_path(&renamed)
821+
.await
822+
.expect("renamed checkout should resolve a session DB path");
823+
assert_path_eq(&resolved, &registered_session_db);
824+
assert_ne!(
825+
normalize_test_path(&resolved),
826+
normalize_test_path(&project_session_db_path(&renamed)),
827+
"renamed checkout must not fork a fresh default-path session DB",
828+
);
829+
}
830+
831+
#[tokio::test]
832+
async fn same_remote_clone_session_db_does_not_borrow_registered_store() {
833+
let _guard = HOME_ENV_LOCK.lock().await;
834+
let dir = TempDir::new().unwrap();
835+
let remote = dir.path().join("remote.git");
836+
let project = dir.path().join("repo");
837+
let clone = dir.path().join("repo-clone");
838+
let home = test_home(&dir);
839+
let _home_guard = HomeGuard::set(&home);
840+
841+
git(dir.path(), &["init", "--bare", remote.to_str().unwrap()]);
842+
git(
843+
dir.path(),
844+
&["clone", remote.to_str().unwrap(), project.to_str().unwrap()],
845+
);
846+
fs::create_dir_all(project.join("src")).unwrap();
847+
fs::write(project.join("src/lib.rs"), "pub fn main_only() {}\n").unwrap();
848+
git(&project, &["config", "user.email", "test@example.com"]);
849+
git(&project, &["config", "user.name", "TraceDecay Test"]);
850+
git(&project, &["add", "."]);
851+
git(&project, &["commit", "-m", "initial"]);
852+
git(&project, &["push", "origin", "HEAD:master"]);
853+
git(
854+
dir.path(),
855+
&["clone", remote.to_str().unwrap(), clone.to_str().unwrap()],
856+
);
857+
858+
let cg = TraceDecay::init(&project).await.unwrap();
859+
let registered_session_db = cg.store_layout().sessions_db_path.clone();
860+
drop(cg);
861+
862+
// The original checkout still exists on disk, so the same-remote clone must
863+
// not inherit its registered session store even though the remote is unique
864+
// in the registry.
865+
let resolved = resolved_project_session_db_path(&clone)
866+
.await
867+
.expect("clone should still resolve a default session DB path");
868+
assert_ne!(
869+
normalize_test_path(&resolved),
870+
normalize_test_path(&registered_session_db),
871+
"a separate same-remote clone must not borrow another checkout's session store",
872+
);
873+
assert_path_eq(&resolved, project_session_db_path(&clone));
874+
}
875+
876+
#[tokio::test]
877+
async fn ambiguous_remote_session_db_falls_back_to_default_path() {
878+
let _guard = HOME_ENV_LOCK.lock().await;
879+
let dir = TempDir::new().unwrap();
880+
let remote = dir.path().join("remote.git");
881+
let one = dir.path().join("repo-one");
882+
let two = dir.path().join("repo-two");
883+
let renamed_one = dir.path().join("repo-one-renamed");
884+
let home = test_home(&dir);
885+
let _home_guard = HomeGuard::set(&home);
886+
887+
git(dir.path(), &["init", "--bare", remote.to_str().unwrap()]);
888+
git(
889+
dir.path(),
890+
&["clone", remote.to_str().unwrap(), one.to_str().unwrap()],
891+
);
892+
fs::create_dir_all(one.join("src")).unwrap();
893+
fs::write(one.join("src/lib.rs"), "pub fn main_only() {}\n").unwrap();
894+
git(&one, &["config", "user.email", "test@example.com"]);
895+
git(&one, &["config", "user.name", "TraceDecay Test"]);
896+
git(&one, &["add", "."]);
897+
git(&one, &["commit", "-m", "initial"]);
898+
git(&one, &["push", "origin", "HEAD:master"]);
899+
git(
900+
dir.path(),
901+
&["clone", remote.to_str().unwrap(), two.to_str().unwrap()],
902+
);
903+
904+
// Two registered checkouts share the same remote, so remote-based fallback
905+
// is ambiguous and must be declined even after one checkout is renamed.
906+
TraceDecay::init(&one).await.unwrap();
907+
TraceDecay::init(&two).await.unwrap();
908+
909+
fs::rename(&one, &renamed_one).unwrap();
910+
911+
let resolved = resolved_project_session_db_path(&renamed_one)
912+
.await
913+
.expect("ambiguous-remote checkout should still resolve a default path");
914+
assert_path_eq(&resolved, project_session_db_path(&renamed_one));
915+
}
916+
785917
#[tokio::test]
786918
async fn nested_linked_worktree_does_not_discover_parent_checkout_marker() {
787919
let _guard = HOME_ENV_LOCK.lock().await;

0 commit comments

Comments
 (0)