├── .gitignore ├── LICENSE ├── README.md ├── cmd └── brew-graph.rb └── docs ├── node_dependencies_w_reduction.png └── node_dependencies_wo_reduction.png /.gitignore: -------------------------------------------------------------------------------- 1 | .redcar 2 | .idea 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-Present Martin Dobmeier 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Attention:** This repository has been renamed from `brew-graph` to `homebrew-graph` to adhere to the Homebrew naming conventions of [tap repositories][3]. 2 | Please update your local clones or forks (for consistency only, GitHub makes sure everything still works for you): 3 | 4 | git remote set-url origin https://github.com/martido/homebrew-graph 5 | 6 | # brew-graph 7 | 8 | 1. [Installation](#installation) 9 | 2. [Usage](#usage) 10 | 3. [Requirements](#requirements) 11 | 4. [Transitive Reduction](#transitive-reduction) 12 | 5. [Upstream Dependencies](#upstream-dependencies) 13 | 6. [Known Issues](#known-issues) 14 | 15 | `brew-graph` is a Ruby script that creates a dependency graph of Homebrew formulae. The currently supported output options are *DOT* and *GraphML*. 16 | 17 | If you would like to know more about [Untangling Your Homebrew Dependencies][2], check out the blog post by Jonathan Palardy. 18 | 19 | ## Installation 20 | 21 | Tapping this repo is the only installation step required! There is no need for a separate `brew install` step. 22 | 23 | brew tap martido/homebrew-graph 24 | 25 | **Note:** If you already have the brew-graph formula installed from [the old tap repository][4], uninstall it first: 26 | 27 | brew uninstall brew-graph 28 | brew untap martido/homebrew-brew-graph 29 | 30 | ## Usage 31 | 32 | Type `brew graph --help`. 33 | 34 | brew graph [options] formula1 formula2 ... | --installed | --all 35 | 36 | Create a dependency graph of Homebrew formulae. 37 | 38 | Options: 39 | 40 | -h, --help Print this help message. 41 | -f, --format FORMAT Specify FORMAT of graph (dot, graphml). Default: dot 42 | -o, --output FILE Write output to FILE instead of stdout 43 | --highlight-leaves Highlight formulae that are not dependencies of another 44 | installed formula. Default: false 45 | --highlight-outdated Highlight outdated formulae. Default: false 46 | --include-casks Include casks in the graph. Default: false 47 | --reduce Apply transitive reduction to graph. Default: false 48 | --installed Create graph for installed Homebrew formulae 49 | --all Create graph for all Homebrew formulae 50 | 51 | Examples: 52 | 53 | brew graph --installed 54 | Create a dependency graph of installed formulae and 55 | print it in DOT format to stdout. 56 | 57 | brew graph -f graphml --installed 58 | Same as before, but output GraphML markup. 59 | 60 | brew graph graphviz python 61 | Create a dependency graph of 'graphviz' and 'python' and 62 | print it in DOT format to stdout. 63 | 64 | brew graph -f graphml -o deps.graphml graphviz python 65 | Same as before, but output GraphML markup to a file named 'deps.graphml'. 66 | 67 | ## Requirements 68 | You can use Graphviz to visualize DOT graphs. 69 | 70 | brew install graphviz 71 | brew graph --installed | dot -Tpng -ograph.png 72 | open graph.png 73 | 74 | You can also use different Graphviz layouts, such as `fdp`. Simply replace `dot` with `fdp`: 75 | 76 | brew graph --installed | fdp -Tpng -ograph.png 77 | 78 | You can use the [yEd][1] graph editor to visualize GraphML markup. The created markup uses yFiles's extensions to GraphML and heavily relies on defaults to keep the output reasonably small. It contains no layout information because yEd already provides an exhaustive set of algorithms. 79 | 80 | ## Transitive Reduction 81 | 82 | The `--reduce` option applies a [transitive reduction][5] to the dependency graph. 83 | 84 | Let's take Node.js as an example. This is the dependency graph: 85 | 86 | ![node_dependencies_wo_reduction](docs/node_dependencies_wo_reduction.png "Node.js dependencies w/o reduction") 87 | 88 | `openssl@1.1` is a dependency of both `node` and `python@3.9` which `node` itself depends on. Similarly, `readline` is both a depedency of `python@3.9` and `sqlite`. 89 | 90 | Transitive reduction simplifies the graph by removing direct edges in favor of transitive dependencies: 91 | 92 | ![node_dependencies_w_reduction](docs/node_dependencies_w_reduction.png "Node.js dependencies w/ reduction") 93 | 94 | Contributed by [Nakilon][6]. 95 | 96 | ## Upstream Dependencies 97 | 98 | `brew-graph` only shows you the downstream dependencies of your installed formulae or arbitrary formulae arguments. If you would like to know which of your installed formulae depend on a given formula, you can use something like the following: 99 | 100 | brew deps -1 --installed | grep ':.*FORMULA' | awk -F':' '{print $1}' 101 | 102 | ## Known Issues 103 | 104 | There's an issue with Homebrew that dependencies are not listed correctly with `brew deps` if they are too outdated. This is described in the brew-graph issue [#13][7] and is also mentioned in [this Homebrew discussion thread][8]. So far, I've managed to resolve the issue everytime by upgrading dependencies with `brew upgrade`. 105 | 106 | [1]: http://www.yworks.com/en/products_yed_about.html 107 | [2]: http://blog.jpalardy.com/posts/untangling-your-homebrew-dependencies 108 | [3]: https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap 109 | [4]: https://github.com/martido/homebrew-brew-graph 110 | [5]: https://en.wikipedia.org/wiki/Transitive_reduction 111 | [6]: https://github.com/Nakilon 112 | [7]: https://github.com/martido/homebrew-graph/issues/13 113 | [8]: https://github.com/Homebrew/discussions/discussions/1574 114 | -------------------------------------------------------------------------------- /cmd/brew-graph.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | #:`brew graph` [options] ... | `--installed` | `--all` 4 | #: 5 | #:Create a dependency graph of Homebrew formulae. 6 | #: 7 | #:Options: 8 | #: 9 | #: `-h`, `--help` Print this help message. 10 | #: `-f`, `--format FORMAT` Specify FORMAT of graph (dot, graphml). Default: dot 11 | #: `-o`, `--output FILE` Write output to FILE instead of stdout 12 | #: `--highlight-leaves` Highlight formulae that are not dependencies of another 13 | #: installed formula. Default: false 14 | #: `--highlight-outdated` Highlight outdated formulae. Default: false 15 | #: `--include-casks` Include casks in the graph. Default: false 16 | #: `--reduce` Apply transitive reduction to graph. Default: false 17 | #: `--installed` Create graph for installed Homebrew formulae 18 | #: `--all` Create graph for all Homebrew formulae 19 | #: 20 | #:Examples: 21 | #: 22 | #:`brew graph` `--installed` 23 | #: Create a dependency graph of installed formulae and 24 | #: print it in DOT format to stdout. 25 | #: 26 | #:`brew graph` `-f` graphml `--installed` 27 | #: Same as before, but output GraphML markup. 28 | #: 29 | #:`brew graph` 30 | #: Create a dependency graph of 'graphviz' and 'python' and 31 | #: print it in DOT format to stdout. 32 | #: 33 | #:`brew graph` `-f` graphml `-o` deps.graphml 34 | #: Same as before, but output GraphML markup to a file named 'deps.graphml'. 35 | 36 | require 'optparse' 37 | 38 | class BrewGraph 39 | 40 | def initialize(argv) 41 | @options = parse_options(argv) 42 | 43 | # Assume that any remaining arguments are formula names. 44 | if argv.length >= 1 45 | @formulae = argv.dup 46 | end 47 | end 48 | 49 | def run 50 | format = @options[:format] 51 | output = @options[:output] 52 | highlight_leaves = @options[:highlight_leaves] 53 | highlight_outdated = @options[:highlight_outdated] 54 | include_casks = @options[:include_casks] 55 | reduce = @options[:reduce] 56 | installed = @options[:installed] 57 | all = @options[:all] 58 | 59 | data = if installed 60 | deps(:installed, include_casks) 61 | elsif all 62 | deps(:all, include_casks) 63 | elsif @formulae 64 | deps(@formulae, false) 65 | else 66 | abort %Q{This command requires one of --installed or --all, or one or more formula arguments. 67 | See brew graph --help.} 68 | end 69 | 70 | if reduce 71 | reduce(data) 72 | end 73 | 74 | if installed 75 | remove_optional_deps(data) 76 | end 77 | 78 | graph = case format 79 | when :dot then Dot.new(data, highlight_leaves, highlight_outdated && outdated) 80 | when :graphml then GraphML.new(data, highlight_leaves, highlight_outdated && outdated) 81 | else abort "Format #{format} not support. Support formats are: dot, graphml" 82 | end 83 | 84 | if output 85 | File.open(output, 'w') { |file| file.write(graph) } 86 | else 87 | puts graph 88 | end 89 | end 90 | 91 | private 92 | 93 | def parse_options(argv) 94 | options = {} 95 | options[:format] = :dot 96 | options[:highlight_leaves] = false 97 | options[:highlight_outdated] = false 98 | options[:include_casks] = false 99 | options[:reduce] = false 100 | options[:installed] = false 101 | options[:all] = false 102 | 103 | opts = OptionParser.new do |opts| 104 | 105 | opts.banner = 'Usage: brew-graph [options] ... | --installed | --all' 106 | 107 | opts.on('-h', '--help' ) do 108 | puts opts 109 | exit 110 | end 111 | 112 | opts.on('-f', '--format FORMAT', [:dot, :graphml], 113 | 'Specify FORMAT of graph (dot, graphml). Default: dot') do |f| 114 | options[:format] = f 115 | end 116 | 117 | opts.on('-o', '--output FILE', 118 | 'Write output to FILE instead of stdout') do |o| 119 | options[:output] = o 120 | end 121 | 122 | opts.on('--highlight-leaves', [:highlight_leaves], 123 | "Highlight formulae that are not dependencies of another installed formula. Default: false") do 124 | options[:highlight_leaves] = true 125 | end 126 | 127 | opts.on('--highlight-outdated', [:highlight_outdated], 128 | "Highlight outdated formulae. Default: false") do 129 | options[:highlight_outdated] = true 130 | end 131 | 132 | opts.on('--include-casks', [:include_casks], 133 | 'Include casks in the graph. Default: false') do 134 | options[:include_casks] = true 135 | end 136 | 137 | opts.on('--reduce', [:reduce], 138 | 'Apply transitive reduction to graph. Default: false') do 139 | options[:reduce] = true 140 | end 141 | 142 | opts.on('--installed', [:installed], 143 | 'Create graph for installed Homebrew formulae') do 144 | options[:installed] = true 145 | end 146 | 147 | opts.on('--all', [:all], 148 | 'Create graph for all Homebrew formulae') do 149 | options[:all] = true 150 | end 151 | 152 | end 153 | 154 | begin 155 | opts.parse!(argv) 156 | rescue OptionParser::InvalidOption, 157 | OptionParser::InvalidArgument, 158 | OptionParser::MissingArgument => e 159 | abort "#{e.message.capitalize}\nSee brew graph --help." 160 | end 161 | 162 | options 163 | end 164 | 165 | def deps(arg, include_casks) 166 | data = {} 167 | deps = brew_deps(arg, include_casks).split("\n") 168 | deps.each do |s| 169 | node,deps = s.split(':') 170 | data[node] = deps.nil? ? [] : deps.strip.split(' ').uniq 171 | end 172 | data 173 | end 174 | 175 | def brew_deps(arg, include_casks) 176 | include_casks_arg = include_casks ? nil : '--formulae' 177 | 178 | case arg 179 | when :all then %x[brew deps --1 --full-name --all] # --formulae results in a Homebrew error with option --all 180 | when :installed then %x[brew deps --1 --full-name --installed #{include_casks_arg}] 181 | else # Treat arg as a list of formulae 182 | res = {} 183 | brew_deps_formulae(res, arg.join(' ')) 184 | res.map { |k, v| "#{k}: #{v}" }.join("\n") 185 | end 186 | end 187 | 188 | # Gets the first-level dependencies of the input formulae and recurses 189 | # down to the leaves to get the complete dependency graph. 190 | # 191 | # The output of `brew deps --for-each --1 ` is of the form: 192 | # formula1: dep1 dep2 dep3 ... 193 | # formula2: dep1 dep2 dep3 ... 194 | # We need to add additional lines 195 | # dep1: 196 | # dep2: 197 | # dep3: 198 | # for all dependencies. 199 | # This is consistent with the output of 'brew deps --installed'. 200 | # Also, the GraphML markup language requires a separate 201 | # block for each node in the graph. 202 | def brew_deps_formulae(res, arg) 203 | out = %x[brew deps --for-each --1 #{arg}] 204 | unless $? == 0 # Check exit code 205 | abort 206 | end 207 | out.split("\n").each do |line| 208 | formula,deps = line.split(':') 209 | unless res.has_key? formula 210 | deps = deps.strip 211 | res[formula] = deps 212 | unless deps.empty? 213 | brew_deps_formulae(res, deps) 214 | end 215 | end 216 | end 217 | end 218 | 219 | def outdated 220 | brew_outdated.split("\n") 221 | end 222 | 223 | def brew_outdated 224 | %x[brew outdated] 225 | end 226 | 227 | def reduce(data) 228 | graph_to_edges = -> _ { _.flat_map { |k, vs| vs.map { |v| [k, v] } } } 229 | 230 | # For each formula, will create an array of edges from the target 231 | # dependencies to their source, taking transitive dependencies into 232 | # account. 233 | # 234 | # Example: webp 235 | # Dependencies: webp => [giflib, jpeg, libpng, libtiff] 236 | # Result: [[giflib, webp], [jpeg, libtiff], [libpng, webp], [libtiff, webp]] 237 | # This is because there are edges [webp, jpeg], [webp, libtiff] and [libtiff, jpeg] 238 | edges = data.keys.flat_map do |start| 239 | atad = {} 240 | layer = [start] 241 | until layer.empty? 242 | layer = layer 243 | .flat_map { |a| data[a].map { |b| [a, b] } } 244 | .group_by(&:last) 245 | .each { |b, g| atad[b] = g.map(&:first) } 246 | .map(&:first) 247 | end 248 | graph_to_edges[atad] 249 | end 250 | 251 | # Transform edges from [target, source] back to [source, target]. 252 | # 253 | # Example: webp 254 | # Result: 255 | # webp => [giflib, libpng, libtiff] 256 | # libtiff => [jpeg] 257 | edges 258 | .uniq 259 | .group_by(&:last) 260 | .each { |v, g| data[v] = g.map(&:first) } 261 | end 262 | 263 | # Remove uninstalled, optional dependencies 264 | def remove_optional_deps(data) 265 | data.each_value do |deps| 266 | deps.keep_if do |dep| 267 | data.include?(dep) 268 | end 269 | end 270 | end 271 | end 272 | 273 | class Graph 274 | 275 | def initialize(data, highlight_leaves, outdated) 276 | @data = data 277 | @dependencies = data.values.flatten.uniq 278 | @highlight_leaves = highlight_leaves 279 | @outdated = outdated 280 | end 281 | 282 | def is_leaf?(node) 283 | !@dependencies.include?(node) 284 | end 285 | 286 | def is_outdated?(node) 287 | @outdated.include?(node) 288 | end 289 | end 290 | 291 | class Dot < Graph 292 | 293 | def to_s 294 | dot = [] 295 | dot << 'digraph G {' 296 | @data.each_key do |node| 297 | dot << create_node(node, @highlight_leaves && is_leaf?(node), @outdated && is_outdated?(node)) 298 | end 299 | @data.each_pair do |source, targets| 300 | targets.each do |target| 301 | dot << create_edge(source, target) 302 | end 303 | end 304 | dot << '}' 305 | dot.join("\n") 306 | end 307 | 308 | private 309 | 310 | def create_node(node, is_leaf, is_outdated) 311 | %Q( "#{node}"#{is_outdated ? ' [style=filled;color=red2]' : is_leaf ? ' [style=filled]' : ''};) 312 | end 313 | 314 | def create_edge(source, target) 315 | %Q( "#{source}" -> "#{target}";) 316 | end 317 | end 318 | 319 | class GraphML < Graph 320 | 321 | def to_s 322 | out = [] 323 | out << header 324 | out << ' ' 325 | @data.each_key do |node| 326 | out << create_node(node, @highlight_leaves && is_leaf?(node), @outdated && is_outdated?(node)) 327 | end 328 | @data.each_pair do |source, targets| 329 | targets.each do |target| 330 | out << create_edge(source, target) 331 | end 332 | end 333 | out << ' ' 334 | out << '' 335 | out.join("\n") 336 | end 337 | 338 | private 339 | 340 | def header 341 | <<-EOS 342 | 343 | 351 | 352 | 353 | EOS 354 | end 355 | 356 | def create_node(node, is_leaf, is_outdated) 357 | fill_color = is_outdated ? '#FF6666': is_leaf ? '#C0C0C0' : '#FFFFFF' 358 | <<-EOS 359 | 360 | 361 | 362 | #{node} 363 | 364 | 365 | 366 | 367 | 368 | EOS 369 | end 370 | 371 | def create_edge(source, target) 372 | @edge_id ||= 0 373 | @edge_id += 1 374 | <<-EOS 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | EOS 384 | end 385 | end 386 | 387 | if RUBY_VERSION =~ /1\.8/ 388 | class Hash 389 | def keep_if(&block) 390 | delete_if do |key, value| 391 | !block.call(key, value) 392 | end 393 | end 394 | end 395 | 396 | class Array 397 | def keep_if(&block) 398 | delete_if do |elem| 399 | !block.call(elem) 400 | end 401 | end 402 | end 403 | end 404 | 405 | brew = BrewGraph.new(ARGV) 406 | brew.run 407 | -------------------------------------------------------------------------------- /docs/node_dependencies_w_reduction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martido/homebrew-graph/05f4def426608a226d8c6ee2ca1edd1696443f43/docs/node_dependencies_w_reduction.png -------------------------------------------------------------------------------- /docs/node_dependencies_wo_reduction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martido/homebrew-graph/05f4def426608a226d8c6ee2ca1edd1696443f43/docs/node_dependencies_wo_reduction.png --------------------------------------------------------------------------------