Минималистичный self-hosted веб-сервис, который клонирует указанный git-репозиторий и показывает граф коммитов в timeline-стиле (по образцу GitKraken/SourceTree).
Репозиторий проекта: https://github.com/SergeyAP/git-graph
Лицензия: MIT
Целевое использование: деплой как поддомен gitgraph.<domain> через docker-compose, всё с корня /.
- Один docker-compose up — и через несколько минут граф доступен в браузере
- Конфигурация только через
.env, никаких UI-настроек - Timeline-стиль: вертикальная ось = время, день растягивается под количество коммитов
- Hover на коммите подсвечивает его дорожку, остальные приглушаются
- Бесконечный скролл: при достижении конца — кнопка "Загрузить ещё", расширяющая историю на N дней
- Поддержка только публичных репозиториев в MVP
- Все ветки origin
- Несколько репозиториев
- Приватные репозитории (но
GITHUB_TOKENуже в.envзарезервирован) - Просмотр diff/файлов коммита
- Поиск, фильтры
- Аутентификация пользователей
- Темы оформления (только dark)
- Аватары авторов
- Backend: Go 1.22+, стандартная библиотека + минимум зависимостей
os/execдля git-команд (не go-git — он плохо дружит с shallow и большими репо)chiили стандартныйnet/httpдля роутингаembedдля вшивания фронта в бинарь
- Frontend: Svelte + Vite (SPA-режим), TypeScript
- Сборка: Один Docker-образ multi-stage:
nodeстейдж собирает фронт вdist/golangстейдж компилирует бинарь сembedalpineфинальный с git-клиентом
- Хранилище: Volume
/data— внутри/data/repo(bare-репозиторий) и/data/state.json(метаданные) - БД: нет
# Что клонируем
REPO_URL=https://github.com/Mindburn-Labs/helm-ai-kernel
# Глубина истории
INITIAL_DEPTH_DAYS=10 # первый clone — shallow на N дней
EXPAND_DEPTH_DAYS=14 # шаг расширения по кнопке "Загрузить ещё"
# Фоновое обновление
FETCH_INTERVAL_SECONDS=300 # как часто делать git fetch --force
# Сервер
PORT=8080
DATA_DIR=/data
LOG_LEVEL=info # debug|info|warn|error
# Зарезервировано на будущее (приватные репы)
GITHUB_TOKEN=Важно: при старте сервис сравнивает REPO_URL из .env с тем, что записан в /data/state.json. Если отличается — удаляет /data/repo и клонирует заново. Если совпадает — просто git fetch --force.
┌─────────────────────────────────────────────┐
│ Docker container (один процесс — Go бинарь)│
│ │
│ ┌──────────────────────────────────────┐ │
│ │ HTTP server (:8080) │ │
│ │ - GET / — SPA │ │
│ │ - GET /api/commits — JSON │ │
│ │ - POST /api/expand — расширение │ │
│ │ - GET /api/status — состояние │ │
│ │ - GET /healthz │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ Background fetcher (goroutine) │ │
│ │ тикер каждые FETCH_INTERVAL_SECONDS │ │
│ │ → git fetch --all --force │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ Graph builder (in-memory cache) │ │
│ │ парсит git log → lanes → JSON │ │
│ │ инвалидируется при fetch/expand │ │
│ └──────────────────────────────────────┘ │
│ │
└──────────────┬──────────────────────────────┘
│
▼
/data (volume)
├── repo/ # bare git
└── state.json # repo_url, current_since, last_fetch
- Читаем
.env - Читаем
/data/state.json(если есть) - Если
state.repo_url != env.REPO_URLили папки/data/repoнет:- Удаляем
/data/repo git clone --bare --shallow-since="<INITIAL_DEPTH_DAYS> days ago" --no-single-branch <REPO_URL> /data/repo- Сохраняем
state.jsonс новымrepo_urlиcurrent_since = <INITIAL_DEPTH_DAYS> days ago(точная ISO-дата)
- Удаляем
- Если совпадает:
git fetch --all --forceв существующем/data/repo
- Запускаем HTTP-сервер
- Запускаем фоновый воркер
- Healthcheck
/healthzвозвращает 200 только после успешного init
Каждые FETCH_INTERVAL_SECONDS:
cd /data/repo && git fetch --all --force --prune- Если есть новые коммиты → инвалидировать кеш графа
- Логировать результат
GET /api/commits?since=<ISO>&until=<ISO>
- Бэк строит граф для диапазона
- Кеш в памяти по ключу
(since, until, head_sha)
POST /api/expand
- Читаем текущий
current_sinceиз state - Считаем новый
since = current_since - EXPAND_DEPTH_DAYS git fetch --shallow-since="<новая дата>" --no-single-branch- Обновляем
state.json - Возвращаем новый
sinceфронту - Фронт делает повторный
GET /api/commitsс расширенным диапазоном
{
"repo_url": "https://github.com/Mindburn-Labs/helm-ai-kernel",
"current_since": "2026-05-16T00:00:00Z",
"last_fetch": "2026-05-26T14:30:00Z",
"ready": true,
"head_branches": ["main", "develop"],
"total_commits_loaded": 1342
}Параметры опциональны. По умолчанию — весь загруженный диапазон.
{
"since": "2026-05-16T00:00:00Z",
"until": "2026-05-26T14:30:00Z",
"commits": [
{
"sha": "80cd776832abc...",
"short_sha": "80cd7768",
"parents": ["ceb0f64906..."],
"subject": "Format kernel zero-trust adapter code",
"author_name": "mindburnlabs",
"author_email": "dev@example.com",
"timestamp": "2026-05-21T13:20:46Z",
"lane": 2,
"refs": {
"branches": ["origin/main"],
"tags": ["v1.2.0"]
},
"is_merge": false
}
],
"lanes": [
{
"index": 0,
"color": "#e06c75",
"head_branch": "origin/main",
"first_commit_sha": "...",
"last_commit_sha": "..."
}
],
"edges": [
{
"from_sha": "80cd...",
"to_sha": "ceb0...",
"from_lane": 2,
"to_lane": 2,
"type": "straight"
},
{
"from_sha": "77828b...",
"to_sha": "ef3fc7...",
"from_lane": 0,
"to_lane": 3,
"type": "merge"
}
]
}Body пустое. Ответ:
{
"previous_since": "2026-05-16T00:00:00Z",
"new_since": "2026-05-02T00:00:00Z",
"added_commits": 87
}- 503 пока идёт первоначальный clone
- 200 после успешного init
Сердце проекта. Реализовать в internal/graph/builder.go.
Результат git log --all --date-order --pretty=format:'%H|%P|%s|%an|%ae|%aI'
- Прочитать refs:
git for-each-ref --format='%(objectname) %(refname)'— получить мапуsha → []ref - Прочитать коммиты в обратном хронологическом порядке (новые сверху)
- Назначить дорожки (lanes):
- Поддерживаем массив
activeLanes []string— каждый элемент = SHA, который мы ожидаем увидеть в этой дорожке - Для каждого коммита:
- Если SHA уже в
activeLanes— это его дорожка - Если несколько дорожек ждут этот SHA — берём минимальный индекс, остальные сливаем (это merge target)
- Если SHA не в
activeLanes— открываем новую дорожку (первый коммит ветки)
- Если SHA уже в
- После обработки коммита:
- В его дорожке заменяем SHA на
parents[0] - Для каждого дополнительного родителя (parents[1..N]) открываем новые дорожки или находим существующую с этим SHA
- В его дорожке заменяем SHA на
- Поддерживаем массив
- Цвет дорожки =
palette[laneIndex % len(palette)] - Edges (рёбра графа) — пары (commit, parent) с указанием from_lane → to_lane
var palette = []string{
"#e06c75", // мягкий красный
"#98c379", // зелёный
"#61afef", // голубой
"#c678dd", // фиолетовый
"#e5c07b", // жёлтый
"#56b6c2", // циан
"#d19a66", // оранжевый
"#abb2bf", // серый
}- Octopus merge (>2 родителей): рисуем все рёбра, открываем все нужные дорожки
- Корневые коммиты (нет родителей): дорожка закрывается
- Shallow boundary: если у коммита есть parent, которого нет в shallow — родитель не существует с точки зрения графа, рисуем "обрыв" дорожки маленьким хвостиком вниз
web/
src/
App.svelte
lib/
api.ts # типизированный клиент API
graph/
Timeline.svelte # главный компонент
Lane.svelte # одна вертикальная линия
CommitRow.svelte # строка справа
DateAxis.svelte # шкала дат слева
TagLabel.svelte # горизонтальный лейбл тега
BranchHead.svelte # вертикальная подпись имени ветки сверху
store.ts # svelte store: коммиты, hover state
app.css
main.ts
index.html
vite.config.ts
package.json
┌──────┬─────────────┬─────────────────────────────────┐
│ дата │ граф │ список коммитов │
│ │ (SVG) │ │
│ Май │ │ │
│ 26 │ ● ● ● │ 80cd7768 Format kernel... │
│ 25 │ │ │ │ │ ceb0f649 feat(guardian)... │
│ ... │ │ │ │ │ │
└──────┴─────────────┴─────────────────────────────────┘
[Загрузить ещё (14 дней)]
- Минимальная высота дня:
MIN_DAY_HEIGHT_PX = 60 - Минимальная высота коммита:
COMMIT_ROW_HEIGHT_PX = 36 - Высота дня =
max(MIN_DAY_HEIGHT_PX, commitsInDay * COMMIT_ROW_HEIGHT_PX) - Внутри дня коммиты равномерно распределены по высоте
- Дни без коммитов всё равно занимают
MIN_DAY_HEIGHT_PX(чтобы шкала была монотонной) - На шкале слева подписан первый день месяца крупно (
Май), числа — мельче - Если день без коммитов идёт подряд несколько дней — схлопываем в один блок
…высотойMIN_DAY_HEIGHT_PX
Имя ветки (HEAD):
- Над верхней точкой дорожки
- Вертикальная подпись, написанная сверху вниз (CSS
writing-mode: vertical-rl) - Цвет — цвет дорожки
- Только для текущих HEAD веток (то есть для дорожек, верхний коммит которых = реальный HEAD ветки)
Теги:
- Горизонтальная плашка рядом с точкой коммита
- Полупрозрачный фон (
rgba(color, 0.25)) с цветом дорожки - Текст светлый
- Если на одном коммите несколько тегов — рисуем стопкой
При наведении мыши на коммит:
- Находим его
lane - Всем SVG-элементам и точкам с другим lane добавляем класс
dimmed(opacity: 0.2,transition: 0.15s) - Строке коммита справа также подсвечиваем фон
- При уходе мыши — возврат через 150ms
Реализация: один svelte-store hoveredLane: number | null, реактивно прокидывается во все компоненты.
- IntersectionObserver на последней строке
- За 200px до конца показываем кнопку "Загрузить ещё (N дней)"
- Клик →
POST /api/expand→GET /api/commits→ дописываем снизу - Во время загрузки — спиннер вместо кнопки
- Если
expandвернул 0 новых коммитов 3 раза подряд (значит репо короче) — прячем кнопку, показываем "Конец истории"
- При старте фронт делает
GET /api/status - Если
ready: false— показываем экран "Cloning repository, please wait..." с поллингом каждые 2 секунды - Когда
ready: true— загружаем граф
git-graph/
├── cmd/
│ └── server/
│ └── main.go # entrypoint
├── internal/
│ ├── config/
│ │ └── config.go # парсинг .env
│ ├── git/
│ │ ├── repo.go # clone, fetch, expand
│ │ └── log.go # парсинг git log
│ ├── graph/
│ │ ├── builder.go # алгоритм lanes
│ │ ├── builder_test.go
│ │ └── palette.go
│ ├── api/
│ │ ├── server.go
│ │ ├── commits.go
│ │ ├── expand.go
│ │ └── status.go
│ ├── state/
│ │ └── state.go # state.json read/write
│ └── worker/
│ └── fetcher.go # фоновый воркер
├── web/ # Svelte SPA
│ └── ... (см. выше)
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── .gitignore
├── go.mod
├── go.sum
├── LICENSE # MIT
└── README.md
# === stage 1: build frontend ===
FROM node:20-alpine AS frontend
WORKDIR /app
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# === stage 2: build backend ===
FROM golang:1.22-alpine AS backend
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=frontend /app/dist ./web/dist
RUN CGO_ENABLED=0 go build -o /git-graph ./cmd/server
# === stage 3: runtime ===
FROM alpine:3.19
RUN apk add --no-cache git ca-certificates
COPY --from=backend /git-graph /usr/local/bin/git-graph
VOLUME ["/data"]
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s --start-period=60s \
CMD wget -qO- http://localhost:8080/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/git-graph"]version: "3.9"
services:
git-graph:
build: .
container_name: git-graph
restart: unless-stopped
env_file: .env
ports:
- "${PORT:-8080}:8080"
volumes:
- git-graph-data:/data
volumes:
git-graph-data:- Конфиг из .env
- Clone/fetch/expand работают руками
state.jsonсохраняется и читаетсяGET /healthz,GET /api/status- Логирование
DoD: docker-compose up клонирует репо, healthz отвечает 200, status показывает корректные данные.
internal/git/log.go— парсингinternal/graph/builder.go— назначение lanes, edges- Юнит-тесты на 5+ синтетических кейсов (линейная история, простой merge, octopus, branch + back-merge, shallow boundary)
GET /api/commitsотдаёт корректный JSON
DoD: на тестовом репо из 50 коммитов с 3 ветками и 2 merge — JSON корректный, проверен глазами и тестами.
- Vite + Svelte + TS setup
- API клиент с типами
- Layout: дата | граф | список
- Рендер коммитов справа (без графа)
- Состояние "cloning"
DoD: Видно список коммитов, дата слева, кнопка "Загрузить ещё" работает.
- Timeline с растягиванием дней
- Точки и линии на дорожках
- Merge-кривые (Bezier)
- Цвета палитры
- Подписи тегов и веток
DoD: Граф визуально похож на скрин референса. Все edge-кейсы из M2 рисуются корректно.
- Hover-подсветка дорожки
- Плавные переходы
- IntersectionObserver для бесконечного скролла
DoD: При наведении видна целевая дорожка, остальные приглушены, скролл подгружает историю.
- README с скриншотом и инструкцией
.env.example- Лицензия MIT
- CI: GitHub Actions для сборки Docker-образа на push в main
DoD: Сторонний человек клонирует репу, делает cp .env.example .env, правит REPO_URL, запускает docker-compose up, и через пару минут видит граф.
-
docker-compose upна чистой машине поднимает сервис - За время clone доступен
/healthzсо статусом 503 и UI с экраном ожидания - После clone доступен граф репо
Mindburn-Labs/helm-ai-kernelза последние 10 дней - Timeline-стиль: дата слева, дни растягиваются
- Все ветки origin отрисованы как дорожки
- Имена HEAD веток — вертикальной подписью сверху, цвет = цвет дорожки
- Теги — горизонтальной полупрозрачной плашкой рядом с точкой
- Hover на коммите подсвечивает его дорожку, остальные приглушены
- "Загрузить ещё" расширяет историю на 14 дней
- Фоновый
git fetch --forceкаждые 5 минут (или из.env) - При изменении
REPO_URLв.envи рестарте — старое удаляется, новое клонируется - README с инструкцией и скриншотом
- MIT лицензия, репозиторий публичный
- Аватары (любые)
- Просмотр diff
- Поиск
- Фильтры
- Авторизация
- Светлая тема
- Несколько репозиториев
- Приватные репозитории (но
GITHUB_TOKENв .env уже есть как заглушка) - Webhook от GitHub (только polling)
- Сравнение коммитов
- Любая запись/мутации в git (только чтение)
- Какой именно роутер —
chi,gorilla/muxилиnet/http. Рекомендуюchi. - Структурное логирование —
slog(стандартное, Go 1.21+) рекомендую. - Формат курсора пагинации — пока не нужен, всё одной пачкой по диапазону дат.
- Конкретная Bezier-кривая для merge — на усмотрение, главное чтобы выглядело плавно.