internal/git/updateref/updateref.go (385 lines of code) (raw):
package updateref
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"gitlab.com/gitlab-org/gitaly/v16/internal/command"
"gitlab.com/gitlab-org/gitaly/v16/internal/git"
"gitlab.com/gitlab-org/gitaly/v16/internal/git/gitcmd"
"gitlab.com/gitlab-org/gitaly/v16/internal/structerr"
)
var (
// errClosed is returned when accessing an updater that has already been closed.
errClosed = errors.New("closed")
// ErrPackedRefsLocked indicates an operation failed due to the 'packed-refs' being locked. This is
// the case if either `packed-refs.new` or `packed-refs.lock` exists in the repository.
ErrPackedRefsLocked = errors.New("packed-refs locked")
)
// MultipleUpdatesError indicates that a reference cannot have multiple updates
// within the same transaction.
type MultipleUpdatesError struct {
// ReferenceName is the name of the reference that has multiple updates.
ReferenceName string
}
func (e MultipleUpdatesError) Error() string {
return "reference has been updated multiple times within a transaction"
}
// ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the name of the reference that
// has multiple updates.
func (e MultipleUpdatesError) ErrorMetadata() []structerr.MetadataItem {
return []structerr.MetadataItem{
{Key: "reference", Value: e.ReferenceName},
}
}
// AlreadyLockedError indicates a reference cannot be locked because another
// process has already locked it.
type AlreadyLockedError struct {
// ReferenceName is the name of the reference that is already locked.
ReferenceName string
}
func (e AlreadyLockedError) Error() string {
return "reference is already locked"
}
// ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the name of the reference that was
// locked already.
func (e AlreadyLockedError) ErrorMetadata() []structerr.MetadataItem {
return []structerr.MetadataItem{
{Key: "reference", Value: e.ReferenceName},
}
}
// ReferenceAlreadyExistsError is returned when attempting to create a reference
// that already exists.
type ReferenceAlreadyExistsError struct {
// ReferenceName is the name of the reference that already exists.
ReferenceName string
}
func (e ReferenceAlreadyExistsError) Error() string {
return "reference already exists"
}
// ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the name of the
// reference that already existed.
func (e ReferenceAlreadyExistsError) ErrorMetadata() []structerr.MetadataItem {
return []structerr.MetadataItem{
{Key: "reference", Value: e.ReferenceName},
}
}
// InvalidReferenceFormatError indicates a reference name was invalid.
type InvalidReferenceFormatError struct {
// ReferenceName is the invalid reference name.
ReferenceName string
}
func (e InvalidReferenceFormatError) Error() string {
return "invalid reference format"
}
// ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the name of the reference that was
// invalid.
func (e InvalidReferenceFormatError) ErrorMetadata() []structerr.MetadataItem {
return []structerr.MetadataItem{
{Key: "reference", Value: e.ReferenceName},
}
}
// FileDirectoryConflictError is returned when an operation would causes a file-directory conflict
// in the reference store.
type FileDirectoryConflictError struct {
// ConflictingReferenceName is the name of the reference that would have conflicted.
ConflictingReferenceName string
// ExistingReferenceName is the name of the already existing reference.
ExistingReferenceName string
}
func (e FileDirectoryConflictError) Error() string {
return "file directory conflict"
}
// ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the name of preexisting and
// conflicting reference names.
func (e FileDirectoryConflictError) ErrorMetadata() []structerr.MetadataItem {
return []structerr.MetadataItem{
{Key: "conflicting_reference", Value: e.ConflictingReferenceName},
{Key: "existing_reference", Value: e.ExistingReferenceName},
}
}
// InTransactionConflictError is returned when creating two F/D or D/F conflicting references
// in the same transaction. For example creation of 'refs/heads/parent' and creation of
// 'refs/heads/parent/child' is not allowed in the same transaction.
type InTransactionConflictError struct {
// FirstReferenceName is the name of the first reference that was created.
FirstReferenceName string
// SecondReferenceName is the name of the second reference that was created.
SecondReferenceName string
}
func (e InTransactionConflictError) Error() string {
return "conflicting reference updates in the same transaction"
}
// ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the name of the first and second
// conflicting reference names.
func (e InTransactionConflictError) ErrorMetadata() []structerr.MetadataItem {
return []structerr.MetadataItem{
{Key: "first_reference", Value: e.FirstReferenceName},
{Key: "second_reference", Value: e.SecondReferenceName},
}
}
// NonExistentObjectError is returned when attempting to point a reference to an object that does not
// exist in the object database.
type NonExistentObjectError struct {
// ReferenceName is the name of the reference that was being updated.
ReferenceName string
// ObjectID is the object ID of the non-existent object.
ObjectID string
}
func (e NonExistentObjectError) Error() string {
return "target object missing"
}
// ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the missing object as well as the
// reference that should have been updated to point to it.
func (e NonExistentObjectError) ErrorMetadata() []structerr.MetadataItem {
return []structerr.MetadataItem{
{Key: "reference", Value: e.ReferenceName},
{Key: "missing_object", Value: e.ObjectID},
}
}
// NonCommitObjectError is returned when attempting to point a branch to an object that is not an object.
type NonCommitObjectError struct {
// ReferenceName is the name of the branch that was being updated.
ReferenceName string
// ObjectID is the object ID of the non-commit object.
ObjectID string
}
func (e NonCommitObjectError) Error() string {
return "target object not a commit"
}
// ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides the object that is not a commit as
// well as the reference that should have been updated to point to it.
func (e NonCommitObjectError) ErrorMetadata() []structerr.MetadataItem {
return []structerr.MetadataItem{
{Key: "reference", Value: e.ReferenceName},
{Key: "non_commit_object", Value: e.ObjectID},
}
}
// MismatchingStateError is returned when attempting to update a reference where the expected object ID does not match
// the actual object ID that the reference currently points to.
type MismatchingStateError struct {
// ReferenceName is the name of the reference that was being updated.
ReferenceName string
// ExpectedObjectID is the expected object ID as specified by the caller.
ExpectedObjectID string
// ActualObjectID is the actual object ID that the reference was pointing to.
ActualObjectID string
}
func (e MismatchingStateError) Error() string {
return "reference does not point to expected object"
}
// ErrorMetadata implements the `structerr.ErrorMetadater` interface and provides error metadata about the expected and
// actual object ID of the failed reference update.
func (e MismatchingStateError) ErrorMetadata() []structerr.MetadataItem {
return []structerr.MetadataItem{
{Key: "reference", Value: e.ReferenceName},
{Key: "expected_object_id", Value: e.ExpectedObjectID},
{Key: "actual_object_id", Value: e.ActualObjectID},
}
}
// state represents a possible state the updater can be in.
type state string
const (
// stateIdle means the updater is ready for a new transaction to start.
stateIdle state = "idle"
// stateStarted means the updater has an open transaction and accepts
// new reference changes.
stateStarted state = "started"
// statePrepared means the updater has prepared a transaction and no longer
// accepts reference changes until the current transaction is committed and
// a new one started.
statePrepared state = "prepared"
)
// invalidStateTransitionError is returned when the updater is used incorrectly.
type invalidStateTransitionError struct {
// expected is the state the updater was expected to be in.
expected state
// actual is the state the updater was actually in.
actual state
}
// Error returns the formatted error string.
func (err invalidStateTransitionError) Error() string {
return fmt.Sprintf("expected state %q but it was %q", err.expected, err.actual)
}
// Updater wraps a `git update-ref --stdin` process, presenting an interface
// that allows references to be easily updated in bulk. It is not suitable for
// concurrent use.
//
// Correct usage of the Updater is as follows:
// 1. Transaction must be started before anything else.
// 2. Transaction can't be started if there is an active transaction.
// 3. Updates can be staged only when there is an unprepared transaction.
// 4. Prepare can be called only with an unprepared transaction.
// 5. Commit can be called only with an active transaction. The transaction
// can be committed unprepared or prepared.
// 7. Close can be called at any time. The active transaction is aborted.
// 8. Any sort of error causes the updater to close.
type Updater struct {
repo gitcmd.RepositoryExecutor
cmd *command.Command
closeErr error
stdout *bufio.Reader
stderr *bytes.Buffer
objectHash git.ObjectHash
referenceBackend git.ReferenceBackend
ctx context.Context
// state tracks the current state of the updater to ensure correct calling semantics.
state state
}
// UpdaterOpt is a type representing options for the Updater.
type UpdaterOpt func(*updaterConfig)
type updaterConfig struct {
disableTransactions bool
noDeref bool
}
// WithDisabledTransactions disables hooks such that no reference-transactions
// are used for the updater.
func WithDisabledTransactions() UpdaterOpt {
return func(cfg *updaterConfig) {
cfg.disableTransactions = true
}
}
// WithNoDeref disables de-reference while updating ref. If this option is turned on,
// <ref> itself is overwritten, rather than the result of following the symbolic ref.
func WithNoDeref() UpdaterOpt {
return func(cfg *updaterConfig) {
cfg.noDeref = true
}
}
// New returns a new bulk updater, wrapping a `git update-ref` process. Call the
// various methods to enqueue updates, then call Commit() to attempt to apply all
// the updates at once.
//
// It is important that ctx gets canceled somewhere. If it doesn't, the process
// spawned by New() may never terminate.
func New(ctx context.Context, repo gitcmd.RepositoryExecutor, opts ...UpdaterOpt) (*Updater, error) {
var cfg updaterConfig
for _, opt := range opts {
opt(&cfg)
}
objectHash, err := repo.ObjectHash(ctx)
if err != nil {
return nil, fmt.Errorf("detecting object hash: %w", err)
}
txOption := gitcmd.WithRefTxHook(objectHash, repo)
if cfg.disableTransactions {
txOption = gitcmd.WithDisabledHooks()
}
cmdFlags := []gitcmd.Option{gitcmd.Flag{Name: "-z"}, gitcmd.Flag{Name: "--stdin"}}
if cfg.noDeref {
cmdFlags = append(cmdFlags, gitcmd.Flag{Name: "--no-deref"})
}
var stderr bytes.Buffer
cmd, err := repo.Exec(ctx,
gitcmd.Command{
Name: "update-ref",
Flags: cmdFlags,
},
txOption,
gitcmd.WithSetupStdin(),
gitcmd.WithSetupStdout(),
gitcmd.WithStderr(&stderr),
)
if err != nil {
return nil, err
}
referenceBackend, err := repo.ReferenceBackend(ctx)
if err != nil {
return nil, fmt.Errorf("detecting reference backend: %w", err)
}
return &Updater{
repo: repo,
cmd: cmd,
stderr: &stderr,
stdout: bufio.NewReader(cmd),
objectHash: objectHash,
referenceBackend: referenceBackend,
state: stateIdle,
ctx: ctx,
}, nil
}
// expectState returns an error and closes the updater if it is not in the expected state.
func (u *Updater) expectState(expected state) error {
if u.closeErr != nil {
return u.closeErr
}
if err := u.checkState(expected); err != nil {
return u.closeWithError(err)
}
return nil
}
// checkState returns an error if the updater is not in the expected state.
func (u *Updater) checkState(expected state) error {
if u.state != expected {
return invalidStateTransitionError{expected: expected, actual: u.state}
}
return nil
}
// Start begins a new reference transaction. The reference changes are not performed until Commit
// is explicitly called.
func (u *Updater) Start() error {
if err := u.expectState(stateIdle); err != nil {
return err
}
u.state = stateStarted
return u.setState("start")
}
// Update commands the reference to be updated to point at the object ID specified in newOID. If
// newOID is the zero OID, then the branch will be deleted. If oldOID is a non-empty string, then
// the reference will only be updated if its current value matches the old value. If the old value
// is the zero OID, then the branch must not exist.
//
// A reference transaction must be started before calling Update.
func (u *Updater) Update(reference git.ReferenceName, newOID, oldOID git.ObjectID) error {
if err := u.expectState(stateStarted); err != nil {
return err
}
return u.write("update %s\x00%s\x00%s\x00", reference.String(), newOID, oldOID)
}
// UpdateSymbolicReference is used to do a symbolic reference update. We can potentially provide the oldTarget
// or the oldOID.
func (u *Updater) UpdateSymbolicReference(version git.Version, reference, newTarget git.ReferenceName) error {
if err := u.expectState(stateStarted); err != nil {
return err
}
return u.write("symref-update %s\x00%s\x00\x00\x00", reference.String(), newTarget)
}
// Create commands the reference to be created with the given object ID. The ref must not exist.
//
// A reference transaction must be started before calling Create.
func (u *Updater) Create(reference git.ReferenceName, oid git.ObjectID) error {
return u.Update(reference, oid, u.objectHash.ZeroOID)
}
// Delete commands the reference to be removed from the repository. This command will ignore any old
// state of the reference and just force-remove it.
//
// A reference transaction must be started before calling Delete.
func (u *Updater) Delete(reference git.ReferenceName) error {
return u.Update(reference, u.objectHash.ZeroOID, "")
}
// Prepare prepares the reference transaction by locking all references and determining their
// current values. The updates are not yet committed and will be rolled back in case there is no
// call to `Commit()`. This call is optional.
func (u *Updater) Prepare() error {
if err := u.expectState(stateStarted); err != nil {
return err
}
u.state = statePrepared
return u.setState("prepare")
}
// Commit applies the commands specified in other calls to the Updater. Commit finishes the
// reference transaction and another one must be started before further changes can be staged.
func (u *Updater) Commit() error {
// Commit can be called without preparing the transactions.
if err := u.checkState(statePrepared); err != nil {
if err := u.expectState(stateStarted); err != nil {
return err
}
}
u.state = stateIdle
if err := u.setState("commit"); err != nil {
return err
}
return nil
}
// Close closes the updater and aborts a possible open transaction. No changes will be written
// to disk, all lockfiles will be cleaned up and the process will exit.
func (u *Updater) Close() error {
return u.closeWithError(nil)
}
// closeWithError closes the updater with the given error. The passed in error is only used
// if the updater closes successfully. This is used to close the Updater with errors raised
// by our logic when the command itself hasn't errored. All subsequent method calls return
// the error returned from first closeWithError call.
func (u *Updater) closeWithError(closeErr error) error {
if u.closeErr != nil {
return u.closeErr
}
if err := u.cmd.Wait(); err != nil {
err = structerr.New("%w", err).WithMetadataItems(
structerr.MetadataItem{Key: "stderr", Value: u.stderr.String()},
structerr.MetadataItem{Key: "close_error", Value: closeErr},
)
if parsedErr := u.parseStderr(); parsedErr != nil {
// If stderr contained a specific error, return it instead.
err = parsedErr
}
u.closeErr = err
return err
}
if u.ctx.Err() != nil {
u.closeErr = u.ctx.Err()
return u.closeErr
}
if closeErr != nil {
u.closeErr = closeErr
return closeErr
}
u.closeErr = errClosed
return nil
}
func (u *Updater) write(format string, args ...interface{}) error {
if _, err := fmt.Fprintf(u.cmd, format, args...); err != nil {
return u.closeWithError(err)
}
return nil
}
func (u *Updater) setState(state string) error {
if err := u.write("%s\x00", state); err != nil {
return err
}
// For each state-changing command, git-update-ref(1) will report successful execution via
// "<command>: ok" lines printed to its stdout. Ideally, we should thus verify here whether
// the command was successfully executed by checking for exactly this line, otherwise we
// cannot be sure whether the command has correctly been processed by Git or if an error was
// raised.
line, err := u.stdout.ReadString('\n')
if err != nil {
return u.closeWithError(fmt.Errorf("state update to %q failed: %w", state, err))
}
if line != fmt.Sprintf("%s: ok\n", state) {
return u.closeWithError(fmt.Errorf("state update to %q not successful: expected ok, got %q", state, line))
}
return nil
}
func (u *Updater) parseStderr() error {
stderr := u.stderr.Bytes()
matches := u.referenceBackend.RefLockedRegex.FindSubmatch(stderr)
// Reftable locks are on an entire table instead of per reference, so
// git doesn't output the name of the individual ref.
if u.referenceBackend == git.ReferenceBackendReftables && len(matches) == 2 {
return AlreadyLockedError{}
} else if len(matches) > 2 {
return AlreadyLockedError{ReferenceName: string(matches[2])}
}
matches = u.referenceBackend.PackedRefsLockedRegex.FindSubmatch(stderr)
if len(matches) > 1 {
return ErrPackedRefsLocked
}
matches = u.referenceBackend.RefInvalidFormatRegex.FindSubmatch(stderr)
if len(matches) > 1 {
return InvalidReferenceFormatError{ReferenceName: string(matches[1])}
}
matches = u.referenceBackend.ReferenceExistsConflictRegex.FindSubmatch(stderr)
if len(matches) > 1 {
return FileDirectoryConflictError{
ExistingReferenceName: string(matches[1]),
ConflictingReferenceName: string(matches[2]),
}
}
matches = u.referenceBackend.InTransactionConflictRegex.FindSubmatch(stderr)
if len(matches) > 1 {
return InTransactionConflictError{
FirstReferenceName: string(matches[1]),
SecondReferenceName: string(matches[2]),
}
}
matches = u.referenceBackend.NonExistentObjectRegex.FindSubmatch(stderr)
if len(matches) > 1 {
return NonExistentObjectError{
ReferenceName: string(matches[1]),
ObjectID: string(matches[2]),
}
}
matches = u.referenceBackend.NonCommitObjectRegex.FindSubmatch(stderr)
if len(matches) > 1 {
return NonCommitObjectError{
ReferenceName: string(matches[2]),
ObjectID: string(matches[1]),
}
}
matches = u.referenceBackend.MismatchingStateRegex.FindSubmatch(stderr)
if len(matches) > 2 {
return MismatchingStateError{
ReferenceName: string(matches[1]),
ExpectedObjectID: string(matches[3]),
ActualObjectID: string(matches[2]),
}
}
matches = u.referenceBackend.ReferenceAlreadyExistsRegex.FindSubmatch(stderr)
if len(matches) > 1 {
return ReferenceAlreadyExistsError{
ReferenceName: string(matches[1]),
}
}
matches = u.referenceBackend.MultipleUpdatesRegex.FindSubmatch(stderr)
if len(matches) > 1 {
return MultipleUpdatesError{
ReferenceName: string(matches[1]),
}
}
return nil
}