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,
preference_draft: PreferenceDraft,
preference_error: Option<String>,
restore_dev_brain_confirmation: bool,
selected_project: Option<i32>,
selected_session: Option<i32>,
/// Unsaved sessions, keyed by project. A draft only becomes a `sessions` row
@@ -361,6 +362,8 @@ pub(crate) enum Message {
ChooseDevBrainVault,
DevBrainVaultPicked(Option<PathBuf>),
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;

View File

@@ -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)),

View File

@@ -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()
}
}

View File

@@ -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::<SourceKeyRow>(&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, &current)
}),
);
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<bool> {
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 Some(matches) = (|| {
let repository = git2::Repository::discover(root).ok()?;
let revision = repository
.head()
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()?
.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(),
))
.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),