diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..768b1fc --- /dev/null +++ b/.air.toml @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bc9bf82 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac0f624 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..0b300c7 --- /dev/null +++ b/AGENT.md @@ -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 non‑negotiable +> rules, and the self‑documenting 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, long‑lived development application: a **multi‑repo 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 — per‑repo +status (clean/dirty, ahead/behind, current branch), branches, remotes, recent +commits, stashes, and tags — plus user‑initiated Git actions (fetch, pull, push, +checkout, create branch, commit, view diffs, manage remotes). It has per‑user +dockable UI panels and is architected so that host/forge integrations +(GitHub/GitLab pull‑ and merge‑requests) 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 +self‑documenting workflow in Section 9 intact. + +**Trust model:** there is **no user authentication**. The app is a single‑operator +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. Non‑negotiable 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 (ActiveX‑spirit, enforced) + +The UI is built **exclusively** from native Web Components. The design intent is +deliberately modeled on the *self‑contained control* philosophy of Windows‑98‑era +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. +- **Self‑instantiating**: the page declares the element in markup (or via a thin + loader). The browser "constructs" it. The page does not micro‑manage 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. +- **Self‑contained**: no shared mutable global state. Cross‑component + 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. +- **Lifecycle‑correct**: implement `connectedCallback` / `disconnectedCallback` + and clean up timers, listeners, and in‑flight 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, dependency‑free helpers like +`lit` *may* be proposed, but only with explicit approval, because `lit` still +produces standards‑based custom elements.) + +**Shared styling — design tokens.** The one sanctioned cross‑shadow‑boundary +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 Git‑state 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 & per‑component docs + +- There is a **top‑level `components/` folder**. All web components live there. +- **Each component gets its own folder**: `components//`. +- **Each component folder contains its own `.md`** (see Section 9) + that records the **history and intent** of that component and acts as the + scoped context when working on it. Component‑specific 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, + read‑optimized in‑memory 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, user‑initiated 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 read‑only.** 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 per‑user UI state (browser `localStorage`, Section 4). Adding any other + datastore (SQLite, a server‑side 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 / docker‑compose + +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** +(read‑write, 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 host‑side +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 server‑rendered 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. Pure‑Go `go-git` may be proposed for cheap read‑only queries, but only behind the same interface and only with approval. | +| Repo discovery / index | **In‑memory 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) | **Provider‑abstracted** (`internal/forge`) — GitHub via `github.com/google/go-github`, GitLab via `gitlab.com/gitlab-org/api/client-go` | Optional, read‑only by default, enabled per‑host 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) +│ └── / +│ ├── .md # history + intent for THIS component (scoped context) +│ ├── .js # the custom element +│ └── .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 top‑level +> directory. Keep `components/` strictly for web components. There is deliberately +> **no `migrations/` and no `db/`** — see Section 1.3. + +--- + +## 4. UI layout & per‑user state (no server DB) + +Because there is no authentication and no database, **per‑user 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 first‑load + defaults (mirror the pattern: view state only — never touch the repos). + +Server‑side there is no session and no user record. Requests act as the single +host operator. + +--- + +## 5. Repo discovery & the refresh scanner (read‑only) + +**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 + in‑memory index keyed by absolute repo path. +- **Refresh (read‑only):** for each repo compute status (dirty/clean, staged vs + unstaged counts), current branch, ahead/behind vs upstream, remotes, and a short + recent‑commit summary — all via read‑only 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, read‑only to + the working tree) to keep ahead/behind counts current. Rate‑limit 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 (per‑repo 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, +self‑fetching, 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 server‑produced 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). +- Cross‑component 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 self‑fetch; 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 non‑dev, 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. +- **Read‑only 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, explicitly‑requested** capability — do not build write + paths without asking, and route them through Section 1.4 if destructive. +- **Enablement is per‑host and token‑gated.** 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. Self‑documenting 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 — + - **What:** what changed (files, behavior). + - **Why:** the intent / the request behind it. + - **Affects:** components/areas touched. + ``` + +### 9.2 Per‑component `.md` +- When you **create a component**, create `components//.md` with: + ``` + # + + ## 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 — . + - YYYY-MM-DD: . + + ## 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 User‑facing 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 user‑facing 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 task‑oriented (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 +- Component‑specific detail lives in the component's `.md`, **not here**. Once a + component has its own `.md`, **move any component‑specific detail out of + AGENT.md** into that file. AGENT.md stays architecture‑level only. +- If a change alters an **architectural law or stack choice** in this file, + update AGENT.md too — but only architecture‑level 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//` 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 top‑level folder**, propose it and + wait for approval. +6. Implement, run it in the **docker‑compose 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 user‑facing; 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: read‑only). +- **Listen address / exposure:** localhost‑only by default. Confirm before binding + to a non‑local 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 per‑component `.md` files +carry the detail.* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6a32605 --- /dev/null +++ b/CHANGELOG.md @@ -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 `` 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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..33e191f --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..ed0a3e8 --- /dev/null +++ b/cmd/server/main.go @@ -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) + } +} diff --git a/components/repo-list/repo-list.js b/components/repo-list/repo-list.js new file mode 100644 index 0000000..b83c1c2 --- /dev/null +++ b/components/repo-list/repo-list.js @@ -0,0 +1,115 @@ +// — 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 = ` + +

Loading repositories…

+ `; + } + + #renderError(err) { + this.shadowRoot.getElementById('body').innerHTML = + `

Could not load repositories: ${this.#esc(err.message)}

`; + } + + #renderRepos(repos) { + const body = this.shadowRoot.getElementById('body'); + if (!repos || repos.length === 0) { + body.innerHTML = `

No repositories found. Check GIT_REPO_ROOTS.

`; + return; + } + const ul = document.createElement('ul'); + for (const r of repos) { + const li = document.createElement('li'); + li.innerHTML = ` + ${this.#esc(r.name)} + ${this.#esc(r.branch || '—')} + + ${r.ahead ? `↑${r.ahead}` : ''} + ${r.behind ? `↓${r.behind}` : ''} + ${r.dirty ? 'dirty' : 'clean'} + `; + li.addEventListener('click', () => this.#select(r)); + ul.appendChild(li); + } + body.replaceChildren(ul); + } + + #esc(s) { + return String(s ?? '').replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); + } +} + +customElements.define('repo-list', RepoList); diff --git a/components/repo-list/repo-list.md b/components/repo-list/repo-list.md new file mode 100644 index 0000000..333ed21 --- /dev/null +++ b/components/repo-list/repo-list.md @@ -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:** `` +- **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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4149767 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a331a89 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bd6070d --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..738318c --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..5812025 --- /dev/null +++ b/internal/git/git.go @@ -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 ` 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 +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..475be11 --- /dev/null +++ b/internal/logging/logging.go @@ -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 +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..cf4328c --- /dev/null +++ b/internal/render/render.go @@ -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) +} diff --git a/internal/repos/repos.go b/internal/repos/repos.go new file mode 100644 index 0000000..d591054 --- /dev/null +++ b/internal/repos/repos.go @@ -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 +} diff --git a/web/static/app.css b/web/static/app.css new file mode 100644 index 0000000..4ea9f4a --- /dev/null +++ b/web/static/app.css @@ -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); +} diff --git a/web/templates/help.html b/web/templates/help.html new file mode 100644 index 0000000..40a204c --- /dev/null +++ b/web/templates/help.html @@ -0,0 +1,59 @@ + + + + + + GitManager — Help + + + + +
+

GitManager

+ +
+
+

Using GitManager

+

+ 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. +

+ +

Getting started

+
    +
  1. Copy .env.example to .env.
  2. +
  3. Set GIT_REPO_ROOTS to the folders that hold your repos.
  4. +
  5. Run docker compose up and open the dashboard.
  6. +
+ +

Reading the dashboard

+
    +
  • Branch — the checked-out branch (or HEAD when detached).
  • +
  • Dirty — the working tree has staged or unstaged changes.
  • +
  • Ahead / behind — commits your branch leads or trails its upstream by.
  • +
+ +

Background refresh

+

+ The dashboard refreshes on its own. By default it does not reach + the network — enable SCAN_FETCH_ENABLED if you want + ahead/behind counts kept current via git fetch. +

+ + +
+ + diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..a376ef8 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,37 @@ + + + + + + GitManager + + + + + + +
+

GitManager

+ multi-repo dashboard + +
+
+ + +
+ +