gitstats: Added with_exceptions=False to 'git rev-parse --verify'
[git-stats.git] / src / git_stats / commit.py
blob480442a1b1a75e3ed771a5b8897686ef67c43141
1 #!/usr/bin/env python
3 import os
4 import sys
5 import re
7 from optparse import OptionParser, OptionValueError
8 from git import Git
10 from git_stats import parse
12 def _logContains(log, regexp):
13 """Traverses the specified log and searches for the specified regexp.
15 Params:
16 log: The log to search through.
17 regexp: The regexp to match for.
18 """
20 matcher = re.compile(regexp)
22 isDiff = log[0].startswith("diff")
24 for line in log:
25 # Skip anything that is not an addition or a removal
26 if isDiff and not line.startswith("+") and not line.startswith("-"):
27 continue
29 # If this line matches the regexp, accept
30 if matcher.search(line):
31 return True
33 # None of the lines matched, reject
34 return False
36 def prettyPrint(commits):
37 """Pretty prints the specified commits with git log.
38 """
39 git = Git(".")
41 for commit in commits:
42 # Enable raw output for the trailing newline, which is desired in this case
43 result = git.log("-1", "--name-only", commit, with_raw_output=True)
44 print(result)
46 def _makePathsRelative(paths):
47 """Helper function that takes a list of paths and makes it relative
49 Args:
50 paths: The paths to make relative.
52 Returns: A new lists in which the paths are relative to the working dir.
53 """
55 result = []
57 git = Git(".")
59 # Get our 'distance' to the top
60 prefix = git.rev_parse("--show-cdup")
62 prefix = prefix
64 # Make it relative
65 for path in paths:
66 result.append(os.path.join(prefix, path))
68 return result
70 def pathsTouchedBy(commit, ignoreAddedFiles):
71 """Returns a list of paths touched by a specific commit.
73 Params:
74 commit: A commit identifier as accepted by git-log.
75 ignoreAddedFiles: When True newly added files are ignored.
77 Returns:
78 A list of paths touched by the specified commit.
79 """
81 git = Git(".")
83 # Enable raw output, we use the last empty line as delimiter
84 result = git.diff_tree("--name-status", "--no-commit-id", "-r", commit, with_raw_output=True)
86 log = result.split('\n')
88 paths = []
90 for line in log:
91 if len(line.lstrip()) == 0:
92 continue
94 splitline = line.split('\t')
95 if splitline[0] == 'A' and ignoreAddedFiles:
96 continue
98 paths.append(splitline[1])
100 return paths
102 def commitsThatTouched(paths, relative=False):
103 """Returns a list of commits that touch the specified paths.
105 Params:
106 paths: A list of changed path relative to the current working dir.
107 relative: Whether the paths are relative to the current directory.
109 Returns:
110 A list of 40-character SHA's of the touched commits.
113 if not paths:
114 raise ValueError("No changed paths specified")
116 git = Git(".")
118 result = git.rev_list("HEAD", with_keep_cwd=relative, *paths)
120 touched = result.split('\n')
122 return touched
124 def commitTouched(commit):
125 """Shows what commit touched the same files as the specified commit.
128 touched = pathsTouchedBy(commit, True)
130 touched = commitsThatTouched(touched)
132 prettyPrint(touched)
134 def commitmsgMatches(commit, regexp):
135 """Returns whether the specified commit matches the specified regexp.
137 The commit message for the specified commit is searched, if a
138 matching line is found, True is returned. Otherwise False is returned.
140 Params:
141 commit: The commit whose commit msg is to be searched.
142 regexp: The regexp to match against.
144 Returns: Whether the commit msg matches the specified regexp.
147 git = Git(".")
149 result = git.cat_file("-p", commit)
151 log = result.split('\n')
153 # Skip the first lines, the commit msg doesn't begin till after these
154 return _logContains(log[4:], regexp)
156 def hasParent(commit):
157 """Checks if the specified commit has a parent
159 Args:
160 commit: The commitish to check
162 Returns: False if the commitish doesn't have a parent,
163 or if the committish is not a commit at all.
166 git = Git(".")
168 hash = git.rev_parse("--verify", commit, with_exceptions=False)
170 if not hash:
171 return False
173 hash = hash + "^"
175 parent = git.rev_parse("--verify", hash, with_exceptions=False)
177 if not parent:
178 return False
180 return True
182 def commitdiffMatches(commit, regexp):
183 """Returns whether the commit diff matches the specified regexp.
185 Params:
186 commit: The commit whose commit diff is to be searched.
187 regexp: The regexp to match against.
189 Returns: Whether the commit diff matches the specified regexp.
192 git = Git(".")
194 if hasParent(commit):
195 lhs = commit + "^"
196 else:
197 lhs = "--root"
199 result = git.diff_tree("-p", "-U0", lhs, commit)
201 log = result.split('\n')
203 return _logContains(log, regexp)
205 def commitList(logFilter=None, diffFilter=None):
209 git = Git(".")
211 result = git.rev_list("HEAD")
212 commits = result.split('\n')
214 if not logFilter and not diffFilter:
215 return commits
217 result = []
219 for commit in commits:
220 addIt = True
222 if logFilter:
223 if not commitmsgMatches(commit, logFilter):
224 addIt = False
226 if diffFilter:
227 if not commitdiffMatches(commit, diffFilter):
228 addIt = False
230 if addIt:
231 result.append(commit)
234 return result
237 def _isUnique(options, atLeastOne=False):
238 """Checks if a list of options is unique
240 Args:
241 options: The list of options to check
242 atLeastOne: If set, when no optiosn are set, return False
245 unique = False
247 for option in options:
248 if option:
249 # If we already found one, it's not unique for sure
250 if unique:
251 return False
252 else:
253 unique = True
255 # If there is only one, it's unique
256 if unique:
257 return True
259 # None found, so unique only if we don't require at least one
260 return not atLeastOne
262 def _checkOptions(parser, options, args):
263 """Checks the specified options and uses the parser to indicate errors
265 Args:
266 parser: The parser to use to signal when the options are bad.
267 options: The options to check.
268 args: The arguments to check, if used.
271 opts = [options.touched, options.log_contains, options.diff_contains, options.all]
273 if not _isUnique(opts, True):
274 parser.error("Please choose exactly one mode")
276 if not options.touched:
277 if options.relative:
278 parser.error("-r makes no sense without -t")
279 else:
280 if len(args) > 1:
281 raise ValueError("Please specify only one file")
283 try:
284 parse.check_file(value=args[0], relative=options.relative)
285 except OptionValueError, e:
286 parser.error(e)
288 def dispatch(*args):
289 """Dispatches commit related commands
292 progname = os.path.basename(sys.argv[0]) + " commit"
294 parser = OptionParser(prog=progname)
296 parser.add_option(
297 "-t", "--touches",
298 dest="touched",
299 action="store_true",
300 help="show only commits that modify the specified file")
302 # Toy option, since we have git log for that
303 parser.add_option(
304 "-a", "--all",
305 action="store_true",
306 help="lists all commits on HEAD")
308 parser.add_option(
309 "-l", "--log-contains",
310 help="show only commits of which the log contains the specified regexp")
312 parser.add_option(
313 "-d", "--diff-contains",
314 help="show only commits of which the diff contains the specified regexp")
316 parser.add_option(
317 "-r", "--relative",
318 action="store_true",
319 help="paths are relative to the current directory")
321 parser.set_default("touched", False)
322 parser.set_default("relative", False)
324 (options, args) = parser.parse_args(list(args))
326 _checkOptions(parser, options, args)
328 if options.touched:
329 result = commitsThatTouched(args, options.relative)
330 prettyPrint(result)
332 if options.diff_contains or options.log_contains or options.all:
333 result = commitList(diffFilter=options.diff_contains,
334 logFilter=options.log_contains)
335 prettyPrint(result)