Slice 6: right-click command menu + git write actions
git boundary: Pull/Push/Commit/DiscardAll (discard is 1.4-destructive). Scanner.RefreshRepo re-scans one repo after a mutation. Service GitFetch/GitPull/GitPush/GitCommit/GitDiscard record a git-* activity event and refresh on success; service.New takes a refresh hook. HTTP POST /api/repo/git. New <repo-menu> overlay with plain-language commands (Get latest/Publish/Check for updates/Save my work/Set active/Ask Claude to switch/Copy path/Discard all changes), summoned by repo-list's repo:contextmenu. Added service test for commit/discard on a temp repo. Verified the menu live for safe commands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
- **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
|
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.
|
— 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
|
||||||
|
`<repo-menu>` 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…); `<repo-list>` 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.
|
||||||
|
|||||||
+36
-1
@@ -68,7 +68,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The one service layer both the HTTP API and the MCP server call (§1.7).
|
// 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")
|
tmpl, err := render.New("web/templates")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -161,6 +162,40 @@ func main() {
|
|||||||
return c.JSON(http.StatusOK, map[string]any{"pending": false})
|
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).
|
// Forge PRs + "Merge & clean up" (§8.4).
|
||||||
e.GET("/api/repo/prs", func(c echo.Context) error {
|
e.GET("/api/repo/prs", func(c echo.Context) error {
|
||||||
prs, err := svc.ForgePRs(c.Request().Context(), c.QueryParam("path"))
|
prs, err := svc.ForgePRs(c.Request().Context(), c.QueryParam("path"))
|
||||||
|
|||||||
@@ -105,6 +105,15 @@ class RepoList extends HTMLElement {
|
|||||||
for (const r of this.#repos) {
|
for (const r of this.#repos) {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
if (r.path === this.#selected) li.classList.add('selected');
|
if (r.path === this.#selected) li.classList.add('selected');
|
||||||
|
// Right-click opens the command menu (§6) for this repo — via an event,
|
||||||
|
// so <repo-menu> 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 = `
|
li.innerHTML = `
|
||||||
<span class="name">${this.#esc(r.name)}</span>
|
<span class="name">${this.#esc(r.name)}</span>
|
||||||
<span class="branch">${this.#esc(r.branch || '—')}</span>
|
<span class="branch">${this.#esc(r.branch || '—')}</span>
|
||||||
|
|||||||
@@ -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
|
- 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 `<activity-feed>` and
|
the active project (a user action, §8.2) — surfaced in `<activity-feed>` and
|
||||||
readable by Claude via `get_active_project`. Fire-and-forget.
|
readable by Claude via `get_active_project`. Fire-and-forget.
|
||||||
|
- 2026-09-20: right-clicking a repo emits `repo:contextmenu` `{repo, x, y}` for
|
||||||
|
`<repo-menu>` (§6). Right-click does not change the selection/active project.
|
||||||
|
|
||||||
## Notes / gotchas
|
## Notes / gotchas
|
||||||
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
|
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// <repo-menu> — the right-click command menu (AGENT.md §6).
|
||||||
|
//
|
||||||
|
// A self-contained control (§1.1): shadow DOM, listens for the bubbling
|
||||||
|
// `repo:contextmenu` event from <repo-list>, 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 <activity-feed>.
|
||||||
|
|
||||||
|
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
|
||||||
|
? '<hr>'
|
||||||
|
: `<button data-cmd="${it.cmd}" class="${it.danger ? 'danger' : ''}">
|
||||||
|
<span>${it.label}</span>${it.hint ? `<code>${it.hint}</code>` : ''}
|
||||||
|
</button>`).join('');
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
#menu {
|
||||||
|
position: fixed; z-index: 1000; min-width: 220px;
|
||||||
|
background: var(--surface-2); border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius); padding: 4px;
|
||||||
|
box-shadow: 0 8px 28px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
.hdr { padding: 6px 10px 4px; color: var(--color-fg-muted); font-size: 12px;
|
||||||
|
border-bottom: 1px solid var(--border); margin-bottom: 4px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
button { display: flex; align-items: center; gap: 10px; width: 100%;
|
||||||
|
background: none; border: none; color: var(--color-fg); font: inherit;
|
||||||
|
text-align: left; padding: 7px 10px; border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer; }
|
||||||
|
button:hover { background: var(--fill-accent); color: #071019; }
|
||||||
|
button code { margin-left: auto; font-size: 11px; color: var(--color-fg-muted); }
|
||||||
|
button:hover code { color: #071019; }
|
||||||
|
button.danger { color: var(--color-danger); }
|
||||||
|
button.danger:hover { background: var(--color-danger); color: #fff; }
|
||||||
|
button.danger:hover code { color: #fff; }
|
||||||
|
hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
|
||||||
|
</style>
|
||||||
|
<div id="menu" hidden>
|
||||||
|
<div class="hdr" id="hdr"></div>
|
||||||
|
${rows}
|
||||||
|
</div>`;
|
||||||
|
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);
|
||||||
@@ -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:** `<repo-menu>` (place once, near the end of the page).
|
||||||
|
- **Listens:** `repo:contextmenu` on `document` — `detail: { repo, x, y }`
|
||||||
|
(dispatched by `<repo-list>` 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 `<activity-feed>` (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.
|
||||||
@@ -120,6 +120,34 @@ func (c *CLI) Fetch(ctx context.Context, dir string) error {
|
|||||||
return err
|
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.
|
// Branch is a local branch and its upstream, if any.
|
||||||
type Branch struct {
|
type Branch struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestMCPRoundTrip(t *testing.T) {
|
|||||||
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
|
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
|
||||||
scanner.Refresh(context.Background())
|
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")
|
srv := NewServer(svc, "test")
|
||||||
|
|
||||||
// Wire an in-memory client<->server session.
|
// Wire an in-memory client<->server session.
|
||||||
|
|||||||
@@ -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 {
|
func (s *Scanner) refreshOne(ctx context.Context, path string) State {
|
||||||
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitmanager/internal/activity"
|
"gitmanager/internal/activity"
|
||||||
"gitmanager/internal/forge"
|
"gitmanager/internal/forge"
|
||||||
@@ -18,16 +19,18 @@ import (
|
|||||||
|
|
||||||
// Service holds the shared dependencies the capabilities need.
|
// Service holds the shared dependencies the capabilities need.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
git *git.CLI
|
git *git.CLI
|
||||||
index *repos.Index
|
index *repos.Index
|
||||||
feed *activity.Feed
|
feed *activity.Feed
|
||||||
forge *forge.Gitea // nil when no forge is configured
|
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
|
// New builds a Service over the git boundary, the scanner's repo index, the
|
||||||
// activity feed, and (optionally) a forge provider.
|
// activity feed, (optionally) a forge provider, and a single-repo refresh hook
|
||||||
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, fg *forge.Gitea) *Service {
|
// (may be nil) used to re-scan a repo after a mutating action.
|
||||||
return &Service{git: g, index: index, feed: feed, forge: fg}
|
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.
|
// 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
|
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
|
// resolveForge maps a repo path to (owner, repo) on the configured forge host via
|
||||||
// its git remotes, preferring "origin".
|
// its git remotes, preferring "origin".
|
||||||
func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) {
|
func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,27 @@
|
|||||||
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
|
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h2>Right-click commands</h2>
|
||||||
|
<p>
|
||||||
|
Right-click any repository for a menu of plain-language commands — no git
|
||||||
|
knowledge needed:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Get latest</strong> — pull the newest changes.</li>
|
||||||
|
<li><strong>Publish</strong> — push your commits.</li>
|
||||||
|
<li><strong>Check for updates</strong> — fetch without changing your files.</li>
|
||||||
|
<li><strong>Save my work…</strong> — commit everything (asks for a message).</li>
|
||||||
|
<li><strong>Set as active project</strong> / <strong>Ask Claude to switch here</strong>.</li>
|
||||||
|
<li><strong>Copy path</strong>.</li>
|
||||||
|
<li><strong>Discard all changes…</strong> — throw away uncommitted edits
|
||||||
|
(asks you to confirm; can't be undone).</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
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.)
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>Repository details</h2>
|
<h2>Repository details</h2>
|
||||||
<p>
|
<p>
|
||||||
Click any repository in the list to open its details on the right: its
|
Click any repository in the list to open its details on the right: its
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<script type="module" src="/components/activity-feed/activity-feed.js"></script>
|
<script type="module" src="/components/activity-feed/activity-feed.js"></script>
|
||||||
<script type="module" src="/components/handoff-bar/handoff-bar.js"></script>
|
<script type="module" src="/components/handoff-bar/handoff-bar.js"></script>
|
||||||
<script type="module" src="/components/pr-list/pr-list.js"></script>
|
<script type="module" src="/components/pr-list/pr-list.js"></script>
|
||||||
|
<script type="module" src="/components/repo-menu/repo-menu.js"></script>
|
||||||
<style>
|
<style>
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -53,5 +54,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<activity-feed></activity-feed>
|
<activity-feed></activity-feed>
|
||||||
</main>
|
</main>
|
||||||
|
<repo-menu></repo-menu>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user