git-trim 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +18 -0
- data/.github/workflows/release.yml +65 -0
- data/Gemfile.lock +17 -16
- data/README.md +77 -7
- data/bin/git-trim +1 -1
- data/git-trim.gemspec +2 -2
- data/lib/git-trim/version.rb +1 -1
- data/lib/git-trim.rb +368 -8
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e0d7321c50f209f50a7aa16e40a26ecc655cc020af8338c4902661bf66260bd7
|
|
4
|
+
data.tar.gz: 48046ed58462e3ef2553e970c24ebcefb125c16b885f1ba942c4ef87d8d31ac1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 07ae69e3dce7993df35a365fbe8845d0d516944213bb29ecd40f10ca660222d10c8205be3fa8c56f940bd016b26d5ac2f830433c00f5170b313767b309ff1b7d
|
|
7
|
+
data.tar.gz: ec29e5f712886391185e166bce3070d197097deb1bc9e8236a67a2d9d357ec07ece989e7db12337277a2f60fb35dbf21e63b8bf7ed0f54764103519a45ec4523
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: build
|
|
2
|
+
on: pull_request
|
|
3
|
+
jobs:
|
|
4
|
+
build:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
steps:
|
|
7
|
+
- uses: actions/checkout@v6
|
|
8
|
+
- uses: ruby/setup-ruby@v1
|
|
9
|
+
with:
|
|
10
|
+
ruby-version: ruby
|
|
11
|
+
bundler-cache: true
|
|
12
|
+
|
|
13
|
+
- name: Run specs
|
|
14
|
+
run: |
|
|
15
|
+
git config --global user.email "ci@example.com"
|
|
16
|
+
git config --global user.name "CI"
|
|
17
|
+
git config --global init.defaultBranch main
|
|
18
|
+
bundle exec rake spec
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
version:
|
|
7
|
+
description: "Version to release (e.g. 0.2.0)"
|
|
8
|
+
required: true
|
|
9
|
+
type: string
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
release:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
permissions:
|
|
15
|
+
contents: write
|
|
16
|
+
id-token: write
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v6
|
|
19
|
+
|
|
20
|
+
- uses: ruby/setup-ruby@v1
|
|
21
|
+
with:
|
|
22
|
+
ruby-version: "3.3"
|
|
23
|
+
bundler-cache: true
|
|
24
|
+
|
|
25
|
+
- name: Run specs
|
|
26
|
+
run: bundle exec rake spec
|
|
27
|
+
|
|
28
|
+
- name: Update version
|
|
29
|
+
run: |
|
|
30
|
+
VERSION="${{ inputs.version }}"
|
|
31
|
+
sed -i "s/VERSION = \".*\"/VERSION = \"$VERSION\"/" lib/git-trim/version.rb
|
|
32
|
+
echo "Updated version to $VERSION"
|
|
33
|
+
cat lib/git-trim/version.rb
|
|
34
|
+
|
|
35
|
+
# bundler-cache sets frozen/deployment mode, which rejects the now
|
|
36
|
+
# out-of-date lockfile entry for git-trim itself. Re-lock so the
|
|
37
|
+
# release commit keeps the gemspec and lockfile in sync.
|
|
38
|
+
- name: Update Gemfile.lock
|
|
39
|
+
run: |
|
|
40
|
+
bundle config unset deployment
|
|
41
|
+
bundle config unset frozen
|
|
42
|
+
BUNDLE_FROZEN=false BUNDLE_DEPLOYMENT=false bundle lock
|
|
43
|
+
|
|
44
|
+
- name: Commit version bump
|
|
45
|
+
run: |
|
|
46
|
+
VERSION="${{ inputs.version }}"
|
|
47
|
+
git config user.name "github-actions[bot]"
|
|
48
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
49
|
+
git add lib/git-trim/version.rb Gemfile.lock
|
|
50
|
+
git commit -m "Release v$VERSION"
|
|
51
|
+
|
|
52
|
+
# Builds the gem into pkg/, tags v$VERSION, pushes the commit and
|
|
53
|
+
# tag, and publishes to RubyGems via OIDC trusted publishing.
|
|
54
|
+
- name: Release to RubyGems
|
|
55
|
+
uses: rubygems/release-gem@v1
|
|
56
|
+
|
|
57
|
+
- name: Create GitHub Release
|
|
58
|
+
env:
|
|
59
|
+
GH_TOKEN: ${{ github.token }}
|
|
60
|
+
run: |
|
|
61
|
+
VERSION="${{ inputs.version }}"
|
|
62
|
+
gh release create "v$VERSION" \
|
|
63
|
+
--title "v$VERSION" \
|
|
64
|
+
--generate-notes \
|
|
65
|
+
pkg/git-trim-*.gem
|
data/Gemfile.lock
CHANGED
|
@@ -1,35 +1,36 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
git-trim (0.
|
|
4
|
+
git-trim (0.3.0)
|
|
5
5
|
|
|
6
6
|
GEM
|
|
7
7
|
remote: https://rubygems.org/
|
|
8
8
|
specs:
|
|
9
|
-
diff-lcs (1.
|
|
10
|
-
rake (13.
|
|
11
|
-
rspec (3.
|
|
12
|
-
rspec-core (~> 3.
|
|
13
|
-
rspec-expectations (~> 3.
|
|
14
|
-
rspec-mocks (~> 3.
|
|
15
|
-
rspec-core (3.
|
|
16
|
-
rspec-support (~> 3.
|
|
17
|
-
rspec-expectations (3.
|
|
9
|
+
diff-lcs (1.6.2)
|
|
10
|
+
rake (13.4.2)
|
|
11
|
+
rspec (3.13.2)
|
|
12
|
+
rspec-core (~> 3.13.0)
|
|
13
|
+
rspec-expectations (~> 3.13.0)
|
|
14
|
+
rspec-mocks (~> 3.13.0)
|
|
15
|
+
rspec-core (3.13.6)
|
|
16
|
+
rspec-support (~> 3.13.0)
|
|
17
|
+
rspec-expectations (3.13.5)
|
|
18
18
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
19
|
-
rspec-support (~> 3.
|
|
20
|
-
rspec-mocks (3.
|
|
19
|
+
rspec-support (~> 3.13.0)
|
|
20
|
+
rspec-mocks (3.13.8)
|
|
21
21
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
22
|
-
rspec-support (~> 3.
|
|
23
|
-
rspec-support (3.7
|
|
22
|
+
rspec-support (~> 3.13.0)
|
|
23
|
+
rspec-support (3.13.7)
|
|
24
24
|
|
|
25
25
|
PLATFORMS
|
|
26
|
+
arm64-darwin-25
|
|
26
27
|
ruby
|
|
27
28
|
|
|
28
29
|
DEPENDENCIES
|
|
29
|
-
bundler (
|
|
30
|
+
bundler (>= 1.16)
|
|
30
31
|
git-trim!
|
|
31
32
|
rake (~> 13.0)
|
|
32
33
|
rspec (~> 3.0)
|
|
33
34
|
|
|
34
35
|
BUNDLED WITH
|
|
35
|
-
|
|
36
|
+
2.5.3
|
data/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# git trim
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
TODO: Delete this and the text above, and describe your gem
|
|
3
|
+
Deletes local branches — and the [linked worktrees](https://git-scm.com/docs/git-worktree) that hold them — once they are merged into `main`, including squash merges. Run it after a round of PRs merge and it cleans up everything that's done, while refusing to touch anything that still has work in it.
|
|
6
4
|
|
|
7
5
|
## Installation
|
|
8
6
|
|
|
@@ -22,9 +20,77 @@ Or install it yourself as:
|
|
|
22
20
|
|
|
23
21
|
## Usage
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
Run `git trim` from anywhere inside a repository. It fetches (with prune), then removes every local branch that is merged into `main`, unless the branch is:
|
|
24
|
+
|
|
25
|
+
- `main` itself,
|
|
26
|
+
- the branch you currently have checked out (or the one checked out in the main worktree), or
|
|
27
|
+
- listed in a `.git-protected-branches` file.
|
|
28
|
+
|
|
29
|
+
If no local `main` exists (common in bare-repo + worktrees layouts), it compares against `origin/main` instead.
|
|
30
|
+
|
|
31
|
+
### Options
|
|
32
|
+
|
|
33
|
+
| Flag | Effect |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `-n`, `--dry-run` | Print exactly what would be removed and which signal proved it merged, without deleting anything. It still runs `git fetch -ap`, so the report reflects the current remote. |
|
|
36
|
+
| `--no-gh` | Skip the GitHub check entirely and use git-only detection. |
|
|
37
|
+
| `-h`, `--help` | Show usage. |
|
|
38
|
+
|
|
39
|
+
### How merges are detected
|
|
40
|
+
|
|
41
|
+
A branch is trimmed when **any** of these proves it is merged:
|
|
42
|
+
|
|
43
|
+
1. **Regular merge.** The branch is in `git branch --merged main`. It is deleted with `git branch -d`.
|
|
44
|
+
2. **Squash merge, offline.** git-trim builds a throwaway commit holding the branch's whole diff on top of its merge base (`git commit-tree <branch>^{tree} -p $(git merge-base main <branch>)`) and asks `git cherry main <tmp>` whether `main` already has an equivalent patch. No network needed.
|
|
45
|
+
3. **Merged GitHub PR.** A merged PR whose head branch has the same name **and** whose head commit is exactly the local tip. All merged PRs are fetched in one `gh pr list --state merged` call (up to 1000, most recent first).
|
|
46
|
+
|
|
47
|
+
Branches proven by (2) or (3) are deleted with `git branch -D`, since `-d` refuses squash-merged branches. `-D` is never used for anything else.
|
|
48
|
+
|
|
49
|
+
Some cases are reported and left alone:
|
|
50
|
+
|
|
51
|
+
- **Extra local commits.** If a merged PR exists for the branch but the local branch has commits the PR never had, the branch is kept:
|
|
52
|
+
`Skipping branch 'feature-x': it has commits that merged PR #42 never had`
|
|
53
|
+
- **Gone upstream, no proof.** A branch whose upstream was deleted (`[gone]`) is **not** trimmed for that reason alone, because a PR closed without merging looks the same. It is reported as possibly stale:
|
|
54
|
+
`Keeping branch 'spike': its upstream is gone but nothing proves it was merged (possibly stale)`
|
|
55
|
+
|
|
56
|
+
### The GitHub CLI is optional
|
|
57
|
+
|
|
58
|
+
Signal (3) uses [`gh`](https://cli.github.com/) when it is available and is skipped otherwise. git-trim prints one warning naming the reason, carries on with git-only detection, and exits 0 as usual:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
git-trim: gh not installed; skipping GitHub PR check, using git-only detection
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The reasons are: `gh` is not installed, no remote points at GitHub, `gh` is not logged in, `gh` can't verify its login (GitHub unreachable or token rejected), GitHub is unreachable, the API rate limit was hit, `gh` timed out, or it returned output that couldn't be parsed. An error, empty output or malformed JSON never counts as evidence that a branch is merged.
|
|
65
|
+
|
|
66
|
+
`gh` is consulted only when git alone can't prove some branch or detached worktree merged. It runs with `GH_PROMPT_DISABLED=1`, with no terminal on stdin, and under a 15-second timeout, so a hung auth prompt or network call can't stall the run.
|
|
67
|
+
|
|
68
|
+
### Worktrees
|
|
69
|
+
|
|
70
|
+
If a merged branch is checked out in a linked worktree, `git trim` removes the worktree first and then deletes the branch. Worktrees with uncommitted changes or untracked files are left alone, along with their branch:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
$ git trim
|
|
74
|
+
Removed worktree /Users/you/src/project/feature-a
|
|
75
|
+
Deleted branch feature-a (was fa68cf94). [merged into main]
|
|
76
|
+
Deleted branch feature-c (was 1b2c3d4e). [squash-merged into main (patch already in main)]
|
|
77
|
+
Skipping branch 'feature-b': worktree at /Users/you/src/project/feature-b has local changes
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Commit or stash the local changes and run `git trim` again to clean up the rest.
|
|
26
81
|
|
|
27
|
-
|
|
82
|
+
**Detached worktrees** (detached HEAD, such as the ones agents create for read-only review) are removed when they are clean and their HEAD is contained in `main`, or equals the head commit of a merged PR:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
Removed detached worktree /Users/you/src/project/review-42 [HEAD is the head of merged PR #42]
|
|
86
|
+
Skipping detached worktree at /Users/you/src/project/review-43: it has local changes
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The main worktree and the worktree you run `git trim` from are never removed.
|
|
90
|
+
|
|
91
|
+
### Protected branches
|
|
92
|
+
|
|
93
|
+
The `.git-protected-branches` file can reside in the current directory or any parent directory; the nearest one wins. Branch names are each listed on a line in the file. For example:
|
|
28
94
|
|
|
29
95
|
```
|
|
30
96
|
main
|
|
@@ -32,9 +98,13 @@ staging
|
|
|
32
98
|
production
|
|
33
99
|
```
|
|
34
100
|
|
|
101
|
+
## Releasing
|
|
102
|
+
|
|
103
|
+
Releases are cut from the GitHub Actions **Release** workflow (Actions → Release → Run workflow → enter a version like `0.2.0`). The workflow runs the specs, bumps `lib/git-trim/version.rb`, tags `v<version>`, publishes to RubyGems via [trusted publishing](https://guides.rubygems.org/trusted-publishing/) (no API key), and creates a GitHub release.
|
|
104
|
+
|
|
35
105
|
## Contributing
|
|
36
106
|
|
|
37
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
|
107
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/cpetersen/git-trim. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
|
38
108
|
|
|
39
109
|
## License
|
|
40
110
|
|
|
@@ -42,4 +112,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
|
|
|
42
112
|
|
|
43
113
|
## Code of Conduct
|
|
44
114
|
|
|
45
|
-
Everyone interacting in the Git::Trim project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/
|
|
115
|
+
Everyone interacting in the Git::Trim project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/cpetersen/git-trim/blob/main/CODE_OF_CONDUCT.md).
|
data/bin/git-trim
CHANGED
data/git-trim.gemspec
CHANGED
|
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
|
|
|
11
11
|
|
|
12
12
|
spec.summary = %q{A script to remove all merged branches that aren't protected.}
|
|
13
13
|
spec.description = %q{A script to remove all merged branches that aren't specified in the .git-protected-branches file.}
|
|
14
|
-
spec.homepage = "https://github.com/
|
|
14
|
+
spec.homepage = "https://github.com/cpetersen/git-trim"
|
|
15
15
|
spec.license = "MIT"
|
|
16
16
|
|
|
17
17
|
# Specify which files should be added to the gem when it is released.
|
|
@@ -23,7 +23,7 @@ Gem::Specification.new do |spec|
|
|
|
23
23
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
|
24
24
|
spec.require_paths = ["lib", "bin"]
|
|
25
25
|
|
|
26
|
-
spec.add_development_dependency "bundler", "
|
|
26
|
+
spec.add_development_dependency "bundler", ">= 1.16"
|
|
27
27
|
spec.add_development_dependency "rake", "~> 13.0"
|
|
28
28
|
spec.add_development_dependency "rspec", "~> 3.0"
|
|
29
29
|
end
|
data/lib/git-trim/version.rb
CHANGED
data/lib/git-trim.rb
CHANGED
|
@@ -1,23 +1,119 @@
|
|
|
1
1
|
require "git-trim/version"
|
|
2
|
+
require "json"
|
|
3
|
+
require "open3"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "shellwords"
|
|
2
6
|
|
|
3
7
|
class GitTrim
|
|
8
|
+
GH_TIMEOUT = 15
|
|
9
|
+
GH_PR_LIMIT = 1000
|
|
10
|
+
|
|
11
|
+
# gh must never stop to ask a question: no prompts, no update notices,
|
|
12
|
+
# no spinner, no colour codes in the JSON.
|
|
13
|
+
GH_ENV = {
|
|
14
|
+
"GH_PROMPT_DISABLED" => "1",
|
|
15
|
+
"GH_NO_UPDATE_NOTIFIER" => "1",
|
|
16
|
+
"GH_SPINNER_DISABLED" => "1",
|
|
17
|
+
"NO_COLOR" => "1",
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# Identity for the throwaway commit built by the offline squash check, so it
|
|
21
|
+
# works even when user.name/user.email aren't configured.
|
|
22
|
+
TMP_COMMIT_ENV = {
|
|
23
|
+
"GIT_AUTHOR_NAME" => "git-trim",
|
|
24
|
+
"GIT_AUTHOR_EMAIL" => "git-trim@localhost",
|
|
25
|
+
"GIT_COMMITTER_NAME" => "git-trim",
|
|
26
|
+
"GIT_COMMITTER_EMAIL" => "git-trim@localhost",
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Why a branch counts as merged. Only a regular merge can use `git branch
|
|
30
|
+
# -d`; git refuses -d for squash-merged branches, so the other proofs force.
|
|
31
|
+
Proof = Struct.new(:reason, :force)
|
|
32
|
+
|
|
33
|
+
def initialize(gh_timeout: GH_TIMEOUT)
|
|
34
|
+
@gh_timeout = gh_timeout
|
|
35
|
+
end
|
|
36
|
+
|
|
4
37
|
def run(argv=nil)
|
|
38
|
+
@dry_run = false
|
|
39
|
+
@use_gh = true
|
|
40
|
+
help = false
|
|
41
|
+
parser = OptionParser.new do |opts|
|
|
42
|
+
opts.banner = "Usage: git trim [--dry-run] [--no-gh]"
|
|
43
|
+
opts.on("-n", "--dry-run", "Show what would be removed, and why, without changing anything") { @dry_run = true }
|
|
44
|
+
opts.on("--[no-]gh", "Use the GitHub CLI to detect merged PRs (default: on when available)") { |value| @use_gh = value }
|
|
45
|
+
opts.on("-h", "--help", "Show this help") { help = true }
|
|
46
|
+
end
|
|
47
|
+
begin
|
|
48
|
+
parser.parse(argv || [])
|
|
49
|
+
rescue OptionParser::ParseError => e
|
|
50
|
+
warn "git-trim: #{e.message}"
|
|
51
|
+
warn parser.banner
|
|
52
|
+
return 1
|
|
53
|
+
end
|
|
54
|
+
if help
|
|
55
|
+
puts parser
|
|
56
|
+
return 0
|
|
57
|
+
end
|
|
58
|
+
remove_instance_variable(:@merged_prs) if defined?(@merged_prs)
|
|
59
|
+
|
|
5
60
|
%x{git fetch -ap}
|
|
6
61
|
|
|
7
|
-
|
|
8
|
-
protected_branches = File.read(".git-protected-branches").split("\n")
|
|
9
|
-
end
|
|
10
|
-
protected_branches ||= []
|
|
62
|
+
protected_branches = read_protected_branches
|
|
11
63
|
protected_branches << "main"
|
|
12
64
|
|
|
13
|
-
|
|
14
|
-
|
|
65
|
+
base = base_ref
|
|
66
|
+
unless base
|
|
67
|
+
warn "git-trim: neither 'main' nor 'origin/main' exists; nothing to trim against"
|
|
68
|
+
return 1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
worktrees = linked_worktrees
|
|
72
|
+
branch_worktrees = worktrees.select { |wt| wt[:branch] }.to_h { |wt| [wt[:branch], wt[:path]] }
|
|
73
|
+
|
|
74
|
+
merged = %x{git branch --merged #{Shellwords.escape(base)} --format='%(refname:short)'}.split("\n").collect(&:strip)
|
|
75
|
+
excluded = protected_branches + [current_branch, main_worktree_branch].compact
|
|
76
|
+
|
|
77
|
+
local_branches.each do |branch|
|
|
78
|
+
next if excluded.include?(branch[:name])
|
|
79
|
+
|
|
80
|
+
proof = merge_proof(branch, base, merged)
|
|
81
|
+
if proof.is_a?(Proof)
|
|
82
|
+
trim_branch(branch[:name], proof, branch_worktrees[branch[:name]])
|
|
83
|
+
elsif proof.nil? && branch[:gone]
|
|
84
|
+
puts "Keeping branch '#{branch[:name]}': its upstream is gone but nothing proves it was merged (possibly stale)"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
here = current_worktree
|
|
89
|
+
worktrees.select { |wt| wt[:detached] }.each do |wt|
|
|
90
|
+
next if same_path?(wt[:path], here)
|
|
91
|
+
reason = detached_merge_reason(wt[:head], base)
|
|
92
|
+
trim_detached_worktree(wt[:path], reason) if reason
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
0
|
|
96
|
+
end
|
|
15
97
|
|
|
16
|
-
|
|
17
|
-
|
|
98
|
+
def current_branch
|
|
99
|
+
%x{git branch --show-current}.strip
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Local main when it exists; otherwise origin/main (e.g. bare-repo +
|
|
103
|
+
# worktrees layouts where no local main is checked out). Nil if neither.
|
|
104
|
+
def base_ref
|
|
105
|
+
["main", "origin/main"].find do |ref|
|
|
106
|
+
%x{git rev-parse --verify --quiet #{Shellwords.escape(ref)}}
|
|
107
|
+
$?.success?
|
|
18
108
|
end
|
|
19
109
|
end
|
|
20
110
|
|
|
111
|
+
# Linked worktrees as {path:, branch:, head:, detached:}. The first entry
|
|
112
|
+
# in `git worktree list` is the main worktree, which is never removable.
|
|
113
|
+
def linked_worktrees
|
|
114
|
+
worktree_entries.drop(1)
|
|
115
|
+
end
|
|
116
|
+
|
|
21
117
|
def find_file(filename, directory=Dir.pwd)
|
|
22
118
|
local_filename = File.expand_path(filename, directory)
|
|
23
119
|
if File.exist?(local_filename)
|
|
@@ -28,4 +124,268 @@ class GitTrim
|
|
|
28
124
|
return find_file(filename, File.expand_path("..", directory))
|
|
29
125
|
end
|
|
30
126
|
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def read_protected_branches
|
|
131
|
+
path = find_file(".git-protected-branches")
|
|
132
|
+
return [] unless path
|
|
133
|
+
File.read(path).split("\n").collect(&:strip).reject(&:empty?)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def worktree_entries
|
|
137
|
+
%x{git worktree list --porcelain}.split("\n\n").collect do |entry|
|
|
138
|
+
{
|
|
139
|
+
path: entry[/^worktree (.+)$/, 1],
|
|
140
|
+
branch: entry[/^branch refs\/heads\/(.+)$/, 1],
|
|
141
|
+
head: entry[/^HEAD (\h+)$/, 1],
|
|
142
|
+
detached: entry.match?(/^detached$/),
|
|
143
|
+
}
|
|
144
|
+
end.select { |wt| wt[:path] }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def main_worktree_branch
|
|
148
|
+
worktree_entries.first&.fetch(:branch)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def current_worktree
|
|
152
|
+
git("rev-parse", "--show-toplevel")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def same_path?(a, b)
|
|
156
|
+
return false unless a && b
|
|
157
|
+
File.realpath(a) == File.realpath(b)
|
|
158
|
+
rescue SystemCallError
|
|
159
|
+
a == b
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def local_branches
|
|
163
|
+
format = "%(refname:short)%09%(objectname)%09%(upstream:track)"
|
|
164
|
+
%x{git for-each-ref refs/heads --format=#{Shellwords.escape(format)}}.split("\n").collect do |line|
|
|
165
|
+
name, tip, track = line.split("\t")
|
|
166
|
+
{ name: name, tip: tip, gone: track.to_s.include?("gone") }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A Proof when some signal shows the branch is merged into base; :mismatch
|
|
171
|
+
# when a merged PR exists but the local branch has moved on (already
|
|
172
|
+
# reported); nil when there's no evidence either way.
|
|
173
|
+
def merge_proof(branch, base, merged)
|
|
174
|
+
return Proof.new("merged into #{base}", false) if merged.include?(branch[:name])
|
|
175
|
+
return Proof.new("squash-merged into #{base} (patch already in #{base})", true) if squash_merged?(base, branch[:tip])
|
|
176
|
+
github_merge_proof(branch)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Offline squash detection: collapse the branch into one commit on top of
|
|
180
|
+
# its merge base, then ask `git cherry` whether base already has an
|
|
181
|
+
# equivalent patch. A leading "-" means it does.
|
|
182
|
+
def squash_merged?(base, tip)
|
|
183
|
+
merge_base = git("merge-base", base, tip) or return false
|
|
184
|
+
tree = git("rev-parse", "#{tip}^{tree}") or return false
|
|
185
|
+
# Nothing to compare when the branch makes no net change.
|
|
186
|
+
return false if tree == git("rev-parse", "#{merge_base}^{tree}")
|
|
187
|
+
tmp = git("commit-tree", tree, "-p", merge_base, "-m", "_", env: TMP_COMMIT_ENV) or return false
|
|
188
|
+
cherry = git("cherry", base, tmp) or return false
|
|
189
|
+
cherry.start_with?("-")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Proof from GitHub: a merged PR for this branch name whose head is exactly
|
|
193
|
+
# the local tip. A merged PR at a different commit is not proof.
|
|
194
|
+
def github_merge_proof(branch)
|
|
195
|
+
prs = merged_prs or return nil
|
|
196
|
+
matching = prs.select { |pr| pr["headRefName"] == branch[:name] }
|
|
197
|
+
return nil if matching.empty?
|
|
198
|
+
|
|
199
|
+
if (pr = matching.find { |candidate| candidate["headRefOid"] == branch[:tip] })
|
|
200
|
+
return Proof.new("merged PR ##{pr["number"]}", true)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
pr = matching.first
|
|
204
|
+
if ancestor?(pr["headRefOid"], branch[:tip])
|
|
205
|
+
puts "Skipping branch '#{branch[:name]}': it has commits that merged PR ##{pr["number"]} never had"
|
|
206
|
+
else
|
|
207
|
+
puts "Skipping branch '#{branch[:name]}': its tip doesn't match the head of merged PR ##{pr["number"]}"
|
|
208
|
+
end
|
|
209
|
+
:mismatch
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def detached_merge_reason(head, base)
|
|
213
|
+
return nil unless head
|
|
214
|
+
return "HEAD is in #{base}" if ancestor?(head, base)
|
|
215
|
+
pr = merged_prs&.find { |candidate| candidate["headRefOid"] == head }
|
|
216
|
+
"HEAD is the head of merged PR ##{pr["number"]}" if pr
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def trim_branch(name, proof, worktree_path)
|
|
220
|
+
if @dry_run
|
|
221
|
+
if worktree_path && dirty?(worktree_path)
|
|
222
|
+
puts "Would skip branch '#{name}': worktree at #{worktree_path} has local changes"
|
|
223
|
+
return
|
|
224
|
+
end
|
|
225
|
+
puts "Would remove worktree #{worktree_path} (#{proof.reason})" if worktree_path
|
|
226
|
+
puts "Would delete branch #{name} (#{proof.reason})"
|
|
227
|
+
return
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
if worktree_path
|
|
231
|
+
output = %x{git worktree remove #{Shellwords.escape(worktree_path)} 2>&1}
|
|
232
|
+
unless $?.success?
|
|
233
|
+
if output.include?("contains modified or untracked files")
|
|
234
|
+
puts "Skipping branch '#{name}': worktree at #{worktree_path} has local changes"
|
|
235
|
+
else
|
|
236
|
+
puts "Skipping branch '#{name}': could not remove worktree at #{worktree_path}"
|
|
237
|
+
puts output
|
|
238
|
+
end
|
|
239
|
+
return
|
|
240
|
+
end
|
|
241
|
+
puts "Removed worktree #{worktree_path}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
flag = proof.force ? "-D" : "-d"
|
|
245
|
+
output = %x{git branch #{flag} #{Shellwords.escape(name)} 2>&1}
|
|
246
|
+
if $?.success?
|
|
247
|
+
puts "#{output.strip} [#{proof.reason}]"
|
|
248
|
+
else
|
|
249
|
+
puts "Skipping branch '#{name}': could not delete it"
|
|
250
|
+
puts output
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def trim_detached_worktree(path, reason)
|
|
255
|
+
if @dry_run
|
|
256
|
+
if dirty?(path)
|
|
257
|
+
puts "Would skip detached worktree at #{path}: it has local changes"
|
|
258
|
+
else
|
|
259
|
+
puts "Would remove detached worktree #{path} (#{reason})"
|
|
260
|
+
end
|
|
261
|
+
return
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
output = %x{git worktree remove #{Shellwords.escape(path)} 2>&1}
|
|
265
|
+
if $?.success?
|
|
266
|
+
puts "Removed detached worktree #{path} [#{reason}]"
|
|
267
|
+
elsif output.include?("contains modified or untracked files")
|
|
268
|
+
puts "Skipping detached worktree at #{path}: it has local changes"
|
|
269
|
+
else
|
|
270
|
+
puts "Skipping detached worktree at #{path}: could not remove it"
|
|
271
|
+
puts output
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def dirty?(path)
|
|
276
|
+
status = git("-C", path, "status", "--porcelain")
|
|
277
|
+
status.nil? || !status.empty?
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def ancestor?(commit, descendant)
|
|
281
|
+
_, _, status = Open3.capture3("git", "merge-base", "--is-ancestor", commit, descendant)
|
|
282
|
+
status.success?
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Stripped stdout of a git command, or nil if it failed.
|
|
286
|
+
def git(*args, env: {})
|
|
287
|
+
out, _, status = Open3.capture3(env, "git", *args)
|
|
288
|
+
out.strip if status.success?
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Merged PRs from GitHub as [{"number", "headRefName", "headRefOid"}], or
|
|
292
|
+
# nil when gh can't give a trustworthy answer. Fetched at most once, and
|
|
293
|
+
# only if some branch or worktree actually needs it.
|
|
294
|
+
def merged_prs
|
|
295
|
+
return @merged_prs if defined?(@merged_prs)
|
|
296
|
+
@merged_prs = fetch_merged_prs
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def fetch_merged_prs
|
|
300
|
+
return nil unless @use_gh
|
|
301
|
+
return gh_unavailable("gh not installed") unless system("command -v gh >/dev/null 2>&1")
|
|
302
|
+
return gh_unavailable("no GitHub remote") unless github_remote?
|
|
303
|
+
|
|
304
|
+
status, = gh("auth", "status")
|
|
305
|
+
return gh_unavailable("gh timed out after #{@gh_timeout}s") if status == :timeout
|
|
306
|
+
unless status&.success?
|
|
307
|
+
# `auth status` checks the token online and reports a network failure
|
|
308
|
+
# as an invalid token. `auth token` is offline, so it tells "never
|
|
309
|
+
# logged in" apart from "logged in but couldn't verify".
|
|
310
|
+
token_status, = gh("auth", "token")
|
|
311
|
+
return gh_unavailable("gh not logged in") unless token_status.is_a?(Process::Status) && token_status.success?
|
|
312
|
+
return gh_unavailable("gh couldn't verify its login (GitHub unreachable or token rejected)")
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
status, out, err = gh("pr", "list", "--state", "merged", "--limit", GH_PR_LIMIT.to_s,
|
|
316
|
+
"--json", "number,headRefName,headRefOid")
|
|
317
|
+
return gh_unavailable("gh timed out after #{@gh_timeout}s") if status == :timeout
|
|
318
|
+
return gh_unavailable(gh_failure(err)) unless status&.success?
|
|
319
|
+
|
|
320
|
+
parse_prs(out) || gh_unavailable("gh returned unparseable output")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def gh_unavailable(reason)
|
|
324
|
+
warn "git-trim: #{reason}; skipping GitHub PR check, using git-only detection"
|
|
325
|
+
nil
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def gh_failure(stderr)
|
|
329
|
+
case stderr
|
|
330
|
+
when /rate limit/i then "GitHub API rate limit hit"
|
|
331
|
+
when /could not resolve|no such host|dial tcp|connection refused|network is unreachable|i\/o timeout|tls/i
|
|
332
|
+
"can't reach GitHub"
|
|
333
|
+
else "gh pr list failed"
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Raw remote URLs (not insteadOf-rewritten), since gh resolves the repo
|
|
338
|
+
# from those too.
|
|
339
|
+
def github_remote?
|
|
340
|
+
hosts = ["github.com", ENV["GH_HOST"]].compact.reject(&:empty?)
|
|
341
|
+
urls = git("config", "--get-regexp", '^remote\..*\.url$').to_s.split("\n")
|
|
342
|
+
urls.any? { |line| hosts.any? { |host| line.include?(host) } }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# An error, empty output or a malformed payload is never evidence.
|
|
346
|
+
def parse_prs(json)
|
|
347
|
+
prs = JSON.parse(json)
|
|
348
|
+
return nil unless prs.is_a?(Array)
|
|
349
|
+
valid = prs.all? do |pr|
|
|
350
|
+
pr.is_a?(Hash) && pr["headRefName"].is_a?(String) && !pr["headRefName"].empty? &&
|
|
351
|
+
pr["headRefOid"].is_a?(String) && pr["headRefOid"].match?(/\A(\h{40}|\h{64})\z/)
|
|
352
|
+
end
|
|
353
|
+
prs if valid
|
|
354
|
+
rescue JSON::ParserError
|
|
355
|
+
nil
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Runs gh with no TTY and a hard deadline. Returns [status, stdout, stderr],
|
|
359
|
+
# with status :timeout if gh was killed, or nil if it couldn't start.
|
|
360
|
+
def gh(*args)
|
|
361
|
+
out_r, out_w = IO.pipe
|
|
362
|
+
err_r, err_w = IO.pipe
|
|
363
|
+
begin
|
|
364
|
+
pid = Process.spawn(GH_ENV, "gh", *args, in: File::NULL, out: out_w, err: err_w, pgroup: true)
|
|
365
|
+
rescue SystemCallError
|
|
366
|
+
return [nil, "", ""]
|
|
367
|
+
ensure
|
|
368
|
+
out_w.close
|
|
369
|
+
err_w.close
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
readers = [out_r, err_r].collect { |io| Thread.new { io.read } }
|
|
373
|
+
waiter = Thread.new { Process.wait2(pid).last }
|
|
374
|
+
status = if waiter.join(@gh_timeout)
|
|
375
|
+
waiter.value
|
|
376
|
+
else
|
|
377
|
+
# Kill the whole process group so children holding the pipes die too.
|
|
378
|
+
begin
|
|
379
|
+
Process.kill("KILL", -pid)
|
|
380
|
+
rescue SystemCallError
|
|
381
|
+
nil
|
|
382
|
+
end
|
|
383
|
+
waiter.join
|
|
384
|
+
:timeout
|
|
385
|
+
end
|
|
386
|
+
[status, *readers.collect(&:value)]
|
|
387
|
+
ensure
|
|
388
|
+
out_r&.close
|
|
389
|
+
err_r&.close
|
|
390
|
+
end
|
|
31
391
|
end
|
metadata
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: git-trim
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Christopher Petersen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-11-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bundler
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
|
-
- - "
|
|
17
|
+
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
19
|
version: '1.16'
|
|
20
20
|
type: :development
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
|
-
- - "
|
|
24
|
+
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '1.16'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
@@ -61,6 +61,8 @@ executables:
|
|
|
61
61
|
extensions: []
|
|
62
62
|
extra_rdoc_files: []
|
|
63
63
|
files:
|
|
64
|
+
- ".github/workflows/ci.yml"
|
|
65
|
+
- ".github/workflows/release.yml"
|
|
64
66
|
- ".gitignore"
|
|
65
67
|
- ".rspec"
|
|
66
68
|
- ".travis.yml"
|
|
@@ -74,7 +76,7 @@ files:
|
|
|
74
76
|
- git-trim.gemspec
|
|
75
77
|
- lib/git-trim.rb
|
|
76
78
|
- lib/git-trim/version.rb
|
|
77
|
-
homepage: https://github.com/
|
|
79
|
+
homepage: https://github.com/cpetersen/git-trim
|
|
78
80
|
licenses:
|
|
79
81
|
- MIT
|
|
80
82
|
metadata: {}
|
|
@@ -94,7 +96,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
94
96
|
- !ruby/object:Gem::Version
|
|
95
97
|
version: '0'
|
|
96
98
|
requirements: []
|
|
97
|
-
rubygems_version: 3.
|
|
99
|
+
rubygems_version: 3.5.22
|
|
98
100
|
signing_key:
|
|
99
101
|
specification_version: 4
|
|
100
102
|
summary: A script to remove all merged branches that aren't protected.
|