Compare commits

..
9 Commits
Author SHA1 Message Date
Christoph Brandau a6c86daf62 feat(gitea): upload release assets to Gitea
publish / Build and publish Windows installer (release) Successful in 28m43s
publish / Build and publish Ubuntu AppImage (release) Successful in 22m26s
publish / Build and publish gitty-desktop to AUR (release) Successful in 45m32s
This update introduces functionality to upload release assets to Gitea. A new helper function has been added to handle the API requests and manage the upload process, ensuring that existing assets are not duplicated. The main function has been modified to call this new upload feature after resolving the version.

- Implemented asset upload to Gitea releases
- Added checks to prevent duplicate uploads
- Enhanced error handling for missing configuration variables
2026-08-10 14:07:08 +02:00
Christoph Brandau f88b6961d1 fix(history): preserve branch colors across parent lanes
Keep branch metadata attached to each parent lane so the history graph
can render consistent colors and markers after merges and branch
transitions. Also soften the styling for behind-path segments to better
match the updated dot coloring.
2026-08-10 10:48:31 +02:00
Christoph eb65c1def7 fix(workflows): improve AUR SSH key handling and clone/push retries
publish / Build and publish Windows installer (release) Successful in 22m29s
publish / Build and publish Ubuntu AppImage (release) Successful in 37m9s
publish / Build and publish gitty-desktop to AUR (release) Failing after 52m11s
This update enhances the workflow for interacting with the AUR by
adding robust error handling for SSH key retrieval and repository
cloning/pushing. It implements retry logic with exponential backoff
for both SSH host key retrieval and git operations, improving
resilience against transient network issues.

- Added retries for SSH key retrieval with exponential backoff
- Implemented retry logic for cloning and pushing to AUR repository
- Ensured script exits gracefully with error messages on failure
2026-08-04 22:23:50 +02:00
Christoph 96e3805568 fix(dialog): improve escape key handling for dialogs
publish / Build and publish Windows installer (release) Successful in 23m4s
publish / Build and publish Ubuntu AppImage (release) Successful in 44m12s
publish / Build and publish gitty-desktop to AUR (release) Failing after 54m38s
The handling of the Escape key has been refined to ensure that it
closes the correct dialog based on the current state. Additionally,
the z-index for the compare dialog backdrop has been updated to
ensure proper layering with other dialogs.

- Enhanced Escape key functionality for better user experience
- Updated z-index for compare dialog backdrop to avoid overlap
2026-08-04 21:17:04 +02:00
Christoph 2d9a10e85c feat(workflows): implement timeout for pacman installation
publish / Build and publish Windows installer (release) Successful in 24m55s
publish / Build and publish Ubuntu AppImage (release) Successful in 33m25s
publish / Build and publish gitty-desktop to AUR (release) Canceled after 16m4s
This update introduces a timeout mechanism for the pacman installation process in the app builder workflow. If the installation does not complete within 30 minutes, it will terminate and log a timeout message, improving the robustness of the build process.

- Adds a timeout for the pacman command to prevent indefinite hangs
- Implements a heartbeat to monitor the installation progress
- Provides clear error messages for timeout and failure scenarios
2026-08-04 20:26:20 +02:00
Christoph 12d1b2657f fix(workflow): improve Arch Linux package installation process
publish / Build and publish Ubuntu AppImage (release) Canceled after 6m6s
publish / Build and publish Windows installer (release) Canceled after 6m4s
publish / Build and publish gitty-desktop to AUR (release) Canceled after 6m2s
The workflow for building and publishing to Arch Linux has been updated to
enhance the package installation process. This includes adding multiple
mirror sources to ensure reliability and implementing retry logic for
package installation attempts.

- Removed dependency on the Windows publish job
- Added backup mirrors for Arch Linux package management
- Implemented retry logic for package installation failures
2026-08-04 20:19:20 +02:00
Christoph b0b4fb69cb Merge branch 'main' of https://git.cbsk-tech.de/Christoph/GitLite 2026-08-04 20:11:51 +02:00
Christoph f3a3fcc079 feat(ci): update workflow for Windows and add Ubuntu publishing
The workflow has been modified to focus on building and publishing
Windows installers, with the previous matrix strategy removed.
Additionally, a new job for building and publishing Ubuntu AppImages
has been added, ensuring that the necessary dependencies and steps
are included for a successful build.

- Renamed job to reflect Windows installer publishing
- Added Ubuntu AppImage publishing workflow
- Simplified job structure by removing matrix strategy
2026-08-04 20:11:44 +02:00
Christoph 9d18f3af61 Update version to 2026.8.1 2026-08-04 19:35:08 +02:00
9 changed files with 280 additions and 63 deletions
+198 -49
View File
@@ -5,7 +5,8 @@ on:
types: [published]
jobs:
publish-tauri:
publish-windows:
name: Build and publish Windows installer
permissions:
contents: write
environment: production
@@ -30,25 +31,7 @@ jobs:
run:
working-directory: GitLite
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
# - platform: 'macos-latest'
# args: '--target aarch64-apple-darwin'
# - platform: 'macos-latest'
# args: '--target x86_64-apple-darwin'
- platform: "windows-latest"
bundles: "nsis"
updater_platform: "windows-x86_64"
no_strip: ""
- platform: "ubuntu-22.04"
bundles: "appimage"
updater_platform: "linux-x86_64"
no_strip: "1"
runs-on: ${{ matrix.platform }}
runs-on: windows-latest
steps:
# cicd_tool/main.py expects the repo at <workspace>/GitLite, so check out there.
@@ -56,25 +39,11 @@ jobs:
with:
path: GitLite
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install uv
if: matrix.platform == 'ubuntu-22.04'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
- name: install rust toolchain
if: matrix.platform == 'ubuntu-22.04'
uses: dtolnay/rust-toolchain@stable
- name: install frontend dependencies
run: npm ci
@@ -88,7 +57,6 @@ jobs:
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
- name: Commit and Push Changes
if: matrix.platform == 'windows-latest'
shell: powershell
run: |
git config user.name '${{ vars.USERNAME_GIT }}'
@@ -139,15 +107,15 @@ jobs:
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
NO_STRIP: ${{ matrix.no_strip }}
run: npm run tauri -- build --bundles ${{ matrix.bundles }}
NO_STRIP: ""
run: npm run tauri -- build --bundles nsis
# ----------------------
# Upload to MinIO (via mc) and create latest.json
# ----------------------
- name: Upload artifacts to MinIO with cicd_tool
working-directory: GitLite/cicd_tool
env:
PUBLISH_PLATFORM: ${{ matrix.updater_platform }}
PUBLISH_PLATFORM: windows-x86_64
run: |
uv sync
uv run main.py
@@ -167,11 +135,66 @@ jobs:
- name: Prepare Arch job container
working-directory: /
run: |
pacman -Syu --noconfirm
pacman -S --needed --noconfirm \
base-devel curl git nodejs npm openssh rust \
webkit2gtk-4.1 gtk3 hicolor-icon-theme \
set -euo pipefail
# The runner image may contain only a single geo mirror. Seed several
# currently active HTTPS mirrors and retain the image's list as a
# fallback so one slow CDN cannot fail the complete release.
ARCH_MIRROR_BACKUP="$(mktemp)"
cp /etc/pacman.d/mirrorlist "$ARCH_MIRROR_BACKUP"
printf '%s\n' \
'Server = https://frankfurt.mirror.pkgbuild.com/$repo/os/$arch' \
'Server = https://mirror.osbeck.com/archlinux/$repo/os/$arch' \
'Server = https://mirror.ubrco.de/archlinux/$repo/os/$arch' \
'Server = https://ftp.halifax.rwth-aachen.de/archlinux/$repo/os/$arch' \
'Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch' \
> /etc/pacman.d/mirrorlist
sed -n '/^[[:space:]]*Server[[:space:]]*=/p' "$ARCH_MIRROR_BACKUP" \
>> /etc/pacman.d/mirrorlist
ARCH_PACKAGES=(
base-devel curl git nodejs npm openssh rust
webkit2gtk-4.1 gtk3 hicolor-icon-theme
libappindicator-gtk3 librsvg xdotool
)
for ARCH_INSTALL_ATTEMPT in 1 2 3; do
echo "pacman attempt $ARCH_INSTALL_ATTEMPT of 3"
timeout --signal=TERM 30m \
pacman -Syu --needed --noconfirm --noprogressbar \
"${ARCH_PACKAGES[@]}" \
</dev/null &
ARCH_PACMAN_PID=$!
(
ARCH_WAIT_SECONDS=0
while true; do
sleep 30
((ARCH_WAIT_SECONDS += 30))
echo "pacman is still running (${ARCH_WAIT_SECONDS}s elapsed)"
done
) &
ARCH_HEARTBEAT_PID=$!
ARCH_PACMAN_STATUS=0
wait "$ARCH_PACMAN_PID" || ARCH_PACMAN_STATUS=$?
kill "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
wait "$ARCH_HEARTBEAT_PID" 2>/dev/null || true
if [ "$ARCH_PACMAN_STATUS" -eq 0 ]; then
break
fi
if [ "$ARCH_PACMAN_STATUS" -eq 124 ]; then
echo "pacman attempt timed out after 30 minutes" >&2
fi
if [ "$ARCH_INSTALL_ATTEMPT" -eq 3 ]; then
echo "pacman failed after 3 attempts" >&2
exit 1
fi
sleep "$((ARCH_INSTALL_ATTEMPT * 5))"
done
- uses: actions/checkout@v5
with:
@@ -218,6 +241,8 @@ jobs:
AUR_GIT_NAME: ${{ vars.USERNAME_GIT }}
AUR_GIT_EMAIL: ${{ vars.EMAIL_GIT }}
run: |
set -euo pipefail
if [ -z "$AUR_SSH_PRIVATE_KEY" ]; then
echo "AUR_SSH_PRIVATE_KEY is required" >&2
exit 1
@@ -226,13 +251,48 @@ jobs:
install -d -m 0700 "$HOME/.ssh"
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur"
chmod 0600 "$HOME/.ssh/aur"
ssh-keyscan -H aur.archlinux.org >> "$HOME/.ssh/known_hosts"
chmod 0600 "$HOME/.ssh/known_hosts"
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes"
AUR_CHECKOUT="$(mktemp -d)/gitty-desktop"
git -c init.defaultBranch=master clone \
ssh://aur@aur.archlinux.org/gitty-desktop.git "$AUR_CHECKOUT"
AUR_RETRY_DELAYS=(30 60 120 240)
AUR_KNOWN_HOSTS_TEMP="$(mktemp)"
AUR_HOST_KEY_READY=0
for AUR_ATTEMPT in 1 2 3 4 5; do
echo "AUR host-key attempt $AUR_ATTEMPT of 5"
if timeout 30s ssh-keyscan -T 15 -H aur.archlinux.org > "$AUR_KNOWN_HOSTS_TEMP" \
&& [ -s "$AUR_KNOWN_HOSTS_TEMP" ]; then
AUR_HOST_KEY_READY=1
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ "$AUR_HOST_KEY_READY" -ne 1 ]; then
echo "Could not retrieve the AUR SSH host key after 5 attempts" >&2
exit 1
fi
install -m 0600 "$AUR_KNOWN_HOSTS_TEMP" "$HOME/.ssh/known_hosts"
export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=2"
AUR_CHECKOUT_ROOT="$(mktemp -d)"
AUR_CHECKOUT=""
for AUR_ATTEMPT in 1 2 3 4 5; do
AUR_CLONE_CANDIDATE="$AUR_CHECKOUT_ROOT/attempt-$AUR_ATTEMPT"
echo "AUR clone attempt $AUR_ATTEMPT of 5"
if timeout 3m git -c init.defaultBranch=master clone \
ssh://aur@aur.archlinux.org/gitty-desktop.git "$AUR_CLONE_CANDIDATE"; then
AUR_CHECKOUT="$AUR_CLONE_CANDIDATE"
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
echo "AUR is unavailable; retrying after backoff"
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ -z "$AUR_CHECKOUT" ]; then
echo "Could not clone the AUR repository after 5 attempts" >&2
exit 1
fi
cp "$AUR_SOURCE_DIR/PKGBUILD" "$AUR_SOURCE_DIR/.SRCINFO" "$AUR_CHECKOUT/"
cd "$AUR_CHECKOUT"
@@ -246,4 +306,93 @@ jobs:
fi
git commit -m "Update to $PACKAGE_VERSION"
git push origin HEAD:master
AUR_PUSHED=0
for AUR_ATTEMPT in 1 2 3 4 5; do
echo "AUR push attempt $AUR_ATTEMPT of 5"
if timeout 3m git push origin HEAD:master; then
AUR_PUSHED=1
break
fi
if [ "$AUR_ATTEMPT" -lt 5 ]; then
echo "AUR push failed; retrying after backoff"
sleep "${AUR_RETRY_DELAYS[$((AUR_ATTEMPT - 1))]}"
fi
done
if [ "$AUR_PUSHED" -ne 1 ]; then
echo "Could not publish to AUR after 5 attempts" >&2
exit 1
fi
publish-ubuntu:
name: Build and publish Ubuntu AppImage
permissions:
contents: write
environment: production
env:
GITEA_API: https://git.cbsk-tech.de/api/v1
OWNER: Christoph
REPO: GitLite
GITEA_TOKEN: ${{ secrets.ACTIONS_TOKEN }}
GITEA_FALLBACK_TOKEN: ${{ github.token }}
MINIO_ENDPOINT: ${{ vars.MINIO_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
ARTIFACT_BASE_URL: ${{ vars.ARTIFACT_BASE_URL }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
defaults:
run:
working-directory: GitLite
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
with:
path: GitLite
- name: Install Ubuntu dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install frontend dependencies
run: npm ci
- name: Update package.json version
run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
- name: Update tauri.conf.json version
env:
RELEASE_VERSION: ${{ github.ref_name }}
run: |
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
- name: Build Tauri App
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD}}
NO_STRIP: "1"
run: npm run tauri -- build --bundles appimage
- name: Upload artifacts to MinIO with cicd_tool
working-directory: GitLite/cicd_tool
env:
PUBLISH_PLATFORM: linux-x86_64
run: |
uv sync
uv run main.py
+65
View File
@@ -1,11 +1,15 @@
import argparse
import io
import json
import mimetypes
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, List, NamedTuple, Optional
from urllib.parse import urlparse
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen
from minio import Minio
from minio.error import S3Error
@@ -329,6 +333,65 @@ def _publish_target(
)
def _gitea_request(url: str, token: str, data: Optional[bytes] = None, **headers: str):
request = Request(url, data=data, headers={"Authorization": f"token {token}", **headers})
return urlopen(request, timeout=120)
def _upload_gitea_release_asset(version: str, artifact: Path) -> None:
"""Attach the installer to the Gitea release that triggered this build."""
api_url = os.environ.get("GITEA_API", "").rstrip("/")
owner = os.environ.get("OWNER", "")
repo = os.environ.get("REPO", "")
token = os.environ.get("GITEA_TOKEN") or os.environ.get("GITEA_FALLBACK_TOKEN")
if not all((api_url, owner, repo, token)):
raise ConfigurationError(
"GITEA_API, OWNER, REPO and GITEA_TOKEN are required to publish "
"the release asset."
)
release_url = (
f"{api_url}/repos/{quote(owner, safe='')}/{quote(repo, safe='')}"
f"/releases/tags/{quote(version, safe='')}"
)
with _gitea_request(release_url, token) as response:
release = json.load(response)
existing_names = {
asset.get("name") for asset in release.get("assets", []) if isinstance(asset, dict)
}
if artifact.name in existing_names:
print(f"Release asset {artifact.name} already exists; skipping upload.")
return
boundary = f"----gitty-{uuid.uuid4().hex}"
content_type = mimetypes.guess_type(artifact.name)[0] or "application/octet-stream"
body = b"".join(
(
f"--{boundary}\r\n".encode(),
(
f'Content-Disposition: form-data; name="attachment"; '
f'filename="{artifact.name}"\r\n'
).encode(),
f"Content-Type: {content_type}\r\n\r\n".encode(),
artifact.read_bytes(),
f"\r\n--{boundary}--\r\n".encode(),
)
)
upload_url = (
f"{api_url}/repos/{quote(owner, safe='')}/{quote(repo, safe='')}"
f"/releases/{release['id']}/assets?{urlencode({'name': artifact.name})}"
)
with _gitea_request(
upload_url,
token,
data=body,
**{"Content-Type": f"multipart/form-data; boundary={boundary}"},
):
pass
print(f"Attached {artifact.name} to Gitea release {version}.")
def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
resolved_version = _resolve_version(version)
artifact_base_url = os.environ.get("ARTIFACT_BASE_URL", "")
@@ -385,6 +448,8 @@ def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
artifact_base_url=artifact_base_url,
)
_upload_gitea_release_asset(resolved_version, primary)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitty",
"version": "2026.7.22",
"version": "2026.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "2026.7.22",
"version": "2026.8.1",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitty",
"version": "2026.7.22",
"version": "2026.8.1",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "2026.7.22",
"version": "2026.8.1",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run dev",
+2 -2
View File
@@ -3967,10 +3967,10 @@
helpOpen = false;
return;
}
if (event.key === "Escape" && fileHistoryDialogOpen && !isBusy) closeFileHistoryDialog();
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && fileHistoryDialogOpen && !isBusy) closeFileHistoryDialog();
else if (event.key === "Escape" && repoTabContextMenu) closeRepoTabContextMenu();
else if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
+4 -4
View File
@@ -2689,9 +2689,8 @@
filter: drop-shadow(0 0 3px rgba(224,160,64,0.24));
}
.graph-svg path.graph-segment-behind {
stroke: #7aacff;
stroke-dasharray: 4 4;
filter: drop-shadow(0 0 3px rgba(122,172,255,0.24));
filter: drop-shadow(0 0 3px rgba(122,172,255,0.2));
}
.graph-dot {
position: absolute;
@@ -2715,8 +2714,8 @@
box-shadow: 0 0 0 1px rgba(224,160,64,0.42), 0 0 10px rgba(224,160,64,0.16);
}
.graph-dot.behind {
background: #7aacff;
box-shadow: 0 0 0 1px rgba(122,172,255,0.46), 0 0 10px rgba(122,172,255,0.18);
background: var(--dot-color, #5a8cf8);
box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8), 0 0 10px rgba(122,172,255,0.18);
}
.graph-dot.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
.graph-dot.merge {
@@ -2882,6 +2881,7 @@
overflow: hidden;
}
.file-history-dialog-backdrop { z-index: 70; }
.compare-dialog-backdrop { z-index: 80; }
.file-history-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
+1 -1
View File
@@ -216,7 +216,7 @@
</script>
<div
class="dialog-backdrop"
class="dialog-backdrop compare-dialog-backdrop"
role="presentation"
>
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
+6 -3
View File
@@ -140,15 +140,18 @@
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
afterBranches[col] = after[col] ? commitBranches.slice() : [];
const firstParent = commit.parents[0] ?? null;
after[col] = firstParent;
afterBranches[col] = firstParent
? (branchMembership.get(firstParent) ?? commitBranches).slice()
: [];
const fromCommit = new Set<number>([col]);
for (let p = 1; p < commit.parents.length; p++) {
let slot = after.indexOf(null);
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
after[slot] = commit.parents[p];
afterBranches[slot] = [];
afterBranches[slot] = (branchMembership.get(commit.parents[p]) ?? commitBranches).slice();
fromCommit.add(slot);
}