fix: less fragile dev brain sources revisions

This commit is contained in:
Georg Bauer
2026-07-28 20:47:44 +02:00
parent ed8f7c4a01
commit 9b5b3a2f6a
4 changed files with 156 additions and 86 deletions

View File

@@ -67,6 +67,7 @@ pub(crate) struct App {
config: Config, config: Config,
preference_draft: PreferenceDraft, preference_draft: PreferenceDraft,
preference_error: Option<String>, preference_error: Option<String>,
restore_dev_brain_confirmation: bool,
selected_project: Option<i32>, selected_project: Option<i32>,
selected_session: Option<i32>, selected_session: Option<i32>,
/// Unsaved sessions, keyed by project. A draft only becomes a `sessions` row /// Unsaved sessions, keyed by project. A draft only becomes a `sessions` row
@@ -361,6 +362,8 @@ pub(crate) enum Message {
ChooseDevBrainVault, ChooseDevBrainVault,
DevBrainVaultPicked(Option<PathBuf>), DevBrainVaultPicked(Option<PathBuf>),
RestoreDevBrainDefaultGuides, RestoreDevBrainDefaultGuides,
ConfirmRestoreDevBrainDefaultGuides,
CancelRestoreDevBrainDefaultGuides,
PreferenceContextChanged(String), PreferenceContextChanged(String),
PreferenceMaxTokensChanged(String), PreferenceMaxTokensChanged(String),
PreferenceSystemPromptAction(text_editor::Action), PreferenceSystemPromptAction(text_editor::Action),
@@ -526,6 +529,7 @@ impl App {
config, config,
preference_draft, preference_draft,
preference_error: None, preference_error: None,
restore_dev_brain_confirmation: false,
selected_project: last_project, selected_project: last_project,
selected_session: None, selected_session: None,
drafts, drafts,
@@ -669,6 +673,7 @@ impl App {
config, config,
preference_draft, preference_draft,
preference_error: None, preference_error: None,
restore_dev_brain_confirmation: false,
selected_project: None, selected_project: None,
selected_session: None, selected_session: None,
drafts: HashMap::new(), drafts: HashMap::new(),
@@ -951,6 +956,7 @@ impl App {
Message::ClosePreferences => { Message::ClosePreferences => {
if let Some(id) = self.preferences_window { if let Some(id) = self.preferences_window {
self.preference_error = None; self.preference_error = None;
self.restore_dev_brain_confirmation = false;
return window::close(id); return window::close(id);
} }
} }
@@ -1033,6 +1039,7 @@ impl App {
if self.preferences_window == Some(id) { if self.preferences_window == Some(id) {
self.preferences_window = None; self.preferences_window = None;
self.preference_error = None; self.preference_error = None;
self.restore_dev_brain_confirmation = false;
} }
if self.help_window == Some(id) { if self.help_window == Some(id) {
self.help_window = None; self.help_window = None;
@@ -1048,6 +1055,10 @@ impl App {
Message::CancelQuit => self.quit_confirmation = false, Message::CancelQuit => self.quit_confirmation = false,
Message::Escape(id) => { Message::Escape(id) => {
if self.preferences_window == Some(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); return self.update(Message::ClosePreferences);
} }
if id == self.main_window { if id == self.main_window {
@@ -1239,11 +1250,19 @@ impl App {
} }
} }
Message::RestoreDevBrainDefaultGuides => { 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_error = crate::dev_brain::restore_default_guides(Path::new(
&self.preference_draft.dev_brain_vault_path, &self.preference_draft.dev_brain_vault_path,
)) ))
.err(); .err();
} }
Message::CancelRestoreDevBrainDefaultGuides => {
self.restore_dev_brain_confirmation = false;
}
Message::PreferenceContextChanged(value) => { Message::PreferenceContextChanged(value) => {
self.preference_draft.context_tokens = value; self.preference_draft.context_tokens = value;
self.preference_error = None; self.preference_error = None;

View File

@@ -358,6 +358,7 @@ impl App {
} }
self.preference_draft = PreferenceDraft::from_saved(&self.config); self.preference_draft = PreferenceDraft::from_saved(&self.config);
self.preference_error = None; self.preference_error = None;
self.restore_dev_brain_confirmation = false;
let (id, open) = window::open(window::Settings { let (id, open) = window::open(window::Settings {
size: Size::new(920.0, 700.0), size: Size::new(920.0, 700.0),
min_size: Some(Size::new(720.0, 480.0)), min_size: Some(Size::new(720.0, 480.0)),

View File

@@ -704,7 +704,7 @@ impl App {
] ]
.spacing(8); .spacing(8);
container(row![ let base: Element<'_, Message> = container(row![
container(navigation) container(navigation)
.width(210) .width(210)
.height(Length::Fill) .height(Length::Fill)
@@ -731,6 +731,39 @@ impl App {
]) ])
.width(Length::Fill) .width(Length::Fill)
.height(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() .into()
} }
} }

View File

@@ -74,14 +74,14 @@ sources:
- project: Registered project name - project: Registered project name
path: src/example.rs path: src/example.rs
symbol: optional_symbol symbol: optional_symbol
revision: current-project-head-revision revision: latest-commit-containing-this-file-content
# Use `git rev-parse HEAD`, or a unique lowercase hex prefix of at least 7 characters, # For a clean tracked file, use `git log -1 --format=%H -- src/example.rs`,
# when this source file matches HEAD. Use hash when this file differs from HEAD, # or a unique lowercase hex prefix of at least 7 characters. Use hash when
# is untracked, or the registered project is not Git. # 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 ## Skills
@@ -601,7 +601,6 @@ impl DevBrain {
) )
.load::<SourceKeyRow>(&mut self.connection) .load::<SourceKeyRow>(&mut self.connection)
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let mut project_names = BTreeSet::new();
for source in sources { for source in sources {
let project = self let project = self
.projects .projects
@@ -626,24 +625,11 @@ impl DevBrain {
path: format!("source:{}:{}", source.project, source.source_path), path: format!("source:{}:{}", source.project, source.source_path),
modified_nanos, modified_nanos,
size: metadata.len(), size: metadata.len(),
hash: hash_file(&full)?, hash: format!(
}); "{}:{:?}",
project_names.insert(source.project); hash_file(&full)?,
} git_source_is_clean(&project.root, &full)
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,
}); });
} }
fingerprints.sort_by(|left, right| left.path.cmp(&right.path)); 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 { if let Some(revision) = &source.revision {
return Ok( return Ok(git_source_is_clean(&project.root, &resolved) == Some(true)
git_source_state(&project.root, &resolved).is_some_and(|(current, clean)| { && git_revision_matches_source(&project.root, &relative, &resolved, revision));
clean && git_revision_matches(&project.root, revision, &current)
}),
);
} }
let expected = source.hash.as_deref().unwrap(); let expected = source.hash.as_deref().unwrap();
if expected.len() != 64 if expected.len() != 64
@@ -1264,64 +1247,46 @@ fn validate_source(source: &SourceRecord, projects: &[RegisteredProject]) -> Res
)); ));
} }
Ok(hash_file(&resolved)? == expected 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<bool> {
let repository = git2::Repository::discover(root).ok()?; let repository = git2::Repository::discover(root).ok()?;
let relative = source.strip_prefix(repository.workdir()?).ok()?; let workdir = repository.workdir()?.canonicalize().ok()?;
let clean = repository.status_file(relative).ok()? == git2::Status::CURRENT; let relative = source.strip_prefix(workdir).ok()?;
let revision = repository Some(repository.status_file(relative).ok()? == git2::Status::CURRENT)
.head()
.ok()?
.peel_to_commit()
.ok()?
.id()
.to_string();
Some((revision, clean))
} }
fn git_revision_matches(root: &Path, revision: &str, current: &str) -> bool { fn git_revision_matches_source(
if revision == current { root: &Path,
return true; relative: &Path,
} source: &Path,
if revision.len() < 7 revision: &str,
|| revision.len() >= current.len() ) -> bool {
if !(7..=40).contains(&revision.len())
|| !revision || !revision
.bytes() .bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{ {
return false; return false;
} }
git2::Repository::discover(root) let Some(matches) = (|| {
.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 repository = git2::Repository::discover(root).ok()?;
let revision = repository let workdir = repository.workdir()?.canonicalize().ok()?;
.head() let project = root.canonicalize().ok()?;
let path = project.strip_prefix(workdir).ok()?.join(relative);
let commit = repository
.revparse_single(revision)
.ok()? .ok()?
.peel_to_commit() .peel_to_commit()
.ok()? .ok()?;
.id() let entry = commit.tree().ok()?.get_path(&path).ok()?;
.to_string(); let blob = repository.find_blob(entry.id()).ok()?;
let mut options = git2::StatusOptions::new(); Some(fs::read(source).ok()?.as_slice() == blob.content())
options })() else {
.include_untracked(true) return false;
.recurse_untracked_dirs(true) };
.include_ignored(false); matches
Some((
revision,
repository.statuses(Some(&mut options)).ok()?.is_empty(),
))
} }
fn collect_managed_topics( fn collect_managed_topics(
@@ -1954,21 +1919,23 @@ mod tests {
index.write().unwrap(); index.write().unwrap();
let tree = repository.find_tree(tree_id).unwrap(); let tree = repository.find_tree(tree_id).unwrap();
let signature = git2::Signature::now("DS4Server", "ds4@example.invalid").unwrap(); let signature = git2::Signature::now("DS4Server", "ds4@example.invalid").unwrap();
repository let revision = repository
.commit(Some("HEAD"), &signature, &signature, "Initial", &tree, &[]) .commit(Some("HEAD"), &signature, &signature, "Initial", &tree, &[])
.unwrap(); .unwrap()
.to_string();
drop(tree); drop(tree);
drop(repository); drop(repository);
let revision = git_state(&fixture.project).unwrap().0; assert!(git_revision_matches_source(
assert!(git_revision_matches(
&fixture.project, &fixture.project,
&revision[..7], Path::new("source.rs"),
&revision &fixture.project.join("source.rs"),
&revision[..7]
)); ));
assert!(!git_revision_matches( assert!(!git_revision_matches_source(
&fixture.project, &fixture.project,
&revision[..6], Path::new("source.rs"),
&revision &fixture.project.join("source.rs"),
&revision[..6]
)); ));
let topic = fixture let topic = fixture
.topic("verified", &"0".repeat(64), "Revision-backed knowledge.") .topic("verified", &"0".repeat(64), "Revision-backed knowledge.")
@@ -1984,7 +1951,30 @@ mod tests {
.unwrap() .unwrap()
.contains("concepts/answer.md") .contains("concepts/answer.md")
); );
let fingerprint = brain.current_fingerprint().unwrap();
fs::write(fixture.project.join("unrelated.rs"), "dirty elsewhere\n").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!( assert!(
brain brain
.search("Revision-backed knowledge", 10, true) .search("Revision-backed knowledge", 10, true)
@@ -1998,6 +1988,33 @@ mod tests {
.unwrap() .unwrap()
.contains("status: stale") .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( let topic = fixture.topic(
"verified", "verified",
&source_hash(&fixture), &source_hash(&fixture),