update-repos/internal/repo/repo.go

256 lines
5.6 KiB
Go

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright © 2023 Thorsten Schubert <tschubert@bafh.org>
package repo
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/rs/zerolog/log"
"golang.org/x/sys/unix"
)
type Repo interface {
GetName() string
GetPath() string
GetObjects() ([]byte, error)
IsHistoryDiverged() bool
FetchOrUpdateWithCtx(context.Context, bool) error
IsRemoteAliveWithCtx(context.Context) (bool, error)
Merge() ([]byte, []byte, error)
Reset(bool) ([]byte, []byte, error)
}
func NewGitRepo(path string) (Repo, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return nil, err
}
// Every git repo is defined by its path
r := &Gitrepo{
Path: absolute,
// Store the name of the repo for faster access
Name: filepath.Base(absolute),
cmdGrace: time.Second * 2,
}
// Every repo type has a local branch
local, err := r.resolveLocal()
if err != nil {
return nil, err
}
r.Branch = local
// Without a remote, this program is pointless
remote, tracking, err := r.resolveTracking()
if err != nil {
return nil, err
}
r.Remote = remote
r.TrackingBranch = tracking
if r.isBareRepo() {
r.IsBare = true
} else if tracking == "" {
// Non-bare repo without a tracking branch are not our target
return nil, errors.New("no tracking branch")
}
log.Trace().Interface("Repo", r).Send()
return r, nil
}
func (r *Gitrepo) GetName() string {
return r.Name
}
func (r *Gitrepo) GetPath() string {
return r.Path
}
func (r *Gitrepo) IsRemoteAliveWithCtx(ctx context.Context) (bool, error) {
cmd := r.prepareGitCmd(true, "ls-remote", "--exit-code", "--heads")
if err := cmd.Start(); err != nil {
return false, err
}
err := waitForCmdWithCtx(ctx, cmd, r.cmdGrace)
if err != nil {
if err == ctx.Err() {
return false, err
}
log.Trace().Str("repo", r.Name).Err(err).Msg("Alive check failed")
return false, err
}
log.Trace().Str("repo", r.Name).Msg("Alive check success")
return true, nil
}
func (r *Gitrepo) FetchOrUpdateWithCtx(ctx context.Context, atomic bool) error {
var cmd *exec.Cmd
var err error
if r.IsBare {
cmd = r.prepareGitCmd(true, "remote", "update", "--prune")
} else {
args := []string{"fetch", "--prune"}
if atomic {
args = append(args, "--atomic")
}
cmd = r.prepareGitCmd(true, args...)
}
if err = cmd.Start(); err != nil {
return err
}
err = waitForCmdWithCtx(ctx, cmd, r.cmdGrace)
if err != nil {
log.Trace().Str("repo", r.Name).Err(err).Msg("Update failed")
} else {
log.Trace().Str("repo", r.Name).Msg("Update success")
}
return err
}
func (r *Gitrepo) GetObjects() ([]byte, error) {
var outBuf bytes.Buffer
var errBuf bytes.Buffer
cmd := r.prepareGitCmd(false, "for-each-ref", "--format", "'%(objectname)'")
cmd.Stdout, cmd.Stderr = &outBuf, &errBuf
err := cmd.Run()
if err != nil {
log.Trace().Str("repo", r.Name).Str("stderr", errBuf.String()).Msg("Object error")
return errBuf.Bytes(), err
}
log.Trace().Str("repo", r.Name).Int("bytes", outBuf.Len()).Msg("Object retrieval successful")
return outBuf.Bytes(), err
}
func (r *Gitrepo) IsHistoryDiverged() bool {
log.Trace().Str("tracking", r.TrackingBranch).Str("remote", r.Remote).Send()
cmdArgs := []string{
"-C",
r.GetPath(),
"rev-list",
"--left-right",
"--count",
fmt.Sprintf("HEAD...%s", r.TrackingBranch),
}
cmd := r.prepareGitCmd(true, cmdArgs...)
output, err := cmd.Output()
if err != nil {
return false
}
counts := strings.Fields(string(output))
if len(counts) != 2 {
return false
}
left, err1 := strconv.Atoi(counts[0])
right, err2 := strconv.Atoi(counts[1])
if err1 != nil || err2 != nil {
return false
}
if left != 0 {
log.Warn().Str("repo", r.Name).Int("left", left).Int("right", right).Msg("Branch diverged")
return true
}
return false
}
func (r *Gitrepo) Merge() (stdout []byte, stderr []byte, err error) {
var (
outBuf bytes.Buffer
errBuf bytes.Buffer
cmd *exec.Cmd
)
if r.IsBare {
return nil, errBuf.Bytes(), errors.New("cannot call merge on a bare repo")
}
cmd = r.prepareGitCmd(true, "merge", "--ff-only", "--stat", r.TrackingBranch)
cmd.Stdout, cmd.Stderr = &outBuf, &errBuf
err = cmd.Run()
if err != nil {
log.Trace().Str("repo", r.Name).Str("stderr", errBuf.String()).Msg("Update error")
return outBuf.Bytes(), errBuf.Bytes(), err
}
return outBuf.Bytes(), errBuf.Bytes(), nil
}
func (r *Gitrepo) Reset(hardReset bool) (stdout []byte, stderr []byte, err error) {
var (
outBuf bytes.Buffer
errBuf bytes.Buffer
cmd *exec.Cmd
)
if r.IsBare {
return nil, errBuf.Bytes(), errors.New("cannot perform reset on a bare repo")
}
resetFlag := "--soft"
if hardReset {
resetFlag = "--hard"
}
cmd = r.prepareGitCmd(true, "reset", resetFlag, "@{u}")
cmd.Stdout, cmd.Stderr = &outBuf, &errBuf
err = cmd.Run()
if err != nil {
log.Trace().Str("repo", r.Name).Str("stderr", errBuf.String()).Msg("Reset error")
return outBuf.Bytes(), errBuf.Bytes(), err
}
return outBuf.Bytes(), errBuf.Bytes(), nil
}
func waitForCmdWithCtx(ctx context.Context, cmd *exec.Cmd, cmdGrace time.Duration) error {
// Buffered channel to avoid leaking the goroutine on ctx.Done()
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-ctx.Done():
log.Trace().Int("pid", cmd.Process.Pid).Str("cmd", cmd.String()).Msg("SIGTERM")
unix.Kill(-cmd.Process.Pid, unix.SIGTERM)
select {
case <-time.After(cmdGrace):
log.Trace().Int("pid", cmd.Process.Pid).Str("cmd", cmd.String()).Msg("SIGKILL")
unix.Kill(-cmd.Process.Pid, unix.SIGKILL)
case <-done:
// SIGTERM successful
}
return ctx.Err()
case err := <-done:
return err
}
}