Compare commits

..
6 Commits
Author SHA1 Message Date
Christoph Brandau 8079ac5f64 chore(deps): update lockfile metadata
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Canceled after 4m59s
publish / Build and publish Arch package (release) Canceled after 4m58s
2026-07-28 20:59:42 +02:00
Christoph Brandau df26ecf9fb chore(release): publish 2026.07.22 2026-07-28 20:59:40 +02:00
Christoph Brandau af7b993550 feat(ai-commit): improve split dialog validation and apply flow 2026-07-28 16:11:24 +02:00
Christoph Brandau e07bc96127 fix(app): ignore stale repository open responses 2026-07-28 16:11:21 +02:00
Christoph Brandau f0e87d67d5 feat(ai): add AI-assisted commit splitting flow
Introduce a split-planning path for staged changes that asks supported AI
providers to group files into ordered Conventional Commit messages. The
plan is validated before execution so every staged file is assigned once
and unsafe states are rejected.

A new dialog lets users review and adjust the proposed groups before
creating the commits in sequence, with safeguards to preserve remaining
changes if something fails.
2026-07-28 15:01:32 +02:00
Christoph 3246dfdcfd Update version to 2026.7.21 2026-07-26 23:13:26 +02:00
15 changed files with 566 additions and 10 deletions
+27
View File
@@ -6,6 +6,32 @@ The project uses calendar versions. Displayed release names use `YYYY.MM.DD`;
package metadata uses the equivalent numeric form without leading zeroes where package metadata uses the equivalent numeric form without leading zeroes where
required by the package manager. required by the package manager.
## [2026.07.22] - 2026-07-22
### Added
- AI-assisted commit splitting for staged changes:
- analyze the staged diff and propose an ordered set of logical commits;
- generate an editable Conventional Commit message for every group;
- move files between proposed commits before applying the plan;
- create all accepted commits sequentially with **Commit all**.
- Support for commit-splitting suggestions through OpenAI, Anthropic, and
custom OpenAI-compatible endpoints.
### Changed
- The commit-splitting dialog now explains why a plan cannot be applied and
clearly marks AI-generated commit messages as editable.
- Safety checks prevent applying a stale plan after the staged files change
and reject files that contain both staged and unstaged changes.
### Fixed
- **Commit all** now passes a plain state snapshot to the commit workflow
instead of failing silently when cloning a reactive UI proxy.
- Closing the active repository tab no longer allows an in-flight status or
fetch response to reopen and reactivate the closed repository.
## [2026.07.21] - 2026-07-21 ## [2026.07.21] - 2026-07-21
### Added ### Added
@@ -59,5 +85,6 @@ required by the package manager.
- Improved repository tabs, diff styling, spacing, and panel structure. - Improved repository tabs, diff styling, spacing, and panel structure.
- Improved file-history state handling. - Improved file-history state handling.
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21 [2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
[2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20 [2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
+1 -1
View File
@@ -5,7 +5,7 @@
# binary + desktop entry. Run `makepkg -si` from the repo root. # binary + desktop entry. Run `makepkg -si` from the repo root.
pkgname=gitty pkgname=gitty
pkgver=2026.7.20 pkgver=2026.7.22
pkgrel=1 pkgrel=1
pkgdesc="A lightweight, modern Git client built with Tauri" pkgdesc="A lightweight, modern Git client built with Tauri"
arch=('x86_64') arch=('x86_64')
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.7.20", "version": "2026.7.22",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gitty", "name": "gitty",
"version": "2026.7.20", "version": "2026.7.22",
"dependencies": { "dependencies": {
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.7.20", "version": "2026.7.22",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+127
View File
@@ -215,6 +215,89 @@ pub async fn review_custom(
openai_compatible_review_request(url, api_key, model, diff).await openai_compatible_review_request(url, api_key, model, diff).await
} }
async fn openai_compatible_split_request(
url: String,
bearer: Option<&str>,
model: &str,
diff: &str,
) -> Result<String, String> {
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.";
let user =
format!("Analyze these staged changes and propose an ordered commit plan:\n\n{diff}");
let body = OpenAiRequest {
model: model.to_string(),
messages: vec![
OpenAiMessage {
role: "system",
content: system.to_string(),
},
OpenAiMessage {
role: "user",
content: user,
},
],
temperature: 0.1,
};
let client = http_client()?;
let mut request = client.post(url).json(&body);
if let Some(key) = bearer.filter(|key| !key.trim().is_empty()) {
request = request.bearer_auth(key);
}
let response = request
.send()
.await
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Could not read response: {err}"))?;
if !status.is_success() {
return Err(format!("API error ({status}): {text}"));
}
let parsed: OpenAiResponse =
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed
.choices
.into_iter()
.next()
.and_then(|choice| choice.message.content)
.map(|content| sanitize_message(&content))
.filter(|content| !content.is_empty())
.ok_or_else(|| "The model did not return a commit plan.".to_string())
}
pub async fn split_openai(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
if api_key.trim().is_empty() {
return Err("OpenAI API key is missing.".to_string());
}
openai_compatible_split_request(
"https://api.openai.com/v1/chat/completions".to_string(),
Some(api_key),
model,
diff,
)
.await
}
pub async fn split_custom(
base_url: &str,
api_key: Option<&str>,
model: &str,
diff: &str,
) -> Result<String, String> {
if base_url.trim().is_empty() {
return Err("Endpoint URL is missing.".to_string());
}
openai_compatible_split_request(
format!("{}/chat/completions", base_url.trim_end_matches('/')),
api_key,
model,
diff,
)
.await
}
#[derive(Serialize)] #[derive(Serialize)]
struct AnthropicMessage { struct AnthropicMessage {
role: &'static str, role: &'static str,
@@ -338,3 +421,47 @@ pub async fn review_anthropic(api_key: &str, model: &str, diff: &str) -> Result<
.filter(|text| !text.is_empty()) .filter(|text| !text.is_empty())
.ok_or_else(|| "The model did not return a review.".to_string()) .ok_or_else(|| "The model did not return a review.".to_string())
} }
pub async fn split_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
if api_key.trim().is_empty() {
return Err("Anthropic API key is missing.".to_string());
}
let system = "You split staged Git changes into small, logical commits. Return only JSON in this exact shape: {\"summary\":\"...\",\"groups\":[{\"message\":\"type(scope): subject\",\"reason\":\"...\",\"files\":[\"path\"]}]}. Every staged file must appear exactly once. Use only paths from the supplied staged file list. Keep messages in English and use Conventional Commits. Do not use markdown.".to_string();
let body = AnthropicRequest {
model: model.to_string(),
max_tokens: 2400,
system,
messages: vec![AnthropicMessage {
role: "user",
content: format!(
"Analyze these staged changes and propose an ordered commit plan:\n\n{diff}"
),
}],
};
let client = http_client()?;
let response = client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|err| format!("Could not read response: {err}"))?;
if !status.is_success() {
return Err(format!("API error ({status}): {text}"));
}
let parsed: AnthropicResponse =
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed
.content
.into_iter()
.find_map(|block| block.text)
.map(|text| sanitize_message(&text))
.filter(|text| !text.is_empty())
.ok_or_else(|| "The model did not return a commit plan.".to_string())
}
+1 -1
View File
@@ -2,7 +2,7 @@ mod cloud;
pub use cloud::{ pub use cloud::{
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom, generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
review_openai, review_openai, split_anthropic, split_custom, split_openai,
}; };
use std::{ use std::{
+132
View File
@@ -1643,6 +1643,127 @@ pub struct AiReviewResult {
pub findings: Vec<AiReviewFinding>, pub findings: Vec<AiReviewFinding>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiCommitGroup {
pub message: String,
pub reason: String,
pub files: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiCommitPlan {
pub summary: String,
pub groups: Vec<AiCommitGroup>,
}
fn parse_ai_commit_plan(raw: &str, staged_files: &[String]) -> Result<AiCommitPlan, String> {
use std::collections::HashSet;
let trimmed = raw.trim().trim_matches('`').trim();
let json = match (trimmed.find('{'), trimmed.rfind('}')) {
(Some(start), Some(end)) if start <= end => &trimmed[start..=end],
_ => return Err("The AI response did not contain a valid commit plan.".to_string()),
};
let mut plan: AiCommitPlan = serde_json::from_str(json)
.map_err(|error| format!("Could not process the commit plan: {error}"))?;
plan.groups
.retain(|group| !group.message.trim().is_empty() && !group.files.is_empty());
if plan.groups.len() < 2 {
return Err("The staged changes do not appear to benefit from splitting.".to_string());
}
if plan.groups.len() > 12 {
return Err("The AI proposed too many commit groups.".to_string());
}
let expected = staged_files.iter().cloned().collect::<HashSet<_>>();
let mut seen = HashSet::new();
for group in &mut plan.groups {
group.message = group.message.trim().to_string();
group.reason = group.reason.trim().to_string();
group
.files
.retain(|file| expected.contains(file) && seen.insert(file.clone()));
if group.files.is_empty() {
return Err("The AI returned an empty or duplicate commit group.".to_string());
}
}
if seen != expected {
return Err("The AI plan did not assign every staged file exactly once.".to_string());
}
plan.summary = plan.summary.trim().to_string();
Ok(plan)
}
#[tauri::command]
pub async fn commit_ai_split(
path: String,
provider: String,
model: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
) -> Result<AiCommitPlan, String> {
let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?;
let staged_files = status
.files
.iter()
.filter(|file| file.staged.is_some())
.map(|file| file.path.clone())
.collect::<Vec<_>>();
if staged_files.len() < 2 {
return Err("Stage at least two files before creating a split plan.".to_string());
}
if status
.files
.iter()
.any(|file| file.staged.is_some() && file.unstaged.is_some())
{
return Err("Files with both staged and unstaged changes cannot be split safely. Stage or discard the remaining changes first.".to_string());
}
let diff = staged_diff(&repo)?;
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());
let raw = match provider.as_str() {
"openai" => {
commit_ai::split_openai(
api_key
.as_deref()
.ok_or_else(|| "OpenAI API key is missing.".to_string())?,
model.as_deref().unwrap_or("gpt-4o-mini"),
&diff,
)
.await?
}
"anthropic" => {
commit_ai::split_anthropic(
api_key
.as_deref()
.ok_or_else(|| "Anthropic API key is missing.".to_string())?,
model.as_deref().unwrap_or("claude-3-5-haiku-latest"),
&diff,
)
.await?
}
"custom" => {
commit_ai::split_custom(
base_url
.as_deref()
.ok_or_else(|| "Endpoint URL is missing.".to_string())?,
api_key.as_deref(),
model
.as_deref()
.ok_or_else(|| "Model name is missing.".to_string())?,
&diff,
)
.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)
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct AiReviewWireFinding { struct AiReviewWireFinding {
severity: String, severity: String,
@@ -7105,4 +7226,15 @@ mod tests {
assert_eq!(review.findings[0].file.as_deref(), Some("src/main.rs")); assert_eq!(review.findings[0].file.as_deref(), Some("src/main.rs"));
assert_eq!(review.findings[0].line, Some(42)); assert_eq!(review.findings[0].line, Some(42));
} }
#[test]
fn parse_ai_commit_plan_requires_each_staged_file_exactly_once() {
let files = vec!["src/app.ts".to_string(), "tests/app.test.ts".to_string()];
let raw = r#"{"summary":"Separate behavior and coverage","groups":[{"message":"feat(app): add behavior","reason":"Production code","files":["src/app.ts"]},{"message":"test(app): cover behavior","reason":"Tests","files":["tests/app.test.ts"]}]}"#;
let plan = parse_ai_commit_plan(raw, &files).expect("complete plan should parse");
assert_eq!(plan.groups.len(), 2);
let duplicate = r#"{"summary":"Bad plan","groups":[{"message":"feat: one","reason":"","files":["src/app.ts"]},{"message":"test: two","reason":"","files":["src/app.ts"]}]}"#;
assert!(parse_ai_commit_plan(duplicate, &files).is_err());
}
} }
+4 -3
View File
@@ -9,9 +9,9 @@ use git::{
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch, SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate, cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits, commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag, cred_delete, cred_load, cred_save, delete_branch, delete_remote_branch, delete_tag,
diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url, diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url,
get_status, init_repository, last_commit_message, list_branches, list_commits, get_status, init_repository, last_commit_message, list_branches, list_commits,
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes, list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
@@ -169,6 +169,7 @@ async fn main() {
commit_ai_local_models, commit_ai_local_models,
commit_ai_generate, commit_ai_generate,
commit_ai_review, commit_ai_review,
commit_ai_split,
pull, pull,
push, push,
fetch, fetch,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty", "productName": "Gitty",
"version": "2026.7.20", "version": "2026.7.22",
"identifier": "com.gitty", "identifier": "com.gitty",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+99
View File
@@ -10,6 +10,7 @@
import RepoTabs from "./lib/RepoTabs.svelte"; import RepoTabs from "./lib/RepoTabs.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte"; import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte"; import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte"; import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte"; import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
@@ -51,6 +52,7 @@
commit, commit,
commitAiGenerate, commitAiGenerate,
commitAiReview, commitAiReview,
commitAiSplit,
commitAiLoad, commitAiLoad,
commitAiLocalModels, commitAiLocalModels,
commitAiStatus, commitAiStatus,
@@ -129,6 +131,7 @@
import type { import type {
AiReviewResult, AiReviewResult,
AiCommitPlan,
AiSettings, AiSettings,
AppLanguage, AppLanguage,
AppTheme, AppTheme,
@@ -285,6 +288,7 @@
let fileHistoryLoading = false; let fileHistoryLoading = false;
let fileHistoryRequestId = 0; let fileHistoryRequestId = 0;
let activeFileHistoryRequestId = ""; let activeFileHistoryRequestId = "";
let repoOpenRequestId = 0;
let lastFileHistoryHeadHash = ""; let lastFileHistoryHeadHash = "";
let commitMessage = ""; let commitMessage = "";
let amendMode = false; let amendMode = false;
@@ -293,6 +297,9 @@
let commitAiPhase: CommitAiPhase = "idle"; let commitAiPhase: CommitAiPhase = "idle";
let commitAiGenerating = false; let commitAiGenerating = false;
let commitAiReviewing = false; let commitAiReviewing = false;
let commitAiSplitting = false;
let aiCommitPlan: AiCommitPlan | null = null;
let aiCommitSplitOpen = false;
let aiReviewResult: AiReviewResult | null = null; let aiReviewResult: AiReviewResult | null = null;
let aiReviewOpen = false; let aiReviewOpen = false;
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined; let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
@@ -1034,6 +1041,75 @@
} }
} }
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 {
if (aiSettings.provider === "openai") {
const cred = await credLoad("ai:openai");
aiCommitPlan = await commitAiSplit(activeRepoPath, { provider: "openai", model: aiSettings.openaiModel, apiKey: cred?.password });
} else if (aiSettings.provider === "anthropic") {
const cred = await credLoad("ai:anthropic");
aiCommitPlan = await commitAiSplit(activeRepoPath, { provider: "anthropic", model: aiSettings.anthropicModel, apiKey: cred?.password });
} else {
const cred = await credLoad("ai:custom");
aiCommitPlan = await commitAiSplit(activeRepoPath, {
provider: "custom", model: aiSettings.customModel, baseUrl: aiSettings.customBaseUrl, apiKey: cred?.password,
});
}
aiCommitSplitOpen = true;
trackEvent("ai_commit_split_planned", { provider: aiSettings.provider, groups: aiCommitPlan.groups.length });
} catch (error) {
errorMessage = errorToMessage(error);
} finally {
commitAiSplitting = false;
}
}
async function applyAiCommitPlan(plan: AiCommitPlan) {
if (!activeRepoPath || isBusy || commitAiSplitting) return;
const allFiles = plan.groups.flatMap((group) => group.files);
if (plan.groups.length < 2 || plan.groups.some((group) => !group.message.trim() || group.files.length === 0)) return;
const currentStaged = changedFiles.filter((file) => file.staged !== null).map((file) => file.path).sort();
if (currentStaged.join("\n") !== [...allFiles].sort().join("\n")) {
errorMessage = "The staged files changed after the plan was created. Generate a new split plan.";
return;
}
if (changedFiles.some((file) => file.staged !== null && file.unstaged !== null)) {
errorMessage = "A file now has both staged and unstaged changes. Stage or discard the remaining changes first.";
return;
}
commitAiSplitting = true;
await runOperation("Creating split commits", async () => {
applyStatus(await unstageFiles(activeRepoPath, currentStaged));
let completed = 0;
try {
for (const group of plan.groups) {
applyStatus(await stageFiles(activeRepoPath, group.files));
applyStatus(await commit(activeRepoPath, group.message.trim()));
completed += 1;
}
} catch (error) {
throw new Error(`${completed} of ${plan.groups.length} commits were created. Remaining changes are preserved and can be staged again. ${errorToMessage(error)}`);
}
aiCommitSplitOpen = false;
aiCommitPlan = null;
commitMessage = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("ai_commit_split_applied", { groups: plan.groups.length, files: allFiles.length });
});
commitAiSplitting = false;
}
// ── Updates ──────────────────────────────────────────────────────────────── // ── Updates ────────────────────────────────────────────────────────────────
async function checkForUpdates() { async function checkForUpdates() {
@@ -1956,6 +2032,7 @@
async function openRepo(pathOverride?: string) { async function openRepo(pathOverride?: string) {
const path = (pathOverride ?? repoPath).trim(); const path = (pathOverride ?? repoPath).trim();
if (!path) { errorMessage = "Enter a repository path."; return; } if (!path) { errorMessage = "Enter a repository path."; return; }
const requestId = ++repoOpenRequestId;
repoPath = path; repoPath = path;
await runOperation("Opening repository", async () => { await runOperation("Opening repository", async () => {
@@ -1967,6 +2044,7 @@
// Single backend round-trip: resolves the repo and reads status, branches, // Single backend round-trip: resolves the repo and reads status, branches,
// commits and files in one pass instead of four sequential git calls. // commits and files in one pass instead of four sequential git calls.
const bundle = await openRepositoryBundle(path, 100); const bundle = await openRepositoryBundle(path, 100);
if (requestId !== repoOpenRequestId) return;
resetRepositoryState(false); resetRepositoryState(false);
applyStatus(bundle.status); applyStatus(bundle.status);
if (globalSearchBusy) void cancelGlobalSearch(); if (globalSearchBusy) void cancelGlobalSearch();
@@ -2160,8 +2238,18 @@
if (!wasActive) return; if (!wasActive) return;
if (next) { if (next) {
// Switch identity immediately. Otherwise a status/fetch request for the
// just-closed repository can still finish while it remains active and
// reinsert its tab through applyStatus().
repoOpenRequestId += 1;
activeRepoPath = next.path;
repoPath = next.path;
status = null;
lastStatusFingerprint = "";
resetRepositoryState(false);
await openRepo(next.path); await openRepo(next.path);
} else { } else {
repoOpenRequestId += 1;
resetRepositoryState(true); resetRepositoryState(true);
activeView = "management"; activeView = "management";
} }
@@ -4440,12 +4528,14 @@
{commitAiPhase} {commitAiPhase}
{commitAiGenerating} {commitAiGenerating}
{commitAiReviewing} {commitAiReviewing}
{commitAiSplitting}
{canAmend} {canAmend}
{amendMode} {amendMode}
onCommit={commitChanges} onCommit={commitChanges}
onCommitMessageChange={updateCommitMessage} onCommitMessageChange={updateCommitMessage}
onGenerateCommitMessage={generateCommitMessageWithAi} onGenerateCommitMessage={generateCommitMessageWithAi}
onReviewStaged={reviewStagedWithAi} onReviewStaged={reviewStagedWithAi}
onSplitStaged={splitStagedWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }} onOpenAiSettings={() => { aiSettingsOpen = true; }}
onToggleAmend={toggleAmendMode} onToggleAmend={toggleAmendMode}
onUndoLastCommit={undoLastCommitChange} onUndoLastCommit={undoLastCommitChange}
@@ -4588,6 +4678,15 @@
/> />
{/if} {/if}
{#if aiCommitSplitOpen && aiCommitPlan}
<AiCommitSplitDialog
plan={aiCommitPlan}
isApplying={commitAiSplitting}
onApply={applyAiCommitPlan}
onClose={() => { if (!commitAiSplitting) aiCommitSplitOpen = false; }}
/>
{/if}
{#if linePatchOpen && linePatchFile} {#if linePatchOpen && linePatchFile}
<LinePatchDialog <LinePatchDialog
file={linePatchFile} file={linePatchFile}
@@ -0,0 +1,103 @@
<script lang="ts">
import { GitCommitHorizontal, LoaderCircle, Sparkles, X } from "@lucide/svelte";
import type { AiCommitPlan } from "../types";
interface Props {
plan: AiCommitPlan;
isApplying: boolean;
onApply: (plan: AiCommitPlan) => void;
onClose: () => void;
}
let { plan, isApplying = false, onApply, onClose }: Props = $props();
let draft = $state<AiCommitPlan>({ summary: "", groups: [] });
$effect.pre(() => {
if (draft.groups.length === 0) draft = structuredClone(plan);
});
let valid = $derived(draft.groups.length > 1 && draft.groups.every((group) => group.message.trim() && group.files.length));
let validationIssue = $derived(
draft.groups.findIndex((group) => group.files.length === 0) >= 0
? "Every commit needs at least one file."
: draft.groups.findIndex((group) => !group.message.trim()) >= 0
? "Every commit needs a message."
: "",
);
function setMessage(index: number, message: string) {
draft.groups[index].message = message;
}
function moveFile(file: string, from: number, to: number) {
if (from === to) return;
draft.groups[from].files = draft.groups[from].files.filter((path) => path !== file);
draft.groups[to].files = [...draft.groups[to].files, file];
}
function applyDraft() {
// `draft` is a deeply reactive Svelte proxy. `structuredClone(draft)`
// throws a DataCloneError before the callback runs, which made the button
// appear to do nothing. A state snapshot is a plain, cloneable object.
onApply($state.snapshot(draft));
}
</script>
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isApplying) onClose(); }} />
<div class="dialog-backdrop" role="presentation">
<div class="dialog split-dialog" role="dialog" aria-modal="true" aria-label="AI commit split">
<header class="dialog-header">
<div><span class="eyebrow">Staged changes</span><h2>Split into logical commits</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isApplying} aria-label="Close"><X size={18} /></button>
</header>
<div class="split-intro"><Sparkles size={18} /><p>{draft.summary}</p></div>
<div class="split-groups">
{#each draft.groups as group, groupIndex}
<article class:empty={group.files.length === 0}>
<header><GitCommitHorizontal size={17} /><strong>Commit {groupIndex + 1}</strong><span>{group.files.length} files</span></header>
<label>
<span>Commit message <em>AI-generated · editable</em></span>
<input value={group.message} oninput={(event) => setMessage(groupIndex, event.currentTarget.value)} disabled={isApplying} />
</label>
{#if group.reason}<p>{group.reason}</p>{/if}
<div class="split-files">
{#each group.files as file}
<div><code>{file}</code>
<select value={groupIndex} onchange={(event) => moveFile(file, groupIndex, Number(event.currentTarget.value))} disabled={isApplying}>
{#each draft.groups as _, target}<option value={target}>Commit {target + 1}</option>{/each}
</select>
</div>
{/each}
</div>
</article>
{/each}
</div>
<footer class="dialog-footer">
<p class:invalid={!!validationIssue}>{validationIssue || "Messages are generated automatically. All commits are created in this order."}</p>
<div><button class="btn-secondary" type="button" onclick={onClose} disabled={isApplying}>Cancel</button>
<button class="btn-primary" type="button" onclick={applyDraft} disabled={!valid || isApplying}>
{#if isApplying}<LoaderCircle class="spin" size={14} />{/if}Commit all ({draft.groups.length})
</button></div>
</footer>
</div>
</div>
<style>
.split-dialog{width:min(820px,calc(100vw - 32px));max-height:min(820px,calc(100vh - 32px));display:flex;flex-direction:column}
.split-intro{display:flex;gap:10px;align-items:flex-start;padding:14px 18px;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
.split-intro p{margin:0;line-height:1.5}
.split-groups{display:grid;gap:10px;padding:14px 18px;overflow:auto}
article{display:grid;gap:10px;padding:13px;border:1px solid var(--color-border-subtle);border-radius:9px;background:var(--color-surface-raised)}
article.empty{border-color:#d88a45}
article>header{display:flex;align-items:center;gap:8px;color:var(--color-ink)}
article>header span{margin-left:auto;color:var(--color-ink-faint);font-size:11px}
label{display:grid;gap:5px;color:var(--color-ink-faint);font-size:10px;font-weight:800;text-transform:uppercase}
label span{display:flex;align-items:center;justify-content:space-between;gap:8px}
label em{color:var(--color-accent);font-size:9px;font-style:normal;font-weight:700;text-transform:none}
input{height:34px;padding:0 10px;border:1px solid var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-ink);font-family:var(--font-mono)}
article>p{margin:0;color:var(--color-ink-muted);font-size:12px;line-height:1.45}
.split-files{display:grid;gap:5px}
.split-files>div{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;background:var(--color-surface)}
code{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;color:var(--color-ink-muted);font-size:11px;white-space:nowrap}
select{height:28px;border:1px solid var(--color-border-subtle);border-radius:5px;background:var(--color-surface-raised);color:var(--color-ink);font-size:11px}
.dialog-footer p.invalid{color:#e0a040}
</style>
+16 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Check, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte"; import { Check, GitCommitHorizontal, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types"; import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props { interface Props {
@@ -14,12 +14,14 @@
commitAiPhase: CommitAiPhase; commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean; commitAiGenerating: boolean;
commitAiReviewing: boolean; commitAiReviewing: boolean;
commitAiSplitting: boolean;
canAmend: boolean; canAmend: boolean;
amendMode: boolean; amendMode: boolean;
onCommit: () => void; onCommit: () => void;
onCommitMessageChange: (msg: string) => void; onCommitMessageChange: (msg: string) => void;
onGenerateCommitMessage: () => void; onGenerateCommitMessage: () => void;
onReviewStaged: () => void; onReviewStaged: () => void;
onSplitStaged: () => void;
onOpenAiSettings: () => void; onOpenAiSettings: () => void;
onToggleAmend: (checked: boolean) => void; onToggleAmend: (checked: boolean) => void;
onUndoLastCommit: () => void; onUndoLastCommit: () => void;
@@ -37,12 +39,14 @@
commitAiPhase = "idle", commitAiPhase = "idle",
commitAiGenerating = false, commitAiGenerating = false,
commitAiReviewing = false, commitAiReviewing = false,
commitAiSplitting = false,
canAmend = false, canAmend = false,
amendMode = false, amendMode = false,
onCommit = () => {}, onCommit = () => {},
onCommitMessageChange = () => {}, onCommitMessageChange = () => {},
onGenerateCommitMessage = () => {}, onGenerateCommitMessage = () => {},
onReviewStaged = () => {}, onReviewStaged = () => {},
onSplitStaged = () => {},
onOpenAiSettings = () => {}, onOpenAiSettings = () => {},
onToggleAmend = () => {}, onToggleAmend = () => {},
onUndoLastCommit = () => {}, onUndoLastCommit = () => {},
@@ -70,6 +74,7 @@
(commitAiProvider !== "local" || commitAiPhase === "ready"), (commitAiProvider !== "local" || commitAiPhase === "ready"),
); );
let canReview = $derived(canGenerate && commitAiProvider !== "local"); let canReview = $derived(canGenerate && commitAiProvider !== "local");
let canSplit = $derived(canReview && stagedCount > 1 && !commitAiSplitting);
</script> </script>
<section class="panel commit-panel" aria-label="Commit"> <section class="panel commit-panel" aria-label="Commit">
@@ -80,6 +85,16 @@
</div> </div>
<div class="commit-head-actions"> <div class="commit-head-actions">
<span class="pill pill-count">{stagedCount} staged</span> <span class="pill pill-count">{stagedCount} staged</span>
<button
class="commit-review-button"
type="button"
onclick={onSplitStaged}
disabled={!canSplit}
title={commitAiProvider === "local" ? "Commit splitting currently requires an API provider" : "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
</button>
<button <button
class="commit-review-button" class="commit-review-button"
type="button" type="button"
+30
View File
@@ -1361,6 +1361,21 @@
label: "Neu in Gitty", label: "Neu in Gitty",
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.", description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
sections: [ sections: [
{
id: "changelog-2026-07-22",
title: "Version 2026.07.22",
summary: "Dieses Release erweitert Gitty um eine AI-gestützte Aufteilung gestagter Änderungen in logisch getrennte Commits.",
steps: [
"AI-Commit-Aufteilung: Der gestagte Diff wird analysiert und als geordneter Plan aus mehreren logisch zusammengehörenden Commits vorgeschlagen.",
"Für jede Gruppe wird automatisch eine editierbare Conventional-Commit-Nachricht erzeugt.",
"Dateien können vor dem Commit zwischen den vorgeschlagenen Gruppen verschoben werden.",
"Mit „Commit all“ werden alle bestätigten Gruppen sicher und der Reihe nach committed.",
"Der Dialog erklärt leere Gruppen oder fehlende Nachrichten und schützt vor einem zwischenzeitlich veränderten Staging-Bereich.",
"„Commit all“ reagiert wieder zuverlässig und bricht nicht mehr beim Kopieren des reaktiven Dialogzustands ab.",
"Ein geschlossenes aktives Repository kann nicht mehr durch einen verspäteten Status- oder Fetch-Request erneut geöffnet werden.",
],
note: "Die AI-Commit-Aufteilung unterstützt OpenAI, Anthropic und eigene OpenAI-kompatible Endpunkte. In Paketdateien erscheint diese Version als 2026.7.22.",
},
{ {
id: "changelog-2026-07-21", id: "changelog-2026-07-21",
title: "Version 2026.07.21", title: "Version 2026.07.21",
@@ -1396,6 +1411,21 @@
label: "What's new", label: "What's new",
description: "Changes since the latest published version and notable additions from earlier releases.", description: "Changes since the latest published version and notable additions from earlier releases.",
sections: [ sections: [
{
id: "changelog-2026-07-22",
title: "Version 2026.07.22",
summary: "This release adds AI-assisted splitting of staged changes into separate logical commits.",
steps: [
"AI commit splitting analyzes the staged diff and proposes an ordered plan of logically related commits.",
"Every group receives an automatically generated, editable Conventional Commit message.",
"Files can be moved between proposed groups before committing.",
"Commit all safely creates every accepted group in sequence.",
"The dialog explains empty groups or missing messages and protects against a staging area that changed after planning.",
"Commit all now responds reliably instead of failing while copying reactive dialog state.",
"Closing the active repository no longer lets a delayed status or fetch request reopen the closed tab.",
],
note: "AI commit splitting supports OpenAI, Anthropic, and custom OpenAI-compatible endpoints. Package metadata represents this version as 2026.7.22.",
},
{ {
id: "changelog-2026-07-21", id: "changelog-2026-07-21",
title: "Version 2026.07.21", title: "Version 2026.07.21",
+11
View File
@@ -2,6 +2,7 @@ import { tracedInvoke as invoke } from "./telemetry";
import type { import type {
AiReviewResult, AiReviewResult,
AiCommitPlan,
CommitAiLocalProfile, CommitAiLocalProfile,
CommitAiProvider, CommitAiProvider,
CommitAiStatus, CommitAiStatus,
@@ -322,6 +323,16 @@ export function commitAiReview(path: string, options: CommitAiGenerateOptions):
}); });
} }
export function commitAiSplit(path: string, options: CommitAiGenerateOptions): Promise<AiCommitPlan> {
return invoke<AiCommitPlan>("commit_ai_split", {
path,
provider: options.provider,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,
});
}
export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise<GitStatus> { 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 }); return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null });
} }
+11
View File
@@ -37,6 +37,17 @@ export interface AiReviewResult {
findings: AiReviewFinding[]; findings: AiReviewFinding[];
} }
export interface AiCommitGroup {
message: string;
reason: string;
files: string[];
}
export interface AiCommitPlan {
summary: string;
groups: AiCommitGroup[];
}
export interface LocalModelOption { export interface LocalModelOption {
id: string; id: string;
label: string; label: string;