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:
@@ -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
|
||||
@@ -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
@@ -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/
|
||||
@@ -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/<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. 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)
|
||||
│ └── <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 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 — <short title>
|
||||
- **What:** what changed (files, behavior).
|
||||
- **Why:** the intent / the request behind it.
|
||||
- **Affects:** components/areas touched.
|
||||
```
|
||||
|
||||
### 9.2 Per‑component `<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 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/<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 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.*
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('repo-list', RepoList);
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user