Replace gix with configurable libgit2 diffs

This commit is contained in:
Georg Bauer
2026-07-28 18:25:24 +02:00
parent ed2226bc60
commit 31f6426eef
9 changed files with 410 additions and 1295 deletions

1136
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,6 @@ cc = "1.3.0"
[dependencies] [dependencies]
diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "64-column-tables"] } diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "64-column-tables"] }
diesel_migrations = "2.3.2" diesel_migrations = "2.3.2"
gix = { version = "0.86.0", default-features = false, features = ["max-performance-safe", "sha1", "status"] }
git2 = { version = "0.20.4", features = ["vendored-libgit2", "vendored-openssl"] } git2 = { version = "0.20.4", features = ["vendored-libgit2", "vendored-openssl"] }
iced = { version = "0.14.0", features = ["advanced", "highlighter", "image-without-codecs", "markdown", "svg", "tokio"] } iced = { version = "0.14.0", features = ["advanced", "highlighter", "image-without-codecs", "markdown", "svg", "tokio"] }
image = { version = "0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] } image = { version = "0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] }

View File

@@ -14,7 +14,9 @@ use preferences::PreferenceDraft;
#[cfg(test)] #[cfg(test)]
use preferences::{parse_optional_gib, parse_streaming_cache}; use preferences::{parse_optional_gib, parse_streaming_cache};
use crate::config::{Config, DevBrainConfig, EndpointConfig}; use crate::config::{
Config, DevBrainConfig, EndpointConfig, GitConfig, GitDiffAlgorithm, GitDiffWhitespace,
};
use crate::database::{Database, ProjectWithSessions, SessionState, StoredMessage}; use crate::database::{Database, ProjectWithSessions, SessionState, StoredMessage};
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use crate::engine::ChatTurn; use crate::engine::ChatTurn;
@@ -248,6 +250,7 @@ pub(super) enum PreferenceSection {
Model, Model,
Endpoint, Endpoint,
DevBrain, DevBrain,
Git,
Generation, Generation,
Execution, Execution,
Acceleration, Acceleration,
@@ -256,10 +259,11 @@ pub(super) enum PreferenceSection {
} }
impl PreferenceSection { impl PreferenceSection {
const ALL: [Self; 8] = [ const ALL: [Self; 9] = [
Self::Model, Self::Model,
Self::Endpoint, Self::Endpoint,
Self::DevBrain, Self::DevBrain,
Self::Git,
Self::Generation, Self::Generation,
Self::Execution, Self::Execution,
Self::Acceleration, Self::Acceleration,
@@ -272,6 +276,7 @@ impl PreferenceSection {
Self::Model => "preferences-model", Self::Model => "preferences-model",
Self::Endpoint => "preferences-endpoint", Self::Endpoint => "preferences-endpoint",
Self::DevBrain => "preferences-dev-brain", Self::DevBrain => "preferences-dev-brain",
Self::Git => "preferences-git",
Self::Generation => "preferences-generation", Self::Generation => "preferences-generation",
Self::Execution => "preferences-execution", Self::Execution => "preferences-execution",
Self::Acceleration => "preferences-acceleration", Self::Acceleration => "preferences-acceleration",
@@ -285,6 +290,7 @@ impl PreferenceSection {
Self::Model => "Model & lifecycle", Self::Model => "Model & lifecycle",
Self::Endpoint => "Local endpoint", Self::Endpoint => "Local endpoint",
Self::DevBrain => "Dev Brain", Self::DevBrain => "Dev Brain",
Self::Git => "Git diffs",
Self::Generation => "Generation", Self::Generation => "Generation",
Self::Execution => "Execution", Self::Execution => "Execution",
Self::Acceleration => "Acceleration & memory", Self::Acceleration => "Acceleration & memory",
@@ -344,6 +350,12 @@ pub(crate) enum Message {
PreferenceEndpointCorsChanged(bool), PreferenceEndpointCorsChanged(bool),
PreferenceDevBrainEnabledChanged(bool), PreferenceDevBrainEnabledChanged(bool),
PreferenceDevBrainVaultChanged(String), PreferenceDevBrainVaultChanged(String),
PreferenceGitDiffAlgorithmChanged(GitDiffAlgorithm),
PreferenceGitContextLinesChanged(String),
PreferenceGitInterhunkLinesChanged(String),
PreferenceGitIndentHeuristicChanged(bool),
PreferenceGitWhitespaceChanged(GitDiffWhitespace),
PreferenceGitIgnoreBlankLinesChanged(bool),
ChooseDevBrainVault, ChooseDevBrainVault,
DevBrainVaultPicked(Option<PathBuf>), DevBrainVaultPicked(Option<PathBuf>),
PreferenceContextChanged(String), PreferenceContextChanged(String),
@@ -1174,6 +1186,30 @@ impl App {
self.preference_draft.dev_brain_vault_path = value; self.preference_draft.dev_brain_vault_path = value;
self.preference_error = None; self.preference_error = None;
} }
Message::PreferenceGitDiffAlgorithmChanged(algorithm) => {
self.preference_draft.git_diff_algorithm = algorithm;
self.preference_error = None;
}
Message::PreferenceGitContextLinesChanged(value) => {
self.preference_draft.git_context_lines = value;
self.preference_error = None;
}
Message::PreferenceGitInterhunkLinesChanged(value) => {
self.preference_draft.git_interhunk_lines = value;
self.preference_error = None;
}
Message::PreferenceGitIndentHeuristicChanged(enabled) => {
self.preference_draft.git_indent_heuristic = enabled;
self.preference_error = None;
}
Message::PreferenceGitWhitespaceChanged(whitespace) => {
self.preference_draft.git_whitespace = whitespace;
self.preference_error = None;
}
Message::PreferenceGitIgnoreBlankLinesChanged(enabled) => {
self.preference_draft.git_ignore_blank_lines = enabled;
self.preference_error = None;
}
Message::ChooseDevBrainVault => { Message::ChooseDevBrainVault => {
return Task::perform( return Task::perform(
async { async {

View File

@@ -1,5 +1,7 @@
use super::*; use super::*;
use std::collections::BTreeMap; use std::collections::BTreeMap;
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum GitChangeKind { pub(super) enum GitChangeKind {
@@ -181,7 +183,7 @@ impl App {
self.error = Some("The selected Git file is no longer available.".into()); self.error = Some("The selected Git file is no longer available.".into());
return; return;
}; };
match load_diff(&worktree.root, file) { match load_diff(&worktree.root, file, &self.config.git) {
Ok(diff) => { Ok(diff) => {
self.git_diff = Some(diff); self.git_diff = Some(diff);
self.error = None; self.error = None;
@@ -420,25 +422,22 @@ impl App {
} }
fn read_worktree(project_path: &Path) -> Result<GitWorktree, String> { fn read_worktree(project_path: &Path) -> Result<GitWorktree, String> {
let repository = gix::discover(project_path).map_err(|error| error.to_string())?; let repository = git2::Repository::discover(project_path).map_err(|error| error.to_string())?;
let root = repository let root = repository
.workdir() .workdir()
.ok_or_else(|| "bare repositories do not have a worktree".to_owned())? .ok_or_else(|| "bare repositories do not have a worktree".to_owned())?
.to_path_buf(); .to_path_buf();
let dirwalk = repository let mut options = git2::StatusOptions::new();
.dirwalk_options() options.include_untracked(true).recurse_untracked_dirs(true);
.map_err(|error| error.to_string())?;
let mut files = BTreeMap::<PathBuf, GitFile>::new(); let mut files = BTreeMap::<PathBuf, GitFile>::new();
let changes = repository let statuses = repository
.status(gix::progress::Discard) .statuses(Some(&mut options))
.map_err(|error| error.to_string())?
.index_worktree_options_mut(|options| options.dirwalk_options = Some(dirwalk))
.untracked_files(gix::status::UntrackedFiles::Files)
.into_iter(Vec::<gix::bstr::BString>::new())
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
for change in changes { for entry in statuses.iter() {
let change = change.map_err(|error| error.to_string())?; #[cfg(unix)]
let path = gix::path::from_bstr(change.location()).into_owned(); let path = PathBuf::from(std::ffi::OsString::from_vec(entry.path_bytes().to_vec()));
#[cfg(not(unix))]
let path = PathBuf::from(String::from_utf8_lossy(entry.path_bytes()).into_owned());
let display_path = path.to_string_lossy().into_owned(); let display_path = path.to_string_lossy().into_owned();
let file = files.entry(path.clone()).or_insert_with(|| GitFile { let file = files.entry(path.clone()).or_insert_with(|| GitFile {
path, path,
@@ -446,18 +445,30 @@ fn read_worktree(project_path: &Path) -> Result<GitWorktree, String> {
staged_kind: None, staged_kind: None,
worktree_kind: None, worktree_kind: None,
}); });
match change { let status = entry.status();
gix::status::Item::IndexWorktree(change) => { if status.intersects(git2::Status::INDEX_NEW) {
file.worktree_kind = change.summary().map(worktree_kind); file.staged_kind = Some(GitChangeKind::Added);
} else if status.intersects(git2::Status::INDEX_DELETED) {
file.staged_kind = Some(GitChangeKind::Deleted);
} else if status.intersects(
git2::Status::INDEX_MODIFIED
| git2::Status::INDEX_RENAMED
| git2::Status::INDEX_TYPECHANGE,
) {
file.staged_kind = Some(GitChangeKind::Modified);
} }
gix::status::Item::TreeIndex(change) => { if status.intersects(git2::Status::WT_NEW) {
file.staged_kind = Some(match change { file.worktree_kind = Some(GitChangeKind::Added);
gix::diff::index::Change::Addition { .. } => GitChangeKind::Added, } else if status.intersects(git2::Status::WT_DELETED) {
gix::diff::index::Change::Deletion { .. } => GitChangeKind::Deleted, file.worktree_kind = Some(GitChangeKind::Deleted);
gix::diff::index::Change::Modification { .. } } else if status.intersects(
| gix::diff::index::Change::Rewrite { .. } => GitChangeKind::Modified, git2::Status::WT_MODIFIED | git2::Status::WT_RENAMED | git2::Status::WT_TYPECHANGE,
}); ) {
file.worktree_kind = Some(GitChangeKind::Modified);
} }
if status.intersects(git2::Status::CONFLICTED) {
file.staged_kind = Some(GitChangeKind::Modified);
file.worktree_kind = Some(GitChangeKind::Modified);
} }
} }
files.retain(|_, file| file.staged_kind.is_some() || file.worktree_kind.is_some()); files.retain(|_, file| file.staged_kind.is_some() || file.worktree_kind.is_some());
@@ -467,76 +478,29 @@ fn read_worktree(project_path: &Path) -> Result<GitWorktree, String> {
}) })
} }
fn worktree_kind(summary: gix::status::index_worktree::iter::Summary) -> GitChangeKind { fn load_diff(root: &Path, file: &GitFile, settings: &GitConfig) -> Result<GitDiff, String> {
use gix::status::index_worktree::iter::Summary; let repository = git2::Repository::discover(root).map_err(|error| error.to_string())?;
match summary { let index = repository.index().map_err(|error| error.to_string())?;
Summary::Added | Summary::IntentToAdd => GitChangeKind::Added,
Summary::Removed => GitChangeKind::Deleted,
Summary::Modified
| Summary::TypeChange
| Summary::Renamed
| Summary::Copied
| Summary::Conflict => GitChangeKind::Modified,
}
}
fn load_diff(root: &Path, file: &GitFile) -> Result<GitDiff, String> {
let repository = gix::discover(root).map_err(|error| error.to_string())?;
let index = repository
.index_or_empty()
.map_err(|error| error.to_string())?;
let index_path =
gix::path::to_unix_separators_on_windows(gix::path::into_bstr(file.path.as_path()));
let index_blob = index
.entry_by_path(index_path.as_ref())
.filter(|entry| {
entry
.mode
.to_tree_entry_mode()
.is_some_and(|mode| mode.is_blob_or_symlink())
})
.map(|entry| {
repository
.find_blob(entry.id)
.map(|mut blob| blob.take_data())
.map_err(|error| error.to_string())
})
.transpose()?;
let mut lines = Vec::new(); let mut lines = Vec::new();
if file.staged_kind.is_some() { if file.staged_kind.is_some() {
let tree = repository let head_tree = repository
.find_tree( .head()
repository .ok()
.head_tree_id_or_empty() .and_then(|head| head.peel_to_tree().ok());
.map_err(|error| error.to_string())?, let mut options = diff_options(file, settings, false);
) let diff = repository
.diff_tree_to_index(head_tree.as_ref(), Some(&index), Some(&mut options))
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let head_blob = tree
.lookup_entry_by_path(&file.path)
.map_err(|error| error.to_string())?
.filter(|entry| entry.mode().is_blob_or_symlink())
.map(|entry| {
repository
.find_blob(entry.object_id())
.map(|mut blob| blob.take_data())
.map_err(|error| error.to_string())
})
.transpose()?;
lines.push(diff_section("Staged changes")); lines.push(diff_section("Staged changes"));
lines.extend(diff_blobs( lines.extend(render_diff(&diff)?);
head_blob.as_deref(),
index_blob.as_deref(),
&file.display_path,
)?);
} }
if file.worktree_kind.is_some() { if file.worktree_kind.is_some() {
let worktree_blob = read_worktree_blob(&root.join(&file.path))?; let mut options = diff_options(file, settings, true);
let diff = repository
.diff_index_to_workdir(Some(&index), Some(&mut options))
.map_err(|error| error.to_string())?;
lines.push(diff_section("Worktree changes")); lines.push(diff_section("Worktree changes"));
lines.extend(diff_blobs( lines.extend(render_diff(&diff)?);
index_blob.as_deref(),
worktree_blob.as_deref(),
&file.display_path,
)?);
} }
if lines.is_empty() { if lines.is_empty() {
lines.push(diff_message("No textual diff is available.")); lines.push(diff_message("No textual diff is available."));
@@ -547,6 +511,67 @@ fn load_diff(root: &Path, file: &GitFile) -> Result<GitDiff, String> {
}) })
} }
fn diff_options(file: &GitFile, settings: &GitConfig, worktree: bool) -> git2::DiffOptions {
let mut options = git2::DiffOptions::new();
options
.pathspec(&file.path)
.context_lines(settings.context_lines)
.interhunk_lines(settings.interhunk_lines)
.indent_heuristic(settings.indent_heuristic)
.ignore_blank_lines(settings.ignore_blank_lines);
match settings.diff_algorithm {
GitDiffAlgorithm::Default => {}
GitDiffAlgorithm::Patience => {
options.patience(true);
}
GitDiffAlgorithm::Minimal => {
options.minimal(true);
}
}
match settings.whitespace {
GitDiffWhitespace::ShowAll => {}
GitDiffWhitespace::IgnoreAll => {
options.ignore_whitespace(true);
}
GitDiffWhitespace::IgnoreChanges => {
options.ignore_whitespace_change(true);
}
GitDiffWhitespace::IgnoreEndOfLine => {
options.ignore_whitespace_eol(true);
}
}
if worktree {
options
.include_untracked(true)
.recurse_untracked_dirs(true)
.show_untracked_content(true);
}
options
}
fn render_diff(diff: &git2::Diff<'_>) -> Result<Vec<GitDiffLine>, String> {
let mut output = Vec::new();
diff.print(git2::DiffFormat::Patch, |_, _, line| {
if matches!(
line.origin_value(),
git2::DiffLineType::Context
| git2::DiffLineType::Addition
| git2::DiffLineType::Deletion
) {
output.push(line.origin() as u8);
}
output.extend_from_slice(line.content());
true
})
.map_err(|error| error.to_string())?;
let lines = parse_unified_diff(&String::from_utf8_lossy(&output));
Ok(if lines.is_empty() {
vec![diff_message("No textual diff is available.")]
} else {
lines
})
}
fn diff_section(text: &str) -> GitDiffLine { fn diff_section(text: &str) -> GitDiffLine {
GitDiffLine { GitDiffLine {
kind: GitDiffLineKind::Section, kind: GitDiffLineKind::Section,
@@ -565,58 +590,6 @@ fn diff_message(text: &str) -> GitDiffLine {
} }
} }
fn read_worktree_blob(path: &Path) -> Result<Option<Vec<u8>>, String> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => fs::read_link(path)
.map(|target| Some(target.as_os_str().as_encoded_bytes().to_vec()))
.map_err(|error| error.to_string()),
Ok(metadata) if metadata.is_file() => {
fs::read(path).map(Some).map_err(|error| error.to_string())
}
Ok(_) => Ok(None),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.to_string()),
}
}
fn diff_blobs(
before: Option<&[u8]>,
after: Option<&[u8]>,
path: &str,
) -> Result<Vec<GitDiffLine>, String> {
let before_label = before.map_or_else(|| "/dev/null".into(), |_| format!("a/{path}"));
let after_label = after.map_or_else(|| "/dev/null".into(), |_| format!("b/{path}"));
let before = before.unwrap_or_default();
let after = after.unwrap_or_default();
let mut lines = vec![
diff_message(&format!("--- {before_label}")),
diff_message(&format!("+++ {after_label}")),
];
if before == after {
lines.push(diff_message("No textual diff is available."));
return Ok(lines);
}
if before.iter().take(8_000).any(|byte| *byte == 0)
|| after.iter().take(8_000).any(|byte| *byte == 0)
{
lines.push(diff_message("Binary file changed."));
return Ok(lines);
}
let input = gix::diff::blob::InternedInput::new(before, after);
let diff =
gix::diff::blob::diff_with_slider_heuristics(gix::diff::blob::Algorithm::Histogram, &input);
let output = gix::diff::blob::UnifiedDiff::new(
&diff,
&input,
gix::diff::blob::unified_diff::ConsumeBinaryHunk::new(Vec::new(), "\n"),
Default::default(),
)
.consume()
.map_err(|error| error.to_string())?;
lines.extend(parse_unified_diff(&String::from_utf8_lossy(&output)));
Ok(lines)
}
fn parse_unified_diff(diff: &str) -> Vec<GitDiffLine> { fn parse_unified_diff(diff: &str) -> Vec<GitDiffLine> {
let mut old_number = None; let mut old_number = None;
let mut new_number = None; let mut new_number = None;
@@ -924,7 +897,7 @@ mod tests {
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
#[test] #[test]
fn gitoxide_status_merges_staged_and_worktree_changes() { fn libgit2_status_merges_staged_and_worktree_changes() {
let directory = repository_fixture(); let directory = repository_fixture();
std::fs::write(directory.join("added.txt"), "added\n").unwrap(); std::fs::write(directory.join("added.txt"), "added\n").unwrap();
git2::Repository::open(&directory) git2::Repository::open(&directory)
@@ -952,7 +925,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(changed.staged_kind, Some(GitChangeKind::Modified)); assert_eq!(changed.staged_kind, Some(GitChangeKind::Modified));
assert_eq!(changed.worktree_kind, Some(GitChangeKind::Modified)); assert_eq!(changed.worktree_kind, Some(GitChangeKind::Modified));
let diff = load_diff(&worktree.root, changed).unwrap(); let diff = load_diff(&worktree.root, changed, &GitConfig::default()).unwrap();
assert_eq!( assert_eq!(
diff.lines diff.lines
.iter() .iter()
@@ -1118,6 +1091,55 @@ mod tests {
))); )));
} }
#[test]
fn configured_git_diff_options_change_the_rendered_patch() {
let directory = empty_repository_fixture();
std::fs::write(
directory.join("changed.txt"),
"first\nsecond\nthird\nfourth\nfifth\n",
)
.unwrap();
stage_paths(&directory, &["changed.txt".into()]).unwrap();
commit_index(&directory, "Initial").unwrap();
std::fs::write(
directory.join("changed.txt"),
"first\nsecond changed\nthird\nfourth changed\nfifth\n",
)
.unwrap();
let worktree = read_worktree(&directory).unwrap();
let changed = worktree
.files
.iter()
.find(|file| file.display_path == "changed.txt")
.unwrap();
let settings = GitConfig {
diff_algorithm: GitDiffAlgorithm::Patience,
context_lines: 0,
interhunk_lines: 0,
indent_heuristic: true,
whitespace: GitDiffWhitespace::ShowAll,
ignore_blank_lines: false,
};
let diff = load_diff(&worktree.root, changed, &settings).unwrap();
assert_eq!(
diff.lines
.iter()
.filter(|line| line.kind == GitDiffLineKind::Context)
.count(),
0
);
assert_eq!(
diff.lines
.iter()
.filter(|line| line.kind == GitDiffLineKind::Hunk)
.count(),
2
);
std::fs::remove_dir_all(directory).unwrap();
}
fn repository_fixture() -> PathBuf { fn repository_fixture() -> PathBuf {
let directory = empty_repository_fixture(); let directory = empty_repository_fixture();
std::fs::write(directory.join("changed.txt"), "original\n").unwrap(); std::fs::write(directory.join("changed.txt"), "original\n").unwrap();

View File

@@ -12,6 +12,12 @@ pub(super) struct PreferenceDraft {
pub(super) endpoint_cors: bool, pub(super) endpoint_cors: bool,
pub(super) dev_brain_enabled: bool, pub(super) dev_brain_enabled: bool,
pub(super) dev_brain_vault_path: String, pub(super) dev_brain_vault_path: String,
pub(super) git_diff_algorithm: GitDiffAlgorithm,
pub(super) git_context_lines: String,
pub(super) git_interhunk_lines: String,
pub(super) git_indent_heuristic: bool,
pub(super) git_whitespace: GitDiffWhitespace,
pub(super) git_ignore_blank_lines: bool,
pub(super) context_tokens: String, pub(super) context_tokens: String,
pub(super) max_generated_tokens: String, pub(super) max_generated_tokens: String,
pub(super) system_prompt: text_editor::Content, pub(super) system_prompt: text_editor::Content,
@@ -64,6 +70,12 @@ impl PreferenceDraft {
endpoint_cors: config.endpoint.cors, endpoint_cors: config.endpoint.cors,
dev_brain_enabled: config.dev_brain.enabled, dev_brain_enabled: config.dev_brain.enabled,
dev_brain_vault_path: config.dev_brain.vault_path.clone().unwrap_or_default(), dev_brain_vault_path: config.dev_brain.vault_path.clone().unwrap_or_default(),
git_diff_algorithm: config.git.diff_algorithm,
git_context_lines: config.git.context_lines.to_string(),
git_interhunk_lines: config.git.interhunk_lines.to_string(),
git_indent_heuristic: config.git.indent_heuristic,
git_whitespace: config.git.whitespace,
git_ignore_blank_lines: config.git.ignore_blank_lines,
context_tokens: generation.context_tokens.to_string(), context_tokens: generation.context_tokens.to_string(),
max_generated_tokens: generation.max_generated_tokens.to_string(), max_generated_tokens: generation.max_generated_tokens.to_string(),
system_prompt: text_editor::Content::with_text(&generation.system_prompt), system_prompt: text_editor::Content::with_text(&generation.system_prompt),
@@ -126,6 +138,17 @@ impl PreferenceDraft {
Ok(preferences) Ok(preferences)
} }
pub(super) fn git(&self) -> Result<GitConfig, String> {
Ok(GitConfig {
diff_algorithm: self.git_diff_algorithm,
context_lines: parse_u32("Git diff context lines", &self.git_context_lines)?,
interhunk_lines: parse_u32("Git diff interhunk lines", &self.git_interhunk_lines)?,
indent_heuristic: self.git_indent_heuristic,
whitespace: self.git_whitespace,
ignore_blank_lines: self.git_ignore_blank_lines,
})
}
pub(super) fn reset(&mut self) { pub(super) fn reset(&mut self) {
*self = Self::from_saved(&Config::default()); *self = Self::from_saved(&Config::default());
} }
@@ -248,6 +271,13 @@ fn parse_optional_u32(name: &str, value: &str) -> Result<Option<u32>, String> {
parse_optional_number(name, value) parse_optional_number(name, value)
} }
fn parse_u32(name: &str, value: &str) -> Result<u32, String> {
value
.trim()
.parse()
.map_err(|_| format!("{name} must be a non-negative whole number."))
}
fn parse_optional_u8(name: &str, value: &str) -> Result<Option<u8>, String> { fn parse_optional_u8(name: &str, value: &str) -> Result<Option<u8>, String> {
parse_optional_number(name, value) parse_optional_number(name, value)
} }
@@ -363,6 +393,13 @@ impl App {
return; return;
} }
}; };
let git = match self.preference_draft.git() {
Ok(git) => git,
Err(error) => {
self.preference_error = Some(error);
return;
}
};
let config = Config { let config = Config {
model: self.preference_draft.model, model: self.preference_draft.model,
idle_timeout_minutes, idle_timeout_minutes,
@@ -378,6 +415,7 @@ impl App {
}, },
generation, generation,
runtime, runtime,
git,
interface: self.config.interface.clone(), interface: self.config.interface.clone(),
}; };
if let Err(error) = config.validate() { if let Err(error) = config.validate() {

View File

@@ -368,31 +368,31 @@ impl App {
} }
fn read_git_state(path: &Path) -> Option<GitState> { fn read_git_state(path: &Path) -> Option<GitState> {
let repository = gix::discover(path).ok()?; let repository = git2::Repository::discover(path).ok()?;
let mut branches = repository let mut branches = repository
.references() .branches(Some(git2::BranchType::Local))
.ok()? .ok()?
.local_branches() .map(|branch| {
.ok()? branch.and_then(|(branch, _)| {
.map(|reference| { branch
reference .name_bytes()
.map(|reference| String::from_utf8_lossy(reference.name().shorten()).into_owned()) .map(|name| String::from_utf8_lossy(name).into_owned())
})
}) })
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
.ok()?; .ok()?;
branches.sort(); branches.sort();
branches.dedup(); branches.dedup();
let current = repository let head = repository.find_reference("HEAD").ok()?;
.head_name() let current = head.symbolic_target_bytes().and_then(|target| {
.ok()? target
.map(|name| String::from_utf8_lossy(name.shorten()).into_owned()); .strip_prefix(b"refs/heads/")
.map(|name| String::from_utf8_lossy(name).into_owned())
});
let label = current.clone().unwrap_or_else(|| { let label = current.clone().unwrap_or_else(|| {
repository head.target()
.head_id() .map(|id| format!("detached @ {}", &id.to_string()[..7]))
.ok()
.and_then(|id| id.shorten().ok())
.map(|id| format!("detached @ {id}"))
.unwrap_or_else(|| "No branch".to_owned()) .unwrap_or_else(|| "No branch".to_owned())
}); });
Some(GitState { Some(GitState {

View File

@@ -12,6 +12,7 @@ use super::{
ModelDownload, ModelOperation, PreferenceSection, ProjectChoice, chat_scroll_id, composer_id, ModelDownload, ModelOperation, PreferenceSection, ProjectChoice, chat_scroll_id, composer_id,
models_path, preferences_scroll_id, models_path, preferences_scroll_id,
}; };
use crate::config::{GIT_DIFF_ALGORITHMS, GIT_DIFF_WHITESPACE_MODES};
use crate::database::{ProjectWithSessions, Session, SessionState}; use crate::database::{ProjectWithSessions, Session, SessionState};
use crate::model::{ use crate::model::{
self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice, self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice,

View File

@@ -183,6 +183,65 @@ impl App {
] ]
.spacing(10), .spacing(10),
); );
let git_group = preference_group(
PreferenceSection::Git,
"GIT DIFFS",
column![
row![
hint(
text("Algorithm").size(13).width(Length::Fill),
"Default uses libgit2's Myers diff. Patience favors unique matching lines; Minimal spends more time finding the smallest edit script.",
),
pick_list(
&GIT_DIFF_ALGORITHMS[..],
Some(self.preference_draft.git_diff_algorithm),
Message::PreferenceGitDiffAlgorithmChanged,
)
.width(240),
]
.spacing(12)
.align_y(Alignment::Center),
preference_input_row(
"Context lines",
"Unchanged lines shown before and after each changed block. libgit2 defaults to 3.",
text_input("3", &self.preference_draft.git_context_lines)
.on_input(Message::PreferenceGitContextLinesChanged),
),
preference_input_row(
"Interhunk lines",
"Merge nearby changed blocks when no more than this many unchanged lines separate them. Zero keeps libgit2's default separation.",
text_input("0", &self.preference_draft.git_interhunk_lines)
.on_input(Message::PreferenceGitInterhunkLinesChanged),
),
row![
hint(
text("Whitespace").size(13).width(Length::Fill),
"Controls which whitespace-only edits libgit2 omits from the displayed diff.",
),
pick_list(
&GIT_DIFF_WHITESPACE_MODES[..],
Some(self.preference_draft.git_whitespace),
Message::PreferenceGitWhitespaceChanged,
)
.width(240),
]
.spacing(12)
.align_y(Alignment::Center),
hint(
checkbox(self.preference_draft.git_indent_heuristic)
.label("Use indentation heuristic")
.on_toggle(Message::PreferenceGitIndentHeuristicChanged),
"Shift ambiguous hunk boundaries toward indentation changes, which usually makes source-code diffs easier to read.",
),
hint(
checkbox(self.preference_draft.git_ignore_blank_lines)
.label("Ignore blank-line changes")
.on_toggle(Message::PreferenceGitIgnoreBlankLinesChanged),
"Hide hunks whose changed lines are all blank.",
),
]
.spacing(10),
);
let generation_group = preference_group( let generation_group = preference_group(
PreferenceSection::Generation, PreferenceSection::Generation,
"GENERATION", "GENERATION",
@@ -580,6 +639,7 @@ impl App {
model_group, model_group,
endpoint_group, endpoint_group,
dev_brain_group, dev_brain_group,
git_group,
generation_group, generation_group,
execution_group, execution_group,
acceleration_group, acceleration_group,

View File

@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_norway::{Mapping, Value}; use serde_norway::{Mapping, Value};
use std::fmt;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
@@ -19,6 +20,7 @@ pub struct Config {
pub endpoint: EndpointConfig, pub endpoint: EndpointConfig,
pub generation: GenerationPreferences, pub generation: GenerationPreferences,
pub runtime: RuntimePreferences, pub runtime: RuntimePreferences,
pub git: GitConfig,
pub interface: InterfaceConfig, pub interface: InterfaceConfig,
} }
@@ -32,11 +34,89 @@ impl Default for Config {
endpoint: EndpointConfig::default(), endpoint: EndpointConfig::default(),
generation: GenerationPreferences::default(), generation: GenerationPreferences::default(),
runtime: RuntimePreferences::default(), runtime: RuntimePreferences::default(),
git: GitConfig::default(),
interface: InterfaceConfig::default(), interface: InterfaceConfig::default(),
} }
} }
} }
pub const GIT_DIFF_ALGORITHMS: [GitDiffAlgorithm; 3] = [
GitDiffAlgorithm::Default,
GitDiffAlgorithm::Patience,
GitDiffAlgorithm::Minimal,
];
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GitDiffAlgorithm {
#[default]
Default,
Patience,
Minimal,
}
impl fmt::Display for GitDiffAlgorithm {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Default => "Default (Myers)",
Self::Patience => "Patience",
Self::Minimal => "Minimal",
})
}
}
pub const GIT_DIFF_WHITESPACE_MODES: [GitDiffWhitespace; 4] = [
GitDiffWhitespace::ShowAll,
GitDiffWhitespace::IgnoreAll,
GitDiffWhitespace::IgnoreChanges,
GitDiffWhitespace::IgnoreEndOfLine,
];
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GitDiffWhitespace {
#[default]
ShowAll,
IgnoreAll,
IgnoreChanges,
IgnoreEndOfLine,
}
impl fmt::Display for GitDiffWhitespace {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::ShowAll => "Show all changes",
Self::IgnoreAll => "Ignore all whitespace",
Self::IgnoreChanges => "Ignore whitespace amount",
Self::IgnoreEndOfLine => "Ignore end-of-line whitespace",
})
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct GitConfig {
pub diff_algorithm: GitDiffAlgorithm,
pub context_lines: u32,
pub interhunk_lines: u32,
pub indent_heuristic: bool,
pub whitespace: GitDiffWhitespace,
pub ignore_blank_lines: bool,
}
impl Default for GitConfig {
fn default() -> Self {
Self {
diff_algorithm: GitDiffAlgorithm::Default,
context_lines: 3,
interhunk_lines: 0,
indent_heuristic: false,
whitespace: GitDiffWhitespace::ShowAll,
ignore_blank_lines: false,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct DevBrainConfig { pub struct DevBrainConfig {
@@ -201,6 +281,12 @@ mod tests {
}, },
..RuntimePreferences::default() ..RuntimePreferences::default()
}, },
git: GitConfig {
diff_algorithm: GitDiffAlgorithm::Patience,
context_lines: 5,
whitespace: GitDiffWhitespace::IgnoreEndOfLine,
..GitConfig::default()
},
..Config::default() ..Config::default()
}; };
config.save(&path).unwrap(); config.save(&path).unwrap();
@@ -210,7 +296,8 @@ mod tests {
text, text,
"model: glm-5.2\na2ui_enabled: false\n\ "model: glm-5.2\na2ui_enabled: false\n\
generation:\n context_tokens: 65536\n reasoning_mode: none\n\ generation:\n context_tokens: 65536\n reasoning_mode: none\n\
runtime:\n ssd:\n enabled: true\n cache: 64GB\n" runtime:\n ssd:\n enabled: true\n cache: 64GB\n\
git:\n diff_algorithm: patience\n context_lines: 5\n whitespace: ignore-end-of-line\n"
); );
assert_eq!(Config::load(&path).unwrap(), config); assert_eq!(Config::load(&path).unwrap(), config);
fs::remove_dir_all(&directory).unwrap(); fs::remove_dir_all(&directory).unwrap();