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,
|
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;
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
181
src/dev_brain.rs
181
src/dev_brain.rs
@@ -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, ¤t)
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
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),
|
||||||
|
|||||||
Reference in New Issue
Block a user