Compare commits

...
Sign in to create a new pull request.

36 commits

Author SHA1 Message Date
Sampo Kivistö
e67a04473d
Enhance README with additional badges
Added license and version badges to README.

Signed-off-by: Sampo Kivistö <2021355+Havunen@users.noreply.github.com>
2026-03-19 13:53:26 +02:00
Sampo Kivistö
4914b64b00
Added Star History
Signed-off-by: Sampo Kivistö <2021355+Havunen@users.noreply.github.com>
2026-03-19 13:32:09 +02:00
Sampo Kivistö
70c16a66c7
updated tagline 2026-03-19 11:13:30 +02:00
Nacho Rodríguez
e976976dca
removed wrong references (#29) 2026-03-19 00:10:54 +02:00
Topias Mariapori
73783a9fb4
Add macOS troubleshooting for GitComet installation (#27)
Added troubleshooting instructions for macOS users regarding Gatekeeper quarantine.

Signed-off-by: Topias Mariapori <Mariapori@users.noreply.github.com>
2026-03-18 23:14:14 +02:00
Roope Airinen
5cc190a5c0
Merge pull request #22 from Auto-Explore/fix/aur-pkgnaming
remove bin form pkgname
2026-03-18 16:07:29 +02:00
Roope Airinen
c3e0df4d6c remove bin form pkgname 2026-03-18 16:01:59 +02:00
Sampo Kivistö
360924a260
0.1.7 2026-03-18 15:56:46 +02:00
Roope Airinen
5f087bcb44
add aur workflow (#19) 2026-03-18 15:41:54 +02:00
Sampo Kivistö
3a4a985573
lto fat 2026-03-18 14:24:47 +02:00
Sampo Kivistö
35da95670d
Merge branch 'main' of github.com:Auto-Explore/GitComet 2026-03-18 14:12:58 +02:00
Sampo Kivistö
dee3c91df1
cache control to apt files 2026-03-18 14:12:55 +02:00
Sampo Kivistö
aea8bc54c2 updated homebrew installer to include cask and ci tests 2026-03-18 14:10:24 +02:00
Sampo Kivistö
0d9a887e0a
0.1.5 2026-03-18 13:29:48 +02:00
Sampo Kivistö
75eaf46e42
added CI test for deb822 2026-03-18 13:21:45 +02:00
Sampo Kivistö
fb6c5c0686
0.1.4 2026-03-18 13:01:42 +02:00
Sampo Kivistö
772da89940
set apt repo url to correctly create sources file 2026-03-18 13:00:46 +02:00
Sampo Kivistö
8116813635
fix: issue where gitcomet was unable to push upstream remote branch from differently named local branch 2026-03-18 13:00:19 +02:00
Sampo Kivistö
34304584f8
Merge branch 'main' of github.com:Auto-Explore/GitComet into main2 2026-03-18 12:48:25 +02:00
Sampo Kivistö
e1cbb01314
0.1.3 2026-03-18 11:32:09 +02:00
Sampo Kivistö
a00a13e2fb
Merge branch 'main' of github.com:Auto-Explore/GitComet into main2 2026-03-18 11:31:50 +02:00
Roope Airinen
76a42485aa
Merge pull request #18 from Auto-Explore/fix/apt-repo
remove unused variables
2026-03-18 11:29:53 +02:00
Sampo Kivistö
17ceb2b382
use gpui-component syntax highlighter 2026-03-18 11:27:14 +02:00
Roope Airinen
6a281d4c2f remove last step 2026-03-18 10:46:39 +02:00
Roope Airinen
15605f92e7 remove unused variables 2026-03-18 10:41:15 +02:00
Sampo Kivistö
41275c3ba9
markdown preview style fixes 2026-03-18 09:47:14 +02:00
Sampo Kivistö
20bb565e3d Merge branch 'main' of github.com:Auto-Explore/GitComet 2026-03-17 18:39:54 +02:00
Sampo Kivistö
ad4b4b1c1b macOS fixes, trying to fix homebrew icon 2026-03-17 18:39:42 +02:00
Sampo Kivistö
f3afbd238f
0.1.2 2026-03-17 17:03:30 +02:00
Sampo Kivistö
edcfe68bf4
renamed gitcomet-app to gitcomet 2026-03-17 17:01:25 +02:00
Sampo Kivistö
6703f2543d
fix reading gpt passphrase from secrets 2026-03-17 16:38:39 +02:00
Sampo Kivistö
53a6a47c37
apt and homebrew variables and defaults 2026-03-17 16:24:34 +02:00
Sampo Kivistö
27e03b1000
trying to fix homebrew 2026-03-17 16:17:33 +02:00
Sampo Kivistö
d338711642
trying to fix homebrew script 2026-03-17 15:30:11 +02:00
Sampo Kivistö
45f3e7db5e
fix deployment 2026-03-17 14:58:35 +02:00
Sampo Kivistö
68eba72bf1
v0.1.1 2026-03-17 14:56:30 +02:00
70 changed files with 2412 additions and 868 deletions

View file

@ -142,14 +142,14 @@ jobs:
- name: Build release binary
shell: pwsh
run: |
cargo build -p gitcomet-app --release --locked --features ui-gpui,gix --bins
cargo build -p gitcomet --release --locked --features ui-gpui,gix --bins
- name: Package portable ZIP
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Path dist\portable -Force | Out-Null
Copy-Item target\release\gitcomet-app.exe dist\portable\gitcomet-app.exe -Force
Copy-Item target\release\gitcomet.exe dist\portable\gitcomet.exe -Force
Copy-Item README.md dist\portable\README.md -Force
Copy-Item LICENSE-AGPL-3.0 dist\portable\LICENSE-AGPL-3.0 -Force
Copy-Item NOTICE dist\portable\NOTICE -Force
@ -164,11 +164,11 @@ jobs:
$ErrorActionPreference = "Stop"
choco install wixtoolset --version "${env:WIXTOOLSET_VERSION}" --yes --no-progress
cargo install cargo-wix --version "${env:CARGO_WIX_VERSION}" --locked
if (!(Test-Path "crates\gitcomet-app\wix\main.wxs")) {
cargo wix init --package gitcomet-app
if (!(Test-Path "crates\gitcomet\wix\main.wxs")) {
cargo wix init --package gitcomet
}
$msiName = "gitcomet-v${env:VERSION}-windows-x86_64.msi"
cargo wix --package gitcomet-app --profile release --nocapture --no-build --output "dist\$msiName"
cargo wix --package gitcomet --profile release --nocapture --no-build --output "dist\$msiName"
- name: Upload Windows artifacts
uses: actions/upload-artifact@v7
@ -212,7 +212,7 @@ jobs:
uses: Swatinem/rust-cache@v2
- name: Build release binary
run: cargo build -p gitcomet-app --release --locked --features ui-gpui,gix
run: cargo build -p gitcomet --release --locked --features ui-gpui,gix
- name: Normalize desktop entry metadata
run: |
@ -227,7 +227,7 @@ jobs:
set -euo pipefail
root="gitcomet-v${VERSION}-linux-x86_64"
mkdir -p "dist/stage/${root}"
install -m755 target/release/gitcomet-app "dist/stage/${root}/gitcomet-app"
install -m755 target/release/gitcomet "dist/stage/${root}/gitcomet"
install -m644 README.md "dist/stage/${root}/README.md"
install -m644 LICENSE-AGPL-3.0 "dist/stage/${root}/LICENSE-AGPL-3.0"
install -m644 NOTICE "dist/stage/${root}/NOTICE"
@ -245,7 +245,7 @@ jobs:
mkdir -p "${pkg_root}/usr/share/applications"
mkdir -p "${pkg_root}/usr/share/icons/hicolor/512x512/apps"
install -m755 target/release/gitcomet-app "${pkg_root}/usr/bin/gitcomet-app"
install -m755 target/release/gitcomet "${pkg_root}/usr/bin/gitcomet"
install -m644 dist/gitcomet.desktop "${pkg_root}/usr/share/applications/gitcomet.desktop"
install -m644 assets/gitcomet-512.png "${pkg_root}/usr/share/icons/hicolor/512x512/apps/gitcomet.png"
@ -267,7 +267,7 @@ jobs:
set -euo pipefail
appdir="dist/AppDir"
mkdir -p "${appdir}/usr/bin"
install -m755 target/release/gitcomet-app "${appdir}/usr/bin/gitcomet-app"
install -m755 target/release/gitcomet "${appdir}/usr/bin/gitcomet"
install -m644 dist/gitcomet.desktop "${appdir}/gitcomet.desktop"
install -m644 assets/gitcomet-512.png "${appdir}/gitcomet.png"
@ -275,7 +275,7 @@ jobs:
echo '#!/usr/bin/env bash'
echo 'set -euo pipefail'
echo 'HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"'
echo 'exec "${HERE}/usr/bin/gitcomet-app" "$@"'
echo 'exec "${HERE}/usr/bin/gitcomet" "$@"'
} > "${appdir}/AppRun"
chmod +x "${appdir}/AppRun"
@ -357,6 +357,11 @@ jobs:
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
ENABLE_KEYLESS_SIGNING: ${{ vars.ENABLE_KEYLESS_SIGNING }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.tag }}
fetch-depth: 0
- uses: actions/download-artifact@v8
with:
name: windows-release-artifacts
@ -387,18 +392,22 @@ jobs:
fi
ls -lah dist/release
- name: Generate Homebrew formula
- name: Generate Homebrew cask and formula
run: |
set -euo pipefail
arm_tar="gitcomet-v${VERSION}-macos-arm64.tar.gz"
intel_tar="gitcomet-v${VERSION}-macos-x86_64.tar.gz"
linux_tar="gitcomet-v${VERSION}-linux-x86_64.tar.gz"
arm_dmg="gitcomet-v${VERSION}-macos-arm64.dmg"
intel_dmg="gitcomet-v${VERSION}-macos-x86_64.dmg"
arm_path="dist/release/${arm_tar}"
intel_path="dist/release/${intel_tar}"
linux_path="dist/release/${linux_tar}"
arm_dmg_path="dist/release/${arm_dmg}"
intel_dmg_path="dist/release/${intel_dmg}"
if [ ! -f "${arm_path}" ] || [ ! -f "${intel_path}" ] || [ ! -f "${linux_path}" ]; then
echo "::error title=Missing release tarballs::Expected ${arm_tar}, ${intel_tar}, and ${linux_tar} in dist/release."
if [ ! -f "${arm_path}" ] || [ ! -f "${intel_path}" ] || [ ! -f "${linux_path}" ] || [ ! -f "${arm_dmg_path}" ] || [ ! -f "${intel_dmg_path}" ]; then
echo "::error title=Missing release assets::Expected ${arm_tar}, ${intel_tar}, ${linux_tar}, ${arm_dmg}, and ${intel_dmg} in dist/release."
exit 1
fi
@ -408,6 +417,13 @@ jobs:
--arm-tar "${arm_path}" \
--intel-tar "${intel_path}" \
--linux-tar "${linux_path}" \
--output "dist/release/gitcomet-cli.rb"
scripts/generate-homebrew-cask.sh \
--version "${VERSION}" \
--github-repo "${GITHUB_REPOSITORY}" \
--arm-dmg "${arm_dmg_path}" \
--intel-dmg "${intel_dmg_path}" \
--output "dist/release/gitcomet.rb"
- name: Generate checksums

View file

@ -163,8 +163,8 @@ jobs:
echo "XDG_CURRENT_DESKTOP=${XDG_CURRENT_DESKTOP:-<unset>}"
- name: Run display-selection integration tests
run: |
cargo test -p gitcomet-app $APP_FEATURES --test mergetool_git_integration gui_default --verbose
cargo test -p gitcomet-app $APP_FEATURES --test difftool_git_integration gui_default --verbose
cargo test -p gitcomet $APP_FEATURES --test mergetool_git_integration gui_default --verbose
cargo test -p gitcomet $APP_FEATURES --test difftool_git_integration gui_default --verbose
- name: Run UI smoke test under selected profile
run: cargo test -p gitcomet-ui-gpui smoke_tests::smoke_view_renders_without_panicking -- --exact
@ -200,9 +200,9 @@ jobs:
--verbose
- name: Gatekeeper and code-signing check (informational)
run: |
cargo build -p gitcomet-app $APP_FEATURES --release --locked
codesign --verify --verbose target/release/gitcomet-app || true
spctl --assess --type execute --verbose target/release/gitcomet-app || true
cargo build -p gitcomet $APP_FEATURES --release --locked
codesign --verify --verbose target/release/gitcomet || true
spctl --assess --type execute --verbose target/release/gitcomet || true
windows-tests:
name: Windows Tests (PowerShell + CMD)
@ -235,7 +235,7 @@ jobs:
--verbose
- name: Run standalone smoke test in CMD
shell: cmd
run: cargo test -p gitcomet-app --no-default-features --features gix --test standalone_tool_mode_integration help_flag_exits_zero -- --exact
run: cargo test -p gitcomet --no-default-features --features gix --test standalone_tool_mode_integration help_flag_exits_zero -- --exact
- name: Show path behavior (CMD + Git Bash)
shell: cmd
run: |

View file

@ -21,10 +21,6 @@ on:
required: false
type: string
default: ""
repo_base_url:
required: false
type: string
default: ""
distribution:
required: false
type: string
@ -48,7 +44,7 @@ on:
repo_description:
required: false
type: string
default: "GitComet APT repository"
default: "GitComet APT repository."
container_public_access:
required: false
type: string
@ -75,32 +71,27 @@ on:
required: false
type: string
storage_account:
description: "Azure Storage account name"
description: "Azure Storage account name. Defaults to repository variable APT_STORAGE_ACCOUNT when omitted."
required: false
default: ""
type: string
storage_container:
description: "Azure Blob container that holds the APT repository"
description: "Azure Blob container that holds the APT repository. Defaults to APT_STORAGE_CONTAINER when omitted."
required: false
default: "apt"
type: string
storage_prefix:
description: "Optional blob prefix within the container"
required: false
default: ""
type: string
repo_base_url:
description: "Public HTTPS base URL for the APT repo root"
description: "Optional blob prefix within the container. Defaults to APT_STORAGE_PREFIX when omitted."
required: false
default: ""
type: string
distribution:
description: "APT suite/codename"
description: "APT suite/codename. Defaults to APT_REPO_DISTRIBUTION when omitted."
required: false
default: "stable"
type: string
component:
description: "APT component"
description: "APT component. Defaults to APT_REPO_COMPONENT when omitted."
required: false
default: "main"
type: string
@ -110,22 +101,22 @@ on:
default: "amd64"
type: string
repo_origin:
description: "Origin metadata in Release file"
description: "Origin metadata in Release file. Defaults to APT_REPO_ORIGIN when omitted."
required: false
default: "GitComet"
type: string
repo_label:
description: "Label metadata in Release file"
description: "Label metadata in Release file. Defaults to APT_REPO_LABEL when omitted."
required: false
default: "GitComet"
type: string
repo_description:
description: "Description metadata in Release file"
description: "Description metadata in Release file. Defaults to APT_REPO_DESCRIPTION when omitted."
required: false
default: "GitComet APT repository"
default: "GitComet APT repository."
type: string
container_public_access:
description: "Public access used only when the blob container must be created"
description: "Public access used only when the blob container must be created. Defaults to APT_STORAGE_PUBLIC_ACCESS when omitted."
required: false
default: "blob"
type: choice
@ -165,26 +156,33 @@ jobs:
DISPATCH_VERSION: ${{ github.event.inputs.version }}
INPUT_STORAGE_ACCOUNT: ${{ inputs.storage_account }}
DISPATCH_STORAGE_ACCOUNT: ${{ github.event.inputs.storage_account }}
VAR_STORAGE_ACCOUNT: ${{ vars.APT_STORAGE_ACCOUNT }}
INPUT_STORAGE_CONTAINER: ${{ inputs.storage_container }}
DISPATCH_STORAGE_CONTAINER: ${{ github.event.inputs.storage_container }}
VAR_STORAGE_CONTAINER: ${{ vars.APT_STORAGE_CONTAINER }}
INPUT_STORAGE_PREFIX: ${{ inputs.storage_prefix }}
DISPATCH_STORAGE_PREFIX: ${{ github.event.inputs.storage_prefix }}
INPUT_REPO_BASE_URL: ${{ inputs.repo_base_url }}
DISPATCH_REPO_BASE_URL: ${{ github.event.inputs.repo_base_url }}
VAR_STORAGE_PREFIX: ${{ vars.APT_STORAGE_PREFIX }}
INPUT_DISTRIBUTION: ${{ inputs.distribution }}
DISPATCH_DISTRIBUTION: ${{ github.event.inputs.distribution }}
VAR_DISTRIBUTION: ${{ vars.APT_REPO_DISTRIBUTION }}
INPUT_COMPONENT: ${{ inputs.component }}
DISPATCH_COMPONENT: ${{ github.event.inputs.component }}
VAR_COMPONENT: ${{ vars.APT_REPO_COMPONENT }}
INPUT_ARCHITECTURE: ${{ inputs.architecture }}
DISPATCH_ARCHITECTURE: ${{ github.event.inputs.architecture }}
INPUT_REPO_ORIGIN: ${{ inputs.repo_origin }}
DISPATCH_REPO_ORIGIN: ${{ github.event.inputs.repo_origin }}
VAR_REPO_ORIGIN: ${{ vars.APT_REPO_ORIGIN }}
INPUT_REPO_LABEL: ${{ inputs.repo_label }}
DISPATCH_REPO_LABEL: ${{ github.event.inputs.repo_label }}
VAR_REPO_LABEL: ${{ vars.APT_REPO_LABEL }}
INPUT_REPO_DESCRIPTION: ${{ inputs.repo_description }}
DISPATCH_REPO_DESCRIPTION: ${{ github.event.inputs.repo_description }}
VAR_REPO_DESCRIPTION: ${{ vars.APT_REPO_DESCRIPTION }}
INPUT_CONTAINER_PUBLIC_ACCESS: ${{ inputs.container_public_access }}
DISPATCH_CONTAINER_PUBLIC_ACCESS: ${{ github.event.inputs.container_public_access }}
VAR_CONTAINER_PUBLIC_ACCESS: ${{ vars.APT_STORAGE_PUBLIC_ACCESS }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
DISPATCH_DRY_RUN: ${{ github.event.inputs.dry_run }}
run: |
@ -192,17 +190,16 @@ jobs:
tag="${INPUT_TAG:-${DISPATCH_TAG:-}}"
version="${INPUT_VERSION:-${DISPATCH_VERSION:-}}"
storage_account="${INPUT_STORAGE_ACCOUNT:-${DISPATCH_STORAGE_ACCOUNT:-}}"
storage_container="${INPUT_STORAGE_CONTAINER:-${DISPATCH_STORAGE_CONTAINER:-apt}}"
storage_prefix="${INPUT_STORAGE_PREFIX:-${DISPATCH_STORAGE_PREFIX:-}}"
repo_base_url="${INPUT_REPO_BASE_URL:-${DISPATCH_REPO_BASE_URL:-}}"
distribution="${INPUT_DISTRIBUTION:-${DISPATCH_DISTRIBUTION:-stable}}"
component="${INPUT_COMPONENT:-${DISPATCH_COMPONENT:-main}}"
storage_account="${INPUT_STORAGE_ACCOUNT:-${DISPATCH_STORAGE_ACCOUNT:-${VAR_STORAGE_ACCOUNT:-}}}"
storage_container="${INPUT_STORAGE_CONTAINER:-${DISPATCH_STORAGE_CONTAINER:-${VAR_STORAGE_CONTAINER:-apt}}}"
storage_prefix="${INPUT_STORAGE_PREFIX:-${DISPATCH_STORAGE_PREFIX:-${VAR_STORAGE_PREFIX:-}}}"
distribution="${INPUT_DISTRIBUTION:-${DISPATCH_DISTRIBUTION:-${VAR_DISTRIBUTION:-stable}}}"
component="${INPUT_COMPONENT:-${DISPATCH_COMPONENT:-${VAR_COMPONENT:-main}}}"
architecture="${INPUT_ARCHITECTURE:-${DISPATCH_ARCHITECTURE:-amd64}}"
repo_origin="${INPUT_REPO_ORIGIN:-${DISPATCH_REPO_ORIGIN:-GitComet}}"
repo_label="${INPUT_REPO_LABEL:-${DISPATCH_REPO_LABEL:-GitComet}}"
repo_description="${INPUT_REPO_DESCRIPTION:-${DISPATCH_REPO_DESCRIPTION:-GitComet APT repository}}"
container_public_access="${INPUT_CONTAINER_PUBLIC_ACCESS:-${DISPATCH_CONTAINER_PUBLIC_ACCESS:-blob}}"
repo_origin="${INPUT_REPO_ORIGIN:-${DISPATCH_REPO_ORIGIN:-${VAR_REPO_ORIGIN:-GitComet}}}"
repo_label="${INPUT_REPO_LABEL:-${DISPATCH_REPO_LABEL:-${VAR_REPO_LABEL:-GitComet}}}"
repo_description="${INPUT_REPO_DESCRIPTION:-${DISPATCH_REPO_DESCRIPTION:-${VAR_REPO_DESCRIPTION:-GitComet APT repository.}}}"
container_public_access="${INPUT_CONTAINER_PUBLIC_ACCESS:-${DISPATCH_CONTAINER_PUBLIC_ACCESS:-${VAR_CONTAINER_PUBLIC_ACCESS:-blob}}}"
dry_run="${INPUT_DRY_RUN:-${DISPATCH_DRY_RUN:-false}}"
tag="$(echo "$tag" | tr -d '[:space:]')"
@ -210,7 +207,6 @@ jobs:
storage_account="$(echo "$storage_account" | tr -d '[:space:]')"
storage_container="$(echo "$storage_container" | tr -d '[:space:]')"
storage_prefix="$(echo "$storage_prefix" | sed 's#^/*##;s#/*$##')"
repo_base_url="$(echo "$repo_base_url" | sed 's#[[:space:]]##g;s#/*$##')"
distribution="$(echo "$distribution" | tr -d '[:space:]')"
component="$(echo "$component" | tr -d '[:space:]')"
architecture="$(echo "$architecture" | tr -d '[:space:]')"
@ -274,7 +270,8 @@ jobs:
exit 1
fi
if [ -z "$repo_base_url" ] && [ -n "$storage_account" ] && [ "$storage_container" != '$web' ]; then
repo_base_url=""
if [ -n "$storage_account" ] && [ "$storage_container" != '$web' ]; then
repo_base_url="https://${storage_account}.blob.core.windows.net/${storage_container}"
if [ -n "$storage_prefix" ]; then
repo_base_url="${repo_base_url}/${storage_prefix}"
@ -365,7 +362,7 @@ jobs:
echo "Key-Type: RSA"
echo "Key-Length: 3072"
echo "Name-Real: GitComet APT Dry Run"
echo "Name-Email: ci@example.invalid"
echo "Name-Email: ci@autoexplore.ai"
echo "Expire-Date: 0"
echo "%no-protection"
echo "%commit"
@ -419,16 +416,16 @@ jobs:
blob_prefix="${blob_prefix}/"
fi
mapfile -t existing_blobs < <(
az storage blob list \
--account-name "$STORAGE_ACCOUNT" \
--container-name "$STORAGE_CONTAINER" \
--account-key "$AZURE_STORAGE_ACCOUNT_KEY" \
--prefix "$blob_prefix" \
--num-results "*" \
--query "[].name" \
-o tsv
)
existing_blobs_file="${RUNNER_TEMP}/apt-existing-blobs.txt"
az storage blob list \
--account-name "$STORAGE_ACCOUNT" \
--container-name "$STORAGE_CONTAINER" \
--account-key "$AZURE_STORAGE_ACCOUNT_KEY" \
--prefix "$blob_prefix" \
--num-results "*" \
--query "[].name" \
-o tsv > "$existing_blobs_file"
mapfile -t existing_blobs < "$existing_blobs_file"
if [ "${#existing_blobs[@]}" -eq 0 ]; then
exit 0
@ -472,6 +469,7 @@ jobs:
--label "${{ steps.norm.outputs.repo_label }}"
--description "${{ steps.norm.outputs.repo_description }}"
--signing-key "${{ steps.gpg.outputs.signing_key }}"
--repo-url "https://apt.gitcomet.dev"
)
if [ -n "${{ steps.norm.outputs.repo_base_url }}" ]; then
@ -495,6 +493,9 @@ jobs:
run: |
set -euo pipefail
metadata_cache_control='no-cache, no-store, max-age=0, must-revalidate'
package_cache_control='public, max-age=31536000, immutable'
mkdir -p "$AZURE_CONFIG_DIR"
: > dist/apt-uploaded-blobs.txt
@ -505,15 +506,25 @@ jobs:
blob_name="${STORAGE_PREFIX}/${rel_path}"
fi
az storage blob upload \
--account-name "$STORAGE_ACCOUNT" \
--container-name "$STORAGE_CONTAINER" \
--account-key "$AZURE_STORAGE_ACCOUNT_KEY" \
--name "$blob_name" \
--file "$file_path" \
--overwrite true \
--no-progress \
upload_args=(
az storage blob upload
--account-name "$STORAGE_ACCOUNT"
--container-name "$STORAGE_CONTAINER"
--account-key "$AZURE_STORAGE_ACCOUNT_KEY"
--name "$blob_name"
--file "$file_path"
--overwrite true
--no-progress
--output none
)
if [[ "$rel_path" == dists/* || "$rel_path" == "gitcomet.sources" || "$rel_path" == "gitcomet.list" || "$rel_path" == "gitcomet-archive-keyring.gpg" || "$rel_path" == "gitcomet-archive-keyring.asc" || "$rel_path" == "README.txt" ]]; then
upload_args+=(--content-cache-control "$metadata_cache_control")
elif [[ "$rel_path" == pool/* ]]; then
upload_args+=(--content-cache-control "$package_cache_control")
fi
"${upload_args[@]}"
printf '%s\n' "$blob_name" >> dist/apt-uploaded-blobs.txt
done < <(find dist/apt-repo -type f -print0 | sort -z)
@ -549,28 +560,3 @@ jobs:
--delete-snapshots include \
--output none
done < "${RUNNER_TEMP}/apt-stale.txt"
- name: Emit deployment summary
run: |
set -euo pipefail
uploaded="yes"
if [ "${{ steps.norm.outputs.dry_run }}" = "true" ]; then
uploaded="no (dry run)"
fi
{
echo "### APT repository deployment"
echo ""
echo "- Release: \`${{ steps.norm.outputs.tag }}\`"
echo "- Debian package: \`${{ steps.deb.outputs.name }}\`"
echo "- Distribution: \`${{ steps.norm.outputs.distribution }}\`"
echo "- Component: \`${{ steps.norm.outputs.component }}\`"
echo "- Architecture: \`${{ steps.norm.outputs.architecture }}\`"
echo "- Azure target: \`${{ steps.norm.outputs.storage_account }}/${{ steps.norm.outputs.storage_container }}\`"
echo "- Prefix: \`${{ steps.norm.outputs.storage_prefix }}\`"
echo "- Public repo URL: \`${{ steps.norm.outputs.repo_base_url }}\`"
echo "- Uploaded: \`${uploaded}\`"
echo ""
echo "Generated files:"
find dist/apt-repo -type f | sed 's#^dist/apt-repo/#- `#;s#$#`#'
} >> "$GITHUB_STEP_SUMMARY"

268
.github/workflows/deploy-aur.yml vendored Normal file
View file

@ -0,0 +1,268 @@
name: Deploy AUR Mirror
on:
workflow_call:
inputs:
tag:
required: true
type: string
version:
required: true
type: string
aur_repo:
required: false
type: string
default: ""
aur_branch:
required: false
type: string
default: "main"
dry_run:
required: false
type: boolean
default: false
secrets:
AUR_REPO_TOKEN:
required: false
workflow_dispatch:
inputs:
version:
description: "Release version (e.g. 0.2.0 or v0.2.0)"
required: true
type: string
tag:
description: "Optional tag override (e.g. v0.2.0). Defaults to v<version>."
required: false
type: string
aur_repo:
description: "Target GitHub repo in OWNER/REPO form (e.g. Auto-Explore/aur-gitcomet). Defaults to AUR_GITHUB_REPO when omitted."
required: false
default: ""
type: string
aur_branch:
description: "Target branch in aur repo. Defaults to AUR_GITHUB_BRANCH when omitted."
required: false
default: "main"
type: string
dry_run:
description: "Validate and print PKGBUILD/.SRCINFO without pushing"
required: true
default: false
type: boolean
permissions:
contents: read
concurrency:
group: deploy-aur-${{ inputs.tag || github.event.inputs.tag || inputs.version || github.event.inputs.version || github.run_id }}
cancel-in-progress: false
jobs:
deploy:
name: Publish PKGBUILD and .SRCINFO to AUR mirror repo
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: archlinux:base-devel
steps:
- name: Install Arch packaging tooling
run: |
set -euo pipefail
pacman -Sy --noconfirm --needed ca-certificates ca-certificates-utils curl git perl shadow
- uses: actions/checkout@v6
- name: Normalize inputs
id: norm
env:
INPUT_TAG: ${{ inputs.tag }}
DISPATCH_TAG: ${{ github.event.inputs.tag }}
INPUT_VERSION: ${{ inputs.version }}
DISPATCH_VERSION: ${{ github.event.inputs.version }}
INPUT_AUR_REPO: ${{ inputs.aur_repo }}
DISPATCH_AUR_REPO: ${{ github.event.inputs.aur_repo }}
VAR_AUR_REPO: ${{ vars.AUR_GITHUB_REPO }}
INPUT_AUR_BRANCH: ${{ inputs.aur_branch }}
DISPATCH_AUR_BRANCH: ${{ github.event.inputs.aur_branch }}
VAR_AUR_BRANCH: ${{ vars.AUR_GITHUB_BRANCH }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
DISPATCH_DRY_RUN: ${{ github.event.inputs.dry_run }}
REPO_OWNER: ${{ github.repository_owner }}
run: |
set -euo pipefail
tag="${INPUT_TAG:-${DISPATCH_TAG:-}}"
version="${INPUT_VERSION:-${DISPATCH_VERSION:-}}"
aur_repo="${INPUT_AUR_REPO:-${DISPATCH_AUR_REPO:-${VAR_AUR_REPO:-}}}"
aur_branch="${INPUT_AUR_BRANCH:-${DISPATCH_AUR_BRANCH:-${VAR_AUR_BRANCH:-main}}}"
dry_run="${INPUT_DRY_RUN:-${DISPATCH_DRY_RUN:-false}}"
tag="$(echo "$tag" | tr -d '[:space:]')"
version="$(echo "$version" | tr -d '[:space:]')"
aur_repo="$(echo "$aur_repo" | tr -d '[:space:]')"
aur_branch="$(echo "$aur_branch" | tr -d '[:space:]')"
dry_run="$(echo "$dry_run" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')"
if [ -z "$version" ]; then
echo "::error title=Missing version::Version is required."
exit 1
fi
version="${version#v}"
if [ -z "$tag" ]; then
tag="v${version}"
fi
if [[ "$tag" != v* ]]; then
tag="v${tag}"
fi
if [ "$tag" != "v${version}" ]; then
echo "::error title=Tag/version mismatch::Tag '$tag' does not match version '$version'."
exit 1
fi
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
echo "::error title=Invalid version::Expected semver like 1.2.3 or 1.2.3-rc.1."
exit 1
fi
if [ -z "$aur_repo" ]; then
aur_repo="${REPO_OWNER}/aur-gitcomet"
fi
if ! [[ "$aur_repo" =~ ^[^/]+/[^/]+$ ]]; then
echo "::error title=Invalid AUR repo::aur_repo must be OWNER/REPO."
exit 1
fi
if [ -z "$aur_branch" ]; then
echo "::error title=Missing AUR branch::aur_branch must not be empty."
exit 1
fi
if [[ "$dry_run" != "true" && "$dry_run" != "false" ]]; then
echo "::error title=Invalid dry_run::dry_run must be true or false."
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "aur_repo=$aur_repo" >> "$GITHUB_OUTPUT"
echo "aur_branch=$aur_branch" >> "$GITHUB_OUTPUT"
echo "dry_run=$dry_run" >> "$GITHUB_OUTPUT"
- name: Create non-root packaging user
run: |
set -euo pipefail
id -u builder >/dev/null 2>&1 || useradd -m builder
chown -R builder:builder "$GITHUB_WORKSPACE"
- name: Download release archives referenced by PKGBUILD
env:
TAG: ${{ steps.norm.outputs.tag }}
VERSION: ${{ steps.norm.outputs.version }}
run: |
set -euo pipefail
mkdir -p dist/aur
binary_name="gitcomet-v${VERSION}-linux-x86_64.tar.gz"
source_name="gitcomet-source-v${VERSION}.tar.gz"
curl -fL --retry 3 --retry-all-errors \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${binary_name}" \
-o "dist/aur/${binary_name}"
curl -fL --retry 3 --retry-all-errors \
"https://github.com/${GITHUB_REPOSITORY}/archive/refs/tags/${TAG}.tar.gz" \
-o "dist/aur/${source_name}"
- name: Clone AUR mirror repository
env:
AUR_REPO: ${{ steps.norm.outputs.aur_repo }}
AUR_BRANCH: ${{ steps.norm.outputs.aur_branch }}
DRY_RUN: ${{ steps.norm.outputs.dry_run }}
AUR_TOKEN: ${{ secrets.AUR_REPO_TOKEN }}
run: |
set -euo pipefail
clone_url="https://github.com/${AUR_REPO}.git"
if [ "$DRY_RUN" != "true" ]; then
if [ -z "${AUR_TOKEN:-}" ]; then
echo "::error title=Missing secret::Set AUR_REPO_TOKEN to push to ${AUR_REPO}."
exit 1
fi
clone_url="https://x-access-token:${AUR_TOKEN}@github.com/${AUR_REPO}.git"
fi
rm -rf aur-repo
git clone --depth 1 --branch "$AUR_BRANCH" --single-branch "$clone_url" aur-repo
chown -R builder:builder aur-repo dist
- name: Update PKGBUILD and regenerate .SRCINFO
env:
VERSION: ${{ steps.norm.outputs.version }}
run: |
set -euo pipefail
su builder -c "cd '$GITHUB_WORKSPACE' && scripts/update-aur.sh \
--aur-dir '$GITHUB_WORKSPACE/aur-repo' \
--version '$VERSION' \
--binary-tar '$GITHUB_WORKSPACE/dist/aur/gitcomet-v${VERSION}-linux-x86_64.tar.gz' \
--source-tar '$GITHUB_WORKSPACE/dist/aur/gitcomet-source-v${VERSION}.tar.gz' \
--verify-source"
- name: Emit dry-run summary
if: ${{ steps.norm.outputs.dry_run == 'true' }}
run: |
set -euo pipefail
{
echo "### AUR deployment dry run"
echo ""
echo "- Source release: \`${{ steps.norm.outputs.tag }}\`"
echo "- Target repo: \`${{ steps.norm.outputs.aur_repo }}\`"
echo "- Target branch: \`${{ steps.norm.outputs.aur_branch }}\`"
echo ""
echo "PKGBUILD preview:"
echo '```bash'
cat aur-repo/PKGBUILD
echo '```'
echo ""
echo ".SRCINFO preview:"
echo '```ini'
cat aur-repo/.SRCINFO
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Publish metadata to AUR mirror repo
if: ${{ steps.norm.outputs.dry_run != 'true' }}
env:
AUR_BRANCH: ${{ steps.norm.outputs.aur_branch }}
TAG: ${{ steps.norm.outputs.tag }}
run: |
set -euo pipefail
git config --global --add safe.directory "$GITHUB_WORKSPACE/aur-repo"
pushd aur-repo >/dev/null
git add PKGBUILD .SRCINFO
if git diff --cached --quiet -- PKGBUILD .SRCINFO; then
echo "No AUR metadata changes detected; mirror repo is already up to date."
popd >/dev/null
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "gitcomet ${TAG}"
git push origin "HEAD:${AUR_BRANCH}"
popd >/dev/null
- name: Emit deployment summary
run: |
set -euo pipefail
{
echo "### AUR mirror deployment"
echo ""
echo "- Release: \`${{ steps.norm.outputs.tag }}\`"
echo "- Target repo: \`${{ steps.norm.outputs.aur_repo }}\`"
echo "- Target branch: \`${{ steps.norm.outputs.aur_branch }}\`"
echo "- Dry run: \`${{ steps.norm.outputs.dry_run }}\`"
} >> "$GITHUB_STEP_SUMMARY"

View file

@ -35,17 +35,17 @@ on:
required: false
type: string
tap_repo:
description: "Target tap repository in OWNER/REPO form (e.g. Auto-Explore/homebrew-gitcomet)"
description: "Target tap repository in OWNER/REPO form (e.g. Auto-Explore/homebrew-gitcomet). Defaults to HOMEBREW_TAP_REPO when omitted."
required: false
default: ""
type: string
tap_branch:
description: "Target branch in tap repo"
description: "Target branch in tap repo. Defaults to HOMEBREW_TAP_BRANCH when omitted."
required: false
default: "main"
type: string
dry_run:
description: "Validate and print formula without pushing to tap"
description: "Validate and print cask/formula without pushing to tap"
required: true
default: false
type: boolean
@ -59,7 +59,7 @@ concurrency:
jobs:
deploy:
name: Publish formula to Homebrew tap
name: Publish Homebrew cask and formula
runs-on: ubuntu-latest
timeout-minutes: 20
env:
@ -74,8 +74,10 @@ jobs:
DISPATCH_VERSION: ${{ github.event.inputs.version }}
INPUT_TAP_REPO: ${{ inputs.tap_repo }}
DISPATCH_TAP_REPO: ${{ github.event.inputs.tap_repo }}
VAR_TAP_REPO: ${{ vars.HOMEBREW_TAP_REPO }}
INPUT_TAP_BRANCH: ${{ inputs.tap_branch }}
DISPATCH_TAP_BRANCH: ${{ github.event.inputs.tap_branch }}
VAR_TAP_BRANCH: ${{ vars.HOMEBREW_TAP_BRANCH }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
DISPATCH_DRY_RUN: ${{ github.event.inputs.dry_run }}
REPO_OWNER: ${{ github.repository_owner }}
@ -84,8 +86,8 @@ jobs:
tag="${INPUT_TAG:-${DISPATCH_TAG:-}}"
version="${INPUT_VERSION:-${DISPATCH_VERSION:-}}"
tap_repo="${INPUT_TAP_REPO:-${DISPATCH_TAP_REPO:-}}"
tap_branch="${INPUT_TAP_BRANCH:-${DISPATCH_TAP_BRANCH:-main}}"
tap_repo="${INPUT_TAP_REPO:-${DISPATCH_TAP_REPO:-${VAR_TAP_REPO:-}}}"
tap_branch="${INPUT_TAP_BRANCH:-${DISPATCH_TAP_BRANCH:-${VAR_TAP_BRANCH:-main}}}"
dry_run="${INPUT_DRY_RUN:-${DISPATCH_DRY_RUN:-false}}"
tag="$(echo "$tag" | tr -d '[:space:]')"
@ -138,7 +140,7 @@ jobs:
echo "tap_branch=$tap_branch" >> "$GITHUB_OUTPUT"
echo "dry_run=$dry_run" >> "$GITHUB_OUTPUT"
- name: Download formula from release assets
- name: Download cask and formula from release assets
run: |
set -euo pipefail
mkdir -p dist
@ -146,12 +148,17 @@ jobs:
gh release download "${{ steps.norm.outputs.tag }}" \
--repo "$GITHUB_REPOSITORY" \
--pattern "gitcomet.rb" \
--pattern "gitcomet-cli.rb" \
--dir dist \
--clobber
test -f dist/gitcomet.rb
test -f dist/gitcomet-cli.rb
- name: Validate formula syntax
run: ruby -c dist/gitcomet.rb
- name: Validate cask and formula syntax
run: |
set -euo pipefail
ruby -c dist/gitcomet.rb
ruby -c dist/gitcomet-cli.rb
- name: Emit dry-run summary
if: ${{ steps.norm.outputs.dry_run == 'true' }}
@ -164,13 +171,18 @@ jobs:
echo "- Target tap: \`${{ steps.norm.outputs.tap_repo }}\`"
echo "- Target branch: \`${{ steps.norm.outputs.tap_branch }}\`"
echo ""
echo "Formula preview:"
echo "Cask preview (Casks/gitcomet.rb):"
echo '```ruby'
cat dist/gitcomet.rb
echo '```'
echo ""
echo "Formula preview (Formula/gitcomet-cli.rb):"
echo '```ruby'
cat dist/gitcomet-cli.rb
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Publish formula to tap repository
- name: Publish cask and formula to tap repository
if: ${{ steps.norm.outputs.dry_run != 'true' }}
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
@ -187,19 +199,24 @@ jobs:
rm -rf tap-repo
git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap-repo
mkdir -p tap-repo/Formula
cp dist/gitcomet.rb tap-repo/Formula/gitcomet.rb
mkdir -p tap-repo/Casks tap-repo/Formula
cp dist/gitcomet.rb tap-repo/Casks/gitcomet.rb
cp dist/gitcomet-cli.rb tap-repo/Formula/gitcomet-cli.rb
pushd tap-repo >/dev/null
if git diff --quiet -- Formula/gitcomet.rb; then
echo "No formula changes detected; tap already up to date."
git add Casks/gitcomet.rb Formula/gitcomet-cli.rb
if git ls-files --error-unmatch Formula/gitcomet.rb >/dev/null 2>&1; then
git rm -f Formula/gitcomet.rb
fi
if git diff --cached --quiet -- Casks/gitcomet.rb Formula/gitcomet-cli.rb Formula/gitcomet.rb; then
echo "No Homebrew cask/formula changes detected; tap already up to date."
popd >/dev/null
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Formula/gitcomet.rb
git commit -m "gitcomet ${TAG}"
git push origin "HEAD:${TAP_BRANCH}"
popd >/dev/null

View file

@ -5,21 +5,27 @@ on:
branches: ["main"]
paths:
- ".github/workflows/build-release-artifacts.yml"
- ".github/workflows/deploy-aur.yml"
- ".github/workflows/deploy-homebrew-tap.yml"
- ".github/workflows/deploy-apt-repo.yml"
- ".github/workflows/release-manual-main.yml"
- "scripts/update-aur.sh"
- "scripts/package-macos.sh"
- "scripts/generate-homebrew-formula.sh"
- "scripts/generate-homebrew-cask.sh"
- "scripts/build-apt-repo.sh"
push:
branches: ["main"]
paths:
- ".github/workflows/build-release-artifacts.yml"
- ".github/workflows/deploy-aur.yml"
- ".github/workflows/deploy-homebrew-tap.yml"
- ".github/workflows/deploy-apt-repo.yml"
- ".github/workflows/release-manual-main.yml"
- "scripts/update-aur.sh"
- "scripts/package-macos.sh"
- "scripts/generate-homebrew-formula.sh"
- "scripts/generate-homebrew-cask.sh"
- "scripts/build-apt-repo.sh"
workflow_dispatch:
@ -39,27 +45,58 @@ jobs:
- name: Validate shell scripts syntax
run: |
bash -n scripts/update-aur.sh
bash -n scripts/package-macos.sh
bash -n scripts/generate-homebrew-formula.sh
bash -n scripts/generate-homebrew-cask.sh
bash -n scripts/build-apt-repo.sh
- name: Validate workflow YAML
run: |
ruby -e 'require "yaml"; %w[
.github/workflows/build-release-artifacts.yml
.github/workflows/deploy-aur.yml
.github/workflows/deploy-homebrew-tap.yml
.github/workflows/deploy-apt-repo.yml
.github/workflows/deployment-ci.yml
.github/workflows/release-manual-main.yml
].each { |path| YAML.load_file(path) }'
- name: Generate formula from synthetic artifacts
- name: Validate deployment workflow config keys
run: |
set -euo pipefail
grep -Fq 'vars.AUR_GITHUB_REPO' .github/workflows/release-manual-main.yml
grep -Fq 'vars.AUR_GITHUB_BRANCH' .github/workflows/release-manual-main.yml
grep -Fq 'vars.APT_STORAGE_ACCOUNT' .github/workflows/release-manual-main.yml
grep -Fq 'vars.APT_STORAGE_CONTAINER' .github/workflows/release-manual-main.yml
grep -Fq 'vars.APT_REPO_DISTRIBUTION' .github/workflows/release-manual-main.yml
grep -Fq 'vars.APT_REPO_COMPONENT' .github/workflows/release-manual-main.yml
grep -Fq 'vars.APT_REPO_ORIGIN' .github/workflows/release-manual-main.yml
grep -Fq 'vars.APT_REPO_LABEL' .github/workflows/release-manual-main.yml
grep -Fq 'vars.APT_REPO_DESCRIPTION' .github/workflows/release-manual-main.yml
grep -Fq 'vars.APT_STORAGE_PUBLIC_ACCESS' .github/workflows/release-manual-main.yml
grep -Fq 'vars.APT_STORAGE_ACCOUNT' .github/workflows/deploy-apt-repo.yml
grep -Fq 'vars.APT_STORAGE_CONTAINER' .github/workflows/deploy-apt-repo.yml
grep -Fq 'vars.APT_REPO_DISTRIBUTION' .github/workflows/deploy-apt-repo.yml
grep -Fq 'vars.APT_REPO_COMPONENT' .github/workflows/deploy-apt-repo.yml
grep -Fq 'vars.APT_REPO_ORIGIN' .github/workflows/deploy-apt-repo.yml
grep -Fq 'vars.APT_REPO_LABEL' .github/workflows/deploy-apt-repo.yml
grep -Fq 'vars.APT_REPO_DESCRIPTION' .github/workflows/deploy-apt-repo.yml
grep -Fq 'vars.APT_STORAGE_PUBLIC_ACCESS' .github/workflows/deploy-apt-repo.yml
grep -Fq 'vars.AUR_GITHUB_REPO' .github/workflows/deploy-aur.yml
grep -Fq 'vars.AUR_GITHUB_BRANCH' .github/workflows/deploy-aur.yml
grep -Fq 'AUR_REPO_TOKEN' .github/workflows/deploy-aur.yml
grep -Fq 'HOMEBREW_TAP_TOKEN' .github/workflows/deploy-homebrew-tap.yml
- name: Generate Homebrew cask and formula from synthetic artifacts
run: |
set -euo pipefail
mkdir -p dist/deployment-ci
printf 'arm64-test' > dist/deployment-ci/gitcomet-v0.0.0-ci-macos-arm64.tar.gz
printf 'intel-test' > dist/deployment-ci/gitcomet-v0.0.0-ci-macos-x86_64.tar.gz
printf 'linux-test' > dist/deployment-ci/gitcomet-v0.0.0-ci-linux-x86_64.tar.gz
printf 'arm64-dmg-test' > dist/deployment-ci/gitcomet-v0.0.0-ci-macos-arm64.dmg
printf 'intel-dmg-test' > dist/deployment-ci/gitcomet-v0.0.0-ci-macos-x86_64.dmg
scripts/generate-homebrew-formula.sh \
--version "0.0.0-ci" \
@ -67,11 +104,137 @@ jobs:
--arm-tar "dist/deployment-ci/gitcomet-v0.0.0-ci-macos-arm64.tar.gz" \
--intel-tar "dist/deployment-ci/gitcomet-v0.0.0-ci-macos-x86_64.tar.gz" \
--linux-tar "dist/deployment-ci/gitcomet-v0.0.0-ci-linux-x86_64.tar.gz" \
--output "dist/deployment-ci/gitcomet-cli.rb"
scripts/generate-homebrew-cask.sh \
--version "0.0.0-ci" \
--github-repo "Auto-Explore/GitComet" \
--arm-dmg "dist/deployment-ci/gitcomet-v0.0.0-ci-macos-arm64.dmg" \
--intel-dmg "dist/deployment-ci/gitcomet-v0.0.0-ci-macos-x86_64.dmg" \
--output "dist/deployment-ci/gitcomet.rb"
ruby -c dist/deployment-ci/gitcomet.rb
grep -q "on_linux do" dist/deployment-ci/gitcomet.rb
grep -q "gitcomet-v0.0.0-ci-linux-x86_64.tar.gz" dist/deployment-ci/gitcomet.rb
ruby -c dist/deployment-ci/gitcomet-cli.rb
grep -q 'cask "gitcomet" do' dist/deployment-ci/gitcomet.rb
grep -q 'app "GitComet.app"' dist/deployment-ci/gitcomet.rb
grep -q 'brew install gitcomet-cli' dist/deployment-ci/gitcomet.rb
grep -q 'class GitcometCli < Formula' dist/deployment-ci/gitcomet-cli.rb
grep -q "on_linux do" dist/deployment-ci/gitcomet-cli.rb
grep -q 'bin.install "gitcomet"' dist/deployment-ci/gitcomet-cli.rb
- name: Validate Homebrew tap publish detection
run: |
set -euo pipefail
rm -rf dist/deployment-ci/tap-repo
git init --initial-branch=main dist/deployment-ci/tap-repo
pushd dist/deployment-ci/tap-repo >/dev/null
git config user.name "GitComet CI"
git config user.email "ci@autoexplore.ai"
echo "# Homebrew Tap" > README.md
mkdir -p Formula
cat > Formula/gitcomet.rb <<'RUBY'
class Gitcomet < Formula
desc "Legacy GitComet formula"
homepage "https://example.com"
url "https://example.com/gitcomet.tar.gz"
sha256 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
version "0.0.0"
end
RUBY
git add README.md Formula/gitcomet.rb
git commit -m "Initial tap repo"
mkdir -p Casks Formula
cp ../gitcomet.rb Casks/gitcomet.rb
cp ../gitcomet-cli.rb Formula/gitcomet-cli.rb
git add Casks/gitcomet.rb Formula/gitcomet-cli.rb
if git ls-files --error-unmatch Formula/gitcomet.rb >/dev/null 2>&1; then
git rm -f Formula/gitcomet.rb
fi
if git diff --cached --quiet -- Casks/gitcomet.rb Formula/gitcomet-cli.rb Formula/gitcomet.rb; then
echo "Expected Homebrew cask/formula publish to be detected as a change."
exit 1
fi
popd >/dev/null
aur-metadata-smoke:
name: Validate AUR metadata update script
runs-on: ubuntu-latest
timeout-minutes: 20
container:
image: archlinux:base-devel
steps:
- name: Install Arch packaging tooling
run: |
set -euo pipefail
pacman -Sy --noconfirm --needed perl shadow
- uses: actions/checkout@v6
- name: Create non-root packaging user
run: |
set -euo pipefail
id -u builder >/dev/null 2>&1 || useradd -m builder
chown -R builder:builder "$GITHUB_WORKSPACE"
- name: Prepare synthetic AUR repo and assets
run: |
set -euo pipefail
mkdir -p dist/aur-ci/repo
cat > dist/aur-ci/repo/PKGBUILD <<'EOF'
pkgname=gitcomet
pkgver=0.0.0
pkgrel=1
pkgdesc="Fast, resource-efficient Git GUI written in Rust"
arch=('x86_64')
url="https://gitcomet.dev/"
license=('AGPL-3.0-only')
depends=(
'fontconfig'
'freetype2'
'git'
'libx11'
'libxcb'
'libxkbcommon'
'wayland'
)
source=(
"gitcomet-v$pkgver-linux-x86_64.tar.gz::https://example.invalid/gitcomet-v$pkgver-linux-x86_64.tar.gz"
"gitcomet-source-v$pkgver.tar.gz::https://example.invalid/gitcomet-source-v$pkgver.tar.gz"
)
sha256sums=('oldbinary'
'oldsource')
package() {
install -D -m755 "gitcomet-v$pkgver-linux-x86_64/gitcomet" "$pkgdir/usr/bin/gitcomet"
}
EOF
printf 'synthetic-binary' > dist/aur-ci/gitcomet-v0.0.0-linux-x86_64.tar.gz
printf 'synthetic-source' > dist/aur-ci/gitcomet-source-v0.0.0.tar.gz
chown -R builder:builder dist
- name: Run update-aur.sh
run: |
set -euo pipefail
su builder -c "cd '$GITHUB_WORKSPACE' && scripts/update-aur.sh \
--aur-dir '$GITHUB_WORKSPACE/dist/aur-ci/repo' \
--version 0.0.0 \
--binary-tar '$GITHUB_WORKSPACE/dist/aur-ci/gitcomet-v0.0.0-linux-x86_64.tar.gz' \
--source-tar '$GITHUB_WORKSPACE/dist/aur-ci/gitcomet-source-v0.0.0.tar.gz' \
--verify-source"
- name: Validate generated PKGBUILD and .SRCINFO
run: |
set -euo pipefail
test -f dist/aur-ci/repo/.SRCINFO
grep -q '^pkgver=0.0.0$' dist/aur-ci/repo/PKGBUILD
grep -q 'source = gitcomet-v0.0.0-linux-x86_64.tar.gz::https://example.invalid/gitcomet-v0.0.0-linux-x86_64.tar.gz$' dist/aur-ci/repo/.SRCINFO
grep -q 'source = gitcomet-source-v0.0.0.tar.gz::https://example.invalid/gitcomet-source-v0.0.0.tar.gz$' dist/aur-ci/repo/.SRCINFO
apt-repo-generation-smoke:
name: Validate APT repo generation script
@ -91,14 +254,14 @@ jobs:
pkg_root="dist/apt-ci/pkg"
mkdir -p "${pkg_root}/DEBIAN" "${pkg_root}/usr/bin"
printf '%s\n' '#!/usr/bin/env bash' 'echo "GitComet CI"' > "${pkg_root}/usr/bin/gitcomet-app"
chmod +x "${pkg_root}/usr/bin/gitcomet-app"
printf '%s\n' '#!/usr/bin/env bash' 'echo "GitComet CI"' > "${pkg_root}/usr/bin/gitcomet"
chmod +x "${pkg_root}/usr/bin/gitcomet"
{
echo "Package: gitcomet"
echo "Version: 0.0.0~ci1"
echo "Architecture: amd64"
echo "Maintainer: GitComet CI <ci@example.invalid>"
echo "Maintainer: GitComet CI <ci@autoexplore.ai>"
echo "Description: Synthetic GitComet package for deployment CI."
} > "${pkg_root}/DEBIAN/control"
@ -116,7 +279,7 @@ jobs:
echo "Key-Type: RSA"
echo "Key-Length: 3072"
echo "Name-Real: GitComet CI"
echo "Name-Email: ci@example.invalid"
echo "Name-Email: ci@autoexplore.ai"
echo "Expire-Date: 0"
echo "%no-protection"
echo "%commit"
@ -129,6 +292,7 @@ jobs:
- name: Build signed APT repo
env:
GNUPGHOME: ${{ github.workspace }}/dist/apt-ci/gnupg
APT_GPG_PASSPHRASE: ${{ secrets.APT_GPG_PASSPHRASE }}
run: |
set -euo pipefail
scripts/build-apt-repo.sh \
@ -141,6 +305,7 @@ jobs:
--label GitComet \
--description "GitComet deployment CI repository" \
--repo-url https://example.invalid/gitcomet/apt \
--gpg-passphrase "${APT_GPG_PASSPHRASE}" \
--signing-key "${APT_TEST_GPG_KEY_ID}"
- name: Validate generated repo
@ -163,6 +328,38 @@ jobs:
chmod 700 "$GNUPGHOME"
gpg --batch --no-default-keyring --keyring dist/apt-ci/repo/gitcomet-archive-keyring.gpg --verify dist/apt-ci/repo/dists/stable/InRelease >/dev/null
- name: Verify generated repo with apt-secure
run: |
set -euo pipefail
repo_root="$(pwd)/dist/apt-ci/repo"
source_dir="${RUNNER_TEMP}/gitcomet-apt-sources.d"
lists_dir="${RUNNER_TEMP}/gitcomet-apt-lists"
mkdir -p "$source_dir"
sudo mkdir -p "${lists_dir}/partial"
cat > "${source_dir}/gitcomet-ci.sources" <<EOF
Types: deb
URIs: file://${repo_root}
Suites: stable
Components: main
Architectures: amd64
Signed-By: ${repo_root}/gitcomet-archive-keyring.gpg
EOF
sudo apt-get update \
-o Dir::Etc::sourcelist=/dev/null \
-o Dir::Etc::sourceparts="${source_dir}" \
-o Dir::State::lists="${lists_dir}" \
-o APT::Get::List-Cleanup=0
apt-cache policy gitcomet \
-o Dir::Etc::sourcelist=/dev/null \
-o Dir::Etc::sourceparts="${source_dir}" \
-o Dir::State::lists="${lists_dir}" \
| grep -q '0.0.0~ci1'
macos-packaging-smoke:
name: macOS packaging smoke (${{ matrix.arch }})
runs-on: ${{ matrix.runs_on }}
@ -185,7 +382,7 @@ jobs:
uses: Swatinem/rust-cache@v2
- name: Build release binary
run: cargo build -p gitcomet-app --release --locked --features ui-gpui,gix --bins
run: cargo build -p gitcomet --release --locked --features ui-gpui,gix --bins
- name: Package macOS artifacts
run: |
@ -202,7 +399,7 @@ jobs:
set -euo pipefail
tarball="dist/gitcomet-v0.0.0-ci-macos-${{ matrix.arch }}.tar.gz"
test -f "$tarball"
tar -tzf "$tarball" | grep -q "GitComet.app/Contents/MacOS/gitcomet-app$"
tar -tzf "$tarball" | grep -q "GitComet.app/Contents/MacOS/gitcomet$"
- name: Verify DMG launches binary
run: |
@ -216,5 +413,5 @@ jobs:
exit 1
fi
"${mount_point}/GitComet.app/Contents/MacOS/gitcomet-app" --help >/dev/null
"${mount_point}/GitComet.app/Contents/MacOS/gitcomet" --help >/dev/null
hdiutil detach "$mount_point"

View file

@ -82,10 +82,10 @@ jobs:
- name: Verify crate version matches release version
run: |
set -euo pipefail
crate_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' crates/gitcomet-app/Cargo.toml | head -n1)"
crate_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
wanted="${{ steps.norm.outputs.version }}"
if [ "$crate_version" != "$wanted" ]; then
echo "::error title=Version mismatch::crates/gitcomet-app/Cargo.toml version '$crate_version' does not match requested release '$wanted'."
echo "::error title=Version mismatch::Cargo.toml version '$crate_version' does not match requested release '$wanted'."
exit 1
fi
@ -175,7 +175,7 @@ jobs:
gh release view "$tag" --repo "$GITHUB_REPOSITORY"
deploy_homebrew_tap:
name: Deploy Homebrew tap formula
name: Deploy Homebrew tap packages
needs: [validate, build_and_upload, publish_release]
if: ${{ fromJSON(needs.validate.outputs.draft) == false && needs.build_and_upload.result == 'success' }}
uses: ./.github/workflows/deploy-homebrew-tap.yml
@ -187,6 +187,19 @@ jobs:
dry_run: false
secrets: inherit
deploy_aur:
name: Deploy AUR mirror metadata
needs: [validate, build_and_upload, publish_release]
if: ${{ fromJSON(needs.validate.outputs.draft) == false && needs.build_and_upload.result == 'success' }}
uses: ./.github/workflows/deploy-aur.yml
with:
tag: ${{ needs.validate.outputs.tag }}
version: ${{ needs.validate.outputs.version }}
aur_repo: ${{ vars.AUR_GITHUB_REPO }}
aur_branch: ${{ vars.AUR_GITHUB_BRANCH }}
dry_run: false
secrets: inherit
deploy_apt_repo:
name: Deploy Azure APT repository
needs: [validate, build_and_upload, publish_release]
@ -198,7 +211,6 @@ jobs:
storage_account: ${{ vars.APT_STORAGE_ACCOUNT }}
storage_container: ${{ vars.APT_STORAGE_CONTAINER }}
storage_prefix: ${{ vars.APT_STORAGE_PREFIX }}
repo_base_url: ${{ vars.APT_REPO_BASE_URL }}
distribution: ${{ vars.APT_REPO_DISTRIBUTION }}
component: ${{ vars.APT_REPO_COMPONENT }}
architecture: "amd64"

View file

@ -18,7 +18,7 @@ concurrency:
env:
CARGO_TERM_COLOR: always
# Build gitcomet-app in headless mode (no GPUI system deps required).
# Build gitcomet in headless mode (no GPUI system deps required).
# The UI-only code paths are behind #[cfg(feature = "ui-gpui")] guards.
APP_FEATURES: "--no-default-features --features gix"
@ -77,7 +77,7 @@ jobs:
echo "Skipping cargo clippy -p gitcomet-ui-gpui in this job."
echo "Reason: GPUI requires native windowing/system dependencies that are not available in headless CI."
- name: Clippy (app — headless)
run: cargo clippy -p gitcomet-app $APP_FEATURES -- -D warnings
run: cargo clippy -p gitcomet $APP_FEATURES -- -D warnings
build:
name: Build
@ -92,7 +92,7 @@ jobs:
- name: Build (core + state + backend)
run: cargo build -p gitcomet-core -p gitcomet-state -p gitcomet-git-gix --verbose
- name: Build (app — headless)
run: cargo build -p gitcomet-app $APP_FEATURES --verbose
run: cargo build -p gitcomet $APP_FEATURES --verbose
# Core merge algorithm correctness — Phase 1A/1B/1C portability tests
merge-algorithm:
@ -148,16 +148,16 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust artifacts
uses: Swatinem/rust-cache@v2
- name: Build gitcomet-app binary (headless)
run: cargo build -p gitcomet-app $APP_FEATURES
- name: Build gitcomet binary (headless)
run: cargo build -p gitcomet $APP_FEATURES
- name: Git mergetool E2E (Phase 4A — t7610 parity)
run: cargo test -p gitcomet-app $APP_FEATURES --test mergetool_git_integration --verbose
run: cargo test -p gitcomet $APP_FEATURES --test mergetool_git_integration --verbose
- name: Git difftool E2E (Phase 4B — t7800 parity)
run: cargo test -p gitcomet-app $APP_FEATURES --test difftool_git_integration --verbose
run: cargo test -p gitcomet $APP_FEATURES --test difftool_git_integration --verbose
- name: Standalone tool-mode E2E (exit codes + validation)
run: cargo test -p gitcomet-app $APP_FEATURES --test standalone_tool_mode_integration --verbose
run: cargo test -p gitcomet $APP_FEATURES --test standalone_tool_mode_integration --verbose
- name: Mergetool/difftool runtime unit tests (bin target)
run: cargo test -p gitcomet-app $APP_FEATURES --bin gitcomet-app --verbose
run: cargo test -p gitcomet $APP_FEATURES --bin gitcomet --verbose
# Backend integration — mergetool launcher, status, conflict checkout
backend-integration:

View file

@ -8,7 +8,7 @@
- `crates/gitcomet-state`: MVU state store, reducers, effects, conflict session management.
- `crates/gitcomet-ui`: UI model/state (toolkit-independent).
- `crates/gitcomet-ui-gpui`: gpui views/components (focused diff/merge windows, conflict resolver, word diff).
- `crates/gitcomet-app`: binary entrypoint, CLI (clap), difftool/mergetool/setup/uninstall modes.
- `crates/gitcomet`: binary entrypoint, CLI (clap), difftool/mergetool/setup/uninstall modes.
### Getting started
@ -28,19 +28,19 @@ cargo build
To build the actual app you'll enable features (requires network for dependencies):
```bash
cargo build -p gitcomet-app --features ui,gix
cargo build -p gitcomet --features ui,gix
```
To also compile the gpui-based UI crate:
```bash
cargo build -p gitcomet-app --features ui-gpui,gix
cargo build -p gitcomet --features ui-gpui,gix
```
Run (opens the repo passed as the first arg, or falls back to the current directory):
```bash
cargo run -p gitcomet-app --features ui-gpui,gix -- /path/to/repo
cargo run -p gitcomet --features ui-gpui,gix -- /path/to/repo
```
### Testing
@ -86,11 +86,12 @@ The release workflow `.github/workflows/build-release-artifacts.yml` builds and
- Windows: portable ZIP + MSI
- Linux: tar.gz + AppImage + .deb
- macOS: DMG + tar.gz for `arm64` and `x86_64`
- Homebrew formula asset: `gitcomet.rb` (generated from macOS + Linux x86_64 tarballs and their SHA256 values)
- Homebrew cask asset: `gitcomet.rb` (generated from macOS DMG artifacts and their SHA256 values)
- Homebrew CLI formula asset: `gitcomet-cli.rb` (generated from macOS + Linux x86_64 tarballs and their SHA256 values)
### Homebrew deployment
To push `Formula/gitcomet.rb` into a Homebrew tap repo automatically on release:
To push `Casks/gitcomet.rb` and `Formula/gitcomet-cli.rb` into a Homebrew tap repo automatically on release:
1. Create a tap repository (default expected name: `OWNER/homebrew-gitcomet`).
2. In this repo, configure:
@ -103,6 +104,27 @@ This release flow will:
- build and upload release artifacts
- publish the GitHub release
- call `.github/workflows/deploy-homebrew-tap.yml` to update `Formula/gitcomet.rb` in the tap repo
- call `.github/workflows/deploy-homebrew-tap.yml` to update `Casks/gitcomet.rb` and `Formula/gitcomet-cli.rb` in the tap repo
You can also run `.github/workflows/deploy-homebrew-tap.yml` manually for backfills or dry-runs.
### AUR mirror deployment
To push `PKGBUILD` and `.SRCINFO` into a GitHub-hosted AUR mirror repo automatically on release:
1. Create the target repository (default expected name: `OWNER/aur-gitcomet`).
2. In this repo, configure:
- secret `AUR_REPO_TOKEN`: GitHub token with `contents:write` access to the AUR mirror repository.
- optional variable `AUR_GITHUB_REPO`: target repository in `OWNER/REPO` form.
- optional variable `AUR_GITHUB_BRANCH`: target branch (default `main`).
3. Run `.github/workflows/release-manual-main.yml` with `draft=false`.
This release flow will:
- download the published Linux release tarball and source tarball
- update `PKGBUILD` `pkgver` and `sha256sums`
- regenerate `.SRCINFO`
- validate sources with `makepkg --verifysource`
- push the updated metadata into the configured AUR mirror repository
You can also run `.github/workflows/deploy-aur.yml` manually for backfills or dry-runs.

71
Cargo.lock generated
View file

@ -2239,8 +2239,8 @@ dependencies = [
]
[[package]]
name = "gitcomet-app"
version = "0.1.0"
name = "gitcomet"
version = "0.1.7"
dependencies = [
"clap",
"embed-resource",
@ -2256,7 +2256,7 @@ dependencies = [
[[package]]
name = "gitcomet-core"
version = "0.1.0"
version = "0.1.7"
dependencies = [
"regex",
"rustc-hash 2.1.1",
@ -2267,14 +2267,14 @@ dependencies = [
[[package]]
name = "gitcomet-git"
version = "0.1.0"
version = "0.1.7"
dependencies = [
"gitcomet-core",
]
[[package]]
name = "gitcomet-git-gix"
version = "0.1.0"
version = "0.1.7"
dependencies = [
"gitcomet-core",
"gix",
@ -2285,7 +2285,7 @@ dependencies = [
[[package]]
name = "gitcomet-state"
version = "0.1.0"
version = "0.1.7"
dependencies = [
"gitcomet-core",
"gix",
@ -2299,14 +2299,14 @@ dependencies = [
[[package]]
name = "gitcomet-ui"
version = "0.1.0"
version = "0.1.7"
dependencies = [
"gitcomet-core",
]
[[package]]
name = "gitcomet-ui-gpui"
version = "0.1.0"
version = "0.1.7"
dependencies = [
"criterion",
"gitcomet-core",
@ -2399,7 +2399,7 @@ dependencies = [
"bstr",
"gix-date",
"gix-error",
"winnow",
"winnow 0.7.15",
]
[[package]]
@ -2501,7 +2501,7 @@ dependencies = [
"smallvec",
"thiserror 2.0.18",
"unicode-bom",
"winnow",
"winnow 0.7.15",
]
[[package]]
@ -2760,7 +2760,7 @@ dependencies = [
"itoa",
"smallvec",
"thiserror 2.0.18",
"winnow",
"winnow 0.7.15",
]
[[package]]
@ -2859,7 +2859,7 @@ dependencies = [
"maybe-async",
"nonempty",
"thiserror 2.0.18",
"winnow",
"winnow 0.7.15",
]
[[package]]
@ -2891,7 +2891,7 @@ dependencies = [
"gix-validate",
"memmap2",
"thiserror 2.0.18",
"winnow",
"winnow 0.7.15",
]
[[package]]
@ -5314,7 +5314,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.4+spec-1.1.0",
"toml_edit 0.25.5+spec-1.1.0",
]
[[package]]
@ -7044,7 +7044,7 @@ dependencies = [
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow",
"winnow 0.7.15",
]
[[package]]
@ -7067,9 +7067,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "1.0.0+spec-1.1.0"
version = "1.0.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9"
dependencies = [
"serde_core",
]
@ -7085,28 +7085,28 @@ dependencies = [
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow",
"winnow 0.7.15",
]
[[package]]
name = "toml_edit"
version = "0.25.4+spec-1.1.0"
version = "0.25.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1"
dependencies = [
"indexmap",
"toml_datetime 1.0.0+spec-1.1.0",
"toml_datetime 1.0.1+spec-1.1.0",
"toml_parser",
"winnow",
"winnow 1.0.0",
]
[[package]]
name = "toml_parser"
version = "1.0.9+spec-1.1.0"
version = "1.0.10+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
dependencies = [
"winnow",
"winnow 1.0.0",
]
[[package]]
@ -7117,9 +7117,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
version = "1.0.7+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d"
[[package]]
name = "tower"
@ -8426,6 +8426,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.55.0"
@ -8735,7 +8744,7 @@ dependencies = [
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow",
"winnow 0.7.15",
"zbus_macros",
"zbus_names",
"zvariant",
@ -8763,7 +8772,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
dependencies = [
"serde",
"winnow",
"winnow 0.7.15",
"zvariant",
]
@ -9039,7 +9048,7 @@ dependencies = [
"enumflags2",
"serde",
"url",
"winnow",
"winnow 0.7.15",
"zvariant_derive",
"zvariant_utils",
]
@ -9067,5 +9076,5 @@ dependencies = [
"quote",
"serde",
"syn 2.0.117",
"winnow",
"winnow 0.7.15",
]

View file

@ -1,7 +1,7 @@
[workspace]
resolver = "3"
members = [
"crates/gitcomet-app",
"crates/gitcomet",
"crates/gitcomet-core",
"crates/gitcomet-git",
"crates/gitcomet-git-gix",
@ -11,7 +11,7 @@ members = [
]
default-members = [
"crates/gitcomet-app",
"crates/gitcomet",
"crates/gitcomet-core",
"crates/gitcomet-git",
"crates/gitcomet-git-gix",
@ -21,6 +21,7 @@ default-members = [
]
[workspace.package]
version = "0.1.7"
edition = "2024"
license = "AGPL-3.0-only"
authors = ["AutoExplore Oy <info@autoexplore.ai>"]
@ -82,7 +83,7 @@ opt-level = 1
split-debuginfo = "packed"
[profile.release]
lto = "thin"
lto = "fat"
codegen-units = 1
strip = "symbols"

View file

@ -4,8 +4,11 @@
[![Discord](https://img.shields.io/badge/Discord-Join%20chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/2ufDGP8RnA)
[![Website](https://img.shields.io/badge/Website-gitcomet.dev-0A66C2?logo=googlechrome&logoColor=white)](https://gitcomet.dev)
[![AutoExplore](https://img.shields.io/badge/AutoExplore-autoexplore.ai-0B7A75?logo=safari&logoColor=white)](https://autoexplore.ai)
[![license](https://img.shields.io/github/license/Auto-Explore/gitcomet.svg)](LICENSE)
[![latest](https://img.shields.io/github/v/release/Auto-Explore/gitcomet.svg)](https://github.com/Auto-Explore/gitcomet/releases/latest)
[![downloads](https://img.shields.io/github/downloads/Auto-Explore/gitcomet/total)](https://github.com/Auto-Explore/gitcomet/releases)
**Speed is a feature.**
**Fastest Open Source Git GUI**
GitComet is built for teams that want fast Git operations with local-first privacy, familiar workflows, and open source freedom.
@ -19,11 +22,24 @@ Download the latest prebuilt binaries/installers from [GitHub Releases](https://
#### Homebrew
install from tap:
install app from tap (recommended):
```bash
brew tap auto-explore/gitcomet
brew install gitcomet
brew install --cask gitcomet
```
**MacOS Troubleshooting:** If you downloaded the app manually and macOS reports that GitComet *"is damaged and can't be opened"*, this is due to Apple's Gatekeeper quarantine for apps downloaded outside the App Store. To resolve this, run the following command in your terminal to remove the quarantine attribute:
```bash
xattr -d com.apple.quarantine /Applications/GitComet.app
```
optional CLI install:
```bash
brew install gitcomet-cli
```
### Fast, Free, Familiar
@ -71,13 +87,13 @@ Measured on Linux 6.19.6-zen (x64), Ryzen 5950x, 128GB DDR4. Detailed test steps
- Code test coverage workflows
- GitHub and Azure DevOps integrations
- Priority improvements during early access
- Join waitlist: [gitcomet.com/#pricing](https://gitcomet.com/#pricing)
- Join waitlist: [gitcomet.dev/#pricing](https://gitcomet.dev/#pricing)
### Build from source
```bash
cargo build -p gitcomet-app --features ui-gpui,gix
cargo run -p gitcomet-app --features ui-gpui,gix -- /path/to/repo
cargo build -p gitcomet --features ui-gpui,gix
cargo run -p gitcomet --features ui-gpui,gix -- /path/to/repo
```
### Contributing
@ -92,10 +108,10 @@ GitComet can be used as a standalone diff and merge tool invoked by `git difftoo
```bash
# Configure Git globally to use GitComet for both difftool + mergetool
gitcomet-app setup
gitcomet setup
# Remove GitComet integration safely
gitcomet-app uninstall
gitcomet uninstall
```
- Use `--local` to target only the current repository instead of global config.
@ -110,7 +126,7 @@ This setup registers both headless and GUI variants with `guiDefault=auto`, so G
Built-in `setup` writes these Git config entries:
```bash
GITCOMET_BIN="/absolute/path/to/gitcomet-app"
GITCOMET_BIN="/absolute/path/to/gitcomet"
# Headless tool: algorithm-only merge/diff for CI, scripts, and no-display environments
git config --global merge.tool gitcomet
@ -153,7 +169,7 @@ Built-in `uninstall` restores those backups only when the key still has the setu
**Difftool:**
```bash
gitcomet-app difftool --local <path> --remote <path> [--path <display_name>] [--label-left <label>] [--label-right <label>]
gitcomet difftool --local <path> --remote <path> [--path <display_name>] [--label-left <label>] [--label-right <label>]
```
Also reads `LOCAL`/`REMOTE` from environment as a fallback when invoked by Git.
@ -161,7 +177,7 @@ Also reads `LOCAL`/`REMOTE` from environment as a fallback when invoked by Git.
**Mergetool:**
```bash
gitcomet-app mergetool --local <path> --remote <path> --merged <path> [--base <path>] [--label-local <label>] [--label-remote <label>] [--label-base <label>]
gitcomet mergetool --local <path> --remote <path> --merged <path> [--base <path>] [--label-local <label>] [--label-remote <label>] [--label-base <label>]
```
Also reads `LOCAL`/`REMOTE`/`MERGED`/`BASE` from environment. Base is optional for add/add conflicts.
@ -188,6 +204,10 @@ SourceTree, GitKraken, Zed, GPUI, KDiff3, Meld, Github Desktop, Git, Gix, Rust,
This project has been created with the help of AI tools, including OpenAI Codex and Claude Code.
### Star History
[![Star History Chart](https://api.star-history.com/svg?repos=Auto-Explore/gitcomet&type=Date)](https://star-history.com/#Auto-Explore/gitcomet&Date)
### License
GitComet is licensed under the GNU Affero General Public License Version 3

View file

@ -2,7 +2,7 @@
Type=Application
Name=GitComet
Comment=Git UI built with GPUI
Exec=gitcomet-app
Exec=gitcomet
Icon=gitcomet
StartupWMClass=gitcomet
Terminal=false

View file

@ -1,6 +1,6 @@
[package]
name = "gitcomet-core"
version = "0.1.0"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true

View file

@ -1,6 +1,6 @@
[package]
name = "gitcomet-git-gix"
version = "0.1.0"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true

View file

@ -3,7 +3,7 @@ use crate::util::{
bytes_to_text_preserving_utf8, run_git_capture, run_git_simple, run_git_with_output,
validate_ref_like_arg,
};
use gitcomet_core::domain::{Remote, RemoteBranch};
use gitcomet_core::domain::{Remote, RemoteBranch, Upstream};
use gitcomet_core::error::{Error, ErrorKind};
use gitcomet_core::services::{CommandOutput, PullMode, RemoteUrlKind, Result};
use gix::bstr::ByteSlice as _;
@ -147,22 +147,38 @@ impl GixRepo {
Ok(Some(head.to_string()))
}
fn branch_has_upstream(&self, branch: &str) -> Result<bool> {
validate_ref_like_arg(branch, "branch name")?;
fn branch_upstream(&self, branch_name: &str) -> Result<Option<Upstream>> {
validate_ref_like_arg(branch_name, "branch name")?;
let repo = self.reopen_repo()?;
let ref_name = format!("refs/heads/{branch}");
let ref_name = format!("refs/heads/{branch_name}");
let Some(reference) = repo
.try_find_reference(ref_name.as_str())
.map_err(|e| Error::new(ErrorKind::Backend(format!("gix try_find_reference: {e}"))))?
else {
return Ok(false);
return Ok(None);
};
Ok(matches!(
reference.remote_tracking_ref_name(gix::remote::Direction::Fetch),
Some(Ok(_))
))
let tracking_ref_name =
match reference.remote_tracking_ref_name(gix::remote::Direction::Fetch) {
Some(Ok(name)) => name,
Some(Err(_)) | None => return Ok(None),
};
let upstream_short = tracking_ref_name.shorten().to_str_lossy().into_owned();
let Some((remote, upstream_branch)) = parse_short_remote_branch_name(&upstream_short)
else {
return Ok(None);
};
Ok(Some(Upstream {
remote: remote.to_string(),
branch: upstream_branch.to_string(),
}))
}
fn branch_has_upstream(&self, branch: &str) -> Result<bool> {
Ok(self.branch_upstream(branch)?.is_some())
}
pub(super) fn list_remotes_impl(&self) -> Result<Vec<Remote>> {
@ -350,16 +366,51 @@ impl GixRepo {
run_git_command_with_optional_output(cmd, &command_label, capture_output)
}
fn push_head_to_branch_with_optional_output_impl(
&self,
remote: &str,
branch: &str,
force_with_lease: bool,
capture_output: bool,
) -> Result<CommandOutput> {
validate_ref_like_arg(remote, "remote name")?;
validate_ref_like_arg(branch, "branch name")?;
let command_label = if force_with_lease {
format!("git push --force-with-lease {remote} HEAD:refs/heads/{branch}")
} else {
format!("git push {remote} HEAD:refs/heads/{branch}")
};
let mut cmd = self.git_workdir_cmd();
cmd.arg("push");
if force_with_lease {
cmd.arg("--force-with-lease");
}
cmd.arg("--")
.arg(remote)
.arg(format!("HEAD:refs/heads/{branch}"));
run_git_command_with_optional_output(cmd, &command_label, capture_output)
}
fn push_with_optional_output_impl(&self, capture_output: bool) -> Result<CommandOutput> {
if let Some(branch) = self.current_branch_name()?
&& !self.branch_has_upstream(&branch)?
&& let Some(remote) = self.preferred_remote_name()?
{
return self.push_set_upstream_with_optional_output_impl(
&remote,
&branch,
capture_output,
);
if let Some(branch) = self.current_branch_name()? {
if let Some(upstream) = self.branch_upstream(&branch)? {
return self.push_head_to_branch_with_optional_output_impl(
&upstream.remote,
&upstream.branch,
false,
capture_output,
);
}
if let Some(remote) = self.preferred_remote_name()? {
return self.push_set_upstream_with_optional_output_impl(
&remote,
&branch,
capture_output,
);
}
}
let mut cmd = self.git_workdir_cmd();
@ -376,6 +427,17 @@ impl GixRepo {
}
fn push_force_with_optional_output_impl(&self, capture_output: bool) -> Result<CommandOutput> {
if let Some(branch) = self.current_branch_name()?
&& let Some(upstream) = self.branch_upstream(&branch)?
{
return self.push_head_to_branch_with_optional_output_impl(
&upstream.remote,
&upstream.branch,
true,
capture_output,
);
}
let mut cmd = self.git_workdir_cmd();
cmd.arg("push").arg("--force-with-lease");
run_git_command_with_optional_output(cmd, "git push --force-with-lease", capture_output)

View file

@ -201,8 +201,7 @@ fn require_git_local_push_for_remote_management_tests() -> bool {
true
}
fn init_repo_with_user(repo: &Path) {
run_git(repo, &["init"]);
fn configure_repo_with_user(repo: &Path) {
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
@ -210,6 +209,11 @@ fn init_repo_with_user(repo: &Path) {
run_git(repo, &["config", "core.eol", "lf"]);
}
fn init_repo_with_user(repo: &Path) {
run_git(repo, &["init"]);
configure_repo_with_user(repo);
}
#[test]
fn remote_add_set_url_and_remove_round_trip() {
let _guard = remote_management_test_lock();
@ -345,6 +349,64 @@ fn push_with_output_sets_upstream_when_missing() {
);
}
#[test]
fn push_with_output_uses_tracked_upstream_when_branch_names_differ() {
let _guard = remote_management_test_lock();
if !require_git_local_push_for_remote_management_tests() {
return;
}
let dir = tempfile::tempdir().expect("create tempdir");
let root = dir.path();
let remote_repo = root.join("remote.git");
let work_repo = root.join("work");
fs::create_dir_all(&remote_repo).expect("create remote repo dir");
fs::create_dir_all(&work_repo).expect("create work repo dir");
run_git(&remote_repo, &["init", "--bare", "-b", "main"]);
run_git(&work_repo, &["init", "-b", "main"]);
configure_repo_with_user(&work_repo);
let remote_str = git_remote_url(&remote_repo);
run_git(&work_repo, &["remote", "add", "origin", &remote_str]);
run_git(&work_repo, &["config", "push.default", "simple"]);
fs::write(work_repo.join("file.txt"), "base\n").expect("write base file");
run_git(&work_repo, &["add", "file.txt"]);
run_git(
&work_repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(&work_repo, &["push", "-u", "origin", "main"]);
run_git(&work_repo, &["checkout", "-b", "main2"]);
run_git(
&work_repo,
&["branch", "--set-upstream-to", "origin/main", "main2"],
);
fs::write(work_repo.join("file.txt"), "base\nnext\n").expect("write updated file");
run_git(&work_repo, &["add", "file.txt"]);
run_git(
&work_repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "next"],
);
let local_head = run_git_capture(&work_repo, &["rev-parse", "HEAD"])
.trim()
.to_string();
let backend = GixBackend;
let opened = backend.open(&work_repo).expect("open work repo");
let output = opened.push_with_output().expect("push tracked upstream");
assert_eq!(output.exit_code, Some(0));
let remote_head = run_git_capture(&remote_repo, &["rev-parse", "refs/heads/main"])
.trim()
.to_string();
assert_eq!(remote_head, local_head);
}
#[test]
fn delete_remote_branch_with_output_deletes_remote_and_tracking_ref() {
let _guard = remote_management_test_lock();
@ -508,7 +570,7 @@ fn push_force_without_output_updates_remote_head_after_rewrite() {
run_git(&remote_repo, &["init", "--bare", "-b", "main"]);
run_git(&work_repo, &["init", "-b", "main"]);
init_repo_with_user(&work_repo);
configure_repo_with_user(&work_repo);
let remote_str = git_remote_url(&remote_repo);
run_git(&work_repo, &["remote", "add", "origin", &remote_str]);
@ -556,6 +618,76 @@ fn push_force_without_output_updates_remote_head_after_rewrite() {
assert_eq!(remote_head_after, local_head);
}
#[test]
fn push_force_with_output_uses_tracked_upstream_when_branch_names_differ() {
let _guard = remote_management_test_lock();
if !require_git_local_push_for_remote_management_tests() {
return;
}
let dir = tempfile::tempdir().expect("create tempdir");
let root = dir.path();
let remote_repo = root.join("remote.git");
let work_repo = root.join("work");
fs::create_dir_all(&remote_repo).expect("create remote repo dir");
fs::create_dir_all(&work_repo).expect("create work repo dir");
run_git(&remote_repo, &["init", "--bare", "-b", "main"]);
run_git(&work_repo, &["init", "-b", "main"]);
configure_repo_with_user(&work_repo);
let remote_str = git_remote_url(&remote_repo);
run_git(&work_repo, &["remote", "add", "origin", &remote_str]);
run_git(&work_repo, &["config", "push.default", "simple"]);
fs::write(work_repo.join("file.txt"), "base\n").expect("write base file");
run_git(&work_repo, &["add", "file.txt"]);
run_git(
&work_repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(&work_repo, &["push", "-u", "origin", "main"]);
run_git(&work_repo, &["checkout", "-b", "main2"]);
run_git(
&work_repo,
&["branch", "--set-upstream-to", "origin/main", "main2"],
);
fs::write(work_repo.join("file.txt"), "base\nnext\n").expect("write updated file");
run_git(&work_repo, &["add", "file.txt"]);
run_git(
&work_repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "next"],
);
run_git(&work_repo, &["push", "origin", "HEAD:refs/heads/main"]);
run_git(&work_repo, &["fetch", "origin"]);
run_git(&work_repo, &["reset", "--hard", "HEAD~1"]);
fs::write(work_repo.join("file.txt"), "base\nrewritten\n").expect("write rewritten file");
run_git(&work_repo, &["add", "file.txt"]);
run_git(
&work_repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "rewritten"],
);
let local_head = run_git_capture(&work_repo, &["rev-parse", "HEAD"])
.trim()
.to_string();
let backend = GixBackend;
let opened = backend.open(&work_repo).expect("open work repo");
let output = opened
.push_force_with_output()
.expect("force push tracked upstream");
assert_eq!(output.exit_code, Some(0));
let remote_head = run_git_capture(&remote_repo, &["rev-parse", "refs/heads/main"])
.trim()
.to_string();
assert_eq!(remote_head, local_head);
}
#[test]
fn pull_non_output_supports_all_modes_when_upstream_exists() {
let _guard = remote_management_test_lock();
@ -574,7 +706,7 @@ fn pull_non_output_supports_all_modes_when_upstream_exists() {
run_git(&origin, &["init", "--bare", "-b", "main"]);
run_git(&repo_a, &["init", "-b", "main"]);
init_repo_with_user(&repo_a);
configure_repo_with_user(&repo_a);
fs::write(repo_a.join("a.txt"), "one\n").expect("write initial file");
run_git(&repo_a, &["add", "a.txt"]);
run_git(
@ -593,7 +725,7 @@ fn pull_non_output_supports_all_modes_when_upstream_exists() {
repo_b.to_string_lossy().as_ref(),
],
);
init_repo_with_user(&repo_b);
configure_repo_with_user(&repo_b);
fs::write(repo_a.join("a.txt"), "one\ntwo\n").expect("write updated file");
run_git(&repo_a, &["add", "a.txt"]);
@ -723,7 +855,7 @@ fn pull_branch_with_output_merges_named_remote_branch() {
run_git(&remote_repo, &["init", "--bare", "-b", "main"]);
run_git(&work_repo, &["init", "-b", "main"]);
init_repo_with_user(&work_repo);
configure_repo_with_user(&work_repo);
let remote_str = git_remote_url(&remote_repo);
run_git(&work_repo, &["remote", "add", "origin", &remote_str]);

View file

@ -1,6 +1,6 @@
[package]
name = "gitcomet-git"
version = "0.1.0"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true

View file

@ -1,6 +1,6 @@
[package]
name = "gitcomet-state"
version = "0.1.0"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true

View file

@ -684,7 +684,7 @@ mod tests {
"/tmp/target/debug/gitcomet_ui_gpui-3ad1b0fd3f0c0d3e"
)));
assert!(!looks_like_test_binary(Path::new(
"/tmp/target/debug/gitcomet-app"
"/tmp/target/debug/gitcomet"
)));
}

View file

@ -1,6 +1,6 @@
[package]
name = "gitcomet-ui-gpui"
version = "0.1.0"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true

View file

@ -1,4 +1,4 @@
//! Focused diff window for standalone `gitcomet-app difftool` invocation.
//! Focused diff window for standalone `gitcomet difftool` invocation.
//!
//! Opens a GPUI window that displays a unified diff with color-coded lines.
//! The user reviews the diff and closes the window (exit 0).

View file

@ -1601,6 +1601,11 @@ fn popover_closes_when_clicking_outside(cx: &mut gpui::TestAppContext) {
#[gpui::test]
fn titlebar_window_controls_update_tooltip_on_hover(cx: &mut gpui::TestAppContext) {
if cfg!(target_os = "macos") {
// The custom Min/Max/Close controls are only rendered on non-macOS.
return;
}
let (store, events) = AppStore::new(Arc::new(TestBackend));
let (view, cx) = cx.add_window_view(|window, cx| {
crate::view::GitCometView::new(store, events, None, window, cx)

View file

@ -293,9 +293,15 @@ fn annotate_change_hints(
changed_new_lines: &[bool],
) {
for row in &mut old_doc.rows {
if matches!(row.kind, MarkdownPreviewRowKind::Spacer) {
continue;
}
row.change_hint = line_range_change_hint(&row.source_line_range, changed_old_lines, true);
}
for row in &mut new_doc.rows {
if matches!(row.kind, MarkdownPreviewRowKind::Spacer) {
continue;
}
row.change_hint = line_range_change_hint(&row.source_line_range, changed_new_lines, false);
}
}
@ -397,13 +403,17 @@ fn push_aligned_markdown_row_groups(
}
fn markdown_preview_spacer_row() -> MarkdownPreviewRow {
markdown_preview_spacer_row_with_range(0..0)
}
fn markdown_preview_spacer_row_with_range(source_line_range: Range<usize>) -> MarkdownPreviewRow {
MarkdownPreviewRow {
kind: MarkdownPreviewRowKind::Spacer,
text: SharedString::from(""),
inline_spans: Arc::new(Vec::new()),
code_language: None,
code_block_horizontal_scroll_hint: false,
source_line_range: 0..0,
source_line_range,
change_hint: MarkdownChangeHint::None,
indent_level: 0,
blockquote_level: 0,
@ -1207,9 +1217,57 @@ fn flatten_to_rows(source: &str, line_starts: &[usize]) -> Option<Vec<MarkdownPr
}
align_table_columns(&mut rows);
insert_top_level_heading_spacer_rows(&mut rows);
Some(rows)
}
fn insert_top_level_heading_spacer_rows(rows: &mut Vec<MarkdownPreviewRow>) {
if rows.len() < 2 {
return;
}
let mut spaced_rows = Vec::with_capacity(rows.len() + rows.len() / 4);
let mut pending_gap_after_heading: Option<Range<usize>> = None;
for row in rows.drain(..) {
let is_top_level_heading = markdown_row_is_top_level_heading(&row);
if let Some(source_line_range) = pending_gap_after_heading.take()
&& !is_top_level_heading
{
spaced_rows.push(markdown_preview_spacer_row_with_range(source_line_range));
}
if is_top_level_heading {
let has_content_before_heading = matches!(
spaced_rows.last(),
Some(previous_row)
if !matches!(
previous_row.kind,
MarkdownPreviewRowKind::Spacer | MarkdownPreviewRowKind::Heading { .. }
)
);
if has_content_before_heading {
spaced_rows.push(markdown_preview_spacer_row_with_range(
row.source_line_range.clone(),
));
} else {
pending_gap_after_heading = Some(row.source_line_range.clone());
}
}
spaced_rows.push(row);
}
*rows = spaced_rows;
}
fn markdown_row_is_top_level_heading(row: &MarkdownPreviewRow) -> bool {
matches!(row.kind, MarkdownPreviewRowKind::Heading { .. })
&& row.indent_level == 0
&& row.blockquote_level == 0
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum HtmlHandling {
Ignore,
@ -1928,6 +1986,61 @@ mod tests {
assert_eq!(row_texts(&doc), vec!["H1", "H2", "H3", "H4", "H5", "H6"]);
}
#[test]
fn top_level_heading_inserts_section_spacer_before_following_content() {
let doc = parse("# Title\n\nParagraph\n");
assert_eq!(doc.rows.len(), 3);
assert_eq!(
doc.rows[0].kind,
MarkdownPreviewRowKind::Heading { level: 1 }
);
assert_eq!(doc.rows[1].kind, MarkdownPreviewRowKind::Spacer);
assert_eq!(doc.rows[1].change_hint, MarkdownChangeHint::None);
assert_eq!(doc.rows[2].kind, MarkdownPreviewRowKind::Paragraph);
}
#[test]
fn content_before_top_level_heading_gets_section_spacer_before_heading() {
let doc = parse("Paragraph\n\n# Title\n");
assert_eq!(doc.rows.len(), 3);
assert_eq!(doc.rows[0].kind, MarkdownPreviewRowKind::Paragraph);
assert_eq!(doc.rows[1].kind, MarkdownPreviewRowKind::Spacer);
assert_eq!(
doc.rows[2].kind,
MarkdownPreviewRowKind::Heading { level: 1 }
);
}
#[test]
fn consecutive_headings_do_not_insert_spacers_between_heading_rows() {
let doc = parse("# Title\n## Subtitle\n");
assert_eq!(
row_kinds(&doc),
vec![
&MarkdownPreviewRowKind::Heading { level: 1 },
&MarkdownPreviewRowKind::Heading { level: 2 },
]
);
}
#[test]
fn middle_heading_uses_a_single_section_spacer_before_the_heading() {
let doc = parse("Intro\n\n# Title\n\nBody\n");
assert_eq!(
row_kinds(&doc),
vec![
&MarkdownPreviewRowKind::Paragraph,
&MarkdownPreviewRowKind::Spacer,
&MarkdownPreviewRowKind::Heading { level: 1 },
&MarkdownPreviewRowKind::Paragraph,
]
);
}
// ── Paragraph tests ─────────────────────────────────────────────────
#[test]
@ -2665,6 +2778,29 @@ mod tests {
assert_eq!(new_para.change_hint, MarkdownChangeHint::Added);
}
#[test]
fn heading_spacing_rows_do_not_receive_change_hints() {
let preview =
build_markdown_diff_preview("# Title\n\nOld paragraph\n", "# Title\n\nNew paragraph\n")
.unwrap();
let old_spacer = preview
.old
.rows
.iter()
.find(|row| matches!(row.kind, MarkdownPreviewRowKind::Spacer))
.expect("expected heading spacer row on old side");
let new_spacer = preview
.new
.rows
.iter()
.find(|row| matches!(row.kind, MarkdownPreviewRowKind::Spacer))
.expect("expected heading spacer row on new side");
assert_eq!(old_spacer.change_hint, MarkdownChangeHint::None);
assert_eq!(new_spacer.change_hint, MarkdownChangeHint::None);
}
#[test]
fn partial_change_ranges_use_modified_hint() {
let (mut old_doc, mut new_doc) =
@ -3091,17 +3227,18 @@ code line
";
let doc = parse(src);
// Should have: Heading, Paragraph, ListItem, ListItem, CodeLine, ThematicBreak
// Should have: Heading, Spacer, Paragraph, ListItem, ListItem, CodeLine, ThematicBreak
assert!(
doc.rows.len() >= 6,
"expected at least 6 rows, got {}",
doc.rows.len() >= 7,
"expected at least 7 rows, got {}",
doc.rows.len()
);
assert!(matches!(
doc.rows[0].kind,
MarkdownPreviewRowKind::Heading { level: 1 }
));
assert_eq!(doc.rows[1].kind, MarkdownPreviewRowKind::Paragraph);
assert_eq!(doc.rows[1].kind, MarkdownPreviewRowKind::Spacer);
assert_eq!(doc.rows[2].kind, MarkdownPreviewRowKind::Paragraph);
}
// ── Internal helpers ────────────────────────────────────────────────
@ -3163,25 +3300,27 @@ code line
#[test]
fn custom_anchor_tags_are_hidden_from_preview() {
let doc = parse("# Section Heading\n\n<a name=\"my-custom-anchor-point\"></a>\nVisible\n");
assert_eq!(doc.rows.len(), 2);
assert_eq!(doc.rows.len(), 3);
assert_eq!(
doc.rows[0].kind,
MarkdownPreviewRowKind::Heading { level: 1 }
);
assert_eq!(doc.rows[0].text.as_ref(), "Section Heading");
assert_eq!(doc.rows[1].text.as_ref(), "Visible");
assert_eq!(doc.rows[1].kind, MarkdownPreviewRowKind::Spacer);
assert_eq!(doc.rows[2].text.as_ref(), "Visible");
}
#[test]
fn custom_anchor_id_tags_are_hidden_from_preview() {
let doc = parse("# Section Heading\n\n<a id=\"jump-target\"></a>\nVisible\n");
assert_eq!(doc.rows.len(), 2);
assert_eq!(doc.rows.len(), 3);
assert_eq!(
doc.rows[0].kind,
MarkdownPreviewRowKind::Heading { level: 1 }
);
assert_eq!(doc.rows[0].text.as_ref(), "Section Heading");
assert_eq!(doc.rows[1].text.as_ref(), "Visible");
assert_eq!(doc.rows[1].kind, MarkdownPreviewRowKind::Spacer);
assert_eq!(doc.rows[2].text.as_ref(), "Visible");
}
#[test]

View file

@ -1247,20 +1247,33 @@ fn whole_file_conflict_streamed_three_way_syntax_survives_view_mode_switch(
let _ = window.draw(app);
});
cx.update(|_window, app| {
let pane = view.read(app).main_pane.read(app);
let styled = conflict_split_cached_styled(
&pane,
crate::view::conflict_resolver::ConflictPickSide::Ours,
ours_body_line,
)
.expect("two-way draw should cache the streamed HTML body row after switching from three-way");
assert!(
!styled.highlights.is_empty(),
"streamed two-way rows above the old 20k line gate should stay syntax highlighted after switching from three-way; got {:?}",
styled_debug_info_with_styles(styled),
);
});
wait_for_main_pane_condition_with_timeout(
cx,
&view,
"streamed two-way HTML row cache after three-way switch",
BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
|pane| {
conflict_split_cached_styled(
pane,
crate::view::conflict_resolver::ConflictPickSide::Ours,
ours_body_line,
)
.is_some_and(|styled| !styled.highlights.is_empty())
},
|pane| {
let split_cached = conflict_split_cached_styled(
pane,
crate::view::conflict_resolver::ConflictPickSide::Ours,
ours_body_line,
)
.map(styled_debug_info_with_styles);
format!(
"split_cached={split_cached:?} split_cache_len={} three_way_cache_len={}",
pane.conflict_diff_segments_cache_split.len(),
pane.conflict_three_way_segments_cache.len(),
)
},
);
cx.update(|_window, app| {
view.update(app, |this, cx| {
@ -1276,18 +1289,28 @@ fn whole_file_conflict_streamed_three_way_syntax_survives_view_mode_switch(
let _ = window.draw(app);
});
cx.update(|_window, app| {
let pane = view.read(app).main_pane.read(app);
let styled = pane
.conflict_three_way_segments_cache
.get(&(2, ThreeWayColumn::Ours))
.expect("three-way draw should repopulate the streamed HTML body row cache after toggling back");
assert!(
!styled.highlights.is_empty(),
"streamed three-way rows above the old 20k line gate should stay syntax highlighted after toggling back; got {:?}",
styled_debug_info_with_styles(styled),
);
});
wait_for_main_pane_condition_with_timeout(
cx,
&view,
"streamed three-way HTML row cache after toggling back",
BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
|pane| {
pane.conflict_three_way_segments_cache
.get(&(2, ThreeWayColumn::Ours))
.is_some_and(|styled| !styled.highlights.is_empty())
},
|pane| {
let three_way_cached = pane
.conflict_three_way_segments_cache
.get(&(2, ThreeWayColumn::Ours))
.map(styled_debug_info_with_styles);
format!(
"three_way_cached={three_way_cached:?} split_cache_len={} three_way_cache_len={}",
pane.conflict_diff_segments_cache_split.len(),
pane.conflict_three_way_segments_cache.len(),
)
},
);
fixture.cleanup();
}
@ -3564,26 +3587,27 @@ fn large_conflict_resolved_output_renders_plain_text_then_upgrades_after_backgro
view.update(app, |this, cx| {
this.main_pane.update(cx, |pane, cx| {
pane.ensure_conflict_resolved_output_materialized(cx);
assert!(
pane.conflict_resolved_output_projection.is_none(),
"explicit materialization should drop the streamed projection immediately"
);
assert_eq!(
pane.conflict_resolved_preview_line_count, line_count,
"materialized preview should preserve the streamed output line count"
);
assert_eq!(
pane.conflict_resolved_preview_syntax_language,
Some(rows::DiffSyntaxLanguage::Rust),
"materialized resolved output should still keep the path-derived syntax language"
);
assert!(
pane.conflict_resolved_preview_prepared_syntax_document.is_none(),
"zero foreground budget should keep syntax preparation deferred immediately after materialization"
);
});
});
});
wait_for_main_pane_condition_with_timeout(
cx,
&view,
"large conflict resolved output materialized on demand",
BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
|pane| pane.conflict_resolved_output_projection.is_none(),
|pane| {
format!(
"projection_present={} line_count={} prepared_document={:?}",
pane.conflict_resolved_output_projection.is_some(),
pane.conflict_resolved_preview_line_count,
pane.conflict_resolved_preview_prepared_syntax_document,
)
},
);
cx.update(|window, app| {
let _ = window.draw(app);
let pane = view.read(app).main_pane.read(app);
@ -3600,10 +3624,6 @@ fn large_conflict_resolved_output_renders_plain_text_then_upgrades_after_backgro
Some(rows::DiffSyntaxLanguage::Rust),
"materialized resolved output should still keep the path-derived syntax language"
);
assert!(
pane.conflict_resolved_preview_prepared_syntax_document.is_none(),
"zero foreground budget should keep syntax preparation deferred immediately after materialization"
);
let styled = pane
.conflict_resolved_preview_segments_cache_get(target_ix)
.expect("materialized output draw should populate the visible fallback row cache");
@ -3612,10 +3632,6 @@ fn large_conflict_resolved_output_renders_plain_text_then_upgrades_after_backgro
comment_line,
"materialized row cache should preserve the expected resolved-output text"
);
assert!(
styled.highlights.is_empty(),
"materialized output should still render plain text until a later background parse upgrades it"
);
});
std::fs::remove_dir_all(&workdir).expect("cleanup conflict resolver fixture");

View file

@ -46,6 +46,7 @@ fn file_preview_renders_scrollable_syntax_highlighted_rows(cx: &mut gpui::TestAp
let (view, cx) = cx.add_window_view(|window, cx| {
super::super::GitCometView::new(store, events, None, window, cx)
});
disable_view_poller_for_test(cx, &view);
let repo_id = gitcomet_state::model::RepoId(1);
let workdir = std::env::temp_dir().join(format!("gitcomet_ui_test_{}", std::process::id()));
@ -104,32 +105,40 @@ fn file_preview_renders_scrollable_syntax_highlighted_rows(cx: &mut gpui::TestAp
let _ = window.draw(app);
});
cx.update(|_window, app| {
let main_pane = view.read(app).main_pane.clone();
let pane = main_pane.read(app);
let max_offset = pane
.worktree_preview_scroll
.0
.borrow()
.base_handle
.max_offset();
assert!(
max_offset.height > px(0.0),
"expected file preview to overflow and be scrollable"
);
assert!(
max_offset.width > px(0.0),
"expected file preview to overflow horizontally"
);
let Some(styled) = pane.worktree_preview_segments_cache_get(0) else {
panic!("expected first visible preview row to populate segment cache");
};
assert!(
!styled.highlights.is_empty(),
"expected syntax highlighting highlights for preview row"
);
});
wait_for_main_pane_condition_with_timeout(
cx,
&view,
"file preview first visible row syntax cache",
BACKGROUND_SYNTAX_MAIN_PANE_WAIT_TIMEOUT,
|pane| {
let max_offset = pane
.worktree_preview_scroll
.0
.borrow()
.base_handle
.max_offset();
max_offset.height > px(0.0)
&& max_offset.width > px(0.0)
&& pane
.worktree_preview_segments_cache_get(0)
.is_some_and(|styled| !styled.highlights.is_empty())
},
|pane| {
let max_offset = pane
.worktree_preview_scroll
.0
.borrow()
.base_handle
.max_offset();
let row_cache = pane
.worktree_preview_segments_cache_get(0)
.map(styled_debug_info_with_styles);
format!(
"max_offset={max_offset:?} style_epoch={} cache_path={:?} row_cache={row_cache:?}",
pane.worktree_preview_style_cache_epoch, pane.worktree_preview_segments_cache_path,
)
},
);
let _ = std::fs::remove_dir_all(&workdir);
}

View file

@ -324,6 +324,333 @@ fn ctrl_f_from_markdown_file_preview_switches_back_to_text_search(cx: &mut gpui:
std::fs::remove_dir_all(&workdir).expect("cleanup markdown preview fixture");
}
#[gpui::test]
fn split_markdown_diff_uses_preview_level_horizontal_overflow_without_local_code_scrollbar(
cx: &mut gpui::TestAppContext,
) {
let _visual_guard = lock_visual_test();
let (store, events) = AppStore::new(Arc::new(TestBackend));
let (view, cx) = cx.add_window_view(|window, cx| {
super::super::GitCometView::new(store, events, None, window, cx)
});
disable_view_poller_for_test(cx, &view);
let repo_id = gitcomet_state::model::RepoId(71);
let workdir = std::env::temp_dir().join(format!(
"gitcomet_ui_test_{}_markdown_code_block_scrollbar",
std::process::id()
));
let file_rel = std::path::PathBuf::from("docs/overflow.md");
let long_code = "0123456789".repeat(8);
let old_text = "# Guide\n\n```rust\nlet value = 1;\n}\n```\n".to_string();
let new_text = format!("# Guide\n\n```rust\n{long_code}\n}}\n```\n");
let target = gitcomet_core::domain::DiffTarget::WorkingTree {
path: file_rel.clone(),
area: gitcomet_core::domain::DiffArea::Unstaged,
};
let _ = std::fs::remove_dir_all(&workdir);
std::fs::create_dir_all(&workdir).expect("create markdown code block diff workdir");
seed_file_diff_state(
cx, &view, repo_id, &workdir, &file_rel, &old_text, &new_text,
);
wait_for_main_pane_condition(
cx,
&view,
"markdown code block diff target activation",
|pane| {
pane.active_repo()
.and_then(|repo| repo.diff_state.diff_target.clone())
== Some(target.clone())
},
|pane| {
format!(
"active_repo={:?} diff_target={:?}",
pane.active_repo().map(|repo| repo.id),
pane.active_repo()
.and_then(|repo| repo.diff_state.diff_target.clone()),
)
},
);
cx.update(|_window, app| {
view.update(app, |this, cx| {
this.main_pane.update(cx, |pane, cx| {
pane.diff_view = DiffViewMode::Split;
pane.rendered_preview_modes
.set(RenderedPreviewKind::Markdown, RenderedPreviewMode::Rendered);
pane.file_markdown_preview_cache_repo_id = Some(repo_id);
pane.file_markdown_preview_cache_rev = 1;
pane.file_markdown_preview_cache_target = Some(target.clone());
pane.file_markdown_preview = gitcomet_state::model::Loadable::Ready(Arc::new(
crate::view::markdown_preview::build_markdown_diff_preview(
&old_text, &new_text,
)
.expect("markdown diff preview with overflowing code block should parse"),
));
pane.file_markdown_preview_inflight = None;
cx.notify();
});
});
});
for _ in 0..3 {
cx.update(|window, app| {
let _ = window.draw(app);
});
cx.run_until_parked();
}
cx.update(|_window, app| {
let pane = view.read(app).main_pane.read(app);
assert!(pane.is_markdown_preview_active());
assert!(
pane.diff_split_right_scroll
.0
.borrow()
.base_handle
.max_offset()
.width
> px(0.0),
"expected split markdown preview to overflow horizontally for the 80-character code line"
);
});
assert!(
cx.debug_bounds("markdown_preview_code_block_hscrollbar")
.is_none(),
"expected overflowing markdown preview code blocks to rely on preview-level horizontal scrolling, not a local code-block scrollbar"
);
std::fs::remove_dir_all(&workdir).expect("cleanup markdown code block diff workdir");
}
#[gpui::test]
fn worktree_markdown_preview_short_code_block_shell_spans_preview_width(
cx: &mut gpui::TestAppContext,
) {
let _visual_guard = lock_visual_test();
let (store, events) = AppStore::new(Arc::new(TestBackend));
let (view, cx) = cx.add_window_view(|window, cx| {
super::super::GitCometView::new(store, events, None, window, cx)
});
disable_view_poller_for_test(cx, &view);
let repo_id = gitcomet_state::model::RepoId(72);
let workdir = std::env::temp_dir().join(format!(
"gitcomet_ui_test_{}_markdown_code_block_width",
std::process::id()
));
let file_rel = std::path::PathBuf::from("docs/snippet.md");
let abs_path = workdir.join(&file_rel);
let source = "```sh\necho hi\n```\n";
let preview_lines = Arc::new(source.lines().map(ToOwned::to_owned).collect::<Vec<_>>());
let target = gitcomet_core::domain::DiffTarget::WorkingTree {
path: file_rel.clone(),
area: gitcomet_core::domain::DiffArea::Unstaged,
};
let _ = std::fs::remove_dir_all(&workdir);
std::fs::create_dir_all(abs_path.parent().expect("fixture parent dir"))
.expect("create markdown code block width workdir");
std::fs::write(&abs_path, source).expect("write markdown code block width fixture");
cx.update(|_window, app| {
view.update(app, |this, cx| {
let mut repo = opening_repo_state(repo_id, &workdir);
set_test_file_status(
&mut repo,
file_rel.clone(),
gitcomet_core::domain::FileStatusKind::Untracked,
gitcomet_core::domain::DiffArea::Unstaged,
);
let next_state = app_state_with_repo(repo, repo_id);
push_test_state(this, next_state, cx);
});
});
wait_for_main_pane_condition(
cx,
&view,
"worktree markdown code block width target activation",
|pane| {
pane.active_repo()
.and_then(|repo| repo.diff_state.diff_target.clone())
== Some(target.clone())
},
|pane| {
format!(
"active_repo={:?} diff_target={:?}",
pane.active_repo().map(|repo| repo.id),
pane.active_repo()
.and_then(|repo| repo.diff_state.diff_target.clone()),
)
},
);
cx.update(|_window, app| {
view.update(app, |this, cx| {
this.main_pane.update(cx, |pane, cx| {
set_ready_worktree_preview(
pane,
abs_path.clone(),
Arc::clone(&preview_lines),
source.len(),
cx,
);
pane.rendered_preview_modes
.set(RenderedPreviewKind::Markdown, RenderedPreviewMode::Rendered);
pane.worktree_markdown_preview_path = Some(abs_path.clone());
pane.worktree_markdown_preview_source_rev = pane.worktree_preview_content_rev;
pane.worktree_markdown_preview = gitcomet_state::model::Loadable::Ready(Arc::new(
crate::view::markdown_preview::parse_markdown(source)
.expect("short fenced markdown preview should parse"),
));
pane.worktree_markdown_preview_inflight = None;
cx.notify();
});
});
});
for _ in 0..3 {
cx.update(|window, app| {
let _ = window.draw(app);
});
cx.run_until_parked();
}
let container_bounds = cx
.debug_bounds("worktree_markdown_preview_scroll_container")
.expect("expected worktree markdown preview container bounds");
let code_shell_bounds = cx
.debug_bounds("markdown_preview_code_shell_0")
.expect("expected code shell bounds for the first markdown preview row");
let width_ratio = code_shell_bounds.size.width / container_bounds.size.width;
assert!(
width_ratio >= 0.95,
"expected short fenced code block shell to span preview width; ratio={width_ratio}, shell={code_shell_bounds:?}, container={container_bounds:?}"
);
std::fs::remove_dir_all(&workdir).expect("cleanup markdown code block width workdir");
}
#[gpui::test]
fn worktree_markdown_preview_list_text_box_stays_shorter_than_row_shell(
cx: &mut gpui::TestAppContext,
) {
let _visual_guard = lock_visual_test();
let (store, events) = AppStore::new(Arc::new(TestBackend));
let (view, cx) = cx.add_window_view(|window, cx| {
super::super::GitCometView::new(store, events, None, window, cx)
});
disable_view_poller_for_test(cx, &view);
let repo_id = gitcomet_state::model::RepoId(73);
let workdir = std::env::temp_dir().join(format!(
"gitcomet_ui_test_{}_markdown_list_selection_box",
std::process::id()
));
let file_rel = std::path::PathBuf::from("docs/list.md");
let abs_path = workdir.join(&file_rel);
let source = "- first item\n";
let preview_lines = Arc::new(source.lines().map(ToOwned::to_owned).collect::<Vec<_>>());
let target = gitcomet_core::domain::DiffTarget::WorkingTree {
path: file_rel.clone(),
area: gitcomet_core::domain::DiffArea::Unstaged,
};
let _ = std::fs::remove_dir_all(&workdir);
std::fs::create_dir_all(abs_path.parent().expect("fixture parent dir"))
.expect("create markdown list workdir");
std::fs::write(&abs_path, source).expect("write markdown list fixture");
cx.update(|_window, app| {
view.update(app, |this, cx| {
let mut repo = opening_repo_state(repo_id, &workdir);
set_test_file_status(
&mut repo,
file_rel.clone(),
gitcomet_core::domain::FileStatusKind::Untracked,
gitcomet_core::domain::DiffArea::Unstaged,
);
let next_state = app_state_with_repo(repo, repo_id);
push_test_state(this, next_state, cx);
});
});
wait_for_main_pane_condition(
cx,
&view,
"worktree markdown list target activation",
|pane| {
pane.active_repo()
.and_then(|repo| repo.diff_state.diff_target.clone())
== Some(target.clone())
},
|pane| {
format!(
"active_repo={:?} diff_target={:?}",
pane.active_repo().map(|repo| repo.id),
pane.active_repo()
.and_then(|repo| repo.diff_state.diff_target.clone()),
)
},
);
cx.update(|_window, app| {
view.update(app, |this, cx| {
this.main_pane.update(cx, |pane, cx| {
set_ready_worktree_preview(
pane,
abs_path.clone(),
Arc::clone(&preview_lines),
source.len(),
cx,
);
pane.rendered_preview_modes
.set(RenderedPreviewKind::Markdown, RenderedPreviewMode::Rendered);
pane.worktree_markdown_preview_path = Some(abs_path.clone());
pane.worktree_markdown_preview_source_rev = pane.worktree_preview_content_rev;
pane.worktree_markdown_preview = gitcomet_state::model::Loadable::Ready(Arc::new(
crate::view::markdown_preview::parse_markdown(source)
.expect("markdown list preview should parse"),
));
pane.worktree_markdown_preview_inflight = None;
cx.notify();
});
});
});
for _ in 0..3 {
cx.update(|window, app| {
let _ = window.draw(app);
});
cx.run_until_parked();
}
let row_bounds = cx
.debug_bounds("markdown_preview_row_box_0")
.expect("expected list row shell bounds");
let text_bounds = cx
.debug_bounds("markdown_preview_text_box_0")
.expect("expected list row text box bounds");
assert!(
text_bounds.size.height < row_bounds.size.height,
"expected markdown list text box to stay shorter than its row shell so selection matches the text height; text={text_bounds:?}, row={row_bounds:?}"
);
assert!(
row_bounds.size.height <= text_bounds.size.height + px(12.0),
"expected markdown list rows to keep only a small vertical gap around the text; text={text_bounds:?}, row={row_bounds:?}"
);
std::fs::remove_dir_all(&workdir).expect("cleanup markdown list fixture");
}
#[gpui::test]
fn ctrl_f_from_conflict_markdown_preview_switches_back_to_text_search(
cx: &mut gpui::TestAppContext,

View file

@ -1045,8 +1045,6 @@ impl MarkdownPreviewFixture {
theme: self.theme,
bar_color: None,
min_width: px(0.0),
row_id_prefix: "benchmark_markdown_preview",
horizontal_scroll_handle: None,
view: None,
text_region: DiffTextRegion::Inline,
},

View file

@ -154,32 +154,36 @@ fn render_conflict_markdown_preview_rows(
return Vec::new();
};
let document = Arc::clone(document);
let (row_id_prefix, horizontal_scroll_handle) = match side {
ThreeWayColumn::Base => (
"conflict_markdown_preview_base",
let viewport_width = match side {
ThreeWayColumn::Base => {
this.conflict_resolver_diff_scroll
.0
.borrow()
.base_handle
.clone(),
),
ThreeWayColumn::Ours => (
"conflict_markdown_preview_ours",
.bounds()
.size
.width
}
ThreeWayColumn::Ours => {
this.conflict_preview_ours_scroll
.0
.borrow()
.base_handle
.clone(),
),
ThreeWayColumn::Theirs => (
"conflict_markdown_preview_theirs",
.bounds()
.size
.width
}
ThreeWayColumn::Theirs => {
this.conflict_preview_theirs_scroll
.0
.borrow()
.base_handle
.clone(),
),
};
.bounds()
.size
.width
}
}
.max(px(0.0));
this.update_markdown_preview_horizontal_min_width(
document.as_ref(),
range.clone(),
@ -193,9 +197,7 @@ fn render_conflict_markdown_preview_rows(
&super::history::MarkdownPreviewRenderContext {
theme,
bar_color: None,
min_width: this.diff_horizontal_min_width,
row_id_prefix,
horizontal_scroll_handle: Some(horizontal_scroll_handle),
min_width: this.diff_horizontal_min_width.max(viewport_width),
view: None,
text_region: DiffTextRegion::Inline,
},

View file

@ -1,122 +0,0 @@
; CSS highlights query — adapted from Zed (crates/languages/src/css/highlights.scm)
; Provenance: Zed editor project, Apache-2.0 / MIT licensed
(comment) @comment
[
(tag_name)
(nesting_selector)
(universal_selector)
] @tag
[
"~"
">"
"+"
"-"
"|"
"*"
"/"
"="
"^="
"|="
"~="
"$="
"*="
] @operator
[
"and"
"or"
"not"
"only"
] @keyword.operator
(id_name) @selector.id
(class_name) @selector.class
(namespace_name) @namespace
(namespace_selector
(tag_name) @namespace
"|")
(attribute_name) @attribute
(pseudo_element_selector
"::"
(tag_name) @selector.pseudo)
(pseudo_class_selector
":"
(class_name) @selector.pseudo)
[
(feature_name)
(property_name)
] @property
(function_name) @function
[
(plain_value)
(keyframes_name)
(keyword_query)
] @constant.builtin
(attribute_selector
(plain_value) @string)
(parenthesized_query
(keyword_query) @property)
([
(property_name)
(plain_value)
] @variable
(#match? @variable "^--"))
[
"@media"
"@import"
"@charset"
"@namespace"
"@supports"
"@keyframes"
(at_keyword)
(to)
(from)
(important)
] @keyword
(string_value) @string
(color_value) @string.special
[
(integer_value)
(float_value)
] @number
(unit) @type
[
","
":"
"."
"::"
";"
] @punctuation.delimiter
(id_selector
"#" @punctuation.delimiter)
[
"{"
")"
"("
"}"
"["
"]"
] @punctuation.bracket

View file

@ -1,27 +1,17 @@
; Vendored from Zed (zed/extensions/html/languages/html/highlights.scm)
; Derived from
; gpui-component/crates/ui/src/highlighter/languages/html/highlights.scm
; (Apache-2.0).
(tag_name) @tag
(doctype) @tag.doctype
(erroneous_end_tag_name) @tag.error
(doctype) @constant
(attribute_name) @attribute
[
"\""
"'"
(attribute_value)
] @string
(attribute_value) @string
(comment) @comment
(entity) @string.special
"=" @punctuation.delimiter
[
"<"
">"
"<!"
"</"
"/>"
] @punctuation.bracket

View file

@ -1,15 +1,14 @@
; Vendored from Zed (zed/extensions/html/languages/html/injections.scm)
; Derived from
; gpui-component/crates/ui/src/highlighter/languages/html/injections.scm
; (Apache-2.0). Local additions preserve inline `style=` and `on*=` injections.
((comment) @injection.content
(#set! injection.language "comment"))
((script_element
(raw_text) @injection.content)
(#set! injection.language "javascript"))
(script_element
(raw_text) @injection.content
(#set! injection.language "javascript"))
(style_element
(raw_text) @injection.content
(#set! injection.language "css"))
((style_element
(raw_text) @injection.content)
(#set! injection.language "css"))
(attribute
(attribute_name) @_attribute_name

View file

@ -1,24 +1,28 @@
; Vendored JavaScript highlights query for tree-sitter-javascript.
; Based on upstream tree-sitter-javascript queries/highlights.scm and
; queries/highlights-jsx.scm, with keyword classification enhancements
; from Zed's JavaScript query assets.
; Derived from
; gpui-component/crates/ui/src/highlighter/languages/javascript/highlights.scm
; (Apache-2.0). Local additions preserve parameter highlighting and JSX-in-.js
; support in GitComet diffs.
; Variables
;----------
(identifier) @variable
; Properties
;-----------
(property_identifier) @property
(shorthand_property_identifier) @property
(shorthand_property_identifier_pattern) @property
(private_property_identifier) @property
; Function and method definitions
;--------------------------------
(function_expression
name: (identifier) @function)
(function_declaration
name: (identifier) @function)
(method_definition
name: (property_identifier) @function.method)
@ -44,6 +48,8 @@
right: [(function_expression) (arrow_function)])
; Function and method calls
;--------------------------
(call_expression
function: (identifier) @function)
@ -55,6 +61,8 @@
constructor: (identifier) @type)
; Parameters
;-----------
(arrow_function
parameter: (identifier) @variable.parameter)
@ -62,31 +70,43 @@
parameter: (identifier) @variable.parameter)
; Special identifiers
;--------------------
((identifier) @type
(#match? @type "^[A-Z]"))
([
(identifier)
(shorthand_property_identifier)
(shorthand_property_identifier_pattern)
] @constant
(#match? @constant "^[A-Z_][A-Z\\d_]+$"))
(#match? @constant "^_*[A-Z_][A-Z\\d_]*$"))
((identifier) @constructor
(#match? @constructor "^[A-Z]"))
((identifier) @variable.special
(#match? @variable.special "^(arguments|module|console|window|document)$")
(#is-not? local))
((identifier) @function.special
(#eq? @function.special "require")
(#is-not? local))
; Literals
;---------
(this) @variable.special
(super) @variable.special
[
(null)
(undefined)
] @constant.builtin
[
(true)
(false)
] @boolean
[
(null)
(undefined)
] @constant.builtin
(comment) @comment
(hash_bang_line) @comment
@ -98,11 +118,13 @@
(escape_sequence) @string.escape
(regex) @string.regex
(regex) @string.special
(number) @number
; Tokens
;-------
[
";"
(optional_chain)
@ -159,9 +181,6 @@
"..."
] @operator
(regex
"/" @string.regex)
[
"("
")"
@ -177,62 +196,49 @@
":"
] @operator)
; Keywords — split into declaration / import / control for richer styling
[
"as"
"async"
"await"
"debugger"
"default"
"delete"
"extends"
"get"
"in"
"instanceof"
"new"
"of"
"set"
"static"
"target"
"typeof"
"void"
"with"
] @keyword
[
"const"
"let"
"var"
"function"
"class"
] @keyword.declaration
[
"export"
"from"
"import"
] @keyword.import
[
"break"
"case"
"catch"
"class"
"const"
"continue"
"debugger"
"default"
"delete"
"do"
"else"
"export"
"extends"
"finally"
"for"
"from"
"function"
"get"
"if"
"import"
"in"
"instanceof"
"let"
"new"
"of"
"return"
"set"
"static"
"switch"
"target"
"throw"
"try"
"typeof"
"var"
"void"
"while"
"with"
"yield"
] @keyword.control
(switch_default
"default" @keyword.control)
] @keyword
(template_substitution
"${" @punctuation.special

View file

@ -1,5 +1,6 @@
; Vendored from Zed (zed/crates/languages/src/rust/highlights.scm)
; Provides richer capture semantics than tree-sitter-rust crate defaults.
; Derived from
; gpui-component/crates/ui/src/highlighter/languages/rust/highlights.scm
; (Apache-2.0). Local additions preserve GitComet's richer diff token classes.
(identifier) @variable
@ -32,6 +33,39 @@
(trait_bounds
(type_identifier) @type.interface)
; Identifier conventions
((identifier) @constant
(#match? @constant "^_*[A-Z][A-Z\\d_]*$"))
((identifier) @type
(#match? @type "^[A-Z]"))
((scoped_identifier
path: (identifier) @type)
(#match? @type "^[A-Z]"))
((scoped_identifier
path: (scoped_identifier
name: (identifier) @type))
(#match? @type "^[A-Z]"))
((scoped_type_identifier
path: (identifier) @type)
(#match? @type "^[A-Z]"))
((scoped_type_identifier
path: (scoped_identifier
name: (identifier) @type))
(#match? @type "^[A-Z]"))
(struct_pattern
type: (scoped_type_identifier
name: (type_identifier) @type))
(enum_variant
name: (identifier) @type)
; Function calls
(call_expression
function: [
(identifier) @function
@ -50,12 +84,14 @@
field: (field_identifier) @function.method)
])
; Function definitions
(function_item
name: (identifier) @function.definition)
(function_signature_item
name: (identifier) @function.definition)
; Macros
(macro_invocation
macro: [
(identifier) @function.special
@ -69,18 +105,17 @@
(macro_definition
name: (identifier) @function.special.definition)
; Identifier conventions
; Assume uppercase names are types/enum-constructors
((identifier) @type
(#match? @type "^[A-Z]"))
[
(line_comment)
(block_comment)
] @comment
; Assume all-caps names are constants
((identifier) @constant
(#match? @constant "^_*[A-Z][A-Z\\d_]*$"))
; Ensure enum variants are highlighted correctly regardless of naming convention
(enum_variant
name: (identifier) @type)
[
(line_comment
(doc_comment))
(block_comment
(doc_comment))
] @comment.doc
[
"("
@ -91,8 +126,11 @@
"]"
] @punctuation.bracket
(_
.
(type_arguments
"<" @punctuation.bracket
">" @punctuation.bracket)
(type_parameters
"<" @punctuation.bracket
">" @punctuation.bracket)
@ -168,18 +206,6 @@
(boolean_literal) @boolean
[
(line_comment)
(block_comment)
] @comment
[
(line_comment
(doc_comment))
(block_comment
(doc_comment))
] @comment.doc
[
"!="
"%"
@ -219,7 +245,6 @@
"?"
] @operator
; Avoid highlighting these as operators when used in doc comments.
(unary_expression
"!" @operator)
@ -232,32 +257,6 @@ operator: "/" @operator
(parameter
(identifier) @variable.parameter)
(attribute_item
(attribute
[
(identifier) @attribute
(scoped_identifier
name: (identifier) @attribute)
(token_tree
(identifier) @attribute
(#match? @attribute "^[a-z\\d_]*$"))
(token_tree
(identifier) @none
"::"
(#match? @none "^[a-z\\d_]*$"))
]))
(attribute_item) @attribute
(inner_attribute_item
(attribute
[
(identifier) @attribute
(scoped_identifier
name: (identifier) @attribute)
(token_tree
(identifier) @attribute
(#match? @attribute "^[a-z\\d_]*$"))
(token_tree
(identifier) @none
"::"
(#match? @none "^[a-z\\d_]*$"))
]))
(inner_attribute_item) @attribute

View file

@ -0,0 +1,13 @@
; Derived from
; gpui-component/crates/ui/src/highlighter/languages/rust/injections.scm
; (Apache-2.0).
((macro_invocation
(token_tree) @injection.content)
(#set! injection.language "rust")
(#set! injection.include-children))
((macro_rule
(token_tree) @injection.content)
(#set! injection.language "rust")
(#set! injection.include-children))

View file

@ -1,3 +1,8 @@
; Derived from
; gpui-component/crates/ui/src/highlighter/languages/typescript/highlights.scm
; (Apache-2.0). Local additions preserve JSX and richer TypeScript diff
; highlighting in GitComet.
; Variables
(identifier) @variable
@ -156,6 +161,14 @@
] @constant
(#match? @constant "^_*[A-Z_][A-Z\\d_]*$"))
((identifier) @variable.special
(#match? @variable.special "^(arguments|module|console|window|document)$")
(#is-not? local))
((identifier) @function.special
(#eq? @function.special "require")
(#is-not? local))
; Literals
(this) @variable.special

View file

@ -1,3 +1,8 @@
; Derived from
; gpui-component/crates/ui/src/highlighter/languages/typescript/highlights.scm
; (Apache-2.0). Local additions preserve richer TypeScript snippet and diff
; highlighting in GitComet.
; Variables
(identifier) @variable
@ -102,6 +107,14 @@
] @constant
(#match? @constant "^_*[A-Z_][A-Z\\d_]*$"))
((identifier) @variable.special
(#match? @variable.special "^(arguments|module|console|window|document)$")
(#is-not? local))
((identifier) @function.special
(#eq? @function.special "require")
(#is-not? local))
; Properties
(property_identifier) @property

View file

@ -25,7 +25,7 @@ const HTML_HIGHLIGHTS_QUERY: &str = include_str!("queries/html_highlights.scm");
#[cfg(feature = "syntax-web")]
const HTML_INJECTIONS_QUERY: &str = include_str!("queries/html_injections.scm");
#[cfg(feature = "syntax-web")]
const CSS_HIGHLIGHTS_QUERY: &str = include_str!("queries/css_highlights.scm");
const CSS_HIGHLIGHTS_QUERY: &str = tree_sitter_css::HIGHLIGHTS_QUERY;
#[cfg(feature = "syntax-web")]
const JAVASCRIPT_HIGHLIGHTS_QUERY: &str = include_str!("queries/javascript_highlights.scm");
#[cfg(feature = "syntax-web")]
@ -34,6 +34,8 @@ const TYPESCRIPT_HIGHLIGHTS_QUERY: &str = include_str!("queries/typescript_highl
const TSX_HIGHLIGHTS_QUERY: &str = include_str!("queries/tsx_highlights.scm");
#[cfg(feature = "syntax-rust")]
const RUST_HIGHLIGHTS_QUERY: &str = include_str!("queries/rust_highlights.scm");
#[cfg(feature = "syntax-rust")]
const RUST_INJECTIONS_QUERY: &str = include_str!("queries/rust_injections.scm");
#[cfg(feature = "syntax-xml")]
const XML_HIGHLIGHTS_QUERY: &str = tree_sitter_xml::XML_HIGHLIGHT_QUERY;
@ -1929,7 +1931,6 @@ impl TreesitterQueryAsset {
}
}
#[cfg(feature = "syntax-web")]
const fn with_injections(highlights: &'static str, injections: &'static str) -> Self {
Self {
highlights,
@ -2734,7 +2735,7 @@ fn tree_sitter_grammar(
#[cfg(feature = "syntax-rust")]
DiffSyntaxLanguage::Rust => Some((
tree_sitter_rust::LANGUAGE.into(),
TreesitterQueryAsset::highlights(RUST_HIGHLIGHTS_QUERY),
TreesitterQueryAsset::with_injections(RUST_HIGHLIGHTS_QUERY, RUST_INJECTIONS_QUERY),
)),
#[cfg(feature = "syntax-python")]
DiffSyntaxLanguage::Python => Some((

View file

@ -105,7 +105,15 @@ impl MainPaneView {
};
let document = Arc::clone(document);
let bar_color = worktree_preview_bar_color(this, theme);
let horizontal_scroll_handle = this.worktree_preview_scroll.0.borrow().base_handle.clone();
let viewport_width = this
.worktree_preview_scroll
.0
.borrow()
.base_handle
.bounds()
.size
.width
.max(px(0.0));
this.update_markdown_preview_horizontal_min_width(
document.as_ref(),
range.clone(),
@ -119,9 +127,7 @@ impl MainPaneView {
&MarkdownPreviewRenderContext {
theme,
bar_color,
min_width: this.diff_horizontal_min_width,
row_id_prefix: "worktree_markdown_preview",
horizontal_scroll_handle: Some(horizontal_scroll_handle),
min_width: this.diff_horizontal_min_width.max(viewport_width),
view: Some(cx.entity().clone()),
text_region: DiffTextRegion::Inline,
},
@ -139,7 +145,15 @@ impl MainPaneView {
return Vec::new();
};
let preview = Arc::clone(preview);
let horizontal_scroll_handle = this.diff_scroll.0.borrow().base_handle.clone();
let viewport_width = this
.diff_scroll
.0
.borrow()
.base_handle
.bounds()
.size
.width
.max(px(0.0));
this.update_markdown_preview_horizontal_min_width(
&preview.old,
range.clone(),
@ -157,9 +171,7 @@ impl MainPaneView {
&MarkdownPreviewRenderContext {
theme,
bar_color: None,
min_width: this.diff_horizontal_min_width,
row_id_prefix: "diff_markdown_preview_left",
horizontal_scroll_handle: Some(horizontal_scroll_handle),
min_width: this.diff_horizontal_min_width.max(viewport_width),
view: Some(cx.entity().clone()),
text_region: region,
},
@ -177,7 +189,15 @@ impl MainPaneView {
return Vec::new();
};
let preview = Arc::clone(preview);
let horizontal_scroll_handle = this.diff_scroll.0.borrow().base_handle.clone();
let viewport_width = this
.diff_scroll
.0
.borrow()
.base_handle
.bounds()
.size
.width
.max(px(0.0));
this.update_markdown_preview_horizontal_min_width(
&preview.inline,
range.clone(),
@ -191,9 +211,7 @@ impl MainPaneView {
&MarkdownPreviewRenderContext {
theme,
bar_color: None,
min_width: this.diff_horizontal_min_width,
row_id_prefix: "diff_markdown_preview_inline",
horizontal_scroll_handle: Some(horizontal_scroll_handle),
min_width: this.diff_horizontal_min_width.max(viewport_width),
view: Some(cx.entity().clone()),
text_region: DiffTextRegion::Inline,
},
@ -211,7 +229,15 @@ impl MainPaneView {
return Vec::new();
};
let preview = Arc::clone(preview);
let horizontal_scroll_handle = this.diff_split_right_scroll.0.borrow().base_handle.clone();
let viewport_width = this
.diff_split_right_scroll
.0
.borrow()
.base_handle
.bounds()
.size
.width
.max(px(0.0));
this.update_markdown_preview_horizontal_min_width(
&preview.new,
range.clone(),
@ -225,9 +251,7 @@ impl MainPaneView {
&MarkdownPreviewRenderContext {
theme,
bar_color: None,
min_width: this.diff_horizontal_min_width,
row_id_prefix: "diff_markdown_preview_right",
horizontal_scroll_handle: Some(horizontal_scroll_handle),
min_width: this.diff_horizontal_min_width.max(viewport_width),
view: Some(cx.entity().clone()),
text_region: DiffTextRegion::SplitRight,
},
@ -257,10 +281,11 @@ impl MainPaneView {
}
}
const MARKDOWN_PREVIEW_ROW_HEIGHT_PX: f32 = 44.0;
const MARKDOWN_PREVIEW_ROW_HEIGHT_PX: f32 = 28.0;
const MARKDOWN_PREVIEW_BASE_FONT_PX: f32 = 13.0;
const MARKDOWN_PREVIEW_BASE_LINE_HEIGHT_PX: f32 = 22.0;
const MARKDOWN_PREVIEW_BASE_LINE_HEIGHT_PX: f32 = 20.0;
const MARKDOWN_PREVIEW_CONTENT_PAD_X_PX: f32 = 18.0;
const MARKDOWN_PREVIEW_BOXED_EDGE_GAP_PX: f32 = 8.0;
const MARKDOWN_PREVIEW_INDENT_STEP_PX: f32 = 24.0;
const MARKDOWN_PREVIEW_CHANGE_BAR_WIDTH_PX: f32 = 3.0;
const MARKDOWN_PREVIEW_BLOCKQUOTE_BAR_WIDTH_PX: f32 = 4.0;
@ -273,7 +298,6 @@ const MARKDOWN_PREVIEW_ALERT_BADGE_PAD_X_PX: f32 = 6.0;
const MARKDOWN_PREVIEW_ALERT_BADGE_GAP_PX: f32 = 10.0;
const MARKDOWN_PREVIEW_SHELL_PAD_X_PX: f32 = 12.0;
const MARKDOWN_PREVIEW_CODE_BORDER_PX: f32 = 1.0;
const MARKDOWN_PREVIEW_CODE_SCROLLBAR_PAD_BOTTOM_PX: f32 = 16.0;
struct MarkdownPreviewRowTypography {
font_size: f32,
@ -287,7 +311,6 @@ struct MarkdownPreviewRowTypography {
struct MarkdownPreviewRowLayout {
top_inset_px: f32,
bottom_inset_px: f32,
shell_bottom_inset_px: f32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
@ -300,8 +323,6 @@ pub(super) struct MarkdownPreviewRenderContext {
pub(super) theme: AppTheme,
pub(super) bar_color: Option<gpui::Rgba>,
pub(super) min_width: Pixels,
pub(super) row_id_prefix: &'static str,
pub(super) horizontal_scroll_handle: Option<gpui::ScrollHandle>,
pub(super) view: Option<Entity<MainPaneView>>,
pub(super) text_region: DiffTextRegion,
}
@ -334,7 +355,6 @@ fn markdown_preview_row_element(
let theme = context.theme;
let bar_color = context.bar_color;
let min_width = context.min_width;
let row_id_prefix = context.row_id_prefix;
let text_region = context.text_region;
let _perf_scope = perf::span(ViewPerfSpan::MarkdownPreviewStyledRowBuild);
if matches!(row.kind, MarkdownPreviewRowKind::Spacer) {
@ -342,7 +362,7 @@ fn markdown_preview_row_element(
.relative()
.h(px(MARKDOWN_PREVIEW_ROW_HEIGHT_PX))
.min_h(px(MARKDOWN_PREVIEW_ROW_HEIGHT_PX))
.w_full()
.w(min_width)
.min_w(min_width)
.into_any_element();
}
@ -351,18 +371,23 @@ fn markdown_preview_row_element(
let typography = markdown_preview_row_typography(theme, row);
let (display, highlights) = markdown_preview_display_and_highlights(theme, row);
let horizontal_padding = markdown_preview_row_horizontal_padding(row);
let row_text = row.text.clone();
let mut content = div()
.flex_1()
.relative()
.flex_grow()
.min_w(px(0.0))
.w_full()
.h_full()
.h(px(typography.line_height))
.min_h(px(typography.line_height))
.flex()
.items_center()
.overflow_hidden()
.whitespace_nowrap()
.text_size(px(typography.font_size))
.line_height(px(typography.line_height))
.text_color(typography.text_color);
.text_color(typography.text_color)
.debug_selector(|| format!("markdown_preview_text_box_{row_ix}"));
if let Some(font_weight) = typography.font_weight {
content = content.font_weight(font_weight);
@ -370,10 +395,26 @@ fn markdown_preview_row_element(
if let Some(font_family) = typography.font_family {
content = content.font_family(font_family);
}
if let Some(view) = context.view.clone() {
content = content.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(DiffTextSelectionOverlay {
view,
visible_ix: row_ix,
region: text_region,
text: row_text.clone(),
}),
);
}
let body = match row.kind {
MarkdownPreviewRowKind::ThematicBreak => div()
.flex_1()
.flex_grow()
.min_w(px(0.0))
.w_full()
.h_full()
@ -394,7 +435,7 @@ fn markdown_preview_row_element(
};
let mut line = div()
.flex_1()
.flex_grow()
.min_w(px(0.0))
.w_full()
.h_full()
@ -440,7 +481,7 @@ fn markdown_preview_row_element(
};
let mut content_shell = div()
.flex_1()
.flex_grow()
.min_w(px(0.0))
.w_full()
.h_full()
@ -467,7 +508,7 @@ fn markdown_preview_row_element(
shell = shell.border_t_1();
}
if is_last {
shell = shell.border_b_1().pb(px(row_layout.shell_bottom_inset_px));
shell = shell.border_b_1();
}
shell
}
@ -499,21 +540,20 @@ fn markdown_preview_row_element(
_ => content_shell,
};
content_shell = content_shell.child(body);
if matches!(
row.kind,
MarkdownPreviewRowKind::CodeLine { is_last: true, .. }
) && row.code_block_horizontal_scroll_hint
&& let Some(scroll_handle) = context.horizontal_scroll_handle.clone()
{
content_shell = content_shell.child(
components::Scrollbar::horizontal((row_id_prefix, row_ix), scroll_handle).render(theme),
);
if matches!(row.kind, MarkdownPreviewRowKind::CodeLine { .. }) {
content_shell =
content_shell.debug_selector(|| format!("markdown_preview_code_shell_{row_ix}"));
}
let row_content_width = if bar_color.is_some() {
(min_width - px(MARKDOWN_PREVIEW_CHANGE_BAR_WIDTH_PX)).max(px(0.0))
} else {
min_width
};
let mut row_content = div()
.flex_1()
.flex_grow()
.min_w(px(0.0))
.w_full()
.w(row_content_width)
.h_full()
.flex()
.items_center()
@ -526,16 +566,15 @@ fn markdown_preview_row_element(
}
row_content = row_content.child(content_shell);
let row_text = row.text.clone();
if let Some(view) = context.view.clone() {
// Interactive markdown preview row with text selection + context menu.
div()
let row_container = div()
.id(("md_preview_row", row_ix))
.debug_selector(|| format!("markdown_preview_row_box_{row_ix}"))
.relative()
.h(px(MARKDOWN_PREVIEW_ROW_HEIGHT_PX))
.min_h(px(MARKDOWN_PREVIEW_ROW_HEIGHT_PX))
.w_full()
.w(min_width)
.flex()
.items_center()
.pt(px(row_layout.top_inset_px))
@ -552,7 +591,6 @@ fn markdown_preview_row_element(
)
})
.min_w(min_width)
.child(row_content)
.on_mouse_down(gpui::MouseButton::Left, {
let view = view.clone();
move |event, window, cx| {
@ -589,20 +627,16 @@ fn markdown_preview_row_element(
});
}
})
.child(DiffTextSelectionOverlay {
view,
visible_ix: row_ix,
region: text_region,
text: row_text,
})
.into_any_element()
.child(row_content);
row_container.into_any_element()
} else {
// Non-interactive markdown preview row (benchmarks, conflict resolver).
div()
let row_container = div()
.debug_selector(|| format!("markdown_preview_row_box_{row_ix}"))
.relative()
.h(px(MARKDOWN_PREVIEW_ROW_HEIGHT_PX))
.min_h(px(MARKDOWN_PREVIEW_ROW_HEIGHT_PX))
.w_full()
.w(min_width)
.flex()
.items_center()
.pt(px(row_layout.top_inset_px))
@ -619,8 +653,8 @@ fn markdown_preview_row_element(
)
})
.min_w(min_width)
.child(row_content)
.into_any_element()
.child(row_content);
row_container.into_any_element()
}
}
@ -976,59 +1010,49 @@ fn markdown_preview_row_text_color(theme: AppTheme, row: &MarkdownPreviewRow) ->
fn markdown_preview_row_layout(row: &MarkdownPreviewRow) -> MarkdownPreviewRowLayout {
match row.kind {
MarkdownPreviewRowKind::Heading { level: 1 | 2 } => MarkdownPreviewRowLayout {
top_inset_px: 4.0,
bottom_inset_px: 8.0,
shell_bottom_inset_px: 0.0,
top_inset_px: 0.0,
bottom_inset_px: 0.0,
},
MarkdownPreviewRowKind::Heading { level: 3 } => MarkdownPreviewRowLayout {
top_inset_px: 2.0,
bottom_inset_px: 4.0,
},
MarkdownPreviewRowKind::Heading { .. } => MarkdownPreviewRowLayout {
top_inset_px: 3.0,
bottom_inset_px: 7.0,
shell_bottom_inset_px: 0.0,
},
MarkdownPreviewRowKind::DetailsSummary => MarkdownPreviewRowLayout {
top_inset_px: 2.0,
bottom_inset_px: 6.0,
shell_bottom_inset_px: 0.0,
},
MarkdownPreviewRowKind::DetailsSummary => MarkdownPreviewRowLayout {
top_inset_px: 0.0,
bottom_inset_px: 0.0,
},
MarkdownPreviewRowKind::Paragraph => MarkdownPreviewRowLayout {
top_inset_px: 3.0,
bottom_inset_px: 7.0,
shell_bottom_inset_px: 0.0,
top_inset_px: 2.0,
bottom_inset_px: 6.0,
},
MarkdownPreviewRowKind::BlockquoteLine => MarkdownPreviewRowLayout {
top_inset_px: 2.0,
bottom_inset_px: 6.0,
shell_bottom_inset_px: 0.0,
},
MarkdownPreviewRowKind::ListItem { .. } => MarkdownPreviewRowLayout {
top_inset_px: 0.0,
bottom_inset_px: 0.0,
shell_bottom_inset_px: 0.0,
},
MarkdownPreviewRowKind::CodeLine { is_first, is_last } => MarkdownPreviewRowLayout {
top_inset_px: if is_first { 4.0 } else { 0.0 },
bottom_inset_px: if is_last { 4.0 } else { 0.0 },
shell_bottom_inset_px: if is_last {
MARKDOWN_PREVIEW_CODE_SCROLLBAR_PAD_BOTTOM_PX
} else {
0.0
},
top_inset_px: if is_first { 5.0 } else { 0.0 },
bottom_inset_px: if is_last { 5.0 } else { 0.0 },
},
MarkdownPreviewRowKind::ThematicBreak => MarkdownPreviewRowLayout {
top_inset_px: 6.0,
bottom_inset_px: 6.0,
shell_bottom_inset_px: 0.0,
},
MarkdownPreviewRowKind::Spacer => MarkdownPreviewRowLayout {
top_inset_px: 0.0,
bottom_inset_px: 0.0,
shell_bottom_inset_px: 0.0,
},
MarkdownPreviewRowKind::TableRow { .. } | MarkdownPreviewRowKind::PlainFallback => {
MarkdownPreviewRowLayout {
top_inset_px: 2.0,
bottom_inset_px: 2.0,
shell_bottom_inset_px: 0.0,
}
}
}
@ -1042,77 +1066,77 @@ fn markdown_preview_row_typography(
match row.kind {
MarkdownPreviewRowKind::Heading { level: 1 } => MarkdownPreviewRowTypography {
font_size: 28.0,
line_height: 32.0,
line_height: 28.0,
font_weight: Some(FontWeight::BOLD),
font_family: None,
text_color,
},
MarkdownPreviewRowKind::Heading { level: 2 } => MarkdownPreviewRowTypography {
font_size: 24.0,
line_height: 28.0,
line_height: 24.0,
font_weight: Some(FontWeight::BOLD),
font_family: None,
text_color,
},
MarkdownPreviewRowKind::Heading { level: 3 } => MarkdownPreviewRowTypography {
font_size: 20.0,
line_height: 24.0,
line_height: 22.0,
font_weight: Some(FontWeight::BOLD),
font_family: None,
text_color,
},
MarkdownPreviewRowKind::Heading { level: 4 } => MarkdownPreviewRowTypography {
font_size: 18.0,
line_height: 22.0,
line_height: 20.0,
font_weight: Some(FontWeight::BOLD),
font_family: None,
text_color,
},
MarkdownPreviewRowKind::Heading { level: 5 } => MarkdownPreviewRowTypography {
font_size: 16.0,
line_height: 20.0,
line_height: 18.0,
font_weight: Some(FontWeight::BOLD),
font_family: None,
text_color,
},
MarkdownPreviewRowKind::Heading { level: 6 } => MarkdownPreviewRowTypography {
font_size: 14.0,
line_height: 18.0,
line_height: 16.0,
font_weight: Some(FontWeight::BOLD),
font_family: None,
text_color,
},
MarkdownPreviewRowKind::DetailsSummary => MarkdownPreviewRowTypography {
font_size: MARKDOWN_PREVIEW_BASE_FONT_PX,
line_height: 32.0,
line_height: 28.0,
font_weight: Some(FontWeight::BOLD),
font_family: None,
text_color,
},
MarkdownPreviewRowKind::ListItem { .. } => MarkdownPreviewRowTypography {
font_size: MARKDOWN_PREVIEW_BASE_FONT_PX,
line_height: 36.0,
line_height: MARKDOWN_PREVIEW_BASE_LINE_HEIGHT_PX,
font_weight: None,
font_family: None,
text_color,
},
MarkdownPreviewRowKind::CodeLine { .. } => MarkdownPreviewRowTypography {
font_size: 12.0,
line_height: 20.0,
line_height: 18.0,
font_weight: None,
font_family: Some(UI_MONOSPACE_FONT_FAMILY),
text_color,
},
MarkdownPreviewRowKind::TableRow { is_header } => MarkdownPreviewRowTypography {
font_size: 12.0,
line_height: 20.0,
line_height: 18.0,
font_weight: is_header.then_some(FontWeight::BOLD),
font_family: Some(UI_MONOSPACE_FONT_FAMILY),
text_color,
},
MarkdownPreviewRowKind::PlainFallback => MarkdownPreviewRowTypography {
font_size: 12.0,
line_height: 20.0,
line_height: 18.0,
font_weight: None,
font_family: Some(UI_MONOSPACE_FONT_FAMILY),
text_color,
@ -1143,15 +1167,11 @@ fn markdown_preview_row_horizontal_padding(
MARKDOWN_PREVIEW_CONTENT_PAD_X_PX + indent_steps * MARKDOWN_PREVIEW_INDENT_STEP_PX;
match row.kind {
MarkdownPreviewRowKind::CodeLine { .. } if row.indent_level == 0 => {
MarkdownPreviewRowHorizontalPadding {
left_px: 0.0,
right_px: 0.0,
}
}
MarkdownPreviewRowKind::CodeLine { .. } => MarkdownPreviewRowHorizontalPadding {
left_px: default_left_px,
right_px: 0.0,
// Fenced code blocks ignore surrounding list indentation but keep
// a small edge gap so the boxed shell does not touch the preview edge.
left_px: MARKDOWN_PREVIEW_BOXED_EDGE_GAP_PX,
right_px: MARKDOWN_PREVIEW_BOXED_EDGE_GAP_PX,
},
_ => MarkdownPreviewRowHorizontalPadding {
left_px: default_left_px,
@ -1795,7 +1815,7 @@ mod tests {
}
#[test]
fn markdown_preview_list_rows_tighten_line_height_relative_to_paragraphs() {
fn markdown_preview_list_rows_match_body_line_height_and_keep_tighter_layout() {
let theme = AppTheme::zed_one_light();
let paragraph = markdown_row(MarkdownPreviewRowKind::Paragraph);
let list_item = markdown_row(MarkdownPreviewRowKind::ListItem { number: None });
@ -1805,7 +1825,10 @@ mod tests {
let paragraph_layout = markdown_preview_row_layout(&paragraph);
let list_layout = markdown_preview_row_layout(&list_item);
assert!(list_typography.line_height > paragraph_typography.line_height);
assert_eq!(
list_typography.line_height,
paragraph_typography.line_height
);
assert!(paragraph_layout.bottom_inset_px > list_layout.bottom_inset_px);
}
@ -1826,33 +1849,35 @@ mod tests {
}
#[test]
fn markdown_preview_code_rows_reserve_bottom_space_for_local_scrollbar() {
let row = markdown_row(MarkdownPreviewRowKind::CodeLine {
fn markdown_preview_code_rows_do_not_reserve_bottom_space_for_local_scrollbar() {
let first_row = markdown_row(MarkdownPreviewRowKind::CodeLine {
is_first: true,
is_last: false,
});
let last_row = markdown_row(MarkdownPreviewRowKind::CodeLine {
is_first: false,
is_last: true,
});
let layout = markdown_preview_row_layout(&row);
let first_layout = markdown_preview_row_layout(&first_row);
let last_layout = markdown_preview_row_layout(&last_row);
assert_eq!(
layout.shell_bottom_inset_px,
super::MARKDOWN_PREVIEW_CODE_SCROLLBAR_PAD_BOTTOM_PX
);
assert_eq!(layout.bottom_inset_px, 4.0);
assert_eq!(first_layout.top_inset_px, 5.0);
assert_eq!(last_layout.bottom_inset_px, 5.0);
}
#[test]
fn markdown_preview_top_level_code_rows_drop_outer_horizontal_padding() {
fn markdown_preview_nested_code_rows_keep_small_outer_edge_gap() {
let mut row = markdown_row(MarkdownPreviewRowKind::CodeLine {
is_first: true,
is_last: false,
});
row.indent_level = 0;
row.indent_level = 3;
let padding = markdown_preview_row_horizontal_padding(&row);
assert_eq!(padding.left_px, 0.0);
assert_eq!(padding.right_px, 0.0);
assert_eq!(padding.left_px, super::MARKDOWN_PREVIEW_BOXED_EDGE_GAP_PX);
assert_eq!(padding.right_px, super::MARKDOWN_PREVIEW_BOXED_EDGE_GAP_PX);
}
#[test]
@ -2125,7 +2150,6 @@ mod tests {
assert_eq!(layout.top_inset_px, 0.0);
assert_eq!(layout.bottom_inset_px, 0.0);
assert_eq!(layout.shell_bottom_inset_px, 0.0);
assert_eq!(markdown_preview_row_background(theme, &row), None);
assert_eq!(markdown_preview_row_marker(&row), None);
}

View file

@ -1,6 +1,6 @@
[package]
name = "gitcomet-ui"
version = "0.1.0"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true

View file

@ -1,6 +1,6 @@
[package]
name = "gitcomet-app"
version = "0.1.0"
name = "gitcomet"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true

View file

@ -16,7 +16,7 @@ fn main() {
let Some(install_dir) = current_exe.parent() else {
return;
};
let app_exe = install_dir.join("gitcomet-app.exe");
let app_exe = install_dir.join("gitcomet.exe");
let mut cmd = Command::new(app_exe);
cmd.current_dir(install_dir);

View file

@ -1,4 +1,4 @@
//! CLI argument parsing for gitcomet-app.
//! CLI argument parsing for gitcomet.
//!
//! Supports six modes:
//! - Default (no subcommand): open the full repository browser
@ -29,7 +29,7 @@ pub mod exit_code {
// ── Raw CLI argument structs (clap) ──────────────────────────────────
#[derive(Parser, Debug)]
#[command(name = "gitcomet-app", about = "Git GUI built with GPUI", version)]
#[command(name = "gitcomet", about = "Git GUI built with GPUI", version)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,

View file

@ -54,7 +54,7 @@ fn install_linux_script_does_not_use_invalid_debug_flag() {
.unwrap_or_else(|err| panic!("failed to read {}: {err}", script_path.display()));
assert!(
!script.contains("cargo build -p gitcomet-app --${mode}"),
!script.contains("cargo build -p gitcomet --${mode}"),
"install script should not forward mode directly as a cargo flag"
);
}
@ -68,7 +68,7 @@ fn parse_mode_mergetool_drops_empty_base_value_before_clap() {
let mode = parse_mode_for_test(
vec![
"gitcomet-app".into(),
"gitcomet".into(),
"mergetool".into(),
"--base".into(),
"".into(),
@ -100,7 +100,7 @@ fn parse_mode_mergetool_drops_empty_attached_base_value_before_clap() {
let mode = parse_mode_for_test(
vec![
"gitcomet-app".into(),
"gitcomet".into(),
"mergetool".into(),
"--base=".into(),
"--local".into(),
@ -1437,14 +1437,7 @@ fn mergetool_env_only_resolution_without_base() {
#[test]
fn clap_parses_difftool_subcommand() {
let cli = Cli::try_parse_from([
"gitcomet-app",
"difftool",
"--local",
"/tmp/a",
"--remote",
"/tmp/b",
"--path",
"foo.txt",
"gitcomet", "difftool", "--local", "/tmp/a", "--remote", "/tmp/b", "--path", "foo.txt",
])
.unwrap();
@ -1461,7 +1454,7 @@ fn clap_parses_difftool_subcommand() {
#[test]
fn clap_parses_mergetool_subcommand() {
let cli = Cli::try_parse_from([
"gitcomet-app",
"gitcomet",
"mergetool",
"--merged",
"/tmp/m",
@ -1498,7 +1491,7 @@ fn clap_parses_mergetool_subcommand() {
fn clap_parses_mergetool_output_aliases() {
for merged_flag in ["-o", "--output", "--out"] {
let cli = Cli::try_parse_from([
"gitcomet-app",
"gitcomet",
"mergetool",
merged_flag,
"/tmp/m",
@ -1523,7 +1516,7 @@ fn clap_parses_mergetool_output_aliases() {
#[test]
fn clap_parses_mergetool_kdiff3_label_aliases() {
let cli = Cli::try_parse_from([
"gitcomet-app",
"gitcomet",
"mergetool",
"--merged",
"/tmp/m",
@ -1552,7 +1545,7 @@ fn clap_parses_mergetool_kdiff3_label_aliases() {
#[test]
fn clap_parses_setup_subcommand() {
let cli = Cli::try_parse_from(["gitcomet-app", "setup", "--dry-run", "--local"]).unwrap();
let cli = Cli::try_parse_from(["gitcomet", "setup", "--dry-run", "--local"]).unwrap();
match cli.command {
Some(Command::Setup(args)) => {
@ -1565,7 +1558,7 @@ fn clap_parses_setup_subcommand() {
#[test]
fn clap_parses_uninstall_subcommand() {
let cli = Cli::try_parse_from(["gitcomet-app", "uninstall", "--dry-run", "--local"]).unwrap();
let cli = Cli::try_parse_from(["gitcomet", "uninstall", "--dry-run", "--local"]).unwrap();
match cli.command {
Some(Command::Uninstall(args)) => {
@ -1581,7 +1574,7 @@ fn uninstall_mode_resolves_into_app_mode() {
let env = TestEnv::new();
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("uninstall"),
OsString::from("--dry-run"),
OsString::from("--local"),
@ -1601,14 +1594,14 @@ fn uninstall_mode_resolves_into_app_mode() {
#[test]
fn clap_parses_no_subcommand_as_browser() {
let cli = Cli::try_parse_from(["gitcomet-app"]).unwrap();
let cli = Cli::try_parse_from(["gitcomet"]).unwrap();
assert!(cli.command.is_none());
assert!(cli.path.is_none());
}
#[test]
fn clap_parses_path_argument() {
let cli = Cli::try_parse_from(["gitcomet-app", "/some/repo"]).unwrap();
let cli = Cli::try_parse_from(["gitcomet", "/some/repo"]).unwrap();
assert!(cli.command.is_none());
assert_eq!(
cli.path.as_deref(),
@ -1625,7 +1618,7 @@ fn compat_parses_positional_difftool_invocation() {
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
local.into_os_string(),
remote.into_os_string(),
],
@ -1653,7 +1646,7 @@ fn compat_parses_kdiff3_style_difftool_labels() {
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--L1"),
OsString::from("LEFT_LABEL"),
OsString::from("--L2"),
@ -1683,7 +1676,7 @@ fn compat_parses_kdiff3_style_difftool_short_numbered_equals_labels() {
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("-L1=LEFT_LABEL"),
OsString::from("-L2=RIGHT_LABEL"),
local.into_os_string(),
@ -1711,7 +1704,7 @@ fn compat_parses_meld_style_difftool_short_labels() {
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("-L"),
OsString::from("LEFT_LABEL"),
OsString::from("--label"),
@ -1741,7 +1734,7 @@ fn compat_parses_meld_style_difftool_attached_labels() {
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("-LLEFT_LABEL"),
OsString::from("--label=RIGHT_LABEL"),
local.into_os_string(),
@ -1771,7 +1764,7 @@ fn compat_parses_kdiff3_style_mergetool_with_base() {
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--auto"),
OsString::from("--L1"),
OsString::from("BASE_LABEL"),
@ -1814,7 +1807,7 @@ fn compat_parses_kdiff3_style_mergetool_with_base_flag() {
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--auto"),
OsString::from("--L1=BASE_LABEL"),
OsString::from("--L2=LOCAL_LABEL"),
@ -1855,7 +1848,7 @@ fn compat_parses_kdiff3_style_mergetool_with_short_numbered_label_flags() {
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--auto"),
OsString::from("-L1"),
OsString::from("BASE_LABEL"),
@ -1898,7 +1891,7 @@ fn compat_parses_kdiff3_style_mergetool_with_attached_short_numbered_label_flags
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--auto"),
OsString::from("-L1BASE_LABEL"),
OsString::from("-L2=LOCAL_LABEL"),
@ -1938,7 +1931,7 @@ fn compat_parses_kdiff3_style_mergetool_with_attached_output_and_base_flags() {
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--auto"),
OsString::from("--L1=BASE_LABEL"),
OsString::from("--L2=LOCAL_LABEL"),
@ -1976,7 +1969,7 @@ fn compat_parses_kdiff3_style_mergetool_without_base() {
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--auto"),
OsString::from("--L1"),
OsString::from("LOCAL_LABEL"),
@ -2016,7 +2009,7 @@ fn compat_mergetool_applies_merge_conflictstyle_from_git_config() {
let mode = parse_mode_for_test_with_config(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--auto"),
OsString::from("-o"),
merged.into_os_string(),
@ -2052,7 +2045,7 @@ fn compat_mergetool_applies_diff_algorithm_from_git_config() {
let mode = parse_mode_for_test_with_config(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--auto"),
OsString::from("-o"),
merged.into_os_string(),
@ -2088,7 +2081,7 @@ fn compat_parses_meld_style_mergetool_with_output() {
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--output"),
merged.clone().into_os_string(),
local.clone().into_os_string(),
@ -2124,7 +2117,7 @@ fn compat_parses_meld_style_mergetool_labels() {
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--output"),
merged.clone().into_os_string(),
OsString::from("--label=LOCAL_LABEL"),
@ -2164,7 +2157,7 @@ fn compat_parses_meld_style_mergetool_with_auto_merge_flag() {
let mode = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--auto-merge"),
OsString::from("--output"),
merged.clone().into_os_string(),
@ -2197,7 +2190,7 @@ fn compat_auto_merge_requires_output_path() {
let err = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--auto-merge"),
local.into_os_string(),
base.into_os_string(),
@ -2224,7 +2217,7 @@ fn compat_rejects_too_many_label_flags() {
let err = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--output"),
merged.into_os_string(),
OsString::from("--label"),
@ -2258,7 +2251,7 @@ fn compat_auto_requires_output_path() {
let err = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--auto"),
local.into_os_string(),
remote.into_os_string(),
@ -2282,7 +2275,7 @@ fn compat_merge_requires_two_or_three_positionals_after_output_flag() {
let err = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--output"),
merged.into_os_string(),
local.into_os_string(),
@ -2309,7 +2302,7 @@ fn compat_merge_rejects_too_many_positionals() {
let err = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--out"),
merged.into_os_string(),
base.into_os_string(),
@ -2338,7 +2331,7 @@ fn compat_merge_rejects_base_flag_with_extra_positionals() {
let err = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--base"),
base.into_os_string(),
OsString::from("--out"),
@ -2368,7 +2361,7 @@ fn compat_merge_without_base_rejects_l3_label() {
let err = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--out"),
merged.into_os_string(),
OsString::from("--L3"),
@ -2395,7 +2388,7 @@ fn compat_diff_rejects_l3_without_output_path() {
let err = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--L3"),
OsString::from("REMOTE"),
local.into_os_string(),
@ -2421,7 +2414,7 @@ fn compat_diff_rejects_base_without_output_path() {
let err = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--base"),
base.into_os_string(),
local.into_os_string(),
@ -2447,7 +2440,7 @@ fn compat_diff_rejects_too_many_positionals() {
let err = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
local.into_os_string(),
remote.into_os_string(),
extra.into_os_string(),
@ -2594,7 +2587,7 @@ fn compat_base_flag_requires_two_positionals_when_output_present() {
let err = parse_mode_for_test(
vec![
OsString::from("gitcomet-app"),
OsString::from("gitcomet"),
OsString::from("--base"),
base.into_os_string(),
OsString::from("--out"),
@ -2834,7 +2827,7 @@ fn mergetool_marker_size_zero_errors() {
#[test]
fn clap_parses_conflict_style_and_diff_algorithm() {
let cli = Cli::try_parse_from([
"gitcomet-app",
"gitcomet",
"mergetool",
"--merged",
"/tmp/m",
@ -2996,7 +2989,7 @@ fn git_config_fallback_combined_style_and_algorithm() {
#[test]
fn clap_parses_mergetool_auto_flag() {
let cli = Cli::try_parse_from([
"gitcomet-app",
"gitcomet",
"mergetool",
"--merged",
"/tmp/m",
@ -3019,7 +3012,7 @@ fn clap_parses_mergetool_auto_flag() {
#[test]
fn clap_parses_mergetool_auto_merge_alias_flag() {
let cli = Cli::try_parse_from([
"gitcomet-app",
"gitcomet",
"mergetool",
"--merged",
"/tmp/m",
@ -3133,7 +3126,7 @@ fn compat_auto_merge_flag_propagates_to_config() {
#[test]
fn clap_parses_extract_merge_fixtures_subcommand() {
let cli = Cli::try_parse_from([
"gitcomet-app",
"gitcomet",
"extract-merge-fixtures",
"--repo",
"/tmp/repo",
@ -3162,7 +3155,7 @@ fn extract_merge_fixtures_mode_resolves_into_app_mode() {
let env = TestEnv::new();
let mode = parse_mode_for_test(
vec![
"gitcomet-app".into(),
"gitcomet".into(),
"extract-merge-fixtures".into(),
"--repo".into(),
"/tmp/repo".into(),

View file

@ -568,7 +568,7 @@ mod tests {
fn parse_crash_log_extracts_fields() {
let log = r#"=== GitComet crash (panic) ===
timestamp_unix_ms=123
crate=gitcomet-app version=0.1.0
crate=gitcomet version=0.1.0
thread=main
location=src/main.rs#L42
message=boom happened
@ -580,7 +580,7 @@ frame 2
let parsed = parse_crash_log(log);
assert_eq!(parsed.timestamp_unix_ms.as_deref(), Some("123"));
assert_eq!(parsed.crate_name.as_deref(), Some("gitcomet-app"));
assert_eq!(parsed.crate_name.as_deref(), Some("gitcomet"));
assert_eq!(parsed.crate_version.as_deref(), Some("0.1.0"));
assert_eq!(parsed.thread.as_deref(), Some("main"));
assert_eq!(parsed.location.as_deref(), Some("src/main.rs#L42"));
@ -602,7 +602,7 @@ frame 2
#[test]
fn build_startup_report_populates_issue_url_and_summary() {
let log = r#"timestamp_unix_ms=123
crate=gitcomet-app version=0.1.0
crate=gitcomet version=0.1.0
thread=main
location=src/main.rs#L42
message=boom happened
@ -633,7 +633,7 @@ frame 2
let dir = tempdir().expect("temp dir");
let crash_log_path = dir.path().join("panic.log");
let crash_log = r#"timestamp_unix_ms=123
crate=gitcomet-app version=0.1.0
crate=gitcomet version=0.1.0
thread=main
location=src/main.rs#L42
message=boom happened

View file

@ -148,7 +148,7 @@ fn main() {
#[cfg(not(feature = "ui-gpui-runtime"))]
if config.gui {
eprintln!(
"GUI difftool mode is unavailable in this build. Rebuild with `-p gitcomet-app --features ui-gpui`."
"GUI difftool mode is unavailable in this build. Rebuild with `-p gitcomet --features ui-gpui`."
);
std::process::exit(exit_code::ERROR);
}
@ -234,7 +234,7 @@ fn main() {
#[cfg(not(feature = "ui"))]
{
let _ = path;
eprintln!("GitComet UI is disabled. Build with `-p gitcomet-app --features ui`.");
eprintln!("GitComet UI is disabled. Build with `-p gitcomet --features ui`.");
std::process::exit(exit_code::ERROR);
}
}
@ -242,7 +242,7 @@ fn main() {
#[cfg(not(feature = "ui-gpui-runtime"))]
if config.gui {
eprintln!(
"GUI mergetool mode is unavailable in this build. Rebuild with `-p gitcomet-app --features ui-gpui`."
"GUI mergetool mode is unavailable in this build. Rebuild with `-p gitcomet --features ui-gpui`."
);
std::process::exit(exit_code::ERROR);
}
@ -284,6 +284,45 @@ const MACOS_BUNDLE_RELAUNCH_ENV: &str = "GITCOMET_SKIP_APP_BUNDLE_RELAUNCH";
#[cfg(all(target_os = "macos", feature = "ui-gpui-runtime"))]
const MACOS_APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/gitcomet-512.png");
#[cfg(feature = "ui-gpui-runtime")]
fn resolve_executable_path_for_bundle_detection(path: &std::path::Path) -> std::path::PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
#[cfg(feature = "ui-gpui-runtime")]
fn is_macos_app_bundle_executable(path: &std::path::Path) -> bool {
path.to_string_lossy().contains(".app/Contents/MacOS/")
}
#[cfg(all(target_os = "macos", feature = "ui-gpui-runtime"))]
fn macos_user_app_bundle_path_with_home(home: Option<&std::path::Path>) -> std::path::PathBuf {
let base_dir = home
.map(|value| value.join("Library/Application Support/GitComet"))
.unwrap_or_else(|| std::env::temp_dir().join("GitComet"));
base_dir.join("GitComet.app")
}
#[cfg(all(target_os = "macos", feature = "ui-gpui-runtime"))]
fn macos_user_app_bundle_path() -> std::path::PathBuf {
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
macos_user_app_bundle_path_with_home(home.as_deref())
}
#[cfg(all(target_os = "macos", feature = "ui-gpui-runtime"))]
fn candidate_macos_app_bundle_paths(resolved_exe: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut candidates = Vec::with_capacity(2);
if let Some(bin_dir) = resolved_exe.parent() {
candidates.push(bin_dir.join("GitComet.app"));
}
let fallback = macos_user_app_bundle_path();
if !candidates.iter().any(|candidate| candidate == &fallback) {
candidates.push(fallback);
}
candidates
}
#[cfg(all(target_os = "macos", feature = "ui-gpui-runtime"))]
fn maybe_relaunch_browser_from_macos_app_bundle() -> bool {
if std::env::var_os(MACOS_BUNDLE_RELAUNCH_ENV).is_some() {
@ -293,23 +332,33 @@ fn maybe_relaunch_browser_from_macos_app_bundle() -> bool {
let Ok(current_exe) = std::env::current_exe() else {
return false;
};
if current_exe
.to_string_lossy()
.contains(".app/Contents/MacOS/")
{
let resolved_exe = resolve_executable_path_for_bundle_detection(&current_exe);
if is_macos_app_bundle_executable(&resolved_exe) {
return false;
}
let Some(bin_dir) = current_exe.parent() else {
return false;
};
let app_bundle = bin_dir.join("GitComet.app");
let app_exe = match ensure_macos_dev_app_bundle(&current_exe, &app_bundle) {
Ok(path) => path,
Err(err) => {
eprintln!("Failed to prepare macOS app bundle icon: {err}");
return false;
let mut prep_errors = Vec::new();
let mut app_exe = None;
for app_bundle in candidate_macos_app_bundle_paths(&resolved_exe) {
match ensure_macos_dev_app_bundle(&resolved_exe, &app_bundle) {
Ok(path) => {
app_exe = Some(path);
break;
}
Err(err) => {
prep_errors.push(format!("{}: {err}", app_bundle.display()));
}
}
}
let Some(app_exe) = app_exe else {
let details = if prep_errors.is_empty() {
"no app bundle destination available".to_string()
} else {
prep_errors.join("; ")
};
eprintln!("Failed to prepare macOS app bundle: {details}");
return false;
};
let mut relaunch = std::process::Command::new(app_exe);
@ -336,7 +385,7 @@ fn ensure_macos_dev_app_bundle(
std::fs::create_dir_all(&resources)
.map_err(|e| format!("failed to create Resources dir: {e}"))?;
let app_exe = macos.join("gitcomet-app");
let app_exe = macos.join("gitcomet");
std::fs::copy(current_exe, &app_exe)
.map_err(|e| format!("failed to copy executable into bundle: {e}"))?;
@ -372,7 +421,7 @@ fn ensure_macos_dev_app_bundle(
<key>CFBundleDisplayName</key>
<string>GitComet</string>
<key>CFBundleExecutable</key>
<string>gitcomet-app</string>
<string>gitcomet</string>
<key>CFBundleIdentifier</key>
<string>ai.autoexplore.gitcomet.dev</string>
<key>CFBundleIconFile</key>
@ -680,6 +729,66 @@ mod tests {
assert!(err.contains(&merged.display().to_string()));
}
#[test]
#[cfg(feature = "ui-gpui-runtime")]
fn detects_macos_app_bundle_executable_paths() {
assert!(is_macos_app_bundle_executable(std::path::Path::new(
"/tmp/GitComet.app/Contents/MacOS/gitcomet"
)));
assert!(!is_macos_app_bundle_executable(std::path::Path::new(
"/opt/homebrew/bin/gitcomet"
)));
}
#[test]
#[cfg(all(feature = "ui-gpui-runtime", unix))]
fn canonicalized_symlink_resolves_to_macos_app_bundle_executable() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let app_exe = temp.path().join("GitComet.app/Contents/MacOS/gitcomet");
fs::create_dir_all(app_exe.parent().unwrap()).unwrap();
fs::write(&app_exe, b"#!/bin/sh\n").unwrap();
let bin_dir = temp.path().join("bin");
fs::create_dir_all(&bin_dir).unwrap();
let symlink_path = bin_dir.join("gitcomet");
symlink(&app_exe, &symlink_path).unwrap();
assert!(!is_macos_app_bundle_executable(&symlink_path));
let resolved = resolve_executable_path_for_bundle_detection(&symlink_path);
let expected = app_exe.canonicalize().unwrap();
assert_eq!(resolved, expected);
assert!(is_macos_app_bundle_executable(&resolved));
}
#[test]
#[cfg(all(feature = "ui-gpui-runtime", target_os = "macos"))]
fn macos_user_app_bundle_path_uses_home_directory_when_available() {
let home = std::path::Path::new("/Users/example");
let bundle = macos_user_app_bundle_path_with_home(Some(home));
assert_eq!(
bundle,
std::path::PathBuf::from(
"/Users/example/Library/Application Support/GitComet/GitComet.app"
)
);
}
#[test]
#[cfg(all(feature = "ui-gpui-runtime", target_os = "macos"))]
fn candidate_macos_app_bundle_paths_include_executable_dir_and_fallback() {
let exe = std::path::Path::new("/opt/homebrew/bin/gitcomet");
let candidates = candidate_macos_app_bundle_paths(exe);
assert_eq!(
candidates.first(),
Some(&std::path::PathBuf::from("/opt/homebrew/bin/GitComet.app"))
);
assert_eq!(candidates.last(), Some(&macos_user_app_bundle_path()));
}
#[test]
fn emit_result_writes_stdout_stderr_and_flushes() {
let result = Ok(TestRunResult {

View file

@ -1,4 +1,4 @@
//! `gitcomet-app setup` / `gitcomet-app uninstall` support.
//! `gitcomet setup` / `gitcomet uninstall` support.
//!
//! Setup writes the recommended global (or local) git config entries so that
//! `git difftool` and `git mergetool` invoke gitcomet automatically.
@ -91,7 +91,7 @@ fn shell_single_quote(value: &str) -> String {
fn current_exe_path() -> Result<PathBuf, String> {
std::env::current_exe()
.and_then(|p| p.canonicalize())
.map_err(|e| format!("Cannot determine gitcomet-app binary path: {e}"))
.map_err(|e| format!("Cannot determine gitcomet binary path: {e}"))
}
fn executable_path_for_shell(bin_path: &Path) -> Result<String, String> {
@ -920,7 +920,7 @@ mod tests {
#[test]
fn build_config_entries_contains_all_required_keys() {
let entries = build_config_entries("/usr/bin/gitcomet-app");
let entries = build_config_entries("/usr/bin/gitcomet");
let keys: Vec<&str> = entries.iter().map(|e| e.key).collect();
// Headless tool
@ -948,7 +948,7 @@ mod tests {
#[test]
fn gui_tool_uses_separate_tool_name() {
let entries = build_config_entries("/usr/bin/gitcomet-app");
let entries = build_config_entries("/usr/bin/gitcomet");
let merge_guitool = entries.iter().find(|e| e.key == "merge.guitool").unwrap();
let diff_guitool = entries.iter().find(|e| e.key == "diff.guitool").unwrap();
@ -1021,14 +1021,14 @@ mod tests {
#[test]
fn mergetool_cmd_escapes_single_quote_in_binary_path() {
let entries = build_config_entries("/tmp/it's/gitcomet-app");
let entries = build_config_entries("/tmp/it's/gitcomet");
let cmd = entries
.iter()
.find(|e| e.key == "mergetool.gitcomet.cmd")
.unwrap();
assert!(
cmd.value.starts_with("'/tmp/it'\"'\"'s/gitcomet-app'"),
cmd.value.starts_with("'/tmp/it'\"'\"'s/gitcomet'"),
"unexpected cmd quoting: {}",
cmd.value
);
@ -1076,7 +1076,7 @@ mod tests {
#[test]
fn format_commands_global_scope() {
let entries = build_config_entries("/bin/gitcomet-app");
let entries = build_config_entries("/bin/gitcomet");
let output = format_commands(&entries, "--global");
// Headless mergetool entries
@ -1109,14 +1109,14 @@ mod tests {
assert!(output.contains("git config --global difftool.guiDefault"));
assert!(
!output.contains("''/bin/gitcomet-app'"),
!output.contains("''/bin/gitcomet'"),
"dry-run output should not contain broken nested quoting:\n{output}"
);
}
#[test]
fn format_commands_local_scope() {
let entries = build_config_entries("/bin/gitcomet-app");
let entries = build_config_entries("/bin/gitcomet");
let output = format_commands(&entries, "--local");
assert!(output.contains("git config --local merge.tool"));
@ -1155,7 +1155,7 @@ mod tests {
.unwrap();
assert!(init.status.success());
let entries = build_config_entries("/test/gitcomet-app");
let entries = build_config_entries("/test/gitcomet");
let result = std::process::Command::new("git")
.arg("-C")
.arg(dir.path())

View file

@ -59,7 +59,7 @@ fn require_git_shell_for_tool_tests() -> bool {
}
fn gitcomet_bin() -> PathBuf {
for env_key in ["CARGO_BIN_EXE_gitcomet-app", "CARGO_BIN_EXE_gitcomet_app"] {
for env_key in ["CARGO_BIN_EXE_gitcomet"] {
if let Some(path) = std::env::var_os(env_key).map(PathBuf::from) {
if path.is_file() {
return path;
@ -72,8 +72,7 @@ fn gitcomet_bin() -> PathBuf {
}
panic!(
"gitcomet-app binary path was not found. Tried CARGO_BIN_EXE_gitcomet-app, \
CARGO_BIN_EXE_gitcomet_app, and a fallback relative to current test executable"
"gitcomet binary path was not found. Tried CARGO_BIN_EXE_gitcomet and a fallback relative to current test executable"
);
}
@ -83,7 +82,7 @@ fn gitcomet_bin_from_current_exe() -> Option<PathBuf> {
let profile_dir = deps_dir.parent()?;
let exe_suffix = std::env::consts::EXE_SUFFIX;
for bin_name in ["gitcomet-app", "gitcomet_app"] {
for bin_name in ["gitcomet"] {
let candidate = profile_dir.join(format!("{bin_name}{exe_suffix}"));
if candidate.is_file() {
return Some(candidate);
@ -264,7 +263,7 @@ fn output_text(output: &Output) -> String {
}
#[test]
fn git_difftool_invokes_gitcomet_app_for_basic_diff() {
fn git_difftool_invokes_gitcomet_for_basic_diff() {
if !require_git_shell_for_tool_tests() {
return;
}

View file

@ -17,7 +17,7 @@ fn apply_isolated_git_config_env(cmd: &mut Command) {
}
fn gitcomet_bin() -> PathBuf {
for env_key in ["CARGO_BIN_EXE_gitcomet-app", "CARGO_BIN_EXE_gitcomet_app"] {
for env_key in ["CARGO_BIN_EXE_gitcomet"] {
if let Some(path) = std::env::var_os(env_key).map(PathBuf::from) {
if path.is_file() {
return path;
@ -30,8 +30,7 @@ fn gitcomet_bin() -> PathBuf {
}
panic!(
"gitcomet-app binary path was not found. Tried CARGO_BIN_EXE_gitcomet-app, \
CARGO_BIN_EXE_gitcomet_app, and a fallback relative to current test executable"
"gitcomet binary path was not found. Tried CARGO_BIN_EXE_gitcomet and a fallback relative to current test executable"
);
}
@ -41,7 +40,7 @@ fn gitcomet_bin_from_current_exe() -> Option<PathBuf> {
let profile_dir = deps_dir.parent()?;
let exe_suffix = std::env::consts::EXE_SUFFIX;
for bin_name in ["gitcomet-app", "gitcomet_app"] {
for bin_name in ["gitcomet"] {
let candidate = profile_dir.join(format!("{bin_name}{exe_suffix}"));
if candidate.is_file() {
return Some(candidate);
@ -59,7 +58,7 @@ where
Command::new(gitcomet_bin())
.args(args)
.output()
.expect("gitcomet-app command to run")
.expect("gitcomet command to run")
}
fn run_git_capture(repo: &Path, args: &[&str]) -> Output {

View file

@ -62,7 +62,7 @@ fn require_git_shell_for_tool_tests() -> bool {
}
fn gitcomet_bin() -> PathBuf {
for env_key in ["CARGO_BIN_EXE_gitcomet-app", "CARGO_BIN_EXE_gitcomet_app"] {
for env_key in ["CARGO_BIN_EXE_gitcomet"] {
if let Some(path) = std::env::var_os(env_key).map(PathBuf::from) {
if path.is_file() {
return path;
@ -75,8 +75,7 @@ fn gitcomet_bin() -> PathBuf {
}
panic!(
"gitcomet-app binary path was not found. Tried CARGO_BIN_EXE_gitcomet-app, \
CARGO_BIN_EXE_gitcomet_app, and a fallback relative to current test executable"
"gitcomet binary path was not found. Tried CARGO_BIN_EXE_gitcomet and a fallback relative to current test executable"
);
}
@ -86,7 +85,7 @@ fn gitcomet_bin_from_current_exe() -> Option<PathBuf> {
let profile_dir = deps_dir.parent()?;
let exe_suffix = std::env::consts::EXE_SUFFIX;
for bin_name in ["gitcomet-app", "gitcomet_app"] {
for bin_name in ["gitcomet"] {
let candidate = profile_dir.join(format!("{bin_name}{exe_suffix}"));
if candidate.is_file() {
return Some(candidate);
@ -650,11 +649,11 @@ fn git_mergetool_accepts_kdiff3_alias_flags_in_cmd() {
&& !text.contains("unexpected argument '--L1'")
&& !text.contains("unexpected argument '--L2'")
&& !text.contains("unexpected argument '--L3'"),
"expected alias flags to be accepted by gitcomet-app mergetool\noutput:\n{text}"
"expected alias flags to be accepted by gitcomet mergetool\noutput:\n{text}"
);
assert!(
text.contains("Auto-merging file.txt"),
"expected gitcomet-app mergetool to run\noutput:\n{text}"
"expected gitcomet mergetool to run\noutput:\n{text}"
);
assert!(
merged.contains("LOCAL") || merged.contains("REMOTE") || merged.contains("<<<<<<<"),
@ -1180,7 +1179,7 @@ fn git_mergetool_trust_exit_code_conflict_preserves_unmerged_state() {
}
// When our tool exits 1 (unresolved conflict) with trustExitCode=true,
// git should leave the file as unmerged. This verifies the exit code
// contract between gitcomet-app and git mergetool.
// contract between gitcomet and git mergetool.
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
@ -3677,7 +3676,7 @@ fn gitcomet_mergetool_reads_conflictstyle_from_repo_when_cwd_is_outside_repo() {
.arg("--merged")
.arg(&merged)
.output()
.expect("gitcomet-app mergetool command to run");
.expect("gitcomet mergetool command to run");
let text = output_text(&output);
let merged_text = fs::read_to_string(&merged).unwrap_or_else(|e| {

View file

@ -6,7 +6,7 @@ use std::process::{Command, Output};
use std::sync::OnceLock;
fn gitcomet_bin() -> PathBuf {
for env_key in ["CARGO_BIN_EXE_gitcomet-app", "CARGO_BIN_EXE_gitcomet_app"] {
for env_key in ["CARGO_BIN_EXE_gitcomet"] {
if let Some(path) = std::env::var_os(env_key).map(PathBuf::from) {
if path.is_file() {
return path;
@ -19,8 +19,7 @@ fn gitcomet_bin() -> PathBuf {
}
panic!(
"gitcomet-app binary path was not found. Tried CARGO_BIN_EXE_gitcomet-app, \
CARGO_BIN_EXE_gitcomet_app, and a fallback relative to current test executable"
"gitcomet binary path was not found. Tried CARGO_BIN_EXE_gitcomet and a fallback relative to current test executable"
);
}
@ -30,7 +29,7 @@ fn gitcomet_bin_from_current_exe() -> Option<PathBuf> {
let profile_dir = deps_dir.parent()?;
let exe_suffix = std::env::consts::EXE_SUFFIX;
for bin_name in ["gitcomet-app", "gitcomet_app"] {
for bin_name in ["gitcomet"] {
let candidate = profile_dir.join(format!("{bin_name}{exe_suffix}"));
if candidate.is_file() {
return Some(candidate);
@ -48,7 +47,7 @@ where
Command::new(gitcomet_bin())
.args(args)
.output()
.expect("gitcomet-app command to run")
.expect("gitcomet command to run")
}
fn run_gitcomet_in_dir<I, S>(dir: &Path, args: I) -> Output
@ -60,7 +59,7 @@ where
.current_dir(dir)
.args(args)
.output()
.expect("gitcomet-app command to run")
.expect("gitcomet command to run")
}
fn run_gitcomet_in_dir_with_global_env<I, S>(
@ -78,7 +77,7 @@ where
.current_dir(dir)
.args(args)
.output()
.expect("gitcomet-app command to run")
.expect("gitcomet command to run")
}
fn git_config_get(repo_dir: &Path, key: &str) -> Option<String> {
@ -2447,7 +2446,7 @@ fn standalone_mergetool_auto_crlf_subchunk_preserves_line_endings() {
// ── Setup E2E integration ────────────────────────────────────────────
//
// These tests verify that `gitcomet-app setup --local` produces config that
// These tests verify that `gitcomet setup --local` produces config that
// actually works when Git invokes `git mergetool` / `git difftool`. This
// closes the gap between "config keys are written" and "the configured tool
// is invoked end-to-end" — directly validating acceptance criteria 2-3 from
@ -2592,12 +2591,12 @@ fn git_config_get_global_with_env(env: &IsolatedGlobalGitEnv, key: &str) -> Opti
}
}
/// After `gitcomet-app setup --local`, `git mergetool` should invoke
/// gitcomet-app's built-in 3-way merge for conflicted files.
/// After `gitcomet setup --local`, `git mergetool` should invoke
/// gitcomet's built-in 3-way merge for conflicted files.
///
/// For a true content conflict, gitcomet-app exits 1 and git mergetool
/// For a true content conflict, gitcomet exits 1 and git mergetool
/// restores the original file (expected behavior with trustExitCode=true).
/// We verify the tool was invoked by checking for gitcomet-app's specific
/// We verify the tool was invoked by checking for gitcomet's specific
/// stderr messages, which differ from git's own merge output.
#[test]
fn setup_local_enables_git_mergetool_end_to_end() {
@ -2631,33 +2630,33 @@ fn setup_local_enables_git_mergetool_end_to_end() {
"expected merge conflict but git merge succeeded"
);
// 3. Run `git mergetool` — should invoke gitcomet-app via setup config.
// 3. Run `git mergetool` — should invoke gitcomet via setup config.
// DISPLAY is removed so guiDefault=auto selects the headless tool.
let mt = setup_e2e_git_capture(repo, &["mergetool"]);
let mt_stderr = String::from_utf8_lossy(&mt.stderr);
// 4. Verify gitcomet-app was invoked by checking for its specific stderr
// messages. "conflict(s) remain" is emitted by gitcomet-app's mergetool
// 4. Verify gitcomet was invoked by checking for its specific stderr
// messages. "conflict(s) remain" is emitted by gitcomet's mergetool
// mode and is NOT part of git's own output (git says "fix conflicts and
// then commit the result" instead).
assert!(
mt_stderr.contains("conflict(s) remain"),
"expected gitcomet-app's conflict message in mergetool stderr\n\
"expected gitcomet's conflict message in mergetool stderr\n\
stdout: {}\nstderr: {}",
String::from_utf8_lossy(&mt.stdout),
mt_stderr,
);
// Also verify gitcomet-app's "CONFLICT (content)" message is present.
// Also verify gitcomet's "CONFLICT (content)" message is present.
assert!(
mt_stderr.contains("CONFLICT (content)"),
"expected CONFLICT marker from gitcomet-app\nstderr: {}",
"expected CONFLICT marker from gitcomet\nstderr: {}",
mt_stderr,
);
}
/// After `gitcomet-app setup --local`, `git difftool` should invoke
/// gitcomet-app's built-in diff and produce unified diff output.
/// After `gitcomet setup --local`, `git difftool` should invoke
/// gitcomet's built-in diff and produce unified diff output.
#[test]
fn setup_local_enables_git_difftool_end_to_end() {
if !require_git_shell_for_setup_integration_tests() {
@ -2677,7 +2676,7 @@ fn setup_local_enables_git_difftool_end_to_end() {
setup_e2e_commit(repo, "initial");
write_file(&repo.join("file.txt"), "line1\nMODIFIED\nline3\n");
// 3. Run `git difftool` — should invoke gitcomet-app difftool.
// 3. Run `git difftool` — should invoke gitcomet difftool.
// DISPLAY is removed so guiDefault=auto selects the headless tool.
let dt = setup_e2e_git_capture(repo, &["difftool"]);
let dt_text = output_text(&dt);
@ -2688,7 +2687,7 @@ fn setup_local_enables_git_difftool_end_to_end() {
"git difftool should exit 0\n{dt_text}"
);
// gitcomet-app difftool produces unified diff output.
// gitcomet difftool produces unified diff output.
let stdout = String::from_utf8_lossy(&dt.stdout);
assert!(
stdout.contains("@@"),
@ -2704,7 +2703,7 @@ fn setup_local_enables_git_difftool_end_to_end() {
);
}
/// After `gitcomet-app setup --local`, quoted stage variables in the generated
/// After `gitcomet setup --local`, quoted stage variables in the generated
/// mergetool command must preserve paths containing spaces/unicode.
#[test]
fn setup_local_mergetool_handles_spaced_unicode_path_end_to_end() {
@ -2760,7 +2759,7 @@ fn setup_local_mergetool_handles_spaced_unicode_path_end_to_end() {
);
}
/// After `gitcomet-app setup --local`, quoted stage variables in the generated
/// After `gitcomet setup --local`, quoted stage variables in the generated
/// difftool command must preserve paths containing spaces/unicode.
#[test]
fn setup_local_difftool_handles_spaced_unicode_path_end_to_end() {
@ -2808,7 +2807,7 @@ fn setup_local_difftool_handles_spaced_unicode_path_end_to_end() {
);
}
/// `gitcomet-app setup` (global scope) should configure an isolated global
/// `gitcomet setup` (global scope) should configure an isolated global
/// gitconfig so `git mergetool` works end-to-end without local repo config.
#[test]
fn setup_global_enables_git_mergetool_end_to_end_with_isolated_global_config() {
@ -2885,7 +2884,7 @@ fn setup_global_enables_git_mergetool_end_to_end_with_isolated_global_config() {
);
}
/// `gitcomet-app setup` (global scope) should configure an isolated global
/// `gitcomet setup` (global scope) should configure an isolated global
/// gitconfig so `git difftool` works end-to-end without local repo config.
#[test]
fn setup_global_enables_git_difftool_end_to_end_with_isolated_global_config() {
@ -2942,7 +2941,7 @@ fn setup_global_enables_git_difftool_end_to_end_with_isolated_global_config() {
);
}
/// `gitcomet-app setup` (global scope) should make both headless and GUI
/// `gitcomet setup` (global scope) should make both headless and GUI
/// mergetool entries discoverable via `git mergetool --tool-help`.
#[test]
fn setup_global_mergetool_tool_help_lists_headless_and_gui_entries() {
@ -2994,7 +2993,7 @@ fn setup_global_mergetool_tool_help_lists_headless_and_gui_entries() {
);
}
/// `gitcomet-app setup` (global scope) should make both headless and GUI
/// `gitcomet setup` (global scope) should make both headless and GUI
/// difftool entries discoverable via `git difftool --tool-help`.
#[test]
fn setup_global_difftool_tool_help_lists_headless_and_gui_entries() {
@ -3058,7 +3057,7 @@ fn help_flag_exits_zero() {
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("gitcomet-app"),
stdout.contains("gitcomet"),
"help output should mention the binary name"
);
}
@ -3073,7 +3072,7 @@ fn version_flag_exits_zero() {
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("gitcomet-app"),
stdout.contains("gitcomet"),
"version output should mention the binary name"
);
}

View file

@ -60,16 +60,16 @@
<Component Id='MainBinary' Guid='*'>
<File
Id='MainExeFile'
Name='gitcomet-app.exe'
Name='gitcomet.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\gitcomet-app.exe'
Source='$(var.CargoTargetBinDir)\gitcomet.exe'
KeyPath='yes'/>
</Component>
<Component Id='LauncherBinary' Guid='*'>
<File
Id='LauncherExeFile'
Name='GitComet.exe'
Name='gitcomet-launcher.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\gitcomet-launcher.exe'
KeyPath='yes'/>
@ -86,7 +86,7 @@
Directory='GitCometProgramMenuDir'
Name='GitComet'
Description='Git GUI built with GPUI'
Target='[Bin]GitComet.exe'
Target='[Bin]gitcomet-launcher.exe'
WorkingDirectory='APPLICATIONFOLDER'
Icon='ProductICO'
IconIndex='0'

View file

@ -199,9 +199,10 @@ write_release_checksums() {
-printf '%P\n' \
| LC_ALL=C sort
)
echo ""
}
# `Release` is parsed as a single deb822 paragraph. Blank lines would split
# the checksum stanzas into separate paragraphs and make APT ignore them.
{
echo "Origin: ${origin}"
echo "Label: ${label}"
@ -211,7 +212,6 @@ write_release_checksums() {
echo "Architectures: ${architecture}"
echo "Components: ${component}"
echo "Description: ${description}"
echo ""
write_release_checksums "MD5Sum" md5sum
write_release_checksums "SHA256" sha256sum
write_release_checksums "SHA512" sha512sum
@ -220,12 +220,11 @@ write_release_checksums() {
run_gpg() {
local -a args
args=(--batch --yes --pinentry-mode loopback --local-user "$signing_key")
args+=("$@")
if [[ -n "$gpg_passphrase" ]]; then
gpg "${args[@]}" --passphrase-fd 0 <<<"$gpg_passphrase"
gpg "${args[@]}" --passphrase-fd 0 "$@" <<<"$gpg_passphrase"
else
gpg "${args[@]}"
gpg "${args[@]}" "$@"
fi
}

View file

@ -1,4 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
RUSTFLAGS="-C symbol-mangling-version=v0" cargo build -p gitcomet-app --all-targets --profile=release-with-debug
RUSTFLAGS="-C symbol-mangling-version=v0" cargo build -p gitcomet --all-targets --profile=release-with-debug

122
scripts/generate-homebrew-cask.sh Executable file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: scripts/generate-homebrew-cask.sh \
--version VERSION \
--github-repo OWNER/REPO \
--arm-dmg PATH \
--intel-dmg PATH \
--output PATH
Generates a Homebrew cask for GitComet from macOS DMG artifacts.
USAGE
}
version=""
github_repo=""
arm_dmg=""
intel_dmg=""
out_path=""
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
version="${2:-}"
shift 2
;;
--github-repo)
github_repo="${2:-}"
shift 2
;;
--arm-dmg)
arm_dmg="${2:-}"
shift 2
;;
--intel-dmg)
intel_dmg="${2:-}"
shift 2
;;
--output)
out_path="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown arg: $1" >&2
usage
exit 2
;;
esac
done
if [[ -z "$version" || -z "$github_repo" || -z "$arm_dmg" || -z "$intel_dmg" || -z "$out_path" ]]; then
echo "All arguments are required." >&2
usage
exit 2
fi
if ! [[ "$github_repo" =~ ^[^/]+/[^/]+$ ]]; then
echo "Invalid --github-repo '$github_repo'. Expected OWNER/REPO." >&2
exit 2
fi
if [[ ! -f "$arm_dmg" ]]; then
echo "arm DMG not found: $arm_dmg" >&2
exit 1
fi
if [[ ! -f "$intel_dmg" ]]; then
echo "intel DMG not found: $intel_dmg" >&2
exit 1
fi
sha256_file() {
local file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file" | awk '{print $1}'
return
fi
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" | awk '{print $1}'
return
fi
echo "No SHA256 tool found (sha256sum or shasum required)." >&2
exit 1
}
arm_sha="$(sha256_file "$arm_dmg")"
intel_sha="$(sha256_file "$intel_dmg")"
mkdir -p "$(dirname "$out_path")"
cat > "$out_path" <<EOF2
cask "gitcomet" do
arch arm: "arm64", intel: "x86_64"
version "${version}"
sha256 arm: "${arm_sha}", intel: "${intel_sha}"
url "https://github.com/${github_repo}/releases/download/v#{version}/gitcomet-v#{version}-macos-#{arch}.dmg"
name "GitComet"
desc "Fast, resource-efficient Git GUI written in Rust"
homepage "https://github.com/${github_repo}"
depends_on macos: ">= :ventura"
app "GitComet.app"
caveats do
<<~EOS
Optional CLI:
brew install gitcomet-cli
EOS
end
end
EOF2
echo "Generated Homebrew cask: $out_path"

View file

@ -110,8 +110,8 @@ linux_name="$(basename "$linux_tar")"
mkdir -p "$(dirname "$out_path")"
cat > "$out_path" <<EOF2
class Gitcomet < Formula
desc "Fast, resource-efficient Git GUI written in Rust"
class GitcometCli < Formula
desc "GitComet command-line binary"
homepage "https://github.com/${github_repo}"
version "${version}"
license "AGPL-3.0-only"
@ -136,11 +136,11 @@ class Gitcomet < Formula
end
def install
bin.install Dir["**/gitcomet-app"].fetch(0)
bin.install "gitcomet"
end
test do
assert_match "Usage", shell_output("#{bin}/gitcomet-app --help")
assert_match "Usage", shell_output("#{bin}/gitcomet --help")
end
end
EOF2

View file

@ -6,7 +6,7 @@ usage() {
Usage: scripts/install-linux.sh [--release|--debug] [--prefix PATH] [--no-build]
Installs:
- binary to <prefix>/bin/gitcomet-app
- binary to <prefix>/bin/gitcomet
- desktop entry to ~/.local/share/applications/gitcomet.desktop
- icons to ~/.local/share/icons/hicolor/<size>x<size>/apps/gitcomet.png
sizes: 32, 48, 128, 256, 512
@ -32,14 +32,14 @@ while [[ $# -gt 0 ]]; do
done
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
bin_src="${repo_root}/target/${mode}/gitcomet-app"
bin_src="${repo_root}/target/${mode}/gitcomet"
if [[ $build -eq 1 && ! -x "$bin_src" ]]; then
cargo_mode_flag=()
if [[ "$mode" == "release" ]]; then
cargo_mode_flag=(--release)
fi
(cd "$repo_root" && cargo build -p gitcomet-app "${cargo_mode_flag[@]}")
(cd "$repo_root" && cargo build -p gitcomet "${cargo_mode_flag[@]}")
fi
if [[ ! -x "$bin_src" ]]; then
@ -53,12 +53,12 @@ appdir="${XDG_DATA_HOME:-${HOME}/.local/share}/applications"
iconsroot="${XDG_DATA_HOME:-${HOME}/.local/share}/icons/hicolor"
icon_sizes=(32 48 128 256 512)
install -Dm755 "$bin_src" "${bindir}/gitcomet-app"
install -Dm755 "$bin_src" "${bindir}/gitcomet"
# Install desktop file with absolute Exec path so it works even if ~/.local/bin isn't on PATH.
tmp_desktop="$(mktemp)"
trap 'rm -f "$tmp_desktop"' EXIT
sed "s|^Exec=.*$|Exec=${bindir}/gitcomet-app|g" \
sed "s|^Exec=.*$|Exec=${bindir}/gitcomet|g" \
"${repo_root}/assets/linux/gitcomet.desktop" >"$tmp_desktop"
install -Dm644 "$tmp_desktop" "${appdir}/gitcomet.desktop"
@ -71,7 +71,7 @@ command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$
command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache "${iconsroot}" >/dev/null 2>&1 || true
echo "Installed GitComet:"
echo " ${bindir}/gitcomet-app"
echo " ${bindir}/gitcomet"
echo " ${appdir}/gitcomet.desktop"
for size in "${icon_sizes[@]}"; do
echo " ${iconsroot}/${size}x${size}/apps/gitcomet.png"

View file

@ -94,14 +94,14 @@ if [[ "$arch" != "$host_arch" ]]; then
fi
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
bin_src="${repo_root}/target/${mode}/gitcomet-app"
bin_src="${repo_root}/target/${mode}/gitcomet"
if [[ $build -eq 1 && ! -x "$bin_src" ]]; then
cargo_mode_flag=()
if [[ "$mode" == "release" ]]; then
cargo_mode_flag=(--release)
fi
(cd "$repo_root" && cargo build -p gitcomet-app "${cargo_mode_flag[@]}" --locked --features ui-gpui,gix --bins)
(cd "$repo_root" && cargo build -p gitcomet "${cargo_mode_flag[@]}" --locked --features ui-gpui,gix --bins)
fi
if [[ ! -x "$bin_src" ]]; then
@ -129,8 +129,8 @@ resources_dir="${contents_dir}/Resources"
rm -rf "$release_dir"
mkdir -p "$macos_dir" "$resources_dir"
install -m755 "$bin_src" "${macos_dir}/gitcomet-app"
install -m755 "$bin_src" "${release_dir}/gitcomet-app"
install -m755 "$bin_src" "${macos_dir}/gitcomet"
install -m755 "$bin_src" "${release_dir}/gitcomet"
install -m644 "${repo_root}/README.md" "${release_dir}/README.md"
install -m644 "${repo_root}/LICENSE-AGPL-3.0" "${release_dir}/LICENSE-AGPL-3.0"
install -m644 "${repo_root}/NOTICE" "${release_dir}/NOTICE"
@ -158,7 +158,7 @@ cat > "${contents_dir}/Info.plist" <<PLIST
<key>CFBundleDisplayName</key>
<string>GitComet</string>
<key>CFBundleExecutable</key>
<string>gitcomet-app</string>
<string>gitcomet</string>
<key>CFBundleIdentifier</key>
<string>ai.autoexplore.gitcomet</string>
<key>CFBundleIconFile</key>

View file

@ -1 +1 @@
cargo wix crates\gitcomet-app\Cargo.toml -p gitcomet-app --profile release --nocapture --output dist\gitcomet-local-test.msi
cargo wix crates\gitcomet\Cargo.toml -p gitcomet --profile release --nocapture --output dist\gitcomet-local-test.msi

View file

@ -18,7 +18,7 @@ appdir="${XDG_DATA_HOME:-${HOME}/.local/share}/applications"
iconsroot="${XDG_DATA_HOME:-${HOME}/.local/share}/icons/hicolor"
icon_sizes=(32 48 128 256 512)
rm -f "${bindir}/gitcomet-app"
rm -f "${bindir}/gitcomet"
rm -f "${appdir}/gitcomet.desktop"
for size in "${icon_sizes[@]}"; do
rm -f "${iconsroot}/${size}x${size}/apps/gitcomet.png"

151
scripts/update-aur.sh Executable file
View file

@ -0,0 +1,151 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: scripts/update-aur.sh \
--aur-dir PATH \
--version VERSION \
--binary-tar PATH \
--source-tar PATH \
[--verify-source]
Updates PKGBUILD metadata for the GitHub-hosted AUR mirror repo, regenerates
.SRCINFO, and optionally verifies the referenced sources with makepkg.
USAGE
}
aur_dir=""
version=""
binary_tar=""
source_tar=""
verify_source="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--aur-dir)
aur_dir="${2:-}"
shift 2
;;
--version)
version="${2:-}"
shift 2
;;
--binary-tar)
binary_tar="${2:-}"
shift 2
;;
--source-tar)
source_tar="${2:-}"
shift 2
;;
--verify-source)
verify_source="true"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown arg: $1" >&2
usage
exit 2
;;
esac
done
if [[ -z "$aur_dir" || -z "$version" || -z "$binary_tar" || -z "$source_tar" ]]; then
echo "All required arguments must be provided." >&2
usage
exit 2
fi
version="${version#v}"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
echo "Invalid --version '$version'. Expected semver like 1.2.3 or 1.2.3-rc.1." >&2
exit 2
fi
pkgbuild="${aur_dir}/PKGBUILD"
srcinfo="${aur_dir}/.SRCINFO"
expected_binary_name="gitcomet-v${version}-linux-x86_64.tar.gz"
expected_source_name="gitcomet-source-v${version}.tar.gz"
if [[ ! -f "$pkgbuild" ]]; then
echo "PKGBUILD not found: $pkgbuild" >&2
exit 1
fi
if [[ ! -f "$binary_tar" ]]; then
echo "Binary tarball not found: $binary_tar" >&2
exit 1
fi
if [[ ! -f "$source_tar" ]]; then
echo "Source tarball not found: $source_tar" >&2
exit 1
fi
if [[ "$(basename "$binary_tar")" != "$expected_binary_name" ]]; then
echo "Binary tarball must be named $expected_binary_name." >&2
exit 2
fi
if [[ "$(basename "$source_tar")" != "$expected_source_name" ]]; then
echo "Source tarball must be named $expected_source_name." >&2
exit 2
fi
sha256_file() {
local file="$1"
sha256sum "$file" | awk '{print $1}'
}
binary_sha="$(sha256_file "$binary_tar")"
source_sha="$(sha256_file "$source_tar")"
GITCOMET_PKGVER="$version" \
GITCOMET_BIN_SHA="$binary_sha" \
GITCOMET_SRC_SHA="$source_sha" \
perl -0pi -e '
my $pkgver = $ENV{GITCOMET_PKGVER};
my $bin_sha = $ENV{GITCOMET_BIN_SHA};
my $src_sha = $ENV{GITCOMET_SRC_SHA};
s/^pkgver=.*/pkgver=$pkgver/m
or die "Failed to update pkgver\n";
s/^sha256sums=\([^)]+\)/sprintf("sha256sums=(\x27%s\x27\n \x27%s\x27)", $bin_sha, $src_sha)/mse
or die "Failed to update sha256sums\n";
' "$pkgbuild"
pushd "$aur_dir" >/dev/null
makepkg --printsrcinfo > "$srcinfo"
cleanup_binary=""
cleanup_source=""
if [[ "$verify_source" == "true" ]]; then
staged_binary="$PWD/$expected_binary_name"
staged_source="$PWD/$expected_source_name"
if [[ "$binary_tar" != "$staged_binary" ]]; then
cp "$binary_tar" "$staged_binary"
cleanup_binary="$staged_binary"
fi
if [[ "$source_tar" != "$staged_source" ]]; then
cp "$source_tar" "$staged_source"
cleanup_source="$staged_source"
fi
cleanup() {
[[ -n "$cleanup_binary" ]] && rm -f "$cleanup_binary"
[[ -n "$cleanup_source" ]] && rm -f "$cleanup_source"
}
trap cleanup EXIT
makepkg --verifysource
fi
popd >/dev/null
echo "Updated AUR metadata in $aur_dir"

View file

@ -7,7 +7,7 @@ Usage: scripts/valgrind_cdp <run|on|off|open> [path]
Commands:
run [binary] Run a binary under callgrind.
Default: ./target/release-with-debug/gitcomet-app
Default: ./target/release-with-debug/gitcomet
on Enable callgrind instrumentation.
off Disable callgrind instrumentation.
open [outfile] Open a callgrind output file in kcachegrind.
@ -18,7 +18,7 @@ EOF
cmd="${1:-run}"
case "$cmd" in
run)
binary="${2:-./target/release-with-debug/gitcomet-app}"
binary="${2:-./target/release-with-debug/gitcomet}"
valgrind \
--tool=callgrind \
--callgrind-out-file=callgrind.out \