Compare commits

...
3 Commits
Author SHA1 Message Date
Christoph f0a1d89152 Update version to 2026.8.9 2026-08-30 20:26:46 +02:00
Christoph c3762ae7a3 feat(git): add opt-in for unrelated histories during pull
publish / Build and publish Windows installer (release) Successful in 10m2s
publish / Build and publish Ubuntu AppImage (release) Successful in 10m28s
publish / Build and publish AUR packages (release) Successful in 22m1s
Allow pulling repositories with unrelated commit histories when the user
explicitly opts in. The pull argument construction was refactored and
branch resolution made more robust so the backend can include the
--allow-unrelated-histories flag when requested.

- Extract pull argument logic and add support for allowing unrelated histories.
- Prompt users in the UI to confirm merging separate histories and retry pull.
- Restyle and improve the update toast UI for better layout and responsiveness.
2026-08-30 20:25:15 +02:00
Christoph 4db6f30461 refactor(commit-ai): remove local models and simplify AI flow
Remove local on-device model support and related IPC commands,
consolidating commit-generation to cloud providers and simplifying the
AI crate surface. Local-specific types, generation profiles, caching,
and the local prompt builder were removed while message sanitization and
diff-echo detection were preserved. Also harden repository handling and
runtime: unborn HEADs are handled gracefully so empty repos still report
files, Git LFS sync is skipped for repositories without commits, and
tokio runtime features were enabled.

- Remove local model engine, load/status commands, and local profile code
- Handle unborn HEAD and skip LFS sync for repos without commits
- Enable tokio runtime features and route AI generation to cloud only
2026-08-30 19:16:32 +02:00
17 changed files with 522 additions and 4125 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitty",
"version": "2026.8.8",
"version": "2026.8.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "2026.8.8",
"version": "2026.8.9",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitty",
"version": "2026.8.8",
"version": "2026.8.9",
"private": true,
"type": "module",
"scripts": {
+37 -3229
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -20,7 +20,7 @@ tauri-plugin-dialog = "=2.7.0"
tauri-plugin-aptabase = "1.0"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
commit_ai = { path = "crates/commit_ai" }
tokio = "1.52.3"
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] }
log = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
-2
View File
@@ -5,8 +5,6 @@ version = "0.1.0"
edition = "2024"
[dependencies]
mistralrs = "0.8"
tokio = { version = "1", features = ["sync"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+6 -359
View File
@@ -5,318 +5,8 @@ pub use cloud::{
review_openai, split_anthropic, split_custom, split_openai,
};
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
sync::Arc,
};
use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole};
use tokio::sync::RwLock;
/// One selectable local (on-device) model. Larger models produce better commit messages
/// but take longer to download (first run only, then cached) and run slower on CPU.
#[derive(Debug, Clone, serde::Serialize)]
pub struct LocalModelOption {
pub id: &'static str,
pub label: &'static str,
pub approx_size_mb: u32,
repo: &'static str,
file: &'static str,
tokenizer_repo: &'static str,
}
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-0.5b";
pub const LOCAL_MODELS: &[LocalModelOption] = &[
LocalModelOption {
id: "qwen2.5-0.5b",
label: "Qwen2.5 0.5B Instruct — fast, lower quality",
approx_size_mb: 490,
repo: "Qwen/Qwen2.5-0.5B-Instruct-GGUF",
file: "qwen2.5-0.5b-instruct-q4_k_m.gguf",
tokenizer_repo: "Qwen/Qwen2.5-0.5B-Instruct",
},
LocalModelOption {
id: "qwen2.5-1.5b",
label: "Qwen2.5 1.5B Instruct — recommended",
approx_size_mb: 1050,
repo: "Qwen/Qwen2.5-1.5B-Instruct-GGUF",
file: "qwen2.5-1.5b-instruct-q4_k_m.gguf",
tokenizer_repo: "Qwen/Qwen2.5-1.5B-Instruct",
},
LocalModelOption {
id: "qwen2.5-3b",
label: "Qwen2.5 3B Instruct — best quality, slower",
approx_size_mb: 2100,
repo: "Qwen/Qwen2.5-3B-Instruct-GGUF",
file: "qwen2.5-3b-instruct-q4_k_m.gguf",
tokenizer_repo: "Qwen/Qwen2.5-3B-Instruct",
},
];
fn find_local_model(model_id: &str) -> Option<&'static LocalModelOption> {
LOCAL_MODELS.iter().find(|option| option.id == model_id)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LocalGenerationProfile {
Fast,
Balanced,
Detailed,
}
impl Default for LocalGenerationProfile {
fn default() -> Self {
Self::Fast
}
}
impl LocalGenerationProfile {
pub fn from_id(value: Option<&str>) -> Self {
match value
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"balanced" => Self::Balanced,
"detailed" => Self::Detailed,
_ => Self::Fast,
}
}
pub fn diff_unified_context(self) -> &'static str {
match self {
Self::Fast => "--unified=1",
Self::Balanced => "--unified=2",
Self::Detailed => "--unified=3",
}
}
fn max_diff_chars(self) -> usize {
match self {
Self::Fast => 8_000,
Self::Balanced => 12_000,
Self::Detailed => 24_000,
}
}
fn max_output_tokens(self) -> usize {
match self {
Self::Fast => 160,
Self::Balanced => 360,
Self::Detailed => 750,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CommitAiPhase {
/// Nothing has been requested yet.
Idle,
/// Downloading (first run only, then cached by hf-hub) and/or loading into memory.
Loading,
Ready,
Error,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CommitAiStatus {
pub phase: CommitAiPhase,
pub model_id: Option<String>,
pub error: Option<String>,
}
struct Inner {
phase: CommitAiPhase,
model_id: Option<String>,
error: Option<String>,
model: Option<Arc<Model>>,
cache: Option<GenerationCache>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GenerationCacheKey {
model_id: String,
profile: LocalGenerationProfile,
input_hash: u64,
}
#[derive(Debug, Clone)]
struct GenerationCache {
key: GenerationCacheKey,
message: String,
}
/// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls
/// (see [`cloud`]) and don't need this — there's nothing to download or keep loaded.
#[derive(Clone)]
pub struct CommitAiEngine {
inner: Arc<RwLock<Inner>>,
}
impl Default for CommitAiEngine {
fn default() -> Self {
Self {
inner: Arc::new(RwLock::new(Inner {
phase: CommitAiPhase::Idle,
model_id: None,
error: None,
model: None,
cache: None,
})),
}
}
}
impl CommitAiEngine {
pub fn new() -> Self {
Self::default()
}
pub async fn status(&self) -> CommitAiStatus {
let guard = self.inner.read().await;
CommitAiStatus {
phase: guard.phase,
model_id: guard.model_id.clone(),
error: guard.error.clone(),
}
}
/// Downloads (first run only; hf-hub caches the files afterwards) and loads the given
/// local model. Safe to call repeatedly — a call for the model that's already
/// ready/loading is a no-op; a call for a *different* model switches to it (the
/// previous one is dropped once no generation is still using it).
pub async fn ensure_loaded(&self, model_id: &str) {
{
let guard = self.inner.read().await;
let same_model = guard.model_id.as_deref() == Some(model_id);
if same_model && matches!(guard.phase, CommitAiPhase::Ready | CommitAiPhase::Loading) {
return;
}
}
let Some(option) = find_local_model(model_id) else {
let mut guard = self.inner.write().await;
guard.phase = CommitAiPhase::Error;
guard.model_id = Some(model_id.to_string());
guard.error = Some(format!("Unknown local model: {model_id}"));
guard.cache = None;
return;
};
{
let mut guard = self.inner.write().await;
guard.phase = CommitAiPhase::Loading;
guard.model_id = Some(model_id.to_string());
guard.error = None;
guard.model = None;
guard.cache = None;
}
let result = GgufModelBuilder::new(option.repo, vec![option.file])
.with_tok_model_id(option.tokenizer_repo)
.with_logging()
.build()
.await;
let mut guard = self.inner.write().await;
// If the user switched to yet another model while this one was loading, drop this
// (now stale) result instead of overwriting the newer request's state.
if guard.model_id.as_deref() != Some(model_id) {
return;
}
match result {
Ok(model) => {
guard.model = Some(Arc::new(model));
guard.phase = CommitAiPhase::Ready;
guard.error = None;
guard.cache = None;
}
Err(err) => {
guard.phase = CommitAiPhase::Error;
guard.error = Some(err.to_string());
guard.cache = None;
}
}
}
pub async fn generate_commit_message(
&self,
diff: &str,
notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<String, String> {
let (model, cache_key) = {
let guard = self.inner.read().await;
match (guard.phase, &guard.model) {
(CommitAiPhase::Ready, Some(model)) => {
let cache_key = GenerationCacheKey {
model_id: guard.model_id.clone().unwrap_or_default(),
profile,
input_hash: generation_input_hash(diff, notes),
};
if let Some(cache) = &guard.cache {
if cache.key == cache_key {
return Ok(cache.message.clone());
}
}
(model.clone(), cache_key)
}
_ => return Err("The local AI model is not ready yet.".to_string()),
}
};
let (system, user) = build_local_messages(diff, notes, profile)?;
let request = RequestBuilder::new()
.set_sampler_max_len(profile.max_output_tokens())
.add_message(TextMessageRole::System, system)
.add_message(TextMessageRole::User, user);
let response = model
.send_chat_request(request)
.await
.map_err(|err| err.to_string())?;
let content = response
.choices
.first()
.and_then(|choice| choice.message.content.clone())
.ok_or_else(|| "The model did not return a response.".to_string())?;
let message = sanitize_message(&content);
if message.is_empty() {
return Err("The model did not return a response.".to_string());
}
if looks_like_diff_echo(&message) {
return Err(
"The local model returned the diff instead of a commit message. Try a larger local model (1.5B or 3B) or a cloud provider.".to_string(),
);
}
{
let mut guard = self.inner.write().await;
guard.cache = Some(GenerationCache {
key: cache_key,
message: message.clone(),
});
}
Ok(message)
}
}
fn generation_input_hash(diff: &str, notes: Option<&str>) -> u64 {
let mut hasher = DefaultHasher::new();
diff.hash(&mut hasher);
notes.unwrap_or("").hash(&mut hasher);
hasher.finish()
}
/// Models occasionally ignore the "no code fences" instruction (small local models
/// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go
/// straight into the commit-message box.
/// Strip a wrapping code fence and wrapping quotes so the result can go straight into
/// the commit-message box.
pub(crate) fn sanitize_message(raw: &str) -> String {
let mut text = raw.trim().to_string();
if text.starts_with("```") {
@@ -333,9 +23,9 @@ pub(crate) fn sanitize_message(raw: &str) -> String {
trimmed.to_string()
}
/// Weak models (small local ones especially) sometimes just echo the prompt's diff
/// sections back instead of writing a commit message. Catch that so the UI can show a
/// clear error instead of dumping raw diff text into the commit-message box.
/// Some models echo the prompt's diff sections instead of writing a commit message.
/// Catch that so the UI can show a clear error instead of dumping raw diff text into
/// the commit-message box.
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("diff --git")
@@ -358,55 +48,12 @@ fn truncate_at_char_boundary(input: &str, max_chars: usize) -> String {
format!("{}\n\n[... diff truncated ...]", &input[..cut])
}
pub(crate) fn build_local_messages(
diff: &str,
notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string());
}
let diff = truncate_at_char_boundary(diff, profile.max_diff_chars());
// Appended to every profile below: small local models occasionally just echo the input
// (the "Staged files:" / "Diff stat:" / "Detailed diff:" sections built in git.rs's
// `staged_diff_local`) instead of writing a new commit message. Naming those exact
// section headers here makes the failure mode explicit enough for weak models to avoid.
const ANTI_ECHO: &str = " Never repeat, quote, or paraphrase the diff or its headers — \
do not include 'diff --git', '@@', 'Staged files:', 'Diff stat:', or 'Detailed diff:' \
anywhere in your answer.";
let system = match profile {
LocalGenerationProfile::Fast => {
format!(
"You generate Git commit messages. Respond only with one Conventional Commits subject line: <type>(<scope>): <subject>. Max 72 characters. No body, bullets, preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Balanced => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then at most two short bullet points. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Detailed => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then a concise body and up to four short bullet points grouped by affected area/file. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
};
let mut user = String::new();
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
user.push_str(&format!("Developer notes:\n{n}\n\n"));
}
user.push_str(&format!("Staged changes:\n{diff}"));
Ok((system, user))
}
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string());
}
// Rough token estimate — small models often have an 8-32k context window.
// Rough token estimate to keep requests within common context windows.
const MAX_CHARS: usize = 24_000;
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
+222 -116
View File
@@ -578,6 +578,25 @@ fn repository_bundle_for_repo(
// branches -> tags -> stashes -> commits -> files waterfall down to the
// duration of its slowest member.
let status = status_for_repo(repo)?;
// A freshly initialized or cloned empty repository has a symbolic HEAD,
// but it does not resolve to a commit yet (an "unborn" HEAD). Some Git
// commands and Git extensions treat that as a hard revision error. Keep
// the repository usable and still report any untracked working-tree files
// without starting commit-dependent workers.
if verify_commit(repo, "HEAD").is_err() {
let files = repository_files_with_status(repo, &status)?;
return Ok(RepositoryBundle {
status,
branches: Vec::new(),
tags: Vec::new(),
stashes: Vec::new(),
commits: Vec::new(),
files,
warning: None,
});
}
let (branches, tags, stashes, commits, files) = thread::scope(|scope| -> Result<_, String> {
let branches = scope.spawn(|| branches_for_repo(repo));
let tags = scope.spawn(|| tags_for_repo(repo));
@@ -2117,28 +2136,6 @@ pub fn get_file_patch(path: String, file: String, staged: bool) -> Result<String
Ok(String::from_utf8_lossy(&output).to_string())
}
#[tauri::command]
pub fn commit_ai_local_models() -> Vec<commit_ai::LocalModelOption> {
commit_ai::LOCAL_MODELS.to_vec()
}
#[tauri::command]
pub async fn commit_ai_status(
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
) -> Result<commit_ai::CommitAiStatus, String> {
Ok(engine.status().await)
}
/// Kicks off the (first-run-only) download and model load in the background and returns
/// immediately; the frontend polls `commit_ai_status` to know when it's ready.
#[tauri::command]
pub fn commit_ai_load(model_id: String, engine: tauri::State<'_, commit_ai::CommitAiEngine>) {
let engine = engine.inner().clone();
tauri::async_runtime::spawn(async move {
engine.ensure_loaded(&model_id).await;
});
}
// The prompt is built from `git diff --cached` only, i.e. exactly the staged changes —
// unstaged edits and untracked files never influence the generated message.
fn staged_diff(repo: &Path) -> Result<String, String> {
@@ -2147,8 +2144,8 @@ fn staged_diff(repo: &Path) -> Result<String, String> {
let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?;
let file_list = String::from_utf8_lossy(&name_status).trim().to_string();
// Generated lockfiles say nothing useful about intent but easily blow the small
// context window of local models, so keep them out of the detailed diff.
// Generated lockfiles say little about intent and can easily dominate the context,
// so keep them out of the detailed diff.
let diff = run_git(
repo,
[
@@ -2178,50 +2175,6 @@ fn staged_diff(repo: &Path) -> Result<String, String> {
Ok(format!("Staged files:\n{file_list}\n\n{diff}"))
}
fn staged_diff_local(
repo: &Path,
profile: commit_ai::LocalGenerationProfile,
) -> Result<String, String> {
let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?;
let file_list = String::from_utf8_lossy(&name_status).trim().to_string();
let stat = run_git(repo, ["diff", "--cached", "--stat", "--summary"])?;
let stat = String::from_utf8_lossy(&stat).trim().to_string();
let diff_args = vec![
"diff",
"--cached",
"--no-ext-diff",
"--no-textconv",
profile.diff_unified_context(),
"--",
".",
":(exclude)*package-lock.json",
":(exclude)*pnpm-lock.yaml",
":(exclude)*yarn.lock",
":(exclude)*bun.lockb",
":(exclude)*Cargo.lock",
":(exclude)*composer.lock",
":(exclude)*Gemfile.lock",
":(exclude)*poetry.lock",
":(exclude)*go.sum",
];
let diff = run_git(repo, diff_args)?;
let diff = String::from_utf8_lossy(&diff).trim().to_string();
let mut sections = Vec::new();
if !file_list.is_empty() {
sections.push(format!("Staged files:\n{file_list}"));
}
if !stat.is_empty() {
sections.push(format!("Diff stat:\n{stat}"));
}
if !diff.is_empty() {
sections.push(format!("Detailed diff:\n{diff}"));
}
Ok(sections.join("\n\n"))
}
#[tauri::command]
pub async fn commit_ai_generate(
path: String,
@@ -2230,27 +2183,15 @@ pub async fn commit_ai_generate(
model: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
local_profile: Option<String>,
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
) -> Result<String, String> {
let repo = resolve_repo(&path)?;
let local_profile = commit_ai::LocalGenerationProfile::from_id(local_profile.as_deref());
let diff = if provider == "local" {
staged_diff_local(&repo, local_profile)?
} else {
staged_diff(&repo)?
};
let diff = staged_diff(&repo)?;
let notes = notes.as_deref();
let model = model.filter(|value| !value.trim().is_empty());
let api_key = api_key.filter(|value| !value.trim().is_empty());
let base_url = base_url.filter(|value| !value.trim().is_empty());
match provider.as_str() {
"local" => {
engine
.generate_commit_message(&diff, notes, local_profile)
.await
}
"openai" => {
let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?;
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
@@ -2420,7 +2361,6 @@ pub async fn commit_ai_split(
)
.await?
}
"local" => return Err("Commit splitting currently requires an API provider.".to_string()),
other => return Err(format!("Unknown AI provider: {other}")),
};
parse_ai_commit_plan(&raw, &staged_files)
@@ -2522,7 +2462,6 @@ pub async fn commit_ai_review(
let model = model.ok_or_else(|| "Model name is missing.".to_string())?;
commit_ai::review_custom(&base_url, api_key.as_deref(), &model, &diff).await?
}
"local" => return Err("Pre-commit review currently requires an API provider.".to_string()),
other => return Err(format!("Unknown AI provider: {other}")),
};
parse_ai_review(&raw)
@@ -2654,32 +2593,23 @@ pub async fn pull(
strategy: Option<String>,
remote: Option<String>,
branch: Option<String>,
allow_unrelated_histories: Option<bool>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let strategy = strategy.as_deref().unwrap_or("merge");
let remote = remote
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let mut pull_args = vec![OsString::from("pull")];
match strategy {
"merge" => {
pull_args.extend([OsString::from("--no-rebase"), OsString::from("--no-edit")])
}
"rebase" => pull_args.push(OsString::from("--rebase")),
"ff-only" => pull_args.push(OsString::from("--ff-only")),
_ => return Err("Unknown pull strategy.".to_string()),
}
if let Some(remote_name) = remote.as_deref() {
validate_remote_name(&repo, remote_name, true)?;
pull_args.push(remote_name.into());
if let Some(branch) = branch
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
pull_args.push(branch.into());
}
}
let branch = branch
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let pull_args = pull_args_for_repo(
&repo,
strategy.as_deref().unwrap_or("merge"),
remote.as_deref(),
branch.as_deref(),
allow_unrelated_histories.unwrap_or(false),
)?;
let output = match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated_output(&repo, pull_args.clone(), u, p)?
@@ -2718,6 +2648,39 @@ pub async fn pull(
.map_err(|err| format!("Could not pull: {err}"))?
}
fn pull_args_for_repo(
repo: &Path,
strategy: &str,
remote: Option<&str>,
branch: Option<&str>,
allow_unrelated_histories: bool,
) -> Result<Vec<OsString>, String> {
let mut pull_args = vec![OsString::from("pull")];
match strategy {
"merge" => {
pull_args.extend([OsString::from("--no-rebase"), OsString::from("--no-edit")]);
if allow_unrelated_histories {
pull_args.push(OsString::from("--allow-unrelated-histories"));
}
}
"rebase" => pull_args.push(OsString::from("--rebase")),
"ff-only" => pull_args.push(OsString::from("--ff-only")),
_ => return Err("Unknown pull strategy.".to_string()),
}
if let Some(remote_name) = remote.map(str::trim).filter(|value| !value.is_empty()) {
validate_remote_name(repo, remote_name, true)?;
pull_args.push(remote_name.into());
let branch = branch
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.map(Ok)
.unwrap_or_else(|| current_branch_name(repo))?;
pull_args.push(branch.into());
}
Ok(pull_args)
}
#[tauri::command]
pub async fn fetch(
path: String,
@@ -3010,7 +2973,9 @@ fn first_remote_name(repo: &Path) -> Option<String> {
}
fn current_branch_name(repo: &Path) -> Result<String, String> {
let branch = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"])?;
// `symbolic-ref` also works before the first commit, while
// `rev-parse --abbrev-ref HEAD` fails for an unborn HEAD.
let branch = run_git(repo, ["symbolic-ref", "--quiet", "--short", "HEAD"])?;
let branch = String::from_utf8_lossy(&branch).trim().to_string();
if branch.is_empty() || branch == "HEAD" {
return Err("Could not determine current branch.".to_string());
@@ -5481,19 +5446,26 @@ fn clone_repository_core(
run_git_clone(remote_url.trim(), &target, username, password)?;
let repo = resolve_repo(&target.to_string_lossy())?;
let lfs_warning = sync_git_lfs_objects_if_needed(
&repo,
Some("origin"),
username,
password,
true,
)
.err()
.map(|error| {
format!(
"Repository cloned, but Git LFS objects could not be downloaded automatically: {error}"
let lfs_warning = if verify_commit(&repo, "HEAD").is_ok() {
sync_git_lfs_objects_if_needed(
&repo,
Some("origin"),
username,
password,
true,
)
});
.err()
.map(|error| {
format!(
"Repository cloned, but Git LFS objects could not be downloaded automatically: {error}"
)
})
} else {
// There cannot be LFS pointers to download before the first commit.
// In particular, avoid Git LFS implementations that try to resolve
// HEAD themselves and fail on an empty repository.
None
};
let mut bundle = repository_bundle_for_repo(&repo, commit_limit)?;
bundle.warning = lfs_warning;
@@ -7840,6 +7812,37 @@ mod tests {
assert!(bundle.warning.is_none());
}
#[test]
fn clone_repository_core_supports_empty_repository() {
let source = init_bare_temp_repo("empty_clone_source");
let parent = temp_dir("empty_clone_parent");
let bundle = clone_repository_core(
source.path.to_str().expect("source path should be UTF-8"),
parent.path.to_str().expect("parent path should be UTF-8"),
Some("local-copy"),
None,
None,
Some(100),
)
.expect("empty repository should clone");
let cloned_repo = parent.path.join("local-copy");
assert_eq!(
PathBuf::from(bundle.status.repo_path),
cloned_repo
.canonicalize()
.expect("clone path should resolve")
);
assert!(bundle.status.clean);
assert!(bundle.commits.is_empty());
assert!(bundle.branches.is_empty());
assert!(bundle.tags.is_empty());
assert!(bundle.stashes.is_empty());
assert!(bundle.files.is_empty());
assert!(bundle.warning.is_none());
}
#[test]
#[cfg_attr(
windows,
@@ -8538,6 +8541,108 @@ mod tests {
);
}
#[test]
fn pull_with_selected_remote_infers_current_branch() {
let repo = init_temp_repo("pull_selected_remote");
let branch = git_output_test(&repo.path, ["symbolic-ref", "--short", "HEAD"]);
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
let args = pull_args_for_repo(&repo.path, "merge", Some("origin"), None, false)
.expect("pull arguments should be created");
assert_eq!(
args,
vec![
OsString::from("pull"),
OsString::from("--no-rebase"),
OsString::from("--no-edit"),
OsString::from("origin"),
OsString::from(branch),
]
);
}
#[test]
fn pull_can_explicitly_allow_unrelated_histories_for_merge() {
let repo = init_temp_repo("pull_unrelated_histories");
let branch = git_output_test(&repo.path, ["symbolic-ref", "--short", "HEAD"]);
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
let args = pull_args_for_repo(&repo.path, "merge", Some("origin"), None, true)
.expect("pull arguments should be created");
assert_eq!(
args,
vec![
OsString::from("pull"),
OsString::from("--no-rebase"),
OsString::from("--no-edit"),
OsString::from("--allow-unrelated-histories"),
OsString::from("origin"),
OsString::from(branch),
]
);
}
#[tokio::test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error"
)]
async fn pull_retries_unrelated_histories_only_after_explicit_opt_in() {
let remote = init_temp_repo("pull_unrelated_remote");
fs::write(remote.path.join("remote.txt"), "remote history\n")
.expect("remote file should be written");
run_git_test(&remote.path, ["add", "remote.txt"]);
run_git_test(&remote.path, ["commit", "-q", "-m", "remote init"]);
let remote_branch = git_output_test(&remote.path, ["branch", "--show-current"]);
let local = init_temp_repo("pull_unrelated_local");
fs::write(local.path.join("local.txt"), "local history\n")
.expect("local file should be written");
run_git_test(&local.path, ["add", "local.txt"]);
run_git_test(&local.path, ["commit", "-q", "-m", "local init"]);
run_git_test(
&local.path,
["remote", "add", "origin", remote.path.to_str().unwrap()],
);
let error = pull(
local.path.to_string_lossy().to_string(),
None,
None,
Some("merge".to_string()),
Some("origin".to_string()),
Some(remote_branch.clone()),
Some(false),
)
.await
.expect_err("unrelated histories should require explicit opt-in");
assert!(error.contains("refusing to merge unrelated histories"));
let status = pull(
local.path.to_string_lossy().to_string(),
None,
None,
Some("merge".to_string()),
Some("origin".to_string()),
Some(remote_branch),
Some(true),
)
.await
.expect("explicitly allowed histories should merge");
assert!(status.clean, "{:?}", status.files);
assert!(local.path.join("local.txt").exists());
assert!(local.path.join("remote.txt").exists());
let parent_count =
git_output_test(&local.path, ["rev-list", "--parents", "-n", "1", "HEAD"])
.split_whitespace()
.count()
- 1;
assert_eq!(parent_count, 2);
}
#[tokio::test]
#[cfg_attr(
windows,
@@ -8580,6 +8685,7 @@ mod tests {
None,
None,
None,
None,
)
.await
.unwrap();
+17 -22
View File
@@ -14,24 +14,23 @@ use git::{
SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit,
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes,
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install,
git_lfs_prune, git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository,
last_commit_message, list_branches, list_commits, list_file_history,
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
rename_remote_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
untrack_paths, update_remote,
commit_ai_review, commit_ai_split, compare_commits, compare_file_to_head,
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
delete_branch, delete_commit_note, delete_remote_branch, delete_remote_branches, delete_tag,
diff_file_against_working_tree, fetch, fetch_commit_notes, get_commit_note, get_file_blame,
get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune, git_lfs_pull,
git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository, last_commit_message,
list_branches, list_commits, list_file_history, list_interactive_rebase_commits, list_reflog,
list_remotes, list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree,
merge_abort, merge_branch, merge_continue, move_worktree, open_repo_in_explorer,
open_repository, open_repository_bundle, open_repository_file, prune_worktrees, pull, push,
push_commit_notes, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue,
remove_remote, remove_worktree, rename_branch, rename_remote_branch, repair_worktree,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
restore_reflog_entry, restore_to_commit, revert_commit, run_sequence_editor_if_requested,
search_code_introductions, set_branch_upstream, set_commit_note, stage_files,
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
unlock_worktree, unstage_files, untrack_paths, update_remote,
};
use integrations::list_integration_repositories;
use std::path::{Path, PathBuf};
@@ -317,7 +316,6 @@ async fn main() {
.manage(StartupRepository(Mutex::new(startup_repository)))
.manage(StartupClone(Mutex::new(startup_clone)))
.manage(SearchCancellationState::default())
.manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init());
// Linux installs are expected to come from the system package manager (see the
@@ -388,9 +386,6 @@ async fn main() {
amend_commit,
undo_last_commit,
last_commit_message,
commit_ai_status,
commit_ai_load,
commit_ai_local_models,
commit_ai_generate,
commit_ai_review,
commit_ai_split,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "2026.8.8",
"version": "2026.8.9",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
+66 -93
View File
@@ -49,9 +49,6 @@
commitAiGenerate,
commitAiReview,
commitAiSplit,
commitAiLoad,
commitAiLocalModels,
commitAiStatus,
compareCommits,
cancelCodeSearch,
cancelFileHistory,
@@ -151,7 +148,6 @@
AppLanguage,
AppTheme,
AnalyticsSettings,
CommitAiPhase,
CustomThemeColors,
ConflictFile,
DetectedExternalTool,
@@ -179,7 +175,6 @@
GitStatus,
GitTag,
GitWorktree,
LocalModelOption,
PatchApplyAction,
PreparedResolution,
RebaseCommit,
@@ -360,8 +355,6 @@
let commitMessage = "";
let amendMode = false;
let preAmendDraftMessage = "";
let lastLocalAiGeneratedMessage = "";
let commitAiPhase: CommitAiPhase = "idle";
let commitAiGenerating = false;
let commitAiReviewing = false;
let commitAiSplitting = false;
@@ -369,7 +362,6 @@
let aiCommitSplitOpen = false;
let aiReviewResult: AiReviewResult | null = null;
let aiReviewOpen = false;
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
let aiSettings: AiSettings = defaultAiSettings();
let aiSettingsOpen = false;
let appSettingsOpen = false;
@@ -387,7 +379,6 @@
let detectedExternalTools: DetectedExternalTool[] = [];
let externalToolsDetectionPending = true;
let externalToolsDetectionUnavailable = false;
let localModelOptions: LocalModelOption[] = [];
let errorMessage = "";
let operation = "";
let compareFrom = "";
@@ -628,7 +619,6 @@
if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = undefined; }
if (backgroundRepoStatusTimer) { clearInterval(backgroundRepoStatusTimer); backgroundRepoStatusTimer = undefined; }
if (backgroundFetchTimer) { clearInterval(backgroundFetchTimer); backgroundFetchTimer = undefined; }
if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
}
function wait(ms: number): Promise<void> {
@@ -681,7 +671,7 @@
loadRepoLists();
void checkForUpdates();
void initCommitAi();
aiSettings = loadAiSettings();
try {
await waitForStartupPaint();
@@ -1029,48 +1019,10 @@
// ── Commit AI ──────────────────────────────────────────────────────────────
function stopCommitAiPolling() {
if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
}
async function pollCommitAiStatus() {
try {
const result = await commitAiStatus();
commitAiPhase = result.phase;
} catch { /* ignore transient errors */ }
if (commitAiPhase === "ready" || commitAiPhase === "error") stopCommitAiPolling();
}
function startCommitAiPolling() {
// Only the local model has a download/load phase worth polling — cloud providers are
// plain API calls with nothing to wait for.
stopCommitAiPolling();
if (aiSettings.provider !== "local") return;
void pollCommitAiStatus();
commitAiPollTimer = setInterval(() => { void pollCommitAiStatus(); }, 2000);
}
async function initCommitAi() {
aiSettings = loadAiSettings();
try {
localModelOptions = await commitAiLocalModels();
} catch { /* AI features stay disabled if this fails; not fatal to the app */ }
if (aiSettings.provider === "local") {
try { await commitAiLoad(aiSettings.localModelId); } catch { /* surfaced via status polling */ }
}
startCommitAiPolling();
}
function saveAiSettings(next: AiSettings) {
const modelChanged = next.provider === "local" && next.localModelId !== aiSettings.localModelId;
aiSettings = next;
persistAiSettings(next);
aiSettingsOpen = false;
if (next.provider === "local" && (modelChanged || commitAiPhase === "idle")) {
commitAiPhase = "idle";
void commitAiLoad(next.localModelId);
}
startCommitAiPolling();
}
function defaultAnalyticsSettings(): AnalyticsSettings {
@@ -1324,27 +1276,15 @@
function updateCommitMessage(message: string) {
commitMessage = message;
if (message !== lastLocalAiGeneratedMessage) {
lastLocalAiGeneratedMessage = "";
}
}
async function generateCommitMessageWithAi() {
if (!activeRepoPath || commitAiGenerating) return;
if (aiSettings.provider === "local" && commitAiPhase !== "ready") return;
commitAiGenerating = true;
errorMessage = "";
try {
const notes = commitMessage.trim() || undefined;
if (aiSettings.provider === "local") {
const localNotes = notes && notes !== lastLocalAiGeneratedMessage ? notes : undefined;
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "local",
notes: localNotes,
localProfile: aiSettings.localProfile,
});
lastLocalAiGeneratedMessage = commitMessage;
} else if (aiSettings.provider === "openai") {
if (aiSettings.provider === "openai") {
const cred = await credLoad("ai:openai");
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "openai",
@@ -1352,7 +1292,6 @@
model: aiSettings.openaiModel,
apiKey: cred?.password,
});
lastLocalAiGeneratedMessage = "";
} else if (aiSettings.provider === "anthropic") {
const cred = await credLoad("ai:anthropic");
commitMessage = await commitAiGenerate(activeRepoPath, {
@@ -1361,7 +1300,6 @@
model: aiSettings.anthropicModel,
apiKey: cred?.password,
});
lastLocalAiGeneratedMessage = "";
} else {
const cred = await credLoad("ai:custom");
commitMessage = await commitAiGenerate(activeRepoPath, {
@@ -1371,7 +1309,6 @@
baseUrl: aiSettings.customBaseUrl,
apiKey: cred?.password,
});
lastLocalAiGeneratedMessage = "";
}
} catch (error) {
errorMessage = errorToMessage(error);
@@ -1382,10 +1319,6 @@
async function reviewStagedWithAi() {
if (!activeRepoPath || commitAiReviewing || stagedCount === 0) return;
if (aiSettings.provider === "local") {
errorMessage = "Pre-commit review currently requires OpenAI, Anthropic, or a custom endpoint.";
return;
}
commitAiReviewing = true;
errorMessage = "";
try {
@@ -1427,10 +1360,6 @@
async function splitStagedWithAi() {
if (!activeRepoPath || commitAiSplitting || stagedCount < 2) return;
if (aiSettings.provider === "local") {
errorMessage = "Commit splitting currently requires OpenAI, Anthropic, or a custom endpoint.";
return;
}
commitAiSplitting = true;
errorMessage = "";
try {
@@ -1735,8 +1664,6 @@
function defaultAiSettings(): AiSettings {
return {
provider: "openai",
localModelId: "qwen2.5-0.5b",
localProfile: "fast",
openaiModel: "gpt-4o-mini",
anthropicModel: "claude-3-5-haiku-latest",
customBaseUrl: "",
@@ -1748,11 +1675,17 @@
try {
const stored = JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) ?? "null") as unknown;
if (stored && typeof stored === "object") {
const merged = { ...defaultAiSettings(), ...(stored as Partial<AiSettings>) };
// Local AI is still in development and disabled in the settings UI — migrate any
// previously saved selection away from it so nobody gets stuck on a dead option.
if (merged.provider === "local") merged.provider = "openai";
return merged;
const candidate = stored as Partial<AiSettings> & { provider?: unknown };
const provider = candidate.provider === "anthropic" || candidate.provider === "custom"
? candidate.provider
: "openai";
return {
provider,
openaiModel: typeof candidate.openaiModel === "string" ? candidate.openaiModel : "gpt-4o-mini",
anthropicModel: typeof candidate.anthropicModel === "string" ? candidate.anthropicModel : "claude-3-5-haiku-latest",
customBaseUrl: typeof candidate.customBaseUrl === "string" ? candidate.customBaseUrl : "",
customModel: typeof candidate.customModel === "string" ? candidate.customModel : "",
};
}
} catch {
// Fall through to defaults below.
@@ -2228,6 +2161,10 @@
|| value.includes("fetch first");
}
function isUnrelatedHistoriesError(message: string): boolean {
return message.toLowerCase().includes("refusing to merge unrelated histories");
}
function statusHasConflicts(value: GitStatus | null): boolean {
return (value?.files ?? []).some((file) => file.staged === "conflicted" || file.unstaged === "conflicted");
}
@@ -3717,17 +3654,61 @@
mode: CredentialMode,
) {
errorMessage = "";
await runOperation("Pulling", async () => {
applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
await refreshRepositoryViews(activeRepoPath);
const pulled = await pullWithUnrelatedHistoryConfirmation(username, password, "Pulling");
if (pulled) {
trackEvent("repository_pulled", {
from_stored_credential: fromStore ? 1 : 0,
changed_files: status?.files.length ?? 0,
});
});
}
handleRemoteResult("pull", key, fromStore, username, mode);
}
async function pullWithUnrelatedHistoryConfirmation(
username: string,
password: string,
label: string,
): Promise<boolean> {
await runOperation(label, async () => {
applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
await refreshRepositoryViews(activeRepoPath);
});
if (!errorMessage || !isUnrelatedHistoriesError(errorMessage)) return !errorMessage;
if (pullStrategy !== "merge") {
errorMessage = appLanguage === "de"
? "Lokales und entferntes Repository haben unabhängige Historien. Wähle in den Sync-Einstellungen die Merge-Strategie, um sie zusammenzuführen."
: "The local and remote repositories have unrelated histories. Choose the Merge strategy in Sync settings to combine them.";
return false;
}
errorMessage = "";
const confirmed = window.confirm(appLanguage === "de"
? "Das lokale und das entfernte Repository besitzen getrennte Commit-Historien.\n\nTrotzdem zusammenführen? Dabei können Merge-Konflikte entstehen."
: "The local and remote repositories have separate commit histories.\n\nMerge them anyway? This may produce merge conflicts.");
if (!confirmed) {
errorMessage = appLanguage === "de"
? "Pull abgebrochen: Die getrennten Historien wurden nicht verändert."
: "Pull cancelled: the separate histories were left unchanged.";
return false;
}
await runOperation(appLanguage === "de" ? "Historien zusammenführen" : "Merging histories", async () => {
applyStatus(await pull(
activeRepoPath,
username,
password,
pullStrategy,
selectedRemote || undefined,
undefined,
true,
));
await refreshRepositoryViews(activeRepoPath);
});
return !errorMessage;
}
async function doActualFetch(
username: string,
password: string,
@@ -3782,10 +3763,7 @@
if (!fromStore) credDialogError = "";
await runOperation("Pulling before push", async () => {
applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
await refreshRepositoryViews(activeRepoPath);
});
await pullWithUnrelatedHistoryConfirmation(username, password, "Pulling before push");
if (errorMessage) {
handleRemoteResult("pull", key, fromStore, username, mode);
@@ -4385,7 +4363,6 @@
commitMessage = "";
amendMode = false;
preAmendDraftMessage = "";
lastLocalAiGeneratedMessage = "";
await refreshRepositoryViews(activeRepoPath);
trackEvent("commit_created", { amend: 1 });
});
@@ -4396,7 +4373,6 @@
await runOperation("Committing", async () => {
applyStatus(await commit(activeRepoPath, message));
commitMessage = "";
lastLocalAiGeneratedMessage = "";
await refreshRepositoryViews(activeRepoPath);
trackEvent("commit_created", { amend: 0, staged_files: trackedStagedCount });
});
@@ -5617,8 +5593,6 @@
{isBusy}
{operation}
{stagedCount}
commitAiProvider={aiSettings.provider}
{commitAiPhase}
{commitAiGenerating}
{commitAiReviewing}
{commitAiSplitting}
@@ -5983,7 +5957,6 @@
{#await import("./lib/components/AiSettingsDialog.svelte") then module}
<module.default
settings={aiSettings}
localModels={localModelOptions}
onSave={saveAiSettings}
onClose={() => { aiSettingsOpen = false; }}
/>
+83 -115
View File
@@ -1486,73 +1486,61 @@
right: 18px;
bottom: 18px;
z-index: 70;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
width: min(430px, calc(100vw - 32px));
padding: 14px;
width: min(410px, calc(100vw - 28px));
overflow: hidden;
border: 1px solid rgba(100, 108, 255, 0.42);
border-radius: 14px;
border: 1px solid var(--color-border);
border-top: 2px solid var(--color-accent);
color: var(--color-ink);
background:
linear-gradient(135deg, rgba(100,108,255,0.22), rgba(189,52,254,0.12) 42%, rgba(65,209,255,0.08)),
rgba(12, 13, 24, 0.96);
box-shadow: 0 24px 72px rgba(0,0,0,0.48), 0 0 0 1px rgba(255,255,255,0.04) inset;
backdrop-filter: blur(18px);
background: var(--app-dialog-bg);
box-shadow: 0 18px 48px rgba(0,0,0,0.42);
animation: update-toast-in 180ms cubic-bezier(.2,.8,.2,1) both;
}
.update-toast.error {
border-color: rgba(232,96,96,0.45);
background:
linear-gradient(135deg, rgba(232,96,96,0.16), rgba(100,108,255,0.11)),
rgba(12, 13, 24, 0.96);
border-top-color: var(--code-delete-strong);
}
.update-toast.installed {
border-color: rgba(78,202,118,0.38);
background:
linear-gradient(135deg, rgba(78,202,118,0.15), rgba(65,209,255,0.1), rgba(100,108,255,0.12)),
rgba(12, 13, 24, 0.96);
border-top-color: var(--code-add-strong);
}
.update-toast-glow {
position: absolute;
inset: auto 18px -46px auto;
width: 170px;
height: 95px;
border-radius: 999px;
background: rgba(65,209,255,0.18);
filter: blur(34px);
pointer-events: none;
.update-toast-header {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 10px 11px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--app-dialog-chrome);
}
.update-toast-icon {
display: grid;
place-items: center;
width: 42px;
height: 42px;
width: 34px;
height: 34px;
flex: 0 0 auto;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 12px;
color: #ffffff;
background: linear-gradient(135deg, rgba(65,209,255,0.28), rgba(100,108,255,0.54), rgba(189,52,254,0.42));
box-shadow: 0 14px 32px rgba(100,108,255,0.22), inset 0 1px 0 rgba(255,255,255,0.16);
border: 1px solid color-mix(in srgb, var(--color-accent) 32%, var(--color-border));
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface-raised));
}
.update-toast-icon.busy { color: #bfefff; }
.update-toast-content {
position: relative;
z-index: 1;
display: grid;
gap: 9px;
min-width: 0;
.update-toast.error .update-toast-icon {
border-color: color-mix(in srgb, var(--code-delete-strong) 34%, var(--color-border));
color: var(--code-delete-strong);
background: var(--code-delete-bg);
}
.update-toast.installed .update-toast-icon {
border-color: color-mix(in srgb, var(--code-add-strong) 34%, var(--color-border));
color: var(--code-add-strong);
background: var(--code-add-bg);
}
.update-toast-icon.busy { color: var(--color-accent); }
.update-toast-top {
.update-toast-body {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 8px;
gap: 10px;
min-width: 0;
padding: 12px;
background: var(--app-dialog-bg);
}
.update-toast-copy { min-width: 0; }
@@ -1561,55 +1549,54 @@
overflow: hidden;
color: var(--color-accent);
font-family: var(--font-mono);
font-size: 11px;
font-size: 10px;
font-weight: 800;
letter-spacing: .035em;
text-transform: uppercase;
text-overflow: ellipsis;
white-space: nowrap;
}
.update-toast h2 {
margin: 2px 0 0;
color: #ffffff;
font-size: 15px;
margin: 3px 0 0;
color: var(--color-ink);
font-size: 14px;
font-weight: 800;
line-height: 1.2;
}
.update-toast p {
margin: 0;
color: var(--color-ink-muted);
font-size: 12.5px;
font-size: 12px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.update-toast-close {
width: 28px;
min-width: 28px;
min-height: 28px;
width: 26px;
min-width: 26px;
min-height: 26px;
padding: 0;
border-color: rgba(255,255,255,0.1);
border-radius: 8px;
border-color: var(--color-border-subtle);
color: var(--color-ink-dim);
background: rgba(255,255,255,0.04);
background: transparent;
}
.update-toast-close:hover:not(:disabled) {
border-color: rgba(255,255,255,0.2);
color: #ffffff;
background: rgba(255,255,255,0.1);
border-color: var(--color-border);
color: var(--color-ink);
background: var(--color-surface-hover);
}
.update-progress {
position: relative;
height: 6px;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: rgba(255,255,255,0.09);
background: var(--color-surface-hover);
}
.update-progress span {
position: absolute;
inset: 0 auto 0 0;
min-width: 0;
border-radius: inherit;
background: linear-gradient(90deg, #4db6d6, #6f8cff, #238eb4);
background: var(--color-accent);
transition: width 160ms ease;
}
.update-progress.indeterminate span {
@@ -1627,29 +1614,48 @@
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
gap: 7px;
min-height: 49px;
padding: 8px 11px;
border-top: 1px solid var(--color-border-subtle);
background: var(--app-dialog-chrome);
}
.update-toast-primary,
.update-toast-secondary {
min-height: 32px;
border-radius: 8px;
font-size: 12.5px;
min-height: 31px;
font-size: 11.5px;
font-weight: 800;
}
.update-toast-primary {
border-color: rgba(111,140,255,0.72);
border-color: var(--color-primary);
color: #ffffff;
background: linear-gradient(135deg, #5f7df2, #238eb4);
background: var(--color-primary);
}
.update-toast-primary:hover:not(:disabled) {
border-color: rgba(77,182,214,0.74);
border-color: var(--color-primary-dark);
color: #ffffff;
background: linear-gradient(135deg, #6f8cff, #2da0c7);
background: var(--color-primary-dark);
}
.update-toast-secondary {
border-color: rgba(255,255,255,0.1);
border-color: var(--color-border);
color: var(--color-ink-muted);
background: rgba(255,255,255,0.05);
background: var(--app-button-bg);
}
@keyframes update-toast-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 480px) {
.update-toast {
right: 8px;
bottom: 8px;
width: calc(100vw - 16px);
}
.update-toast-actions > button {
flex: 1 1 0;
}
}
/* --- Workspace layout --- */
@@ -4160,44 +4166,6 @@
color: #f5f7ff;
background: linear-gradient(180deg, rgba(100,108,255,0.22), rgba(65,209,255,0.1));
}
.ai-provider-option-local {
flex-wrap: wrap;
row-gap: 2px;
}
.ai-provider-badge {
flex-basis: 100%;
text-align: center;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-ink-faint);
}
.ai-local-profile-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ai-local-profile-option {
min-width: 0;
min-height: 34px;
padding: 0 8px;
border-color: var(--color-border-subtle);
background: rgba(255,255,255,0.03);
color: var(--color-ink-dim);
font-size: 12px;
font-weight: 700;
}
.ai-local-profile-option:hover:not(:disabled) {
border-color: var(--color-border);
color: var(--color-ink);
background: var(--color-surface-hover);
}
.ai-local-profile-option.active {
border-color: rgba(65,209,255,0.48);
color: #f5f7ff;
background: linear-gradient(180deg, rgba(65,209,255,0.16), rgba(100,108,255,0.12));
}
.new-branch-form {
display: flex;
flex-direction: column;
+1 -2
View File
@@ -15,8 +15,7 @@
function providerLabel(value: CommitAiProvider): string {
if (value === "openai") return "OpenAI";
if (value === "anthropic") return "Anthropic";
if (value === "custom") return "Custom endpoint";
return "Local AI";
return "Custom endpoint";
}
function locationLabel(finding: AiReviewFinding): string {
+6 -76
View File
@@ -1,20 +1,18 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
import { Bot, Check, Eye, EyeOff, Globe, Key, LoaderCircle, X } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
import SelectMenu from "./SelectMenu.svelte";
import type { AiSettings, CommitAiProvider } from "../types";
interface Props {
settings: AiSettings;
localModels: LocalModelOption[];
onSave: (settings: AiSettings) => void;
onClose: () => void;
}
let { settings, localModels = [], onSave, onClose }: Props = $props();
let { settings, onSave, onClose }: Props = $props();
type CloudProvider = Exclude<CommitAiProvider, "local">;
type CloudProvider = CommitAiProvider;
const CRED_KEYS: Record<CloudProvider, string> = {
openai: "ai:openai",
@@ -22,9 +20,7 @@
custom: "ai:custom",
};
let provider = $state<CommitAiProvider>("local");
let localModelId = $state("");
let localProfile = $state<CommitAiLocalProfile>("fast");
let provider = $state<CommitAiProvider>("openai");
let openaiModel = $state("");
let anthropicModel = $state("");
let customBaseUrl = $state("");
@@ -41,8 +37,6 @@
$effect(() => {
provider = settings.provider;
localModelId = settings.localModelId;
localProfile = settings.localProfile ?? "fast";
openaiModel = settings.openaiModel;
anthropicModel = settings.anthropicModel;
customBaseUrl = settings.customBaseUrl;
@@ -103,8 +97,6 @@
]);
onSave({
provider,
localModelId,
localProfile,
openaiModel: openaiModel.trim() || "gpt-4o-mini",
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
customBaseUrl: customBaseUrl.trim(),
@@ -117,26 +109,6 @@
}
}
function formatSize(mb: number): string {
return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`;
}
function recommendedModelForProfile(profile: CommitAiLocalProfile): string {
if (profile === "balanced") return "qwen2.5-1.5b";
if (profile === "detailed") return "qwen2.5-3b";
return "qwen2.5-0.5b";
}
function selectLocalProfile(profile: CommitAiLocalProfile) {
const previousRecommended = recommendedModelForProfile(localProfile);
localProfile = profile;
const nextRecommended = recommendedModelForProfile(profile);
if (!localModelId || localModelId === previousRecommended) {
localModelId = nextRecommended;
}
}
let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId));
</script>
<div
@@ -156,17 +128,6 @@
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
<button
type="button"
class="ai-provider-option ai-provider-option-local"
class:active={provider === "local"}
disabled
title="Local AI is still in development and not yet available"
>
<Cpu size={16} aria-hidden="true" />
Local AI
<span class="ai-provider-badge">In development</span>
</button>
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
<Bot size={16} aria-hidden="true" />
OpenAI
@@ -181,38 +142,7 @@
</button>
</div>
{#if provider === "local"}
<div class="cred-field">
<span class="cred-field-label">Local speed</span>
<div class="ai-local-profile-options" role="radiogroup" aria-label="Local AI speed">
<button type="button" class="ai-local-profile-option" class:active={localProfile === "fast"} onclick={() => selectLocalProfile("fast")}>
<Zap size={15} aria-hidden="true" />
Fast
</button>
<button type="button" class="ai-local-profile-option" class:active={localProfile === "balanced"} onclick={() => selectLocalProfile("balanced")}>
<Gauge size={15} aria-hidden="true" />
Balanced
</button>
<button type="button" class="ai-local-profile-option" class:active={localProfile === "detailed"} onclick={() => selectLocalProfile("detailed")}>
<Sparkles size={15} aria-hidden="true" />
Detailed
</button>
</div>
</div>
<label class="cred-field">
<span class="cred-field-label">Model</span>
<SelectMenu value={localModelId} options={localModels.map((option) => ({ value: option.id, label: `${option.label} - ${formatSize(option.approx_size_mb)}` }))} onChange={(value) => { localModelId = value; }} />
</label>
<div class="cred-token-hint">
<AlertCircle size={13} aria-hidden="true" />
<span>
Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
in the background — depending on your internet connection this can take several minutes.
After that it stays cached locally and loads instantly on the next start.
The speed setting only changes Local AI; API providers keep their existing prompt.
</span>
</div>
{:else if provider === "openai"}
{#if provider === "openai"}
<label class="cred-field">
<span class="cred-field-label">Model</span>
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
+7 -16
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { Check, GitCommitHorizontal, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props {
commitMessage: string;
@@ -10,8 +9,6 @@
isBusy: boolean;
operation: string;
stagedCount: number;
commitAiProvider: CommitAiProvider;
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
commitAiReviewing: boolean;
commitAiSplitting: boolean;
@@ -35,8 +32,6 @@
isBusy = false,
operation = "",
stagedCount = 0,
commitAiProvider = "local",
commitAiPhase = "idle",
commitAiGenerating = false,
commitAiReviewing = false,
commitAiSplitting = false,
@@ -57,23 +52,19 @@
onCommit();
}
function aiButtonTitle(provider: CommitAiProvider, phase: CommitAiPhase, staged: number): string {
function aiButtonTitle(staged: number): string {
if (staged === 0) return "Stage changes first";
if (provider === "local" && phase === "loading") return "AI model is downloading/loading — this happens once";
if (provider === "local" && phase === "error") return "AI model failed to load — check AI settings";
return "Generate commit message with AI from the staged diff";
}
let localModelLoading = $derived(commitAiProvider === "local" && commitAiPhase === "loading");
let canGenerate = $derived(
hasRepository &&
!isBusy &&
!commitAiGenerating &&
!commitAiReviewing &&
stagedCount > 0 &&
(commitAiProvider !== "local" || commitAiPhase === "ready"),
stagedCount > 0,
);
let canReview = $derived(canGenerate && commitAiProvider !== "local");
let canReview = $derived(canGenerate);
let canSplit = $derived(canReview && stagedCount > 1 && !commitAiSplitting);
</script>
@@ -90,7 +81,7 @@
type="button"
onclick={onSplitStaged}
disabled={!canSplit}
title={commitAiProvider === "local" ? "Commit splitting currently requires an API provider" : "Suggest logical commits for the staged files"}
title="Suggest logical commits for the staged files"
>
{#if commitAiSplitting}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<GitCommitHorizontal size={14} aria-hidden="true" />{/if}
Split
@@ -100,7 +91,7 @@
type="button"
onclick={onReviewStaged}
disabled={!canReview}
title={commitAiProvider === "local" ? "Pre-commit review currently requires an API provider" : stagedCount === 0 ? "Stage changes first" : "Review staged changes for bugs and risks"}
title={stagedCount === 0 ? "Stage changes first" : "Review staged changes for bugs and risks"}
>
{#if commitAiReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<ShieldCheck size={14} aria-hidden="true" />{/if}
Review
@@ -110,9 +101,9 @@
type="button"
onclick={onGenerateCommitMessage}
disabled={!canGenerate}
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
title={aiButtonTitle(stagedCount)}
>
{#if commitAiGenerating || localModelLoading}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<Sparkles size={14} aria-hidden="true" />{/if}
{#if commitAiGenerating}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<Sparkles size={14} aria-hidden="true" />{/if}
Generate
</button>
<button class="commit-settings-button" type="button" onclick={onOpenAiSettings} disabled={isBusy} title="AI settings" aria-label="AI settings">
+53 -54
View File
@@ -53,7 +53,7 @@
);
const versionLabel = $derived(
version && currentVersion
? `${currentVersion} -> ${version}`
? `${currentVersion} ${version}`
: version
? `Version ${version}`
: "New version",
@@ -69,34 +69,32 @@
role={state === "error" ? "alert" : "status"}
aria-live={state === "error" ? "assertive" : "polite"}
>
<div class="update-toast-glow" aria-hidden="true"></div>
<div class="update-toast-icon" class:busy={isBusy}>
{#if state === "downloading"}
<LoaderCircle class="spin" size={21} aria-hidden="true" />
{:else if state === "installed"}
<PackageCheck size={21} aria-hidden="true" />
{:else if state === "error"}
<AlertCircle size={21} aria-hidden="true" />
{:else}
<Sparkles size={21} aria-hidden="true" />
{/if}
</div>
<div class="update-toast-content">
<div class="update-toast-top">
<div class="update-toast-copy">
<span class="update-toast-kicker">{versionLabel}</span>
<h2>{title}</h2>
</div>
{#if !isBusy}
<button class="update-toast-close" type="button" onclick={onDismiss} title="Close" aria-label="Close">
<X size={15} aria-hidden="true" />
</button>
<header class="update-toast-header">
<div class="update-toast-icon" class:busy={isBusy}>
{#if state === "downloading"}
<LoaderCircle class="spin" size={18} aria-hidden="true" />
{:else if state === "installed"}
<PackageCheck size={18} aria-hidden="true" />
{:else if state === "error"}
<AlertCircle size={18} aria-hidden="true" />
{:else}
<Sparkles size={18} aria-hidden="true" />
{/if}
</div>
<div class="update-toast-copy">
<span class="update-toast-kicker">Gitty update · {versionLabel}</span>
<h2>{title}</h2>
</div>
{#if !isBusy}
<button class="update-toast-close" type="button" onclick={onDismiss} title="Close" aria-label="Close">
<X size={15} aria-hidden="true" />
</button>
{/if}
</header>
<div class="update-toast-body">
<p>{description}</p>
{#if showProgress}
@@ -115,33 +113,34 @@
</div>
{/if}
<div class="update-toast-actions">
{#if state === "installed"}
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
Got it
</button>
{:else if state === "error"}
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
Close
</button>
<button class="update-toast-primary" type="button" onclick={onInstall}>
<Download size={14} aria-hidden="true" />
Try again
</button>
{:else}
<button class="update-toast-secondary" type="button" onclick={onLater} disabled={isBusy}>
Later
</button>
<button class="update-toast-primary" type="button" onclick={onInstall} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={14} aria-hidden="true" />
Installing
{:else}
<Download size={14} aria-hidden="true" />
Install
{/if}
</button>
{/if}
</div>
</div>
<footer class="update-toast-actions">
{#if state === "installed"}
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
Got it
</button>
{:else if state === "error"}
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
Close
</button>
<button class="update-toast-primary" type="button" onclick={onInstall}>
<Download size={14} aria-hidden="true" />
Try again
</button>
{:else}
<button class="update-toast-secondary" type="button" onclick={onLater} disabled={isBusy}>
Later
</button>
<button class="update-toast-primary" type="button" onclick={onInstall} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={14} aria-hidden="true" />
Installing
{:else}
<Download size={14} aria-hidden="true" />
Install
{/if}
</button>
{/if}
</footer>
</section>
+18 -19
View File
@@ -3,9 +3,7 @@ import { tracedInvoke as invoke } from "./telemetry";
import type {
AiReviewResult,
AiCommitPlan,
CommitAiLocalProfile,
CommitAiProvider,
CommitAiStatus,
ConflictFile,
DetectedExternalTool,
ExternalToolCommand,
@@ -30,7 +28,6 @@ import type {
GitStatus,
GitTag,
GitWorktree,
LocalModelOption,
PatchApplyAction,
RepositoryBundle,
StoredCredential,
@@ -369,22 +366,9 @@ export function stashDrop(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_drop", { path, selector });
}
export function commitAiStatus(): Promise<CommitAiStatus> {
return invoke<CommitAiStatus>("commit_ai_status");
}
export function commitAiLoad(modelId: string): Promise<void> {
return invoke<void>("commit_ai_load", { modelId });
}
export function commitAiLocalModels(): Promise<LocalModelOption[]> {
return invoke<LocalModelOption[]>("commit_ai_local_models");
}
export interface CommitAiGenerateOptions {
notes?: string;
provider: CommitAiProvider;
localProfile?: CommitAiLocalProfile;
model?: string;
apiKey?: string;
baseUrl?: string;
@@ -395,7 +379,6 @@ export function commitAiGenerate(path: string, options: CommitAiGenerateOptions)
path,
notes: options.notes,
provider: options.provider,
localProfile: options.localProfile,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,
@@ -422,8 +405,24 @@ export function commitAiSplit(path: string, options: CommitAiGenerateOptions): P
});
}
export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise<GitStatus> {
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null });
export function pull(
path: string,
username?: string,
password?: string,
strategy: PullStrategy = "merge",
remote?: string,
branch?: string,
allowUnrelatedHistories = false,
): Promise<GitStatus> {
return invoke<GitStatus>("pull", {
path,
username: username ?? null,
password: password ?? null,
strategy,
remote: remote || null,
branch: branch || null,
allowUnrelatedHistories,
});
}
export function fetchRemote(path: string, username?: string, password?: string, prune = false, remote?: string): Promise<GitStatus> {
+1 -17
View File
@@ -9,9 +9,7 @@ export type FileStatusKind =
export type GitIgnoreKind = "file" | "extension" | "folder";
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
export type CommitAiProvider = "openai" | "anthropic" | "custom";
export type AppTheme = "system" | "light" | "dark";
export type AppAppearance = "modern" | "classic" | "custom";
export type AppLanguage = "en" | "de";
@@ -74,12 +72,6 @@ export interface CustomThemeColors {
text: string;
}
export interface CommitAiStatus {
phase: CommitAiPhase;
model_id: string | null;
error: string | null;
}
export type AiReviewRisk = "low" | "medium" | "high";
export type AiReviewSeverity = "critical" | "warning" | "info";
@@ -109,16 +101,8 @@ export interface AiCommitPlan {
groups: AiCommitGroup[];
}
export interface LocalModelOption {
id: string;
label: string;
approx_size_mb: number;
}
export interface AiSettings {
provider: CommitAiProvider;
localModelId: string;
localProfile: CommitAiLocalProfile;
openaiModel: string;
anthropicModel: string;
customBaseUrl: string;