Git LFS section is live with:

Enable LFS toggle — turns LFS on/off for the repo; all other controls dim when disabled
File locking toggle — enables the LFS locking protocol for binary assets
Maximum file size — optional per-file size cap in MB (blank = unlimited)
Info callout linking to the git-lfs client install page and noting the .gitattributes requirement
This commit is contained in:
2026-05-07 16:12:25 +02:00
parent 39eeccb314
commit 803672a610
7 changed files with 328 additions and 2 deletions
+31
View File
@@ -0,0 +1,31 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
const lfsSettingsSchema = z.object({
enabled: z.boolean(),
lockingEnabled: z.boolean(),
maxFileSizeMB: z.number(),
})
export type LFSSettings = z.infer<typeof lfsSettingsSchema>
export function useLFSSettings(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'lfs-settings'],
queryFn: () =>
api.get<LFSSettings>(`/api/v1/repos/${owner}/${repo}/lfs-settings`, lfsSettingsSchema),
enabled: Boolean(owner && repo),
})
}
export function useUpdateLFSSettings(owner: string, repo: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: Partial<LFSSettings>) =>
api.put<LFSSettings>(`/api/v1/repos/${owner}/${repo}/lfs-settings`, lfsSettingsSchema, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'lfs-settings'] })
},
})
}
+139 -1
View File
@@ -14,6 +14,7 @@ import {
useDefaultDescription, useUpdateDefaultDescription, useDefaultDescription, useUpdateDefaultDescription,
useExcludedFiles, useUpdateExcludedFiles, useExcludedFiles, useUpdateExcludedFiles,
} from '../api/queries/prs' } from '../api/queries/prs'
import { useLFSSettings, useUpdateLFSSettings } from '../api/queries/lfs'
import { useRecentRepos } from '../hooks/useRecentRepos' import { useRecentRepos } from '../hooks/useRecentRepos'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { Skeleton } from '../ui/Skeleton' import { Skeleton } from '../ui/Skeleton'
@@ -153,9 +154,10 @@ export default function RepoSettingsPage() {
{section === 'default-reviewers' && <DefaultReviewersSection owner={owner} repo={repoName} />} {section === 'default-reviewers' && <DefaultReviewersSection owner={owner} repo={repoName} />}
{section === 'default-description' && <DefaultDescriptionSection owner={owner} repo={repoName} />} {section === 'default-description' && <DefaultDescriptionSection owner={owner} repo={repoName} />}
{section === 'excluded-files' && <ExcludedFilesSection owner={owner} repo={repoName} />} {section === 'excluded-files' && <ExcludedFilesSection owner={owner} repo={repoName} />}
{section === 'git-lfs' && <GitLFSSection owner={owner} repo={repoName} />}
{!['repository-details','repository-permissions','access-keys','access-tokens', {!['repository-details','repository-permissions','access-keys','access-tokens',
'branch-restrictions','branching-model','merge-strategies','webhooks', 'branch-restrictions','branching-model','merge-strategies','webhooks',
'default-reviewers','default-description','excluded-files'].includes(section) && <ComingSoon sectionId={section} />} 'default-reviewers','default-description','excluded-files','git-lfs'].includes(section) && <ComingSoon sectionId={section} />}
</main> </main>
</div> </div>
) )
@@ -1680,6 +1682,142 @@ function ExcludedFilesSection({ owner, repo }: { owner: string; repo: string })
) )
} }
// ─── Git LFS ─────────────────────────────────────────────────────────────────
function GitLFSSection({ owner, repo }: { owner: string; repo: string }) {
const { data, isLoading } = useLFSSettings(owner, repo)
const update = useUpdateLFSSettings(owner, repo)
const [maxSizeInput, setMaxSizeInput] = useState('')
const [sizeError, setSizeError] = useState('')
const [saved, setSaved] = useState(false)
useEffect(() => {
if (data) setMaxSizeInput(data.maxFileSizeMB === 0 ? '' : String(data.maxFileSizeMB))
}, [data])
async function toggle(field: 'enabled' | 'lockingEnabled', value: boolean) {
await update.mutateAsync({ [field]: value })
}
async function saveMaxSize(e: React.FormEvent) {
e.preventDefault()
setSizeError('')
const raw = maxSizeInput.trim()
const mb = raw === '' ? 0 : parseInt(raw, 10)
if (raw !== '' && (isNaN(mb) || mb < 0)) {
setSizeError('Enter a positive number of megabytes, or leave blank for unlimited.')
return
}
await update.mutateAsync({ maxFileSizeMB: mb })
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
if (isLoading) {
return (
<div className="max-w-2xl px-6 py-6 space-y-4">
<Skeleton className="h-6 w-48 rounded" />
<Skeleton className="h-24 rounded" />
<Skeleton className="h-24 rounded" />
</div>
)
}
const enabled = data?.enabled ?? false
const lockingEnabled = data?.lockingEnabled ?? true
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--c-text)]">Git LFS</h1>
<p className="text-sm text-[var(--c-muted)] mt-1">
Git Large File Storage replaces large binary files (images, videos, datasets) with lightweight text pointers inside Git,
while storing the actual file content on the server.
</p>
</div>
{/* Enable / disable */}
<div className="border border-[var(--c-border)] rounded-lg divide-y divide-[var(--c-border)]">
<div className="flex items-start justify-between gap-4 px-4 py-4">
<div>
<p className="text-sm font-medium text-[var(--c-text)]">Enable Git LFS</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
Allow contributors to push large files using the LFS protocol.
</p>
</div>
<button
onClick={() => toggle('enabled', !enabled)}
disabled={update.isPending}
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${enabled ? 'bg-[var(--c-brand)]' : 'bg-[var(--c-border)]'}`}
>
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
<div className={`flex items-start justify-between gap-4 px-4 py-4 transition-opacity ${enabled ? '' : 'opacity-40 pointer-events-none'}`}>
<div>
<p className="text-sm font-medium text-[var(--c-text)]">File locking</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
Allow contributors to lock LFS files to prevent conflicting edits on binary assets.
</p>
</div>
<button
onClick={() => toggle('lockingEnabled', !lockingEnabled)}
disabled={update.isPending || !enabled}
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${lockingEnabled ? 'bg-[var(--c-brand)]' : 'bg-[var(--c-border)]'}`}
>
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${lockingEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
</div>
{/* Max file size */}
<div className={`space-y-3 transition-opacity ${enabled ? '' : 'opacity-40 pointer-events-none'}`}>
<div>
<p className="text-sm font-medium text-[var(--c-text)]">Maximum file size</p>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
Reject LFS pushes for individual files exceeding this size. Leave blank for no limit.
</p>
</div>
<form onSubmit={saveMaxSize} className="flex items-center gap-2">
<div className="relative">
<input
type="number"
min="1"
value={maxSizeInput}
onChange={e => setMaxSizeInput(e.target.value)}
disabled={!enabled}
placeholder="Unlimited"
className="w-36 border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] disabled:opacity-50 pr-10"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--c-muted)] pointer-events-none">MB</span>
</div>
<button
type="submit"
disabled={update.isPending || !enabled}
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
>
{update.isPending ? 'Saving…' : 'Save'}
</button>
{saved && <span className="text-xs text-[var(--c-success)]">Saved</span>}
</form>
{sizeError && <p className="text-xs text-[var(--c-danger)]">{sizeError}</p>}
</div>
{/* Info box */}
<div className="rounded-lg bg-[var(--c-surface-muted)] border border-[var(--c-border)] px-4 py-3 flex gap-3">
<svg className="shrink-0 mt-0.5 text-[var(--c-brand)]" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
<p className="text-xs text-[var(--c-muted)] leading-relaxed">
Git LFS must be <a href="https://git-lfs.com" target="_blank" rel="noreferrer" className="text-[var(--c-brand)] hover:underline">installed on the client</a> to push and pull tracked files.
Add a <code className="font-mono bg-[var(--c-surface)] px-1 rounded">.gitattributes</code> file to your repository to specify which file patterns LFS should track.
</p>
</div>
</div>
)
}
// ─── Coming soon ────────────────────────────────────────────────────────────── // ─── Coming soon ──────────────────────────────────────────────────────────────
function ComingSoon({ sectionId }: { sectionId: SectionId }) { function ComingSoon({ sectionId }: { sectionId: SectionId }) {
+132
View File
@@ -0,0 +1,132 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/models"
)
type LFSHandler struct{ db *xorm.Engine }
func NewLFSHandler(db *xorm.Engine) *LFSHandler { return &LFSHandler{db: db} }
func (h *LFSHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
if err != nil {
jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError)
return nil, false
}
if !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
var repo models.Repository
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
if err != nil {
jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError)
return nil, false
}
if !found {
jsonError(w, "repository not found", http.StatusNotFound)
return nil, false
}
return &repo, true
}
func (h *LFSHandler) canManage(repo *models.Repository, callerID int64) bool {
if callerID == repo.OwnerID {
return true
}
var m models.RepoMember
found, _ := h.db.Where("repo_id = ? AND user_id = ? AND permission = 'admin'", repo.ID, callerID).Get(&m)
return found
}
func (h *LFSHandler) Get(w http.ResponseWriter, r *http.Request) {
repo, ok := h.resolveRepo(w, r)
if !ok {
return
}
var s models.RepoLFSSettings
h.db.Where("repo_id = ?", repo.ID).Get(&s)
jsonOK(w, lfsResponse(repo.ID, &s))
}
func (h *LFSHandler) Update(w http.ResponseWriter, r *http.Request) {
repo, ok := h.resolveRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if !h.canManage(repo, callerID) {
jsonError(w, "forbidden", http.StatusForbidden)
return
}
var body struct {
Enabled *bool `json:"enabled"`
LockingEnabled *bool `json:"lockingEnabled"`
MaxFileSizeMB *int64 `json:"maxFileSizeMB"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
var s models.RepoLFSSettings
found, err := h.db.Where("repo_id = ?", repo.ID).Get(&s)
if err != nil {
jsonError(w, "database error: "+err.Error(), http.StatusInternalServerError)
return
}
s.RepoID = repo.ID
if body.Enabled != nil {
s.Enabled = *body.Enabled
}
if body.LockingEnabled != nil {
s.LockingEnabled = *body.LockingEnabled
}
if body.MaxFileSizeMB != nil {
s.MaxFileSizeMB = *body.MaxFileSizeMB
}
if found {
if _, err := h.db.ID(s.ID).Cols("enabled", "locking_enabled", "max_file_size_mb").Update(&s); err != nil {
jsonError(w, "could not update LFS settings", http.StatusInternalServerError)
return
}
} else {
if _, err := h.db.Insert(&s); err != nil {
jsonError(w, "could not save LFS settings", http.StatusInternalServerError)
return
}
}
jsonOK(w, lfsResponse(repo.ID, &s))
}
type lfsSettingsResponse struct {
Enabled bool `json:"enabled"`
LockingEnabled bool `json:"lockingEnabled"`
MaxFileSizeMB int64 `json:"maxFileSizeMB"`
}
func lfsResponse(_ int64, s *models.RepoLFSSettings) lfsSettingsResponse {
return lfsSettingsResponse{
Enabled: s.Enabled,
LockingEnabled: s.LockingEnabled,
MaxFileSizeMB: s.MaxFileSizeMB,
}
}
+3
View File
@@ -49,6 +49,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
workflowH := handlers.NewWorkflowHandler(engine) workflowH := handlers.NewWorkflowHandler(engine)
webhookH := handlers.NewWebhookHandler(engine) webhookH := handlers.NewWebhookHandler(engine)
prSettingsH := handlers.NewPRSettingsHandler(engine) prSettingsH := handlers.NewPRSettingsHandler(engine)
lfsH := handlers.NewLFSHandler(engine)
// ── Git smart-HTTP transport ─────────────────────────────────────────────── // ── Git smart-HTTP transport ───────────────────────────────────────────────
// These routes MUST be registered before the SPA catch-all and outside CSRF. // These routes MUST be registered before the SPA catch-all and outside CSRF.
@@ -182,6 +183,8 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
r.With(csrf).Put("/default-description", prSettingsH.UpdateDefaultDescription) r.With(csrf).Put("/default-description", prSettingsH.UpdateDefaultDescription)
r.Get("/excluded-files", prSettingsH.GetExcludedFiles) r.Get("/excluded-files", prSettingsH.GetExcludedFiles)
r.With(csrf).Put("/excluded-files", prSettingsH.UpdateExcludedFiles) r.With(csrf).Put("/excluded-files", prSettingsH.UpdateExcludedFiles)
r.Get("/lfs-settings", lfsH.Get)
r.With(csrf).Put("/lfs-settings", lfsH.Update)
}) })
}) })
}) })
+9
View File
@@ -0,0 +1,9 @@
package models
type RepoLFSSettings struct {
ID int64 `xorm:"'id' pk autoincr"`
RepoID int64 `xorm:"'repo_id' notnull unique"`
Enabled bool `xorm:"'enabled' default false"`
LockingEnabled bool `xorm:"'locking_enabled' default true"`
MaxFileSizeMB int64 `xorm:"'max_file_size_mb' default 0"` // 0 = unlimited
}
+4 -1
View File
@@ -28,5 +28,8 @@ func Run(engine *xorm.Engine) error {
if err := Run005(engine); err != nil { if err := Run005(engine); err != nil {
return err return err
} }
return Run006(engine) if err := Run006(engine); err != nil {
return err
}
return Run007(engine)
} }
+10
View File
@@ -0,0 +1,10 @@
package migrations
import (
"github.com/forgeo/forgebucket/internal/models"
"xorm.io/xorm"
)
func Run007(engine *xorm.Engine) error {
return engine.Sync2(&models.RepoLFSSettings{})
}