sources: chat cursor and purge-tombstone keys are never read as repo names by the forge retire

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Eureka 2026-11-11 19:14:03 +00:00
commit f855f6bad3
3 changed files with 40 additions and 1 deletions

View file

@ -0,0 +1,17 @@
//! Chat ingest: joined-room history into the shared source collection, beside
//! the forge's repos. `refresh_chat` is the only writer of `corpus=chat` points
//! and of the cursors object's chat keys.
/// A chat cursor is the key `chat:<room_id>`; a forge repo name cannot contain
/// a colon. The only definition of the prefix.
pub const CHAT_CURSOR_PREFIX: &str = "chat:";
/// A steward's purge tombstone is the key `chat-purge:<room_id>`. The only
/// definition of the prefix.
pub const CHAT_PURGE_PREFIX: &str = "chat-purge:";
/// Whether a cursors-object key is chat's (a room cursor or a purge tombstone)
/// and not a forge repo name. The only test that tells the two apart.
pub fn is_chat_key(key: &str) -> bool {
key.starts_with(CHAT_CURSOR_PREFIX) || key.starts_with(CHAT_PURGE_PREFIX)
}

View file

@ -19,6 +19,7 @@
//! the delete and the upserts leaves no cursor, and the repo is ingested whole.
//! The cursors object is written whole by this job alone, once per repo.
pub mod chat;
pub mod chunk;
pub mod forge;
pub mod git;
@ -381,7 +382,7 @@ fn retire<S: SourceIndex>(
failed: &mut Vec<String>,
) {
let listed = |name: &str| catalog.iter().any(|repo| repo.name == name);
let mut gone: BTreeSet<String> = cursors.keys().filter(|name| !listed(name)).cloned().collect();
let mut gone: BTreeSet<String> = cursors.keys().filter(|name| !chat::is_chat_key(name) && !listed(name)).cloned().collect();
if let Ok(entries) = std::fs::read_dir(mirrors) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_string_lossy().strip_suffix(".git")

View file

@ -974,3 +974,24 @@ fn the_span_bounds_are_inclusive_for_chunks_and_for_messages() {
assert!(filter.keeps("!a:x", None, 100, 200) && filter.keeps("!a:x", None, 300, 400));
assert!(!filter.keeps("!a:x", None, 100, 199) && !filter.keeps("!a:x", None, 301, 400));
}
/// A chat cursor and a purge tombstone share the forge's cursors object; the
/// forge retire never reads either as a repo name, and still retires a repo.
#[test]
fn chat_cursor_and_purge_tombstone_keys_survive_the_forge_retire() {
let (keep, gone) = (Remote::new(), Remote::new());
keep.commit(&[("a.rs", b"a\n")], &[]);
gone.commit(&[("b.rs", b"b\n")], &[]);
let mut rig = Rig::new();
rig.converged(&mut FakeForge::of(&[("Keep", &keep), ("Gone", &gone)]));
let mut cursors = rig.index.cursors();
cursors.insert("chat:!r:s".into(), "1:$e".into());
cursors.insert("chat-purge:!r:s".into(), "1..2".into());
rig.index.set_cursors(&cursors).unwrap();
rig.index.upsert_chat(&[chat_point("!r:s", None, 0, 4, &[("$e", 5)])]).unwrap();
rig.converged(&mut FakeForge::of(&[("Keep", &keep)]));
let after = rig.index.cursors();
assert_eq!(after.keys().map(String::as_str).collect::<Vec<_>>(), ["Keep", "chat-purge:!r:s", "chat:!r:s"], "Gone retired; both chat keys kept");
assert!(rig.index.state.lock().unwrap().deletes.iter().all(|(repo, _)| !repo.starts_with("chat")));
assert_eq!(rig.index.state.lock().unwrap().chat.len(), 1);
}