Scaffold GitManager multi-repo dashboard

Runnable skeleton per AGENT.md: Echo server (/, /help, /healthz, /api/repos), read-only repo scanner with in-memory index, the internal/git boundary, the <repo-list> web component with design tokens, and dev tooling (Dockerfile, docker-compose, air, .env.example).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 16:34:43 -04:00
parent c24604fe65
commit 1a2ad98c33
20 changed files with 1527 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Hot reload for the dev container (AGENT.md §2). Static assets under web/static
# and components/ are served live from disk, so they need no rebuild; only Go
# and template (.html) changes trigger a rebuild + restart.
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/gitmanager ./cmd/server"
bin = "./tmp/gitmanager"
include_ext = ["go", "html"]
exclude_dir = ["tmp", "bin", ".git", "repos"]
delay = 500
stop_on_error = true
[log]
time = true
[misc]
clean_on_exit = true
+45
View File
@@ -0,0 +1,45 @@
# ---------------------------------------------------------------------------
# GitManager configuration. Copy this file to `.env` and fill in values.
# NEVER commit a real `.env` (it is git-ignored). See AGENT.md §1.5.
# ---------------------------------------------------------------------------
# Address the HTTP server binds to. Localhost-only by default: there is NO
# authentication (AGENT.md §0). Only bind to a non-local interface deliberately.
LISTEN_ADDR=127.0.0.1:8080
# Roots to scan for Git repositories, comma-separated (absolute paths).
# Inside Docker these must be the *container* paths that the host roots are
# mounted to (see docker-compose.yml). Example: /repos,/work/other
GIT_REPO_ROOTS=/repos
# Path to the git binary. "git" resolves it from PATH (git is installed in the
# container image).
GIT_BIN=git
# --- Repo scanner (read-only; AGENT.md §5) ---------------------------------
# How often the background scanner refreshes repo state.
SCAN_INTERVAL=30s
# Max directory depth to descend under each root when discovering repos.
SCAN_MAX_DEPTH=4
# Directory names to skip during discovery, comma-separated.
SCAN_IGNORE=node_modules,vendor,.cache
# Allow the scanner to run `git fetch` (network) to keep ahead/behind counts
# current. OFF by default — no unsolicited network. (AGENT.md §5)
SCAN_FETCH_ENABLED=false
# --- Logging (AGENT.md §7) --------------------------------------------------
# "dev" uses a readable console handler; anything else uses structured JSON.
APP_ENV=dev
# Optional: also append structured logs to this file. Leave empty to disable.
LOG_FILE=
# --- Forge integration — OPTIONAL, read-only, token-gated (AGENT.md §8) -----
# With no token the feature is simply absent; the rest of the app is unaffected.
GITHUB_TOKEN=
GITLAB_TOKEN=
+16
View File
@@ -0,0 +1,16 @@
# Environment (never commit real secrets/config — see .env.example)
.env
# Build output
/bin/
/tmp/
gitmanager
gitmanager.exe
# Logs
*.log
# Editor / OS
.DS_Store
.idea/
.vscode/
+409
View File
@@ -0,0 +1,409 @@
# AGENT.md
> **This file is the contract for Claude Code working on this repository.**
> Read it fully before any task. It defines the architecture, the nonnegotiable
> rules, and the selfdocumenting workflow you must follow on every change.
>
> When this file and the code disagree, this file wins until you are explicitly
> told to change the file. If a request conflicts with a rule here, **pause and
> ask** before proceeding.
---
## 0. What this project is
A standalone, longlived development application: a **multirepo web dashboard for
Git**. It scans one or more configured roots on the host, discovers every Git
repository under them, and presents a control panel over the whole set — perrepo
status (clean/dirty, ahead/behind, current branch), branches, remotes, recent
commits, stashes, and tags — plus userinitiated Git actions (fetch, pull, push,
checkout, create branch, commit, view diffs, manage remotes). It has peruser
dockable UI panels and is architected so that host/forge integrations
(GitHub/GitLab pull and mergerequests) can be attached without touching the
core.
This is a **dev environment first**. It is expected to grow continuously via
incremental requests ("add X functionality"). Every addition must keep the
selfdocumenting workflow in Section 9 intact.
**Trust model:** there is **no user authentication**. The app is a singleoperator
tool that acts as the host user against local repositories. It binds to
**localhost** by default; exposing it to a network is the operator's decision and
their responsibility. Do not add a login/auth layer without asking.
---
## 1. Nonnegotiable architectural laws
These are hard rules. Do not violate them, do not "optimize them away," and do
not assume an exception without asking.
### 1.1 Web Components are the only UI unit (ActiveXspirit, enforced)
The UI is built **exclusively** from native Web Components. The design intent is
deliberately modeled on the *selfcontained control* philosophy of Windows98era
ActiveX/OCX controls — **the spirit, not the dead COM/binary/registry mechanism**.
Concretely, **every** UI element that does real work MUST be:
- A **Custom Element v1** (`customElements.define('x-thing', ...)`).
- Encapsulated with **Shadow DOM** (`attachShadow({mode:'open'})`). Styles and
DOM are isolated. No leaking global CSS into components, no reaching out of a
component into another component's internals.
- **Selfinstantiating**: the page declares the element in markup (or via a thin
loader). The browser "constructs" it. The page does not micromanage it.
- **Independently live**: on connect, each component **fetches its own data and
renders itself**. Components do **not** wait for a central controller to hand
them data. One component being slow or failing must not block another.
- **Selfcontained**: no shared mutable global state. Crosscomponent
communication happens only via (a) DOM custom events (`dispatchEvent` of a
`CustomEvent` that bubbles/composes) or (b) explicit attributes/properties set
on the element. Treat each component like a control you could drop onto any
page and it would just work.
- **Lifecyclecorrect**: implement `connectedCallback` / `disconnectedCallback`
and clean up timers, listeners, and inflight fetches on disconnect.
If you are tempted to introduce a heavy SPA framework (React/Vue/Angular/Svelte)
for the component layer — **stop and ask.** The default answer is no. Vanilla
Web Components are the chosen model. (Small, dependencyfree helpers like
`lit` *may* be proposed, but only with explicit approval, because `lit` still
produces standardsbased custom elements.)
**Shared styling — design tokens.** The one sanctioned crossshadowboundary
styling channel is **CSS custom properties**. The shared theme palette and corner
radii live as tokens (`--color-*`, `--surface-*`, `--border-*`, `--fill-*`,
`--radius*`) in `web/static/app.css :root`; custom properties inherit *into* every
shadow root, so a component references them with `var(--…)` for all shared colors
and radii instead of hardcoding hex. This is **not** the forbidden "leaking global
CSS" — no global *selectors* reach into a component; it is the deliberate theming
layer (change a token once, the whole UI follows). Status feedback is semantic —
destructive/error use the `--color-danger*` set, success uses `--color-success`,
and Gitstate colors (ahead/behind, clean/dirty, conflicted) get their own
semantic tokens. **New or edited components MUST consume the tokens for shared
values** rather than reintroducing hardcoded hex.
### 1.2 Component foldering & percomponent docs
- There is a **toplevel `components/` folder**. All web components live there.
- **Each component gets its own folder**: `components/<component-name>/`.
- **Each component folder contains its own `<component-name>.md`** (see Section 9)
that records the **history and intent** of that component and acts as the
scoped context when working on it. Componentspecific detail belongs in that
file, **not** in this AGENT.md. This keeps AGENT.md lean as the app grows.
### 1.3 Git is the system of record (the app never silently mutates a repo)
- **The Git repositories are the truth.** The app holds only a **derived,
readoptimized inmemory index** of repo state (discovered repos, their status,
branches, remotes, recent log), refreshed by the scanner (Section 5). That index
is a cache — it can be thrown away and rebuilt from the repos at any time.
- **All mutations go through explicit, userinitiated Git operations.** The app
**never** writes to a repository except in direct response to a user action
routed through the `internal/git` boundary. No background process ever mutates a
working tree, index, or ref.
- **The background scanner is readonly.** It may run `status`, `rev-list`,
`for-each-ref`, `log`, and (only when explicitly enabled) `fetch`. It must never
run a command that changes local state.
- **Never invent local persistence for domain state.** If something must survive a
restart and it isn't already in Git, it is either app config (`.env`, Section
1.4) or peruser UI state (browser `localStorage`, Section 4). Adding any other
datastore (SQLite, a serverside DB) requires asking first — the default is no.
### 1.4 Destructive Git operations are explicit, confirmed, and never automatic
Operations that can lose work or rewrite shared history are a distinct class and
must be treated as such:
- **Always require an explicit, deliberate user action** (a dedicated control, not
a side effect of another action) **and a confirmation** that names exactly what
will happen and to which repo/branch.
- This class includes at least: `push --force`/`--force-with-lease`, `reset --hard`,
`checkout`/`restore` that discards uncommitted changes, `clean -fd`, branch/tag
deletion, `stash drop`/`clear`, history rewrites (`rebase`, `commit --amend`),
and remote deletions.
- **Never chain a destructive operation into an automated flow** and never pick a
destructive default. Prefer the safe variant (`--force-with-lease` over
`--force`) and surface it as such.
### 1.5 Configuration via `.env`
Repo scan roots, the `git` binary path, scan/fetch behavior, the listen address,
and any optional forge tokens are all configurable through a single `.env` file. A
committed `.env.example` documents every variable. **Never commit a real `.env`**
and never hardcode paths, tokens, hosts, or the set of watched repositories.
### 1.6 Everything runs in Docker / dockercompose
The dev environment (the Go app + hot reload) is brought up with
`docker compose up`. Because the app operates on repositories that live on the
**host**, the compose file **mounts the configured repo roots into the container**
(readwrite, since Git operations write to them) along with whatever Git needs to
authenticate to remotes (SSH agent socket / mounted keys, or a credential helper).
The app must be runnable by a new developer with: clone → copy `.env.example` to
`.env` → set the repo roots → `docker compose up`. Document any hostside
prerequisites (SSH agent, credential helper) in `.env.example` and the help page.
---
## 2. Technology stack (locked unless told otherwise)
| Concern | Choice | Notes |
|---|---|---|
| Language | **Go** | Backend, page rendering, Git orchestration, scanner. |
| HTTP framework | **Echo** (`github.com/labstack/echo/v4`) | Idiomatic, fast, good middleware story. If you prefer Chi, ask first. |
| Page rendering | Go serverrendered HTML shell | Server emits the page + declares web components; components fetch their own data. Use `html/template`. |
| Git access | **System `git` via `os/exec`**, wrapped behind an `internal/git` interface | The system binary is authoritative: it honors the user's credential helpers, SSH keys, hooks, and config exactly. PureGo `go-git` may be proposed for cheap readonly queries, but only behind the same interface and only with approval. |
| Repo discovery / index | **Inmemory cache + `github.com/fsnotify/fsnotify`** (optional) | Scan roots for `.git`, hold an index, refresh on interval and/or on filesystem change. |
| Forge integration (PRs/MRs) | **Providerabstracted** (`internal/forge`) — GitHub via `github.com/google/go-github`, GitLab via `gitlab.com/gitlab-org/api/client-go` | Optional, readonly by default, enabled perhost when a token is configured. See Section 8. |
| Diff rendering | Server produces unified diff from `git`; client renders it in a component | No heavy client diff lib without asking. |
| Config | **`.env`** via `github.com/joho/godotenv` + a typed config struct | Section 1.5. |
| Logging | **`log/slog`** → stdout/stderr (structured), optional rotating file sink | See Section 7. No database sink (there is no database). |
| Hot reload (dev) | **air** (`github.com/air-verse/air`) | Inside the app container. |
Anything not in this table that you want to add as a dependency: **propose it and
wait for approval.** Keep the dependency surface small. Note in particular there is
**no database and no auth library** — do not add one without asking (Section 1.3,
Section 0).
---
## 3. Repository layout (target)
```
.
├── AGENT.md # this file — lean, architecture-level only
├── CHANGELOG.md # append-only running history of ALL changes (Section 9)
├── .env.example # every config var, documented, no secrets
├── docker-compose.yml # go app (+ hot reload); mounts host repo roots + git creds
├── Dockerfile # multi-stage build for the Go app (git installed in the image)
├── .air.toml # hot reload config (dev)
├── cmd/
│ └── server/main.go # entrypoint: wire config, git, scanner, router
├── internal/
│ ├── config/ # .env loading, typed config struct
│ ├── git/ # THE Git boundary: interface + os/exec impl (all git ops)
│ ├── repos/ # discovery, in-memory index/cache, refresh scanner worker
│ ├── forge/ # OPTIONAL seam: GitHub/GitLab PR/MR + remote metadata (provider-abstracted)
│ ├── logging/ # slog handler → stdout/stderr (+ optional file)
│ └── render/ # html/template page shell rendering
├── components/ # ALL web components live here (Section 1.2)
│ └── <component-name>/
│ ├── <component-name>.md # history + intent for THIS component (scoped context)
│ ├── <component-name>.js # the custom element
│ └── <component-name>.css # (optional) styles consumed inside shadow DOM
└── web/
├── static/ # shared static assets (app.css tokens, etc. — NOT component-specific)
└── templates/ # Go html/template page shells (incl. help.html)
```
> If a new concern doesn't fit cleanly, **ask** before inventing a new toplevel
> directory. Keep `components/` strictly for web components. There is deliberately
> **no `migrations/` and no `db/`** — see Section 1.3.
---
## 4. UI layout & peruser state (no server DB)
Because there is no authentication and no database, **peruser UI state lives in
the browser** (`localStorage`), namespaced under a `gitmanager.*` prefix:
- Dockable panels: the **repo list docked LEFT**, the **repo detail panel docked
RIGHT** by default. Dock options are `top`, `left`, `bottom`, `right`; positions
are remembered in `localStorage` and reapplied on load.
- Other view state (selected repo, active filter/search, expanded sections, chosen
branch view) also persists in `localStorage`.
- A **reset control** clears every `gitmanager.*` key and reloads to firstload
defaults (mirror the pattern: view state only — never touch the repos).
Serverside there is no session and no user record. Requests act as the single
host operator.
---
## 5. Repo discovery & the refresh scanner (readonly)
**Git is truth; the index is a derived cache.** Implement in `internal/repos`,
using the `internal/git` boundary for every command.
- **Discovery:** walk each configured root (`GIT_REPO_ROOTS`) for directories
containing `.git`, honoring a configurable max depth and ignore globs. Build an
inmemory index keyed by absolute repo path.
- **Refresh (readonly):** for each repo compute status (dirty/clean, staged vs
unstaged counts), current branch, ahead/behind vs upstream, remotes, and a short
recentcommit summary — all via readonly Git commands (Section 1.3). Refresh on
a **configurable interval** and, optionally, on filesystem change via `fsnotify`.
- **Optional background `fetch`:** disabled by default. Only when
`SCAN_FETCH_ENABLED=true` may the scanner run `git fetch` (network, readonly to
the working tree) to keep ahead/behind counts current. Ratelimit it.
- **Never block a request on a slow repo.** The dashboard reads from the index;
the scanner updates the index in the background. A single unreachable remote or
huge repo must not stall the others (perrepo timeouts, isolated goroutines).
Resulting data path:
```
Host repositories (system of record)
│ read-only scan (status / rev-list / for-each-ref / log [/ fetch if enabled])
internal/repos ──► in-memory index (per-repo state)
Echo JSON endpoints ──► web components (self-fetch)
User action ──► internal/git (os/exec) ──► repository mutated ──► index refresh
```
---
## 6. Dashboard & Git operations (client side)
The dashboard is composed of web components, each obeying Section 1 (shadow DOM,
selffetching, independent lifecycle, cleanup on disconnect). Expected surface
(create each with its own folder + `.md` as you build it):
- A **repo list / grid** of all discovered repos with status badges (branch,
dirty/clean, ahead/behind), searchable/filterable.
- A **repo detail panel** for the selected repo: branches, remotes, recent commit
log, stashes, tags.
- **Diff / commit views** rendered from serverproduced unified diffs.
- An **action surface** for Git operations. Safe operations (fetch, pull,
checkout, create branch, stage, commit, push) can proceed on a normal click;
**destructive operations follow Section 1.4** (explicit control + confirmation
naming the target).
- Crosscomponent communication is via bubbling/composed `CustomEvent`s (e.g. a
`repo:select` event the detail panel listens for) — never shared globals.
Server endpoints return JSON for the components to selffetch; mutating endpoints
route through `internal/git` and trigger an index refresh for the affected repo so
the UI reflects reality without a full rescan.
---
## 7. Logging (structured, to stdout/stderr)
- Use Go's **`log/slog`** as the logging API throughout.
- Emit **structured records to stdout/stderr** (JSON in nondev, a readable
console handler in dev). Capture: timestamp, level, message, structured
attributes, request id, and the repo path when an operation targets one.
- **Log every Git mutation** (the command, target repo/branch, and outcome) so the
operator has an audit trail of what the tool did on their behalf.
- Optionally also write to a **rotating file** when `LOG_FILE` is set. There is no
database sink — do not add one (Section 1.3).
---
## 8. Forge integration — OPTIONAL seam (PRs / MRs)
Viewing pull/merge requests requires talking to a hosting provider, which is
**outside** the "Git is the store" core. Keep it isolated and optional.
- Live in `internal/forge` behind a **provider interface** so GitHub, GitLab, and
others can drop in without touching the dashboard or the Git boundary.
- **Readonly by default**: list open PRs/MRs and their CI/check status for a repo
whose remote points at a supported host. Any write action (comment, merge,
approve) is a **separate, explicitlyrequested** capability — do not build write
paths without asking, and route them through Section 1.4 if destructive.
- **Enablement is perhost and tokengated.** Tokens come from `.env`
(e.g. `GITHUB_TOKEN`, `GITLAB_TOKEN`); with no token, the feature is simply
absent and the rest of the app works unchanged (**graceful degradation** — never
a hard dependency).
- The provider is inferred from a repo's remote URL. Never send repo data to a host
the user didn't configure.
Design the interface cleanly now; implement providers incrementally as requested.
---
## 9. Selfdocumenting workflow (MANDATORY on every change)
This is how the project documents itself so AGENT.md stays small and each
component carries its own context.
### 9.1 Root `CHANGELOG.md`
- **On every change you make**, append an entry to root `CHANGELOG.md`. Never
rewrite history; only append. Each entry:
```
## YYYY-MM-DD — <short title>
- **What:** what changed (files, behavior).
- **Why:** the intent / the request behind it.
- **Affects:** components/areas touched.
```
### 9.2 Percomponent `<component-name>.md`
- When you **create a component**, create `components/<name>/<name>.md` with:
```
# <component-name>
## Intent
What this component is for; the ActiveX-spirit contract it fulfills.
## Public surface
Tag name, attributes/properties, emitted events, what data it fetches and from where.
## History
- YYYY-MM-DD: created — <reason>.
- YYYY-MM-DD: <change> — <reason>.
## Notes / gotchas
```
- When you **work on an existing component**, **read its `.md` first** (it is the
scoped context for that component), make the change, then **append to its
History** and update Public surface if it changed.
### 9.3 Userfacing help page
- `web/templates/help.html` (served at `/help`, linked from the app header) explains
**how to use the app** for end users. **When a userfacing feature changes** — a new
control, a changed workflow, a removed/renamed option — **update the help page in the
same change**, the way you update the `CHANGELOG`. Keep it taskoriented (how to do
things), not implementation detail. Document any host prerequisites (SSH agent /
credential helper for pushing from the container).
### 9.4 Keep AGENT.md lean
- Componentspecific detail lives in the component's `.md`, **not here**. Once a
component has its own `.md`, **move any componentspecific detail out of
AGENT.md** into that file. AGENT.md stays architecturelevel only.
- If a change alters an **architectural law or stack choice** in this file,
update AGENT.md too — but only architecturelevel facts belong here.
---
## 10. How to take a new task ("add X functionality")
1. **Read** this AGENT.md, then any relevant component `.md` files.
2. **Check the rules** in Section 1. If the request conflicts, **pause and ask.**
3. If the work is UI: it is a **web component** in `components/<name>/` with its
own `.md`. No exceptions without asking.
4. If it touches repositories: go through the **`internal/git` boundary**, respect
**Git = system of record** (Section 1.3), and treat **destructive operations**
per Section 1.4. Never add a datastore.
5. If it needs a **new dependency** or a **new toplevel folder**, propose it and
wait for approval.
6. Implement, run it in the **dockercompose dev environment**, verify hot reload
and that it operates correctly against a real mounted repo.
7. **Document:** append to `CHANGELOG.md`; create/append the component `.md`;
update `help.html` if userfacing; trim AGENT.md if component detail crept in.
---
## 11. Open items to confirm before/while building
*(Claude Code: surface these to the human at the first relevant moment; don't
silently guess.)*
- **Repo discovery strategy:** recursive scan of `GIT_REPO_ROOTS` (max depth?
ignore globs?) vs an explicit list of repo paths. Default assumption: recursive
scan with a configurable depth.
- **Background `fetch`:** off by default (no unsolicited network). Confirm whether
it should be enabled, and the interval / rate limit, before turning it on.
- **Git access library:** system `git` via `os/exec` is the locked default
(Section 2). Confirm before introducing `go-git` for any read path.
- **Forge providers:** which to support first (GitHub? GitLab?), and whether any
**write** actions (merge/comment/approve) are ever in scope (default: readonly).
- **Listen address / exposure:** localhostonly by default. Confirm before binding
to a nonlocal interface — there is no auth (Section 0).
- **Credential path from the container:** SSH agent socket vs mounted keys vs
credential helper, for pushing/fetching from inside Docker.
---
*End of AGENT.md. Keep it lean. Let the CHANGELOG and percomponent `.md` files
carry the detail.*
+21
View File
@@ -0,0 +1,21 @@
# Changelog
Append-only running history of all changes (AGENT.md §9.1). Newest last.
## 2026-09-19 — Project scaffold
- **What:** Initial runnable skeleton for the GitManager multi-repo dashboard.
Added the Go backend (`cmd/server/main.go` + `internal/{config,logging,git,repos,render}`),
the Echo HTTP server with `/`, `/help`, `/healthz`, and `/api/repos`, a
read-only repo scanner that discovers repositories under `GIT_REPO_ROOTS` and
keeps an in-memory index, the `<repo-list>` web component, shared design tokens
(`web/static/app.css`), page shells (`web/templates/{index,help}.html`), and the
dev tooling: `Dockerfile` (build/dev/runtime stages), `docker-compose.yml`,
`.air.toml`, `.env.example`, `.gitignore`, `go.mod`.
- **Why:** Stand up the architecture defined in AGENT.md so feature work can begin.
- **Affects:** whole repo (foundation); `components/repo-list`.
### Notes to confirm (from AGENT.md §11)
- **Go module path** is the placeholder `gitmanager`; change it if this gets a
canonical import path (e.g. a GitHub URL).
- All items in AGENT.md §11 (discovery strategy, background fetch, forge
providers, listen address, container credentials) remain open.
+33
View File
@@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1
# --- build: compile a static binary ----------------------------------------
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/gitmanager ./cmd/server
# --- dev: hot reload with air (used by docker-compose) ---------------------
FROM golang:1.26 AS dev
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client \
&& rm -rf /var/lib/apt/lists/*
RUN go install github.com/air-verse/air@latest
WORKDIR /app
EXPOSE 8080
CMD ["air", "-c", ".air.toml"]
# --- runtime: small image with git available -------------------------------
# git is required at runtime — the app shells out to it for every Git operation
# (AGENT.md §2). openssh-client + ca-certificates let it reach remotes.
FROM debian:stable-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /out/gitmanager /usr/local/bin/gitmanager
COPY web ./web
COPY components ./components
EXPOSE 8080
CMD ["gitmanager"]
+104
View File
@@ -0,0 +1,104 @@
// Command server is the GitManager entrypoint. It wires config, logging, the
// Git boundary, the repo scanner, and the Echo HTTP server, then serves the
// dashboard shell and the JSON endpoints the web components fetch from.
package main
import (
"context"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"gitmanager/internal/config"
"gitmanager/internal/git"
"gitmanager/internal/logging"
"gitmanager/internal/render"
"gitmanager/internal/repos"
)
func main() {
cfg, err := config.Load()
if err != nil {
panic(err)
}
log, closer, err := logging.Setup(cfg.Dev, cfg.LogFile)
if err != nil {
panic(err)
}
if closer != nil {
defer closer.Close()
}
g := git.New(cfg.GitBin)
if v, err := g.Version(context.Background()); err != nil {
log.Warn("git binary not usable — repo operations will fail", "bin", cfg.GitBin, "err", err)
} else {
log.Info("git detected", "version", v)
}
// Start the read-only scanner in the background.
scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled)
scanCtx, stopScan := context.WithCancel(context.Background())
defer stopScan()
go scanner.Run(scanCtx)
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled)
tmpl, err := render.New("web/templates")
if err != nil {
log.Error("failed to parse templates", "err", err)
os.Exit(1)
}
e := echo.New()
e.HideBanner = true
e.Renderer = tmpl
e.Use(middleware.Recover())
e.Use(middleware.RequestID())
// Static assets and component sources.
e.Static("/static", "web/static")
e.Static("/components", "components")
// Page shells.
e.GET("/", func(c echo.Context) error {
return c.Render(http.StatusOK, "index.html", nil)
})
e.GET("/help", func(c echo.Context) error {
return c.Render(http.StatusOK, "help.html", nil)
})
// JSON API — components self-fetch from here.
e.GET("/healthz", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
e.GET("/api/repos", func(c echo.Context) error {
return c.JSON(http.StatusOK, scanner.Index.List())
})
// Serve with graceful shutdown.
go func() {
if err := e.Start(cfg.ListenAddr); err != nil && err != http.ErrServerClosed {
log.Error("server error", "err", err)
os.Exit(1)
}
}()
log.Info("listening", "addr", cfg.ListenAddr)
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
log.Info("shutting down")
stopScan()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := e.Shutdown(ctx); err != nil {
log.Error("graceful shutdown failed", "err", err)
}
}
+115
View File
@@ -0,0 +1,115 @@
// <repo-list> — the dashboard's list of discovered repositories.
//
// A self-contained control in the ActiveX spirit (AGENT.md §1.1): it lives in a
// shadow root, fetches its own data from /api/repos on connect, renders itself,
// and cleans up on disconnect. It talks to the rest of the app only via a
// bubbling/composed `repo:select` CustomEvent — no shared globals.
class RepoList extends HTMLElement {
#refreshMs = 15000;
#timer = null;
#controller = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#load();
this.#timer = setInterval(() => this.#load(), this.#refreshMs);
}
disconnectedCallback() {
clearInterval(this.#timer);
this.#controller?.abort();
}
async #load() {
this.#controller?.abort();
this.#controller = new AbortController();
try {
const res = await fetch('/api/repos', { signal: this.#controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.#renderRepos(await res.json());
} catch (err) {
if (err.name !== 'AbortError') this.#renderError(err);
}
}
#select(repo) {
// Cross-component communication is via events only (AGENT.md §1.1).
this.dispatchEvent(new CustomEvent('repo:select', {
detail: repo, bubbles: true, composed: true,
}));
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
li {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
cursor: pointer;
display: flex; align-items: center; gap: 12px;
}
li:hover { border-color: var(--border-strong); }
.name { font-weight: 600; }
.branch { color: var(--color-fg-muted); }
.spacer { margin-left: auto; }
.badge {
font-size: 12px; padding: 1px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
}
.dirty { color: var(--git-dirty); border-color: var(--git-dirty); }
.clean { color: var(--git-clean); border-color: var(--git-clean); }
.ahead { color: var(--git-ahead); }
.behind { color: var(--git-behind); }
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
.error { color: var(--color-danger); }
</style>
<div id="body"><p class="empty">Loading repositories…</p></div>
`;
}
#renderError(err) {
this.shadowRoot.getElementById('body').innerHTML =
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
}
#renderRepos(repos) {
const body = this.shadowRoot.getElementById('body');
if (!repos || repos.length === 0) {
body.innerHTML = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`;
return;
}
const ul = document.createElement('ul');
for (const r of repos) {
const li = document.createElement('li');
li.innerHTML = `
<span class="name">${this.#esc(r.name)}</span>
<span class="branch">${this.#esc(r.branch || '—')}</span>
<span class="spacer"></span>
${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''}
${r.behind ? `<span class="badge behind">↓${r.behind}</span>` : ''}
<span class="badge ${r.dirty ? 'dirty' : 'clean'}">${r.dirty ? 'dirty' : 'clean'}</span>
`;
li.addEventListener('click', () => this.#select(r));
ul.appendChild(li);
}
body.replaceChildren(ul);
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('repo-list', RepoList);
+28
View File
@@ -0,0 +1,28 @@
# repo-list
## Intent
The dashboard's list of every discovered repository. It is the first
ActiveX-spirit control in the app (AGENT.md §1.1): a self-contained custom
element that fetches its own data, renders inside its shadow root, and
communicates outward only through events. It exists to prove and anchor the
component pattern the rest of the UI follows.
## Public surface
- **Tag:** `<repo-list>`
- **Attributes/properties:** none yet.
- **Fetches:** `GET /api/repos` on connect and every 15s (in-flight request is
aborted on refresh and on disconnect).
- **Emits:** `repo:select` — a `CustomEvent` (bubbles + composed) whose `detail`
is the clicked repo's state object. The detail panel (future) listens for this.
## History
- 2026-09-19: created — first component; renders name, branch, ahead/behind, and
a clean/dirty badge; establishes the shadow-DOM + self-fetch + event pattern.
## Notes / gotchas
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
colors and radii — no hardcoded hex (AGENT.md §1.1).
- Server output is escaped before insertion (`#esc`); repo names come from the
filesystem, so treat them as untrusted.
- Polling is a placeholder cadence; a push/SSE update channel can replace it
later without changing the public surface.
+38
View File
@@ -0,0 +1,38 @@
# Dev environment: `docker compose up` builds the app with hot reload (air) and
# mounts your host repositories in (AGENT.md §1.6). Because the app operates on
# repos that live on the host, the roots are mounted read-write.
services:
app:
build:
context: .
target: dev
env_file: .env
environment:
# Bind all interfaces INSIDE the container so the published port reaches
# it; the `ports` mapping below still keeps it localhost-only on the HOST.
- LISTEN_ADDR=0.0.0.0:8080
# The scanner looks here; matches the volume mount below.
- GIT_REPO_ROOTS=/repos
ports:
- "127.0.0.1:8080:8080"
volumes:
# Source, for hot reload.
- .:/app
# Cache the Go module + build cache across restarts.
- gomod:/go/pkg/mod
# Your repositories. Set REPOS_HOST_PATH in .env (or your shell) to the
# host folder that holds them; defaults to ./repos next to this file.
- "${REPOS_HOST_PATH:-./repos}:/repos"
# --- Optional: let git authenticate to remotes from inside the container.
# Uncomment ONE approach and adjust for your host (AGENT.md §1.6, §11):
# SSH agent socket (Linux/macOS):
# - "${SSH_AUTH_SOCK}:/ssh-agent"
# or mounted keys (read-only):
# - "${HOME}/.ssh:/root/.ssh:ro"
# environment for the SSH-agent option:
# environment:
# - SSH_AUTH_SOCK=/ssh-agent
volumes:
gomod:
+21
View File
@@ -0,0 +1,21 @@
module gitmanager
go 1.26
require (
github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.15.4
)
require (
github.com/labstack/gommon v0.5.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+32
View File
@@ -0,0 +1,32 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+95
View File
@@ -0,0 +1,95 @@
// Package config loads GitManager's runtime configuration from the environment
// (and an optional .env file). See AGENT.md §1.5 — every setting is env-driven;
// nothing is hardcoded.
package config
import (
"os"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
// Config is the typed application configuration.
type Config struct {
ListenAddr string // address the HTTP server binds to
RepoRoots []string // roots to scan for git repositories
GitBin string // path to the git binary
ScanInterval time.Duration // scanner refresh interval
ScanMaxDepth int // max discovery depth under each root
ScanIgnore []string // directory names to skip during discovery
ScanFetchEnabled bool // allow the scanner to run `git fetch`
Dev bool // readable console logging vs structured JSON
LogFile string // optional file to also append logs to
GitHubToken string // optional forge token (AGENT.md §8)
GitLabToken string // optional forge token (AGENT.md §8)
}
// Load reads .env (if present) then the environment, applying defaults.
// A missing .env is not an error — the environment may be set another way.
func Load() (Config, error) {
_ = godotenv.Load()
c := Config{
ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"),
RepoRoots: splitList(env("GIT_REPO_ROOTS", "")),
GitBin: env("GIT_BIN", "git"),
ScanMaxDepth: envInt("SCAN_MAX_DEPTH", 4),
ScanIgnore: splitList(env("SCAN_IGNORE", "node_modules,vendor,.cache")),
ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false),
Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"),
LogFile: env("LOG_FILE", ""),
GitHubToken: env("GITHUB_TOKEN", ""),
GitLabToken: env("GITLAB_TOKEN", ""),
}
interval, err := time.ParseDuration(env("SCAN_INTERVAL", "30s"))
if err != nil {
return Config{}, err
}
c.ScanInterval = interval
return c, nil
}
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
if v, ok := os.LookupEnv(key); ok {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
return n
}
}
return def
}
func envBool(key string, def bool) bool {
if v, ok := os.LookupEnv(key); ok {
if b, err := strconv.ParseBool(strings.TrimSpace(v)); err == nil {
return b
}
}
return def
}
// splitList splits a comma-separated value, trimming spaces and dropping empties.
func splitList(v string) []string {
var out []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
+121
View File
@@ -0,0 +1,121 @@
// Package git is THE boundary for every Git operation (AGENT.md §1.3). All Git
// access — read and, later, write — goes through here by shelling out to the
// system `git` binary via os/exec, so the user's credential helpers, SSH keys,
// hooks, and config apply exactly.
//
// The methods below are all READ-ONLY. Mutating operations (checkout, commit,
// push, …) will be added here as they are built; destructive ones must obey
// AGENT.md §1.4 (explicit, confirmed, never automatic, never a default).
package git
import (
"bytes"
"context"
"os/exec"
"strconv"
"strings"
)
// CLI runs Git commands via the system binary.
type CLI struct {
Bin string // path to git; "git" resolves from PATH
}
// New returns a CLI using the given binary (defaults to "git").
func New(bin string) *CLI {
if bin == "" {
bin = "git"
}
return &CLI{Bin: bin}
}
// run executes `git <args...>` in dir and returns trimmed stdout. On failure it
// returns an error whose message includes stderr.
func (c *CLI) run(ctx context.Context, dir string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, c.Bin, args...)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return "", &Error{Args: args, Stderr: msg, Err: err}
}
return "", &Error{Args: args, Err: err}
}
return strings.TrimSpace(stdout.String()), nil
}
// Error describes a failed git invocation.
type Error struct {
Args []string
Stderr string
Err error
}
func (e *Error) Error() string {
s := "git " + strings.Join(e.Args, " ") + ": " + e.Err.Error()
if e.Stderr != "" {
s += ": " + e.Stderr
}
return s
}
func (e *Error) Unwrap() error { return e.Err }
// Version returns the installed git version string.
func (c *CLI) Version(ctx context.Context) (string, error) {
return c.run(ctx, "", "version")
}
// CurrentBranch returns the checked-out branch, or "HEAD" when detached.
func (c *CLI) CurrentBranch(ctx context.Context, dir string) (string, error) {
return c.run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
}
// IsDirty reports whether the working tree has staged or unstaged changes.
func (c *CLI) IsDirty(ctx context.Context, dir string) (bool, error) {
out, err := c.run(ctx, dir, "status", "--porcelain")
if err != nil {
return false, err
}
return out != "", nil
}
// AheadBehind returns how many commits HEAD is ahead of and behind its upstream.
// Both are 0 with no error when there is no configured upstream.
func (c *CLI) AheadBehind(ctx context.Context, dir string) (ahead, behind int, err error) {
out, err := c.run(ctx, dir, "rev-list", "--left-right", "--count", "@{u}...HEAD")
if err != nil {
// No upstream is a normal state, not a failure to report.
return 0, 0, nil
}
fields := strings.Fields(out)
if len(fields) != 2 {
return 0, 0, nil
}
behind, _ = strconv.Atoi(fields[0])
ahead, _ = strconv.Atoi(fields[1])
return ahead, behind, nil
}
// Remotes returns the configured remote names.
func (c *CLI) Remotes(ctx context.Context, dir string) ([]string, error) {
out, err := c.run(ctx, dir, "remote")
if err != nil {
return nil, err
}
if out == "" {
return nil, nil
}
return strings.Split(out, "\n"), nil
}
// Fetch updates remote-tracking refs. It does not modify the working tree, but
// it does touch the network, so the scanner only calls it when explicitly
// enabled (AGENT.md §5).
func (c *CLI) Fetch(ctx context.Context, dir string) error {
_, err := c.run(ctx, dir, "fetch", "--quiet", "--all")
return err
}
+35
View File
@@ -0,0 +1,35 @@
// Package logging builds the application's slog logger. Logs go to stderr
// (readable console in dev, structured JSON otherwise) and, optionally, to a
// file. There is no database sink — see AGENT.md §7.
package logging
import (
"io"
"log/slog"
"os"
)
// Setup returns a configured *slog.Logger and, if a file sink was opened, an
// io.Closer to flush/close it on shutdown (nil when no file sink is used).
func Setup(dev bool, logFile string) (*slog.Logger, io.Closer, error) {
var w io.Writer = os.Stderr
var closer io.Closer
if logFile != "" {
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return nil, nil, err
}
w = io.MultiWriter(os.Stderr, f)
closer = f
}
var handler slog.Handler
if dev {
handler = slog.NewTextHandler(w, &slog.HandlerOptions{Level: slog.LevelDebug})
} else {
handler = slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo})
}
return slog.New(handler), closer, nil
}
+31
View File
@@ -0,0 +1,31 @@
// Package render wires Go html/template page shells into Echo. The server emits
// the page shell and declares web components; the components fetch their own
// data (AGENT.md §1.1, §2).
package render
import (
"html/template"
"io"
"path/filepath"
"github.com/labstack/echo/v4"
)
// Templates implements echo.Renderer over the templates in a directory.
type Templates struct {
tmpl *template.Template
}
// New parses every *.html file in dir.
func New(dir string) (*Templates, error) {
t, err := template.ParseGlob(filepath.Join(dir, "*.html"))
if err != nil {
return nil, err
}
return &Templates{tmpl: t}, nil
}
// Render satisfies echo.Renderer.
func (t *Templates) Render(w io.Writer, name string, data any, _ echo.Context) error {
return t.tmpl.ExecuteTemplate(w, name, data)
}
+214
View File
@@ -0,0 +1,214 @@
// Package repos discovers Git repositories under the configured roots and keeps
// a read-optimized in-memory index of their state. The repositories are the
// system of record; this index is a derived cache that can be rebuilt at any
// time. The scanner is strictly READ-ONLY (AGENT.md §1.3, §5).
package repos
import (
"context"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"gitmanager/internal/git"
)
// State is the cached snapshot of one repository.
type State struct {
Path string `json:"path"`
Name string `json:"name"`
Branch string `json:"branch"`
Dirty bool `json:"dirty"`
Ahead int `json:"ahead"`
Behind int `json:"behind"`
Remotes []string `json:"remotes"`
UpdatedAt time.Time `json:"updatedAt"`
Error string `json:"error,omitempty"` // set if refreshing this repo failed
}
// Index is a concurrency-safe map of repo path -> State.
type Index struct {
mu sync.RWMutex
byKey map[string]State
}
func newIndex() *Index { return &Index{byKey: make(map[string]State)} }
func (i *Index) set(s State) {
i.mu.Lock()
defer i.mu.Unlock()
i.byKey[s.Path] = s
}
// List returns a snapshot of all known repos, sorted by name then path.
func (i *Index) List() []State {
i.mu.RLock()
defer i.mu.RUnlock()
out := make([]State, 0, len(i.byKey))
for _, s := range i.byKey {
out = append(out, s)
}
sort.Slice(out, func(a, b int) bool {
if out[a].Name != out[b].Name {
return out[a].Name < out[b].Name
}
return out[a].Path < out[b].Path
})
return out
}
// Scanner discovers repositories and refreshes the index on an interval.
type Scanner struct {
git *git.CLI
log *slog.Logger
roots []string
maxDepth int
ignore map[string]struct{}
interval time.Duration
fetchEnabled bool
Index *Index
}
// NewScanner builds a scanner. ignore is a set of directory names to skip.
func NewScanner(g *git.CLI, log *slog.Logger, roots []string, maxDepth int, ignore []string, interval time.Duration, fetchEnabled bool) *Scanner {
ig := make(map[string]struct{}, len(ignore))
for _, name := range ignore {
ig[name] = struct{}{}
}
return &Scanner{
git: g,
log: log,
roots: roots,
maxDepth: maxDepth,
ignore: ig,
interval: interval,
fetchEnabled: fetchEnabled,
Index: newIndex(),
}
}
// Run does an immediate refresh, then refreshes every interval until ctx is done.
func (s *Scanner) Run(ctx context.Context) {
s.Refresh(ctx)
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.Refresh(ctx)
}
}
}
// Refresh discovers repos and updates the index. Each repo is refreshed under
// its own timeout so a single slow/unreachable repo cannot stall the rest.
func (s *Scanner) Refresh(ctx context.Context) {
paths := s.discover()
s.log.Debug("scan discovered repositories", "count", len(paths))
for _, p := range paths {
select {
case <-ctx.Done():
return
default:
}
s.Index.set(s.refreshOne(ctx, p))
}
}
func (s *Scanner) refreshOne(ctx context.Context, path string) State {
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
st := State{Path: path, Name: filepath.Base(path), UpdatedAt: time.Now()}
if s.fetchEnabled {
if err := s.git.Fetch(rctx, path); err != nil {
s.log.Warn("scan fetch failed", "repo", path, "err", err)
}
}
branch, err := s.git.CurrentBranch(rctx, path)
if err != nil {
st.Error = err.Error()
return st
}
st.Branch = branch
if dirty, err := s.git.IsDirty(rctx, path); err == nil {
st.Dirty = dirty
} else {
st.Error = err.Error()
}
st.Ahead, st.Behind, _ = s.git.AheadBehind(rctx, path)
if remotes, err := s.git.Remotes(rctx, path); err == nil {
st.Remotes = remotes
}
return st
}
// discover walks each root looking for directories that contain a .git entry,
// recording the parent as a repo and not descending into it. Depth is measured
// relative to each root; ignored directory names are skipped.
func (s *Scanner) discover() []string {
seen := make(map[string]struct{})
var out []string
for _, root := range s.roots {
root = filepath.Clean(root)
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil // unreadable entry: skip, don't abort the walk
}
if !d.IsDir() {
return nil
}
name := d.Name()
if path != root {
if _, skip := s.ignore[name]; skip {
return filepath.SkipDir
}
}
if depth(root, path) > s.maxDepth {
return filepath.SkipDir
}
if hasGit(path) {
if _, ok := seen[path]; !ok {
seen[path] = struct{}{}
out = append(out, path)
}
return filepath.SkipDir // don't descend into a repo
}
return nil
})
}
return out
}
// hasGit reports whether dir is a git repository (a .git directory, or a .git
// file for worktrees/submodules).
func hasGit(dir string) bool {
_, err := os.Stat(filepath.Join(dir, ".git"))
return err == nil
}
// depth returns how many path segments below root path is (root itself is 0).
func depth(root, path string) int {
rel, err := filepath.Rel(root, path)
if err != nil || rel == "." {
return 0
}
return strings.Count(rel, string(filepath.Separator)) + 1
}
+53
View File
@@ -0,0 +1,53 @@
/* Shared design tokens — the ONE sanctioned cross-shadow-boundary styling
channel (AGENT.md §1.1). Custom properties inherit into every shadow root, so
components reference these with var(--…) instead of hardcoding hex. Change a
token here and the whole UI follows. */
:root {
color-scheme: dark;
/* Surfaces & structure */
--surface-0: #14161a; /* app background */
--surface-1: #1b1e24; /* panels */
--surface-2: #242830; /* raised cards */
--border: #333944;
--border-strong: #454c59;
/* Text */
--color-fg: #e6e9ef;
--color-fg-muted: #9aa4b2;
/* Fills / accents */
--fill-accent: #4a9eff;
/* Semantic status (AGENT.md §1.1) */
--color-danger: #ff5c5c;
--color-danger-bg: #3a1e1e;
--color-success: #4ade80;
--color-warning: #f5c451;
/* Git-state colors */
--git-clean: var(--color-success);
--git-dirty: var(--color-warning);
--git-ahead: #7ab8ff;
--git-behind: #d08bff;
--git-conflict: var(--color-danger);
/* Radii */
--radius: 8px;
--radius-sm: 4px;
--radius-lg: 12px;
}
/* The page shell owns only the app frame; components own everything inside their
shadow roots. Keep global selectors OUT of components (AGENT.md §1.1). */
body {
margin: 0;
background: var(--surface-0);
color: var(--color-fg);
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
a {
color: var(--fill-accent);
}
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GitManager — Help</title>
<link rel="stylesheet" href="/static/app.css">
<style>
header {
display: flex; align-items: baseline; gap: 16px;
padding: 12px 20px; background: var(--surface-1);
border-bottom: 1px solid var(--border);
}
header h1 { margin: 0; font-size: 16px; }
header nav { margin-left: auto; }
main { max-width: 760px; padding: 20px; }
h2 { font-size: 15px; margin-top: 28px; }
code { background: var(--surface-2); padding: 1px 5px; border-radius: var(--radius-sm); }
</style>
</head>
<body>
<header>
<h1>GitManager</h1>
<nav><a href="/">← Dashboard</a></nav>
</header>
<main>
<h1>Using GitManager</h1>
<p>
GitManager scans the folders you configure and shows every Git repository
it finds, with each repo's current branch, whether it has uncommitted
changes, and how far ahead or behind its upstream it is.
</p>
<h2>Getting started</h2>
<ol>
<li>Copy <code>.env.example</code> to <code>.env</code>.</li>
<li>Set <code>GIT_REPO_ROOTS</code> to the folders that hold your repos.</li>
<li>Run <code>docker compose up</code> and open the dashboard.</li>
</ol>
<h2>Reading the dashboard</h2>
<ul>
<li><strong>Branch</strong> — the checked-out branch (or <code>HEAD</code> when detached).</li>
<li><strong>Dirty</strong> — the working tree has staged or unstaged changes.</li>
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
</ul>
<h2>Background refresh</h2>
<p>
The dashboard refreshes on its own. By default it does <em>not</em> reach
the network — enable <code>SCAN_FETCH_ENABLED</code> if you want
ahead/behind counts kept current via <code>git fetch</code>.
</p>
<!-- Keep this page task-oriented and update it whenever a user-facing
feature changes, in the same change (AGENT.md §9.3). -->
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GitManager</title>
<link rel="stylesheet" href="/static/app.css">
<!-- Web components declare themselves; the page does not micro-manage them
(AGENT.md §1.1). Each fetches its own data on connect. -->
<script type="module" src="/components/repo-list/repo-list.js"></script>
<style>
header {
display: flex;
align-items: baseline;
gap: 16px;
padding: 12px 20px;
background: var(--surface-1);
border-bottom: 1px solid var(--border);
}
header h1 { margin: 0; font-size: 16px; }
header nav { margin-left: auto; }
main { padding: 20px; }
</style>
</head>
<body>
<header>
<h1>GitManager</h1>
<span style="color: var(--color-fg-muted)">multi-repo dashboard</span>
<nav><a href="/help">Help</a></nav>
</header>
<main>
<!-- The repo list docks LEFT by default (AGENT.md §4). Docking is layered on
later; for now the list stands alone. -->
<repo-list></repo-list>
</main>
</body>
</html>