diff --git a/CHANGELOG.md b/CHANGELOG.md index ce92f18..a7261e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,3 +159,32 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last. - **Not yet live-tested:** needs `GITEA_URL`+`GITEA_TOKEN` set and a real PR; build/vet/tests pass and the parser is unit-tested. A real merge is irreversible — will only run one against a PR Thomas designates, with confirmation. + +## 2026-09-20 — Forge live-tested (Merge & clean up) +- **What:** With `GITEA_URL`+`GITEA_TOKEN` set, verified end-to-end against + git.nilles.net: created an isolated throwaway PR via the Gitea API (on a + dedicated base branch so `main` was untouched), listed it through `GET + /api/repo/prs`, then ran `POST /api/repo/pr/merge` → `{merged:true, + branchDeleted:true}`; confirmed the branch was gone (404), the PR list emptied, + and the feed logged `pr-merged`. Cleaned up the base branch afterward. +- **Why:** Prove the write path with real auth before relying on it. +- **Affects:** none (runtime verification only; no code change). + +## 2026-09-20 — Slice 6: right-click command menu + git write actions (§6) +- **What:** git boundary gained `Pull`/`Push`/`Commit`/`DiscardAll` (the last is + §1.4-destructive). Scanner got `RefreshRepo` (single-repo re-scan). Service + gained `GitFetch/GitPull/GitPush/GitCommit/GitDiscard` — each records a `git-*` + activity event (ok/failed) and refreshes the repo after success; `service.New` + takes a refresh hook. New HTTP `POST /api/repo/git {path, op, message?}`. New + `` overlay (plain-language commands: Get latest, Publish, Check for + updates, Save my work…, Set as active project, Ask Claude to switch here, Copy + path, and the confirmed Discard all changes…); `` emits + `repo:contextmenu` on right-click. Added `internal/service` test covering + commit/discard on a temp repo. +- **Why:** The GUI-first reason the app exists (§0) — run git in plain language + without a terminal. Logic lives in the shared service (§1.7) so the same ops can + be exposed to Claude via MCP next. +- **Affects:** `internal/git`, `internal/repos`, `internal/service` (+test), + `cmd/server/main.go`, `components/repo-menu` (new), `components/repo-list`, + `web/templates/{index,help}.html`. +- **Next:** expose these git ops as MCP tools so Claude can run them too. diff --git a/cmd/server/main.go b/cmd/server/main.go index 9b0f61a..c5849f7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -68,7 +68,8 @@ func main() { } // The one service layer both the HTTP API and the MCP server call (§1.7). - svc := service.New(g, scanner.Index, feed, fg) + // scanner.RefreshRepo lets a mutating action re-scan just that repo. + svc := service.New(g, scanner.Index, feed, fg, scanner.RefreshRepo) tmpl, err := render.New("web/templates") if err != nil { @@ -161,6 +162,40 @@ func main() { return c.JSON(http.StatusOK, map[string]any{"pending": false}) }) + // Plain-language git commands (the right-click menu, §6). One endpoint, + // op-switched. Destructive ops (discard) are confirmed UI-side per §1.4. + e.POST("/api/repo/git", func(c echo.Context) error { + var body struct { + Path string `json:"path"` + Op string `json:"op"` + Message string `json:"message"` + } + if err := c.Bind(&body); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) + } + ctx := c.Request().Context() + var out string + var err error + switch body.Op { + case "fetch": + out, err = svc.GitFetch(ctx, activity.ActorUser, body.Path) + case "pull": + out, err = svc.GitPull(ctx, activity.ActorUser, body.Path) + case "push": + out, err = svc.GitPush(ctx, activity.ActorUser, body.Path) + case "commit": + out, err = svc.GitCommit(ctx, activity.ActorUser, body.Path, body.Message) + case "discard": + out, err = svc.GitDiscard(ctx, activity.ActorUser, body.Path) + default: + return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown op: " + body.Op}) + } + if err != nil { + return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]any{"ok": true, "output": out}) + }) + // Forge PRs + "Merge & clean up" (§8.4). e.GET("/api/repo/prs", func(c echo.Context) error { prs, err := svc.ForgePRs(c.Request().Context(), c.QueryParam("path")) diff --git a/components/repo-list/repo-list.js b/components/repo-list/repo-list.js index 4bcf272..445d7ec 100644 --- a/components/repo-list/repo-list.js +++ b/components/repo-list/repo-list.js @@ -105,6 +105,15 @@ class RepoList extends HTMLElement { for (const r of this.#repos) { const li = document.createElement('li'); if (r.path === this.#selected) li.classList.add('selected'); + // Right-click opens the command menu (§6) for this repo — via an event, + // so stays decoupled from this component (§1.1). + li.addEventListener('contextmenu', (e) => { + e.preventDefault(); + this.dispatchEvent(new CustomEvent('repo:contextmenu', { + detail: { repo: r, x: e.clientX, y: e.clientY }, + bubbles: true, composed: true, + })); + }); li.innerHTML = ` ${this.#esc(r.name)} ${this.#esc(r.branch || '—')} diff --git a/components/repo-list/repo-list.md b/components/repo-list/repo-list.md index 905917d..970731d 100644 --- a/components/repo-list/repo-list.md +++ b/components/repo-list/repo-list.md @@ -24,6 +24,8 @@ component pattern the rest of the UI follows. - 2026-09-20: selecting a repo now also `POST`s `/api/active-project` to make it the active project (a user action, §8.2) — surfaced in `` and readable by Claude via `get_active_project`. Fire-and-forget. +- 2026-09-20: right-clicking a repo emits `repo:contextmenu` `{repo, x, y}` for + `` (§6). Right-click does not change the selection/active project. ## Notes / gotchas - Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all diff --git a/components/repo-menu/repo-menu.js b/components/repo-menu/repo-menu.js new file mode 100644 index 0000000..3a24cc9 --- /dev/null +++ b/components/repo-menu/repo-menu.js @@ -0,0 +1,176 @@ +// — the right-click command menu (AGENT.md §6). +// +// A self-contained control (§1.1): shadow DOM, listens for the bubbling +// `repo:contextmenu` event from , and shows a positioned menu of +// PLAIN-LANGUAGE commands for people who don't memorize git. Safe commands run +// on click; the destructive one ("Discard all changes") confirms first (§1.4). +// It calls the same endpoints Claude uses via MCP (one service layer, §1.7); +// results show up live in . + +const ITEMS = [ + { cmd: 'pull', label: 'Get latest', hint: 'pull' }, + { cmd: 'push', label: 'Publish', hint: 'push' }, + { cmd: 'fetch', label: 'Check for updates', hint: 'fetch' }, + { cmd: 'commit', label: 'Save my work…', hint: 'commit' }, + { sep: true }, + { cmd: 'active', label: 'Set as active project' }, + { cmd: 'handoff', label: 'Ask Claude to switch here' }, + { cmd: 'copy', label: 'Copy path' }, + { sep: true }, + { cmd: 'discard', label: 'Discard all changes…', hint: 'reset --hard', danger: true }, +]; + +class RepoMenu extends HTMLElement { + #repo = null; + #onContext = null; + #onDocClick = null; + #onKey = null; + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.#renderShell(); + this.#onContext = (e) => this.#open(e.detail); + document.addEventListener('repo:contextmenu', this.#onContext); + } + + disconnectedCallback() { + document.removeEventListener('repo:contextmenu', this.#onContext); + this.#teardownDismiss(); + } + + #open({ repo, x, y }) { + if (!repo) return; + this.#repo = repo; + const menu = this.shadowRoot.getElementById('menu'); + this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path); + menu.hidden = false; + // Position, clamped to the viewport. + const rect = menu.getBoundingClientRect(); + const left = Math.min(x, window.innerWidth - rect.width - 8); + const top = Math.min(y, window.innerHeight - rect.height - 8); + menu.style.left = Math.max(8, left) + 'px'; + menu.style.top = Math.max(8, top) + 'px'; + + // Dismiss on next outside click, Esc, or scroll. + this.#onDocClick = (ev) => { if (!ev.composedPath().includes(this)) this.#hide(); }; + this.#onKey = (ev) => { if (ev.key === 'Escape') this.#hide(); }; + setTimeout(() => { + document.addEventListener('click', this.#onDocClick, { once: true }); + document.addEventListener('keydown', this.#onKey); + window.addEventListener('scroll', this.#hideBound(), { once: true, capture: true }); + }, 0); + } + + #hideBound() { return () => this.#hide(); } + + #hide() { + this.shadowRoot.getElementById('menu').hidden = true; + this.#teardownDismiss(); + } + + #teardownDismiss() { + if (this.#onDocClick) document.removeEventListener('click', this.#onDocClick); + if (this.#onKey) document.removeEventListener('keydown', this.#onKey); + this.#onDocClick = this.#onKey = null; + } + + async #dispatch(cmd) { + const repo = this.#repo; + const name = this.#base(repo.path); + this.#hide(); + switch (cmd) { + case 'pull': case 'push': case 'fetch': + await this.#git(cmd); + break; + case 'commit': { + const msg = window.prompt(`Commit message for ${name}:`); + if (msg && msg.trim()) await this.#git('commit', { message: msg }); + break; + } + case 'discard': { + const ok = window.confirm( + `Discard ALL uncommitted changes in ${name}?\n\n` + + `This resets tracked files to the last commit and cannot be undone.` + ); + if (ok) await this.#git('discard'); + break; + } + case 'active': + await this.#post('/api/active-project', { path: repo.path }); + break; + case 'handoff': + await this.#post('/api/switch', { target: repo.path }); + break; + case 'copy': + try { await navigator.clipboard.writeText(repo.path); } catch { /* ignore */ } + break; + } + } + + async #git(op, extra = {}) { + try { + const res = await fetch('/api/repo/git', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: this.#repo.path, op, ...extra }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + } catch (err) { + window.alert(`${op} failed: ${err.message}`); + } + } + + async #post(url, body) { + try { + await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + } catch { /* ignore */ } + } + + #renderShell() { + const rows = ITEMS.map((it) => it.sep + ? '
' + : ``).join(''); + this.shadowRoot.innerHTML = ` + + `; + this.shadowRoot.getElementById('menu').addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (btn) this.#dispatch(btn.dataset.cmd); + }); + } + + #base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; } +} + +customElements.define('repo-menu', RepoMenu); diff --git a/components/repo-menu/repo-menu.md b/components/repo-menu/repo-menu.md new file mode 100644 index 0000000..effdd35 --- /dev/null +++ b/components/repo-menu/repo-menu.md @@ -0,0 +1,32 @@ +# repo-menu + +## Intent +The right-click command menu (AGENT.md §6) — the app's reason for being: run git +in **plain language** ("Get latest", "Publish", "Save my work…") without a +terminal. A self-contained overlay control (§1.1) that any list can summon via an +event. Safe commands run on click; the destructive one confirms first (§1.4). + +## Public surface +- **Tag:** `` (place once, near the end of the page). +- **Listens:** `repo:contextmenu` on `document` — `detail: { repo, x, y }` + (dispatched by `` on right-click). Shows the menu at (x, y). +- **Commands → endpoints:** + - Get latest / Publish / Check for updates / Save my work… / Discard all + changes… → `POST /api/repo/git {path, op, message?}` (op: pull/push/fetch/ + commit/discard). "Save my work…" prompts for a message; "Discard all + changes…" confirms (destructive). + - Set as active project → `POST /api/active-project`. + - Ask Claude to switch here → `POST /api/switch` (the handoff request). + - Copy path → clipboard. +- Dismisses on outside click, Esc, or scroll. + +## History +- 2026-09-20: created — slice 6; plain-language git commands + coordination + actions, backed by the shared service layer (same ops Claude gets via MCP). + +## Notes / gotchas +- Results aren't shown inline; they appear in `` (each op records a + `git-*` event with ok/failed), and errors raise a browser alert. +- Network ops (pull/push/fetch) need git credentials reachable from the server; + inside Docker that means a mounted SSH agent / credential helper (§11) — until + then they'll report an auth error. commit/discard are local and always work. diff --git a/internal/git/git.go b/internal/git/git.go index 8831eec..6e38556 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -120,6 +120,34 @@ func (c *CLI) Fetch(ctx context.Context, dir string) error { return err } +// --- Mutating operations (user/Claude-initiated only; §1.3, §1.4) ----------- + +// Pull integrates the upstream branch (fetch + merge/ff per repo config). +func (c *CLI) Pull(ctx context.Context, dir string) (string, error) { + return c.run(ctx, dir, "pull") +} + +// Push publishes the current branch to its upstream. Plain push only — never a +// forced push here (that is a §1.4 action to be added deliberately if ever). +func (c *CLI) Push(ctx context.Context, dir string) (string, error) { + return c.run(ctx, dir, "push") +} + +// Commit stages every change and commits it with message. +func (c *CLI) Commit(ctx context.Context, dir, message string) (string, error) { + if _, err := c.run(ctx, dir, "add", "-A"); err != nil { + return "", err + } + return c.run(ctx, dir, "commit", "-m", message) +} + +// DiscardAll hard-resets tracked files to HEAD, throwing away uncommitted +// changes. DESTRUCTIVE (§1.4): callers MUST confirm with the user first. +// Untracked files are left in place. +func (c *CLI) DiscardAll(ctx context.Context, dir string) (string, error) { + return c.run(ctx, dir, "reset", "--hard", "HEAD") +} + // Branch is a local branch and its upstream, if any. type Branch struct { Name string `json:"name"` diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index f8aa8a4..5ed4a0e 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -42,7 +42,7 @@ func TestMCPRoundTrip(t *testing.T) { scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false) scanner.Refresh(context.Background()) - svc := service.New(g, scanner.Index, activity.New(log, 200), nil) + svc := service.New(g, scanner.Index, activity.New(log, 200), nil, scanner.RefreshRepo) srv := NewServer(svc, "test") // Wire an in-memory client<->server session. diff --git a/internal/repos/repos.go b/internal/repos/repos.go index d591054..047298e 100644 --- a/internal/repos/repos.go +++ b/internal/repos/repos.go @@ -122,6 +122,13 @@ func (s *Scanner) Refresh(ctx context.Context) { } } +// RefreshRepo re-scans a single repository and updates the index. Used after a +// mutating action so the UI reflects the new state without waiting for the next +// full scan. +func (s *Scanner) RefreshRepo(ctx context.Context, path string) { + s.Index.set(s.refreshOne(ctx, path)) +} + func (s *Scanner) refreshOne(ctx context.Context, path string) State { rctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() diff --git a/internal/service/service.go b/internal/service/service.go index fec4a23..c42caa9 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -9,6 +9,7 @@ import ( "context" "fmt" "path/filepath" + "strings" "gitmanager/internal/activity" "gitmanager/internal/forge" @@ -18,16 +19,18 @@ import ( // Service holds the shared dependencies the capabilities need. type Service struct { - git *git.CLI - index *repos.Index - feed *activity.Feed - forge *forge.Gitea // nil when no forge is configured + git *git.CLI + index *repos.Index + feed *activity.Feed + forge *forge.Gitea // nil when no forge is configured + refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil) } // New builds a Service over the git boundary, the scanner's repo index, the -// activity feed, and (optionally) a forge provider. -func New(g *git.CLI, index *repos.Index, feed *activity.Feed, fg *forge.Gitea) *Service { - return &Service{git: g, index: index, feed: feed, forge: fg} +// activity feed, (optionally) a forge provider, and a single-repo refresh hook +// (may be nil) used to re-scan a repo after a mutating action. +func New(g *git.CLI, index *repos.Index, feed *activity.Feed, fg *forge.Gitea, refresh func(context.Context, string)) *Service { + return &Service{git: g, index: index, feed: feed, forge: fg, refresh: refresh} } // ListRepos returns a snapshot of every discovered repository. @@ -154,6 +157,64 @@ func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, rep return res, nil } +// --- Git actions (the plain-language commands, §6) -------------------------- + +// gitAction runs one mutating git op through the boundary, records the outcome +// on the activity feed, and refreshes the repo in the index on success. Callers +// are responsible for §1.4 confirmation of destructive ops (e.g. discard). +func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath, kind string, run func(dir string) (string, error)) (string, error) { + base, ok := s.index.Get(filepath.Clean(repoPath)) + if !ok { + return "", fmt.Errorf("unknown repository %q", repoPath) + } + out, err := run(base.Path) + if err != nil { + s.feed.Record(actor, kind, base.Path, "failed: "+err.Error()) + return "", err + } + s.feed.Record(actor, kind, base.Path, "ok") + if s.refresh != nil { + s.refresh(ctx, base.Path) + } + return out, nil +} + +// GitFetch, GitPull, GitPush, GitCommit, GitDiscard are the mutating commands +// the right-click menu (and, later, MCP) invoke. +func (s *Service) GitFetch(ctx context.Context, actor activity.Actor, repoPath string) (string, error) { + return s.gitAction(ctx, actor, repoPath, "git-fetch", func(d string) (string, error) { + return "", s.git.Fetch(ctx, d) + }) +} + +func (s *Service) GitPull(ctx context.Context, actor activity.Actor, repoPath string) (string, error) { + return s.gitAction(ctx, actor, repoPath, "git-pull", func(d string) (string, error) { + return s.git.Pull(ctx, d) + }) +} + +func (s *Service) GitPush(ctx context.Context, actor activity.Actor, repoPath string) (string, error) { + return s.gitAction(ctx, actor, repoPath, "git-push", func(d string) (string, error) { + return s.git.Push(ctx, d) + }) +} + +func (s *Service) GitCommit(ctx context.Context, actor activity.Actor, repoPath, message string) (string, error) { + if strings.TrimSpace(message) == "" { + return "", fmt.Errorf("a commit message is required") + } + return s.gitAction(ctx, actor, repoPath, "git-commit", func(d string) (string, error) { + return s.git.Commit(ctx, d, message) + }) +} + +// GitDiscard is DESTRUCTIVE (§1.4) — the caller must confirm with the user first. +func (s *Service) GitDiscard(ctx context.Context, actor activity.Actor, repoPath string) (string, error) { + return s.gitAction(ctx, actor, repoPath, "git-discard", func(d string) (string, error) { + return s.git.DiscardAll(ctx, d) + }) +} + // resolveForge maps a repo path to (owner, repo) on the configured forge host via // its git remotes, preferring "origin". func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) { diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..31c30db --- /dev/null +++ b/internal/service/service_test.go @@ -0,0 +1,110 @@ +package service + +import ( + "context" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "gitmanager/internal/activity" + "gitmanager/internal/git" + "gitmanager/internal/repos" +) + +// TestGitActions exercises the mutating commands (commit, discard) on a throwaway +// temp repo — never a real one. +func TestGitActions(t *testing.T) { + root := t.TempDir() + repoPath := filepath.Join(root, "r") + mustMkdir(t, repoPath) + runGit(t, repoPath, "init", "-b", "main") + runGit(t, repoPath, "config", "user.email", "t@e.com") + runGit(t, repoPath, "config", "user.name", "T") + writeFile(t, filepath.Join(repoPath, "a.txt"), "one\n") + runGit(t, repoPath, "add", "-A") + runGit(t, repoPath, "commit", "-m", "init") + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + g := git.New("git") + scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false) + scanner.Refresh(context.Background()) + feed := activity.New(log, 200) + svc := New(g, scanner.Index, feed, nil, scanner.RefreshRepo) + ctx := context.Background() + + // Commit a new file, then the repo should be clean in the index. + writeFile(t, filepath.Join(repoPath, "b.txt"), "two\n") + if _, err := svc.GitCommit(ctx, activity.ActorUser, repoPath, "add b"); err != nil { + t.Fatalf("GitCommit: %v", err) + } + if st, _ := svc.GetRepo(repoPath); st.Dirty { + t.Fatalf("expected clean repo after commit, got dirty") + } + + // Commit with a blank message is rejected. + if _, err := svc.GitCommit(ctx, activity.ActorUser, repoPath, " "); err == nil { + t.Fatalf("expected error committing with blank message") + } + + // Modify a tracked file, then discard resets it. (We check the file itself + // rather than index dirtiness, since the index only updates on a refresh.) + writeFile(t, filepath.Join(repoPath, "a.txt"), "CHANGED\n") + if _, err := svc.GitDiscard(ctx, activity.ActorUser, repoPath); err != nil { + t.Fatalf("GitDiscard: %v", err) + } + if st, _ := svc.GetRepo(repoPath); st.Dirty { + t.Fatalf("expected clean repo after discard") + } + // Trim to ignore autocrlf line-ending normalization on Windows. + if got := strings.TrimSpace(readFile(t, filepath.Join(repoPath, "a.txt"))); got != "one" { + t.Fatalf("a.txt = %q, want restored to \"one\"", got) + } + + // The feed recorded the successful actions. + kinds := map[string]bool{} + for _, e := range feed.Events(0) { + if e.Detail == "ok" { + kinds[e.Kind] = true + } + } + if !kinds["git-commit"] || !kinds["git-discard"] { + t.Fatalf("expected git-commit and git-discard ok events, got %v", kinds) + } +} + +func mustMkdir(t *testing.T, p string) { + t.Helper() + if err := os.Mkdir(p, 0o755); err != nil { + t.Fatal(err) + } +} + +func writeFile(t *testing.T, p, s string) { + t.Helper() + if err := os.WriteFile(p, []byte(s), 0o644); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} diff --git a/web/templates/help.html b/web/templates/help.html index ecc1ecb..d9e8f99 100644 --- a/web/templates/help.html +++ b/web/templates/help.html @@ -45,6 +45,27 @@
  • Ahead / behind — commits your branch leads or trails its upstream by.
  • +

    Right-click commands

    +

    + Right-click any repository for a menu of plain-language commands — no git + knowledge needed: +

    +
      +
    • Get latest — pull the newest changes.
    • +
    • Publish — push your commits.
    • +
    • Check for updates — fetch without changing your files.
    • +
    • Save my work… — commit everything (asks for a message).
    • +
    • Set as active project / Ask Claude to switch here.
    • +
    • Copy path.
    • +
    • Discard all changes… — throw away uncommitted edits + (asks you to confirm; can't be undone).
    • +
    +

    + What each command did shows up in the activity panel. (Get latest / Publish / + Check for updates need your server to have git credentials; until then they'll + report a sign-in error.) +

    +

    Repository details

    Click any repository in the list to open its details on the right: its diff --git a/web/templates/index.html b/web/templates/index.html index 4351692..31c1519 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -12,6 +12,7 @@ +