diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 5de2af8..a6c50a2 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -30,7 +30,7 @@ jobs: cache: false - uses: golangci/golangci-lint-action@v9 with: - version: v2.12.2 + version: v2.13.2 fmt: runs-on: ubuntu-24.04-htzhel1-ax42-a diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f99a95..f32cf48 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: cache: false - uses: golangci/golangci-lint-action@v9 with: - version: v2.12.2 + version: v2.13.2 fmt: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index 7a39186..41db4d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1 — builder -FROM golang:1.26.6-alpine AS builder +FROM golang:1.27-alpine AS builder ARG VERSION=dev ARG COMMIT=unknown diff --git a/go.mod b/go.mod index 7a1b2ee..e9bd7cb 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/obmondo/gfetch -go 1.26.6 +go 1.27 require ( github.com/go-co-op/gocron/v2 v2.22.0 diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 71059b8..bfe556b 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -449,8 +449,7 @@ func TestValidate_NoReposConfigured(t *testing.T) { if err == nil { t.Fatal("expected error for empty config, got nil") } - var partial *PartialValidateError - if errors.As(err, &partial) { + if _, ok := errors.AsType[*PartialValidateError](err); ok { t.Fatalf("expected plain error, got *PartialValidateError: %v", err) } if err.Error() != "no repos configured" { @@ -647,12 +646,10 @@ func TestApplyDefaults_PruneBoolOverride(t *testing.T) { PruneStale: tt.defaultStale, } repo := &RepoConfig{ - Name: testRepoName, - URL: testRepoURL, - RepoDefaults: RepoDefaults{ - Prune: tt.repoPrune, - PruneStale: tt.repoStale, - }, + Name: testRepoName, + URL: testRepoURL, + Prune: tt.repoPrune, + PruneStale: tt.repoStale, } applyDefaults(repo, defaults) @@ -676,3 +673,32 @@ func TestApplyDefaults_PruneBoolOverride(t *testing.T) { }) } } + +func TestParseDuration(t *testing.T) { + tests := []struct { + input string + want time.Duration + wantErr bool + }{ + {"", 0, false}, + {"30s", 30 * time.Second, false}, + {"5m", 5 * time.Minute, false}, + {"2h", 2 * time.Hour, false}, + {"1d", 24 * time.Hour, false}, + {"0.5d", 12 * time.Hour, false}, + {"invalid", 0, true}, + {"10x", 0, true}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := ParseDuration(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseDuration(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if !tt.wantErr && got != tt.want { + t.Errorf("ParseDuration(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} diff --git a/pkg/daemon/scheduler_test.go b/pkg/daemon/scheduler_test.go index d520710..51cfecd 100644 --- a/pkg/daemon/scheduler_test.go +++ b/pkg/daemon/scheduler_test.go @@ -147,13 +147,11 @@ func newTestScheduler(t *testing.T) *Scheduler { func testRepo(name, branch string) config.RepoConfig { return config.RepoConfig{ - RepoDefaults: config.RepoDefaults{ - LocalPath: "/tmp/" + name, - PollInterval: config.Duration(time.Minute), - Branches: []config.Pattern{{Raw: branch}}, - }, - Name: name, - URL: "https://example.com/" + name + ".git", + LocalPath: "/tmp/" + name, + PollInterval: config.Duration(time.Minute), + Branches: []config.Pattern{{Raw: branch}}, + Name: name, + URL: "https://example.com/" + name + ".git", } } diff --git a/pkg/gsync/auth_test.go b/pkg/gsync/auth_test.go index 81ae1d7..69a8a93 100644 --- a/pkg/gsync/auth_test.go +++ b/pkg/gsync/auth_test.go @@ -154,6 +154,8 @@ func TestSSHHostPort(t *testing.T) { {"ssh://git@git.example.com:2223/foo/bar.git", "git.example.com:2223"}, {"ssh://git@github.com/foo/bar.git", "github.com:22"}, {"git@github.com:foo/bar.git", "github.com:22"}, + {"git@192.168.1.10:repo.git", "192.168.1.10:22"}, + {"ssh://git@[2001:db8::1]:2222/repo.git", "[2001:db8::1]:2222"}, } for _, tc := range cases { t.Run(tc.url, func(t *testing.T) { @@ -193,6 +195,12 @@ func TestMergeAlgorithms(t *testing.T) { fallback: []string{"y", "z"}, want: []string{"x", "y", "z"}, }, + { + name: "multiple overlapping hostAlgos and fallback", + hostAlgos: []string{"a", "c"}, + fallback: []string{"a", "b", "c", "d"}, + want: []string{"a", "c", "b", "d"}, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/gsync/openvox_test.go b/pkg/gsync/openvox_test.go index ce3b744..b36798f 100644 --- a/pkg/gsync/openvox_test.go +++ b/pkg/gsync/openvox_test.go @@ -29,9 +29,9 @@ func TestEnsureClonedOpenVox_RecreatesNonRepoDir(t *testing.T) { } repoCfg := &config.RepoConfig{ - RepoDefaults: config.RepoDefaults{LocalPath: localPath}, - Name: DefaultTestName, - URL: "https://example.com/repo.git", + LocalPath: localPath, + Name: DefaultTestName, + URL: "https://example.com/repo.git", } r, err := getRepoWithSharedCache(localPath, filepath.Join(basePath, ".git", "cache.git"), "https://example.com/repo.git", nil) @@ -99,12 +99,10 @@ func TestEnsureProductionAlias(t *testing.T) { openVox := true productionAlias := true repo := &config.RepoConfig{ - RepoDefaults: config.RepoDefaults{ - LocalPath: basePath, - OpenVox: &openVox, - ProductionAlias: &productionAlias, - }, - Name: DefaultTestName, + LocalPath: basePath, + OpenVox: &openVox, + ProductionAlias: &productionAlias, + Name: DefaultTestName, } ensureProductionAlias(context.Background(), repo, testDefaultBranch, map[string]struct{}{testDefaultBranch: {}}) @@ -135,12 +133,10 @@ func TestEnsureProductionAlias_SkipsWhenProductionBranchExists(t *testing.T) { openVox := true productionAlias := true repo := &config.RepoConfig{ - RepoDefaults: config.RepoDefaults{ - LocalPath: basePath, - OpenVox: &openVox, - ProductionAlias: &productionAlias, - }, - Name: DefaultTestName, + LocalPath: basePath, + OpenVox: &openVox, + ProductionAlias: &productionAlias, + Name: DefaultTestName, } ensureProductionAlias(context.Background(), repo, testDefaultBranch, map[string]struct{}{testDefaultBranch: {}, productionAliasName: {}}) @@ -477,8 +473,8 @@ func TestPruneStaleOpenVoxDirs(t *testing.T) { } repo := &config.RepoConfig{ - RepoDefaults: config.RepoDefaults{LocalPath: basePath}, - Name: DefaultTestName, + LocalPath: basePath, + Name: DefaultTestName, } result := &Result{RepoName: DefaultTestName} @@ -528,8 +524,8 @@ func TestPruneStaleOpenVoxDirs_DryRun(t *testing.T) { } repo := &config.RepoConfig{ - RepoDefaults: config.RepoDefaults{LocalPath: basePath}, - Name: DefaultTestName, + LocalPath: basePath, + Name: DefaultTestName, } result := &Result{RepoName: DefaultTestName} @@ -566,8 +562,8 @@ func TestPruneStaleOpenVoxDirs_LeavesLockFileForOrphanCleanup(t *testing.T) { } repo := &config.RepoConfig{ - RepoDefaults: config.RepoDefaults{LocalPath: basePath}, - Name: DefaultTestName, + LocalPath: basePath, + Name: DefaultTestName, } result := &Result{RepoName: DefaultTestName} @@ -592,8 +588,8 @@ func TestPruneStaleOpenVoxDirs_MissingDir(t *testing.T) { } repo := &config.RepoConfig{ - RepoDefaults: config.RepoDefaults{LocalPath: basePath}, - Name: DefaultTestName, + LocalPath: basePath, + Name: DefaultTestName, } result := &Result{RepoName: DefaultTestName} diff --git a/pkg/gsync/syncer.go b/pkg/gsync/syncer.go index 81a37bd..c45c367 100644 --- a/pkg/gsync/syncer.go +++ b/pkg/gsync/syncer.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "sort" + "strings" "sync" "time" @@ -123,12 +124,31 @@ func (s *Syncer) SyncRepo(ctx context.Context, repo *config.RepoConfig, opts Syn refs, err := listRemoteRefs(ctx, r, auth, repo.Name, "standard") if err != nil { + if strings.Contains(err.Error(), "empty") { + log.Info("remote repository is empty; skipping sync and checkout") + duration := time.Since(start) + telemetry.SyncDurationSeconds.WithLabelValues(repo.Name, "total").Observe(duration.Seconds()) + logSyncSuccess(ctx, result, duration) + telemetry.SyncSuccessTotal.WithLabelValues(repo.Name).Inc() + telemetry.LastSuccessTimestamp.WithLabelValues(repo.Name).Set(float64(time.Now().Unix())) + return result + } log.Error("failed to list remote refs", "error", err) telemetry.SyncFailuresTotal.WithLabelValues(repo.Name, "clone").Inc() result.Err = fmt.Errorf("listing remote refs: %w", err) return result } + if len(refs) == 0 { + log.Info("remote repository is empty; skipping sync and checkout") + duration := time.Since(start) + telemetry.SyncDurationSeconds.WithLabelValues(repo.Name, "total").Observe(duration.Seconds()) + logSyncSuccess(ctx, result, duration) + telemetry.SyncSuccessTotal.WithLabelValues(repo.Name).Inc() + telemetry.LastSuccessTimestamp.WithLabelValues(repo.Name).Set(float64(time.Now().Unix())) + return result + } + defaultBranch, _, matchedBranches, matchedTagRefs := extractRemoteRefState(refs, repo.Branches, repo.Tags) matchedTags := make([]string, 0, len(matchedTagRefs)) for _, tagRef := range matchedTagRefs { @@ -408,6 +428,11 @@ func (s *Syncer) handleCheckout(r *git.Repository, repo *config.RepoConfig, defa err := checkoutRef(r, repo.Checkout) if err != nil { + if _, headErr := r.Head(); headErr != nil { + slog.Info("skipping checkout: repository has no commits or HEAD yet", "ref", repo.Checkout) + return + } + if defaultBranch == "" || repo.Checkout == defaultBranch { slog.Error("failed to checkout", "ref", repo.Checkout, "error", err) s.setErr(result, fmt.Errorf("checkout %s: %w", repo.Checkout, err)) diff --git a/pkg/gsync/syncer_test.go b/pkg/gsync/syncer_test.go index a30a717..b5b7d55 100644 --- a/pkg/gsync/syncer_test.go +++ b/pkg/gsync/syncer_test.go @@ -262,12 +262,10 @@ func TestSyncHTTPS_Example(t *testing.T) { syncer := New() localDir := t.TempDir() repoConfig := &config.RepoConfig{ - RepoDefaults: config.RepoDefaults{ - LocalPath: localDir, - Branches: []config.Pattern{{Raw: testDefaultBranch}}, - }, - Name: "linuxaid-config-template", - URL: "https://github.com/Obmondo/linuxaid-config-template.git", + LocalPath: localDir, + Branches: []config.Pattern{{Raw: testDefaultBranch}}, + Name: "linuxaid-config-template", + URL: "https://github.com/Obmondo/linuxaid-config-template.git", } result := syncer.SyncRepo(context.Background(), repoConfig, SyncOptions{}) @@ -370,15 +368,13 @@ func TestPruneStaleBranches(t *testing.T) { syncer := New() pruneStaleTrue := true repoConfig := &config.RepoConfig{ - RepoDefaults: config.RepoDefaults{ - LocalPath: localDir, - SSHKeyPath: sshKey, - Branches: []config.Pattern{{Raw: "*"}}, - PruneStale: &pruneStaleTrue, - StaleAge: config.Duration(180 * 24 * time.Hour), - }, - Name: DefaultTestName, - URL: bareDir, + LocalPath: localDir, + SSHKeyPath: sshKey, + Branches: []config.Pattern{{Raw: "*"}}, + PruneStale: &pruneStaleTrue, + StaleAge: config.Duration(180 * 24 * time.Hour), + Name: DefaultTestName, + URL: bareDir, } // First verify it's there. @@ -558,14 +554,12 @@ func TestPruneFalseOverridesDefault(t *testing.T) { pruneFalse := false repoConfig := &config.RepoConfig{ - RepoDefaults: config.RepoDefaults{ - LocalPath: localDir, - SSHKeyPath: sshKey, - Branches: []config.Pattern{{Raw: "master"}}, // extra-branch does not match - Prune: &pruneFalse, - }, - Name: DefaultTestName, - URL: bareDir, + LocalPath: localDir, + SSHKeyPath: sshKey, + Branches: []config.Pattern{{Raw: "master"}}, // extra-branch does not match + Prune: &pruneFalse, + Name: DefaultTestName, + URL: bareDir, } // Verify extra-branch exists before sync. @@ -603,14 +597,12 @@ func TestPruneTrueFromConfigIsApplied(t *testing.T) { pruneTrue := true repoConfig := &config.RepoConfig{ - RepoDefaults: config.RepoDefaults{ - LocalPath: localDir, - SSHKeyPath: sshKey, - Branches: []config.Pattern{{Raw: "master"}}, // extra-branch does not match - Prune: &pruneTrue, - }, - Name: DefaultTestName, - URL: bareDir, + LocalPath: localDir, + SSHKeyPath: sshKey, + Branches: []config.Pattern{{Raw: "master"}}, // extra-branch does not match + Prune: &pruneTrue, + Name: DefaultTestName, + URL: bareDir, } // Verify extra-branch exists before sync. @@ -713,3 +705,128 @@ func TestHandleCheckout_FallbackToDefault(t *testing.T) { } }) } + +func TestSyncRepo_EmptyRemote(t *testing.T) { + tmp := t.TempDir() + bareDir := filepath.Join(tmp, "bare.git") + localDir := filepath.Join(tmp, "local") + + if _, err := git.PlainInit(bareDir, true); err != nil { + t.Fatal(err) + } + + repoCfg := &config.RepoConfig{ + LocalPath: localDir, + PollInterval: config.Duration(10 * time.Second), + Branches: []config.Pattern{{Raw: "main"}}, + Name: "empty-repo", + URL: bareDir, + Checkout: "main", + } + + s := New() + result := s.SyncRepo(context.Background(), repoCfg, SyncOptions{}) + + if result.Err != nil { + t.Fatalf("expected successful sync on empty remote, got error: %v", result.Err) + } + if result.RepoName != "empty-repo" { + t.Fatalf("expected RepoName to be 'empty-repo', got %s", result.RepoName) + } +} + +func TestHandleCheckout_Table(t *testing.T) { + tmp := t.TempDir() + bareDir := filepath.Join(tmp, "bare.git") + localDir := filepath.Join(tmp, "local") + + if _, err := git.PlainInit(bareDir, true); err != nil { + t.Fatal(err) + } + + r, err := git.PlainClone(localDir, false, &git.CloneOptions{URL: bareDir}) + if err != nil { + wt, initErr := git.PlainInit(localDir, false) + if initErr != nil { + t.Fatal(initErr) + } + r = wt + } + + sig := &object.Signature{Name: DefaultTestName, Email: DefaultTestEmail, When: time.Now()} + wt, err := r.Worktree() + if err != nil { + t.Fatal(err) + } + testFile := filepath.Join(localDir, "README.md") + if err := os.WriteFile(testFile, []byte("# Test"), 0644); err != nil { + t.Fatal(err) + } + if _, err := wt.Add("README.md"); err != nil { + t.Fatal(err) + } + _, err = wt.Commit("init", &git.CommitOptions{Author: sig, Committer: sig}) + if err != nil { + t.Fatal(err) + } + + head, err := r.Head() + if err != nil { + t.Fatal(err) + } + defBranch := head.Name().Short() + + s := New() + + tests := []struct { + name string + checkout string + defaultBranch string + wantErr bool + wantCheckout string + }{ + { + name: "empty checkout does nothing", + checkout: "", + defaultBranch: defBranch, + wantErr: false, + wantCheckout: "", + }, + { + name: "valid checkout", + checkout: defBranch, + defaultBranch: defBranch, + wantErr: false, + wantCheckout: defBranch, + }, + { + name: "missing checkout with valid fallback", + checkout: "missing", + defaultBranch: defBranch, + wantErr: false, + wantCheckout: defBranch, + }, + { + name: "missing checkout with empty fallback", + checkout: "missing", + defaultBranch: "", + wantErr: true, + wantCheckout: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repoCfg := &config.RepoConfig{Checkout: tt.checkout} + result := &Result{} + s.handleCheckout(r, repoCfg, tt.defaultBranch, result) + + if (result.Err != nil) != tt.wantErr { + t.Errorf("handleCheckout() error = %v, wantErr %v", result.Err, tt.wantErr) + } + if !tt.wantErr && result.Checkout != tt.wantCheckout { + t.Errorf("handleCheckout() checkout = %q, want %q", result.Checkout, tt.wantCheckout) + } + }) + } +}