diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-28 09:12:03 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-28 09:12:03 +0200 |
| commit | 1615abaacccdbb5002404a77270fd333ce8ad718 (patch) | |
| tree | c84cde36805a8717d21504cf954cffc72a2f1181 | |
| parent | a694dd751eb16d47b0956facd5594ef575c9dce4 (diff) | |
feat(showcase): support per-repo stats branchesv0.16.0
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | doc/api-reference.md | 9 | ||||
| -rw-r--r-- | doc/configuration.md | 22 | ||||
| -rw-r--r-- | internal/config/config.go | 20 | ||||
| -rw-r--r-- | internal/config/config_test.go | 27 | ||||
| -rw-r--r-- | internal/showcase/metadata.go | 17 | ||||
| -rw-r--r-- | internal/showcase/metadata_test.go | 63 | ||||
| -rw-r--r-- | internal/showcase/showcase.go | 102 | ||||
| -rw-r--r-- | internal/showcase/showcase_test.go | 125 | ||||
| -rw-r--r-- | internal/version/version.go | 2 |
10 files changed, 370 insertions, 24 deletions
@@ -62,7 +62,10 @@ Create a configuration file at `~/.config/gitsyncer/config.json` (or specify a c "repositories": [ "repo1", "repo2" - ] + ], + "showcase_stats_branches": { + "foo.zone": "content-gemtext" + } } ``` @@ -449,6 +452,8 @@ Weekly rank snapshots are written on full showcase runs (all repositories), incl The showcase output is written to `~/git/foo.zone-content/gemtext/about/showcase.gmi.tpl` by default (currently hardcoded). +You can override the branch used for showcase stats and cached code snippets on a per-repository basis with `showcase_stats_branches`. For example, `foo.zone` can use `content-gemtext` while the rest of the repos continue to use their current checkout branch. + Projects can be excluded from the showcase by creating a `.nosync` file in their repository root. ## Example Workflows diff --git a/doc/api-reference.md b/doc/api-reference.md index cc4f69c..c189e99 100644 --- a/doc/api-reference.md +++ b/doc/api-reference.md @@ -205,9 +205,10 @@ type Organization struct { #### type Config ```go type Config struct { - Organizations []Organization `json:"organizations"` // List of git organizations - Repositories []string `json:"repositories"` // Specific repos to sync - ExcludeBranches []string `json:"exclude_branches"` // Regex patterns for branch exclusion + Organizations []Organization `json:"organizations"` // List of git organizations + Repositories []string `json:"repositories"` // Specific repos to sync + ExcludeBranches []string `json:"exclude_branches"` // Regex patterns for branch exclusion + ShowcaseStatsBranches map[string]string `json:"showcase_stats_branches"` // Per-repo branch overrides for showcase stats/code snippets } ``` @@ -511,4 +512,4 @@ gitsyncer version 0.1.0 ``` #### func GetShortVersion() string -Returns just the version number: `0.1.0`
\ No newline at end of file +Returns just the version number: `0.1.0` diff --git a/doc/configuration.md b/doc/configuration.md index 25a425f..230cac6 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -37,7 +37,10 @@ GitSyncer looks for configuration files in the following order: "exclude_branches": [ "^temp-", "-wip$" - ] + ], + "showcase_stats_branches": { + "foo.zone": "content-gemtext" + } } ``` @@ -78,6 +81,18 @@ Example: } ``` +#### showcase_stats_branches (optional) +Map of repository names to the branch that should be used when generating showcase statistics and cached code snippets. This is useful when the primary content for a repo lives on a non-default branch. + +Example: +```json +{ + "showcase_stats_branches": { + "foo.zone": "content-gemtext" + } +} +``` + ## Examples ### Minimal Configuration @@ -123,7 +138,10 @@ Sync between GitHub and Codeberg: "^temp-", "-wip$", "^old-" - ] + ], + "showcase_stats_branches": { + "foo.zone": "content-gemtext" + } } ``` diff --git a/internal/config/config.go b/internal/config/config.go index 48e6d5f..4e40cdf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,11 +19,12 @@ type Organization struct { // Config holds the application configuration type Config struct { - Organizations []Organization `json:"organizations"` - Repositories []string `json:"repositories,omitempty"` - ExcludeBranches []string `json:"exclude_branches,omitempty"` // Regex patterns for branches to exclude - WorkDir string `json:"work_dir,omitempty"` // Working directory for cloning repositories - ExcludeFromShowcase []string `json:"exclude_from_showcase,omitempty"` // Repository names to exclude from showcase + Organizations []Organization `json:"organizations"` + Repositories []string `json:"repositories,omitempty"` + ExcludeBranches []string `json:"exclude_branches,omitempty"` // Regex patterns for branches to exclude + WorkDir string `json:"work_dir,omitempty"` // Working directory for cloning repositories + ExcludeFromShowcase []string `json:"exclude_from_showcase,omitempty"` // Repository names to exclude from showcase + ShowcaseStatsBranches map[string]string `json:"showcase_stats_branches,omitempty"` // Repository names mapped to the branch used for showcase stats/code snippets // SkipReleases maps a repository name to a list of tag names for which // releases should NOT be created on any platform (GitHub/Codeberg) SkipReleases map[string][]string `json:"skip_releases,omitempty"` @@ -102,6 +103,15 @@ func (c *Config) Validate() error { } } + for repo, branch := range c.ShowcaseStatsBranches { + if strings.TrimSpace(repo) == "" { + return fmt.Errorf("showcase_stats_branches: repository name cannot be empty") + } + if strings.TrimSpace(branch) == "" { + return fmt.Errorf("showcase_stats_branches[%q]: branch name cannot be empty", repo) + } + } + return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..db70457 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,27 @@ +package config + +import ( + "strings" + "testing" +) + +func TestValidate_ShowcaseStatsBranchesRejectsEmptyBranch(t *testing.T) { + t.Parallel() + + cfg := &Config{ + Organizations: []Organization{ + {Host: "git@github.com", Name: "test-user"}, + }, + ShowcaseStatsBranches: map[string]string{ + "foo.zone": " ", + }, + } + + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() error = nil, want branch validation error") + } + if !strings.Contains(err.Error(), "showcase_stats_branches") { + t.Fatalf("Validate() error = %q, want showcase_stats_branches context", err) + } +} diff --git a/internal/showcase/metadata.go b/internal/showcase/metadata.go index 798b8fe..038dc08 100644 --- a/internal/showcase/metadata.go +++ b/internal/showcase/metadata.go @@ -131,9 +131,9 @@ func calculateRepoScore(linesOfCode int, avgCommitAge float64, tagCount int, has return score } -// getCommitCount returns the total number of commits +// getCommitCount returns the total number of commits reachable from the current HEAD. func getCommitCount(repoPath string) (int, error) { - cmd := exec.Command("git", "-C", repoPath, "rev-list", "--all", "--count") + cmd := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD") output, err := cmd.Output() if err != nil { return 0, err @@ -179,7 +179,7 @@ func countLinesOfCode(repoPath string) (int, error) { // getFirstCommitDate returns the date of the first commit func getFirstCommitDate(repoPath string) (string, error) { - cmd := exec.Command("git", "-C", repoPath, "log", "--reverse", "--pretty=format:%ai", "--date=short") + cmd := exec.Command("git", "-C", repoPath, "log", "--reverse", "--pretty=format:%ai", "--date=short", "HEAD") output, err := cmd.Output() if err != nil { return "", err @@ -199,7 +199,7 @@ func getFirstCommitDate(repoPath string) (string, error) { // getLastCommitDate returns the date of the last commit func getLastCommitDate(repoPath string) (string, error) { - cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--pretty=format:%ai", "--date=short") + cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--pretty=format:%ai", "--date=short", "HEAD") output, err := cmd.Output() if err != nil { return "", err @@ -273,7 +273,7 @@ func detectLicense(repoPath string) string { // getAverageCommitAge calculates the average age of the last N commits in days func getAverageCommitAge(repoPath string, commitCount int) (float64, error) { // Get the last N commit dates - cmd := exec.Command("git", "-C", repoPath, "log", fmt.Sprintf("-%d", commitCount), "--pretty=format:%at") + cmd := exec.Command("git", "-C", repoPath, "log", fmt.Sprintf("-%d", commitCount), "--pretty=format:%at", "HEAD") output, err := cmd.Output() if err != nil { return 0, err @@ -311,14 +311,15 @@ func getAverageCommitAge(repoPath string, commitCount int) (float64, error) { return totalAge / float64(validCommits), nil } -// getLatestTag returns the latest version-like tag, its date, whether the repo has releases, and total tag count. +// getLatestTag returns the latest version-like tag merged into HEAD, its date, +// whether the repo has releases, and total merged tag count. func getLatestTag(repoPath string) (string, string, bool, int, error) { // First try to get tags sorted by version - cmd := exec.Command("git", "-C", repoPath, "tag", "-l", "--sort=-version:refname") + cmd := exec.Command("git", "-C", repoPath, "tag", "-l", "--merged", "HEAD", "--sort=-version:refname") output, err := cmd.Output() if err != nil { // Fallback to describe - cmd = exec.Command("git", "-C", repoPath, "describe", "--tags", "--abbrev=0") + cmd = exec.Command("git", "-C", repoPath, "describe", "--tags", "--abbrev=0", "HEAD") output, err = cmd.Output() if err != nil { // No tags at all diff --git a/internal/showcase/metadata_test.go b/internal/showcase/metadata_test.go index e8777ce..abc4664 100644 --- a/internal/showcase/metadata_test.go +++ b/internal/showcase/metadata_test.go @@ -79,6 +79,69 @@ func TestGetLatestTag_ReturnsTotalTagCount(t *testing.T) { } } +func TestExtractRepoMetadata_UsesCurrentBranchState(t *testing.T) { + t.Parallel() + + repoPath := t.TempDir() + runGit(t, repoPath, "init", "--initial-branch=main") + runGit(t, repoPath, "config", "user.name", "Test User") + runGit(t, repoPath, "config", "user.email", "test@example.com") + + writeAndCommit := func(name, content, message string) { + path := filepath.Join(repoPath, name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("write file %s: %v", name, err) + } + runGit(t, repoPath, "add", name) + runGit(t, repoPath, "commit", "-m", message) + } + + writeAndCommit("main.go", "package main\n\nfunc main() {\n}\n", "main branch") + runGit(t, repoPath, "tag", "v1.0.0") + + runGit(t, repoPath, "checkout", "-b", "content-gemtext") + writeAndCommit("content.go", "package main\n\nfunc render() string {\n\treturn \"gemtext\"\n}\n", "content branch") + runGit(t, repoPath, "tag", "v2.0.0") + + runGit(t, repoPath, "checkout", "main") + + mainMetadata, err := extractRepoMetadata(repoPath) + if err != nil { + t.Fatalf("extractRepoMetadata(main) error = %v", err) + } + if mainMetadata.CommitCount != 1 { + t.Fatalf("main branch CommitCount = %d, want %d", mainMetadata.CommitCount, 1) + } + if mainMetadata.LinesOfCode != 4 { + t.Fatalf("main branch LinesOfCode = %d, want %d", mainMetadata.LinesOfCode, 4) + } + if mainMetadata.LatestTag != "v1.0.0" { + t.Fatalf("main branch LatestTag = %q, want %q", mainMetadata.LatestTag, "v1.0.0") + } + if mainMetadata.TagCount != 1 { + t.Fatalf("main branch TagCount = %d, want %d", mainMetadata.TagCount, 1) + } + + runGit(t, repoPath, "checkout", "content-gemtext") + + contentMetadata, err := extractRepoMetadata(repoPath) + if err != nil { + t.Fatalf("extractRepoMetadata(content-gemtext) error = %v", err) + } + if contentMetadata.CommitCount != 2 { + t.Fatalf("content-gemtext CommitCount = %d, want %d", contentMetadata.CommitCount, 2) + } + if contentMetadata.LinesOfCode != 9 { + t.Fatalf("content-gemtext LinesOfCode = %d, want %d", contentMetadata.LinesOfCode, 9) + } + if contentMetadata.LatestTag != "v2.0.0" { + t.Fatalf("content-gemtext LatestTag = %q, want %q", contentMetadata.LatestTag, "v2.0.0") + } + if contentMetadata.TagCount != 2 { + t.Fatalf("content-gemtext TagCount = %d, want %d", contentMetadata.TagCount, 2) + } +} + func runGit(t *testing.T, repoPath string, args ...string) string { t.Helper() diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go index 25f28f9..9cf43a6 100644 --- a/internal/showcase/showcase.go +++ b/internal/showcase/showcase.go @@ -174,7 +174,11 @@ func (g *Generator) GenerateShowcase(repoFilter []string, forceRegenerate bool) // runCommandWithTimeout runs a command with a short timeout and returns trimmed stdout. // Stderr is included in the error message for easier debugging when GITSYNCER_DEBUG=1. func runCommandWithTimeout(name string, args ...string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + return runCommandWithCustomTimeout(8*time.Second, name, args...) +} + +func runCommandWithCustomTimeout(timeout time.Duration, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, name, args...) out, err := cmd.CombinedOutput() @@ -680,6 +684,88 @@ func (g *Generator) buildProjectLinks(repoName string) (string, string) { return codebergURL, githubURL } +func (g *Generator) prepareStatsRepoPath(repoName, repoPath string) (string, func() error, error) { + if g.config == nil { + return repoPath, func() error { return nil }, nil + } + + branch := strings.TrimSpace(g.config.ShowcaseStatsBranches[repoName]) + if branch == "" { + return repoPath, func() error { return nil }, nil + } + + resolvedRef, err := resolveShowcaseStatsRef(repoPath, branch) + if err != nil { + return "", nil, fmt.Errorf("failed to resolve showcase stats branch for %s: %w", repoName, err) + } + + tempPrefix := strings.ReplaceAll(repoName, string(os.PathSeparator), "-") + tempRoot, err := os.MkdirTemp("", "gitsyncer-showcase-"+tempPrefix+"-") + if err != nil { + return "", nil, fmt.Errorf("failed to create temporary worktree root for %s: %w", repoName, err) + } + + worktreePath := filepath.Join(tempRoot, "repo") + if _, err := runCommandWithCustomTimeout(45*time.Second, "git", "-C", repoPath, "worktree", "add", "--detach", worktreePath, resolvedRef); err != nil { + _ = os.RemoveAll(tempRoot) + return "", nil, fmt.Errorf("failed to create showcase stats worktree for %s on branch %q: %w", repoName, branch, err) + } + + cleanup := func() error { + defer os.RemoveAll(tempRoot) + + if _, err := runCommandWithCustomTimeout(45*time.Second, "git", "-C", repoPath, "worktree", "remove", "--force", worktreePath); err != nil { + return fmt.Errorf("failed to remove temporary worktree for %s: %w", repoName, err) + } + + return nil + } + + if resolvedRef == branch { + fmt.Printf("Using showcase stats branch %q for %s\n", branch, repoName) + } else { + fmt.Printf("Using showcase stats branch %q for %s (resolved to %s)\n", branch, repoName, resolvedRef) + } + + return worktreePath, cleanup, nil +} + +func resolveShowcaseStatsRef(repoPath, branch string) (string, error) { + localRef := "refs/heads/" + branch + if _, err := runCommandWithTimeout("git", "-C", repoPath, "show-ref", "--verify", "--quiet", localRef); err == nil { + return branch, nil + } + + output, err := runCommandWithTimeout("git", "-C", repoPath, "for-each-ref", "--format=%(refname)", "refs/remotes") + if err != nil { + return "", fmt.Errorf("failed to inspect remote refs for branch %q: %w", branch, err) + } + + var candidates []string + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + ref := strings.TrimSpace(line) + if ref == "" || strings.HasSuffix(ref, "/HEAD") { + continue + } + if strings.HasSuffix(ref, "/"+branch) { + candidates = append(candidates, ref) + } + } + + if len(candidates) == 0 { + return "", fmt.Errorf("branch %q not found locally or on any remote", branch) + } + + sort.Strings(candidates) + for _, ref := range candidates { + if strings.HasPrefix(ref, "refs/remotes/origin/") { + return ref, nil + } + } + + return candidates[0], nil +} + // generateProjectSummary generates a summary for a single project func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool) (*ProjectSummary, error) { repoPath := filepath.Join(g.workDir, repoName) @@ -708,9 +794,19 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool readmeContent, readmeFile, readmeFound := findReadmeContent(repoPath) + statsRepoPath, cleanupStatsRepoPath, err := g.prepareStatsRepoPath(repoName, repoPath) + if err != nil { + return nil, err + } + defer func() { + if err := cleanupStatsRepoPath(); err != nil { + fmt.Printf("Warning: %v\n", err) + } + }() + // Always extract metadata (not cached) fmt.Printf("Extracting repository metadata...\n") - metadata, err := extractRepoMetadata(repoPath) + metadata, err := extractRepoMetadata(statsRepoPath) if err != nil { fmt.Printf("Warning: Failed to extract some metadata: %v\n", err) // Continue anyway with partial metadata @@ -755,7 +851,7 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool // Extract code snippet for all projects var codeSnippet, codeLanguage string if metadata != nil && len(metadata.Languages) > 0 { - snippet, lang, err := extractCodeSnippet(repoPath, metadata.Languages) + snippet, lang, err := extractCodeSnippet(statsRepoPath, metadata.Languages) if err != nil { fmt.Printf("Warning: Failed to extract code snippet: %v\n", err) } else { diff --git a/internal/showcase/showcase_test.go b/internal/showcase/showcase_test.go index 8bcad88..fa18799 100644 --- a/internal/showcase/showcase_test.go +++ b/internal/showcase/showcase_test.go @@ -2,6 +2,7 @@ package showcase import ( "os" + "os/exec" "path/filepath" "reflect" "strings" @@ -229,3 +230,127 @@ func TestExtractUsefulSummary_SkipsFencedCodeBlocks(t *testing.T) { t.Fatalf("extractUsefulSummary() = %q, want %q", got, want) } } + +func TestPrepareStatsRepoPath_UsesConfiguredBranchWithoutChangingMainCheckout(t *testing.T) { + t.Parallel() + + repoPath := t.TempDir() + runGit(t, repoPath, "init", "--initial-branch=main") + runGit(t, repoPath, "config", "user.name", "Test User") + runGit(t, repoPath, "config", "user.email", "test@example.com") + + mainFile := filepath.Join(repoPath, "README.md") + if err := os.WriteFile(mainFile, []byte("main branch"), 0644); err != nil { + t.Fatalf("write README.md: %v", err) + } + runGit(t, repoPath, "add", "README.md") + runGit(t, repoPath, "commit", "-m", "main") + + runGit(t, repoPath, "checkout", "-b", "content-gemtext") + branchOnlyFile := filepath.Join(repoPath, "branch-only.txt") + if err := os.WriteFile(branchOnlyFile, []byte("content branch"), 0644); err != nil { + t.Fatalf("write branch-only.txt: %v", err) + } + runGit(t, repoPath, "add", "branch-only.txt") + runGit(t, repoPath, "commit", "-m", "content branch") + runGit(t, repoPath, "checkout", "main") + + g := &Generator{ + config: &config.Config{ + ShowcaseStatsBranches: map[string]string{ + "foo.zone": "content-gemtext", + }, + }, + } + + statsRepoPath, cleanup, err := g.prepareStatsRepoPath("foo.zone", repoPath) + if err != nil { + t.Fatalf("prepareStatsRepoPath() error = %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Fatalf("cleanup() error = %v", err) + } + }() + + if statsRepoPath == repoPath { + t.Fatal("expected a detached worktree path for configured stats branch") + } + if _, err := os.Stat(filepath.Join(statsRepoPath, "branch-only.txt")); err != nil { + t.Fatalf("expected branch-only file in detached worktree: %v", err) + } + if _, err := os.Stat(filepath.Join(repoPath, "branch-only.txt")); !os.IsNotExist(err) { + t.Fatalf("expected branch-only file to stay absent from main checkout, stat err = %v", err) + } + + currentBranch := strings.TrimSpace(runGit(t, repoPath, "branch", "--show-current")) + if currentBranch != "main" { + t.Fatalf("current branch = %q, want %q", currentBranch, "main") + } +} + +func TestPrepareStatsRepoPath_UsesRemoteTrackingBranchWhenLocalBranchMissing(t *testing.T) { + t.Parallel() + + rootDir := t.TempDir() + seedRepoPath := filepath.Join(rootDir, "seed") + runGit(t, rootDir, "init", "--initial-branch=main", seedRepoPath) + runGit(t, seedRepoPath, "config", "user.name", "Test User") + runGit(t, seedRepoPath, "config", "user.email", "test@example.com") + + if err := os.WriteFile(filepath.Join(seedRepoPath, "README.md"), []byte("main branch"), 0644); err != nil { + t.Fatalf("write README.md: %v", err) + } + runGit(t, seedRepoPath, "add", "README.md") + runGit(t, seedRepoPath, "commit", "-m", "main") + + runGit(t, seedRepoPath, "checkout", "-b", "content-gemtext") + if err := os.WriteFile(filepath.Join(seedRepoPath, "branch-only.txt"), []byte("content branch"), 0644); err != nil { + t.Fatalf("write branch-only.txt: %v", err) + } + runGit(t, seedRepoPath, "add", "branch-only.txt") + runGit(t, seedRepoPath, "commit", "-m", "content branch") + runGit(t, seedRepoPath, "checkout", "main") + + remoteRepoPath := filepath.Join(rootDir, "remote.git") + cloneCmd := exec.Command("git", "clone", "--bare", seedRepoPath, remoteRepoPath) + if output, err := cloneCmd.CombinedOutput(); err != nil { + t.Fatalf("git clone --bare failed: %v\n%s", err, string(output)) + } + + cloneRepoPath := filepath.Join(rootDir, "clone") + workingCloneCmd := exec.Command("git", "clone", remoteRepoPath, cloneRepoPath) + if output, err := workingCloneCmd.CombinedOutput(); err != nil { + t.Fatalf("git clone failed: %v\n%s", err, string(output)) + } + + g := &Generator{ + config: &config.Config{ + ShowcaseStatsBranches: map[string]string{ + "foo.zone": "content-gemtext", + }, + }, + } + + statsRepoPath, cleanup, err := g.prepareStatsRepoPath("foo.zone", cloneRepoPath) + if err != nil { + t.Fatalf("prepareStatsRepoPath() error = %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Fatalf("cleanup() error = %v", err) + } + }() + + if _, err := os.Stat(filepath.Join(statsRepoPath, "branch-only.txt")); err != nil { + t.Fatalf("expected branch-only file in detached worktree from remote branch: %v", err) + } + if _, err := os.Stat(filepath.Join(cloneRepoPath, "branch-only.txt")); !os.IsNotExist(err) { + t.Fatalf("expected branch-only file to stay absent from main checkout, stat err = %v", err) + } + + currentBranch := strings.TrimSpace(runGit(t, cloneRepoPath, "branch", "--show-current")) + if currentBranch != "main" { + t.Fatalf("current branch = %q, want %q", currentBranch, "main") + } +} diff --git a/internal/version/version.go b/internal/version/version.go index feb050c..afd3d4e 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( var ( // Version is the current version of gitsyncer - Version = "0.15.8" + Version = "0.16.0" // GitCommit is the git commit hash at build time GitCommit = "unknown" |
