From 9b5b3a2f6a5616ade5fc6650f9c0fc3acd9b800b Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Tue, 28 Jul 2026 20:47:44 +0200 Subject: [PATCH] fix: less fragile dev brain sources revisions --- src/app.rs | 19 ++++ src/app/preferences.rs | 1 + src/app/view/preferences.rs | 35 ++++++- src/dev_brain.rs | 187 ++++++++++++++++++++---------------- 4 files changed, 156 insertions(+), 86 deletions(-) diff --git a/src/app.rs b/src/app.rs index fe502cd..d0ca3a2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -67,6 +67,7 @@ pub(crate) struct App { config: Config, preference_draft: PreferenceDraft, preference_error: Option, + restore_dev_brain_confirmation: bool, selected_project: Option, selected_session: Option, /// Unsaved sessions, keyed by project. A draft only becomes a `sessions` row @@ -361,6 +362,8 @@ pub(crate) enum Message { ChooseDevBrainVault, DevBrainVaultPicked(Option), RestoreDevBrainDefaultGuides, + ConfirmRestoreDevBrainDefaultGuides, + CancelRestoreDevBrainDefaultGuides, PreferenceContextChanged(String), PreferenceMaxTokensChanged(String), PreferenceSystemPromptAction(text_editor::Action), @@ -526,6 +529,7 @@ impl App { config, preference_draft, preference_error: None, + restore_dev_brain_confirmation: false, selected_project: last_project, selected_session: None, drafts, @@ -669,6 +673,7 @@ impl App { config, preference_draft, preference_error: None, + restore_dev_brain_confirmation: false, selected_project: None, selected_session: None, drafts: HashMap::new(), @@ -951,6 +956,7 @@ impl App { Message::ClosePreferences => { if let Some(id) = self.preferences_window { self.preference_error = None; + self.restore_dev_brain_confirmation = false; return window::close(id); } } @@ -1033,6 +1039,7 @@ impl App { if self.preferences_window == Some(id) { self.preferences_window = None; self.preference_error = None; + self.restore_dev_brain_confirmation = false; } if self.help_window == Some(id) { self.help_window = None; @@ -1048,6 +1055,10 @@ impl App { Message::CancelQuit => self.quit_confirmation = false, Message::Escape(id) => { if self.preferences_window == Some(id) { + if self.restore_dev_brain_confirmation { + self.restore_dev_brain_confirmation = false; + return Task::none(); + } return self.update(Message::ClosePreferences); } if id == self.main_window { @@ -1239,11 +1250,19 @@ impl App { } } Message::RestoreDevBrainDefaultGuides => { + self.preference_error = None; + self.restore_dev_brain_confirmation = true; + } + Message::ConfirmRestoreDevBrainDefaultGuides => { + self.restore_dev_brain_confirmation = false; self.preference_error = crate::dev_brain::restore_default_guides(Path::new( &self.preference_draft.dev_brain_vault_path, )) .err(); } + Message::CancelRestoreDevBrainDefaultGuides => { + self.restore_dev_brain_confirmation = false; + } Message::PreferenceContextChanged(value) => { self.preference_draft.context_tokens = value; self.preference_error = None; diff --git a/src/app/preferences.rs b/src/app/preferences.rs index 913ac43..5421806 100644 --- a/src/app/preferences.rs +++ b/src/app/preferences.rs @@ -358,6 +358,7 @@ impl App { } self.preference_draft = PreferenceDraft::from_saved(&self.config); self.preference_error = None; + self.restore_dev_brain_confirmation = false; let (id, open) = window::open(window::Settings { size: Size::new(920.0, 700.0), min_size: Some(Size::new(720.0, 480.0)), diff --git a/src/app/view/preferences.rs b/src/app/view/preferences.rs index 030a045..01f3406 100644 --- a/src/app/view/preferences.rs +++ b/src/app/view/preferences.rs @@ -704,7 +704,7 @@ impl App { ] .spacing(8); - container(row![ + let base: Element<'_, Message> = container(row![ container(navigation) .width(210) .height(Length::Fill) @@ -731,6 +731,39 @@ impl App { ]) .width(Length::Fill) .height(Length::Fill) + .into(); + if !self.restore_dev_brain_confirmation { + return base; + } + let confirmation = container( + column![ + text("Recreate Dev Brain guidance?").size(22), + text("This replaces purpose.md and schema.md in the selected vault with this version's defaults. Topic pages, generated indexes, skills, and log.md are preserved.") + .size(13), + row![ + Space::new().width(Length::Fill), + action_button("Cancel") + .on_press(Message::CancelRestoreDevBrainDefaultGuides), + danger_button("Recreate files") + .on_press(Message::ConfirmRestoreDevBrainDefaultGuides), + ] + .spacing(8), + ] + .spacing(14), + ) + .padding(22) + .width(460) + .style(overview_style); + stack![ + base, + opaque( + container(confirmation) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(|_| container::Style::default() + .background(Color::from_rgba8(0, 0, 0, 0.68))) + ) + ] .into() } } diff --git a/src/dev_brain.rs b/src/dev_brain.rs index b2aafe4..2cce9f3 100644 --- a/src/dev_brain.rs +++ b/src/dev_brain.rs @@ -74,14 +74,14 @@ sources: - project: Registered project name path: src/example.rs symbol: optional_symbol - revision: current-project-head-revision - # Use `git rev-parse HEAD`, or a unique lowercase hex prefix of at least 7 characters, - # when this source file matches HEAD. Use hash when this file differs from HEAD, - # is untracked, or the registered project is not Git. + revision: latest-commit-containing-this-file-content + # For a clean tracked file, use `git log -1 --format=%H -- src/example.rs`, + # or a unique lowercase hex prefix of at least 7 characters. Use hash when + # this file differs from HEAD, is untracked, or the registered project is not Git. --- ``` -`project` names a registered project, not this Dev Brain vault; `dev_brain_info` lists the exact registered names and folders. Each source `path` is relative to that registered project's folder; never resolve it inside the vault. Cite only files that support the page's claims, not every dirty file in the project. Each source has exactly one evidence version: `revision` or a lowercase SHA-256 `hash`. A revision is the current project HEAD's full object ID or a unique lowercase hexadecimal prefix of at least 7 characters, and is valid when that specific source file matches HEAD even if unrelated files are dirty. Use a hash when that specific file differs from HEAD, is untracked, or its registered project is not Git. +`project` names a registered project, not this Dev Brain vault; `dev_brain_info` lists the exact registered names and folders. Each source `path` is relative to that registered project's folder; never resolve it inside the vault. Cite only files that support the page's claims, not every dirty file in the project. Each source has exactly one evidence version: `revision` or a lowercase SHA-256 `hash`. A revision is a commit whose version of that source file exactly matches the current clean file, preferably the latest commit returned by `git log -1 --format=%H -- path`; validation compares that file at the cited revision, not the repository's current HEAD. Use a hash when that specific file differs from HEAD, is untracked, or its registered project is not Git. ## Skills @@ -601,7 +601,6 @@ impl DevBrain { ) .load::(&mut self.connection) .map_err(|error| error.to_string())?; - let mut project_names = BTreeSet::new(); for source in sources { let project = self .projects @@ -626,24 +625,11 @@ impl DevBrain { path: format!("source:{}:{}", source.project, source.source_path), modified_nanos, size: metadata.len(), - hash: hash_file(&full)?, - }); - project_names.insert(source.project); - } - for name in project_names { - let project = self - .projects - .iter() - .find(|project| project.name == name) - .unwrap(); - let state = git_state(&project.root) - .map(|(revision, clean)| format!("{revision}:{clean}")) - .unwrap_or_default(); - fingerprints.push(Fingerprint { - path: format!("git:{name}"), - modified_nanos: 0, - size: 0, - hash: state, + hash: format!( + "{}:{:?}", + hash_file(&full)?, + git_source_is_clean(&project.root, &full) + ), }); } fingerprints.sort_by(|left, right| left.path.cmp(&right.path)); @@ -1246,11 +1232,8 @@ fn validate_source(source: &SourceRecord, projects: &[RegisteredProject]) -> Res )); } if let Some(revision) = &source.revision { - return Ok( - git_source_state(&project.root, &resolved).is_some_and(|(current, clean)| { - clean && git_revision_matches(&project.root, revision, ¤t) - }), - ); + return Ok(git_source_is_clean(&project.root, &resolved) == Some(true) + && git_revision_matches_source(&project.root, &relative, &resolved, revision)); } let expected = source.hash.as_deref().unwrap(); if expected.len() != 64 @@ -1264,64 +1247,46 @@ fn validate_source(source: &SourceRecord, projects: &[RegisteredProject]) -> Res )); } Ok(hash_file(&resolved)? == expected - && !git_source_state(&project.root, &resolved).is_some_and(|(_, clean)| clean)) + && git_source_is_clean(&project.root, &resolved) != Some(true)) } -fn git_source_state(root: &Path, source: &Path) -> Option<(String, bool)> { +fn git_source_is_clean(root: &Path, source: &Path) -> Option { let repository = git2::Repository::discover(root).ok()?; - let relative = source.strip_prefix(repository.workdir()?).ok()?; - let clean = repository.status_file(relative).ok()? == git2::Status::CURRENT; - let revision = repository - .head() - .ok()? - .peel_to_commit() - .ok()? - .id() - .to_string(); - Some((revision, clean)) + let workdir = repository.workdir()?.canonicalize().ok()?; + let relative = source.strip_prefix(workdir).ok()?; + Some(repository.status_file(relative).ok()? == git2::Status::CURRENT) } -fn git_revision_matches(root: &Path, revision: &str, current: &str) -> bool { - if revision == current { - return true; - } - if revision.len() < 7 - || revision.len() >= current.len() +fn git_revision_matches_source( + root: &Path, + relative: &Path, + source: &Path, + revision: &str, +) -> bool { + if !(7..=40).contains(&revision.len()) || !revision .bytes() .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) { return false; } - git2::Repository::discover(root) - .ok() - .is_some_and(|repository| { - repository - .revparse_single(revision) - .ok() - .and_then(|object| object.peel_to_commit().ok()) - .is_some_and(|commit| commit.id().to_string() == current) - }) -} - -fn git_state(root: &Path) -> Option<(String, bool)> { - let repository = git2::Repository::discover(root).ok()?; - let revision = repository - .head() - .ok()? - .peel_to_commit() - .ok()? - .id() - .to_string(); - let mut options = git2::StatusOptions::new(); - options - .include_untracked(true) - .recurse_untracked_dirs(true) - .include_ignored(false); - Some(( - revision, - repository.statuses(Some(&mut options)).ok()?.is_empty(), - )) + let Some(matches) = (|| { + let repository = git2::Repository::discover(root).ok()?; + let workdir = repository.workdir()?.canonicalize().ok()?; + let project = root.canonicalize().ok()?; + let path = project.strip_prefix(workdir).ok()?.join(relative); + let commit = repository + .revparse_single(revision) + .ok()? + .peel_to_commit() + .ok()?; + let entry = commit.tree().ok()?.get_path(&path).ok()?; + let blob = repository.find_blob(entry.id()).ok()?; + Some(fs::read(source).ok()?.as_slice() == blob.content()) + })() else { + return false; + }; + matches } fn collect_managed_topics( @@ -1954,21 +1919,23 @@ mod tests { index.write().unwrap(); let tree = repository.find_tree(tree_id).unwrap(); let signature = git2::Signature::now("DS4Server", "ds4@example.invalid").unwrap(); - repository + let revision = repository .commit(Some("HEAD"), &signature, &signature, "Initial", &tree, &[]) - .unwrap(); + .unwrap() + .to_string(); drop(tree); drop(repository); - let revision = git_state(&fixture.project).unwrap().0; - assert!(git_revision_matches( + assert!(git_revision_matches_source( &fixture.project, - &revision[..7], - &revision + Path::new("source.rs"), + &fixture.project.join("source.rs"), + &revision[..7] )); - assert!(!git_revision_matches( + assert!(!git_revision_matches_source( &fixture.project, - &revision[..6], - &revision + Path::new("source.rs"), + &fixture.project.join("source.rs"), + &revision[..6] )); let topic = fixture .topic("verified", &"0".repeat(64), "Revision-backed knowledge.") @@ -1984,7 +1951,30 @@ mod tests { .unwrap() .contains("concepts/answer.md") ); + let fingerprint = brain.current_fingerprint().unwrap(); fs::write(fixture.project.join("unrelated.rs"), "dirty elsewhere\n").unwrap(); + let repository = git2::Repository::open(&fixture.project).unwrap(); + let mut index = repository.index().unwrap(); + index.add_path(Path::new("unrelated.rs")).unwrap(); + let tree_id = index.write_tree().unwrap(); + index.write().unwrap(); + let tree = repository.find_tree(tree_id).unwrap(); + let parent = repository.head().unwrap().peel_to_commit().unwrap(); + repository + .commit( + Some("HEAD"), + &signature, + &signature, + "Unrelated", + &tree, + &[&parent], + ) + .unwrap(); + drop(parent); + drop(tree); + drop(repository); + assert!(brain.current_fingerprint().unwrap() == fingerprint); + brain.validate().unwrap(); assert!( brain .search("Revision-backed knowledge", 10, true) @@ -1998,6 +1988,33 @@ mod tests { .unwrap() .contains("status: stale") ); + let repository = git2::Repository::open(&fixture.project).unwrap(); + let mut index = repository.index().unwrap(); + index.add_path(Path::new("source.rs")).unwrap(); + let tree_id = index.write_tree().unwrap(); + index.write().unwrap(); + let tree = repository.find_tree(tree_id).unwrap(); + let parent = repository.head().unwrap().peel_to_commit().unwrap(); + repository + .commit( + Some("HEAD"), + &signature, + &signature, + "Source changed", + &tree, + &[&parent], + ) + .unwrap(); + drop(parent); + drop(tree); + drop(repository); + assert!( + brain + .validate() + .unwrap_err() + .contains("evidence has drifted") + ); + fs::write(fixture.project.join("source.rs"), "dirty again\n").unwrap(); let topic = fixture.topic( "verified", &source_hash(&fixture),