gitstats: Bugfix for commitdiffMatches
[git-stats.git] / src / git_stats / commit.py
blob5e70cbf3b6225f3b401fac97d8ae1659ed304799
1 #!/usr/bin/env python
3 import os
4 import copy
5 import sys
6 import re
8 from optparse import OptionParser, Option, OptionValueError
9 from git import Git
11 def _logContains(log, regexp):
12 """Traverses the specified log and searches for the specified regexp.
14 Params:
15 log: The log to search through.
16 regexp: The regexp to match for.
17 """
19 matcher = re.compile(regexp)
21 isDiff = log[0].startswith("diff")
23 for line in log:
24 # Skip anything that is not an addition or a removal
25 if isDiff and not line.startswith("+") and not line.startswith("-"):
26 continue
28 # If this line matches the regexp, accept
29 if matcher.search(line):
30 return True
32 # None of the lines matched, reject
33 return False
35 def check_commit(option, opt, value):
36 """Checks whether value is a valid rev and returns its hash
37 """
39 git = Git(".")
40 rev = git.rev_parse("--verify", value)
42 if not rev:
43 raise OptionValueError("Unknown commit '" + value + "'")
45 return rev
47 class CommitOption(Option):
48 """This parser understands a new type, "commit"
49 """
51 TYPES = Option.TYPES + ("commit",)
52 TYPE_CHECKER = copy.copy(Option.TYPE_CHECKER)
53 TYPE_CHECKER["commit"] = check_commit
55 def prettyPrint(commits):
56 """Pretty prints the specified commits with git log.
57 """
58 git = Git(".")
60 for commit in commits:
61 # Enable raw output for the trailing newline, which is desired in this case
62 result = git.log("-1", "--name-only", commit, with_raw_output=True)
63 print(result)
65 def _makePathsRelative(paths):
66 """Helper function that takes a list of paths and makes it relative
68 Args:
69 paths: The paths to make relative.
71 Returns: A new lists in which the paths are relative to the working dir.
72 """
74 result = []
76 git = Git(".")
78 # Get our 'distance' to the top
79 prefix = git.rev_parse("--show-cdup")
81 prefix = prefix
83 # Make it relative
84 for path in paths:
85 result.append(os.path.join(prefix, path))
87 return result
89 def pathsTouchedBy(commit, ignoreAddedFiles):
90 """Returns a list of paths touched by a specific commit.
92 Params:
93 commit: A commit identifier as accepted by git-log.
94 ignoreAddedFiles: When True newly added files are ignored.
96 Returns:
97 A list of paths touched by the specified commit.
98 """
100 git = Git(".")
102 # Enable raw output, we use the last empty line as delimiter
103 result = git.diff_tree("--name-status", "--no-commit-id", "-r", commit, with_raw_output=True)
105 log = result.split('\n')
107 paths = []
109 for line in log:
110 if len(line.lstrip()) == 0:
111 continue
113 splitline = line.split('\t')
114 if splitline[0] == 'A' and ignoreAddedFiles:
115 continue
117 paths.append(splitline[1])
119 return paths
121 def commitsThatTouched(paths, relative=False):
122 """Returns a list of commits that touch the specified paths.
124 Params:
125 paths: A list of changed path relative to the current working dir.
126 relative: Whether the paths are relative to the current directory.
128 Returns:
129 A list of 40-character SHA's of the touched commits.
132 if not paths:
133 raise ValueError("No changed paths specified")
135 if not relative:
136 relativePaths = _makePathsRelative(paths)
137 else:
138 relativePaths = paths
140 git = Git(".")
142 result = git.rev_list("HEAD", *relativePaths)
144 touched = result.split('\n')
146 return touched
148 def commitTouched(commit):
149 """Shows what commit touched the same files as the specified commit.
152 touched = pathsTouchedBy(commit, True)
154 touched = commitsThatTouched(touched)
156 prettyPrint(touched)
158 def commitmsgMatches(commit, regexp):
159 """Returns whether the specified commit matches the specified regexp.
161 The commit message for the specified commit is searched, if a
162 matching line is found, True is returned. Otherwise False is returned.
164 Params:
165 commit: The commit whose commit msg is to be searched.
166 regexp: The regexp to match against.
168 Returns: Whether the commit msg matches the specified regexp.
171 git = Git(".")
173 result = git.cat_file("-p", commit)
175 log = result.split('\n')
177 # Skip the first lines, the commit msg doesn't begin till after these
178 return _logContains(log[4:], regexp)
180 def hasParent(commit):
181 """Checks if the specified commit has a parent
183 Args:
184 commit: The commitish to check
186 Returns: False if the commitish doesn't have a parent,
187 or if the committish is not a commit at all.
190 git = Git(".")
192 hash = git.rev_parse("--verify", commit)
194 if not hash:
195 return False
197 hash = hash + "^"
199 parent = git.rev_parse("--verify", hash)
201 if not parent:
202 return False
204 return True
206 def commitdiffMatches(commit, regexp):
207 """Returns whether the commit diff matches the specified regexp.
209 Params:
210 commit: The commit whose commit diff is to be searched.
211 regexp: The regexp to match against.
213 Returns: Whether the commit diff matches the specified regexp.
216 git = Git(".")
218 if hasParent(commit):
219 lhs = commit + "^"
220 else:
221 lhs = "--root"
223 result = git.diff_tree("-p", "-U0", lhs, commit)
225 log = result.split('\n')
227 return _logContains(log, regexp)
229 def commitList(logFilter=None, diffFilter=None):
233 git = Git(".")
235 result = git.rev_list("HEAD")
236 commits = result.split('\n')
238 if not logFilter and not diffFilter:
239 return commits
241 result = []
243 for commit in commits:
244 addIt = True
246 if logFilter:
247 if not commitmsgMatches(commit, logFilter):
248 addIt = False
250 if diffFilter:
251 if not commitdiffMatches(commit, diffFilter):
252 addIt = False
254 if addIt:
255 result.append(commit)
258 return result
261 def _isUnique(options, atLeastOne=False):
262 """Checks if a list of options is unique
264 Args:
265 options: The list of options to check
266 atLeastOne: If set, when no optiosn are set, return False
269 unique = False
271 for option in options:
272 if option:
273 # If we already found one, it's not unique for sure
274 if unique:
275 return False
276 else:
277 unique = True
279 # If there is only one, it's unique
280 if unique:
281 return True
283 # None found, so unique only if we don't require at least one
284 return not atLeastOne
286 def _checkOptions(parser, options):
287 """Checks the specified options and uses the parser to indicate errors
289 Args:
290 parser: The parser to use to signal when the options are bad.
291 options: The options to check.
294 opts = [options.touched, options.log_contains, options.diff_contains, options.all]
296 if not _isUnique(opts, True):
297 parser.error("Please choose exactly one mode")
299 if options.relative and not options.touched:
300 parser.error("-r makes no sense without -t")
302 def dispatch(*args):
303 """Dispatches commit related commands
306 progname = os.path.basename(sys.argv[0]) + " commit"
308 parser = OptionParser(prog=progname)
310 parser.add_option(
311 "-t", "--touches",
312 dest="touched",
313 action="store_true",
314 help="show only commits that modify the specified files")
316 # Toy option, since we have git log for that
317 parser.add_option(
318 "-a", "--all",
319 action="store_true",
320 help="lists all commits on HEAD")
322 parser.add_option(
323 "-l", "--log-contains",
324 help="show only commits of which the log contains the specified regexp")
326 parser.add_option(
327 "-d", "--diff-contains",
328 help="show only commits of which the diff contains the specified regexp")
330 parser.add_option(
331 "-r", "--relative",
332 action="store_true",
333 help="paths are relative to the current directory")
335 parser.set_default("touched", False)
336 parser.set_default("relative", False)
338 (options, args) = parser.parse_args(list(args))
340 _checkOptions(parser, options)
342 if options.touched:
343 result = commitsThatTouched(args, options.relative)
344 prettyPrint(result)
346 if options.diff_contains or options.log_contains or options.all:
347 result = commitList(diffFilter=options.diff_contains,
348 logFilter=options.log_contains)
349 prettyPrint(result)