shithub: furgit

ref: 6e28d6a7ec210349b8c336249ba130422310fecb
dir: /object/ident.go/

View raw version
package object

import (
	"bytes"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"

	"codeberg.org/lindenii/furgit/internal/intconv"
)

// Ident represents a Git identity (author/committer/tagger).
type Ident struct {
	Name          []byte
	Email         []byte
	WhenUnix      int64
	OffsetMinutes int32
}

// ParseIdent parses a canonical Git identity line:
// "Name <email> 123456789 +0000".
func ParseIdent(line []byte) (*Ident, error) {
	lt := bytes.IndexByte(line, '<')
	if lt < 0 {
		return nil, errors.New("object: ident: missing opening <")
	}
	gtRel := bytes.IndexByte(line[lt+1:], '>')
	if gtRel < 0 {
		return nil, errors.New("object: ident: missing closing >")
	}
	gt := lt + 1 + gtRel

	nameBytes := append([]byte(nil), bytes.TrimRight(line[:lt], " ")...)
	emailBytes := append([]byte(nil), line[lt+1:gt]...)

	rest := line[gt+1:]
	if len(rest) == 0 || rest[0] != ' ' {
		return nil, errors.New("object: ident: missing timestamp separator")
	}
	rest = rest[1:]
	before, after, ok := bytes.Cut(rest, []byte{' '})
	if !ok {
		return nil, errors.New("object: ident: missing timezone separator")
	}
	when, err := strconv.ParseInt(string(before), 10, 64)
	if err != nil {
		return nil, fmt.Errorf("object: ident: invalid timestamp: %w", err)
	}

	tz := after
	if len(tz) < 5 {
		return nil, errors.New("object: ident: invalid timezone encoding")
	}
	sign := 1
	switch tz[0] {
	case '-':
		sign = -1
	case '+':
	default:
		return nil, errors.New("object: ident: invalid timezone sign")
	}

	hh, err := strconv.Atoi(string(tz[1:3]))
	if err != nil {
		return nil, fmt.Errorf("object: ident: invalid timezone hours: %w", err)
	}
	mm, err := strconv.Atoi(string(tz[3:5]))
	if err != nil {
		return nil, fmt.Errorf("object: ident: invalid timezone minutes: %w", err)
	}
	if hh < 0 || hh > 23 {
		return nil, errors.New("object: ident: invalid timezone hours range")
	}
	if mm < 0 || mm > 59 {
		return nil, errors.New("object: ident: invalid timezone minutes range")
	}
	total := int64(hh)*60 + int64(mm)
	offset, err := intconv.Int64ToInt32(total)
	if err != nil {
		return nil, errors.New("object: ident: timezone overflow")
	}
	if sign < 0 {
		offset = -offset
	}
	return &Ident{
		Name:          nameBytes,
		Email:         emailBytes,
		WhenUnix:      when,
		OffsetMinutes: offset,
	}, nil
}

// Serialize renders the identity in canonical Git format.
func (ident Ident) Serialize() ([]byte, error) {
	var b strings.Builder
	b.Grow(len(ident.Name) + len(ident.Email) + 32)
	b.Write(ident.Name)
	b.WriteString(" <")
	b.Write(ident.Email)
	b.WriteString("> ")
	b.WriteString(strconv.FormatInt(ident.WhenUnix, 10))
	b.WriteByte(' ')

	offset := ident.OffsetMinutes
	sign := '+'
	if offset < 0 {
		sign = '-'
		offset = -offset
	}
	hh := offset / 60
	mm := offset % 60
	fmt.Fprintf(&b, "%c%02d%02d", sign, hh, mm)
	return []byte(b.String()), nil
}

// When returns a time.Time with the identity's timezone offset.
func (ident Ident) When() time.Time {
	loc := time.FixedZone("git", int(ident.OffsetMinutes)*60)
	return time.Unix(ident.WhenUnix, 0).In(loc)
}