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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c941e5077e893642ba38fbd33d94f9c8fbef9c8590b8b9107457d7027da16ea
4
- data.tar.gz: 50297be5101150a1898acef6b2a09da3d82b03878765cf09d36fa0fc6fe769ae
3
+ metadata.gz: e0d7321c50f209f50a7aa16e40a26ecc655cc020af8338c4902661bf66260bd7
4
+ data.tar.gz: 48046ed58462e3ef2553e970c24ebcefb125c16b885f1ba942c4ef87d8d31ac1
5
5
  SHA512:
6
- metadata.gz: e2befed7aefe3649a8fa13b0fd7afbebf630c29f2a788370b0211f3291740ce230cf46f9bd14c42df862d73f32b5d951134c29211da85e1a6b86e57b72f078d4
7
- data.tar.gz: 56e28769e4b6554df8c6058d728678600b03082415d8c2043075a4abea758a36df0bb0f02c6d4d676c6ce71c7ea4e4a0ee2c621e6dd2f4bd76db0e58e781fb94
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.1.2)
4
+ git-trim (0.3.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
8
8
  specs:
9
- diff-lcs (1.3)
10
- rake (13.0.1)
11
- rspec (3.7.0)
12
- rspec-core (~> 3.7.0)
13
- rspec-expectations (~> 3.7.0)
14
- rspec-mocks (~> 3.7.0)
15
- rspec-core (3.7.1)
16
- rspec-support (~> 3.7.0)
17
- rspec-expectations (3.7.0)
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.7.0)
20
- rspec-mocks (3.7.0)
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.7.0)
23
- rspec-support (3.7.1)
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 (~> 1.16)
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
- 1.17.3
36
+ 2.5.3
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # git trim
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/git/trim`. To experiment with that code, run `bin/console` for an interactive prompt.
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
- You can call `git trim` and it will remove any branch that isn't currently merged into the current branch that isn't also in the `.git-protected-branches` file. It will not remove the main branch.
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
- The `.git-protected-branches` file can reside in the current directory or any parent directory. Branch names are each listed on a line in the file. For example:
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/[USERNAME]/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.
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/[USERNAME]/git-trim/blob/main/CODE_OF_CONDUCT.md).
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
@@ -2,4 +2,4 @@
2
2
 
3
3
  require 'git-trim'
4
4
 
5
- GitTrim.new.run(ARGV)
5
+ exit GitTrim.new.run(ARGV)
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/assaydepot/git-trim"
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", "~> 1.16"
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
@@ -1,3 +1,3 @@
1
1
  class GitTrim
2
- VERSION = "0.1.3"
2
+ VERSION = "0.3.0"
3
3
  end
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
- if File.exist?(".git-protected-branches")
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
- branches = %x{git branch --merged main}.split("\n").collect {|b| b.gsub('*', '').strip}
14
- branches -= protected_branches
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
- branches.each do |branch|
17
- puts %x{git branch -d #{branch}}
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.1.3
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: 2023-10-03 00:00:00.000000000 Z
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/assaydepot/git-trim
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.4.10
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.