diff --git a/config/flipt.schema.cue b/config/flipt.schema.cue index b27464a75d..6276c83b97 100644 --- a/config/flipt.schema.cue +++ b/config/flipt.schema.cue @@ -200,6 +200,7 @@ JsonPath: string #storage: [string]: { remote?: string + fetch_policy?: *"strict" | "lenient" backend?: { type: *"memory" | "local" path?: string diff --git a/config/flipt.schema.json b/config/flipt.schema.json index 09a7bfd260..33bb57cc58 100644 --- a/config/flipt.schema.json +++ b/config/flipt.schema.json @@ -776,6 +776,11 @@ "remote": { "type": "string" }, + "fetchPolicy": { + "type": "string", + "enum": ["strict", "lenient"], + "default": "strict" + }, "backend": { "type": [ "object", diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 98e415d7fe..80e829f780 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -459,6 +459,7 @@ func TestLoad(t *testing.T) { Type: MemoryStorageBackendType, }, Remote: "https://github.com/flipt-io/flipt.git", + FetchPolicy: "strict", Branch: "main", PollInterval: 5 * time.Second, Credentials: "git", @@ -578,6 +579,7 @@ func TestLoad(t *testing.T) { Type: MemoryStorageBackendType, }, Remote: "git@github.com:foo/bar.git", + FetchPolicy: "strict", Branch: "main", PollInterval: 30 * time.Second, }, @@ -597,6 +599,7 @@ func TestLoad(t *testing.T) { Path: "/path/to/gitdir", }, Remote: "git@github.com:foo/bar.git", + FetchPolicy: "lenient", Branch: "main", PollInterval: 30 * time.Second, }, @@ -635,6 +638,7 @@ func TestLoad(t *testing.T) { Type: MemoryStorageBackendType, }, Remote: "git@github.com:foo/bar.git", + FetchPolicy: "strict", Branch: "main", PollInterval: 30 * time.Second, Credentials: "git", @@ -889,6 +893,7 @@ func TestLoad(t *testing.T) { Type: MemoryStorageBackendType, }, Remote: "git@github.com:foo/bar.git", + FetchPolicy: "strict", Branch: "main", PollInterval: 30 * time.Second, Signature: SignatureConfig{ @@ -1141,7 +1146,7 @@ OUTER: } } - return + return vals } func TestMarshalYAML(t *testing.T) { @@ -1516,7 +1521,7 @@ func TestLicenseConfig_validate(t *testing.T) { setupFile: func(t *testing.T) string { tmpDir := t.TempDir() licenseFile := filepath.Join(tmpDir, "license.cert") - err := os.WriteFile(licenseFile, []byte("test license content"), 0600) + err := os.WriteFile(licenseFile, []byte("test license content"), 0o600) require.NoError(t, err) return licenseFile }, @@ -1531,7 +1536,7 @@ func TestLicenseConfig_validate(t *testing.T) { setupFile: func(t *testing.T) string { tmpDir := t.TempDir() licenseFile := filepath.Join(tmpDir, "license.cert") - err := os.WriteFile(licenseFile, []byte("test license content"), 0600) + err := os.WriteFile(licenseFile, []byte("test license content"), 0o600) require.NoError(t, err) return licenseFile }, diff --git a/internal/config/storage.go b/internal/config/storage.go index bbdfc5bb50..1673bb50f2 100644 --- a/internal/config/storage.go +++ b/internal/config/storage.go @@ -60,6 +60,10 @@ func (s *StoragesConfig) setDefaults(v *viper.Viper) error { if getString("poll_interval") == "" { setDefault("poll_interval", "30s") } + + if getString("remote") != "" && getString("fetch_policy") == "" { + setDefault("fetch_policy", FetchPolicyStrict) + } } return nil @@ -77,10 +81,18 @@ type StorageBackendConfig struct { Path string `json:"path,omitempty" mapstructure:"path" yaml:"path,omitempty"` } +type FetchPolicy string + +const ( + FetchPolicyStrict = FetchPolicy("strict") + FetchPolicyLenient = FetchPolicy("lenient") +) + // StorageConfig contains fields which will configure the type of backend in which Flipt will serve // flag state. type StorageConfig struct { Remote string `json:"remote,omitempty" mapstructure:"remote" yaml:"remote,omitempty"` + FetchPolicy FetchPolicy `json:"fetch_policy,omitempty" mapstructure:"fetch_policy" yaml:"fetch_policy,omitempty"` Backend StorageBackendConfig `json:"backend,omitempty" mapstructure:"backend" yaml:"backend,omitempty"` Branch string `json:"branch,omitempty" mapstructure:"branch" yaml:"branch,omitempty"` CaCertBytes string `json:"-" mapstructure:"ca_cert_bytes" yaml:"-"` diff --git a/internal/config/testdata/storage/git_provided_with_directory.yml b/internal/config/testdata/storage/git_provided_with_directory.yml index 2e88896837..5d1e255f36 100644 --- a/internal/config/testdata/storage/git_provided_with_directory.yml +++ b/internal/config/testdata/storage/git_provided_with_directory.yml @@ -1,6 +1,7 @@ storage: default: remote: "git@github.com:foo/bar.git" + fetch_policy: lenient backend: type: "local" path: "/path/to/gitdir" diff --git a/internal/storage/environments/environments.go b/internal/storage/environments/environments.go index c985606c6c..f496d0f492 100644 --- a/internal/storage/environments/environments.go +++ b/internal/storage/environments/environments.go @@ -147,6 +147,9 @@ func (rm *RepositoryManager) GetOrCreate(ctx context.Context, envConf *config.En zap.String("key_id", storage.Signature.KeyID)) } } + if storage.FetchPolicy == config.FetchPolicyLenient { + opts = append(opts, storagegit.WithLenientFetchPolicy()) + } newRepo, err := storagegit.NewRepository(ctx, logger, opts...) if err != nil { @@ -512,7 +515,12 @@ func NewStore(ctx context.Context, logger *zap.Logger, cfg *config.Config, secre // branched environments have been added for _, repo := range repoManager.repos { if err := repo.Fetch(ctx); err != nil { - if !errors.Is(err, transport.ErrEmptyRemoteRepository) && !errors.Is(err, git.ErrRemoteRefNotFound) { + switch { + case repo.HasLenientFetchPolicy() && repo.IsConnectionRefused(err): + continue + case errors.Is(err, transport.ErrEmptyRemoteRepository) || errors.Is(err, git.ErrRemoteRefNotFound): + continue + default: return nil, err } } diff --git a/internal/storage/git/repository.go b/internal/storage/git/repository.go index 65482ad79b..799b086cdf 100644 --- a/internal/storage/git/repository.go +++ b/internal/storage/git/repository.go @@ -9,6 +9,7 @@ import ( "slices" "strings" "sync" + "syscall" "time" "github.com/go-git/go-billy/v6/osfs" @@ -33,19 +34,20 @@ type Repository struct { logger *zap.Logger - mu sync.RWMutex - remote *config.RemoteConfig - defaultBranch string - auth transport.AuthMethod - insecureSkipTLS bool - caBundle []byte - localPath string - readme []byte - sigName string - sigEmail string - signer signing.Signer - maxOpenDescriptors int - isNormalRepo bool // true if opened with PlainOpen, false if bare repository + mu sync.RWMutex + remote *config.RemoteConfig + lenientFetchPolicyEnabled bool + defaultBranch string + auth transport.AuthMethod + insecureSkipTLS bool + caBundle []byte + localPath string + readme []byte + sigName string + sigEmail string + signer signing.Signer + maxOpenDescriptors int + isNormalRepo bool // true if opened with PlainOpen, false if bare repository subs []Subscriber @@ -71,7 +73,7 @@ func NewRepository(ctx context.Context, logger *zap.Logger, opts ...containers.O logger.Debug("repository empty, attempting to add and push a README") // add initial readme if repo is empty if _, err := repo.UpdateAndPush(ctx, repo.defaultBranch, func(fs envsfs.Filesystem) (string, error) { - fi, err := fs.OpenFile("README.md", os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644) + fi, err := fs.OpenFile("README.md", os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o644) if err != nil { return "", err } @@ -198,15 +200,28 @@ func newRepository(ctx context.Context, logger *zap.Logger, opts ...containers.O // do an initial fetch to setup remote tracking branches if err := r.Fetch(ctx); err != nil { - if !errors.Is(err, transport.ErrEmptyRemoteRepository) && !errors.Is(err, git.ErrRemoteRefNotFound) { - return nil, empty, fmt.Errorf("performing initial fetch: %w", err) + fetchErr := fmt.Errorf("performing initial fetch: %w", err) + switch { + case r.HasLenientFetchPolicy() && r.IsConnectionRefused(err): + // if lenient, we check if the error is connection refused + // and there is non-empty repo and flags could be evaluated + objs, rerr := r.CommitObjects() + if rerr != nil { + return nil, empty, fetchErr + } + _, rerr = objs.Next() + objs.Close() + if rerr != nil { + return nil, empty, fetchErr + } + case errors.Is(err, transport.ErrEmptyRemoteRepository) || errors.Is(err, git.ErrRemoteRefNotFound): + // the remote was reachable but either its contents was completely empty + // or our default branch doesn't exist and so we decide to seed it + empty = true + logger.Debug("initial fetch empty", zap.String("reference", r.defaultBranch), zap.Error(err)) + default: + return nil, empty, fetchErr } - - // the remote was reachable but either its contents was completely empty - // or our default branch doesn't exist and so we decide to seed it - empty = true - - logger.Debug("initial fetch empty", zap.String("reference", r.defaultBranch), zap.Error(err)) } } @@ -296,6 +311,17 @@ func (r *Repository) fetchHeads() []string { return slices.Collect(maps.Keys(heads)) } +// IsConnectionRefused checks if the provided error is a connection refused error. +func (r *Repository) IsConnectionRefused(err error) bool { + return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.EHOSTUNREACH) || + errors.Is(err, syscall.ENETUNREACH) || errors.Is(err, syscall.EHOSTDOWN) +} + +// HasLenientFetchPolicy returns true if the fetch policy set to lenient. +func (r *Repository) HasLenientFetchPolicy() bool { + return r.lenientFetchPolicyEnabled +} + // Fetch does a fetch for the requested head names on a configured remote. // If the remote is not defined, then it is a silent noop. // Iff specific is explicitly requested then only the heads in specific are fetched. @@ -830,6 +856,13 @@ func WithMaxOpenDescriptors(n int) containers.Option[Repository] { } } +// WithLenientFetchPolicy sets the lenient fetch policy which allows skip startup git fetch. +func WithLenientFetchPolicy() containers.Option[Repository] { + return func(r *Repository) { + r.lenientFetchPolicyEnabled = true + } +} + const defaultReadmeContents = `Flipt Configuration Repository ============================== diff --git a/internal/storage/git/repository_test.go b/internal/storage/git/repository_test.go index 4013ec5e8f..8aa0e8620f 100644 --- a/internal/storage/git/repository_test.go +++ b/internal/storage/git/repository_test.go @@ -1,9 +1,10 @@ package git import ( - "context" + "net" "os" "path/filepath" + "syscall" "testing" "time" @@ -19,7 +20,7 @@ func TestNewRepository_EmptyDirectory(t *testing.T) { tempDir := t.TempDir() logger := zap.NewNop() - repo, empty, err := newRepository(context.Background(), logger, WithFilesystemStorage(tempDir)) + repo, empty, err := newRepository(t.Context(), logger, WithFilesystemStorage(tempDir)) require.NoError(t, err) require.NotNil(t, repo) assert.True(t, empty, "empty directory should be marked as empty") @@ -31,7 +32,7 @@ func TestNewRepository_NonExistentDirectory(t *testing.T) { nonExistentDir := filepath.Join(tempDir, "nonexistent") logger := zap.NewNop() - repo, empty, err := newRepository(context.Background(), logger, WithFilesystemStorage(nonExistentDir)) + repo, empty, err := newRepository(t.Context(), logger, WithFilesystemStorage(nonExistentDir)) require.NoError(t, err) require.NotNil(t, repo) assert.True(t, empty, "non-existent directory should be marked as empty") @@ -43,7 +44,7 @@ func TestNewRepository_DirectoryWithFiles_NoGitRepo(t *testing.T) { logger := zap.NewNop() // Create some content files - require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "production"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "production"), 0o755)) featuresFile := filepath.Join(tempDir, "production", "features.yaml") content := `namespace: key: production @@ -54,9 +55,9 @@ flags: type: VARIANT_FLAG_TYPE description: A test flag enabled: true` - require.NoError(t, os.WriteFile(featuresFile, []byte(content), 0600)) + require.NoError(t, os.WriteFile(featuresFile, []byte(content), 0o600)) - repo, empty, err := newRepository(context.Background(), logger, WithFilesystemStorage(tempDir)) + repo, empty, err := newRepository(t.Context(), logger, WithFilesystemStorage(tempDir)) require.NoError(t, err) require.NotNil(t, repo) assert.True(t, empty, "directory with files but no git repo should be marked as empty for initial commit") @@ -72,7 +73,7 @@ func TestNewRepository_NormalGitRepository(t *testing.T) { require.NoError(t, err) // Create and commit some content - require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "production"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "production"), 0o755)) featuresFile := filepath.Join(tempDir, "production", "features.yaml") content := `namespace: key: production @@ -83,7 +84,7 @@ flags: type: VARIANT_FLAG_TYPE description: A test flag enabled: true` - require.NoError(t, os.WriteFile(featuresFile, []byte(content), 0600)) + require.NoError(t, os.WriteFile(featuresFile, []byte(content), 0o600)) // Commit the files plainRepo, err := git.PlainOpen(tempDir) @@ -102,7 +103,7 @@ flags: require.NoError(t, err) // Test opening with Flipt - repo, empty, err := newRepository(context.Background(), logger, WithFilesystemStorage(tempDir)) + repo, empty, err := newRepository(t.Context(), logger, WithFilesystemStorage(tempDir)) require.NoError(t, err) require.NotNil(t, repo) assert.False(t, empty, "normal git repo with commits should not be marked as empty") @@ -122,7 +123,7 @@ func TestNewRepository_NormalGitRepository_NoCommits(t *testing.T) { _, err := git.PlainInit(tempDir, false) require.NoError(t, err) - repo, empty, err := newRepository(context.Background(), logger, WithFilesystemStorage(tempDir)) + repo, empty, err := newRepository(t.Context(), logger, WithFilesystemStorage(tempDir)) require.NoError(t, err) require.NotNil(t, repo) assert.True(t, empty, "normal git repo without commits should be marked as empty") @@ -134,14 +135,14 @@ func TestNewRepository_BareGitRepository(t *testing.T) { logger := zap.NewNop() // Create a bare Git repository by initializing with custom storage - repo1, empty1, err := newRepository(context.Background(), logger, WithFilesystemStorage(tempDir)) + repo1, empty1, err := newRepository(t.Context(), logger, WithFilesystemStorage(tempDir)) require.NoError(t, err) require.NotNil(t, repo1) assert.True(t, empty1, "initial bare repository should be empty") assert.False(t, repo1.isNormalRepo, "should be bare repository") // Try to reopen the empty bare repository (no files added yet) - repo2, empty2, err := newRepository(context.Background(), logger, WithFilesystemStorage(tempDir)) + repo2, empty2, err := newRepository(t.Context(), logger, WithFilesystemStorage(tempDir)) require.NoError(t, err) require.NotNil(t, repo2) // The empty status may vary when reopening existing repositories depending on internal Git state @@ -155,14 +156,14 @@ func TestNewRepository_FilesWithNonGitBareRepository(t *testing.T) { logger := zap.NewNop() // Create a bare repository first - repo1, empty1, err := newRepository(context.Background(), logger, WithFilesystemStorage(tempDir)) + repo1, empty1, err := newRepository(t.Context(), logger, WithFilesystemStorage(tempDir)) require.NoError(t, err) require.NotNil(t, repo1) assert.True(t, empty1, "initial bare repository should be empty") assert.False(t, repo1.isNormalRepo, "should be bare repository") // Now add some files to the temp directory (simulating files in storage path) - require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "production"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "production"), 0o755)) featuresFile := filepath.Join(tempDir, "production", "features.yaml") content := `namespace: key: production @@ -171,10 +172,10 @@ flags: - key: test-flag name: Test Flag type: VARIANT_FLAG_TYPE` - require.NoError(t, os.WriteFile(featuresFile, []byte(content), 0600)) + require.NoError(t, os.WriteFile(featuresFile, []byte(content), 0o600)) // Try to reopen - should detect existing bare repository instead of treating as normal repo - repo2, empty2, err := newRepository(context.Background(), logger, WithFilesystemStorage(tempDir)) + repo2, empty2, err := newRepository(t.Context(), logger, WithFilesystemStorage(tempDir)) require.NoError(t, err) require.NotNil(t, repo2) // The behavior here depends on whether the bare repository has been initialized with objects @@ -192,7 +193,7 @@ func TestUpdateWorkingDirectory(t *testing.T) { require.NoError(t, err) // Create initial content and commit - require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "production"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "production"), 0o755)) featuresFile := filepath.Join(tempDir, "production", "features.yaml") initialContent := `namespace: key: production @@ -201,7 +202,7 @@ flags: - key: test-flag name: Test Flag enabled: false` - require.NoError(t, os.WriteFile(featuresFile, []byte(initialContent), 0600)) + require.NoError(t, os.WriteFile(featuresFile, []byte(initialContent), 0o600)) worktree, err := plainRepo.Worktree() require.NoError(t, err) @@ -224,7 +225,7 @@ flags: - key: test-flag name: Test Flag enabled: true` - require.NoError(t, os.WriteFile(featuresFile, []byte(updatedContent), 0600)) + require.NoError(t, os.WriteFile(featuresFile, []byte(updatedContent), 0o600)) _, err = worktree.Add("production/features.yaml") require.NoError(t, err) commit2, err := worktree.Commit("Update flag", &git.CommitOptions{ @@ -237,12 +238,12 @@ flags: require.NoError(t, err) // Create Flipt repository instance - repo, _, err := newRepository(context.Background(), logger, WithFilesystemStorage(tempDir)) + repo, _, err := newRepository(t.Context(), logger, WithFilesystemStorage(tempDir)) require.NoError(t, err) require.True(t, repo.isNormalRepo, "should be normal repository") // Test updating working directory to first commit - err = repo.updateWorkingDirectory(context.Background(), commit1) + err = repo.updateWorkingDirectory(t.Context(), commit1) require.NoError(t, err) // Verify file content matches first commit @@ -251,7 +252,7 @@ flags: assert.Contains(t, string(content), "enabled: false", "working directory should show first commit content") // Test updating working directory to second commit - err = repo.updateWorkingDirectory(context.Background(), commit2) + err = repo.updateWorkingDirectory(t.Context(), commit2) require.NoError(t, err) // Verify file content matches second commit @@ -265,12 +266,12 @@ func TestUpdateWorkingDirectory_BareRepository(t *testing.T) { logger := zap.NewNop() // Create a bare repository - repo, _, err := newRepository(context.Background(), logger, WithFilesystemStorage(tempDir)) + repo, _, err := newRepository(t.Context(), logger, WithFilesystemStorage(tempDir)) require.NoError(t, err) require.False(t, repo.isNormalRepo, "should be bare repository") // updateWorkingDirectory should be a no-op for bare repositories - err = repo.updateWorkingDirectory(context.Background(), plumbing.ZeroHash) + err = repo.updateWorkingDirectory(t.Context(), plumbing.ZeroHash) assert.NoError(t, err, "updateWorkingDirectory should not error on bare repositories") } @@ -278,7 +279,7 @@ func TestRepositoryDefaults(t *testing.T) { tempDir := t.TempDir() logger := zap.NewNop() - repo, _, err := newRepository(context.Background(), logger, WithFilesystemStorage(tempDir)) + repo, _, err := newRepository(t.Context(), logger, WithFilesystemStorage(tempDir)) require.NoError(t, err) require.NotNil(t, repo) @@ -291,7 +292,7 @@ func TestRepositoryWithCustomBranch(t *testing.T) { tempDir := t.TempDir() logger := zap.NewNop() - repo, _, err := newRepository(context.Background(), logger, + repo, _, err := newRepository(t.Context(), logger, WithFilesystemStorage(tempDir), WithDefaultBranch("develop")) require.NoError(t, err) @@ -299,3 +300,182 @@ func TestRepositoryWithCustomBranch(t *testing.T) { assert.Equal(t, "develop", repo.defaultBranch, "should use custom default branch") } + +func TestFetchPolicy_Strict(t *testing.T) { + tempDir := t.TempDir() + logger := zap.NewNop() + + // Create a repository with strict fetch policy (default) + repo, _, err := newRepository(t.Context(), logger, + WithFilesystemStorage(tempDir)) + require.NoError(t, err) + require.NotNil(t, repo) + + assert.False(t, repo.lenientFetchPolicyEnabled, "should set fetch policy to strict") +} + +func TestFetchPolicy_Lenient(t *testing.T) { + tempDir := t.TempDir() + logger := zap.NewNop() + + // Create a repository with lenient fetch policy + repo, _, err := newRepository(t.Context(), logger, + WithFilesystemStorage(tempDir), + WithLenientFetchPolicy(), + ) + require.NoError(t, err) + require.NotNil(t, repo) + + assert.True(t, repo.lenientFetchPolicyEnabled, "should set fetch policy to lenient") +} + +func TestIsConnectionRefused(t *testing.T) { + tempDir := t.TempDir() + logger := zap.NewNop() + + repo, _, err := newRepository(t.Context(), logger, + WithFilesystemStorage(tempDir)) + require.NoError(t, err) + + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "generic error", + err: assert.AnError, + expected: false, + }, + { + name: "connection refused wrapped in net.OpError", + err: &net.OpError{Op: "dial", Net: "tcp", Err: &os.SyscallError{Syscall: "connect", Err: syscall.ECONNREFUSED}}, + expected: true, + }, + { + name: "different syscall error", + err: &os.SyscallError{Syscall: "connect", Err: syscall.ETIMEDOUT}, + expected: false, + }, + { + name: "net.OpError with non-connection-refused error", + err: &net.OpError{Op: "dial", Net: "tcp", Err: &os.SyscallError{Syscall: "connect", Err: syscall.EADDRINUSE}}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := repo.IsConnectionRefused(tt.err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFetch_NoRemote(t *testing.T) { + tempDir := t.TempDir() + logger := zap.NewNop() + + // Create repository without remote + repo, _, err := newRepository(t.Context(), logger, + WithFilesystemStorage(tempDir)) + require.NoError(t, err) + + // Fetch should be a no-op when no remote is configured + err = repo.Fetch(t.Context()) + assert.NoError(t, err, "fetch without remote should succeed silently") +} + +func TestFetch_StrictPolicy_WithConnectionRefused(t *testing.T) { + tempDir := t.TempDir() + logger := zap.NewNop() + + // Create repository with strict fetch policy and invalid remote + _, _, err := newRepository(t.Context(), logger, + WithFilesystemStorage(tempDir), + WithRemote("origin", "http://localhost:1/invalid-repo.git"), + ) + require.Error(t, err) +} + +func TestFetch_LenientPolicy_WithConnectionRefusedAndCommits(t *testing.T) { + tempDir := t.TempDir() + logger := zap.NewNop() + + plainRepo, err := git.PlainInit(tempDir, false) + require.NoError(t, err) + + // Add a commit to the repository + worktree, err := plainRepo.Worktree() + require.NoError(t, err) + + // Create a file + testFile := filepath.Join(tempDir, "test.txt") + require.NoError(t, os.WriteFile(testFile, []byte("test content"), 0o600)) + + _, err = worktree.Add("test.txt") + require.NoError(t, err) + + _, err = worktree.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + When: time.Now(), + }, + }) + require.NoError(t, err) + + // Now create our Repository wrapper with lenient policy and invalid remote + repo, _, err := newRepository(t.Context(), logger, + WithFilesystemStorage(tempDir), + WithRemote("origin", "http://localhost:1/invalid-repo.git"), + WithLenientFetchPolicy()) + require.NoError(t, err) + assert.True(t, repo.lenientFetchPolicyEnabled) + assert.True(t, repo.HasLenientFetchPolicy()) + + err = repo.Fetch(t.Context()) + assert.Error(t, err) +} + +func TestFetch_LenientPolicy_WithConnectionRefusedAndNoCommits(t *testing.T) { + tempDir := t.TempDir() + logger := zap.NewNop() + + // Create repository with lenient fetch policy and invalid remote, but no commits + repo, _, err := newRepository(t.Context(), logger, + WithFilesystemStorage(tempDir), + WithRemote("origin", "http://localhost:1/invalid-repo.git"), + WithLenientFetchPolicy()) + require.Error(t, err) + assert.Nil(t, repo) +} + +func TestFetch_LenientPolicy_WithNonConnectionError(t *testing.T) { + tempDir := t.TempDir() + logger := zap.NewNop() + + // Create repository with lenient fetch policy but a different type of error (not connection refused) + // Using an invalid URL format to trigger a different error + _, _, err := newRepository(t.Context(), logger, + WithFilesystemStorage(tempDir), + WithRemote("origin", "invalid://bad-url"), + WithLenientFetchPolicy()) + require.Error(t, err) +} + +func TestFetch_DefaultPolicy_BehavesAsStrict(t *testing.T) { + tempDir := t.TempDir() + logger := zap.NewNop() + + // Create repository without specifying policy (should default to strict behavior) + _, _, err := newRepository(t.Context(), logger, + WithFilesystemStorage(tempDir), + WithRemote("origin", "http://localhost:1/invalid-repo.git")) + require.Error(t, err) +}