├── .gitignore ├── go.mod ├── Makefile ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── README.md ├── main_test.go └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | releases 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kevinburke/differ 2 | 3 | go 1.21 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | vet: 2 | go vet -trimpath ./... 3 | staticcheck ./... 4 | 5 | test: vet 6 | go test -trimpath -race ./... 7 | 8 | release: 9 | bump_version minor main.go 10 | git push origin --tags 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.22.x, 1.23.x, 1.24.x] 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Install Go 11 | uses: WillAbides/setup-go-faster@main 12 | with: 13 | go-version: ${{ matrix.go-version }} 14 | - uses: actions/checkout@v4 15 | with: 16 | path: './src/github.com/kevinburke/differ' 17 | # staticcheck needs this for GOPATH 18 | - run: echo "GOPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV 19 | - run: echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" >> $GITHUB_ENV 20 | - run: echo "GO111MODULE=on" >> $GITHUB_ENV 21 | - name: Run tests 22 | run: | 23 | go install honnef.co/go/tools/cmd/staticcheck@latest 24 | make test 25 | working-directory: './src/github.com/kevinburke/differ' 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Kevin Burke 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # differ 2 | 3 | Differ makes it easy to run a command and error if it generated a change in a 4 | git worktree. You can use this in tests or the build process to verify that 5 | a given build step was run correctly. For example you may want to verify that 6 | all files in a Go project have run `go fmt`. Run: 7 | 8 | ``` 9 | differ go fmt ./... 10 | ``` 11 | 12 | This will execute `go fmt ./...` and error if it modifies any file tracked by 13 | Git. 14 | 15 | Other uses: 16 | 17 | - Restore and revendor all vendored libraries and error if a git diff is 18 | generated. 19 | - Check whether new CSS files have been generated from SCSS, HTML files from 20 | Markdown, JS files from Coffeescript, or any other compilation step. 21 | 22 | ## Usage 23 | 24 | Run the same command you would usually run but put `differ` before it, for 25 | example: 26 | 27 | ``` 28 | differ go generate ./... 29 | ``` 30 | 31 | differ will exit with a non-zero return code if: 32 | 33 | - your command exits with an error 34 | 35 | - "git status" errors, for example if you run it in a directory that is not 36 | a Git repository. 37 | 38 | - "git status" says that there are untracked or modified files present 39 | 40 | ## Installation 41 | 42 | Find your target operating system (darwin, windows, linux) and desired bin 43 | directory, and modify the command below as appropriate: 44 | 45 | curl --silent --location --output /usr/local/bin/differ https://github.com/kevinburke/differ/releases/download/1.2/differ-linux-amd64 && chmod 755 /usr/local/bin/differ 46 | 47 | On Travis, you may want to create `$HOME/bin` and write to that, since 48 | /usr/local/bin isn't writable with their container-based infrastructure. 49 | 50 | The latest version is 1.2. 51 | 52 | If you have a Go development environment, you can also install via source code: 53 | 54 | go install github.com/kevinburke/differ@1.2 55 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "testing" 10 | ) 11 | 12 | func writeFile(t *testing.T, path string, content string) { 13 | t.Helper() 14 | 15 | // Ensure the directory exists 16 | dir := filepath.Dir(path) 17 | if err := os.MkdirAll(dir, 0755); err != nil { 18 | t.Fatalf("Failed to create directory %s: %v", dir, err) 19 | } 20 | 21 | // Write the file 22 | if err := os.WriteFile(path, []byte(content), 0644); err != nil { 23 | t.Fatalf("Failed to write file %s: %v", path, err) 24 | } 25 | } 26 | 27 | func runCmd(t *testing.T, dir string, command string, args ...string) { 28 | t.Helper() 29 | 30 | cmd := exec.Command(command, args...) 31 | cmd.Dir = dir 32 | 33 | output, err := cmd.CombinedOutput() 34 | if err != nil { 35 | t.Fatalf("Command failed: %s %v\nOutput: %s\nError: %v", 36 | command, args, string(output), err) 37 | } 38 | } 39 | 40 | func TestIntegration(t *testing.T) { 41 | tmpDir := t.TempDir() 42 | 43 | // Initialize git repo 44 | runCmd(t, tmpDir, "git", "init") 45 | runCmd(t, tmpDir, "git", "config", "user.email", "test@example.com") 46 | runCmd(t, tmpDir, "git", "config", "user.name", "Test User") 47 | 48 | // Create and commit initial file 49 | writeFile(t, filepath.Join(tmpDir, "test.txt"), "initial content") 50 | runCmd(t, tmpDir, "git", "add", "test.txt") 51 | runCmd(t, tmpDir, "git", "commit", "-m", "initial commit") 52 | 53 | // Test 1: Clean repo - should succeed 54 | buf := new(bytes.Buffer) 55 | // echo goes to stdout, so buffer should be clean even though there's output 56 | code := run(context.Background(), tmpDir, buf, []string{"differ", "echo", "hello"}) 57 | if code != 0 && buf.Len() > 0 { 58 | t.Errorf("Expected success in clean repo, got: %v", code) 59 | } 60 | 61 | // Test 2: Untracked file - should fail 62 | buf.Reset() 63 | writeFile(t, filepath.Join(tmpDir, "new.txt"), "new file") 64 | code = run(context.Background(), tmpDir, buf, []string{"differ", "echo", "hello"}) 65 | if code == 0 { 66 | t.Errorf("Expected failure with untracked file, got code %d, output: %v", code, buf.String()) 67 | } 68 | if !bytes.Contains(buf.Bytes(), []byte("Untracked or modified files present")) { 69 | t.Errorf("Expected error message about untracked files, got: %s", buf.String()) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "os" 11 | "os/exec" 12 | "strings" 13 | ) 14 | 15 | const usage = `differ [utility [argument ...]] 16 | 17 | Execute utility with the given arguments. Then exit with an error if git reports 18 | there are untracked changes. 19 | ` 20 | 21 | const Version = "1.1" 22 | 23 | func init() { 24 | flag.Usage = func() { 25 | os.Stderr.WriteString(usage) 26 | } 27 | } 28 | 29 | func getGitDiff(ctx context.Context) string { 30 | diffBuf := new(bytes.Buffer) 31 | diffCmd := exec.CommandContext(ctx, "git", "diff", "--no-color") 32 | diffCmd.Stdout = diffBuf 33 | diffCmd.Stderr = diffBuf 34 | if diffErr := diffCmd.Run(); diffErr != nil { 35 | return "" 36 | } 37 | if diffBuf.Len() == 0 { 38 | return "" 39 | } 40 | bs := bufio.NewScanner(diffBuf) 41 | diffOutput := strings.Builder{} 42 | for i := 0; i < 20 && bs.Scan(); i++ { 43 | diffOutput.Write(bs.Bytes()) 44 | diffOutput.WriteByte('\n') 45 | } 46 | _ = bs.Err() 47 | return "\nFirst few lines of the git diff:\n" + diffOutput.String() 48 | } 49 | 50 | func run(ctx context.Context, wd string, stderr io.Writer, args []string) int { 51 | if wd != "" { 52 | pwd, err := os.Getwd() 53 | if err != nil { 54 | fmt.Fprintf(stderr, "Error getting current working directory: %v\n", err) 55 | return 2 56 | } 57 | if err := os.Chdir(wd); err != nil { 58 | fmt.Fprintf(stderr, "Error changing working directory to %q: %v\n", wd, err) 59 | return 2 60 | } 61 | defer os.Chdir(pwd) 62 | } 63 | var cmd *exec.Cmd 64 | if len(args) == 2 { 65 | cmd = exec.CommandContext(ctx, args[1]) 66 | } else { 67 | cmd = exec.CommandContext(ctx, args[1], args[2:]...) 68 | } 69 | // todo encapsulation broken here 70 | cmd.Stdin = os.Stdin 71 | cmd.Stdout = os.Stdout 72 | cmd.Stderr = os.Stderr 73 | if err := cmd.Run(); err != nil { 74 | fmt.Fprintf(stderr, "\n\nthe %q command exited with an error; quitting\n", args[1]) 75 | // actually really difficult to pass through the return code from Run so 76 | // just do 2 77 | return 2 78 | } 79 | gitCmd := exec.CommandContext(ctx, "git", "status", "--porcelain") 80 | buf := new(bytes.Buffer) 81 | gitCmd.Stdout = buf 82 | gitCmd.Stderr = buf 83 | if err := gitCmd.Run(); err != nil { 84 | fmt.Fprintf(stderr, ` 85 | differ: Error running git status --porcelain: %v 86 | 87 | Output: %s`, err, buf.String()) 88 | return 2 89 | } 90 | if buf.Len() > 0 { 91 | diff := getGitDiff(ctx) 92 | fmt.Fprintf(stderr, ` 93 | Untracked or modified files present after running '%s': 94 | 95 | %s%s 96 | The command should not generate a diff. Please fix the problem and try again. 97 | `, strings.Join(args[1:], " "), buf.String(), diff) 98 | return 2 99 | } 100 | return 0 101 | } 102 | 103 | func main() { 104 | vsn := flag.Bool("v", false, "Print the version") 105 | flag.Parse() 106 | ctx, cancel := context.WithCancel(context.Background()) 107 | defer cancel() 108 | if len(os.Args) <= 1 { 109 | flag.Usage() 110 | os.Exit(2) 111 | } 112 | if *vsn { 113 | fmt.Fprintf(os.Stdout, "differ version %s\n", Version) 114 | os.Exit(0) 115 | } 116 | code := run(ctx, "", os.Stderr, os.Args) 117 | os.Exit(code) 118 | } 119 | --------------------------------------------------------------------------------