diff --git a/cmd/git-go-patch/README.md b/cmd/git-go-patch/README.md index 13733833..80106979 100644 --- a/cmd/git-go-patch/README.md +++ b/cmd/git-go-patch/README.md @@ -23,6 +23,18 @@ go install github.com/microsoft/go-infra/cmd/git-go-patch@latest > [!NOTE] > Make sure `git-go-patch` is accessible in your shell's `PATH` variable. You may need to add `$GOPATH/bin` to your `PATH`. Use `go env GOPATH` to locate it. +## Cross-Platform Shell Support + +The interactive shell feature (`rebase -shell`) works on both Unix and Windows: + +* **Unix/Linux/macOS**: Uses your `$SHELL` environment variable, defaults to `/bin/bash` +* **Windows**: Uses your `%COMSPEC%` environment variable, defaults to `cmd.exe` + +The shell prompt is automatically updated to show `(git-go-patch)` to indicate you're in the interactive mode: +* Unix shells: Updates the `PS1` environment variable +* Windows Command Prompt: Updates the `PROMPT` environment variable +* Powershell is not currently supported + Then, run the command to see the help documentation: ``` @@ -42,10 +54,30 @@ Sometimes you have to fix a bug in a patch file, add a new patch file, etc., and 1. Use `git go-patch apply` to apply patches onto the submodule as a series of commits. 1. Navigate into the submodule. 1. Edit the commits as desired. We recommend using an **interactive rebase** ([Pro Git guide](https://git-scm.com/book/en/v2/Git-Tools-Rewriting-History#_changing_multiple)) ([Git docs](https://git-scm.com/docs/git-rebase#_interactive_mode)) started by `git go-patch rebase`. A few recommended editing workflows are: + +### Option A: Streamlined workflow with `rebase -shell` + +Use `git go-patch rebase -shell` for a guided, automated workflow: + +1. Run `git go-patch apply` (if not already done) +1. Run `git go-patch rebase -shell` + * This opens a new shell session in the submodule directory + * The prompt shows `(git-go-patch)` to remind you of the special mode + * An interactive rebase starts automatically with the suggested base commit + * Make your changes, resolve conflicts, etc. + * Optionally run `code .` to open VS Code in the submodule context + * When finished, type `exit` to close the shell + * **Exit behavior:** + * Exit with code 0 (normal `exit`): Automatically runs `git go-patch extract` + * Exit with non-zero code (`exit 1`): Skips extraction, you handle it manually later + * Use `--skip-extract` flag: Disables extraction regardless of exit code + +### Option B: Manual workflow + * Commit-then-rebase: 1. Make some changes in the submodule and create commits. 1. Use `git go-patch rebase` to start an interactive rebase of the commits that include the patch changes and your changes. - * This command runs `git rebase -i` with with the necessary base commit. + * This command runs `git go-patch rebase` with with the necessary base commit. * Reorder the list to put each of your commits under the patch file that it applies to. * For each commit, choose `squash` if you want to edit the commit message or `fixup` if you don't. Use `pick` if you want to create a new patch file. 1. Follow the usual interactive rebase process. diff --git a/cmd/git-go-patch/rebase.go b/cmd/git-go-patch/rebase.go index 6eab9305..1debcd23 100644 --- a/cmd/git-go-patch/rebase.go +++ b/cmd/git-go-patch/rebase.go @@ -4,10 +4,12 @@ package main import ( + "flag" "fmt" "os" "os/exec" "path/filepath" + "runtime" "strings" "github.com/microsoft/go-infra/executil" @@ -27,6 +29,10 @@ You can use this command to apply fixup and squash commits generated by the "git "--fixup" and "--squash". To do this, configure Git using "git config --global rebase.autoSquash 1" before running this command. +With the -shell flag, this opens an interactive terminal session in the submodule directory and +automatically starts 'git rebase -i'. When you exit the terminal, it automatically runs 'git go-patch extract' +to save your changes back to patch files. + Be aware that editing earlier patch files may cause conflicts with later patch files. ` + repoRootSearchDescription, Handle: handleRebase, @@ -34,6 +40,9 @@ Be aware that editing earlier patch files may cause conflicts with later patch f } func handleRebase(p subcmd.ParseFunc) error { + shell := flag.Bool("shell", false, "Open an interactive shell in the submodule and automatically start 'git rebase -i'. When you exit, automatically runs 'git go-patch extract'.") + skipExtract := flag.Bool("skip-extract", false, "When using -shell, skip automatic extraction when exiting the shell session.") + if err := p(); err != nil { return err } @@ -46,9 +55,24 @@ func handleRebase(p subcmd.ParseFunc) error { since, err := readStatusFile(config.FullPrePatchStatusFilePath()) if err != nil { - return err + return fmt.Errorf("no pre-patch status found - run 'git go-patch apply' first: %w", err) + } + + if *shell { + // Check if we're already in a git-go-patch shell session (only for -shell mode) + if os.Getenv("GIT_GO_PATCH_INTERACTIVE") != "" { + return fmt.Errorf("already in a git-go-patch interactive shell session - exit the current session first") + } + + // Check if git is already in a rebase state + if err := checkForOngoingGitOperations(goDir); err != nil { + return err + } + + return handleInteractiveShell(goDir, since, *skipExtract) } + // Original rebase behaviorå cmd := exec.Command("git", "rebase", "-i", since) cmd.Stdin = os.Stdin cmd.Dir = goDir @@ -61,6 +85,191 @@ func handleRebase(p subcmd.ParseFunc) error { return nil } +func handleInteractiveShell(goDir, since string, skipExtract bool) error { + fmt.Printf("Starting interactive shell session in submodule directory: %s\n", goDir) + fmt.Printf("Base commit for rebase: %s\n", since) + fmt.Printf("\n%s=== GIT GO-PATCH SHELL MODE ===%s\n", + "\033[1;32m", "\033[0m") // Green bold text with reset + fmt.Printf("\nYou are now in a nested shell session. The prompt shows '(git-go-patch)' to remind you.\n") + fmt.Printf("\nWorkflow:\n") + fmt.Printf(" - An interactive rebase will start automatically\n") + fmt.Printf(" - Complete the rebase process (edit commits, resolve conflicts, etc.)\n") + fmt.Printf(" - Optionally run 'code .' to open VS Code in the submodule context\n") + fmt.Printf(" - Use any other git commands as needed\n") + fmt.Printf(" - When done, type 'exit' to return to the main session\n") + fmt.Printf(" - To skip extraction, exit with a non-zero code: 'exit 1'\n") + + if !skipExtract { + fmt.Printf("\nWhen you exit successfully (exit code 0), 'git go-patch extract' will run automatically.\n") + fmt.Printf("Exit with a non-zero code (e.g., 'exit 1') to skip automatic extraction.\n") + fmt.Printf("Use --skip-extract flag if you want to disable extraction regardless of exit code.\n") + } else { + fmt.Printf("\nAutomatic extraction is disabled via --skip-extract flag.\n") + fmt.Printf("You'll need to run 'git go-patch extract' manually when ready.\n") + } + + fmt.Printf("\n%s=== Press Enter to continue ===%s\n", "\033[1;33m", "\033[0m") // Yellow text + _, _ = fmt.Scanln() // Wait for user to press Enter + + // Determine the user's shell based on OS + var userShell string + if runtime.GOOS == "windows" { + userShell = os.Getenv("COMSPEC") + if userShell == "" { + userShell = "cmd.exe" // Default Windows shell + } + } else { + userShell = os.Getenv("SHELL") + if userShell == "" { + userShell = "/bin/bash" // Default Unix shell + } + } + + // Create a modified environment + env := os.Environ() + + // On Unix systems, update PS1 to show git-go-patch indicator + if runtime.GOOS != "windows" { + // Find and update PS1, or add it if it doesn't exist + ps1Updated := false + for i, envVar := range env { + if strings.HasPrefix(envVar, "PS1=") { + // Extract the current PS1 value + currentPS1, _ := strings.CutPrefix(envVar, "PS1=") + // Prepend our indicator + newPS1 := fmt.Sprintf("PS1=(git-go-patch) %s", currentPS1) + env[i] = newPS1 + ps1Updated = true + break + } + } + + // If PS1 wasn't found in environment, add a default one + if !ps1Updated { + env = append(env, "PS1=(git-go-patch) \\u@\\h:\\w\\$ ") + } + } else { + // On Windows, update the PROMPT environment variable + promptUpdated := false + for i, envVar := range env { + if strings.HasPrefix(envVar, "PROMPT=") { + // Extract the current PROMPT value + currentPrompt, _ := strings.CutPrefix(envVar, "PROMPT=") + // Prepend our indicator + newPrompt := fmt.Sprintf("PROMPT=(git-go-patch) %s", currentPrompt) + env[i] = newPrompt + promptUpdated = true + break + } + } + + // If PROMPT wasn't found, add a default one + if !promptUpdated { + env = append(env, "PROMPT=(git-go-patch) $P$G") + } + } + + // Add an environment variable to indicate we're in git-go-patch mode + env = append(env, "GIT_GO_PATCH_INTERACTIVE=1") + + // Start the interactive shell in the submodule directory + cmd := exec.Command(userShell) + cmd.Dir = goDir + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = env + + fmt.Printf("\nStarting shell in %s...\n", goDir) + fmt.Printf("Current working directory will be: %s\n", goDir) + fmt.Printf("Automatically starting 'git rebase -i %s'...\n\n", since) + + // Start the rebase automatically in the background + // We'll use a shell command to run the rebase and then keep the shell open + var shellCmd string + var shellArgs []string + + if runtime.GOOS == "windows" { + shellCmd = userShell + shellArgs = []string{"/k", fmt.Sprintf("git rebase -i %s", since)} + } else { + shellCmd = userShell + // Use shell -c to run the rebase command and then start an interactive shell + shellArgs = []string{"-c", fmt.Sprintf("git rebase -i %s; exec %s", since, userShell)} + } + + cmd = exec.Command(shellCmd, shellArgs...) + cmd.Dir = goDir + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = env + + err := cmd.Run() + shellExitedWithError := err != nil + + if shellExitedWithError { + fmt.Printf("\nShell exited with error: %v\n", err) + fmt.Printf("Exit code indicates to skip automatic extraction.\n") + } else { + fmt.Printf("\nShell session completed successfully.\n") + } + + // Skip extract if user requested it via flag or if shell exited with non-zero code + if skipExtract || shellExitedWithError { + if skipExtract { + fmt.Printf("\nSkipping automatic extraction as requested via -skip-extract flag.\n") + } else { + fmt.Printf("\nSkipping automatic extraction due to non-zero shell exit code.\n") + } + fmt.Printf("Remember to run 'git go-patch extract' manually when you're ready.\n") + return nil + } + + // Now automatically run extract + fmt.Printf("\nAutomatically running 'git go-patch extract' to save your changes...\n") + + // Call extract internally instead of shelling out + extractParseFunc := func() error { return nil } // No-op since we don't need to parse flags + if err := handleExtract(extractParseFunc); err != nil { + return fmt.Errorf("failed to run extract: %w", err) + } + + fmt.Printf("\n%s=== Shell session completed successfully! ===%s\n", + "\033[1;32m", "\033[0m") // Green bold text + fmt.Printf("Your changes have been extracted to patch files.\n") + + return nil +} + +func checkForOngoingGitOperations(gitDir string) error { + // Check if git is in the middle of a rebase + rebaseHeadFile := filepath.Join(gitDir, ".git", "rebase-merge", "head-name") + if _, err := os.Stat(rebaseHeadFile); err == nil { + return fmt.Errorf("git is currently in the middle of a rebase - complete or abort it first with 'git rebase --continue' or 'git rebase --abort'") + } + + // Check for interactive rebase + rebaseInteractiveFile := filepath.Join(gitDir, ".git", "rebase-apply", "applying") + if _, err := os.Stat(rebaseInteractiveFile); err == nil { + return fmt.Errorf("git is currently in the middle of an interactive rebase - complete or abort it first") + } + + // Check if git is in the middle of a merge + mergeHeadFile := filepath.Join(gitDir, ".git", "MERGE_HEAD") + if _, err := os.Stat(mergeHeadFile); err == nil { + return fmt.Errorf("git is currently in the middle of a merge - complete or abort it first with 'git merge --continue' or 'git merge --abort'") + } + + // Check if git is in the middle of a cherry-pick + cherryPickHeadFile := filepath.Join(gitDir, ".git", "CHERRY_PICK_HEAD") + if _, err := os.Stat(cherryPickHeadFile); err == nil { + return fmt.Errorf("git is currently in the middle of a cherry-pick - complete or abort it first with 'git cherry-pick --continue' or 'git cherry-pick --abort'") + } + + return nil +} + func warnIfOutsideSubmodule(submoduleDir string) { const unexpected = "WARNING: Unexpected error while checking if working dir is inside submodule: "