Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 73 additions & 5 deletions bridge/core/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"regexp"
"sort"
Expand All @@ -21,6 +22,7 @@ import (

var ErrImportNotSupported = errors.New("import is not supported")
var ErrExportNotSupported = errors.New("export is not supported")
var ErrBridgeRunning = errors.New("a bridge is already running in this repository")

const (
ConfigKeyTarget = "target"
Expand All @@ -32,6 +34,12 @@ const (
bridgeConfigSubsectionPrefix = "bridge."

configKeyLastImportTime = "lastImportTime"

// bridgeLockFile is locked while a bridge imports or exports, so that only
// one runs at a time in a repository: concurrent runs would import or export
// the same items twice. Every bridge shares it, as two bridges can target the
// same remote, and the import and export of a bridge race too.
bridgeLockFile = "bridge.lock"
)

// bridgeConfigKey returns the key of the configuration entry name of the bridge.
Expand Down Expand Up @@ -337,24 +345,33 @@ func (b *Bridge) ImportAllSince(ctx context.Context, since time.Time) (<-chan Im
return nil, ErrImportNotSupported
}

err := b.ensureConfig()
unlock, err := lockBridges(b.repo)
if err != nil {
return nil, err
}

err = b.ensureConfig()
if err != nil {
unlock()
return nil, err
}

err = b.ensureImportInit(ctx)
if err != nil {
unlock()
return nil, err
}

events, err := importer.ImportAll(ctx, b.repo, since)
if err != nil {
unlock()
return nil, err
}

out := make(chan ImportResult)
go func() {
defer close(out)
defer unlock()
Comment thread
greptile-apps[bot] marked this conversation as resolved.
noError := true

// relay all events while checking that everything went well
Expand All @@ -365,8 +382,9 @@ func (b *Bridge) ImportAllSince(ctx context.Context, since time.Time) (<-chan Im
out <- event
}

// store the last import time ONLY if no error happened
if noError {
// store the last import time ONLY if no error happened and the import
// ran to completion (importers stop silently on cancellation)
if noError && ctx.Err() == nil {
err = b.repo.Config().Update(func(f *gitconfig.File) error {
return f.ReplaceAll(bridgeConfigKey(b.Name, configKeyLastImportTime), strconv.FormatInt(importStartTime.Unix(), 10))
})
Expand Down Expand Up @@ -396,15 +414,65 @@ func (b *Bridge) ExportAll(ctx context.Context, since time.Time) (<-chan ExportR
return nil, ErrExportNotSupported
}

err := b.ensureConfig()
unlock, err := lockBridges(b.repo)
if err != nil {
return nil, err
}

err = b.ensureConfig()
if err != nil {
unlock()
return nil, err
}

err = b.ensureExportInit(ctx)
if err != nil {
unlock()
return nil, err
}

events, err := exporter.ExportAll(ctx, b.repo, since)
if err != nil {
unlock()
return nil, err
}

out := make(chan ExportResult)
go func() {
defer close(out)
defer unlock()

for event := range events {
out <- event
Comment thread
MichaelMure marked this conversation as resolved.
}
}()

return out, nil
}

// lockBridges takes the bridge lock of the repository, see bridgeLockFile, and
// returns the function releasing it. Like git with its index.lock, the lock is a
// file created exclusively: if it exists, it fails with ErrBridgeRunning rather
// than waiting.
func lockBridges(repo repository.RepoStorage) (unlock func(), err error) {
storage := repo.LocalStorage()

f, err := storage.OpenFile(bridgeLockFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if errors.Is(err, os.ErrExist) {
path := filepath.Join(storage.Root(), bridgeLockFile)
return nil, fmt.Errorf("%w: '%s' exists.\n\n"+
"If no other git-bug bridge is running, a previous one may have crashed:\n"+
"remove the file manually to continue", ErrBridgeRunning, path)
}
if err != nil {
return nil, err
}

err = f.Close()
if err != nil {
_ = storage.Remove(bridgeLockFile)
return nil, err
}

return exporter.ExportAll(ctx, b.repo, since)
return func() { _ = storage.Remove(bridgeLockFile) }, nil
Comment thread
MichaelMure marked this conversation as resolved.
}
25 changes: 25 additions & 0 deletions bridge/core/lock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package core

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/git-bug/git-bug/repository"
)

func TestLockBridges(t *testing.T) {
repo := repository.NewMockRepo()

unlock, err := lockBridges(repo)
require.NoError(t, err)

_, err = lockBridges(repo)
require.ErrorIs(t, err, ErrBridgeRunning)

unlock()

unlock, err = lockBridges(repo)
require.NoError(t, err)
unlock()
}
1 change: 1 addition & 0 deletions bridge/launchpad/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func (li *launchpadImporter) ImportAll(ctx context.Context, repo *cache.RepoCach
}

go func() {
defer close(out)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cancelled pull appears complete

When a Launchpad pull is cancelled after SearchTasks returns, closing this channel lets the relay finish without receiving an error event. It then records lastImportTime, and the CLI reports a completed import even though some tasks were not processed. This makes it harder to tell that the pull stopped early and may cause later pulls to resume from the wrong checkpoint.

for _, lpBug := range lpBugs {
select {
case <-ctx.Done():
Expand Down
Loading