fix: less fragile dev brain sources revisions
This commit is contained in:
19
src/app.rs
19
src/app.rs
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
187
src/dev_brain.rs
187
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::<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, ¤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<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 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),
|
||||
|
||||
Reference in New Issue
Block a user