├── .github └── workflows │ └── go.yml ├── LICENSE.md ├── README.md ├── go.mod ├── install.sh ├── main.go └── terminal.gif /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | release-linux-amd64: 12 | name: release linux/amd64 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | # build and publish in parallel: linux/386, linux/amd64, linux/arm64, windows/386, windows/amd64, darwin/amd64, darwin/arm64 17 | goos: [linux, windows, darwin] 18 | goarch: ["386", amd64, arm64] 19 | exclude: 20 | - goarch: "386" 21 | goos: darwin 22 | - goarch: arm64 23 | goos: windows 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: wangyoucao577/go-release-action@v1.51 27 | with: 28 | github_token: ${{ secrets.GITHUB_TOKEN }} 29 | goos: ${{ matrix.goos }} 30 | goarch: ${{ matrix.goarch }} 31 | goversion: "https://dl.google.com/go/go1.23.2.linux-amd64.tar.gz" 32 | binary_name: "git-cleaner" -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2024 Yoan Bernabeu 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Git Cleaner - Clean Your Git History ! 2 | 3 | ![Git Cleaner Gif](terminal.gif) 4 | 5 | Git Cleaner is a command-line tool designed to help you easily remove files from your Git history. It allows you to delete files that should no longer be tracked, even if they are present in previous commits. The tool provides an interactive experience to ensure safe removal, making your repository cleaner and smaller. 6 | 7 | ## Features 8 | 9 | - File History Removal: Search for and remove a specific file from the entire Git history. 10 | 11 | - Automatic .gitignore Update: If the specified file exists in the current directory, it is automatically added to the .gitignore file to prevent future tracking. 12 | 13 | - Interactive Confirmation: Git Cleaner will prompt for user confirmation before making any destructive changes. 14 | 15 | - Git Filter-Repo vs Native Git: Git Cleaner supports by default git-filter-repo, but if it is not available, it will use native Git commands to remove the file from the history. 16 | 17 | ## Installation 18 | 19 | > **Recommended**: Install Git Filter-Repo to ensure the best performance: [Git Filter-Repo Installation Guide](https://github.com/newren/git-filter-repo/blob/main/INSTALL.md) 20 | 21 | To install Git Cleaner, run the following command: 22 | 23 | ```bash 24 | curl -sSL https://raw.githubusercontent.com/yoanbernabeu/GitCleaner/main/install.sh | bash 25 | ``` 26 | 27 | ## Usage 28 | 29 | To use Git Cleaner, run the following command (replace `` with the path of the file you want to remove from the Git history): 30 | 31 | ```bash 32 | git-cleaner --file 33 | ``` 34 | 35 | Replace `` with the path of the file you want to remove from the Git history. 36 | 37 | ### Example 38 | 39 | ```bash 40 | git-cleaner --file secrets.txt 41 | ``` 42 | 43 | This command will search for all the commits containing `secrets.txt` and then prompt you to confirm its removal from the Git history. 44 | 45 | ## How It Works 46 | 47 | 1. Search for Commits: It searches for all the commits containing the file. 48 | 49 | 2. User Confirmation: It provides a list of commits and asks for user confirmation to proceed with removing the file from the history. 50 | 51 | 3. Remove File from History: If confirmed, it removes the file from the Git history using either git-filter-repo or native Git commands. 52 | 53 | 4. Add to .gitignore: If the file is present, it will be added to .gitignore to ensure it won't be tracked in future commits. 54 | 55 | ## Important Notes 56 | 57 | - **Force Push Required**: After running Git Cleaner, you need to force push to update the remote repository: 58 | 59 | ```bash 60 | git push origin --force --all 61 | git push origin --force --tags 62 | ``` 63 | 64 | - **Destructive Operation**: Removing files from Git history is a destructive operation. It rewrites the commit history, so be sure all collaborators are aware and understand the implications. 65 | 66 | ## Requirements 67 | 68 | - **Git**: Git must be installed and accessible from the command line. 69 | 70 | - **git-filter-repo** (Recommended): If git-filter-repo is not available, Git Cleaner will use native Git commands to remove the file from the history. 71 | 72 | - **Go**: The Go runtime is required if you want to build and run the tool from source. 73 | 74 | ## Disclaimer 75 | 76 | Git Cleaner rewrites the Git commit history, which can be risky if not done properly. It is recommended to make backups before running this tool and to coordinate with your team if you are working in a shared repository. 77 | 78 | ## License 79 | 80 | This project is open-source and available under the MIT License. -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yoanbernabeu/GitCleaner 2 | 3 | go 1.23.2 4 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Set variables 4 | REPO_NAME="yoanbernabeu/GitCleaner" 5 | BINARY_NAME="git-cleaner" 6 | VERSION="0.2.1" 7 | 8 | # Determine the OS and architecture 9 | OS=$(uname | tr '[:upper:]' '[:lower:]') 10 | ARCH=$(uname -m) 11 | 12 | if [[ "$OS" == "darwin" && "$ARCH" == "arm64" ]]; then 13 | ARCH="arm64" # Apple M1 chip 14 | elif [[ "$ARCH" == "x86_64" ]]; then 15 | ARCH="amd64" 16 | elif [[ "$ARCH" == "aarch64" || "$ARCH" == "armv8l" ]]; then 17 | ARCH="arm64" 18 | elif [[ "$ARCH" == "i386" || "$ARCH" == "i686" ]]; then 19 | ARCH="386" 20 | fi 21 | 22 | # Delete previous installation 23 | if [ -f /usr/local/bin/$BINARY_NAME ]; then 24 | echo "Removing previous installation..." 25 | sudo rm /usr/local/bin/$BINARY_NAME 26 | fi 27 | 28 | # Construct the download URL 29 | DOWNLOAD_URL="https://github.com/$REPO_NAME/releases/download/$VERSION/$BINARY_NAME-$VERSION-$OS-$ARCH.tar.gz" 30 | 31 | # Download the archive 32 | echo "Downloading $BINARY_NAME from $DOWNLOAD_URL..." 33 | curl -L $DOWNLOAD_URL -o $BINARY_NAME.tar.gz 34 | 35 | # Extract the archive 36 | echo "Extracting $BINARY_NAME..." 37 | tar -xzf $BINARY_NAME.tar.gz 38 | 39 | # Remove the archive 40 | rm $BINARY_NAME.tar.gz 41 | 42 | # Check if the binary exists 43 | if [ ! -f $BINARY_NAME ]; then 44 | echo "Error: Binary not found. Please try again." 45 | exit 1 46 | fi 47 | 48 | # Add the binary to the PATH 49 | echo "Adding $BINARY_NAME to PATH..." 50 | sudo mv $BINARY_NAME /usr/local/bin 51 | 52 | # Check if the binary was added successfully 53 | if [ ! -f /usr/local/bin/$BINARY_NAME ]; then 54 | echo "Error: Binary not added to PATH. Please try again." 55 | exit 1 56 | fi 57 | 58 | # Print success message 59 | echo "Installation complete! $BINARY_NAME is now available in your PATH." -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | func main() { 15 | // Fun ASCII Art Header 16 | fmt.Println("\n==============================") 17 | fmt.Println("Git Cleaner - Simplify your Git history!") 18 | fmt.Println("Removing files from Git history safely and effectively.") 19 | fmt.Println("==============================") 20 | 21 | // Check if we are in a directory with a git repository 22 | if !isGitRepository() { 23 | log.Fatalf("This directory is not a Git repository. Please run the command inside a valid Git repository.") 24 | } 25 | 26 | // Use the flag package to parse arguments 27 | filePath := flag.String("file", "", "Path of the file to remove from Git history") 28 | flag.Parse() 29 | 30 | if *filePath == "" { 31 | fmt.Println("Usage: git-cleaner --file ") 32 | os.Exit(1) 33 | } 34 | 35 | cleanedPath := filepath.ToSlash(filepath.Clean(*filePath)) 36 | 37 | // Search for commits containing the file 38 | fmt.Println("Searching for commits containing the file...") 39 | commits, err := getCommitsWithFile(cleanedPath) 40 | if err != nil { 41 | log.Fatalf("Error searching for commits: %v", err) 42 | } 43 | 44 | if len(commits) == 0 { 45 | fmt.Printf("The file '%s' was not found in the Git history.\n", cleanedPath) 46 | os.Exit(0) 47 | } 48 | 49 | // Display a summary of the commits 50 | fmt.Printf("The file '%s' is present in %d commit(s):\n", cleanedPath, len(commits)) 51 | for _, commit := range commits { 52 | fmt.Println(commit) 53 | } 54 | 55 | // Ask for user confirmation 56 | if !getUserConfirmation() { 57 | fmt.Println("Operation canceled by the user.") 58 | os.Exit(0) 59 | } 60 | 61 | // Determine if git-filter-repo is available 62 | if isGitFilterRepoAvailable() { 63 | removeFileWithFilterRepo(cleanedPath) 64 | } else { 65 | removeFileFromHistoryNative(cleanedPath) 66 | } 67 | 68 | // Add the file to .gitignore after removing it from history 69 | addFileToGitignore(cleanedPath) 70 | 71 | fmt.Println("The file has been removed from the Git history.") 72 | fmt.Println("Don't forget to force update the remote references with:") 73 | fmt.Println("git push origin --force --all") 74 | fmt.Println("git push origin --force --tags") 75 | } 76 | 77 | // getCommitsWithFile returns a list of commits where the file is present 78 | func getCommitsWithFile(filePath string) ([]string, error) { 79 | cmd := exec.Command("git", "log", "--pretty=format:%h %ad | %s%d [%an]", "--date=short", "--", filePath) 80 | output, err := cmd.Output() 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | lines := strings.Split(string(output), "\n") 86 | var commits []string 87 | for _, line := range lines { 88 | if strings.TrimSpace(line) != "" { 89 | commits = append(commits, line) 90 | } 91 | } 92 | return commits, nil 93 | } 94 | 95 | // getUserConfirmation asks for user confirmation 96 | func getUserConfirmation() bool { 97 | scanner := bufio.NewScanner(os.Stdin) 98 | for { 99 | fmt.Print("Do you really want to remove this file from the Git history? (yes/no): ") 100 | scanner.Scan() 101 | response := strings.ToLower(strings.TrimSpace(scanner.Text())) 102 | if response == "yes" || response == "y" { 103 | return true 104 | } else if response == "no" || response == "n" { 105 | return false 106 | } else { 107 | fmt.Println("Please answer 'yes' or 'no'.") 108 | } 109 | } 110 | } 111 | 112 | // removeFileWithFilterRepo uses git-filter-repo to remove the file from history 113 | func removeFileWithFilterRepo(filePath string) { 114 | cmd := exec.Command("git", "filter-repo", "--path", filePath, "--invert-paths", "--force") 115 | cmd.Stdout = os.Stdout 116 | cmd.Stderr = os.Stderr 117 | 118 | fmt.Println("Removing the file from the Git history using git-filter-repo...") 119 | 120 | if err := cmd.Run(); err != nil { 121 | log.Fatalf("Error removing the file with git-filter-repo: %v", err) 122 | } 123 | } 124 | 125 | // removeFileFromHistoryNative uses native git commands to remove the file from history 126 | func removeFileFromHistoryNative(filePath string) { 127 | // Set environment variable globally for the process 128 | if err := os.Setenv("FILTER_BRANCH_SQUELCH_WARNING", "1"); err != nil { 129 | log.Fatalf("Error setting environment variable: %v", err) 130 | } 131 | 132 | cmd := exec.Command("git", "filter-branch", "--force", "--index-filter", fmt.Sprintf("git rm --cached --ignore-unmatch '%s'", filePath), "--prune-empty", "--tag-name-filter", "cat", "--", "--all") 133 | cmd.Stdout = os.Stdout 134 | cmd.Stderr = os.Stderr 135 | 136 | fmt.Println("Removing the file from the Git history using git filter-branch...") 137 | 138 | if err := cmd.Run(); err != nil { 139 | log.Fatalf("Error removing the file: %v", err) 140 | } 141 | 142 | // Clean up backup references created by git filter-branch 143 | if err := os.RemoveAll(".git/refs/original"); err != nil { 144 | log.Fatalf("Error removing original references: %v", err) 145 | } 146 | if err := exec.Command("git", "reflog", "expire", "--expire=now", "--all").Run(); err != nil { 147 | log.Fatalf("Error expiring reflogs: %v", err) 148 | } 149 | if err := exec.Command("git", "gc", "--prune=now", "--aggressive").Run(); err != nil { 150 | log.Fatalf("Error running garbage collection: %v", err) 151 | } 152 | } 153 | 154 | // isGitFilterRepoAvailable checks if git-filter-repo is available 155 | func isGitFilterRepoAvailable() bool { 156 | cmd := exec.Command("git", "filter-repo", "--version") 157 | if err := cmd.Run(); err != nil { 158 | return false 159 | } 160 | return true 161 | } 162 | 163 | // isGitRepository checks if the current directory is a Git repository 164 | func isGitRepository() bool { 165 | cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree") 166 | if err := cmd.Run(); err != nil { 167 | return false 168 | } 169 | return true 170 | } 171 | 172 | // addFileToGitignore adds the file to .gitignore if it is not already there 173 | func addFileToGitignore(filePath string) { 174 | gitignorePath := ".gitignore" 175 | file, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0644) 176 | if err != nil { 177 | log.Fatalf("Error opening .gitignore: %v", err) 178 | } 179 | defer file.Close() 180 | 181 | scanner := bufio.NewScanner(file) 182 | for scanner.Scan() { 183 | if strings.TrimSpace(scanner.Text()) == filePath { 184 | // The file is already in .gitignore 185 | return 186 | } 187 | } 188 | 189 | // Add the file to .gitignore 190 | if _, err := file.WriteString(filePath + "\n"); err != nil { 191 | log.Fatalf("Error writing to .gitignore: %v", err) 192 | } 193 | fmt.Printf("Added '%s' to .gitignore\n", filePath) 194 | } 195 | -------------------------------------------------------------------------------- /terminal.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yoanbernabeu/GitCleaner/ded3ed11118e1785bf171ac826b694ce1bae80a9/terminal.gif --------------------------------------------------------------------------------