From df85d29c62e1770b91253e3c200454279d7cdd80 Mon Sep 17 00:00:00 2001 From: Alisher Nurmanov Date: Wed, 5 Nov 2025 19:00:25 +0500 Subject: [PATCH] [AIR-1959] qs: handle non-mergable main properly --- gitcmds/gitcmds.go | 18 +++++--- internal/commands/dev.go | 2 +- internal/systrun/const.go | 4 ++ internal/systrun/impl.go | 93 +++++++++++++++++++++++++++++++++++++++ sys_test.go | 35 +++++++++++++++ 5 files changed, 146 insertions(+), 6 deletions(-) diff --git a/gitcmds/gitcmds.go b/gitcmds/gitcmds.go index ab83546..980ebfa 100644 --- a/gitcmds/gitcmds.go +++ b/gitcmds/gitcmds.go @@ -1683,7 +1683,7 @@ func showWorkaroundIfConflict(wd, mainBranch, stderr string) error { Command("git", "rebase", "--abort"). WorkingDir(wd).RunToStrings() // Provide instructions to reset and force-push - fmt.Printf("A conflict is detected in %s branch. To resolve the conflict, run the following commands to reset your %s branch to match upstream/%s and force-push the changes to your fork:\n\n", mainBranch, mainBranch, mainBranch) + fmt.Printf("A conflict is detected in %s branch. To resolve the conflict, you CAN run the following commands to reset your %s branch to match upstream/%s and force-push the changes to your fork:\n\n", mainBranch, mainBranch, mainBranch) fmt.Print("git checkout main\ngit fetch upstream\ngit reset --hard upstream/main\ngit push origin main --force\n\n") fmt.Print("Warning: This will overwrite your main branch on origin with the state of upstream/main, discarding any local or remote changes that diverge from upstream. Make sure you have backed up any important work before proceeding.\n\n") @@ -1812,9 +1812,7 @@ func isOldStyledBranch(notes []string) bool { func GetIssueNameByNumber(issueNum string, parentrepo string) (string, error) { stdout, stderr, err := new(exec.PipedExec). - Command("gh", "issue", "view", issueNum, "--repo", parentrepo). - Command("grep", "title:"). - Command("gawk", "{ $1=\"\"; print substr($0, 2) }"). + Command("gh", "issue", "view", issueNum, "--repo", parentrepo, "--json", "title"). RunToStrings() if err != nil { logger.Verbose(stderr) @@ -1825,7 +1823,17 @@ func GetIssueNameByNumber(issueNum string, parentrepo string) (string, error) { return "", fmt.Errorf("failed to get issue name by number: %w", err) } - return strings.TrimSpace(stdout), nil + + type issueDetails struct { + Title string `json:"title"` + } + + var issue issueDetails + if err := json.Unmarshal([]byte(stdout), &issue); err != nil { + return "", fmt.Errorf("failed to parse issue title JSON: %w", err) + } + + return issue.Title, nil } // CreateDevBranch creates dev branch and pushes it to origin diff --git a/internal/commands/dev.go b/internal/commands/dev.go index 2090741..b3d7dd9 100644 --- a/internal/commands/dev.go +++ b/internal/commands/dev.go @@ -274,7 +274,7 @@ func argContainsGithubIssueLink(wd string, args ...string) (issueNum int, issueU func checkIssueLink(wd, issueURL string) error { // This function checks if the provided issueURL is a valid GitHub issue link via `gh issue view`. - cmd := exec.Command("gh", "issue", "view", issueURL) + cmd := exec.Command("gh", "issue", "view", "--json", "title,state", issueURL) cmd.Dir = wd if _, err := cmd.Output(); err != nil { return fmt.Errorf("failed to check issue link: %w", err) diff --git a/internal/systrun/const.go b/internal/systrun/const.go index 896ede6..06cc167 100644 --- a/internal/systrun/const.go +++ b/internal/systrun/const.go @@ -60,6 +60,8 @@ const ( SyncStateDoesntTrackOrigin // SyncStateCloneIsAheadOfFork means that the clone repo is ahead of the fork repo SyncStateCloneIsAheadOfFork + // SyncStateMainBranchConflict means that the main branch in fork has diverged from upstream and cannot be rebased + SyncStateMainBranchConflict ) const ( @@ -114,6 +116,8 @@ func (s SyncState) String() string { return "SyncStateBothChangedConflict" case SyncStateDoesntTrackOrigin: return "SyncStateDoesntTrackOrigin" + case SyncStateMainBranchConflict: + return "SyncStateMainBranchConflict" default: return "unknown" } diff --git a/internal/systrun/impl.go b/internal/systrun/impl.go index b35a5e8..8bd29e4 100644 --- a/internal/systrun/impl.go +++ b/internal/systrun/impl.go @@ -973,6 +973,8 @@ func (st *SystemTest) processSyncState() error { false, "", ) + case SyncStateMainBranchConflict: + return st.setMainBranchConflict() default: return fmt.Errorf("not supported yet sync state: %s", st.cfg.SyncState) } @@ -1090,6 +1092,97 @@ func (st *SystemTest) setSyncState( return nil } +// setMainBranchConflict creates a conflict scenario in the main branch +// This simulates the case where the fork's main branch has diverged from upstream/main +// and cannot be rebased cleanly (AIR-1959) +func (st *SystemTest) setMainBranchConflict() error { + mainBranch, err := gitcmds.GetMainBranch(st.cloneRepoPath) + if err != nil { + return fmt.Errorf("failed to get main branch: %w", err) + } + + // Step 1: Make a conflicting change in upstream/main + // We'll modify the same file in both upstream and fork to create a conflict + upstreamRepoPath, err := os.MkdirTemp("", "qs-test-upstream-*") + if err != nil { + return fmt.Errorf("failed to create temp upstream path: %w", err) + } + defer os.RemoveAll(upstreamRepoPath) + + // Clone upstream repo + upstreamURL := gitcmds.BuildRemoteURL( + st.cfg.GHConfig.UpstreamAccount, + st.cfg.GHConfig.UpstreamToken, + st.repoName, + true, + ) + cloneCmd := exec.Command(git, "clone", upstreamURL, upstreamRepoPath) + cloneCmd.Env = append(os.Environ(), fmt.Sprintf(formatGithubTokenEnv, st.cfg.GHConfig.UpstreamToken)) + if output, err := cloneCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to clone upstream repo: %w, output: %s", err, output) + } + + // Make a change in upstream/main + conflictFilePath := filepath.Join(upstreamRepoPath, "conflict.txt") + if err := os.WriteFile(conflictFilePath, []byte("Upstream version of conflict file\n"), commitFilePerm); err != nil { + return fmt.Errorf("failed to create conflict file in upstream: %w", err) + } + + addCmd := exec.Command(git, "-C", upstreamRepoPath, "add", "conflict.txt") + if err := addCmd.Run(); err != nil { + return fmt.Errorf("failed to add conflict file in upstream: %w", err) + } + + commitCmd := exec.Command(git, "-C", upstreamRepoPath, "commit", "-m", "Upstream change to conflict.txt") + if err := commitCmd.Run(); err != nil { + return fmt.Errorf("failed to commit in upstream: %w", err) + } + + pushCmd := exec.Command(git, "-C", upstreamRepoPath, "push", "origin", mainBranch) + pushCmd.Env = append(os.Environ(), fmt.Sprintf(formatGithubTokenEnv, st.cfg.GHConfig.UpstreamToken)) + if output, err := pushCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to push to upstream: %w, output: %s", err, output) + } + + // Step 2: Make a conflicting change in fork/main (in the local clone) + // First, ensure we're on the main branch + if err := checkoutOnBranch(st.cloneRepoPath, mainBranch); err != nil { + return err + } + + // Create a conflicting change in the same file + conflictFilePathFork := filepath.Join(st.cloneRepoPath, "conflict.txt") + if err := os.WriteFile(conflictFilePathFork, []byte("Fork version of conflict file\n"), commitFilePerm); err != nil { + return fmt.Errorf("failed to create conflict file in fork: %w", err) + } + + addCmdFork := exec.Command(git, "-C", st.cloneRepoPath, "add", "conflict.txt") + if err := addCmdFork.Run(); err != nil { + return fmt.Errorf("failed to add conflict file in fork: %w", err) + } + + commitCmdFork := exec.Command(git, "-C", st.cloneRepoPath, "commit", "-m", "Fork change to conflict.txt") + if err := commitCmdFork.Run(); err != nil { + return fmt.Errorf("failed to commit in fork: %w", err) + } + + // Push to fork/main + pushCmdFork := exec.Command(git, "-C", st.cloneRepoPath, "push", "origin", mainBranch) + pushCmdFork.Env = append(os.Environ(), fmt.Sprintf(formatGithubTokenEnv, st.cfg.GHConfig.ForkToken)) + if output, err := pushCmdFork.CombinedOutput(); err != nil { + return fmt.Errorf("failed to push to fork: %w, output: %s", err, output) + } + + // Step 3: Fetch upstream to ensure the conflict will be detected + fetchCmd := exec.Command(git, "-C", st.cloneRepoPath, "fetch", "upstream") + fetchCmd.Env = append(os.Environ(), fmt.Sprintf(formatGithubTokenEnv, st.cfg.GHConfig.ForkToken)) + if output, err := fetchCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to fetch upstream: %w, output: %s", err, output) + } + + return nil +} + // commitFiles creates files in the cloned repository and commits them // Parameters: // - headerOfFiles: optional header to be added to each file diff --git a/sys_test.go b/sys_test.go index 093a344..741391f 100644 --- a/sys_test.go +++ b/sys_test.go @@ -878,6 +878,41 @@ func TestDev_CustomName_NoUpstream(t *testing.T) { require.NoError(err) } +// TestDev_MainBranchConflict tests AIR-1959: handling non-mergeable main branch properly +// This test verifies that when there's a conflict in the main branch (local main diverged +// from upstream/main and cannot be rebased), the tool provides a helpful workaround message +func TestDev_MainBranchConflict(t *testing.T) { + require := require.New(t) + + testConfig := &systrun.TestConfig{ + TestID: strings.ToLower(t.Name()), + GHConfig: getGithubConfig(t), + CommandConfig: &systrun.CommandConfig{ + Command: "dev", + Stdin: "y", + }, + ClipboardContent: systrun.ClipboardContentGithubIssue, + UpstreamState: systrun.RemoteStateOK, + ForkState: systrun.RemoteStateOK, + SyncState: systrun.SyncStateMainBranchConflict, + NeedCollaboration: true, + ExpectedStdout: []string{ + "A conflict is detected in main branch", + "you CAN run the following commands", + "git checkout main", + "git fetch upstream", + "git reset --hard upstream/main", + "git push origin main --force", + "Warning: This will overwrite your main branch", + }, + } + + sysTest := systrun.New(t, testConfig) + err := sysTest.Run() + // The command should fail with an error because of the conflict + require.Error(err) +} + // getGithubConfig retrieves GitHub credentials from environment variables // and skips the test if any credentials are missing func getGithubConfig(t *testing.T) systrun.GithubConfig {