From f76a669e15835c5f0e05081f50a054843919ab36 Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 11 May 2026 18:18:03 +0500 Subject: [PATCH 1/2] feat: ctx usage in hot paths, simplify project structure feat: ctx usage in hot paths --- cmd/seq.go | 25 ---------------- cmd/app.go => internal/cmd/concur.go | 44 +++++++--------------------- internal/cmd/render.go | 27 +++++++++++++++++ internal/cmd/seq.go | 23 +++++++++++++++ internal/diffs/api.go | 20 +++++++------ internal/diffs/api_html.go | 11 ++++++- internal/gitutils/gitutils.go | 13 ++++---- internal/gitutils/gitutils_test.go | 3 +- main.go | 17 +++++++++-- 9 files changed, 105 insertions(+), 78 deletions(-) delete mode 100644 cmd/seq.go rename cmd/app.go => internal/cmd/concur.go (62%) create mode 100644 internal/cmd/render.go create mode 100644 internal/cmd/seq.go diff --git a/cmd/seq.go b/cmd/seq.go deleted file mode 100644 index 5093d3f..0000000 --- a/cmd/seq.go +++ /dev/null @@ -1,25 +0,0 @@ -package cmd - -import ( - "github.com/hashmap-kz/relimpact/internal/diffs" - "github.com/hashmap-kz/relimpact/internal/gitutils" -) - -func CreateChangelogSequential(repoDir, oldRef, newRef string) string { - return CreateAPIReportSequential(repoDir, oldRef, newRef, ReportFormatMarkdown) -} - -func CreateAPIReportSequential(repoDir, oldRef, newRef string, format ReportFormat) string { - // Checkout old/new worktrees. - tmpOld := gitutils.CheckoutWorktree(repoDir, oldRef) - defer gitutils.CleanupWorktree(repoDir, tmpOld) - - tmpNew := gitutils.CheckoutWorktree(repoDir, newRef) - defer gitutils.CleanupWorktree(repoDir, tmpNew) - - oldAPI := diffs.SnapshotAPI(tmpOld) - newAPI := diffs.SnapshotAPI(tmpNew) - - apiDiff := diffs.DiffAPI(oldAPI, newAPI) - return renderAPIReport(apiDiff, repoDir, oldRef, newRef, format) -} diff --git a/cmd/app.go b/internal/cmd/concur.go similarity index 62% rename from cmd/app.go rename to internal/cmd/concur.go index 5326a28..472f7a3 100644 --- a/cmd/app.go +++ b/internal/cmd/concur.go @@ -1,34 +1,23 @@ package cmd import ( + "context" "fmt" "sync" - "time" "github.com/hashmap-kz/relimpact/internal/diffs" "github.com/hashmap-kz/relimpact/internal/gitutils" "github.com/hashmap-kz/relimpact/internal/loggr" ) -type ReportFormat string - -const ( - ReportFormatMarkdown ReportFormat = "markdown" - ReportFormatHTML ReportFormat = "html" -) - -func CreateChangelog(repoDir, oldRef, newRef string) string { - return CreateAPIReport(repoDir, oldRef, newRef, ReportFormatMarkdown) -} - -func CreateAPIReport(repoDir, oldRef, newRef string, format ReportFormat) string { +func CreateAPIReportConcurrently(ctx context.Context, repoDir, oldRef, newRef string, format ReportFormat) string { // 1. Concurrent checkout old/new worktrees. - tmpOld, tmpNew := checkout(repoDir, oldRef, newRef) + tmpOld, tmpNew := checkout(ctx, repoDir, oldRef, newRef) defer gitutils.CleanupWorktree(repoDir, tmpOld) defer gitutils.CleanupWorktree(repoDir, tmpNew) // 2. Concurrent API snapshots. - oldAPI, newAPI := snap(tmpOld, tmpNew) + oldAPI, newAPI := snap(ctx, tmpOld, tmpNew) // 3. Render API-only report. apiDiff := diffs.DiffAPI(oldAPI, newAPI) @@ -36,7 +25,7 @@ func CreateAPIReport(repoDir, oldRef, newRef string, format ReportFormat) string } //nolint:gocritic -func checkout(repoDir, oldRef, newRef string) (string, string) { +func checkout(ctx context.Context, repoDir, oldRef, newRef string) (string, string) { type worktreeResult struct { which string path string @@ -51,7 +40,7 @@ func checkout(repoDir, oldRef, newRef string) (string, string) { worktreeCh <- worktreeResult{"old", "", fmt.Errorf("checkout old failed: %v", r)} } }() - path := gitutils.CheckoutWorktree(repoDir, oldRef) + path := gitutils.CheckoutWorktree(ctx, repoDir, oldRef) worktreeCh <- worktreeResult{"old", path, nil} }() @@ -61,7 +50,7 @@ func checkout(repoDir, oldRef, newRef string) (string, string) { worktreeCh <- worktreeResult{"new", "", fmt.Errorf("checkout new failed: %v", r)} } }() - path := gitutils.CheckoutWorktree(repoDir, newRef) + path := gitutils.CheckoutWorktree(ctx, repoDir, newRef) worktreeCh <- worktreeResult{"new", path, nil} }() @@ -84,7 +73,7 @@ func checkout(repoDir, oldRef, newRef string) (string, string) { } //nolint:gocritic -func snap(tmpOld, tmpNew string) (map[string]diffs.APIPackage, map[string]diffs.APIPackage) { +func snap(ctx context.Context, tmpOld, tmpNew string) (map[string]diffs.APIPackage, map[string]diffs.APIPackage) { var wgSnapshots sync.WaitGroup apiOldCh := make(chan map[string]diffs.APIPackage, 1) apiNewCh := make(chan map[string]diffs.APIPackage, 1) @@ -92,11 +81,11 @@ func snap(tmpOld, tmpNew string) (map[string]diffs.APIPackage, map[string]diffs. wgSnapshots.Add(2) go func() { defer wgSnapshots.Done() - apiOldCh <- diffs.SnapshotAPI(tmpOld) + apiOldCh <- diffs.SnapshotAPI(ctx, tmpOld) }() go func() { defer wgSnapshots.Done() - apiNewCh <- diffs.SnapshotAPI(tmpNew) + apiNewCh <- diffs.SnapshotAPI(ctx, tmpNew) }() wgSnapshots.Wait() @@ -108,16 +97,3 @@ func snap(tmpOld, tmpNew string) (map[string]diffs.APIPackage, map[string]diffs. return oldAPI, newAPI } - -func renderAPIReport(apiDiff *diffs.APIDiff, repoDir, oldRef, newRef string, format ReportFormat) string { - meta := diffs.ReportMetadata{ - Repo: repoDir, - OldRef: oldRef, - NewRef: newRef, - Now: time.Now(), - } - if format == ReportFormatHTML { - return apiDiff.HTML(meta) - } - return apiDiff.Markdown(meta) -} diff --git a/internal/cmd/render.go b/internal/cmd/render.go new file mode 100644 index 0000000..c02881b --- /dev/null +++ b/internal/cmd/render.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "time" + + "github.com/hashmap-kz/relimpact/internal/diffs" +) + +type ReportFormat string + +const ( + ReportFormatMarkdown ReportFormat = "markdown" + ReportFormatHTML ReportFormat = "html" +) + +func renderAPIReport(apiDiff *diffs.APIDiff, repoDir, oldRef, newRef string, format ReportFormat) string { + meta := diffs.ReportMetadata{ + Repo: repoDir, + OldRef: oldRef, + NewRef: newRef, + Now: time.Now(), + } + if format == ReportFormatHTML { + return apiDiff.HTML(meta) + } + return apiDiff.Markdown(meta) +} diff --git a/internal/cmd/seq.go b/internal/cmd/seq.go new file mode 100644 index 0000000..e4f3c93 --- /dev/null +++ b/internal/cmd/seq.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "context" + + "github.com/hashmap-kz/relimpact/internal/diffs" + "github.com/hashmap-kz/relimpact/internal/gitutils" +) + +func CreateAPIReportSequential(ctx context.Context, repoDir, oldRef, newRef string, format ReportFormat) string { + // Checkout old/new worktrees. + tmpOld := gitutils.CheckoutWorktree(ctx, repoDir, oldRef) + defer gitutils.CleanupWorktree(repoDir, tmpOld) + + tmpNew := gitutils.CheckoutWorktree(ctx, repoDir, newRef) + defer gitutils.CleanupWorktree(repoDir, tmpNew) + + oldAPI := diffs.SnapshotAPI(ctx, tmpOld) + newAPI := diffs.SnapshotAPI(ctx, tmpNew) + + apiDiff := diffs.DiffAPI(oldAPI, newAPI) + return renderAPIReport(apiDiff, repoDir, oldRef, newRef, format) +} diff --git a/internal/diffs/api.go b/internal/diffs/api.go index 110e3ea..ac1e311 100644 --- a/internal/diffs/api.go +++ b/internal/diffs/api.go @@ -1,6 +1,7 @@ package diffs import ( + "context" "encoding/json" "fmt" "go/token" @@ -72,10 +73,10 @@ func getCacheDir() string { return filepath.Join(os.TempDir(), "relimpact-api-cache") // fallback for local runs } -func SnapshotAPI(dir string) map[string]APIPackage { +func SnapshotAPI(ctx context.Context, dir string) map[string]APIPackage { // TODO: debuglog - sha := getGitCommitSHA(dir) + sha := getGitCommitSHA(ctx, dir) cachePath := filepath.Join(getCacheDir(), apiCacheSchemaVersion+"-"+sha+".json") loggr.Debugf("cache path: %s", cachePath) @@ -101,8 +102,9 @@ func SnapshotAPI(dir string) map[string]APIPackage { // } cfg := &packages.Config{ - Mode: packages.NeedName | packages.NeedTypes | packages.NeedImports, - Dir: dir, + Context: ctx, + Mode: packages.NeedName | packages.NeedTypes | packages.NeedImports, + Dir: dir, } // NOTE: this is the most expensive routine in the whole app. @@ -116,7 +118,7 @@ func SnapshotAPI(dir string) map[string]APIPackage { loggr.Debugf("packages load. time=%s, sha=%s", time.Since(loadStart).String(), sha) - modulePath := getModulePath(dir) + modulePath := getModulePath(ctx, dir) api := make(map[string]APIPackage) for _, pkg := range pkgs { @@ -381,8 +383,8 @@ func diffList(label, path string, oldList, newList []string) (added, removed []D return added, removed } -func getModulePath(dir string) string { - cmd := exec.Command("go", "list", "-m") +func getModulePath(ctx context.Context, dir string) string { + cmd := exec.CommandContext(ctx, "go", "list", "-m") cmd.Dir = dir out, err := cmd.Output() if err != nil { @@ -391,8 +393,8 @@ func getModulePath(dir string) string { return strings.TrimSpace(string(out)) } -func getGitCommitSHA(dir string) string { - cmd := exec.Command("git", "rev-parse", "HEAD") +func getGitCommitSHA(ctx context.Context, dir string) string { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD") cmd.Dir = dir out, err := cmd.Output() if err != nil { diff --git a/internal/diffs/api_html.go b/internal/diffs/api_html.go index d00f714..cb6e41b 100644 --- a/internal/diffs/api_html.go +++ b/internal/diffs/api_html.go @@ -5,6 +5,7 @@ import ( "regexp" "sort" "strings" + "sync" "time" ) @@ -1243,8 +1244,14 @@ var importPathRE = regexp.MustCompile( `([A-Za-z0-9_./~-]+/[A-Za-z0-9_./~-]+)\.([A-Za-z_][A-Za-z0-9_]*)`, ) +var abbreviateCache sync.Map + func abbreviateImportPaths(s string) string { - return importPathRE.ReplaceAllStringFunc(s, func(match string) string { + if v, ok := abbreviateCache.Load(s); ok { + //nolint:errcheck + return v.(string) + } + result := importPathRE.ReplaceAllStringFunc(s, func(match string) string { idx := strings.LastIndex(match, ".") if idx < 0 { return match @@ -1256,6 +1263,8 @@ func abbreviateImportPaths(s string) string { } return path + "." + ident }) + abbreviateCache.Store(s, result) + return result } func cleanAPILabel(fallback, label string) string { diff --git a/internal/gitutils/gitutils.go b/internal/gitutils/gitutils.go index 5a5d122..087178f 100644 --- a/internal/gitutils/gitutils.go +++ b/internal/gitutils/gitutils.go @@ -1,27 +1,30 @@ package gitutils import ( + "context" "io" "log" "os" "os/exec" ) -func CheckoutWorktree(repoDir, ref string) string { +func CheckoutWorktree(ctx context.Context, repoDir, ref string) string { tmpDir, err := os.MkdirTemp("", "apidiff-"+ref) if err != nil { log.Fatal(err) } - runGitInDir(repoDir, "worktree", "add", "--detach", tmpDir, ref) + runGitInDir(ctx, repoDir, "worktree", "add", "--detach", tmpDir, ref) return tmpDir } +// CleanupWorktree removes the worktree unconditionally; it uses a fresh +// context so that an expired deadline does not prevent cleanup. func CleanupWorktree(repoDir, path string) { - runGitInDir(repoDir, "worktree", "remove", "--force", path) + runGitInDir(context.Background(), repoDir, "worktree", "remove", "--force", path) } -func runGitInDir(dir string, args ...string) { - cmd := exec.Command("git", args...) +func runGitInDir(ctx context.Context, dir string, args ...string) { + cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir cmd.Stdout = io.Discard cmd.Stderr = io.Discard diff --git a/internal/gitutils/gitutils_test.go b/internal/gitutils/gitutils_test.go index a7b2ee5..a8d4801 100644 --- a/internal/gitutils/gitutils_test.go +++ b/internal/gitutils/gitutils_test.go @@ -1,6 +1,7 @@ package gitutils import ( + "context" "os" "path/filepath" "testing" @@ -39,7 +40,7 @@ func TestCheckoutAndCleanupWorktree(t *testing.T) { // git worktree must be run inside repo! require.NoError(t, os.Chdir(tmpDir)) - worktreeDir := CheckoutWorktree(tmpDir, "v1") + worktreeDir := CheckoutWorktree(context.Background(), tmpDir, "v1") // Verify worktree dir exists and contains file.txt _, err = os.Stat(filepath.Join(worktreeDir, "file.txt")) diff --git a/main.go b/main.go index 804683e..cdc8d83 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,15 @@ package main import ( + "context" "flag" "fmt" "os" "strings" + "time" + + "github.com/hashmap-kz/relimpact/internal/cmd" - "github.com/hashmap-kz/relimpact/cmd" "github.com/hashmap-kz/relimpact/internal/loggr" "github.com/hashmap-kz/relimpact/internal/x/fmtx" ) @@ -18,6 +21,7 @@ func main() { format := flag.String("format", "markdown", "report format: markdown or html") outputFile := flag.String("output", "", "write report to file instead of stdout") greedy := flag.Bool("greedy", false, "use maximum concurrency") + timeoutFlag := flag.Duration("timeout", 10*time.Minute, `operation timeout, e.g. "5m", "30s" (default "10m")`) flag.Usage = usage @@ -41,11 +45,14 @@ func main() { // TODO: make log level configurable via env/CLI. loggr.Init(loggr.LevelTrace, "relimpact") + ctx, cancel := context.WithTimeout(context.Background(), *timeoutFlag) + defer cancel() + var report string if *greedy { - report = cmd.CreateAPIReport(*dir, *oldRef, *newRef, reportFormat) + report = cmd.CreateAPIReportConcurrently(ctx, *dir, *oldRef, *newRef, reportFormat) } else { - report = cmd.CreateAPIReportSequential(*dir, *oldRef, *newRef, reportFormat) + report = cmd.CreateAPIReportSequential(ctx, *dir, *oldRef, *newRef, reportFormat) } if *outputFile == "" { @@ -73,9 +80,13 @@ Flags: --dir string path to git repository (default ".") --format string report format: markdown or html (default "markdown") --output string write report to file instead of stdout + --timeout duration overall operation timeout, e.g. "5m", "30s" (default "10m") --greedy use maximum concurrency -h, --help show help +Environment: + RELIMPACT_API_CACHE_DIR override the directory used for API snapshot cache + Examples: relimpact --old v1.0.0 --new HEAD From 7c3005a05244eb61f8aae966573354a06092d2a5 Mon Sep 17 00:00:00 2001 From: "alexey.zh" Date: Mon, 11 May 2026 18:43:03 +0500 Subject: [PATCH 2/2] fix: minor changes in CLI usage --- main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index cdc8d83..48d017d 100644 --- a/main.go +++ b/main.go @@ -77,9 +77,9 @@ Required: --new string new git ref, tag, branch, or commit Flags: - --dir string path to git repository (default ".") - --format string report format: markdown or html (default "markdown") - --output string write report to file instead of stdout + --dir string path to git repository (default ".") + --format string report format: markdown or html (default "markdown") + --output string write report to file instead of stdout --timeout duration overall operation timeout, e.g. "5m", "30s" (default "10m") --greedy use maximum concurrency -h, --help show help