gitstats: Ran ispell on all non gitstats-* files in doc/
[git-stats.git] / src / git_stats / commit.py
blob6ce40f61dc1c9a3e6b30ae263aef650929a87d56
1 #!/usr/bin/env python
3 import os
4 import sys
5 import re
7 from optparse import OptionParser, OptionValueError
8 from git import Repo
10 from git_stats import parse
12 GIT_STATS_PRETTY_PRINT = os.environ.get("GIT_STATS_PRETTY_PRINT", "full")
14 def _logContains(log, regexp):
15 """Traverses the specified log and searches for the specified regexp.
17 If the first line of the log starts with 'diff' only
18 lines that start with '+' or '-' will be examined.
20 Params:
21 log: The log to search through.
22 regexp: The regexp to match for.
23 """
25 matcher = re.compile(regexp)
27 isDiff = log[0].startswith("diff")
29 for line in log:
30 # Skip anything that is not an addition or a removal
31 if isDiff and not line.startswith("+") and not line.startswith("-"):
32 continue
34 # If this line matches the regexp, accept
35 if matcher.search(line):
36 return True
38 # None of the lines matched, reject
39 return False
41 def prettyPrint(commits):
42 """Pretty prints the specified commits with git log
44 Respects the GIT_STATS_PRETTY_PRINT environment variable.
45 Supported are 'full' and 'oneline', defaults to 'full'.
46 """
48 git = Repo(".").git
50 if GIT_STATS_PRETTY_PRINT == "full":
51 opts = ["-1", "--name-only"]
52 # Enable raw output for the trailing newline, which is desired in this case
53 kwargs = { "with_raw_output" : True }
54 elif GIT_STATS_PRETTY_PRINT == "oneline":
55 opts = ["-1", "--pretty=oneline"]
56 kwargs = {}
58 for commit in commits:
59 # Copy the opts so that we don't have to kick off the commit later on
60 thisopts = opts[:]
61 thisopts.append(commit)
62 result = git.log(*thisopts, **kwargs)
64 print(result)
66 def _makePathsRelative(paths):
67 """Helper function that takes a list of paths and makes it relative
69 Args:
70 paths: The paths to make relative.
72 Returns: A new lists in which the paths are relative to the working dir.
73 """
75 result = []
77 git = Repo(".").git
79 # Get our 'distance' to the top
80 prefix = git.rev_parse("--show-cdup")
82 prefix = prefix
84 # Make it relative
85 for path in paths:
86 result.append(os.path.join(prefix, path))
88 return result
90 def pathsTouchedBy(commit, ignore_added_files=True):
91 """Returns a list of paths touched by a specific commit.
93 Params:
94 commit: A commit identifier as accepted by git-log.
95 ignore_added_files: When True newly added files are ignored.
97 Returns:
98 A list of paths touched by the specified commit.
99 """
101 git = Repo(".").git
103 result = git.diff_tree("--name-status", "--no-commit-id", "-r", commit)
104 log = result.split('\n')
106 paths = []
108 for line in log:
109 # Ignore empty lines
110 if len(line.lstrip()) == 0:
111 continue
113 # Split the status part and the file name
114 splitline = line.split('\t')
115 if splitline[0] == 'A' and ignore_added_files:
116 continue
118 paths.append(splitline[1])
120 return paths
122 def commitsThatTouched(paths, relative=False, touched_files={}):
123 """Returns a list of commits that touch the specified paths.
125 Params:
126 paths: A list of changed path relative to the current working dir.
127 relative: Whether the paths are relative to the current directory.
128 touched_files: A dictionary of files and the commits that touched them.
130 Returns:
131 A list of 40-character SHA's of the touched commits.
134 if not paths:
135 raise ValueError("No changed paths specified")
137 paths = tuple(paths)
139 if not paths in touched_files:
140 git = Repo(".").git
142 result = git.rev_list("HEAD", "--", with_keep_cwd=relative, *paths)
143 touched = result.split('\n')
144 touched_files[paths] = touched
145 else:
146 touched = touched_files[paths]
148 return touched
150 def commitTouched(commit):
151 """Shows what commit touched the same files as the specified commit
154 touched = pathsTouchedBy(commit, True)
155 touched = commitsThatTouched(touched)
157 prettyPrint(touched)
159 def commitmsgMatches(commit, regexp):
160 """Returns whether the specified commit matches the specified regexp.
162 The commit message for the specified commit is searched,
163 returns true iff amatching line is found.
165 Params:
166 commit: The commit whose commit msg is to be searched.
167 regexp: The regexp to match against.
169 Returns: Whether the commit msg matches the specified regexp.
172 git = Repo(".").git
174 result = git.cat_file("-p", commit)
175 log = result.split('\n')
177 # The commit msg begin after the first empty line
178 pos = log.index("")
179 pos += 1
180 log = log[pos:]
182 return _logContains(log, regexp)
184 def getDiff(commit, ignore_whitespace=True, noContext=False):
185 """Returns the commit diff for the specified commit
187 Params:
188 commit: The commit to get the diff for.
190 Returns: The commit diff.
193 git = Repo(".").git
195 args = ["-p"]
197 if ignore_whitespace:
198 args.append("-w")
200 if noContext:
201 args.append("-U0")
203 args.append(commit + "^")
204 args.append(commit)
205 args.append("--")
207 # Don't look before we leap, just try with 'hash^'
208 result = git.diff_tree(with_exceptions=False, *args)
210 # We missed, retry with '--root' instead
211 if not result:
212 args[-3] = "--root"
213 result = git.diff_tree(*args)
215 return result
218 def commitdiffMatches(commit, regexp, raw_diffs={}):
219 """Returns whether the commit diff matches the specified regexp.
221 Params:
222 commit: The commit whose commit diff is to be searched.
223 regexp: The regexp to match against.
224 raw_diffs: A dictionary with commits and their diffs.
226 Returns: Whether the commit diff matches the specified regexp.
229 if not commit in raw_diffs:
230 result = getDiff(commit, noContext=True)
231 diff = result.split('\n')
232 raw_diffs[commit] = diff
233 else:
234 diff = raw_diffs[commit]
236 return _logContains(diff, regexp)
238 def commitList(log_filter=None, diff_filter=None):
239 """Returns a list of all commits that match the specified filters
241 Args:
242 log_filter: If specified, only commits whose log match are included.
243 diff_filter: If specified, only commits whose diff match are included.
246 git = Repo(".").git
248 result = git.rev_list("HEAD")
249 commits = result.split('\n')
251 if not log_filter and not diff_filter:
252 return commits
254 result = []
256 for commit in commits:
257 add_it = True
259 if log_filter:
260 if not commitmsgMatches(commit, log_filter):
261 add_it = False
263 if diff_filter:
264 if not commitdiffMatches(commit, diff_filter):
265 add_it = False
267 if add_it:
268 result.append(commit)
270 return result
272 def _checkOptions(parser, options):
273 """Checks the specified options and uses the parser to indicate errors
275 Args:
276 parser: The parser to use to signal when the options are bad.
277 options: The options to check.
280 opts = [options.touched, options.log_contains,
281 options.diff_contains, options.all]
283 if not parse.isUnique(opts, at_least_one=True):
284 parser.error("Please choose exactly one mode")
286 if not options.touched:
287 if options.relative:
288 parser.error("-r makes no sense without -t")
289 else:
290 # Check if the specified file is valid
291 try:
292 parse.checkFile(value=options.touched, relative=options.relative)
293 except OptionValueError, e:
294 parser.error(e)
296 def dispatch(*args):
297 """Dispatches commit related commands
300 # Make the help show 'progname commit' instead of just 'progname'
301 progname = os.path.basename(sys.argv[0]) + " commit"
303 parser = OptionParser(option_class=parse.GitOption, prog=progname)
305 parser.add_option(
306 "-t", "--touches",
307 dest="touched",
308 help="show only commits that modify the specified file")
310 # Toy option, since we have git log for that
311 parser.add_option(
312 "-a", "--all",
313 action="store_true",
314 help="lists all commits on HEAD")
316 parser.add_option(
317 "-l", "--log-contains",
318 help="show only commits of which the log contains the specified regexp")
320 parser.add_option(
321 "-d", "--diff-contains",
322 help="show only commits of which the diff contains the specified regexp")
324 parser.add_option(
325 "-r", "--relative",
326 action="store_true",
327 help="paths are relative to the current directory")
329 parser.set_default("relative", False)
331 (options, args) = parser.parse_args(list(args))
333 _checkOptions(parser, options)
335 if options.touched:
336 result = commitsThatTouched([options.touched], options.relative)
338 if options.diff_contains or options.log_contains or options.all:
339 result = commitList(diff_filter=options.diff_contains,
340 log_filter=options.log_contains)
342 prettyPrint(result)