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:
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useDefaultDescription, useUpdateDefaultDescription,
|
||||
useExcludedFiles, useUpdateExcludedFiles,
|
||||
} from '../api/queries/prs'
|
||||
import { useLFSSettings, useUpdateLFSSettings } from '../api/queries/lfs'
|
||||
import { useRecentRepos } from '../hooks/useRecentRepos'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { Skeleton } from '../ui/Skeleton'
|
||||
@@ -153,9 +154,10 @@ export default function RepoSettingsPage() {
|
||||
{section === 'default-reviewers' && <DefaultReviewersSection owner={owner} repo={repoName} />}
|
||||
{section === 'default-description' && <DefaultDescriptionSection 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',
|
||||
'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>
|
||||
</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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ComingSoon({ sectionId }: { sectionId: SectionId }) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
|
||||
workflowH := handlers.NewWorkflowHandler(engine)
|
||||
webhookH := handlers.NewWebhookHandler(engine)
|
||||
prSettingsH := handlers.NewPRSettingsHandler(engine)
|
||||
lfsH := handlers.NewLFSHandler(engine)
|
||||
|
||||
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
||||
// 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.Get("/excluded-files", prSettingsH.GetExcludedFiles)
|
||||
r.With(csrf).Put("/excluded-files", prSettingsH.UpdateExcludedFiles)
|
||||
r.Get("/lfs-settings", lfsH.Get)
|
||||
r.With(csrf).Put("/lfs-settings", lfsH.Update)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -28,5 +28,8 @@ func Run(engine *xorm.Engine) error {
|
||||
if err := Run005(engine); err != nil {
|
||||
return err
|
||||
}
|
||||
return Run006(engine)
|
||||
if err := Run006(engine); err != nil {
|
||||
return err
|
||||
}
|
||||
return Run007(engine)
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
Reference in New Issue
Block a user