365 lines
8.6 KiB
Go
365 lines
8.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)
|
|
}
|
|
|
|
type Gitrepo struct {
|
|
Path string
|
|
Name string
|
|
Branch string
|
|
Remote string
|
|
TrackingBranch string
|
|
IsBare bool
|
|
cmdGrace time.Duration
|
|
}
|
|
|
|
// Repositories without a remote should be ignored and not treated as severe errors
|
|
var missingRemoteError = errors.New("no remote")
|
|
|
|
func IsMissingRemoteError(err error) bool {
|
|
return err == missingRemoteError
|
|
}
|
|
|
|
func (r *Gitrepo) resolveTracking() (string, string, error) {
|
|
// First check for remote
|
|
cmd := r.prepareGitCmd(false, "remote")
|
|
remoteOutput, err := cmd.Output()
|
|
if err != nil || strings.TrimSpace(string(remoteOutput)) == "" {
|
|
return "", "", missingRemoteError
|
|
}
|
|
|
|
remote := strings.TrimSpace(string(remoteOutput))
|
|
|
|
var tracking string
|
|
var errBuf bytes.Buffer
|
|
|
|
cmd = r.prepareGitCmd(false, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
|
cmd.Stderr = &errBuf
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
// Might be a bare repo or a mirror
|
|
if strings.HasPrefix(errBuf.String(), "fatal: no upstream configured") {
|
|
log.Trace().Str("repo", r.Name).Msg("Remote but no tracking branch")
|
|
return remote, "", nil
|
|
}
|
|
return remote, "", err
|
|
}
|
|
|
|
// Get tracking branch and also extract default remote
|
|
tracking = strings.TrimSpace(string(output))
|
|
parts := strings.SplitN(tracking, "/", 2)
|
|
if len(parts) != 2 {
|
|
return remote, tracking, errors.New("unexpected format")
|
|
}
|
|
// Associated remote
|
|
remote = parts[0]
|
|
log.Trace().Str("repo", r.Name).Str("tracking", tracking).Str("remote", remote).Send()
|
|
|
|
return remote, tracking, nil
|
|
}
|
|
|
|
func (r *Gitrepo) resolveLocal() (string, error) {
|
|
cmd := r.prepareGitCmd(true, "symbolic-ref", "--short", "HEAD")
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
currentBranch := strings.TrimSpace(string(output))
|
|
log.Trace().Str("repo", r.Name).Str("branch", currentBranch).Send()
|
|
|
|
return currentBranch, nil
|
|
}
|
|
|
|
func (r *Gitrepo) isBareRepo() bool {
|
|
cmd := r.prepareGitCmd(true, "rev-parse", "--is-bare-repository")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
bare := strings.Contains(string(out), "true")
|
|
log.Trace().Str("repo", r.Name).Bool("bare", bare).Send()
|
|
|
|
return bare
|
|
}
|
|
|
|
func (r *Gitrepo) prepareGitCmd(pgroup bool, args ...string) *exec.Cmd {
|
|
cmd := exec.Command("git", append([]string{"-C", r.GetPath()}, args...)...)
|
|
cmd.Stdin = nil
|
|
|
|
if pgroup {
|
|
// We don't want the subprocess to receive the SIGINT from the shell, to
|
|
// not poison the failed repo collection with invalid entries. Create a new
|
|
// process group for each spawned subprocess.
|
|
cmd.SysProcAttr = &unix.SysProcAttr{
|
|
Setpgid: true,
|
|
Pgid: 0,
|
|
}
|
|
}
|
|
log.Trace().Str("name", r.Name).Str("cmd", cmd.String()).Send()
|
|
|
|
return cmd
|
|
}
|
|
|
|
func (r *Gitrepo) prepareGitCmdWithCtx(ctx context.Context, args ...string) *exec.Cmd {
|
|
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", r.GetPath()}, args...)...)
|
|
cmd.Stdin = nil
|
|
|
|
log.Trace().Str("name", r.Name).Str("cmd", cmd.String()).Send()
|
|
|
|
return cmd
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|