Skip to content

Commit 71e0f8b

Browse files
author
y-p
committed
Merge pull request #3316 from y-p/tooling_find_touchy
SCR: add a script for tracking down all commits touching a named method
2 parents 5ea9d01 + 987e0df commit 71e0f8b

File tree

1 file changed

+201
-0
lines changed

1 file changed

+201
-0
lines changed

scripts/find_commits_touching_func.py

+201
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
4+
# copryright 2013, y-p @ github
5+
6+
from __future__ import print_function
7+
8+
"""Search the git history for all commits touching a named method
9+
10+
You need the sh module to run this
11+
WARNING: this script uses git clean -f, running it on a repo with untracked files
12+
will probably erase them.
13+
"""
14+
import logging
15+
import re
16+
import os
17+
from collections import namedtuple
18+
from dateutil import parser
19+
20+
try:
21+
import sh
22+
except ImportError:
23+
raise ImportError("The 'sh' package is required in order to run this script. ")
24+
25+
import argparse
26+
27+
desc = """
28+
Find all commits touching a sepcified function across the codebase.
29+
""".strip()
30+
argparser = argparse.ArgumentParser(description=desc)
31+
argparser.add_argument('funcname', metavar='FUNCNAME',
32+
help='Name of function/method to search for changes on.')
33+
argparser.add_argument('-f', '--file-masks', metavar='f_re(,f_re)*',
34+
default=["\.py.?$"],
35+
help='comma seperated list of regexes to match filenames against\n'+
36+
'defaults all .py? files')
37+
argparser.add_argument('-d', '--dir-masks', metavar='d_re(,d_re)*',
38+
default=[],
39+
help='comma seperated list of regexes to match base path against')
40+
argparser.add_argument('-p', '--path-masks', metavar='p_re(,p_re)*',
41+
default=[],
42+
help='comma seperated list of regexes to match full file path against')
43+
argparser.add_argument('-y', '--saw-the-warning',
44+
action='store_true',default=False,
45+
help='must specify this to run, acknowledge you realize this will erase untracked files')
46+
argparser.add_argument('--debug-level',
47+
default="CRITICAL",
48+
help='debug level of messages (DEBUG,INFO,etc...)')
49+
50+
args = argparser.parse_args()
51+
52+
53+
lfmt = logging.Formatter(fmt='%(levelname)-8s %(message)s',
54+
datefmt='%m-%d %H:%M:%S'
55+
)
56+
57+
shh = logging.StreamHandler()
58+
shh.setFormatter(lfmt)
59+
60+
logger=logging.getLogger("findit")
61+
logger.addHandler(shh)
62+
63+
64+
Hit=namedtuple("Hit","commit path")
65+
HASH_LEN=8
66+
67+
def clean_checkout(comm):
68+
h,s,d = get_commit_vitals(comm)
69+
if len(s) > 60:
70+
s = s[:60] + "..."
71+
s=s.split("\n")[0]
72+
logger.info("CO: %s %s" % (comm,s ))
73+
74+
sh.git('checkout', comm ,_tty_out=False)
75+
sh.git('clean', '-f')
76+
77+
def get_hits(defname,files=()):
78+
cs=set()
79+
for f in files:
80+
try:
81+
r=sh.git('blame', '-L', '/def\s*{start}/,/def/'.format(start=defname),f,_tty_out=False)
82+
except sh.ErrorReturnCode_128:
83+
logger.debug("no matches in %s" % f)
84+
continue
85+
86+
lines = r.strip().splitlines()[:-1]
87+
# remove comment lines
88+
lines = [x for x in lines if not re.search("^\w+\s*\(.+\)\s*#",x)]
89+
hits = set(map(lambda x: x.split(" ")[0],lines))
90+
cs.update(set([Hit(commit=c,path=f) for c in hits]))
91+
92+
return cs
93+
94+
def get_commit_info(c,fmt,sep='\t'):
95+
r=sh.git('log', "--format={}".format(fmt), '{}^..{}'.format(c,c),"-n","1",_tty_out=False)
96+
return unicode(r).split(sep)
97+
98+
def get_commit_vitals(c,hlen=HASH_LEN):
99+
h,s,d= get_commit_info(c,'%H\t%s\t%ci',"\t")
100+
return h[:hlen],s,parser.parse(d)
101+
102+
def file_filter(state,dirname,fnames):
103+
if args.dir_masks and not any([re.search(x,dirname) for x in args.dir_masks]):
104+
return
105+
for f in fnames:
106+
p = os.path.abspath(os.path.join(os.path.realpath(dirname),f))
107+
if any([re.search(x,f) for x in args.file_masks])\
108+
or any([re.search(x,p) for x in args.path_masks]):
109+
if os.path.isfile(p):
110+
state['files'].append(p)
111+
112+
def search(defname,head_commit="HEAD"):
113+
HEAD,s = get_commit_vitals("HEAD")[:2]
114+
logger.info("HEAD at %s: %s" % (HEAD,s))
115+
done_commits = set()
116+
# allhits = set()
117+
files = []
118+
state = dict(files=files)
119+
os.path.walk('.',file_filter,state)
120+
# files now holds a list of paths to files
121+
122+
# seed with hits from q
123+
allhits= set(get_hits(defname, files = files))
124+
q = set([HEAD])
125+
try:
126+
while q:
127+
h=q.pop()
128+
clean_checkout(h)
129+
hits = get_hits(defname, files = files)
130+
for x in hits:
131+
prevc = get_commit_vitals(x.commit+"^")[0]
132+
if prevc not in done_commits:
133+
q.add(prevc)
134+
allhits.update(hits)
135+
done_commits.add(h)
136+
137+
logger.debug("Remaining: %s" % q)
138+
finally:
139+
logger.info("Restoring HEAD to %s" % HEAD)
140+
clean_checkout(HEAD)
141+
return allhits
142+
143+
def pprint_hits(hits):
144+
SUBJ_LEN=50
145+
PATH_LEN = 20
146+
hits=list(hits)
147+
max_p = 0
148+
for hit in hits:
149+
p=hit.path.split(os.path.realpath(os.curdir)+os.path.sep)[-1]
150+
max_p=max(max_p,len(p))
151+
152+
if max_p < PATH_LEN:
153+
SUBJ_LEN += PATH_LEN - max_p
154+
PATH_LEN = max_p
155+
156+
def sorter(i):
157+
h,s,d=get_commit_vitals(hits[i].commit)
158+
return hits[i].path,d
159+
160+
print("\nThese commits touched the %s method in these files on these dates:\n" \
161+
% args.funcname)
162+
for i in sorted(range(len(hits)),key=sorter):
163+
hit = hits[i]
164+
h,s,d=get_commit_vitals(hit.commit)
165+
p=hit.path.split(os.path.realpath(os.curdir)+os.path.sep)[-1]
166+
167+
fmt = "{:%d} {:10} {:<%d} {:<%d}" % (HASH_LEN, SUBJ_LEN, PATH_LEN)
168+
if len(s) > SUBJ_LEN:
169+
s = s[:SUBJ_LEN-5] + " ..."
170+
print(fmt.format(h[:HASH_LEN],d.isoformat()[:10],s,p[-20:]) )
171+
172+
print("\n")
173+
174+
def main():
175+
if not args.saw_the_warning:
176+
argparser.print_help()
177+
print("""
178+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
179+
WARNING: this script uses git clean -f, running it on a repo with untracked files.
180+
It's recommended that you make a fresh clone and run from it's root directory.
181+
You must specify the -y argument to ignore this warning.
182+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
183+
""")
184+
return
185+
if isinstance(args.file_masks,basestring):
186+
args.file_masks = args.file_masks.split(',')
187+
if isinstance(args.path_masks,basestring):
188+
args.path_masks = args.path_masks.split(',')
189+
if isinstance(args.dir_masks,basestring):
190+
args.dir_masks = args.dir_masks.split(',')
191+
192+
logger.setLevel(getattr(logging,args.debug_level))
193+
194+
hits=search(args.funcname)
195+
pprint_hits(hits)
196+
197+
pass
198+
199+
if __name__ == "__main__":
200+
import sys
201+
sys.exit(main())

0 commit comments

Comments
 (0)