Gitfed
bastien-mrq/gitfed/ Commits/ 7d180b8

Redesign the one-shot import form; fix its RPC timeout

UI: source URL now comes first, the repo name field is a locked "username/" prefix plus an editable suffix, and the suffix auto-fills from the URL's last path segment (still editable, never overwrites a name someone typed themselves). Fix found while testing the above: the RPC client used a flat 5s timeout for every call, including ImportRepo — far too short for an actual network clone, so real imports intermittently errored with an i/o timeout even when the clone was still running fine server-side. ImportRepo now gets its own deadline (admin.ImportCloneTimeout + 30s buffer) via a new callWithTimeout, instead of the shared 5s default.

bastien-mrq 2026-07-29 09:14 commit 7d180b8d1669c6f86cd3d1d4ee979cda9748f73a parent 2250dd503135d713d223a715fe3f893f74217f9e
4 files changed +49 −11
M cmd/gitfed-web/handlers_dashboard.go +7 −4
M cmd/gitfed-web/render.go +18 −0
M internal/admin/admin.go +7 −4
M internal/adminrpc/client.go +17 −3
cmd/gitfed-web/handlers_dashboard.go
diff --git a/cmd/gitfed-web/handlers_dashboard.go b/cmd/gitfed-web/handlers_dashboard.go index 699633e..fbc6b97 100644 --- a/cmd/gitfed-web/handlers_dashboard.go +++ b/cmd/gitfed-web/handlers_dashboard.go @@ -24,11 +24,14 @@ var dashboardTpl = newTpl("dashboard", ` <div class="gf-page-head-actions"> <details class="gf-new-repo"> <summary class="gf-btn" style="margin:0;">+ {{t .Lang "dashboard.import_repo"}}</summary> - <form class="card" method="post" action="/repos/import" style="margin-top:0.75rem;"> - <label>{{t .Lang "dashboard.repo_name"}}</label> - <input name="name" required placeholder="{{.Username}}/my-project"> + <form class="card gf-import-form" method="post" action="/repos/import" style="margin-top:0.75rem;"> <label>{{t .Lang "dashboard.import_url_label"}}</label> - <input name="source_url" type="url" required placeholder="https://github.com/owner/repo.git"> + <input name="source_url" type="url" required placeholder="https://github.com/owner/repo.git" id="importUrl" autocomplete="off"> + <label>{{t .Lang "dashboard.repo_name"}}</label> + <div class="gf-input-group"> + <span class="prefix">{{.Username}}/</span> + <input name="name" required placeholder="my-project" id="importName" autocomplete="off"> + </div> <button type="submit">{{t .Lang "dashboard.import"}}</button> </form> </details>
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index cd00783..9f8f495 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -488,6 +488,11 @@ const shellHeadSrc = `<!doctype html> .gf-new-repo summary { list-style: none; cursor: pointer; } .gf-new-repo summary::-webkit-details-marker { display: none; } .gf-new-repo[open] summary { margin-bottom: 0.5rem; } + .gf-new-repo form.card { min-width: 320px; } + + .gf-input-group { display: flex; align-items: stretch; margin-top: 0.25rem; } + .gf-input-group input { margin-top: 0; border-radius: 0 6px 6px 0; } + .gf-input-group .prefix { display: flex; align-items: center; flex-shrink: 0; padding: 0 0.6rem; background: var(--surface-3); border: 1px solid var(--border-strong); border-right: none; border-radius: 6px 0 0 6px; color: var(--text-faint); font-size: 0.9rem; font-family: var(--mono); white-space: nowrap; } .gf-page-sub { color: var(--text-dim); font-size: 0.9rem; margin: -0.6rem 0 1.1rem; } .gf-search { display: flex; align-items: center; gap: 0.6rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 0.55rem 0.8rem; margin-bottom: 1.1rem; color: var(--text-faint); } @@ -747,6 +752,19 @@ const shellScriptJS = ` e.preventDefault(); } }); + // Derives a repo name from the pasted source URL's last path segment, on + // the one-shot import form — only while the name field is still empty or + // still holds a previous auto-fill, so it never clobbers a name someone + // typed themselves. + document.addEventListener('input', function (e) { + if (e.target.id !== 'importUrl') return; + var nameInput = document.getElementById('importName'); + if (!nameInput || (nameInput.value !== '' && nameInput.value !== nameInput.dataset.autofilled)) return; + var m = e.target.value.trim().match(/\/([^\/]+?)(\.git)?\/?$/); + var derived = m ? m[1] : ''; + nameInput.value = derived; + nameInput.dataset.autofilled = derived; + }); })(); `
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index d6d577b..1d47eee 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -276,9 +276,12 @@ func (a *Admin) CreateRepo(name, ownerUsername string) error { return a.Store.CreateRepo(store.Repo{Name: name, Owner: owner, Path: path}) } -// importCloneTimeout bounds how long a one-shot import's git clone may run, -// so a slow or hostile source can't tie up the server indefinitely. -const importCloneTimeout = 5 * time.Minute +// ImportCloneTimeout bounds how long a one-shot import's git clone may run, +// so a slow or hostile source can't tie up the server indefinitely. Exported +// so adminrpc's Client can give the RPC call itself a matching (slightly +// longer) deadline — the default per-call RPC timeout is tuned for the +// local, near-instant operations everything else in this package does. +const ImportCloneTimeout = 5 * time.Minute // ImportRepo one-shot-clones sourceURL into a new bare repo, registered like // any other repo and owned by "<ownerUsername>@<localDomain>" — see @@ -292,7 +295,7 @@ func (a *Admin) ImportRepo(name, sourceURL, ownerUsername string) error { return err } - ctx, cancel := context.WithTimeout(context.Background(), importCloneTimeout) + ctx, cancel := context.WithTimeout(context.Background(), ImportCloneTimeout) defer cancel() if err := gitexec.CloneMirror(ctx, path, sourceURL); err != nil { return err
internal/adminrpc/client.go
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go index d370259..8125be8 100644 --- a/internal/adminrpc/client.go +++ b/internal/adminrpc/client.go @@ -31,12 +31,20 @@ func (c *Client) Ping() error { } func (c *Client) call(method string, args any, out any) error { - conn, err := net.DialTimeout("unix", c.socketPath, c.timeout) + return c.callWithTimeout(method, args, out, c.timeout) +} + +// callWithTimeout is call with an explicit deadline, for the rare RPC method +// (currently just ImportRepo) whose underlying operation can legitimately +// run far longer than every other admin call, which are all local and +// near-instant. +func (c *Client) callWithTimeout(method string, args any, out any, timeout time.Duration) error { + conn, err := net.DialTimeout("unix", c.socketPath, timeout) if err != nil { return err } defer conn.Close() - _ = conn.SetDeadline(time.Now().Add(c.timeout)) + _ = conn.SetDeadline(time.Now().Add(timeout)) if err := json.NewEncoder(conn).Encode(request{Method: method, Args: args}); err != nil { return err @@ -108,8 +116,14 @@ func (c *Client) CreateRepo(name, ownerUsername string) error { return err } +// importRPCTimeout gives the RPC call itself a bit more headroom than +// admin.ImportCloneTimeout, so the server-side clone's own timeout is always +// what actually fires first — the client-side deadline is a backstop, not +// the primary bound. +const importRPCTimeout = admin.ImportCloneTimeout + 30*time.Second + func (c *Client) ImportRepo(name, sourceURL, ownerUsername string) error { - return c.call(methodImportRepo, importRepoArgs{Name: name, SourceURL: sourceURL, Owner: ownerUsername}, nil) + return c.callWithTimeout(methodImportRepo, importRepoArgs{Name: name, SourceURL: sourceURL, Owner: ownerUsername}, nil, importRPCTimeout) } func (c *Client) DeleteRepo(name string) error {