Skip to content
Open
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
34 changes: 33 additions & 1 deletion cmd/git-go-patch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also doesn't work if on Linux if using PROMPT_COMMAND because it overwrites PS1 all the time.


Then, run the command to see the help documentation:

```
Expand All @@ -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.
Expand Down
211 changes: 210 additions & 1 deletion cmd/git-go-patch/rebase.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
package main

import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"

"github.com/microsoft/go-infra/executil"
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
Copy link
Copy Markdown
Member

@dagood dagood Jul 21, 2025

Choose a reason for hiding this comment

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

I (slightly) preferred git go-patch shell rather than tacking this onto rebase, but wasn't sure why... now I realize I don't think it's always useful to immediately run a rebase. It seems like it would only make sense to rebase immediately if you're using edit/break and other rebase commands directly, but I rarely do that. I rebase after I've poked around and figured out what I want to do, and probably integrate some new commits back into the sequence of patches.

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.

git go-patch shell could accept git go-patch shell -rebase as a way to keep this functionality (which I could see being useful depending on what kind of patches you're doing), but in a more flexible way.

An -apply flag would also fit in, that way.


// 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
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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: "

Expand Down
Loading