Skip to content
Merged
Show file tree
Hide file tree
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
18 changes: 13 additions & 5 deletions gitcmds/gitcmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions internal/systrun/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -114,6 +116,8 @@ func (s SyncState) String() string {
return "SyncStateBothChangedConflict"
case SyncStateDoesntTrackOrigin:
return "SyncStateDoesntTrackOrigin"
case SyncStateMainBranchConflict:
return "SyncStateMainBranchConflict"
default:
return "unknown"
}
Expand Down
93 changes: 93 additions & 0 deletions internal/systrun/impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions sys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading