git-trim 0.2.0 → 0.3.1

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: 14a8db7d9bf6cca92301a221f4e096d633882d103f9f7ea4b68e4ce5a6187ce5
4
- data.tar.gz: 26a4e8614638c39d3692815c34aee4d4358088e62aa13ebb3d6ff0372d1ee3f7
3
+ metadata.gz: c721c2afcb4d965bf03f2db2ee3ec6b4db03ab0fcfbd9e3d445906a43d2c99f9
4
+ data.tar.gz: 11447cff59d31c816984ff1ff81b2022d1146eee264fc38aae5430e5e61eb86f
5
5
  SHA512:
6
- metadata.gz: a3915fb4d49379edebb0928cde8f4c7f2774c429f437ee67381edfd263b567c32b3d6f7ae5c922b7c1d963079859ea844b3916d62f7a3a5712a899f2e0a6d75e
7
- data.tar.gz: 4e604a55e748caa6af8537b929d6bdde5d50b352c6e5a73cb38950133e9f796f0df855e620b45173928c5ef535b311f8dd28ae8a40d3b5e94c26239815cdbe44
6
+ metadata.gz: bd295f3e6aa3cfda842f1360b5e0e3bf51aa8f497f45c7af1dc93b3caa5eaa24139c618a3dc8d8b54b95992d07c56439bc20c06b0ac98f8a77aab9479f55a7dc
7
+ data.tar.gz: eee51e772726efe61e54d0991042b0212cba6f46eeaa50b1f2b8e580724d773e9924011f3668188a7c8547e0c2afbd2d864fb42b44c67fcdf6b8bb167bcc5c57
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- git-trim (0.2.0)
4
+ git-trim (0.3.1)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
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 fully merged into `main`. 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.
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,52 @@ 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 fully merged into `main`, unless the branch 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
+ | `-v`, `--version` | Print the installed version. |
38
+ | `-h`, `--help` | Show usage. |
39
+
40
+ ### How merges are detected
41
+
42
+ A branch is trimmed when **any** of these proves it is merged:
43
+
44
+ 1. **Regular merge.** The branch is in `git branch --merged main`. It is deleted with `git branch -d`.
45
+ 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.
46
+ 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).
47
+
48
+ 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.
49
+
50
+ Some cases are reported and left alone:
51
+
52
+ - **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:
53
+ `Skipping branch 'feature-x': it has commits that merged PR #42 never had`
54
+ - **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:
55
+ `Keeping branch 'spike': its upstream is gone but nothing proves it was merged (possibly stale)`
56
+
57
+ ### The GitHub CLI is optional
58
+
59
+ 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:
60
+
61
+ ```
62
+ git-trim: gh not installed; skipping GitHub PR check, using git-only detection
63
+ ```
64
+
65
+ 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.
66
+
67
+ `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.
68
+
31
69
  ### Worktrees
32
70
 
33
71
  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 +73,25 @@ If a merged branch is checked out in a linked worktree, `git trim` removes the w
35
73
  ```
36
74
  $ git trim
37
75
  Removed worktree /Users/you/src/project/feature-a
38
- Deleted branch feature-a (was fa68cf94).
76
+ Deleted branch feature-a (was fa68cf94). [merged into main]
77
+ Deleted branch feature-c (was 1b2c3d4e). [squash-merged into main (patch already in main)]
39
78
  Skipping branch 'feature-b': worktree at /Users/you/src/project/feature-b has local changes
40
79
  ```
41
80
 
42
81
  Commit or stash the local changes and run `git trim` again to clean up the rest.
43
82
 
83
+ **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:
84
+
85
+ ```
86
+ Removed detached worktree /Users/you/src/project/review-42 [HEAD is the head of merged PR #42]
87
+ Skipping detached worktree at /Users/you/src/project/review-43: it has local changes
88
+ ```
89
+
90
+ The main worktree and the worktree you run `git trim` from are never removed.
91
+
44
92
  ### Protected branches
45
93
 
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:
94
+ 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
95
 
48
96
  ```
49
97
  main
@@ -1,3 +1,3 @@
1
1
  class GitTrim
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.1"
3
3
  end
data/lib/git-trim.rb CHANGED
@@ -1,14 +1,71 @@
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
+ version = false
42
+ parser = OptionParser.new do |opts|
43
+ opts.banner = "Usage: git trim [--dry-run] [--no-gh]"
44
+ opts.on("-n", "--dry-run", "Show what would be removed, and why, without changing anything") { @dry_run = true }
45
+ opts.on("--[no-]gh", "Use the GitHub CLI to detect merged PRs (default: on when available)") { |value| @use_gh = value }
46
+ opts.on("-v", "--version", "Show the version") { version = true }
47
+ opts.on("-h", "--help", "Show this help") { help = true }
48
+ end
49
+ begin
50
+ parser.parse(argv || [])
51
+ rescue OptionParser::ParseError => e
52
+ warn "git-trim: #{e.message}"
53
+ warn parser.banner
54
+ return 1
55
+ end
56
+ if help
57
+ puts parser
58
+ return 0
59
+ end
60
+ if version
61
+ puts "git-trim #{VERSION}"
62
+ return 0
63
+ end
64
+ remove_instance_variable(:@merged_prs) if defined?(@merged_prs)
65
+
6
66
  %x{git fetch -ap}
7
67
 
8
- if File.exist?(".git-protected-branches")
9
- protected_branches = File.read(".git-protected-branches").split("\n")
10
- end
11
- protected_branches ||= []
68
+ protected_branches = read_protected_branches
12
69
  protected_branches << "main"
13
70
 
14
71
  base = base_ref
@@ -17,27 +74,28 @@ class GitTrim
17
74
  return 1
18
75
  end
19
76
 
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
77
  worktrees = linked_worktrees
78
+ branch_worktrees = worktrees.select { |wt| wt[:branch] }.to_h { |wt| [wt[:branch], wt[:path]] }
25
79
 
26
- branches.each do |branch|
27
- if (path = worktrees[branch])
28
- output = %x{git worktree remove #{Shellwords.escape(path)} 2>&1}
29
- unless $?.success?
30
- if output.include?("contains modified or untracked files")
31
- puts "Skipping branch '#{branch}': worktree at #{path} has local changes"
32
- else
33
- puts "Skipping branch '#{branch}': could not remove worktree at #{path}"
34
- puts output
35
- end
36
- next
37
- end
38
- puts "Removed worktree #{path}"
80
+ merged = %x{git branch --merged #{Shellwords.escape(base)} --format='%(refname:short)'}.split("\n").collect(&:strip)
81
+ excluded = protected_branches + [current_branch, main_worktree_branch].compact
82
+
83
+ local_branches.each do |branch|
84
+ next if excluded.include?(branch[:name])
85
+
86
+ proof = merge_proof(branch, base, merged)
87
+ if proof.is_a?(Proof)
88
+ trim_branch(branch[:name], proof, branch_worktrees[branch[:name]])
89
+ elsif proof.nil? && branch[:gone]
90
+ puts "Keeping branch '#{branch[:name]}': its upstream is gone but nothing proves it was merged (possibly stale)"
39
91
  end
40
- puts %x{git branch -d #{Shellwords.escape(branch)}}
92
+ end
93
+
94
+ here = current_worktree
95
+ worktrees.select { |wt| wt[:detached] }.each do |wt|
96
+ next if same_path?(wt[:path], here)
97
+ reason = detached_merge_reason(wt[:head], base)
98
+ trim_detached_worktree(wt[:path], reason) if reason
41
99
  end
42
100
 
43
101
  0
@@ -56,15 +114,10 @@ class GitTrim
56
114
  end
57
115
  end
58
116
 
59
- # Maps branch name => worktree path for linked worktrees. The first entry
117
+ # Linked worktrees as {path:, branch:, head:, detached:}. The first entry
60
118
  # in `git worktree list` is the main worktree, which is never removable.
61
119
  def linked_worktrees
62
- entries = %x{git worktree list --porcelain}.split("\n\n")
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
120
+ worktree_entries.drop(1)
68
121
  end
69
122
 
70
123
  def find_file(filename, directory=Dir.pwd)
@@ -77,4 +130,268 @@ class GitTrim
77
130
  return find_file(filename, File.expand_path("..", directory))
78
131
  end
79
132
  end
133
+
134
+ private
135
+
136
+ def read_protected_branches
137
+ path = find_file(".git-protected-branches")
138
+ return [] unless path
139
+ File.read(path).split("\n").collect(&:strip).reject(&:empty?)
140
+ end
141
+
142
+ def worktree_entries
143
+ %x{git worktree list --porcelain}.split("\n\n").collect do |entry|
144
+ {
145
+ path: entry[/^worktree (.+)$/, 1],
146
+ branch: entry[/^branch refs\/heads\/(.+)$/, 1],
147
+ head: entry[/^HEAD (\h+)$/, 1],
148
+ detached: entry.match?(/^detached$/),
149
+ }
150
+ end.select { |wt| wt[:path] }
151
+ end
152
+
153
+ def main_worktree_branch
154
+ worktree_entries.first&.fetch(:branch)
155
+ end
156
+
157
+ def current_worktree
158
+ git("rev-parse", "--show-toplevel")
159
+ end
160
+
161
+ def same_path?(a, b)
162
+ return false unless a && b
163
+ File.realpath(a) == File.realpath(b)
164
+ rescue SystemCallError
165
+ a == b
166
+ end
167
+
168
+ def local_branches
169
+ format = "%(refname:short)%09%(objectname)%09%(upstream:track)"
170
+ %x{git for-each-ref refs/heads --format=#{Shellwords.escape(format)}}.split("\n").collect do |line|
171
+ name, tip, track = line.split("\t")
172
+ { name: name, tip: tip, gone: track.to_s.include?("gone") }
173
+ end
174
+ end
175
+
176
+ # A Proof when some signal shows the branch is merged into base; :mismatch
177
+ # when a merged PR exists but the local branch has moved on (already
178
+ # reported); nil when there's no evidence either way.
179
+ def merge_proof(branch, base, merged)
180
+ return Proof.new("merged into #{base}", false) if merged.include?(branch[:name])
181
+ return Proof.new("squash-merged into #{base} (patch already in #{base})", true) if squash_merged?(base, branch[:tip])
182
+ github_merge_proof(branch)
183
+ end
184
+
185
+ # Offline squash detection: collapse the branch into one commit on top of
186
+ # its merge base, then ask `git cherry` whether base already has an
187
+ # equivalent patch. A leading "-" means it does.
188
+ def squash_merged?(base, tip)
189
+ merge_base = git("merge-base", base, tip) or return false
190
+ tree = git("rev-parse", "#{tip}^{tree}") or return false
191
+ # Nothing to compare when the branch makes no net change.
192
+ return false if tree == git("rev-parse", "#{merge_base}^{tree}")
193
+ tmp = git("commit-tree", tree, "-p", merge_base, "-m", "_", env: TMP_COMMIT_ENV) or return false
194
+ cherry = git("cherry", base, tmp) or return false
195
+ cherry.start_with?("-")
196
+ end
197
+
198
+ # Proof from GitHub: a merged PR for this branch name whose head is exactly
199
+ # the local tip. A merged PR at a different commit is not proof.
200
+ def github_merge_proof(branch)
201
+ prs = merged_prs or return nil
202
+ matching = prs.select { |pr| pr["headRefName"] == branch[:name] }
203
+ return nil if matching.empty?
204
+
205
+ if (pr = matching.find { |candidate| candidate["headRefOid"] == branch[:tip] })
206
+ return Proof.new("merged PR ##{pr["number"]}", true)
207
+ end
208
+
209
+ pr = matching.first
210
+ if ancestor?(pr["headRefOid"], branch[:tip])
211
+ puts "Skipping branch '#{branch[:name]}': it has commits that merged PR ##{pr["number"]} never had"
212
+ else
213
+ puts "Skipping branch '#{branch[:name]}': its tip doesn't match the head of merged PR ##{pr["number"]}"
214
+ end
215
+ :mismatch
216
+ end
217
+
218
+ def detached_merge_reason(head, base)
219
+ return nil unless head
220
+ return "HEAD is in #{base}" if ancestor?(head, base)
221
+ pr = merged_prs&.find { |candidate| candidate["headRefOid"] == head }
222
+ "HEAD is the head of merged PR ##{pr["number"]}" if pr
223
+ end
224
+
225
+ def trim_branch(name, proof, worktree_path)
226
+ if @dry_run
227
+ if worktree_path && dirty?(worktree_path)
228
+ puts "Would skip branch '#{name}': worktree at #{worktree_path} has local changes"
229
+ return
230
+ end
231
+ puts "Would remove worktree #{worktree_path} (#{proof.reason})" if worktree_path
232
+ puts "Would delete branch #{name} (#{proof.reason})"
233
+ return
234
+ end
235
+
236
+ if worktree_path
237
+ output = %x{git worktree remove #{Shellwords.escape(worktree_path)} 2>&1}
238
+ unless $?.success?
239
+ if output.include?("contains modified or untracked files")
240
+ puts "Skipping branch '#{name}': worktree at #{worktree_path} has local changes"
241
+ else
242
+ puts "Skipping branch '#{name}': could not remove worktree at #{worktree_path}"
243
+ puts output
244
+ end
245
+ return
246
+ end
247
+ puts "Removed worktree #{worktree_path}"
248
+ end
249
+
250
+ flag = proof.force ? "-D" : "-d"
251
+ output = %x{git branch #{flag} #{Shellwords.escape(name)} 2>&1}
252
+ if $?.success?
253
+ puts "#{output.strip} [#{proof.reason}]"
254
+ else
255
+ puts "Skipping branch '#{name}': could not delete it"
256
+ puts output
257
+ end
258
+ end
259
+
260
+ def trim_detached_worktree(path, reason)
261
+ if @dry_run
262
+ if dirty?(path)
263
+ puts "Would skip detached worktree at #{path}: it has local changes"
264
+ else
265
+ puts "Would remove detached worktree #{path} (#{reason})"
266
+ end
267
+ return
268
+ end
269
+
270
+ output = %x{git worktree remove #{Shellwords.escape(path)} 2>&1}
271
+ if $?.success?
272
+ puts "Removed detached worktree #{path} [#{reason}]"
273
+ elsif output.include?("contains modified or untracked files")
274
+ puts "Skipping detached worktree at #{path}: it has local changes"
275
+ else
276
+ puts "Skipping detached worktree at #{path}: could not remove it"
277
+ puts output
278
+ end
279
+ end
280
+
281
+ def dirty?(path)
282
+ status = git("-C", path, "status", "--porcelain")
283
+ status.nil? || !status.empty?
284
+ end
285
+
286
+ def ancestor?(commit, descendant)
287
+ _, _, status = Open3.capture3("git", "merge-base", "--is-ancestor", commit, descendant)
288
+ status.success?
289
+ end
290
+
291
+ # Stripped stdout of a git command, or nil if it failed.
292
+ def git(*args, env: {})
293
+ out, _, status = Open3.capture3(env, "git", *args)
294
+ out.strip if status.success?
295
+ end
296
+
297
+ # Merged PRs from GitHub as [{"number", "headRefName", "headRefOid"}], or
298
+ # nil when gh can't give a trustworthy answer. Fetched at most once, and
299
+ # only if some branch or worktree actually needs it.
300
+ def merged_prs
301
+ return @merged_prs if defined?(@merged_prs)
302
+ @merged_prs = fetch_merged_prs
303
+ end
304
+
305
+ def fetch_merged_prs
306
+ return nil unless @use_gh
307
+ return gh_unavailable("gh not installed") unless system("command -v gh >/dev/null 2>&1")
308
+ return gh_unavailable("no GitHub remote") unless github_remote?
309
+
310
+ status, = gh("auth", "status")
311
+ return gh_unavailable("gh timed out after #{@gh_timeout}s") if status == :timeout
312
+ unless status&.success?
313
+ # `auth status` checks the token online and reports a network failure
314
+ # as an invalid token. `auth token` is offline, so it tells "never
315
+ # logged in" apart from "logged in but couldn't verify".
316
+ token_status, = gh("auth", "token")
317
+ return gh_unavailable("gh not logged in") unless token_status.is_a?(Process::Status) && token_status.success?
318
+ return gh_unavailable("gh couldn't verify its login (GitHub unreachable or token rejected)")
319
+ end
320
+
321
+ status, out, err = gh("pr", "list", "--state", "merged", "--limit", GH_PR_LIMIT.to_s,
322
+ "--json", "number,headRefName,headRefOid")
323
+ return gh_unavailable("gh timed out after #{@gh_timeout}s") if status == :timeout
324
+ return gh_unavailable(gh_failure(err)) unless status&.success?
325
+
326
+ parse_prs(out) || gh_unavailable("gh returned unparseable output")
327
+ end
328
+
329
+ def gh_unavailable(reason)
330
+ warn "git-trim: #{reason}; skipping GitHub PR check, using git-only detection"
331
+ nil
332
+ end
333
+
334
+ def gh_failure(stderr)
335
+ case stderr
336
+ when /rate limit/i then "GitHub API rate limit hit"
337
+ when /could not resolve|no such host|dial tcp|connection refused|network is unreachable|i\/o timeout|tls/i
338
+ "can't reach GitHub"
339
+ else "gh pr list failed"
340
+ end
341
+ end
342
+
343
+ # Raw remote URLs (not insteadOf-rewritten), since gh resolves the repo
344
+ # from those too.
345
+ def github_remote?
346
+ hosts = ["github.com", ENV["GH_HOST"]].compact.reject(&:empty?)
347
+ urls = git("config", "--get-regexp", '^remote\..*\.url$').to_s.split("\n")
348
+ urls.any? { |line| hosts.any? { |host| line.include?(host) } }
349
+ end
350
+
351
+ # An error, empty output or a malformed payload is never evidence.
352
+ def parse_prs(json)
353
+ prs = JSON.parse(json)
354
+ return nil unless prs.is_a?(Array)
355
+ valid = prs.all? do |pr|
356
+ pr.is_a?(Hash) && pr["headRefName"].is_a?(String) && !pr["headRefName"].empty? &&
357
+ pr["headRefOid"].is_a?(String) && pr["headRefOid"].match?(/\A(\h{40}|\h{64})\z/)
358
+ end
359
+ prs if valid
360
+ rescue JSON::ParserError
361
+ nil
362
+ end
363
+
364
+ # Runs gh with no TTY and a hard deadline. Returns [status, stdout, stderr],
365
+ # with status :timeout if gh was killed, or nil if it couldn't start.
366
+ def gh(*args)
367
+ out_r, out_w = IO.pipe
368
+ err_r, err_w = IO.pipe
369
+ begin
370
+ pid = Process.spawn(GH_ENV, "gh", *args, in: File::NULL, out: out_w, err: err_w, pgroup: true)
371
+ rescue SystemCallError
372
+ return [nil, "", ""]
373
+ ensure
374
+ out_w.close
375
+ err_w.close
376
+ end
377
+
378
+ readers = [out_r, err_r].collect { |io| Thread.new { io.read } }
379
+ waiter = Thread.new { Process.wait2(pid).last }
380
+ status = if waiter.join(@gh_timeout)
381
+ waiter.value
382
+ else
383
+ # Kill the whole process group so children holding the pipes die too.
384
+ begin
385
+ Process.kill("KILL", -pid)
386
+ rescue SystemCallError
387
+ nil
388
+ end
389
+ waiter.join
390
+ :timeout
391
+ end
392
+ [status, *readers.collect(&:value)]
393
+ ensure
394
+ out_r&.close
395
+ err_r&.close
396
+ end
80
397
  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.2.0
4
+ version: 0.3.1
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-06-12 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