summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/codeberg/codeberg.go21
-rw-r--r--internal/github/github.go21
-rw-r--r--internal/release/release.go43
-rw-r--r--internal/release/release_prompt_test.go74
-rw-r--r--internal/showcase/code_extractor.go14
-rw-r--r--internal/showcase/images.go17
-rw-r--r--internal/showcase/images_test.go55
-rw-r--r--internal/showcase/language_detector.go4
-rw-r--r--internal/showcase/showcase.go6
-rw-r--r--internal/sync/branch_analyzer.go66
10 files changed, 266 insertions, 55 deletions
diff --git a/internal/codeberg/codeberg.go b/internal/codeberg/codeberg.go
index f881be9..9bff36e 100644
--- a/internal/codeberg/codeberg.go
+++ b/internal/codeberg/codeberg.go
@@ -42,6 +42,15 @@ type Client struct {
var _ forge.RepoClient = (*Client)(nil)
var _ forge.RepoDescriptionClient = (*Client)(nil)
+// closeResponseBody closes an HTTP response body. By the time this runs the
+// body has already been fully read (or the caller is bailing out on an
+// earlier error), so a close failure here cannot change the outcome that was
+// already determined - the error is intentionally discarded rather than
+// treated as actionable.
+func closeResponseBody(resp *http.Response) {
+ _ = resp.Body.Close()
+}
+
// NewClient creates a new Codeberg API client
func NewClient(token, org string) *Client {
c := &Client{
@@ -97,7 +106,7 @@ func (c *Client) GetRepo(repoName string) (Repository, bool, error) {
if err != nil {
return repo, false, err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode == 404 {
return repo, false, nil
@@ -149,7 +158,7 @@ func (c *Client) UpdateRepoDescription(repoName, description string) error {
if err != nil {
return err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
@@ -242,7 +251,7 @@ func (c *Client) listReposPage(url string) ([]Repository, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch repositories: %w", err)
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
@@ -273,7 +282,7 @@ func (c *Client) RepoExists(repoName string) (bool, error) {
if err != nil {
return false, err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
return resp.StatusCode == 200, nil
}
@@ -316,7 +325,7 @@ func (c *Client) CreateRepo(repoName, description string, private bool) error {
if err != nil {
return err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode != http.StatusCreated {
// Read the response body to get more detailed error information
@@ -365,7 +374,7 @@ func (c *Client) DeleteRepo(repoName string) error {
if err != nil {
return err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
body, _ := io.ReadAll(resp.Body)
return forge.DeleteStatusError(resp.StatusCode, string(body))
diff --git a/internal/github/github.go b/internal/github/github.go
index a05d09f..4d09093 100644
--- a/internal/github/github.go
+++ b/internal/github/github.go
@@ -23,6 +23,15 @@ type Client struct {
var _ forge.RepoClient = (*Client)(nil)
var _ forge.RepoDescriptionClient = (*Client)(nil)
+// closeResponseBody closes an HTTP response body. By the time this runs the
+// body has already been fully read (or the caller is bailing out on an
+// earlier error), so a close failure here cannot change the outcome that was
+// already determined - the error is intentionally discarded rather than
+// treated as actionable.
+func closeResponseBody(resp *http.Response) {
+ _ = resp.Body.Close()
+}
+
// NewClient creates a new GitHub API client
func NewClient(token, org string) *Client {
return &Client{
@@ -104,7 +113,7 @@ func (c *Client) RepoExists(repoName string) (bool, error) {
if err != nil {
return false, err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode == 200 {
return true, nil
@@ -166,7 +175,7 @@ func (c *Client) CreateRepo(repoName, description string, private bool) error {
if err != nil {
return err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode == 201 {
var createResp CreateRepoResponse
@@ -216,7 +225,7 @@ func (c *Client) GetRepo(repoName string) (Repository, bool, error) {
if err != nil {
return repo, false, err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode == 404 {
return repo, false, nil
@@ -269,7 +278,7 @@ func (c *Client) UpdateRepoDescription(repoName, description string) error {
if err != nil {
return err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode != 200 {
b, _ := io.ReadAll(resp.Body)
@@ -339,7 +348,7 @@ func (c *Client) listPublicReposPage(url string) ([]Repository, error) {
if err != nil {
return nil, err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
@@ -388,7 +397,7 @@ func (c *Client) DeleteRepo(repoName string) error {
if err != nil {
return err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
body, _ := io.ReadAll(resp.Body)
return forge.DeleteStatusError(resp.StatusCode, string(body))
diff --git a/internal/release/release.go b/internal/release/release.go
index fc414c9..5608f33 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -38,6 +38,15 @@ type Manager struct {
aiTool string
}
+// closeResponseBody closes an HTTP response body. By the time this runs the
+// body has already been fully read (or the caller is bailing out on an
+// earlier error), so a close failure here cannot change the outcome that was
+// already determined - the error is intentionally discarded rather than
+// treated as actionable.
+func closeResponseBody(resp *http.Response) {
+ _ = resp.Body.Close()
+}
+
// NewManager creates a new release manager
func NewManager(workDir string) *Manager {
return &Manager{
@@ -79,7 +88,7 @@ func (m *Manager) EnsureCodebergReleasesEnabled(owner, repo string) error {
if err != nil {
return err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to get repo info: %s - %s", resp.Status, string(body))
@@ -112,7 +121,7 @@ func (m *Manager) EnsureCodebergReleasesEnabled(owner, repo string) error {
if err != nil {
return err
}
- defer patchResp.Body.Close()
+ defer closeResponseBody(patchResp)
if patchResp.StatusCode != 200 {
pbody, _ := io.ReadAll(patchResp.Body)
return fmt.Errorf("failed to enable releases: %s - %s", patchResp.Status, string(pbody))
@@ -544,7 +553,7 @@ func (m *Manager) GetGitHubReleases(owner, repo string) ([]string, error) {
if err != nil {
return nil, err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode == 404 {
// Repository might not exist on GitHub
@@ -588,7 +597,7 @@ func (m *Manager) GetCodebergReleases(owner, repo string) ([]string, error) {
if err != nil {
return nil, err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode == 404 {
// Repository might not exist on Codeberg
@@ -669,7 +678,7 @@ func (m *Manager) CreateGitHubRelease(owner, repo, tag, releaseNotes string) err
if err != nil {
return err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode != 201 {
body, _ := io.ReadAll(resp.Body)
@@ -722,7 +731,7 @@ func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) e
if err != nil {
return err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode != 201 {
body, _ := io.ReadAll(resp.Body)
@@ -739,7 +748,7 @@ func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) e
probeReq.Header.Set("Authorization", "token "+m.codebergToken)
}
if probeResp, perr2 := httpclient.Do(probeReq); perr2 == nil {
- defer probeResp.Body.Close()
+ defer closeResponseBody(probeResp)
if probeResp.StatusCode == 200 {
// Try to detect if releases are disabled
var repoInfo struct {
@@ -768,7 +777,7 @@ func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) e
if rerr != nil {
return rerr
}
- defer retryResp.Body.Close()
+ defer closeResponseBody(retryResp)
if retryResp.StatusCode != 201 {
rbody, _ := io.ReadAll(retryResp.Body)
return fmt.Errorf("failed to create Codeberg release after enabling releases: %s - %s", retryResp.Status, string(rbody))
@@ -812,7 +821,11 @@ func PromptConfirmation(message string) bool {
fmt.Printf("%s [y/N]: ", message)
var response string
- fmt.Scanln(&response)
+ // Scanln can return an error for empty input (e.g. a bare newline) or if
+ // stdin is closed; either way response stays "" and the check below
+ // already treats that as a decline, so there is nothing extra to do
+ // with the error here.
+ _, _ = fmt.Scanln(&response)
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes"
@@ -828,7 +841,9 @@ func PromptConfirmationWithNotes(message, releaseNotes string) bool {
fmt.Printf("%s [y/N]: ", message)
var response string
- fmt.Scanln(&response)
+ // See PromptConfirmation: a Scanln error still leaves response == "",
+ // which the check below already treats as a decline.
+ _, _ = fmt.Scanln(&response)
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes"
@@ -856,7 +871,7 @@ func (m *Manager) UpdateGitHubRelease(owner, repo, tag, releaseNotes string) err
if err != nil {
return err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
@@ -898,7 +913,7 @@ func (m *Manager) UpdateGitHubRelease(owner, repo, tag, releaseNotes string) err
if err != nil {
return err
}
- defer updateResp.Body.Close()
+ defer closeResponseBody(updateResp)
if updateResp.StatusCode != 200 {
body, _ := io.ReadAll(updateResp.Body)
@@ -929,7 +944,7 @@ func (m *Manager) UpdateCodebergRelease(owner, repo, tag, releaseNotes string) e
if err != nil {
return err
}
- defer resp.Body.Close()
+ defer closeResponseBody(resp)
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
@@ -970,7 +985,7 @@ func (m *Manager) UpdateCodebergRelease(owner, repo, tag, releaseNotes string) e
if err != nil {
return err
}
- defer updateResp.Body.Close()
+ defer closeResponseBody(updateResp)
if updateResp.StatusCode != 200 {
body, _ := io.ReadAll(updateResp.Body)
diff --git a/internal/release/release_prompt_test.go b/internal/release/release_prompt_test.go
new file mode 100644
index 0000000..fdb1d27
--- /dev/null
+++ b/internal/release/release_prompt_test.go
@@ -0,0 +1,74 @@
+package release
+
+import (
+ "os"
+ "testing"
+)
+
+// withStdin temporarily replaces os.Stdin with r for the duration of fn, then
+// restores the original value. Not run in parallel with other tests since
+// os.Stdin is a shared global.
+func withStdin(t *testing.T, r *os.File, fn func()) {
+ t.Helper()
+
+ original := os.Stdin
+ os.Stdin = r
+ defer func() { os.Stdin = original }()
+
+ fn()
+}
+
+// TestPromptConfirmation_EmptyInputDeclines verifies that when Scanln
+// returns an error (e.g. a bare newline, which fmt.Scanln reports as
+// "unexpected newline"), PromptConfirmation still safely defaults to
+// declining rather than panicking or hanging - this is the discarded-error
+// behavior that PromptConfirmation's fmt.Scanln call relies on.
+func TestPromptConfirmation_EmptyInputDeclines(t *testing.T) {
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("failed to create pipe: %v", err)
+ }
+ defer func() { _ = r.Close() }()
+
+ if _, err := w.WriteString("\n"); err != nil {
+ t.Fatalf("failed to write to pipe: %v", err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatalf("failed to close pipe writer: %v", err)
+ }
+
+ var got bool
+ withStdin(t, r, func() {
+ got = PromptConfirmation("Proceed?")
+ })
+
+ if got {
+ t.Fatal("PromptConfirmation() = true for empty input, want false")
+ }
+}
+
+// TestPromptConfirmation_YesInputConfirms is a regression check that the
+// happy path (explicit "y") still works after touching the Scanln call.
+func TestPromptConfirmation_YesInputConfirms(t *testing.T) {
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("failed to create pipe: %v", err)
+ }
+ defer func() { _ = r.Close() }()
+
+ if _, err := w.WriteString("y\n"); err != nil {
+ t.Fatalf("failed to write to pipe: %v", err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatalf("failed to close pipe writer: %v", err)
+ }
+
+ var got bool
+ withStdin(t, r, func() {
+ got = PromptConfirmation("Proceed?")
+ })
+
+ if !got {
+ t.Fatal("PromptConfirmation() = false for \"y\" input, want true")
+ }
+}
diff --git a/internal/showcase/code_extractor.go b/internal/showcase/code_extractor.go
index 92ee1e4..7c40573 100644
--- a/internal/showcase/code_extractor.go
+++ b/internal/showcase/code_extractor.go
@@ -9,6 +9,16 @@ import (
"strings"
)
+// closeFile closes a file opened for reading. These reads are best-effort
+// (shebang sniffing, line counting, snippet extraction) and the file is never
+// written to, so a close failure here cannot affect correctness - the error
+// is intentionally discarded rather than treated as actionable. Shared by
+// the other showcase package files that open read-only files (images.go,
+// language_detector.go).
+func closeFile(file *os.File) {
+ _ = file.Close()
+}
+
// extractCodeSnippet extracts a random code snippet from the repository
func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, string, error) {
if len(languages) == 0 {
@@ -113,7 +123,7 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str
matched = true
}
}
- file.Close()
+ closeFile(file)
}
}
@@ -185,7 +195,7 @@ func extractSnippetFromFile(filePath string, minLines, maxLines int) (string, er
if err != nil {
return "", err
}
- defer file.Close()
+ defer closeFile(file)
// Read all lines
var lines []string
diff --git a/internal/showcase/images.go b/internal/showcase/images.go
index b6fe6d7..2c09a8e 100644
--- a/internal/showcase/images.go
+++ b/internal/showcase/images.go
@@ -226,19 +226,28 @@ func isGitHostedImage(url string) bool {
strings.Contains(url, "codeberg.page")
}
-// copyFile copies a file from src to dst
-func copyFile(src, dst string) error {
+// copyFile copies a file from src to dst. The destination is a write, so
+// unlike the read-only closeFile helper used elsewhere in this package, a
+// failure to close it is reported via the named return: Sync() already
+// covers the common flush-failure case, but a close error on a lagging
+// network filesystem is still real data-loss information worth surfacing,
+// and it must not mask an earlier, more specific error.
+func copyFile(src, dst string) (err error) {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
- defer sourceFile.Close()
+ defer closeFile(sourceFile)
destFile, err := os.Create(dst)
if err != nil {
return err
}
- defer destFile.Close()
+ defer func() {
+ if cerr := destFile.Close(); cerr != nil && err == nil {
+ err = fmt.Errorf("failed to close destination file %s: %w", dst, cerr)
+ }
+ }()
_, err = io.Copy(destFile, sourceFile)
if err != nil {
diff --git a/internal/showcase/images_test.go b/internal/showcase/images_test.go
new file mode 100644
index 0000000..c88da00
--- /dev/null
+++ b/internal/showcase/images_test.go
@@ -0,0 +1,55 @@
+package showcase
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestCopyFile_CopiesContent verifies the basic success path still works
+// after copyFile was changed to use a named return so that a destination
+// Close() error can be surfaced without changing the happy-path result.
+func TestCopyFile_CopiesContent(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ src := filepath.Join(dir, "source.txt")
+ dst := filepath.Join(dir, "dest.txt")
+ want := "hello showcase\n"
+
+ if err := os.WriteFile(src, []byte(want), 0644); err != nil {
+ t.Fatalf("failed to write source file: %v", err)
+ }
+
+ if err := copyFile(src, dst); err != nil {
+ t.Fatalf("copyFile() returned error: %v", err)
+ }
+
+ got, err := os.ReadFile(dst)
+ if err != nil {
+ t.Fatalf("failed to read destination file: %v", err)
+ }
+ if string(got) != want {
+ t.Fatalf("copyFile() wrote %q, want %q", string(got), want)
+ }
+}
+
+// TestCopyFile_MissingSourceReturnsError verifies that a missing source file
+// still produces an error (and never a nil error masked by the deferred
+// destination-close handling) and does not leave a destination file behind.
+func TestCopyFile_MissingSourceReturnsError(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ src := filepath.Join(dir, "does-not-exist.txt")
+ dst := filepath.Join(dir, "dest.txt")
+
+ err := copyFile(src, dst)
+ if err == nil {
+ t.Fatal("copyFile() with missing source returned nil error, want non-nil")
+ }
+
+ if _, statErr := os.Stat(dst); statErr == nil {
+ t.Fatal("copyFile() with missing source created a destination file, want none")
+ }
+}
diff --git a/internal/showcase/language_detector.go b/internal/showcase/language_detector.go
index 692f048..9a42be1 100644
--- a/internal/showcase/language_detector.go
+++ b/internal/showcase/language_detector.go
@@ -201,7 +201,7 @@ func detectLanguages(repoPath string) (languages []LanguageStats, documentation
}
}
}
- file.Close()
+ closeFile(file)
}
}
@@ -281,7 +281,7 @@ func countFileLines(path string) (int, error) {
if err != nil {
return 0, err
}
- defer file.Close()
+ defer closeFile(file)
scanner := bufio.NewScanner(file)
lines := 0
diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go
index ae938cb..9582c1f 100644
--- a/internal/showcase/showcase.go
+++ b/internal/showcase/showcase.go
@@ -350,7 +350,11 @@ func (g *Generator) prepareStatsRepoPath(repoName, repoPath string) (string, fun
}
cleanup := func() error {
- defer os.RemoveAll(tempRoot)
+ // Best-effort cleanup of the temporary worktree root, matching the
+ // discard above for the same call on the worktree-add failure path:
+ // this is scratch space, and by the time we get here the actual
+ // worktree removal below is the operation whose error matters.
+ defer func() { _ = 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)
diff --git a/internal/sync/branch_analyzer.go b/internal/sync/branch_analyzer.go
index eaa6cdc..6b51198 100644
--- a/internal/sync/branch_analyzer.go
+++ b/internal/sync/branch_analyzer.go
@@ -518,8 +518,11 @@ func writeBranchDeletionBlock(writer io.Writer, branches []BranchInfo, reviewBra
return nil
}
-// GenerateDeleteScript generates a shell script file to delete all abandoned branches
-func (s *Syncer) GenerateDeleteScript() (string, error) {
+// GenerateDeleteScript generates a shell script file to delete all abandoned branches.
+// The return value uses named results so that a failure to close the script file
+// (e.g. a delayed flush error on a lagging filesystem) is reported via err instead
+// of being silently discarded, without masking any earlier, more specific error.
+func (s *Syncer) GenerateDeleteScript() (scriptPath string, err error) {
if len(s.abandonedReports) == 0 {
return "", nil
}
@@ -539,7 +542,7 @@ func (s *Syncer) GenerateDeleteScript() (string, error) {
// Generate script filename with timestamp
timestamp := time.Now().Format("20060102_150405")
- scriptPath := filepath.Join(s.workDir, fmt.Sprintf("delete_abandoned_branches_%s.sh", timestamp))
+ scriptPath = filepath.Join(s.workDir, fmt.Sprintf("delete_abandoned_branches_%s.sh", timestamp))
scriptBaseName := filepath.Base(scriptPath)
// Create the script file
@@ -547,7 +550,11 @@ func (s *Syncer) GenerateDeleteScript() (string, error) {
if err != nil {
return "", fmt.Errorf("failed to create script file: %w", err)
}
- defer file.Close()
+ defer func() {
+ if cerr := file.Close(); cerr != nil && err == nil {
+ err = fmt.Errorf("failed to close script file %s: %w", scriptPath, cerr)
+ }
+ }()
if err := writeDeleteScriptTemplate(file, "deleteScriptPreamble", deleteScriptTemplateData{
GeneratedAt: time.Now().Format("2006-01-02 15:04:05"),
@@ -567,24 +574,15 @@ func (s *Syncer) GenerateDeleteScript() (string, error) {
continue
}
- fmt.Fprintf(file, "# ======================================\n")
- fmt.Fprintf(file, "# Repository: %s\n", repoName)
- fmt.Fprintf(file, "# ======================================\n")
- fmt.Fprintf(file, "echo\n")
- fmt.Fprintf(file, "echo \"📁 Processing repository: %s\"\n", repoName)
- fmt.Fprintf(file, "cd \"%s/%s\" || { echo \"Failed to change to repository directory\"; exit 1; }\n\n", s.workDir, repoName)
-
- // Find main branch for review mode
- fmt.Fprintf(file, "if [[ \"$MODE\" == \"review\" || \"$MODE\" == \"review-full\" ]]; then\n")
- fmt.Fprintf(file, " main_branch=$(find_main_branch)\n")
- fmt.Fprintf(file, " if [[ -z \"$main_branch\" ]]; then\n")
- fmt.Fprintf(file, " echo -e \"${RED}⚠️ No main/master branch found in %s${NC}\"\n", repoName)
- fmt.Fprintf(file, " fi\n")
- fmt.Fprintf(file, "fi\n\n")
+ if err := writeDeleteScriptRepoHeader(file, s.workDir, repoName); err != nil {
+ return scriptPath, err
+ }
// Process regular abandoned branches
if len(report.AbandonedBranches) > 0 {
- fmt.Fprintf(file, "# Regular abandoned branches\n")
+ if _, err := fmt.Fprintf(file, "# Regular abandoned branches\n"); err != nil {
+ return scriptPath, fmt.Errorf("failed to write regular branches header for %s: %w", repoName, err)
+ }
if err := writeBranchDeletionBlock(file, report.AbandonedBranches, "regular", "🔸 Deleting branch: "); err != nil {
return scriptPath, err
}
@@ -592,7 +590,9 @@ func (s *Syncer) GenerateDeleteScript() (string, error) {
// Process ignored abandoned branches
if len(report.AbandonedIgnoredBranches) > 0 {
- fmt.Fprintf(file, "# Ignored abandoned branches\n")
+ if _, err := fmt.Fprintf(file, "# Ignored abandoned branches\n"); err != nil {
+ return scriptPath, fmt.Errorf("failed to write ignored branches header for %s: %w", repoName, err)
+ }
if err := writeBranchDeletionBlock(file, report.AbandonedIgnoredBranches, "ignored", "🔹 Deleting ignored branch: "); err != nil {
return scriptPath, err
}
@@ -612,3 +612,29 @@ func (s *Syncer) GenerateDeleteScript() (string, error) {
return scriptPath, nil
}
+
+// writeDeleteScriptRepoHeader writes the per-repository banner and the
+// review-mode main-branch check at the top of each repository's block in
+// the generated delete script.
+func writeDeleteScriptRepoHeader(file *os.File, workDir, repoName string) error {
+ lines := []string{
+ "# ======================================\n",
+ fmt.Sprintf("# Repository: %s\n", repoName),
+ "# ======================================\n",
+ "echo\n",
+ fmt.Sprintf("echo \"📁 Processing repository: %s\"\n", repoName),
+ fmt.Sprintf("cd \"%s/%s\" || { echo \"Failed to change to repository directory\"; exit 1; }\n\n", workDir, repoName),
+ "if [[ \"$MODE\" == \"review\" || \"$MODE\" == \"review-full\" ]]; then\n",
+ " main_branch=$(find_main_branch)\n",
+ " if [[ -z \"$main_branch\" ]]; then\n",
+ fmt.Sprintf(" echo -e \"${RED}⚠️ No main/master branch found in %s${NC}\"\n", repoName),
+ " fi\n",
+ "fi\n\n",
+ }
+ for _, line := range lines {
+ if _, err := fmt.Fprint(file, line); err != nil {
+ return fmt.Errorf("failed to write repository header for %s: %w", repoName, err)
+ }
+ }
+ return nil
+}