gitstats: Refactored bug.py to use a Memory and Options class
[git-stats.git] / src / git_stats / commit.py
blob799201a03a8c85ce2ce4b7053b47c5c85d2d28fa
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 Params:
18 log: The log to search through.
19 regexp: The regexp to match for.
20 """
22 matcher = re.compile(regexp)
24 isDiff = log[0].startswith("diff")
26 for line in log:
27 # Skip anything that is not an addition or a removal
28 if isDiff and not line.startswith("+") and not line.startswith("-"):
29 continue
31 # If this line matches the regexp, accept
32 if matcher.search(line):
33 return True
35 # None of the lines matched, reject
36 return False
38 def prettyPrint(commits):
39 """Pretty prints the specified commits with git log.
40 """
42 git = Repo(".").git
44 if GIT_STATS_PRETTY_PRINT == "full":
45 opts = ["-1", "--name-only"]
46 kwargs = { "with_raw_output" : True }
47 elif GIT_STATS_PRETTY_PRINT == "oneline":
48 opts = ["-1", "--pretty=oneline"]
49 kwargs = {}
51 for commit in commits:
52 # Enable raw output for the trailing newline, which is desired in this case
53 thisopts = opts[:]
54 thisopts.append(commit)
55 result = git.log(*thisopts, **kwargs)
57 print(result)
59 def _makePathsRelative(paths):
60 """Helper function that takes a list of paths and makes it relative
62 Args:
63 paths: The paths to make relative.
65 Returns: A new lists in which the paths are relative to the working dir.
66 """
68 result = []
70 git = Repo(".").git
72 # Get our 'distance' to the top
73 prefix = git.rev_parse("--show-cdup")
75 prefix = prefix
77 # Make it relative
78 for path in paths:
79 result.append(os.path.join(prefix, path))
81 return result
83 def pathsTouchedBy(commit, ignoreAddedFiles=True):
84 """Returns a list of paths touched by a specific commit.
86 Params:
87 commit: A commit identifier as accepted by git-log.
88 ignoreAddedFiles: When True newly added files are ignored.
90 Returns:
91 A list of paths touched by the specified commit.
92 """
94 git = Repo(".").git
96 # Enable raw output, we use the last empty line as delimiter
97 result = git.diff_tree("--name-status", "--no-commit-id", "-r", commit, with_raw_output=True)
99 log = result.split('\n')
101 paths = []
103 for line in log:
104 if len(line.lstrip()) == 0:
105 continue
107 splitline = line.split('\t')
108 if splitline[0] == 'A' and ignoreAddedFiles:
109 continue
111 paths.append(splitline[1])
113 return paths
115 def commitsThatTouched(paths, relative=False, touched_files={}):
116 """Returns a list of commits that touch the specified paths.
118 Params:
119 paths: A list of changed path relative to the current working dir.
120 relative: Whether the paths are relative to the current directory.
121 touched_files: A dictionary of files and the commits that touched them.
123 Returns:
124 A list of 40-character SHA's of the touched commits.
127 if not paths:
128 raise ValueError("No changed paths specified")
130 paths = tuple(paths)
132 if not touched_files.has_key(paths):
133 git = Repo(".").git
135 result = git.rev_list("HEAD", "--", with_keep_cwd=relative, *paths)
136 touched = result.split('\n')
137 touched_files[paths] = touched
138 else:
139 touched = touched_files[paths]
141 return touched
143 def commitTouched(commit):
144 """Shows what commit touched the same files as the specified commit.
147 touched = pathsTouchedBy(commit, True)
149 touched = commitsThatTouched(touched)
151 prettyPrint(touched)
153 def commitmsgMatches(commit, regexp):
154 """Returns whether the specified commit matches the specified regexp.
156 The commit message for the specified commit is searched, if a
157 matching line is found, True is returned. Otherwise False is returned.
159 Params:
160 commit: The commit whose commit msg is to be searched.
161 regexp: The regexp to match against.
163 Returns: Whether the commit msg matches the specified regexp.
166 git = Repo(".").git
168 result = git.cat_file("-p", commit)
170 log = result.split('\n')
172 # Skip the first lines, the commit msg doesn't begin till after these
173 return _logContains(log[4:], regexp)
175 def hasParent(commit):
176 """Checks if the specified commit has a parent
178 Args:
179 commit: The commitish to check
181 Returns: False if the commitish doesn't have a parent,
182 or if the committish is not a commit at all.
185 git = Repo(".").git
187 hash = git.rev_parse("--verify", commit, with_exceptions=False)
189 if not hash:
190 return False
192 hash = hash + "^"
194 parent = git.rev_parse("--verify", hash, with_exceptions=False)
196 if not parent:
197 return False
199 return True
201 def getDiff(commit, ignoreWhitespace=True, noContext=False):
202 """Returns the commit diff for the specified commit
204 Params:
205 commit: The commit to get the diff for.
207 Returns: The commit diff.
210 git = Repo(".").git
212 args = ["-p"]
214 if ignoreWhitespace:
215 args.append("-w")
217 if noContext:
218 args.append("-U0")
220 args.append(commit + "^")
221 args.append(commit)
222 args.append("--")
224 result = git.diff_tree(with_exceptions=False, *args)
226 if not result:
227 args[-3] = "--root"
228 result = git.diff_tree(*args)
230 return result
233 def commitdiffMatches(commit, regexp, raw_diffs={}):
234 """Returns whether the commit diff matches the specified regexp.
236 Params:
237 commit: The commit whose commit diff is to be searched.
238 regexp: The regexp to match against.
239 raw_diffs: A dictionary with commits and their diffs.
241 Returns: Whether the commit diff matches the specified regexp.
244 if not raw_diffs.has_key(commit):
245 result = getDiff(commit, noContext=True)
246 diff = result.split('\n')
247 raw_diffs[commit] = diff
248 else:
249 diff = raw_diffs[commit]
251 return _logContains(diff, regexp)
253 def commitList(logFilter=None, diffFilter=None):
254 """Returns a list of all commits that match the specified filters
256 Args:
257 logFilter: If specified, only commits whose log match are included.
258 diffFilter: If specified, only commits whose diff match are included.
261 git = Repo(".").git
263 result = git.rev_list("HEAD")
264 commits = result.split('\n')
266 if not logFilter and not diffFilter:
267 return commits
269 result = []
271 for commit in commits:
272 addIt = True
274 if logFilter:
275 if not commitmsgMatches(commit, logFilter):
276 addIt = False
278 if diffFilter:
279 if not commitdiffMatches(commit, diffFilter):
280 addIt = False
282 if addIt:
283 result.append(commit)
286 return result
288 def _checkOptions(parser, options):
289 """Checks the specified options and uses the parser to indicate errors
291 Args:
292 parser: The parser to use to signal when the options are bad.
293 options: The options to check.
294 args: The arguments to check, if used.
297 opts = [options.touched, options.log_contains,
298 options.diff_contains, options.all]
300 if not parse.isUnique(opts, True):
301 parser.error("Please choose exactly one mode")
303 if not options.touched:
304 if options.relative:
305 parser.error("-r makes no sense without -t")
306 else:
307 try:
308 parse.check_file(value=options.touched, relative=options.relative)
309 except OptionValueError, e:
310 parser.error(e)
312 def dispatch(*args):
313 """Dispatches commit related commands
316 progname = os.path.basename(sys.argv[0]) + " commit"
318 parser = OptionParser(option_class=parse.GitOption, prog=progname)
320 parser.add_option(
321 "-t", "--touches",
322 dest="touched",
323 help="show only commits that modify the specified file")
325 # Toy option, since we have git log for that
326 parser.add_option(
327 "-a", "--all",
328 action="store_true",
329 help="lists all commits on HEAD")
331 parser.add_option(
332 "-l", "--log-contains",
333 help="show only commits of which the log contains the specified regexp")
335 parser.add_option(
336 "-d", "--diff-contains",
337 help="show only commits of which the diff contains the specified regexp")
339 parser.add_option(
340 "-r", "--relative",
341 action="store_true",
342 help="paths are relative to the current directory")
344 parser.set_default("relative", False)
346 (options, args) = parser.parse_args(list(args))
348 _checkOptions(parser, options)
350 if options.touched:
351 result = commitsThatTouched([options.touched], options.relative)
353 if options.diff_contains or options.log_contains or options.all:
354 result = commitList(diffFilter=options.diff_contains,
355 logFilter=options.log_contains)
357 prettyPrint(result)