├── .gitignore ├── usage.txt ├── package.json ├── README.md ├── LICENSE.txt └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /usage.txt: -------------------------------------------------------------------------------- 1 | SYNOPSIS 2 | recursive-blame [options] PATTERN FILE 3 | recursive-blame [options] [-p PATTERN | -e PATTERN] FILE 4 | 5 | DESCRIPTION 6 | Recursive-blame searches the history of a file for lines containing 7 | a match to the given PATTERN. 8 | 9 | OPTIONS 10 | -p PATTERN, --pattern=PATTERN 11 | The pattern to search for. 12 | 13 | -f FILE, --file=FILE 14 | Which file to search for the PATTERN. 15 | 16 | -e[ PATTERN], --regexp[=PATTERN] 17 | Treat the pattern as a regular expression. 18 | 19 | -C NUM, --context=NUM 20 | Print NUM lines of output context. Defaults to 4. 21 | 22 | -c COMMITTISH, --committish=COMMITTISH 23 | Start searching the history from COMMITTISH. Defaults to HEAD. 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "recursive-blame", 3 | "version": "0.3.0", 4 | "description": "Recursive blame for Git", 5 | "main": "index.js", 6 | "keywords": [ 7 | "git", 8 | "blame" 9 | ], 10 | "homepage": "https://github.com/scottgonzalez/recursive-blame", 11 | "bugs": "https://github.com/scottgonzalez/recursive-blame/issues", 12 | "author": { 13 | "name": "Scott González", 14 | "email": "scott.gonzalez@gmail.com", 15 | "url": "http://scottgonzalez.com" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git://github.com/scottgonzalez/recursive-blame.git" 20 | }, 21 | "license": "MIT", 22 | "bin": { 23 | "recursive-blame": "./index.js", 24 | "git-recursive-blame": "./index.js" 25 | }, 26 | "dependencies": { 27 | "colors": "0.6.2", 28 | "git-tools": "0.1.3", 29 | "minimist": "0.0.8" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Recursive blame for Git 2 | 3 | Recursively blame files, filtered by patterns. 4 | 5 | Support this project by [donating on Gratipay](https://gratipay.com/scottgonzalez/). 6 | 7 | ## About 8 | 9 | I often need to trace a blame through many revisions to figure out when a specific change was introduced. This is painful using git or GitHub, especially if the file was renamed at some point. `recursive-blame` makes tracing through history dead simple. 10 | 11 | 12 | 13 | ## Installation 14 | 15 | ```sh 16 | npm install -g recursive-blame 17 | ``` 18 | 19 | 20 | 21 | ## Usage 22 | 23 | ```sh 24 | recursive-blame 25 | ``` 26 | 27 | OR: 28 | 29 | ```sh 30 | git recursive-blame 31 | ``` 32 | 33 | See [usage.txt](/usage.txt) for full usage documenation. 34 | 35 | 36 | 37 | ## License 38 | 39 | Copyright Scott González. Released under the terms of the MIT license. 40 | 41 | --- 42 | 43 | Support this project by [donating on Gratipay](https://gratipay.com/scottgonzalez/). 44 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright Scott González http://scottgonzalez.com 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require( "fs" ); 4 | var readline = require( "readline" ); 5 | var Repo = require( "git-tools" ); 6 | var colors = require( "colors" ); 7 | var minimist = require( "minimist" ); 8 | 9 | // Parse arguments 10 | var args = minimist( process.argv.slice( 2 ), { 11 | alias: { 12 | f: "file", 13 | p: "pattern", 14 | C: "context", 15 | c: "committish", 16 | e: "regexp" 17 | } 18 | }); 19 | var path = args.file || args._.pop(); 20 | var rawPattern = args.pattern || args._.pop(); 21 | var useRegexp = args.regexp || false; 22 | var committish = args.committish || "HEAD"; 23 | var initialContext = args.context || 4; 24 | 25 | // Determine pattern 26 | if ( typeof rawPattern !== "string" ) { 27 | rawPattern = ""; 28 | } 29 | if ( !useRegexp ) { 30 | rawPattern = rawPattern.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1" ); 31 | } else if ( typeof useRegexp === "string" ) { 32 | rawPattern = useRegexp; 33 | } 34 | var pattern = new RegExp( rawPattern ); 35 | 36 | // Check for required arguments 37 | if ( !path || pattern.source === "(?:)" ) { 38 | console.log( "\n" + fs.readFileSync( __dirname + "/usage.txt", "utf-8" ) ); 39 | process.exit( 1 ); 40 | } 41 | 42 | var repo = new Repo( "." ); 43 | var actionPrompt = "Next action [r,n,p,c,d,q,?]?"; 44 | var isFirst = true; 45 | var walking = false; 46 | 47 | process.stdin.setEncoding( "utf8" ); 48 | 49 | function prompt( message, fn ) { 50 | var rl = readline.createInterface({ 51 | input: process.stdin, 52 | output: process.stdout 53 | }); 54 | rl.question( message + " ", function( answer ) { 55 | rl.close(); 56 | fn( null, answer ); 57 | }); 58 | } 59 | 60 | function blame( options, callback ) { 61 | var context = initialContext; 62 | repo.blame( options, function( error, blame ) { 63 | if ( error ) { 64 | return callback( error ); 65 | } 66 | 67 | var patternIndex = 0; 68 | var patternMatches = blame.filter(function( line ) { 69 | return pattern.test( line.content ); 70 | }); 71 | 72 | if ( !patternMatches.length ) { 73 | return callback( null, null ); 74 | } 75 | 76 | function doAction( error, action ) { 77 | if ( error ) { 78 | return callback( error ); 79 | } 80 | 81 | if ( action === "q" ) { 82 | process.exit(); 83 | } 84 | 85 | if ( action === "r" ) { 86 | return callback( null, patternMatches[ patternIndex ] ); 87 | } 88 | 89 | if ( action === "n" ) { 90 | if ( patternIndex === patternMatches.length - 1 ) { 91 | console.log( "No more matches." ); 92 | return prompt( actionPrompt, doAction ); 93 | } 94 | 95 | patternIndex++; 96 | return show(); 97 | } 98 | 99 | if ( action === "p" ) { 100 | if ( patternIndex === 0 ) { 101 | console.log( "No previous matches." ); 102 | return prompt( actionPrompt, doAction ); 103 | } 104 | 105 | patternIndex--; 106 | return show(); 107 | } 108 | 109 | if ( action === "c" ) { 110 | context = Math.ceil( context * 1.5 ); 111 | return show(); 112 | } 113 | 114 | if ( action === "d" ) { 115 | return showDiff({ 116 | commit: patternMatches[ patternIndex ].commit, 117 | path: patternMatches[ patternIndex ].path 118 | }, function( error ) { 119 | if ( error ) { 120 | return callback( error ); 121 | } 122 | 123 | prompt( actionPrompt, doAction ); 124 | }); 125 | } 126 | 127 | showHelp(); 128 | show(); 129 | } 130 | 131 | function show() { 132 | if ( isFirst && walking ) { 133 | isFirst = false; 134 | return repo.resolveCommittish( options.committish.slice( 0, -1 ), function( error, sha ) { 135 | if ( error ) { 136 | console.log( error ); 137 | return; 138 | } 139 | 140 | console.log( "\n\nPattern removed in " + sha.red ); 141 | show(); 142 | }); 143 | } 144 | 145 | showPatternMatch({ 146 | full: blame, 147 | patternMatches: patternMatches, 148 | patternIndex: patternIndex, 149 | path: options.path, 150 | context: context 151 | }, doAction ); 152 | } 153 | 154 | show(); 155 | }); 156 | } 157 | 158 | function showHelp() { 159 | console.log( "r - recurse; view previous revision" ); 160 | console.log( "n - view next match in current revision" ); 161 | console.log( "p - view previous match in current revision" ); 162 | console.log( "c - increase context" ); 163 | console.log( "d - view diff for current revision" ); 164 | console.log( "q - quit" ); 165 | console.log( "? - show help" ); 166 | } 167 | 168 | function showPatternMatch( blame, callback ) { 169 | var totalLines = blame.full.length; 170 | var patternIndex = blame.patternIndex + 1; 171 | var patternCount = blame.patternMatches.length; 172 | var line = blame.patternMatches[ blame.patternIndex ]; 173 | var context = blame.context; 174 | var format = 175 | "Commit: %C(yellow)%H%Creset\n" + 176 | "Author: %aN <%aE>\n" + 177 | "Date: %cd (%cr)\n" + 178 | "Path: " + blame.path + "\n" + 179 | "Match: " + patternIndex + " of " + patternCount + "\n" + 180 | "\n" + 181 | " %s\n"; 182 | 183 | repo.exec( "log", "--pretty=" + format, "-1", line.commit, function( error, commitInfo ) { 184 | if ( error ) { 185 | return callback( error ); 186 | } 187 | 188 | console.log( "\n" + commitInfo + "\n" ); 189 | 190 | var lineOutput; 191 | for ( var i = Math.max( 0, line.lineNumber - context - 1 ); 192 | i < Math.min( totalLines, line.lineNumber + context ); 193 | i++ ) { 194 | // TODO: padding for line numbers 195 | lineOutput = blame.full[ i ].lineNumber + ") " + blame.full[ i ].content; 196 | if ( i === line.lineNumber - 1 ) { 197 | lineOutput = lineOutput.cyan; 198 | } 199 | console.log( lineOutput ); 200 | } 201 | 202 | console.log( "" ); 203 | prompt( actionPrompt, callback ); 204 | }); 205 | } 206 | 207 | function showDiff( options, callback ) { 208 | repo.exec( "diff", "--color", options.commit + "^.." + options.commit, "--", options.path, function( error, diff ) { 209 | if ( error ) { 210 | return callback( error ); 211 | } 212 | 213 | console.log( diff ); 214 | callback( null ); 215 | }); 216 | } 217 | 218 | function recur( options ) { 219 | blame( options, function( error, line ) { 220 | if ( error ) { 221 | console.log( error ); 222 | return; 223 | } 224 | 225 | if ( !line ) { 226 | if ( !isFirst ) { 227 | console.log( "No matches. Recursive blame complete." ); 228 | return; 229 | } 230 | 231 | if ( walking ) { 232 | process.stdout.write( new Array( walking.toString().length + 1 ).join( "\b" ) ); 233 | process.stdout.write( "" + (++walking) ); 234 | 235 | return recur({ 236 | path: options.path, 237 | committish: options.committish + "^" 238 | }); 239 | } 240 | 241 | return prompt( "No matches found. Walk through previous revisions?", function( error, action ) { 242 | if ( error ) { 243 | console.log( error ); 244 | return; 245 | } 246 | 247 | if ( action !== "y" ) { 248 | return; 249 | } 250 | 251 | walking = 1; 252 | process.stdout.write( "Walking revisions: " + walking ); 253 | 254 | recur({ 255 | path: options.path, 256 | committish: options.committish + "^" 257 | }); 258 | }); 259 | } 260 | 261 | isFirst = false; 262 | recur({ 263 | path: line.path, 264 | committish: line.commit + "^" 265 | }); 266 | }); 267 | } 268 | 269 | recur({ 270 | path: path, 271 | committish: committish 272 | }); 273 | --------------------------------------------------------------------------------