git-trim 0.2.0 → 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/Gemfile.lock +1 -1
- data/README.md +52 -5
- data/lib/git-trim/version.rb +1 -1
- data/lib/git-trim.rb +340 -29
- metadata +2 -2
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
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# git trim
|
|
2
2
|
|
|
3
|
-
Deletes local branches — and the [linked worktrees](https://git-scm.com/docs/git-worktree) that hold them — once they are
|
|
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.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -20,14 +20,51 @@ Or install it yourself as:
|
|
|
20
20
|
|
|
21
21
|
## Usage
|
|
22
22
|
|
|
23
|
-
Run `git trim` from anywhere inside a repository. It fetches (with prune), then removes every local branch that is
|
|
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
24
|
|
|
25
25
|
- `main` itself,
|
|
26
|
-
- the branch you currently have checked out, or
|
|
26
|
+
- the branch you currently have checked out (or the one checked out in the main worktree), or
|
|
27
27
|
- listed in a `.git-protected-branches` file.
|
|
28
28
|
|
|
29
29
|
If no local `main` exists (common in bare-repo + worktrees layouts), it compares against `origin/main` instead.
|
|
30
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
|
+
|
|
31
68
|
### Worktrees
|
|
32
69
|
|
|
33
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:
|
|
@@ -35,15 +72,25 @@ If a merged branch is checked out in a linked worktree, `git trim` removes the w
|
|
|
35
72
|
```
|
|
36
73
|
$ git trim
|
|
37
74
|
Removed worktree /Users/you/src/project/feature-a
|
|
38
|
-
Deleted branch feature-a (was fa68cf94).
|
|
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)]
|
|
39
77
|
Skipping branch 'feature-b': worktree at /Users/you/src/project/feature-b has local changes
|
|
40
78
|
```
|
|
41
79
|
|
|
42
80
|
Commit or stash the local changes and run `git trim` again to clean up the rest.
|
|
43
81
|
|
|
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
|
+
|
|
44
91
|
### Protected branches
|
|
45
92
|
|
|
46
|
-
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:
|
|
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:
|
|
47
94
|
|
|
48
95
|
```
|
|
49
96
|
main
|
data/lib/git-trim/version.rb
CHANGED
data/lib/git-trim.rb
CHANGED
|
@@ -1,14 +1,65 @@
|
|
|
1
1
|
require "git-trim/version"
|
|
2
|
+
require "json"
|
|
3
|
+
require "open3"
|
|
4
|
+
require "optparse"
|
|
2
5
|
require "shellwords"
|
|
3
6
|
|
|
4
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
|
+
|
|
5
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
|
+
|
|
6
60
|
%x{git fetch -ap}
|
|
7
61
|
|
|
8
|
-
|
|
9
|
-
protected_branches = File.read(".git-protected-branches").split("\n")
|
|
10
|
-
end
|
|
11
|
-
protected_branches ||= []
|
|
62
|
+
protected_branches = read_protected_branches
|
|
12
63
|
protected_branches << "main"
|
|
13
64
|
|
|
14
65
|
base = base_ref
|
|
@@ -17,27 +68,28 @@ class GitTrim
|
|
|
17
68
|
return 1
|
|
18
69
|
end
|
|
19
70
|
|
|
20
|
-
branches = %x{git branch --merged #{Shellwords.escape(base)} --format='%(refname:short)'}.split("\n").collect(&:strip)
|
|
21
|
-
branches -= protected_branches
|
|
22
|
-
branches -= [current_branch]
|
|
23
|
-
|
|
24
71
|
worktrees = linked_worktrees
|
|
72
|
+
branch_worktrees = worktrees.select { |wt| wt[:branch] }.to_h { |wt| [wt[:branch], wt[:path]] }
|
|
25
73
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
end
|
|
38
|
-
puts "Removed worktree #{path}"
|
|
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)"
|
|
39
85
|
end
|
|
40
|
-
|
|
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
|
|
41
93
|
end
|
|
42
94
|
|
|
43
95
|
0
|
|
@@ -56,15 +108,10 @@ class GitTrim
|
|
|
56
108
|
end
|
|
57
109
|
end
|
|
58
110
|
|
|
59
|
-
#
|
|
111
|
+
# Linked worktrees as {path:, branch:, head:, detached:}. The first entry
|
|
60
112
|
# in `git worktree list` is the main worktree, which is never removable.
|
|
61
113
|
def linked_worktrees
|
|
62
|
-
|
|
63
|
-
entries.drop(1).each_with_object({}) do |entry, map|
|
|
64
|
-
path = entry[/^worktree (.+)$/, 1]
|
|
65
|
-
branch = entry[/^branch refs\/heads\/(.+)$/, 1]
|
|
66
|
-
map[branch] = path if path && branch
|
|
67
|
-
end
|
|
114
|
+
worktree_entries.drop(1)
|
|
68
115
|
end
|
|
69
116
|
|
|
70
117
|
def find_file(filename, directory=Dir.pwd)
|
|
@@ -77,4 +124,268 @@ class GitTrim
|
|
|
77
124
|
return find_file(filename, File.expand_path("..", directory))
|
|
78
125
|
end
|
|
79
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
|
|
80
391
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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: 2026-
|
|
11
|
+
date: 2026-11-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bundler
|