Skip to content

Latest commit

 

History

History
553 lines (456 loc) · 23.8 KB

File metadata and controls

553 lines (456 loc) · 23.8 KB

git-graph — ТЗ

Минималистичный self-hosted веб-сервис, который клонирует указанный git-репозиторий и показывает граф коммитов в timeline-стиле (по образцу GitKraken/SourceTree).

Репозиторий проекта: https://github.com/SergeyAP/git-graph Лицензия: MIT Целевое использование: деплой как поддомен gitgraph.<domain> через docker-compose, всё с корня /.


1. Цели и не-цели

Цели (MVP)

  • Один docker-compose up — и через несколько минут граф доступен в браузере
  • Конфигурация только через .env, никаких UI-настроек
  • Timeline-стиль: вертикальная ось = время, день растягивается под количество коммитов
  • Hover на коммите подсвечивает его дорожку, остальные приглушаются
  • Бесконечный скролл: при достижении конца — кнопка "Загрузить ещё", расширяющая историю на N дней
  • Поддержка только публичных репозиториев в MVP
  • Все ветки origin

Не-цели (не делаем в MVP, но архитектура не должна мешать добавить)

  • Несколько репозиториев
  • Приватные репозитории (но GITHUB_TOKEN уже в .env зарезервирован)
  • Просмотр diff/файлов коммита
  • Поиск, фильтры
  • Аутентификация пользователей
  • Темы оформления (только dark)
  • Аватары авторов

2. Стек

  • Backend: Go 1.22+, стандартная библиотека + минимум зависимостей
    • os/exec для git-команд (не go-git — он плохо дружит с shallow и большими репо)
    • chi или стандартный net/http для роутинга
    • embed для вшивания фронта в бинарь
  • Frontend: Svelte + Vite (SPA-режим), TypeScript
  • Сборка: Один Docker-образ multi-stage:
    1. node стейдж собирает фронт в dist/
    2. golang стейдж компилирует бинарь с embed
    3. alpine финальный с git-клиентом
  • Хранилище: Volume /data — внутри /data/repo (bare-репозиторий) и /data/state.json (метаданные)
  • БД: нет

3. Конфигурация (.env)

# Что клонируем
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.


4. Архитектура

┌─────────────────────────────────────────────┐
│  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

5. Жизненный цикл

При старте контейнера

  1. Читаем .env
  2. Читаем /data/state.json (если есть)
  3. Если 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-дата)
  4. Если совпадает:
    • git fetch --all --force в существующем /data/repo
  5. Запускаем HTTP-сервер
  6. Запускаем фоновый воркер
  7. Healthcheck /healthz возвращает 200 только после успешного init

Фоновый fetch

Каждые FETCH_INTERVAL_SECONDS:

  1. cd /data/repo && git fetch --all --force --prune
  2. Если есть новые коммиты → инвалидировать кеш графа
  3. Логировать результат

Запрос графа от фронта

GET /api/commits?since=<ISO>&until=<ISO>

  • Бэк строит граф для диапазона
  • Кеш в памяти по ключу (since, until, head_sha)

Расширение истории

POST /api/expand

  1. Читаем текущий current_since из state
  2. Считаем новый since = current_since - EXPAND_DEPTH_DAYS
  3. git fetch --shallow-since="<новая дата>" --no-single-branch
  4. Обновляем state.json
  5. Возвращаем новый since фронту
  6. Фронт делает повторный GET /api/commits с расширенным диапазоном

6. API контракт

GET /api/status

{
  "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
}

GET /api/commits?since=<ISO>&until=<ISO>

Параметры опциональны. По умолчанию — весь загруженный диапазон.

{
  "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"
    }
  ]
}

POST /api/expand

Body пустое. Ответ:

{
  "previous_since": "2026-05-16T00:00:00Z",
  "new_since": "2026-05-02T00:00:00Z",
  "added_commits": 87
}

GET /healthz

  • 503 пока идёт первоначальный clone
  • 200 после успешного init

7. Алгоритм построения графа

Сердце проекта. Реализовать в internal/graph/builder.go.

Вход

Результат git log --all --date-order --pretty=format:'%H|%P|%s|%an|%ae|%aI'

Шаги

  1. Прочитать refs: git for-each-ref --format='%(objectname) %(refname)' — получить мапу sha → []ref
  2. Прочитать коммиты в обратном хронологическом порядке (новые сверху)
  3. Назначить дорожки (lanes):
    • Поддерживаем массив activeLanes []string — каждый элемент = SHA, который мы ожидаем увидеть в этой дорожке
    • Для каждого коммита:
      • Если SHA уже в activeLanes — это его дорожка
      • Если несколько дорожек ждут этот SHA — берём минимальный индекс, остальные сливаем (это merge target)
      • Если SHA не в activeLanes — открываем новую дорожку (первый коммит ветки)
    • После обработки коммита:
      • В его дорожке заменяем SHA на parents[0]
      • Для каждого дополнительного родителя (parents[1..N]) открываем новые дорожки или находим существующую с этим SHA
  4. Цвет дорожки = palette[laneIndex % len(palette)]
  5. Edges (рёбра графа) — пары (commit, parent) с указанием from_lane → to_lane

Палитра (8 цветов, мягкие, читаемые на тёмном)

var palette = []string{
    "#e06c75", // мягкий красный
    "#98c379", // зелёный
    "#61afef", // голубой
    "#c678dd", // фиолетовый
    "#e5c07b", // жёлтый
    "#56b6c2", // циан
    "#d19a66", // оранжевый
    "#abb2bf", // серый
}

Edge cases

  • Octopus merge (>2 родителей): рисуем все рёбра, открываем все нужные дорожки
  • Корневые коммиты (нет родителей): дорожка закрывается
  • Shallow boundary: если у коммита есть parent, которого нет в shallow — родитель не существует с точки зрения графа, рисуем "обрыв" дорожки маленьким хвостиком вниз

8. Frontend

Структура

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

Layout (схематически)

┌──────┬─────────────┬─────────────────────────────────┐
│ дата │  граф       │  список коммитов                │
│      │ (SVG)       │                                  │
│  Май │             │                                  │
│  26  │  ● ● ●      │  80cd7768  Format kernel...     │
│  25  │  │ │ │      │  ceb0f649  feat(guardian)...    │
│  ... │  │ │ │      │                                  │
└──────┴─────────────┴─────────────────────────────────┘
                     [Загрузить ещё (14 дней)]

Timeline и растягивание дня

  • Минимальная высота дня: 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)) с цветом дорожки
  • Текст светлый
  • Если на одном коммите несколько тегов — рисуем стопкой

Hover

При наведении мыши на коммит:

  • Находим его lane
  • Всем SVG-элементам и точкам с другим lane добавляем класс dimmed (opacity: 0.2, transition: 0.15s)
  • Строке коммита справа также подсвечиваем фон
  • При уходе мыши — возврат через 150ms

Реализация: один svelte-store hoveredLane: number | null, реактивно прокидывается во все компоненты.

Бесконечный скролл

  • IntersectionObserver на последней строке
  • За 200px до конца показываем кнопку "Загрузить ещё (N дней)"
  • Клик → POST /api/expandGET /api/commits → дописываем снизу
  • Во время загрузки — спиннер вместо кнопки
  • Если expand вернул 0 новых коммитов 3 раза подряд (значит репо короче) — прячем кнопку, показываем "Конец истории"

Состояние "ещё клонируется"

  • При старте фронт делает GET /api/status
  • Если ready: false — показываем экран "Cloning repository, please wait..." с поллингом каждые 2 секунды
  • Когда ready: true — загружаем граф

9. Структура репозитория

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

10. Dockerfile (схематически)

# === 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"]

11. docker-compose.yml

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:

12. Milestones

M1 — Backend skeleton (1-2 дня)

  • Конфиг из .env
  • Clone/fetch/expand работают руками
  • state.json сохраняется и читается
  • GET /healthz, GET /api/status
  • Логирование

DoD: docker-compose up клонирует репо, healthz отвечает 200, status показывает корректные данные.

M2 — Graph algorithm (2-3 дня)

  • 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 корректный, проверен глазами и тестами.

M3 — Frontend skeleton (2 дня)

  • Vite + Svelte + TS setup
  • API клиент с типами
  • Layout: дата | граф | список
  • Рендер коммитов справа (без графа)
  • Состояние "cloning"

DoD: Видно список коммитов, дата слева, кнопка "Загрузить ещё" работает.

M4 — SVG graph rendering (3-4 дня)

  • Timeline с растягиванием дней
  • Точки и линии на дорожках
  • Merge-кривые (Bezier)
  • Цвета палитры
  • Подписи тегов и веток

DoD: Граф визуально похож на скрин референса. Все edge-кейсы из M2 рисуются корректно.

M5 — Interactivity (1 день)

  • Hover-подсветка дорожки
  • Плавные переходы
  • IntersectionObserver для бесконечного скролла

DoD: При наведении видна целевая дорожка, остальные приглушены, скролл подгружает историю.

M6 — Polish & ship (1 день)

  • README с скриншотом и инструкцией
  • .env.example
  • Лицензия MIT
  • CI: GitHub Actions для сборки Docker-образа на push в main

DoD: Сторонний человек клонирует репу, делает cp .env.example .env, правит REPO_URL, запускает docker-compose up, и через пару минут видит граф.


13. Definition of Done (общий MVP)

  • 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 лицензия, репозиторий публичный

14. Что точно не делаем в MVP (на случай если агент спросит)

  • Аватары (любые)
  • Просмотр diff
  • Поиск
  • Фильтры
  • Авторизация
  • Светлая тема
  • Несколько репозиториев
  • Приватные репозитории (но GITHUB_TOKEN в .env уже есть как заглушка)
  • Webhook от GitHub (только polling)
  • Сравнение коммитов
  • Любая запись/мутации в git (только чтение)

15. Открытые вопросы (решает агент по своему усмотрению, если не критично)

  • Какой именно роутер — chi, gorilla/mux или net/http. Рекомендую chi.
  • Структурное логирование — slog (стандартное, Go 1.21+) рекомендую.
  • Формат курсора пагинации — пока не нужен, всё одной пачкой по диапазону дат.
  • Конкретная Bezier-кривая для merge — на усмотрение, главное чтобы выглядело плавно.