Download
168 lines 4.8 KiBGoModified 2026-10-22 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
// Package queue is the append-only job log written by the post-receive hook
// and drained by repo-render under an exclusive flock.
package queue

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"strings"

	"golang.org/x/sys/unix"
)

// Job is one queue line: repo TAB ref TAB old TAB new.
type Job struct {
	Repo string
	Ref  string
	Old  string
	New  string
}

// Format renders the job as the hook would write it.
func (j Job) Format() string {
	return fmt.Sprintf("%s\t%s\t%s\t%s\n", j.Repo, j.Ref, j.Old, j.New)
}

// Append adds one job with a single write(2). The hook and the renderer
// both run as git, so the file is git's alone.
//
// Appends hold a shared flock and Take holds an exclusive one, so a job is
// never written into a queue file that Take has already read. An appender
// that opened the file just before Take renamed it notices, once it has the
// lock, that the name now points elsewhere, and starts over on the new file.
func Append(path string, j Job) error {
	for {
		f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
		if err != nil {
			return err
		}
		if err := unix.Flock(int(f.Fd()), unix.LOCK_SH); err != nil {
			_ = f.Close() // nothing written yet
			return err
		}
		if !stillNamed(f, path) {
			_ = f.Close() // nothing written yet
			continue
		}
		_, err = f.WriteString(j.Format())
		if cerr := f.Close(); err == nil {
			err = cerr
		}
		return err
	}
}

// stillNamed reports whether path still refers to the open file f.
func stillNamed(f *os.File, path string) bool {
	a, err := f.Stat()
	if err != nil {
		return false
	}
	b, err := os.Stat(path)
	return err == nil && os.SameFile(a, b)
}

// Take atomically claims the current queue contents: the file is renamed
// aside (new kicks append to a fresh file) and parsed. The caller deletes the
// returned claimed path once every job in it has been handled; a crash leaves
// it in place for Recover.
//
// The caller must hold the render lock, which makes it the only process
// creating claimed files: the name it picks stays free until the rename.
func Take(path string) (jobs []Job, claimed string, err error) {
	// A crashed run with a reused pid may have left this pid's name behind,
	// still waiting to be handled; renaming onto it would lose its jobs.
	claimed = fmt.Sprintf("%s.%d", path, os.Getpid())
	for n := 1; ; n++ {
		if _, err := os.Lstat(claimed); errors.Is(err, fs.ErrNotExist) {
			break
		} else if err != nil {
			return nil, "", err
		}
		claimed = fmt.Sprintf("%s.%d.%d", path, os.Getpid(), n)
	}
	if err := os.Rename(path, claimed); err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, "", nil
		}
		return nil, "", err
	}
	f, err := os.Open(claimed)
	if err != nil {
		return nil, "", err
	}
	defer func() { _ = f.Close() }() // read only, through parse
	// Wait out appenders that got the file before the rename. Later ones
	// see the rename and write to the fresh queue instead.
	if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil {
		return nil, "", err
	}
	jobs, err = parse(claimed)
	return jobs, claimed, err
}

// Recover returns jobs from claimed files left behind by crashed runs, and
// those files. It leaves the files in place: the caller deletes them once
// the jobs are handled, so a second crash recovers them again. Jobs are
// idempotent so re-running them is safe.
func Recover(path string) (jobs []Job, files []string, err error) {
	// Listed, not globbed: the directory's own name may hold glob
	// characters.
	dir, prefix := filepath.Dir(path), filepath.Base(path)+"."
	ents, err := os.ReadDir(dir)
	if errors.Is(err, fs.ErrNotExist) {
		return nil, nil, nil
	}
	if err != nil {
		return nil, nil, err
	}
	for _, e := range ents {
		if !strings.HasPrefix(e.Name(), prefix) {
			continue
		}
		m := filepath.Join(dir, e.Name())
		js, err := parse(m)
		if err != nil {
			return nil, nil, err
		}
		jobs = append(jobs, js...)
		files = append(files, m)
	}
	return jobs, files, nil
}

func parse(path string) ([]Job, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var jobs []Job
	for line := range strings.Lines(string(data)) {
		line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
		f := strings.Split(line, "\t")
		if len(f) != 4 {
			// A blank, torn or hand-edited line, however long: never let one
			// bad line wedge the queue.
			continue
		}
		jobs = append(jobs, Job{Repo: f[0], Ref: f[1], Old: f[2], New: f[3]})
	}
	return jobs, nil
}

// Lock takes a blocking exclusive flock on path and returns the file; the
// lock is released when the file is closed or the process exits.
func Lock(path string) (*os.File, error) {
	f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o644)
	if err != nil {
		return nil, err
	}
	if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil {
		_ = f.Close() // the flock error is the one to report
		return nil, err
	}
	return f, nil
}