Files
forge/modules/lfstransfer/backend/lock.go
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

295 lines
7.3 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package backend
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/hanzoai/git/modules/json"
lfslock "github.com/hanzoai/git/modules/structs"
"github.com/charmbracelet/git-lfs-transfer/transfer"
)
var _ transfer.LockBackend = &gitLockBackend{}
type gitLockBackend struct {
ctx context.Context
g *GitBackend
server *url.URL
authToken string
internalAuth string
logger transfer.Logger
}
func newGitLockBackend(g *GitBackend) transfer.LockBackend {
server := g.server.JoinPath("locks")
return &gitLockBackend{ctx: g.ctx, g: g, server: server, authToken: g.authToken, internalAuth: g.internalAuth, logger: g.logger}
}
// Create implements transfer.LockBackend
func (g *gitLockBackend) Create(path, refname string) (transfer.Lock, error) {
reqBody := lfslock.LFSLockRequest{Path: path}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
g.logger.Log("json marshal error", err)
return nil, err
}
headers := map[string]string{
headerAuthorization: g.authToken,
headerInternalAuth: g.internalAuth,
headerAccept: mimeGitLFS,
headerContentType: mimeGitLFS,
}
req := newInternalRequestLFS(g.ctx, g.server.String(), http.MethodPost, headers, bodyBytes)
resp, err := req.Response()
if err != nil {
g.logger.Log("http request error", err)
return nil, err
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
g.logger.Log("http read error", err)
return nil, err
}
if resp.StatusCode != http.StatusCreated {
g.logger.Log("http statuscode error", resp.StatusCode, statusCodeToErr(resp.StatusCode))
return nil, statusCodeToErr(resp.StatusCode)
}
var respBody lfslock.LFSLockResponse
err = json.Unmarshal(respBytes, &respBody)
if err != nil {
g.logger.Log("json unmarshal error", err)
return nil, err
}
if respBody.Lock == nil {
g.logger.Log("api returned nil lock")
return nil, errors.New("api returned nil lock")
}
respLock := respBody.Lock
owner := userUnknown
if respLock.Owner != nil {
owner = respLock.Owner.Name
}
lock := newGitLock(g, respLock.ID, respLock.Path, respLock.LockedAt, owner)
return lock, nil
}
// Unlock implements transfer.LockBackend
func (g *gitLockBackend) Unlock(lock transfer.Lock) error {
reqBody := lfslock.LFSLockDeleteRequest{}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
g.logger.Log("json marshal error", err)
return err
}
headers := map[string]string{
headerAuthorization: g.authToken,
headerInternalAuth: g.internalAuth,
headerAccept: mimeGitLFS,
headerContentType: mimeGitLFS,
}
req := newInternalRequestLFS(g.ctx, g.server.JoinPath(lock.ID(), "unlock").String(), http.MethodPost, headers, bodyBytes)
resp, err := req.Response()
if err != nil {
g.logger.Log("http request error", err)
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
g.logger.Log("http statuscode error", resp.StatusCode, statusCodeToErr(resp.StatusCode))
return statusCodeToErr(resp.StatusCode)
}
// no need to read response
return nil
}
// FromPath implements transfer.LockBackend
func (g *gitLockBackend) FromPath(path string) (transfer.Lock, error) {
v := url.Values{
argPath: []string{path},
}
respLocks, _, err := g.queryLocks(v)
if err != nil {
return nil, err
}
if len(respLocks) == 0 {
return nil, transfer.ErrNotFound
}
return respLocks[0], nil
}
// FromID implements transfer.LockBackend
func (g *gitLockBackend) FromID(id string) (transfer.Lock, error) {
v := url.Values{
argID: []string{id},
}
respLocks, _, err := g.queryLocks(v)
if err != nil {
return nil, err
}
if len(respLocks) == 0 {
return nil, transfer.ErrNotFound
}
return respLocks[0], nil
}
// Range implements transfer.LockBackend
func (g *gitLockBackend) Range(cursor string, limit int, iter func(transfer.Lock) error) (string, error) {
v := url.Values{
argLimit: []string{strconv.FormatInt(int64(limit), 10)},
}
if cursor != "" {
v[argCursor] = []string{cursor}
}
respLocks, cursor, err := g.queryLocks(v)
if err != nil {
return "", err
}
for _, lock := range respLocks {
err := iter(lock)
if err != nil {
return "", err
}
}
return cursor, nil
}
func (g *gitLockBackend) queryLocks(v url.Values) ([]transfer.Lock, string, error) {
serverURLWithQuery := g.server.JoinPath() // get a copy
serverURLWithQuery.RawQuery = v.Encode()
headers := map[string]string{
headerAuthorization: g.authToken,
headerInternalAuth: g.internalAuth,
headerAccept: mimeGitLFS,
headerContentType: mimeGitLFS,
}
req := newInternalRequestLFS(g.ctx, serverURLWithQuery.String(), http.MethodGet, headers, nil)
resp, err := req.Response()
if err != nil {
g.logger.Log("http request error", err)
return nil, "", err
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
g.logger.Log("http read error", err)
return nil, "", err
}
if resp.StatusCode != http.StatusOK {
g.logger.Log("http statuscode error", resp.StatusCode, statusCodeToErr(resp.StatusCode))
return nil, "", statusCodeToErr(resp.StatusCode)
}
var respBody lfslock.LFSLockList
err = json.Unmarshal(respBytes, &respBody)
if err != nil {
g.logger.Log("json unmarshal error", err)
return nil, "", err
}
respLocks := make([]transfer.Lock, 0, len(respBody.Locks))
for _, respLock := range respBody.Locks {
owner := userUnknown
if respLock.Owner != nil {
owner = respLock.Owner.Name
}
lock := newGitLock(g, respLock.ID, respLock.Path, respLock.LockedAt, owner)
respLocks = append(respLocks, lock)
}
return respLocks, respBody.Next, nil
}
var _ transfer.Lock = &gitLock{}
type gitLock struct {
g *gitLockBackend
id string
path string
lockedAt time.Time
owner string
}
func newGitLock(g *gitLockBackend, id, path string, lockedAt time.Time, owner string) transfer.Lock {
return &gitLock{g: g, id: id, path: path, lockedAt: lockedAt, owner: owner}
}
// Unlock implements transfer.Lock
func (g *gitLock) Unlock() error {
return g.g.Unlock(g)
}
// ID implements transfer.Lock
func (g *gitLock) ID() string {
return g.id
}
// Path implements transfer.Lock
func (g *gitLock) Path() string {
return g.path
}
// FormattedTimestamp implements transfer.Lock
func (g *gitLock) FormattedTimestamp() string {
return g.lockedAt.UTC().Format(time.RFC3339)
}
// OwnerName implements transfer.Lock
func (g *gitLock) OwnerName() string {
return g.owner
}
func (g *gitLock) CurrentUser() (string, error) {
return userSelf, nil
}
// AsLockSpec implements transfer.Lock
func (g *gitLock) AsLockSpec(ownerID bool) ([]string, error) {
msgs := []string{
"lock " + g.ID(),
fmt.Sprintf("path %s %s", g.ID(), g.Path()),
fmt.Sprintf("locked-at %s %s", g.ID(), g.FormattedTimestamp()),
fmt.Sprintf("ownername %s %s", g.ID(), g.OwnerName()),
}
if ownerID {
user, err := g.CurrentUser()
if err != nil {
return nil, fmt.Errorf("error getting current user: %w", err)
}
who := "theirs"
if user == g.OwnerName() {
who = "ours"
}
msgs = append(msgs, fmt.Sprintf("owner %s %s", g.ID(), who))
}
return msgs, nil
}
// AsArguments implements transfer.Lock
func (g *gitLock) AsArguments() []string {
return []string{
"id=" + g.ID(),
"path=" + g.Path(),
"locked-at=" + g.FormattedTimestamp(),
"ownername=" + g.OwnerName(),
}
}