Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 88 additions & 24 deletions cmd/repo-brancher/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,21 +124,23 @@ func main() {
failedConfigs.Insert(configInfo)
}

// Multiple CI config variants can share the same org/repo/branch.
// Track which ones we've already processed to avoid duplicate
// ls-remote calls and git operations.
seenBranches := sets.New[string]()
if err := o.OperateOnCIOperatorConfigDir(o.ConfigDir, api.WithoutOKD, func(configuration *api.ReleaseBuildConfiguration, repoInfo *config.Info) error {
if ignoreSet.Has(repoInfo.Org) || ignoreSet.Has(fmt.Sprintf("%s/%s", repoInfo.Org, repoInfo.Repo)) {
logrus.WithField("repo", fmt.Sprintf("%s/%s", repoInfo.Org, repoInfo.Repo)).Info("Skipping due to --ignore")
return nil
}

logger := config.LoggerForInfo(*repoInfo)

repoDir := path.Join(gitDir, repoInfo.Org, repoInfo.Repo)
if err := os.MkdirAll(repoDir, 0775); err != nil {
logger.WithError(err).Fatal("could not ensure git dir existed")
branchKey := fmt.Sprintf("%s/%s@%s", repoInfo.Org, repoInfo.Repo, repoInfo.Branch)
if seenBranches.Has(branchKey) {
return nil
}
seenBranches.Insert(branchKey)

gitCmd := gitCmdFunc(repoDir)
logger := config.LoggerForInfo(*repoInfo)

remote, err := url.Parse(fmt.Sprintf("https://github.com/%s/%s", repoInfo.Org, repoInfo.Repo))
if err != nil {
Expand All @@ -149,13 +151,22 @@ func main() {
if o.Confirm {
remote.User = url.UserPassword(o.username, token)
}
for _, command := range [][]string{{"init"}, {"fetch", "--depth", "1", remote.String(), repoInfo.Branch}} {
if err := gitCmd(logger, command...); err != nil {
appendFailedConfig(configuration)
return err
}

// Determine which future branches need work by comparing remote refs
// in a single ls-remote call instead of fetching and pushing each one.
remoteRefs, err := lsRemoteRefs(logger, remote)
if err != nil {
appendFailedConfig(configuration)
return err
}
currentSHA := remoteRefs[fmt.Sprintf("refs/heads/%s", repoInfo.Branch)]
if currentSHA == "" {
logger.Error("Current branch not found on remote.")
appendFailedConfig(configuration)
return nil
}

var branchesNeedingWork []string
for _, futureRelease := range o.FutureReleases.Strings() {
futureBranch, err := promotion.DetermineReleaseBranch(o.CurrentRelease, futureRelease, repoInfo.Branch)
if err != nil {
Expand All @@ -167,24 +178,47 @@ func main() {
continue
}

// when we're initializing the branch, we just want to make sure
// it is in sync with the current branch that is promoting
logger := logger.WithField("future-branch", futureBranch)
command := []string{"ls-remote", remote.String(), fmt.Sprintf("refs/heads/%s", futureBranch)}
if err := gitCmd(logger, command...); err != nil {
appendFailedConfig(configuration)
futureLogger := logger.WithField("future-branch", futureBranch)
futureRef := fmt.Sprintf("refs/heads/%s", futureBranch)
if remoteRefs[futureRef] == currentSHA {
futureLogger.Debug("Already up to date, skipping.")
continue
}

if !o.Confirm {
logger.Info("Would create new branch.")
futureLogger.Info("Would create new branch.")
continue
}

branchesNeedingWork = append(branchesNeedingWork, futureBranch)
}

if !o.Confirm || len(branchesNeedingWork) == 0 {
return nil
}

// Only init and fetch if there is actual work to do.
repoDir := path.Join(gitDir, repoInfo.Org, repoInfo.Repo)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repoDir is built with path.Join, which is intended for slash-separated URL paths and can produce non-native paths on Windows. The rest of this repo typically uses filepath.Join for filesystem paths (e.g., cmd/private-org-sync/main.go:313), so this should likely use filepath.Join and update imports accordingly.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is correct in general, but this tool only runs in Linux CI containers, so path.Join works fine here. The existing code before this PR already used path.Join for the same purpose, so changing it would be out of scope.

if err := os.MkdirAll(repoDir, 0775); err != nil {
logger.WithError(err).Fatal("could not ensure git dir existed")
return nil
Comment on lines +202 to +204
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n cmd/repo-brancher/main.go | head -250 | tail -100

Repository: openshift/ci-tools

Length of output: 3695


🏁 Script executed:

cat -n cmd/repo-brancher/main.go | head -150

Repository: openshift/ci-tools

Length of output: 5277


Don't terminate the whole run from one repo's MkdirAll failure.

Calling Fatal() at line 193 exits the entire program, bypassing the deferred temp-dir cleanup (lines 99–103) and the final failed-config summary (lines 244–247). This also prevents remaining repos from being processed. Log the error, append the config to the failed list, and return the error instead.

🛠️ Proposed fix
 		repoDir := path.Join(gitDir, repoInfo.Org, repoInfo.Repo)
 		if err := os.MkdirAll(repoDir, 0775); err != nil {
-			logger.WithError(err).Fatal("could not ensure git dir existed")
-			return nil
+			logger.WithError(err).Error("could not ensure git dir existed")
+			appendFailedConfig(configuration)
+			return err
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if err := os.MkdirAll(repoDir, 0775); err != nil {
logger.WithError(err).Fatal("could not ensure git dir existed")
return nil
repoDir := path.Join(gitDir, repoInfo.Org, repoInfo.Repo)
if err := os.MkdirAll(repoDir, 0775); err != nil {
logger.WithError(err).Error("could not ensure git dir existed")
appendFailedConfig(configuration)
return err
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/repo-brancher/main.go` around lines 192 - 194, The current
os.MkdirAll(repoDir, 0775) error path calls logger.WithError(err).Fatal which
exits the process; change this to log the error (e.g.,
logger.WithError(err).Error or .Warn), append the current repo's config
identifier to the shared failed configs list (e.g., failedConfigs or failed list
used for the final failed-config summary), and return the error from the
function instead of calling Fatal so the caller can continue processing
remaining repos and deferred temp-dir cleanup still runs. Specifically, replace
logger.WithError(err).Fatal("could not ensure git dir existed") with a non-fatal
log, push the config (the variable that represents the repo config) onto the
failed list, and return err.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid observation, but this is pre-existing behavior — the Fatal call was already there before this PR, I just moved the block lower to defer it until after the ls-remote check. Fixing it is out of scope for this change.

Comment on lines +203 to +204
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.MkdirAll failure is handled with logger.Fatal, which exits the process immediately (skipping deferred cleanup like the temp git dir removal) and makes the following return nil unreachable. Prefer returning the error (and letting the main error/failedConfigs path decide exit status) rather than calling Fatal inside the per-config callback.

Suggested change
logger.WithError(err).Fatal("could not ensure git dir existed")
return nil
appendFailedConfig(configuration)
return err

Copilot uses AI. Check for mistakes.
}

gitCmd := gitCmdFunc(repoDir)
for _, command := range [][]string{{"init"}, {"fetch", "--depth", "1", remote.String(), repoInfo.Branch}} {
if err := gitCmd(logger, command...); err != nil {
appendFailedConfig(configuration)
return err
}
}

for _, futureBranch := range branchesNeedingWork {
futureLogger := logger.WithField("future-branch", futureBranch)

for depth := 1; depth <= 9; depth += 1 {
retry, err := pushBranch(logger, remote, futureBranch, gitCmd)
retry, err := pushBranch(futureLogger, remote, futureBranch, gitCmd)
if err != nil {
logger.WithError(err).Error("Failed to push branch")
futureLogger.WithError(err).Error("Failed to push branch")
appendFailedConfig(configuration)
break
}
Expand All @@ -194,18 +228,18 @@ func main() {
}

if depth == 9 {
logger.Error("Could not push branch even after unshallowing.")
futureLogger.Error("Could not push branch even after unshallowing.")
appendFailedConfig(configuration)
break
}

if depth == 8 {
logger.Warn("Progressive deepening was not enough, fetching full history...")
if err := fetchUnshallow(logger, remote, gitCmd, repoInfo); err != nil {
futureLogger.Warn("Progressive deepening was not enough, fetching full history...")
if err := fetchUnshallow(futureLogger, remote, gitCmd, repoInfo); err != nil {
appendFailedConfig(configuration)
return nil
}
} else if err := fetchDeeper(logger, remote, gitCmd, repoInfo, int(math.Exp2(float64(depth-1)))); err != nil {
} else if err := fetchDeeper(futureLogger, remote, gitCmd, repoInfo, int(math.Exp2(float64(depth-1)))); err != nil {
appendFailedConfig(configuration)
return nil
}
Expand All @@ -227,6 +261,36 @@ func main() {
}
}

// lsRemoteRefs runs git ls-remote and returns a map of ref -> SHA.
// This does not require a local repo — git ls-remote works without one.
func lsRemoteRefs(logger *logrus.Entry, remote *url.URL) (map[string]string, error) {
Comment on lines +264 to +266
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New helper lsRemoteRefs introduces non-trivial parsing/retry behavior but has no unit tests. Since this package already has tests for related git helpers (pushBranch, fetchDeeper, fetchUnshallow), it would be good to add tests for lsRemoteRefs parsing (e.g., heads vs HEAD lines, empty output, malformed lines) and error propagation.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parsing logic is trivial (strings.Fields on ls-remote output), and the retry/backoff pattern is the same one used in gitCmdFunc which is also untested at that level. Unit testing this would require mocking exec.Command which adds complexity without much value — the interesting behavior (correct skipping of synced branches) is better validated by e2e tests against real repos.

refs := map[string]string{}
var b []byte
var err error
sleepyTime := time.Second
for i := 0; i < 3; i++ {
c := exec.Command("git", "ls-remote", "--heads", remote.String())
b, err = c.CombinedOutput()
if err == nil {
break
}
logger.WithError(err).Debugf("ls-remote failed (attempt %d/3), retrying...", i+1)
Comment on lines +273 to +277
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When ls-remote fails, the retry log and final error drop the command output (b), which makes diagnosing auth/host/key issues difficult. Consider including (possibly truncated) stdout/stderr in the debug log and/or the returned error, similar to how gitCmdFunc wraps errors with output.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remote URL contains credentials, and this function runs outside of gitCmdFunc which handles sanitization. Including raw command output here risks leaking the token into logs. The error from exec.Command already includes the exit code, and the debug-level retry log captures the error — that should be sufficient for diagnosing issues.

time.Sleep(sleepyTime)
sleepyTime *= 2
Comment on lines +278 to +279
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In lsRemoteRefs, the retry loop always sleeps after a failed attempt, including after the final (3rd) attempt. This adds unnecessary delay on permanent failures; consider only sleeping when another retry will occur (e.g., guard the sleep with if i < maxAttempts-1).

Suggested change
time.Sleep(sleepyTime)
sleepyTime *= 2
if i < 2 {
time.Sleep(sleepyTime)
sleepyTime *= 2
}

Copilot uses AI. Check for mistakes.
}
if err != nil {
logger.WithError(err).Error("Failed to run ls-remote after retries.")
return nil, fmt.Errorf("ls-remote failed: %w", err)
}
for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
parts := strings.Fields(line)
if len(parts) == 2 {
refs[parts[1]] = parts[0]
}
}
return refs, nil
}

func pushBranch(logger *logrus.Entry, remote *url.URL, futureBranch string, gitCmd gitCmd) (bool, error) {
command := []string{"push", remote.String(), fmt.Sprintf("FETCH_HEAD:refs/heads/%s", futureBranch)}
logger = logger.WithFields(logrus.Fields{"commands": fmt.Sprintf("git %s", strings.Join(command, " "))})
Expand Down
Loading