-
Notifications
You must be signed in to change notification settings - Fork 12
cmd/git-go-patch: enhance git-go-patch with robust patch application and streamlined interactive rebase workflow #281
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also doesn't work if on Linux if using |
||
|
|
||
| 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. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,10 +4,12 @@ | |
| package main | ||
|
|
||
| import ( | ||
| "flag" | ||
| "fmt" | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "runtime" | ||
| "strings" | ||
|
|
||
| "github.com/microsoft/go-infra/executil" | ||
|
|
@@ -27,13 +29,20 @@ 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, | ||
| }) | ||
| } | ||
|
|
||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I (slightly) preferred Or maybe I only have a new patch to add, no rebase at all. Splitting it out simplifies each command and removes the reason to have this "just press enter" step.
An |
||
|
|
||
| // 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 | ||
| } | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. File issue about PowerShell support (on Windows and on Linux)? |
||
|
|
||
| // 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: " | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.