Compare commits

..
7 Commits
Author SHA1 Message Date
Christoph 0cd97db523 This diff correctly updates the help documentation in HelpOverlay.svelte. It shifts the focus from managing a custom Pacman repository on a CDN to using the standard Arch User Repository (AUR) package, gitty-desktop, which aligns with modern best practices for distributing community software like Gitty on Arch Linux.
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 22m25s
publish / Build and publish gitty-desktop to AUR (release) Successful in 45m7s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 0s
The changes are consistent across both German and English sections:
1.  **Summary:** Updated to reflect building from source via AUR.
2.  **Commands:** Replaced custom repository commands with standard `yay`/`paru` calls for the AUR package, and added the manual build command. The package name is standardized to `gitty-desktop`.
3.  **Steps:** Changed the workflow description from managing `/etc/pacman.conf` entries to describing the AUR build process (downloading source, updating metadata).
4.  **Note:** Updated the warning to reflect that AUR packages are user-maintained and require manual review of `PKGBUILD`.

No changes are needed; the provided diff is correct.
2026-07-31 14:19:30 +02:00
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
18 changed files with 682 additions and 202 deletions
+62 -44
View File
@@ -87,10 +87,6 @@ jobs:
run: | run: |
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');" node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
- name: Update PKGBUILD Version
run: |
node -e "const fs=require('fs'); const version=require('./package.json').version; const pkgbuild=fs.readFileSync('PKGBUILD','utf8').replace(/^pkgver=.*$/m, 'pkgver='+version).replace(/^pkgrel=.*$/m, 'pkgrel=1'); fs.writeFileSync('PKGBUILD', pkgbuild);"
- name: Commit and Push Changes - name: Commit and Push Changes
if: matrix.platform == 'windows-latest' if: matrix.platform == 'windows-latest'
shell: powershell shell: powershell
@@ -99,7 +95,7 @@ jobs:
git config user.email '${{ vars.EMAIL_GIT }}' git config user.email '${{ vars.EMAIL_GIT }}'
# npm version also bumps the version inside package-lock.json, so stage it # npm version also bumps the version inside package-lock.json, so stage it
# too -- otherwise it stays as an unstaged change and blocks the rebase. # too -- otherwise it stays as an unstaged change and blocks the rebase.
git add package.json package-lock.json src-tauri/tauri.conf.json PKGBUILD git add package.json package-lock.json src-tauri/tauri.conf.json
git commit -m 'Update version to ${{ github.ref_name }}' git commit -m 'Update version to ${{ github.ref_name }}'
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
@@ -157,14 +153,11 @@ jobs:
uv run main.py uv run main.py
publish-arch: publish-arch:
name: Build and publish Arch package name: Build and publish gitty-desktop to AUR
runs-on: archlinux runs-on: archlinux
environment: production environment: production
env: env:
MINIO_ENDPOINT: ${{ vars.MINIO_ENDPOINT }} AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
defaults: defaults:
run: run:
@@ -175,7 +168,10 @@ jobs:
working-directory: / working-directory: /
run: | run: |
pacman -Syu --noconfirm pacman -Syu --noconfirm
pacman -S --needed --noconfirm nodejs npm git pacman -S --needed --noconfirm \
base-devel curl git nodejs npm openssh rust \
webkit2gtk-4.1 gtk3 hicolor-icon-theme \
libappindicator-gtk3 librsvg xdotool
- uses: actions/checkout@v5 - uses: actions/checkout@v5
with: with:
@@ -186,46 +182,68 @@ jobs:
RELEASE_VERSION: ${{ github.ref_name }} RELEASE_VERSION: ${{ github.ref_name }}
run: | run: |
npm version "$RELEASE_VERSION" --no-git-tag-version --allow-same-version npm version "$RELEASE_VERSION" --no-git-tag-version --allow-same-version
node -e "const fs=require('fs'); const version=require('./package.json').version; const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=version; fs.writeFileSync(path,JSON.stringify(config,null,2)+'\n');"
PACKAGE_VERSION="$(node -p "require('./package.json').version")" PACKAGE_VERSION="$(node -p "require('./package.json').version")"
sed -i "s/^pkgver=.*/pkgver=$PACKAGE_VERSION/; s/^pkgrel=.*/pkgrel=1/" PKGBUILD echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
echo "RELEASE_TAG=$RELEASE_VERSION" >> "$GITHUB_ENV"
- name: Install uv - name: Generate and build AUR package
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
- name: Restore pacman repository database
working-directory: GitLite/cicd_tool
run: | run: |
uv sync if ! id -u builder >/dev/null 2>&1; then
uv run arch_repo.py download-db --output ../arch-repo/gitty.db.tar.gz
- name: Build pkg.tar.zst with PKGBUILD
run: |
pacman -S --needed --noconfirm \
base-devel git nodejs npm rust \
webkit2gtk-4.1 gtk3 hicolor-icon-theme \
libappindicator-gtk3 librsvg xdotool
mkdir -p arch-output
BUILD_ROOT="$(mktemp -d)"
cp -a . "$BUILD_ROOT/source"
mkdir -p "$BUILD_ROOT/makepkg"
useradd --create-home builder useradd --create-home builder
chown -R builder:builder "$BUILD_ROOT" fi
AUR_SOURCE_DIR="$(mktemp -d)"
cp PKGBUILD "$AUR_SOURCE_DIR/PKGBUILD"
SOURCE_ARCHIVE="$AUR_SOURCE_DIR/gitty-desktop-$PACKAGE_VERSION.tar.gz"
curl --fail --location --silent --show-error \
--output "$SOURCE_ARCHIVE" \
"https://git.cbsk-tech.de/Christoph/GitLite/archive/$RELEASE_TAG.tar.gz"
CHECKSUM="$(sha256sum "$SOURCE_ARCHIVE" | cut -d ' ' -f 1)"
sed -i \
-e "s/^pkgver=.*/pkgver=$PACKAGE_VERSION/" \
-e "s/^pkgrel=.*/pkgrel=1/" \
-e "s/^_tag=.*/_tag=$RELEASE_TAG/" \
-e "s/^sha256sums=.*/sha256sums=('$CHECKSUM')/" \
"$AUR_SOURCE_DIR/PKGBUILD"
chown -R builder:builder "$AUR_SOURCE_DIR"
runuser -u builder -- \ runuser -u builder -- \
bash -lc "cd '$BUILD_ROOT/source' && BUILDDIR='$BUILD_ROOT/makepkg' makepkg --cleanbuild --noconfirm" bash -lc "cd '$AUR_SOURCE_DIR' && makepkg --cleanbuild --noconfirm && makepkg --printsrcinfo > .SRCINFO"
cp "$BUILD_ROOT"/source/*.pkg.tar.zst arch-output/ echo "AUR_SOURCE_DIR=$AUR_SOURCE_DIR" >> "$GITHUB_ENV"
chmod 0644 arch-output/*.pkg.tar.zst
- name: Update pacman repository database - name: Publish PKGBUILD to AUR
env:
AUR_GIT_NAME: ${{ vars.USERNAME_GIT }}
AUR_GIT_EMAIL: ${{ vars.EMAIL_GIT }}
run: | run: |
mkdir -p arch-repo if [ -z "$AUR_SSH_PRIVATE_KEY" ]; then
repo-add arch-repo/gitty.db.tar.gz arch-output/*.pkg.tar.zst echo "AUR_SSH_PRIVATE_KEY is required" >&2
exit 1
fi
- name: Upload package and repository database to CDN install -d -m 0700 "$HOME/.ssh"
working-directory: GitLite/cicd_tool printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur"
run: uv run arch_repo.py publish --packages-dir ../arch-output --repo-dir ../arch-repo chmod 0600 "$HOME/.ssh/aur"
ssh-keyscan -H aur.archlinux.org >> "$HOME/.ssh/known_hosts"
chmod 0600 "$HOME/.ssh/known_hosts"
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes"
AUR_CHECKOUT="$(mktemp -d)/gitty-desktop"
git -c init.defaultBranch=master clone \
ssh://aur@aur.archlinux.org/gitty-desktop.git "$AUR_CHECKOUT"
cp "$AUR_SOURCE_DIR/PKGBUILD" "$AUR_SOURCE_DIR/.SRCINFO" "$AUR_CHECKOUT/"
cd "$AUR_CHECKOUT"
git config user.name "${AUR_GIT_NAME:-Gitty Release Bot}"
git config user.email "${AUR_GIT_EMAIL:-aur@localhost}"
git add PKGBUILD .SRCINFO
if git diff --cached --quiet; then
echo "AUR metadata already matches release $PACKAGE_VERSION"
exit 0
fi
git commit -m "Update to $PACKAGE_VERSION"
git push origin HEAD:master
+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
+24 -31
View File
@@ -1,11 +1,7 @@
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com> # Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
#
# Local/in-tree PKGBUILD: builds straight from this working directory (no
# source download, no tauri-bundler/AppImage step) and installs the raw
# binary + desktop entry. Run `makepkg -si` from the repo root.
pkgname=gitty pkgname=gitty-desktop
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')
@@ -15,21 +11,23 @@ depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme' 'libappindicator-gtk
makedepends=('rust' 'nodejs' 'npm') makedepends=('rust' 'nodejs' 'npm')
options=('!lto' '!debug') options=('!lto' '!debug')
source=() _tag=2026.7.22
sha256sums=() source=("gitty-desktop-${pkgver}.tar.gz::${url}/archive/${_tag}.tar.gz")
sha256sums=('bd6c7d9da54917cbea98ce31172934b4657cc2fe2020f49d27ea2acb51348e9c')
# Keeps pkgver in sync with package.json (the release CI bumps that file). prepare() {
pkgver() { cd "$srcdir/gitlite"
cd "$startdir"
node -p "require('./package.json').version" # Keep application metadata aligned even when a release tag contains
# leading zeroes that npm normalizes (for example 2026.7.01 -> 2026.7.1).
npm version "$pkgver" --no-git-tag-version --allow-same-version
RELEASE_VERSION="$pkgver" node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path,JSON.stringify(config,null,2)+'\n');"
} }
build() { build() {
cd "$startdir" cd "$srcdir/gitlite"
# mistralrs-core is large enough that the regular release profile (thin LTO, # Keep the large Rust release build within the Arch runner's memory limit.
# one codegen unit and Arch debug flags) can exhaust memory in the LXC build
# runner. Keep the package optimized while bounding peak compiler memory.
export CARGO_BUILD_JOBS=1 export CARGO_BUILD_JOBS=1
export CARGO_PROFILE_RELEASE_OPT_LEVEL=2 export CARGO_PROFILE_RELEASE_OPT_LEVEL=2
export CARGO_PROFILE_RELEASE_LTO=false export CARGO_PROFILE_RELEASE_LTO=false
@@ -37,40 +35,35 @@ build() {
export CARGO_PROFILE_RELEASE_DEBUG=0 export CARGO_PROFILE_RELEASE_DEBUG=0
npm ci npm ci
# Build through the Tauri CLI (not a raw `cargo build --release`): it enables
# the `custom-protocol` cargo feature that makes the binary load the embedded
# `dist/` assets instead of the Vite dev server URL (127.0.0.1:1420), and runs
# `beforeBuildCommand` (`npm run build`) for us. `--no-bundle` skips deb/rpm/
# appimage packaging (and with it linuxdeploy/FUSE) since pacman owns that here.
npm run tauri -- build --no-bundle npm run tauri -- build --no-bundle
} }
package() { package() {
cd "$startdir" cd "$srcdir/gitlite"
install -Dm755 "src-tauri/target/release/gitty" "$pkgdir/usr/bin/gitty" install -Dm755 "src-tauri/target/release/gitty" "$pkgdir/usr/bin/gitty-desktop"
install -Dm644 "src-tauri/icons/32x32.png" \ install -Dm644 "src-tauri/icons/32x32.png" \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/gitty.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/gitty-desktop.png"
install -Dm644 "src-tauri/icons/128x128.png" \ install -Dm644 "src-tauri/icons/128x128.png" \
"$pkgdir/usr/share/icons/hicolor/128x128/apps/gitty.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/gitty-desktop.png"
install -Dm644 "src-tauri/icons/128x128@2x.png" \ install -Dm644 "src-tauri/icons/128x128@2x.png" \
"$pkgdir/usr/share/icons/hicolor/256x256@2/apps/gitty.png" "$pkgdir/usr/share/icons/hicolor/256x256@2/apps/gitty-desktop.png"
install -Dm644 "src-tauri/icons/icon.png" \ install -Dm644 "src-tauri/icons/icon.png" \
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty-desktop.png"
install -d "$pkgdir/usr/share/applications" install -d "$pkgdir/usr/share/applications"
cat > "$pkgdir/usr/share/applications/gitty.desktop" <<-EOF cat > "$pkgdir/usr/share/applications/gitty-desktop.desktop" <<-EOF
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=Gitty Name=Gitty
Comment=$pkgdesc Comment=$pkgdesc
Exec=gitty Exec=gitty-desktop
Icon=gitty Icon=gitty-desktop
Terminal=false Terminal=false
Categories=Development;RevisionControl; Categories=Development;RevisionControl;
StartupWMClass=gitty StartupWMClass=gitty
EOF EOF
install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md" install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
} }
+12 -13
View File
@@ -54,23 +54,22 @@ MIT License
## Arch Linux ## Arch Linux
Release builds are published as an `x86_64` pacman repository. Add this block to Install Gitty from the AUR with an AUR helper:
`/etc/pacman.conf` once:
```ini
[gitty]
SigLevel = Optional TrustAll
Server = https://cdn.cbsk-tech.de/gitty/arch/$arch
```
Then install Gitty and receive future releases through the normal system update:
```bash ```bash
sudo pacman -Syu gitty yay -S gitty-desktop
``` ```
The repository is currently distributed over HTTPS but is not GPG-signed. A Or build the AUR package manually:
repository signing key should be added before recommending it to third parties.
```bash
git clone https://aur.archlinux.org/gitty-desktop.git
cd gitty-desktop
makepkg -si
```
The AUR recipe downloads the public Gitea release archive and builds Gitty from
source. The release pipeline updates its version, checksum, and `.SRCINFO`.
--- ---
-86
View File
@@ -1,86 +0,0 @@
import argparse
import os
from pathlib import Path
from minio import Minio
from minio.error import S3Error
from main import (
ConfigurationError,
_create_minio_client,
_ensure_bucket,
_parse_publish_targets,
)
REPO_PREFIX = "arch/x86_64"
DATABASE_NAME = "gitty.db.tar.gz"
def _targets():
bucket_name = (os.environ.get("S3_BUCKET") or "gitty").lower()
app_dir = (os.environ.get("APP_DIR") or "gitty").strip("/")
return _parse_publish_targets(bucket_name, app_dir)
def download_database(client: Minio, output: Path) -> None:
target = _targets()[0]
object_name = f"{REPO_PREFIX}/{DATABASE_NAME}"
output.parent.mkdir(parents=True, exist_ok=True)
try:
client.fget_object(target.bucket_name, object_name, str(output))
print(f"Downloaded s3://{target.bucket_name}/{object_name} -> {output}")
except S3Error as exc:
if exc.code in {"NoSuchKey", "NoSuchObject"}:
print("No existing Arch repository database found; creating a new one.")
return
raise
def publish_repository(client: Minio, packages_dir: Path, repo_dir: Path) -> None:
packages = sorted(packages_dir.glob("*.pkg.tar.zst"))
if not packages:
raise FileNotFoundError(f"No .pkg.tar.zst package found in {packages_dir}")
repository_files = [
path
for path in repo_dir.iterdir()
if path.is_file() and path.name.startswith("gitty.")
]
if not any(path.name == DATABASE_NAME for path in repository_files):
raise FileNotFoundError(f"Missing repository database: {repo_dir / DATABASE_NAME}")
for target in _targets():
_ensure_bucket(client, target.bucket_name)
for path in [*packages, *repository_files]:
object_name = f"{REPO_PREFIX}/{path.name}"
client.fput_object(target.bucket_name, object_name, str(path))
print(f"Uploaded {path.name} -> s3://{target.bucket_name}/{object_name}")
def main() -> None:
parser = argparse.ArgumentParser(description="Manage the Gitty pacman repository on MinIO.")
subparsers = parser.add_subparsers(dest="command", required=True)
download_parser = subparsers.add_parser("download-db")
download_parser.add_argument("--output", type=Path, required=True)
publish_parser = subparsers.add_parser("publish")
publish_parser.add_argument("--packages-dir", type=Path, required=True)
publish_parser.add_argument("--repo-dir", type=Path, required=True)
args = parser.parse_args()
client = _create_minio_client()
if args.command == "download-db":
download_database(client, args.output)
else:
publish_repository(client, args.packages_dir, args.repo_dir)
if __name__ == "__main__":
try:
main()
except (ConfigurationError, FileNotFoundError, S3Error) as exc:
raise SystemExit(f"Arch repository operation failed: {exc}") from exc
+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"
+50 -20
View File
@@ -647,20 +647,20 @@
{ {
id: "app-package-updates", id: "app-package-updates",
title: "Gitty unter Arch Linux installieren und aktualisieren", title: "Gitty unter Arch Linux installieren und aktualisieren",
summary: "Ein Pacman-Repository auf der CDN-Seite ermöglicht signierte, versionierte Updates. Alternativ lässt sich eine heruntergeladene .pkg.tar.zst-Datei direkt installieren.", summary: "Das AUR-Paket gitty-desktop lädt das öffentliche Gitea-Release und baut Gitty lokal aus dem Quellcode.",
commands: [ commands: [
{ command: "sudo pacman --config /pfad/gitty-pacman.conf -Syu gitty", description: "Repository-Konfiguration für genau diesen Aufruf verwenden und Gitty aktualisieren" }, { command: "yay -S gitty-desktop", description: "Gitty mit yay aus dem AUR bauen, installieren oder aktualisieren" },
{ command: "sudo pacman -U /pfad/gitty-<version>-x86_64.pkg.tar.zst", description: "Ein einzelnes lokales oder heruntergeladenes Paket installieren" }, { command: "paru -S gitty-desktop", description: "Gitty alternativ mit paru bauen, installieren oder aktualisieren" },
{ command: "pacman -Qi gitty", description: "Installierte Version und Paketinformationen anzeigen" }, { command: "git clone https://aur.archlinux.org/gitty-desktop.git && cd gitty-desktop && makepkg -si", description: "AUR-Paket ohne AUR-Helfer prüfen und manuell bauen" },
{ command: "pacman -Si gitty", description: "Im konfigurierten Repository verfügbare Version anzeigen" }, { command: "pacman -Qi gitty-desktop", description: "Installierte Version und Paketinformationen anzeigen" },
], ],
steps: [ steps: [
"Für normale Systemupdates trägst du das Gitty-Repository einmal dauerhaft in /etc/pacman.conf ein.", "Das PKGBUILD lädt das öffentliche Quellarchiv des jeweiligen Gitea-Tags herunter.",
"Wenn du die Systemdatei nicht ändern möchtest, gib --config bei jedem Pacman-Aufruf erneut an oder verwende ein eigenes Wrapper-Skript.", "Die Pipeline aktualisiert Version, Prüfsumme und .SRCINFO im AUR-Paket gitty-desktop.",
"Die Build-Pipeline erzeugt das Paket aus dem PKGBUILD, aktualisiert die Repository-Datenbank und veröffentlicht beides auf dem CDN.", "AUR-Helfer erkennen neue Versionen und erzeugen daraus lokal ein normales Pacman-Paket.",
"Pacman installiert nur dann eine neue Version, wenn Paketversion und Repository-Datenbank entsprechend aktualisiert wurden.", "Ohne AUR-Helfer kannst du das AUR-Git-Repository klonen, die Dateien prüfen und makepkg -si ausführen.",
], ],
note: "--config gilt nur für den aktuellen Pacman-Prozess. Ein späteres sudo pacman -Syu erinnert sich nicht automatisch an den zuvor angegebenen Pfad.", note: "AUR-Pakete werden von Nutzern gepflegt. Prüfe PKGBUILD und .SRCINFO vor der Installation, besonders nach größeren Änderungen.",
}, },
); );
@@ -1090,20 +1090,20 @@
{ {
id: "app-package-updates", id: "app-package-updates",
title: "Install and update Gitty on Arch Linux", title: "Install and update Gitty on Arch Linux",
summary: "A Pacman repository hosted on the CDN provides versioned updates. Alternatively, install a downloaded .pkg.tar.zst package directly.", summary: "The gitty-desktop AUR package downloads the public Gitea release and builds Gitty locally from source.",
commands: [ commands: [
{ command: "sudo pacman --config /path/gitty-pacman.conf -Syu gitty", description: "Use the custom repository configuration for this invocation and update Gitty" }, { command: "yay -S gitty-desktop", description: "Build, install, or update Gitty from the AUR with yay" },
{ command: "sudo pacman -U /path/gitty-<version>-x86_64.pkg.tar.zst", description: "Install one local or downloaded package file" }, { command: "paru -S gitty-desktop", description: "Alternatively build, install, or update Gitty with paru" },
{ command: "pacman -Qi gitty", description: "Show the installed version and package information" }, { command: "git clone https://aur.archlinux.org/gitty-desktop.git && cd gitty-desktop && makepkg -si", description: "Inspect and build the AUR package without an AUR helper" },
{ command: "pacman -Si gitty", description: "Show the version available from configured repositories" }, { command: "pacman -Qi gitty-desktop", description: "Show the installed version and package information" },
], ],
steps: [ steps: [
"For regular system upgrades, add the Gitty repository to /etc/pacman.conf once.", "PKGBUILD downloads the public source archive for the corresponding Gitea tag.",
"If you do not want to change the system file, pass --config on every Pacman invocation or create a dedicated wrapper script.", "The pipeline updates the version, checksum, and .SRCINFO in the gitty-desktop AUR package.",
"The build pipeline creates the package from PKGBUILD, updates the repository database, and publishes both to the CDN.", "AUR helpers detect new versions and turn the recipe into a regular Pacman package locally.",
"Pacman offers an update only after both the package version and repository database have been updated.", "Without an AUR helper, clone the AUR Git repository, inspect its files, and run makepkg -si.",
], ],
note: "--config applies only to the current Pacman process. A later sudo pacman -Syu does not remember a previously supplied configuration path.", note: "AUR packages are user-maintained. Review PKGBUILD and .SRCINFO before installing, especially after major changes.",
}, },
); );
@@ -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;