##// END OF EJS Templates
get stderr also for git commands, pass in shell = False
marcink -
r2760:fd5f2b21 beta
parent child Browse files
Show More
@@ -1,408 +1,409 b''
1 '''
1 '''
2 Module provides a class allowing to wrap communication over subprocess.Popen
2 Module provides a class allowing to wrap communication over subprocess.Popen
3 input, output, error streams into a meaningfull, non-blocking, concurrent
3 input, output, error streams into a meaningfull, non-blocking, concurrent
4 stream processor exposing the output data as an iterator fitting to be a
4 stream processor exposing the output data as an iterator fitting to be a
5 return value passed by a WSGI applicaiton to a WSGI server per PEP 3333.
5 return value passed by a WSGI applicaiton to a WSGI server per PEP 3333.
6
6
7 Copyright (c) 2011 Daniel Dotsenko <dotsa@hotmail.com>
7 Copyright (c) 2011 Daniel Dotsenko <dotsa@hotmail.com>
8
8
9 This file is part of git_http_backend.py Project.
9 This file is part of git_http_backend.py Project.
10
10
11 git_http_backend.py Project is free software: you can redistribute it and/or
11 git_http_backend.py Project is free software: you can redistribute it and/or
12 modify it under the terms of the GNU Lesser General Public License as
12 modify it under the terms of the GNU Lesser General Public License as
13 published by the Free Software Foundation, either version 2.1 of the License,
13 published by the Free Software Foundation, either version 2.1 of the License,
14 or (at your option) any later version.
14 or (at your option) any later version.
15
15
16 git_http_backend.py Project is distributed in the hope that it will be useful,
16 git_http_backend.py Project is distributed in the hope that it will be useful,
17 but WITHOUT ANY WARRANTY; without even the implied warranty of
17 but WITHOUT ANY WARRANTY; without even the implied warranty of
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 GNU Lesser General Public License for more details.
19 GNU Lesser General Public License for more details.
20
20
21 You should have received a copy of the GNU Lesser General Public License
21 You should have received a copy of the GNU Lesser General Public License
22 along with git_http_backend.py Project.
22 along with git_http_backend.py Project.
23 If not, see <http://www.gnu.org/licenses/>.
23 If not, see <http://www.gnu.org/licenses/>.
24 '''
24 '''
25 import os
25 import os
26 import subprocess
26 import subprocess
27 import threading
27 import threading
28 from rhodecode.lib.compat import deque, Event
28 from rhodecode.lib.compat import deque, Event
29
29
30
30
31 class StreamFeeder(threading.Thread):
31 class StreamFeeder(threading.Thread):
32 """
32 """
33 Normal writing into pipe-like is blocking once the buffer is filled.
33 Normal writing into pipe-like is blocking once the buffer is filled.
34 This thread allows a thread to seep data from a file-like into a pipe
34 This thread allows a thread to seep data from a file-like into a pipe
35 without blocking the main thread.
35 without blocking the main thread.
36 We close inpipe once the end of the source stream is reached.
36 We close inpipe once the end of the source stream is reached.
37 """
37 """
38 def __init__(self, source):
38 def __init__(self, source):
39 super(StreamFeeder, self).__init__()
39 super(StreamFeeder, self).__init__()
40 self.daemon = True
40 self.daemon = True
41 filelike = False
41 filelike = False
42 self.bytes = bytes()
42 self.bytes = bytes()
43 if type(source) in (type(''), bytes, bytearray): # string-like
43 if type(source) in (type(''), bytes, bytearray): # string-like
44 self.bytes = bytes(source)
44 self.bytes = bytes(source)
45 else: # can be either file pointer or file-like
45 else: # can be either file pointer or file-like
46 if type(source) in (int, long): # file pointer it is
46 if type(source) in (int, long): # file pointer it is
47 ## converting file descriptor (int) stdin into file-like
47 ## converting file descriptor (int) stdin into file-like
48 try:
48 try:
49 source = os.fdopen(source, 'rb', 16384)
49 source = os.fdopen(source, 'rb', 16384)
50 except Exception:
50 except Exception:
51 pass
51 pass
52 # let's see if source is file-like by now
52 # let's see if source is file-like by now
53 try:
53 try:
54 filelike = source.read
54 filelike = source.read
55 except Exception:
55 except Exception:
56 pass
56 pass
57 if not filelike and not self.bytes:
57 if not filelike and not self.bytes:
58 raise TypeError("StreamFeeder's source object must be a readable "
58 raise TypeError("StreamFeeder's source object must be a readable "
59 "file-like, a file descriptor, or a string-like.")
59 "file-like, a file descriptor, or a string-like.")
60 self.source = source
60 self.source = source
61 self.readiface, self.writeiface = os.pipe()
61 self.readiface, self.writeiface = os.pipe()
62
62
63 def run(self):
63 def run(self):
64 t = self.writeiface
64 t = self.writeiface
65 if self.bytes:
65 if self.bytes:
66 os.write(t, self.bytes)
66 os.write(t, self.bytes)
67 else:
67 else:
68 s = self.source
68 s = self.source
69 b = s.read(4096)
69 b = s.read(4096)
70 while b:
70 while b:
71 os.write(t, b)
71 os.write(t, b)
72 b = s.read(4096)
72 b = s.read(4096)
73 os.close(t)
73 os.close(t)
74
74
75 @property
75 @property
76 def output(self):
76 def output(self):
77 return self.readiface
77 return self.readiface
78
78
79
79
80 class InputStreamChunker(threading.Thread):
80 class InputStreamChunker(threading.Thread):
81 def __init__(self, source, target, buffer_size, chunk_size):
81 def __init__(self, source, target, buffer_size, chunk_size):
82
82
83 super(InputStreamChunker, self).__init__()
83 super(InputStreamChunker, self).__init__()
84
84
85 self.daemon = True # die die die.
85 self.daemon = True # die die die.
86
86
87 self.source = source
87 self.source = source
88 self.target = target
88 self.target = target
89 self.chunk_count_max = int(buffer_size / chunk_size) + 1
89 self.chunk_count_max = int(buffer_size / chunk_size) + 1
90 self.chunk_size = chunk_size
90 self.chunk_size = chunk_size
91
91
92 self.data_added = Event()
92 self.data_added = Event()
93 self.data_added.clear()
93 self.data_added.clear()
94
94
95 self.keep_reading = Event()
95 self.keep_reading = Event()
96 self.keep_reading.set()
96 self.keep_reading.set()
97
97
98 self.EOF = Event()
98 self.EOF = Event()
99 self.EOF.clear()
99 self.EOF.clear()
100
100
101 self.go = Event()
101 self.go = Event()
102 self.go.set()
102 self.go.set()
103
103
104 def stop(self):
104 def stop(self):
105 self.go.clear()
105 self.go.clear()
106 self.EOF.set()
106 self.EOF.set()
107 try:
107 try:
108 # this is not proper, but is done to force the reader thread let
108 # this is not proper, but is done to force the reader thread let
109 # go of the input because, if successful, .close() will send EOF
109 # go of the input because, if successful, .close() will send EOF
110 # down the pipe.
110 # down the pipe.
111 self.source.close()
111 self.source.close()
112 except:
112 except:
113 pass
113 pass
114
114
115 def run(self):
115 def run(self):
116 s = self.source
116 s = self.source
117 t = self.target
117 t = self.target
118 cs = self.chunk_size
118 cs = self.chunk_size
119 ccm = self.chunk_count_max
119 ccm = self.chunk_count_max
120 kr = self.keep_reading
120 kr = self.keep_reading
121 da = self.data_added
121 da = self.data_added
122 go = self.go
122 go = self.go
123 b = s.read(cs)
123 b = s.read(cs)
124 while b and go.is_set():
124 while b and go.is_set():
125 if len(t) > ccm:
125 if len(t) > ccm:
126 kr.clear()
126 kr.clear()
127 kr.wait(2)
127 kr.wait(2)
128 # # this only works on 2.7.x and up
128 # # this only works on 2.7.x and up
129 # if not kr.wait(10):
129 # if not kr.wait(10):
130 # raise Exception("Timed out while waiting for input to be read.")
130 # raise Exception("Timed out while waiting for input to be read.")
131 # instead we'll use this
131 # instead we'll use this
132 if len(t) > ccm + 3:
132 if len(t) > ccm + 3:
133 raise IOError("Timed out while waiting for input from subprocess.")
133 raise IOError("Timed out while waiting for input from subprocess.")
134 t.append(b)
134 t.append(b)
135 da.set()
135 da.set()
136 b = s.read(cs)
136 b = s.read(cs)
137 self.EOF.set()
137 self.EOF.set()
138 da.set() # for cases when done but there was no input.
138 da.set() # for cases when done but there was no input.
139
139
140
140
141 class BufferedGenerator():
141 class BufferedGenerator():
142 '''
142 '''
143 Class behaves as a non-blocking, buffered pipe reader.
143 Class behaves as a non-blocking, buffered pipe reader.
144 Reads chunks of data (through a thread)
144 Reads chunks of data (through a thread)
145 from a blocking pipe, and attaches these to an array (Deque) of chunks.
145 from a blocking pipe, and attaches these to an array (Deque) of chunks.
146 Reading is halted in the thread when max chunks is internally buffered.
146 Reading is halted in the thread when max chunks is internally buffered.
147 The .next() may operate in blocking or non-blocking fashion by yielding
147 The .next() may operate in blocking or non-blocking fashion by yielding
148 '' if no data is ready
148 '' if no data is ready
149 to be sent or by not returning until there is some data to send
149 to be sent or by not returning until there is some data to send
150 When we get EOF from underlying source pipe we raise the marker to raise
150 When we get EOF from underlying source pipe we raise the marker to raise
151 StopIteration after the last chunk of data is yielded.
151 StopIteration after the last chunk of data is yielded.
152 '''
152 '''
153
153
154 def __init__(self, source, buffer_size=65536, chunk_size=4096,
154 def __init__(self, source, buffer_size=65536, chunk_size=4096,
155 starting_values=[], bottomless=False):
155 starting_values=[], bottomless=False):
156
156
157 if bottomless:
157 if bottomless:
158 maxlen = int(buffer_size / chunk_size)
158 maxlen = int(buffer_size / chunk_size)
159 else:
159 else:
160 maxlen = None
160 maxlen = None
161
161
162 self.data = deque(starting_values, maxlen)
162 self.data = deque(starting_values, maxlen)
163
163
164 self.worker = InputStreamChunker(source, self.data, buffer_size,
164 self.worker = InputStreamChunker(source, self.data, buffer_size,
165 chunk_size)
165 chunk_size)
166 if starting_values:
166 if starting_values:
167 self.worker.data_added.set()
167 self.worker.data_added.set()
168 self.worker.start()
168 self.worker.start()
169
169
170 ####################
170 ####################
171 # Generator's methods
171 # Generator's methods
172 ####################
172 ####################
173
173
174 def __iter__(self):
174 def __iter__(self):
175 return self
175 return self
176
176
177 def next(self):
177 def next(self):
178 while not len(self.data) and not self.worker.EOF.is_set():
178 while not len(self.data) and not self.worker.EOF.is_set():
179 self.worker.data_added.clear()
179 self.worker.data_added.clear()
180 self.worker.data_added.wait(0.2)
180 self.worker.data_added.wait(0.2)
181 if len(self.data):
181 if len(self.data):
182 self.worker.keep_reading.set()
182 self.worker.keep_reading.set()
183 return bytes(self.data.popleft())
183 return bytes(self.data.popleft())
184 elif self.worker.EOF.is_set():
184 elif self.worker.EOF.is_set():
185 raise StopIteration
185 raise StopIteration
186
186
187 def throw(self, type, value=None, traceback=None):
187 def throw(self, type, value=None, traceback=None):
188 if not self.worker.EOF.is_set():
188 if not self.worker.EOF.is_set():
189 raise type(value)
189 raise type(value)
190
190
191 def start(self):
191 def start(self):
192 self.worker.start()
192 self.worker.start()
193
193
194 def stop(self):
194 def stop(self):
195 self.worker.stop()
195 self.worker.stop()
196
196
197 def close(self):
197 def close(self):
198 try:
198 try:
199 self.worker.stop()
199 self.worker.stop()
200 self.throw(GeneratorExit)
200 self.throw(GeneratorExit)
201 except (GeneratorExit, StopIteration):
201 except (GeneratorExit, StopIteration):
202 pass
202 pass
203
203
204 def __del__(self):
204 def __del__(self):
205 self.close()
205 self.close()
206
206
207 ####################
207 ####################
208 # Threaded reader's infrastructure.
208 # Threaded reader's infrastructure.
209 ####################
209 ####################
210 @property
210 @property
211 def input(self):
211 def input(self):
212 return self.worker.w
212 return self.worker.w
213
213
214 @property
214 @property
215 def data_added_event(self):
215 def data_added_event(self):
216 return self.worker.data_added
216 return self.worker.data_added
217
217
218 @property
218 @property
219 def data_added(self):
219 def data_added(self):
220 return self.worker.data_added.is_set()
220 return self.worker.data_added.is_set()
221
221
222 @property
222 @property
223 def reading_paused(self):
223 def reading_paused(self):
224 return not self.worker.keep_reading.is_set()
224 return not self.worker.keep_reading.is_set()
225
225
226 @property
226 @property
227 def done_reading_event(self):
227 def done_reading_event(self):
228 '''
228 '''
229 Done_reding does not mean that the iterator's buffer is empty.
229 Done_reding does not mean that the iterator's buffer is empty.
230 Iterator might have done reading from underlying source, but the read
230 Iterator might have done reading from underlying source, but the read
231 chunks might still be available for serving through .next() method.
231 chunks might still be available for serving through .next() method.
232
232
233 @return An Event class instance.
233 @return An Event class instance.
234 '''
234 '''
235 return self.worker.EOF
235 return self.worker.EOF
236
236
237 @property
237 @property
238 def done_reading(self):
238 def done_reading(self):
239 '''
239 '''
240 Done_reding does not mean that the iterator's buffer is empty.
240 Done_reding does not mean that the iterator's buffer is empty.
241 Iterator might have done reading from underlying source, but the read
241 Iterator might have done reading from underlying source, but the read
242 chunks might still be available for serving through .next() method.
242 chunks might still be available for serving through .next() method.
243
243
244 @return An Bool value.
244 @return An Bool value.
245 '''
245 '''
246 return self.worker.EOF.is_set()
246 return self.worker.EOF.is_set()
247
247
248 @property
248 @property
249 def length(self):
249 def length(self):
250 '''
250 '''
251 returns int.
251 returns int.
252
252
253 This is the lenght of the que of chunks, not the length of
253 This is the lenght of the que of chunks, not the length of
254 the combined contents in those chunks.
254 the combined contents in those chunks.
255
255
256 __len__() cannot be meaningfully implemented because this
256 __len__() cannot be meaningfully implemented because this
257 reader is just flying throuh a bottomless pit content and
257 reader is just flying throuh a bottomless pit content and
258 can only know the lenght of what it already saw.
258 can only know the lenght of what it already saw.
259
259
260 If __len__() on WSGI server per PEP 3333 returns a value,
260 If __len__() on WSGI server per PEP 3333 returns a value,
261 the responce's length will be set to that. In order not to
261 the responce's length will be set to that. In order not to
262 confuse WSGI PEP3333 servers, we will not implement __len__
262 confuse WSGI PEP3333 servers, we will not implement __len__
263 at all.
263 at all.
264 '''
264 '''
265 return len(self.data)
265 return len(self.data)
266
266
267 def prepend(self, x):
267 def prepend(self, x):
268 self.data.appendleft(x)
268 self.data.appendleft(x)
269
269
270 def append(self, x):
270 def append(self, x):
271 self.data.append(x)
271 self.data.append(x)
272
272
273 def extend(self, o):
273 def extend(self, o):
274 self.data.extend(o)
274 self.data.extend(o)
275
275
276 def __getitem__(self, i):
276 def __getitem__(self, i):
277 return self.data[i]
277 return self.data[i]
278
278
279
279
280 class SubprocessIOChunker(object):
280 class SubprocessIOChunker(object):
281 '''
281 '''
282 Processor class wrapping handling of subprocess IO.
282 Processor class wrapping handling of subprocess IO.
283
283
284 In a way, this is a "communicate()" replacement with a twist.
284 In a way, this is a "communicate()" replacement with a twist.
285
285
286 - We are multithreaded. Writing in and reading out, err are all sep threads.
286 - We are multithreaded. Writing in and reading out, err are all sep threads.
287 - We support concurrent (in and out) stream processing.
287 - We support concurrent (in and out) stream processing.
288 - The output is not a stream. It's a queue of read string (bytes, not unicode)
288 - The output is not a stream. It's a queue of read string (bytes, not unicode)
289 chunks. The object behaves as an iterable. You can "for chunk in obj:" us.
289 chunks. The object behaves as an iterable. You can "for chunk in obj:" us.
290 - We are non-blocking in more respects than communicate()
290 - We are non-blocking in more respects than communicate()
291 (reading from subprocess out pauses when internal buffer is full, but
291 (reading from subprocess out pauses when internal buffer is full, but
292 does not block the parent calling code. On the flip side, reading from
292 does not block the parent calling code. On the flip side, reading from
293 slow-yielding subprocess may block the iteration until data shows up. This
293 slow-yielding subprocess may block the iteration until data shows up. This
294 does not block the parallel inpipe reading occurring parallel thread.)
294 does not block the parallel inpipe reading occurring parallel thread.)
295
295
296 The purpose of the object is to allow us to wrap subprocess interactions into
296 The purpose of the object is to allow us to wrap subprocess interactions into
297 and interable that can be passed to a WSGI server as the application's return
297 and interable that can be passed to a WSGI server as the application's return
298 value. Because of stream-processing-ability, WSGI does not have to read ALL
298 value. Because of stream-processing-ability, WSGI does not have to read ALL
299 of the subprocess's output and buffer it, before handing it to WSGI server for
299 of the subprocess's output and buffer it, before handing it to WSGI server for
300 HTTP response. Instead, the class initializer reads just a bit of the stream
300 HTTP response. Instead, the class initializer reads just a bit of the stream
301 to figure out if error ocurred or likely to occur and if not, just hands the
301 to figure out if error ocurred or likely to occur and if not, just hands the
302 further iteration over subprocess output to the server for completion of HTTP
302 further iteration over subprocess output to the server for completion of HTTP
303 response.
303 response.
304
304
305 The real or perceived subprocess error is trapped and raised as one of
305 The real or perceived subprocess error is trapped and raised as one of
306 EnvironmentError family of exceptions
306 EnvironmentError family of exceptions
307
307
308 Example usage:
308 Example usage:
309 # try:
309 # try:
310 # answer = SubprocessIOChunker(
310 # answer = SubprocessIOChunker(
311 # cmd,
311 # cmd,
312 # input,
312 # input,
313 # buffer_size = 65536,
313 # buffer_size = 65536,
314 # chunk_size = 4096
314 # chunk_size = 4096
315 # )
315 # )
316 # except (EnvironmentError) as e:
316 # except (EnvironmentError) as e:
317 # print str(e)
317 # print str(e)
318 # raise e
318 # raise e
319 #
319 #
320 # return answer
320 # return answer
321
321
322
322
323 '''
323 '''
324 def __init__(self, cmd, inputstream=None, buffer_size=65536,
324 def __init__(self, cmd, inputstream=None, buffer_size=65536,
325 chunk_size=4096, starting_values=[], **kwargs):
325 chunk_size=4096, starting_values=[], **kwargs):
326 '''
326 '''
327 Initializes SubprocessIOChunker
327 Initializes SubprocessIOChunker
328
328
329 :param cmd: A Subprocess.Popen style "cmd". Can be string or array of strings
329 :param cmd: A Subprocess.Popen style "cmd". Can be string or array of strings
330 :param inputstream: (Default: None) A file-like, string, or file pointer.
330 :param inputstream: (Default: None) A file-like, string, or file pointer.
331 :param buffer_size: (Default: 65536) A size of total buffer per stream in bytes.
331 :param buffer_size: (Default: 65536) A size of total buffer per stream in bytes.
332 :param chunk_size: (Default: 4096) A max size of a chunk. Actual chunk may be smaller.
332 :param chunk_size: (Default: 4096) A max size of a chunk. Actual chunk may be smaller.
333 :param starting_values: (Default: []) An array of strings to put in front of output que.
333 :param starting_values: (Default: []) An array of strings to put in front of output que.
334 '''
334 '''
335
335
336 if inputstream:
336 if inputstream:
337 input_streamer = StreamFeeder(inputstream)
337 input_streamer = StreamFeeder(inputstream)
338 input_streamer.start()
338 input_streamer.start()
339 inputstream = input_streamer.output
339 inputstream = input_streamer.output
340
340
341 if isinstance(cmd, (list, tuple)):
341 if isinstance(cmd, (list, tuple)):
342 cmd = ' '.join(cmd)
342 cmd = ' '.join(cmd)
343
343
344 _shell = kwargs.get('shell') or True
345 kwargs['shell'] = _shell
344 _p = subprocess.Popen(cmd,
346 _p = subprocess.Popen(cmd,
345 bufsize=-1,
347 bufsize=-1,
346 shell=True,
347 stdin=inputstream,
348 stdin=inputstream,
348 stdout=subprocess.PIPE,
349 stdout=subprocess.PIPE,
349 stderr=subprocess.PIPE,
350 stderr=subprocess.PIPE,
350 **kwargs
351 **kwargs
351 )
352 )
352
353
353 bg_out = BufferedGenerator(_p.stdout, buffer_size, chunk_size, starting_values)
354 bg_out = BufferedGenerator(_p.stdout, buffer_size, chunk_size, starting_values)
354 bg_err = BufferedGenerator(_p.stderr, 16000, 1, bottomless=True)
355 bg_err = BufferedGenerator(_p.stderr, 16000, 1, bottomless=True)
355
356
356 while not bg_out.done_reading and not bg_out.reading_paused and not bg_err.length:
357 while not bg_out.done_reading and not bg_out.reading_paused and not bg_err.length:
357 # doing this until we reach either end of file, or end of buffer.
358 # doing this until we reach either end of file, or end of buffer.
358 bg_out.data_added_event.wait(1)
359 bg_out.data_added_event.wait(1)
359 bg_out.data_added_event.clear()
360 bg_out.data_added_event.clear()
360
361
361 # at this point it's still ambiguous if we are done reading or just full buffer.
362 # at this point it's still ambiguous if we are done reading or just full buffer.
362 # Either way, if error (returned by ended process, or implied based on
363 # Either way, if error (returned by ended process, or implied based on
363 # presence of stuff in stderr output) we error out.
364 # presence of stuff in stderr output) we error out.
364 # Else, we are happy.
365 # Else, we are happy.
365 _returncode = _p.poll()
366 _returncode = _p.poll()
366 if _returncode or (_returncode == None and bg_err.length):
367 if _returncode or (_returncode == None and bg_err.length):
367 try:
368 try:
368 _p.terminate()
369 _p.terminate()
369 except:
370 except:
370 pass
371 pass
371 bg_out.stop()
372 bg_out.stop()
372 bg_err.stop()
373 bg_err.stop()
373 err = '%s' % ''.join(bg_err)
374 err = '%s' % ''.join(bg_err)
374 raise EnvironmentError("Subprocess exited due to an error:\n" + err)
375 raise EnvironmentError("Subprocess exited due to an error:\n" + err)
375
376
376 self.process = _p
377 self.process = _p
377 self.output = bg_out
378 self.output = bg_out
378 self.error = bg_err
379 self.error = bg_err
379
380
380 def __iter__(self):
381 def __iter__(self):
381 return self
382 return self
382
383
383 def next(self):
384 def next(self):
384 if self.process.poll():
385 if self.process.poll():
385 err = '%s' % ''.join(self.error)
386 err = '%s' % ''.join(self.error)
386 raise EnvironmentError("Subprocess exited due to an error:\n" + err)
387 raise EnvironmentError("Subprocess exited due to an error:\n" + err)
387 return self.output.next()
388 return self.output.next()
388
389
389 def throw(self, type, value=None, traceback=None):
390 def throw(self, type, value=None, traceback=None):
390 if self.output.length or not self.output.done_reading:
391 if self.output.length or not self.output.done_reading:
391 raise type(value)
392 raise type(value)
392
393
393 def close(self):
394 def close(self):
394 try:
395 try:
395 self.process.terminate()
396 self.process.terminate()
396 except:
397 except:
397 pass
398 pass
398 try:
399 try:
399 self.output.close()
400 self.output.close()
400 except:
401 except:
401 pass
402 pass
402 try:
403 try:
403 self.error.close()
404 self.error.close()
404 except:
405 except:
405 pass
406 pass
406
407
407 def __del__(self):
408 def __del__(self):
408 self.close()
409 self.close()
@@ -1,655 +1,654 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 """
2 """
3 vcs.backends.git
3 vcs.backends.git
4 ~~~~~~~~~~~~~~~~
4 ~~~~~~~~~~~~~~~~
5
5
6 Git backend implementation.
6 Git backend implementation.
7
7
8 :created_on: Apr 8, 2010
8 :created_on: Apr 8, 2010
9 :copyright: (c) 2010-2011 by Marcin Kuzminski, Lukasz Balcerzak.
9 :copyright: (c) 2010-2011 by Marcin Kuzminski, Lukasz Balcerzak.
10 """
10 """
11
11
12 import os
12 import os
13 import re
13 import re
14 import time
14 import time
15 import posixpath
15 import posixpath
16 import logging
16 import logging
17 import traceback
17 import traceback
18 import urllib
18 import urllib
19 import urllib2
19 import urllib2
20 from dulwich.repo import Repo, NotGitRepository
20 from dulwich.repo import Repo, NotGitRepository
21 #from dulwich.config import ConfigFile
21 #from dulwich.config import ConfigFile
22 from string import Template
22 from string import Template
23 from subprocess import Popen, PIPE
23 from subprocess import Popen, PIPE
24 from rhodecode.lib.vcs.backends.base import BaseRepository
24 from rhodecode.lib.vcs.backends.base import BaseRepository
25 from rhodecode.lib.vcs.exceptions import BranchDoesNotExistError
25 from rhodecode.lib.vcs.exceptions import BranchDoesNotExistError
26 from rhodecode.lib.vcs.exceptions import ChangesetDoesNotExistError
26 from rhodecode.lib.vcs.exceptions import ChangesetDoesNotExistError
27 from rhodecode.lib.vcs.exceptions import EmptyRepositoryError
27 from rhodecode.lib.vcs.exceptions import EmptyRepositoryError
28 from rhodecode.lib.vcs.exceptions import RepositoryError
28 from rhodecode.lib.vcs.exceptions import RepositoryError
29 from rhodecode.lib.vcs.exceptions import TagAlreadyExistError
29 from rhodecode.lib.vcs.exceptions import TagAlreadyExistError
30 from rhodecode.lib.vcs.exceptions import TagDoesNotExistError
30 from rhodecode.lib.vcs.exceptions import TagDoesNotExistError
31 from rhodecode.lib.vcs.utils import safe_unicode, makedate, date_fromtimestamp
31 from rhodecode.lib.vcs.utils import safe_unicode, makedate, date_fromtimestamp
32 from rhodecode.lib.vcs.utils.lazy import LazyProperty
32 from rhodecode.lib.vcs.utils.lazy import LazyProperty
33 from rhodecode.lib.vcs.utils.ordered_dict import OrderedDict
33 from rhodecode.lib.vcs.utils.ordered_dict import OrderedDict
34 from rhodecode.lib.vcs.utils.paths import abspath
34 from rhodecode.lib.vcs.utils.paths import abspath
35 from rhodecode.lib.vcs.utils.paths import get_user_home
35 from rhodecode.lib.vcs.utils.paths import get_user_home
36 from .workdir import GitWorkdir
36 from .workdir import GitWorkdir
37 from .changeset import GitChangeset
37 from .changeset import GitChangeset
38 from .inmemory import GitInMemoryChangeset
38 from .inmemory import GitInMemoryChangeset
39 from .config import ConfigFile
39 from .config import ConfigFile
40 from rhodecode.lib import subprocessio
40 from rhodecode.lib import subprocessio
41
41
42
42
43 log = logging.getLogger(__name__)
43 log = logging.getLogger(__name__)
44
44
45
45
46 class GitRepository(BaseRepository):
46 class GitRepository(BaseRepository):
47 """
47 """
48 Git repository backend.
48 Git repository backend.
49 """
49 """
50 DEFAULT_BRANCH_NAME = 'master'
50 DEFAULT_BRANCH_NAME = 'master'
51 scm = 'git'
51 scm = 'git'
52
52
53 def __init__(self, repo_path, create=False, src_url=None,
53 def __init__(self, repo_path, create=False, src_url=None,
54 update_after_clone=False, bare=False):
54 update_after_clone=False, bare=False):
55
55
56 self.path = abspath(repo_path)
56 self.path = abspath(repo_path)
57 self._repo = self._get_repo(create, src_url, update_after_clone, bare)
57 self._repo = self._get_repo(create, src_url, update_after_clone, bare)
58 #temporary set that to now at later we will move it to constructor
58 #temporary set that to now at later we will move it to constructor
59 baseui = None
59 baseui = None
60 if baseui is None:
60 if baseui is None:
61 from mercurial.ui import ui
61 from mercurial.ui import ui
62 baseui = ui()
62 baseui = ui()
63 # patch the instance of GitRepo with an "FAKE" ui object to add
63 # patch the instance of GitRepo with an "FAKE" ui object to add
64 # compatibility layer with Mercurial
64 # compatibility layer with Mercurial
65 setattr(self._repo, 'ui', baseui)
65 setattr(self._repo, 'ui', baseui)
66
66
67 try:
67 try:
68 self.head = self._repo.head()
68 self.head = self._repo.head()
69 except KeyError:
69 except KeyError:
70 self.head = None
70 self.head = None
71
71
72 self._config_files = [
72 self._config_files = [
73 bare and abspath(self.path, 'config') or abspath(self.path, '.git',
73 bare and abspath(self.path, 'config') or abspath(self.path, '.git',
74 'config'),
74 'config'),
75 abspath(get_user_home(), '.gitconfig'),
75 abspath(get_user_home(), '.gitconfig'),
76 ]
76 ]
77 self.bare = self._repo.bare
77 self.bare = self._repo.bare
78
78
79 @LazyProperty
79 @LazyProperty
80 def revisions(self):
80 def revisions(self):
81 """
81 """
82 Returns list of revisions' ids, in ascending order. Being lazy
82 Returns list of revisions' ids, in ascending order. Being lazy
83 attribute allows external tools to inject shas from cache.
83 attribute allows external tools to inject shas from cache.
84 """
84 """
85 return self._get_all_revisions()
85 return self._get_all_revisions()
86
86
87 def run_git_command(self, cmd):
87 def run_git_command(self, cmd):
88 """
88 """
89 Runs given ``cmd`` as git command and returns tuple
89 Runs given ``cmd`` as git command and returns tuple
90 (returncode, stdout, stderr).
90 (returncode, stdout, stderr).
91
91
92 .. note::
92 .. note::
93 This method exists only until log/blame functionality is implemented
93 This method exists only until log/blame functionality is implemented
94 at Dulwich (see https://bugs.launchpad.net/bugs/645142). Parsing
94 at Dulwich (see https://bugs.launchpad.net/bugs/645142). Parsing
95 os command's output is road to hell...
95 os command's output is road to hell...
96
96
97 :param cmd: git command to be executed
97 :param cmd: git command to be executed
98 """
98 """
99
99
100 _copts = ['-c', 'core.quotepath=false', ]
100 _copts = ['-c', 'core.quotepath=false', ]
101 _str_cmd = False
101 _str_cmd = False
102 if isinstance(cmd, basestring):
102 if isinstance(cmd, basestring):
103 cmd = [cmd]
103 cmd = [cmd]
104 _str_cmd = True
104 _str_cmd = True
105
105
106 gitenv = os.environ
106 gitenv = os.environ
107 # need to clean fix GIT_DIR !
107 # need to clean fix GIT_DIR !
108 if 'GIT_DIR' in gitenv:
108 if 'GIT_DIR' in gitenv:
109 del gitenv['GIT_DIR']
109 del gitenv['GIT_DIR']
110 gitenv['GIT_CONFIG_NOGLOBAL'] = '1'
110 gitenv['GIT_CONFIG_NOGLOBAL'] = '1'
111
111
112 cmd = ['git'] + _copts + cmd
112 cmd = ['git'] + _copts + cmd
113 if _str_cmd:
113 if _str_cmd:
114 cmd = ' '.join(cmd)
114 cmd = ' '.join(cmd)
115 try:
115 try:
116 opts = dict(
116 opts = dict(
117 env=gitenv,
117 env=gitenv,
118 shell=False,
118 )
119 )
119 if os.path.isdir(self.path):
120 if os.path.isdir(self.path):
120 opts['cwd'] = self.path
121 opts['cwd'] = self.path
121 p = subprocessio.SubprocessIOChunker(cmd, **opts)
122 p = subprocessio.SubprocessIOChunker(cmd, **opts)
122 except (EnvironmentError, OSError), err:
123 except (EnvironmentError, OSError), err:
123 log.error(traceback.format_exc())
124 log.error(traceback.format_exc())
124 raise RepositoryError("Couldn't run git command (%s).\n"
125 raise RepositoryError("Couldn't run git command (%s).\n"
125 "Original error was:%s" % (cmd, err))
126 "Original error was:%s" % (cmd, err))
126
127
127 so = ''.join(p)
128 return ''.join(p.output), ''.join(p.error)
128 se = None
129 return so, se
130
129
131 @classmethod
130 @classmethod
132 def _check_url(cls, url):
131 def _check_url(cls, url):
133 """
132 """
134 Functon will check given url and try to verify if it's a valid
133 Functon will check given url and try to verify if it's a valid
135 link. Sometimes it may happened that mercurial will issue basic
134 link. Sometimes it may happened that mercurial will issue basic
136 auth request that can cause whole API to hang when used from python
135 auth request that can cause whole API to hang when used from python
137 or other external calls.
136 or other external calls.
138
137
139 On failures it'll raise urllib2.HTTPError
138 On failures it'll raise urllib2.HTTPError
140 """
139 """
141 from mercurial.util import url as Url
140 from mercurial.util import url as Url
142
141
143 # those authnadlers are patched for python 2.6.5 bug an
142 # those authnadlers are patched for python 2.6.5 bug an
144 # infinit looping when given invalid resources
143 # infinit looping when given invalid resources
145 from mercurial.url import httpbasicauthhandler, httpdigestauthhandler
144 from mercurial.url import httpbasicauthhandler, httpdigestauthhandler
146
145
147 # check first if it's not an local url
146 # check first if it's not an local url
148 if os.path.isdir(url) or url.startswith('file:'):
147 if os.path.isdir(url) or url.startswith('file:'):
149 return True
148 return True
150
149
151 if('+' in url[:url.find('://')]):
150 if('+' in url[:url.find('://')]):
152 url = url[url.find('+') + 1:]
151 url = url[url.find('+') + 1:]
153
152
154 handlers = []
153 handlers = []
155 test_uri, authinfo = Url(url).authinfo()
154 test_uri, authinfo = Url(url).authinfo()
156 if not test_uri.endswith('info/refs'):
155 if not test_uri.endswith('info/refs'):
157 test_uri = test_uri.rstrip('/') + '/info/refs'
156 test_uri = test_uri.rstrip('/') + '/info/refs'
158 if authinfo:
157 if authinfo:
159 #create a password manager
158 #create a password manager
160 passmgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
159 passmgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
161 passmgr.add_password(*authinfo)
160 passmgr.add_password(*authinfo)
162
161
163 handlers.extend((httpbasicauthhandler(passmgr),
162 handlers.extend((httpbasicauthhandler(passmgr),
164 httpdigestauthhandler(passmgr)))
163 httpdigestauthhandler(passmgr)))
165
164
166 o = urllib2.build_opener(*handlers)
165 o = urllib2.build_opener(*handlers)
167 o.addheaders = [('User-Agent', 'git/1.7.8.0')] # fake some git
166 o.addheaders = [('User-Agent', 'git/1.7.8.0')] # fake some git
168
167
169 q = {"service": 'git-upload-pack'}
168 q = {"service": 'git-upload-pack'}
170 qs = '?%s' % urllib.urlencode(q)
169 qs = '?%s' % urllib.urlencode(q)
171 cu = "%s%s" % (test_uri, qs)
170 cu = "%s%s" % (test_uri, qs)
172 req = urllib2.Request(cu, None, {})
171 req = urllib2.Request(cu, None, {})
173
172
174 try:
173 try:
175 resp = o.open(req)
174 resp = o.open(req)
176 return resp.code == 200
175 return resp.code == 200
177 except Exception, e:
176 except Exception, e:
178 # means it cannot be cloned
177 # means it cannot be cloned
179 raise urllib2.URLError("[%s] %s" % (url, e))
178 raise urllib2.URLError("[%s] %s" % (url, e))
180
179
181 def _get_repo(self, create, src_url=None, update_after_clone=False,
180 def _get_repo(self, create, src_url=None, update_after_clone=False,
182 bare=False):
181 bare=False):
183 if create and os.path.exists(self.path):
182 if create and os.path.exists(self.path):
184 raise RepositoryError("Location already exist")
183 raise RepositoryError("Location already exist")
185 if src_url and not create:
184 if src_url and not create:
186 raise RepositoryError("Create should be set to True if src_url is "
185 raise RepositoryError("Create should be set to True if src_url is "
187 "given (clone operation creates repository)")
186 "given (clone operation creates repository)")
188 try:
187 try:
189 if create and src_url:
188 if create and src_url:
190 GitRepository._check_url(src_url)
189 GitRepository._check_url(src_url)
191 self.clone(src_url, update_after_clone, bare)
190 self.clone(src_url, update_after_clone, bare)
192 return Repo(self.path)
191 return Repo(self.path)
193 elif create:
192 elif create:
194 os.mkdir(self.path)
193 os.mkdir(self.path)
195 if bare:
194 if bare:
196 return Repo.init_bare(self.path)
195 return Repo.init_bare(self.path)
197 else:
196 else:
198 return Repo.init(self.path)
197 return Repo.init(self.path)
199 else:
198 else:
200 return Repo(self.path)
199 return Repo(self.path)
201 except (NotGitRepository, OSError), err:
200 except (NotGitRepository, OSError), err:
202 raise RepositoryError(err)
201 raise RepositoryError(err)
203
202
204 def _get_all_revisions(self):
203 def _get_all_revisions(self):
205 # we must check if this repo is not empty, since later command
204 # we must check if this repo is not empty, since later command
206 # fails if it is. And it's cheaper to ask than throw the subprocess
205 # fails if it is. And it's cheaper to ask than throw the subprocess
207 # errors
206 # errors
208 try:
207 try:
209 self._repo.head()
208 self._repo.head()
210 except KeyError:
209 except KeyError:
211 return []
210 return []
212 cmd = 'rev-list --all --reverse --date-order'
211 cmd = 'rev-list --all --reverse --date-order'
213 try:
212 try:
214 so, se = self.run_git_command(cmd)
213 so, se = self.run_git_command(cmd)
215 except RepositoryError:
214 except RepositoryError:
216 # Can be raised for empty repositories
215 # Can be raised for empty repositories
217 return []
216 return []
218 return so.splitlines()
217 return so.splitlines()
219
218
220 def _get_all_revisions2(self):
219 def _get_all_revisions2(self):
221 #alternate implementation using dulwich
220 #alternate implementation using dulwich
222 includes = [x[1][0] for x in self._parsed_refs.iteritems()
221 includes = [x[1][0] for x in self._parsed_refs.iteritems()
223 if x[1][1] != 'T']
222 if x[1][1] != 'T']
224 return [c.commit.id for c in self._repo.get_walker(include=includes)]
223 return [c.commit.id for c in self._repo.get_walker(include=includes)]
225
224
226 def _get_revision(self, revision):
225 def _get_revision(self, revision):
227 """
226 """
228 For git backend we always return integer here. This way we ensure
227 For git backend we always return integer here. This way we ensure
229 that changset's revision attribute would become integer.
228 that changset's revision attribute would become integer.
230 """
229 """
231 pattern = re.compile(r'^[[0-9a-fA-F]{12}|[0-9a-fA-F]{40}]$')
230 pattern = re.compile(r'^[[0-9a-fA-F]{12}|[0-9a-fA-F]{40}]$')
232 is_bstr = lambda o: isinstance(o, (str, unicode))
231 is_bstr = lambda o: isinstance(o, (str, unicode))
233 is_null = lambda o: len(o) == revision.count('0')
232 is_null = lambda o: len(o) == revision.count('0')
234
233
235 if len(self.revisions) == 0:
234 if len(self.revisions) == 0:
236 raise EmptyRepositoryError("There are no changesets yet")
235 raise EmptyRepositoryError("There are no changesets yet")
237
236
238 if revision in (None, '', 'tip', 'HEAD', 'head', -1):
237 if revision in (None, '', 'tip', 'HEAD', 'head', -1):
239 revision = self.revisions[-1]
238 revision = self.revisions[-1]
240
239
241 if ((is_bstr(revision) and revision.isdigit() and len(revision) < 12)
240 if ((is_bstr(revision) and revision.isdigit() and len(revision) < 12)
242 or isinstance(revision, int) or is_null(revision)):
241 or isinstance(revision, int) or is_null(revision)):
243 try:
242 try:
244 revision = self.revisions[int(revision)]
243 revision = self.revisions[int(revision)]
245 except:
244 except:
246 raise ChangesetDoesNotExistError("Revision %r does not exist "
245 raise ChangesetDoesNotExistError("Revision %r does not exist "
247 "for this repository %s" % (revision, self))
246 "for this repository %s" % (revision, self))
248
247
249 elif is_bstr(revision):
248 elif is_bstr(revision):
250 # get by branch/tag name
249 # get by branch/tag name
251 _ref_revision = self._parsed_refs.get(revision)
250 _ref_revision = self._parsed_refs.get(revision)
252 _tags_shas = self.tags.values()
251 _tags_shas = self.tags.values()
253 if _ref_revision: # and _ref_revision[1] in ['H', 'RH', 'T']:
252 if _ref_revision: # and _ref_revision[1] in ['H', 'RH', 'T']:
254 return _ref_revision[0]
253 return _ref_revision[0]
255
254
256 # maybe it's a tag ? we don't have them in self.revisions
255 # maybe it's a tag ? we don't have them in self.revisions
257 elif revision in _tags_shas:
256 elif revision in _tags_shas:
258 return _tags_shas[_tags_shas.index(revision)]
257 return _tags_shas[_tags_shas.index(revision)]
259
258
260 elif not pattern.match(revision) or revision not in self.revisions:
259 elif not pattern.match(revision) or revision not in self.revisions:
261 raise ChangesetDoesNotExistError("Revision %r does not exist "
260 raise ChangesetDoesNotExistError("Revision %r does not exist "
262 "for this repository %s" % (revision, self))
261 "for this repository %s" % (revision, self))
263
262
264 # Ensure we return full id
263 # Ensure we return full id
265 if not pattern.match(str(revision)):
264 if not pattern.match(str(revision)):
266 raise ChangesetDoesNotExistError("Given revision %r not recognized"
265 raise ChangesetDoesNotExistError("Given revision %r not recognized"
267 % revision)
266 % revision)
268 return revision
267 return revision
269
268
270 def _get_archives(self, archive_name='tip'):
269 def _get_archives(self, archive_name='tip'):
271
270
272 for i in [('zip', '.zip'), ('gz', '.tar.gz'), ('bz2', '.tar.bz2')]:
271 for i in [('zip', '.zip'), ('gz', '.tar.gz'), ('bz2', '.tar.bz2')]:
273 yield {"type": i[0], "extension": i[1], "node": archive_name}
272 yield {"type": i[0], "extension": i[1], "node": archive_name}
274
273
275 def _get_url(self, url):
274 def _get_url(self, url):
276 """
275 """
277 Returns normalized url. If schema is not given, would fall to
276 Returns normalized url. If schema is not given, would fall to
278 filesystem (``file:///``) schema.
277 filesystem (``file:///``) schema.
279 """
278 """
280 url = str(url)
279 url = str(url)
281 if url != 'default' and not '://' in url:
280 if url != 'default' and not '://' in url:
282 url = ':///'.join(('file', url))
281 url = ':///'.join(('file', url))
283 return url
282 return url
284
283
285 @LazyProperty
284 @LazyProperty
286 def name(self):
285 def name(self):
287 return os.path.basename(self.path)
286 return os.path.basename(self.path)
288
287
289 @LazyProperty
288 @LazyProperty
290 def last_change(self):
289 def last_change(self):
291 """
290 """
292 Returns last change made on this repository as datetime object
291 Returns last change made on this repository as datetime object
293 """
292 """
294 return date_fromtimestamp(self._get_mtime(), makedate()[1])
293 return date_fromtimestamp(self._get_mtime(), makedate()[1])
295
294
296 def _get_mtime(self):
295 def _get_mtime(self):
297 try:
296 try:
298 return time.mktime(self.get_changeset().date.timetuple())
297 return time.mktime(self.get_changeset().date.timetuple())
299 except RepositoryError:
298 except RepositoryError:
300 idx_loc = '' if self.bare else '.git'
299 idx_loc = '' if self.bare else '.git'
301 # fallback to filesystem
300 # fallback to filesystem
302 in_path = os.path.join(self.path, idx_loc, "index")
301 in_path = os.path.join(self.path, idx_loc, "index")
303 he_path = os.path.join(self.path, idx_loc, "HEAD")
302 he_path = os.path.join(self.path, idx_loc, "HEAD")
304 if os.path.exists(in_path):
303 if os.path.exists(in_path):
305 return os.stat(in_path).st_mtime
304 return os.stat(in_path).st_mtime
306 else:
305 else:
307 return os.stat(he_path).st_mtime
306 return os.stat(he_path).st_mtime
308
307
309 @LazyProperty
308 @LazyProperty
310 def description(self):
309 def description(self):
311 idx_loc = '' if self.bare else '.git'
310 idx_loc = '' if self.bare else '.git'
312 undefined_description = u'unknown'
311 undefined_description = u'unknown'
313 description_path = os.path.join(self.path, idx_loc, 'description')
312 description_path = os.path.join(self.path, idx_loc, 'description')
314 if os.path.isfile(description_path):
313 if os.path.isfile(description_path):
315 return safe_unicode(open(description_path).read())
314 return safe_unicode(open(description_path).read())
316 else:
315 else:
317 return undefined_description
316 return undefined_description
318
317
319 @LazyProperty
318 @LazyProperty
320 def contact(self):
319 def contact(self):
321 undefined_contact = u'Unknown'
320 undefined_contact = u'Unknown'
322 return undefined_contact
321 return undefined_contact
323
322
324 @property
323 @property
325 def branches(self):
324 def branches(self):
326 if not self.revisions:
325 if not self.revisions:
327 return {}
326 return {}
328 sortkey = lambda ctx: ctx[0]
327 sortkey = lambda ctx: ctx[0]
329 _branches = [(x[0], x[1][0])
328 _branches = [(x[0], x[1][0])
330 for x in self._parsed_refs.iteritems() if x[1][1] == 'H']
329 for x in self._parsed_refs.iteritems() if x[1][1] == 'H']
331 return OrderedDict(sorted(_branches, key=sortkey, reverse=False))
330 return OrderedDict(sorted(_branches, key=sortkey, reverse=False))
332
331
333 @LazyProperty
332 @LazyProperty
334 def tags(self):
333 def tags(self):
335 return self._get_tags()
334 return self._get_tags()
336
335
337 def _get_tags(self):
336 def _get_tags(self):
338 if not self.revisions:
337 if not self.revisions:
339 return {}
338 return {}
340
339
341 sortkey = lambda ctx: ctx[0]
340 sortkey = lambda ctx: ctx[0]
342 _tags = [(x[0], x[1][0])
341 _tags = [(x[0], x[1][0])
343 for x in self._parsed_refs.iteritems() if x[1][1] == 'T']
342 for x in self._parsed_refs.iteritems() if x[1][1] == 'T']
344 return OrderedDict(sorted(_tags, key=sortkey, reverse=True))
343 return OrderedDict(sorted(_tags, key=sortkey, reverse=True))
345
344
346 def tag(self, name, user, revision=None, message=None, date=None,
345 def tag(self, name, user, revision=None, message=None, date=None,
347 **kwargs):
346 **kwargs):
348 """
347 """
349 Creates and returns a tag for the given ``revision``.
348 Creates and returns a tag for the given ``revision``.
350
349
351 :param name: name for new tag
350 :param name: name for new tag
352 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
351 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
353 :param revision: changeset id for which new tag would be created
352 :param revision: changeset id for which new tag would be created
354 :param message: message of the tag's commit
353 :param message: message of the tag's commit
355 :param date: date of tag's commit
354 :param date: date of tag's commit
356
355
357 :raises TagAlreadyExistError: if tag with same name already exists
356 :raises TagAlreadyExistError: if tag with same name already exists
358 """
357 """
359 if name in self.tags:
358 if name in self.tags:
360 raise TagAlreadyExistError("Tag %s already exists" % name)
359 raise TagAlreadyExistError("Tag %s already exists" % name)
361 changeset = self.get_changeset(revision)
360 changeset = self.get_changeset(revision)
362 message = message or "Added tag %s for commit %s" % (name,
361 message = message or "Added tag %s for commit %s" % (name,
363 changeset.raw_id)
362 changeset.raw_id)
364 self._repo.refs["refs/tags/%s" % name] = changeset._commit.id
363 self._repo.refs["refs/tags/%s" % name] = changeset._commit.id
365
364
366 self._parsed_refs = self._get_parsed_refs()
365 self._parsed_refs = self._get_parsed_refs()
367 self.tags = self._get_tags()
366 self.tags = self._get_tags()
368 return changeset
367 return changeset
369
368
370 def remove_tag(self, name, user, message=None, date=None):
369 def remove_tag(self, name, user, message=None, date=None):
371 """
370 """
372 Removes tag with the given ``name``.
371 Removes tag with the given ``name``.
373
372
374 :param name: name of the tag to be removed
373 :param name: name of the tag to be removed
375 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
374 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
376 :param message: message of the tag's removal commit
375 :param message: message of the tag's removal commit
377 :param date: date of tag's removal commit
376 :param date: date of tag's removal commit
378
377
379 :raises TagDoesNotExistError: if tag with given name does not exists
378 :raises TagDoesNotExistError: if tag with given name does not exists
380 """
379 """
381 if name not in self.tags:
380 if name not in self.tags:
382 raise TagDoesNotExistError("Tag %s does not exist" % name)
381 raise TagDoesNotExistError("Tag %s does not exist" % name)
383 tagpath = posixpath.join(self._repo.refs.path, 'refs', 'tags', name)
382 tagpath = posixpath.join(self._repo.refs.path, 'refs', 'tags', name)
384 try:
383 try:
385 os.remove(tagpath)
384 os.remove(tagpath)
386 self._parsed_refs = self._get_parsed_refs()
385 self._parsed_refs = self._get_parsed_refs()
387 self.tags = self._get_tags()
386 self.tags = self._get_tags()
388 except OSError, e:
387 except OSError, e:
389 raise RepositoryError(e.strerror)
388 raise RepositoryError(e.strerror)
390
389
391 @LazyProperty
390 @LazyProperty
392 def _parsed_refs(self):
391 def _parsed_refs(self):
393 return self._get_parsed_refs()
392 return self._get_parsed_refs()
394
393
395 def _get_parsed_refs(self):
394 def _get_parsed_refs(self):
396 refs = self._repo.get_refs()
395 refs = self._repo.get_refs()
397 keys = [('refs/heads/', 'H'),
396 keys = [('refs/heads/', 'H'),
398 ('refs/remotes/origin/', 'RH'),
397 ('refs/remotes/origin/', 'RH'),
399 ('refs/tags/', 'T')]
398 ('refs/tags/', 'T')]
400 _refs = {}
399 _refs = {}
401 for ref, sha in refs.iteritems():
400 for ref, sha in refs.iteritems():
402 for k, type_ in keys:
401 for k, type_ in keys:
403 if ref.startswith(k):
402 if ref.startswith(k):
404 _key = ref[len(k):]
403 _key = ref[len(k):]
405 _refs[_key] = [sha, type_]
404 _refs[_key] = [sha, type_]
406 break
405 break
407 return _refs
406 return _refs
408
407
409 def _heads(self, reverse=False):
408 def _heads(self, reverse=False):
410 refs = self._repo.get_refs()
409 refs = self._repo.get_refs()
411 heads = {}
410 heads = {}
412
411
413 for key, val in refs.items():
412 for key, val in refs.items():
414 for ref_key in ['refs/heads/', 'refs/remotes/origin/']:
413 for ref_key in ['refs/heads/', 'refs/remotes/origin/']:
415 if key.startswith(ref_key):
414 if key.startswith(ref_key):
416 n = key[len(ref_key):]
415 n = key[len(ref_key):]
417 if n not in ['HEAD']:
416 if n not in ['HEAD']:
418 heads[n] = val
417 heads[n] = val
419
418
420 return heads if reverse else dict((y, x) for x, y in heads.iteritems())
419 return heads if reverse else dict((y, x) for x, y in heads.iteritems())
421
420
422 def get_changeset(self, revision=None):
421 def get_changeset(self, revision=None):
423 """
422 """
424 Returns ``GitChangeset`` object representing commit from git repository
423 Returns ``GitChangeset`` object representing commit from git repository
425 at the given revision or head (most recent commit) if None given.
424 at the given revision or head (most recent commit) if None given.
426 """
425 """
427 if isinstance(revision, GitChangeset):
426 if isinstance(revision, GitChangeset):
428 return revision
427 return revision
429 revision = self._get_revision(revision)
428 revision = self._get_revision(revision)
430 changeset = GitChangeset(repository=self, revision=revision)
429 changeset = GitChangeset(repository=self, revision=revision)
431 return changeset
430 return changeset
432
431
433 def get_changesets(self, start=None, end=None, start_date=None,
432 def get_changesets(self, start=None, end=None, start_date=None,
434 end_date=None, branch_name=None, reverse=False):
433 end_date=None, branch_name=None, reverse=False):
435 """
434 """
436 Returns iterator of ``GitChangeset`` objects from start to end (both
435 Returns iterator of ``GitChangeset`` objects from start to end (both
437 are inclusive), in ascending date order (unless ``reverse`` is set).
436 are inclusive), in ascending date order (unless ``reverse`` is set).
438
437
439 :param start: changeset ID, as str; first returned changeset
438 :param start: changeset ID, as str; first returned changeset
440 :param end: changeset ID, as str; last returned changeset
439 :param end: changeset ID, as str; last returned changeset
441 :param start_date: if specified, changesets with commit date less than
440 :param start_date: if specified, changesets with commit date less than
442 ``start_date`` would be filtered out from returned set
441 ``start_date`` would be filtered out from returned set
443 :param end_date: if specified, changesets with commit date greater than
442 :param end_date: if specified, changesets with commit date greater than
444 ``end_date`` would be filtered out from returned set
443 ``end_date`` would be filtered out from returned set
445 :param branch_name: if specified, changesets not reachable from given
444 :param branch_name: if specified, changesets not reachable from given
446 branch would be filtered out from returned set
445 branch would be filtered out from returned set
447 :param reverse: if ``True``, returned generator would be reversed
446 :param reverse: if ``True``, returned generator would be reversed
448 (meaning that returned changesets would have descending date order)
447 (meaning that returned changesets would have descending date order)
449
448
450 :raise BranchDoesNotExistError: If given ``branch_name`` does not
449 :raise BranchDoesNotExistError: If given ``branch_name`` does not
451 exist.
450 exist.
452 :raise ChangesetDoesNotExistError: If changeset for given ``start`` or
451 :raise ChangesetDoesNotExistError: If changeset for given ``start`` or
453 ``end`` could not be found.
452 ``end`` could not be found.
454
453
455 """
454 """
456 if branch_name and branch_name not in self.branches:
455 if branch_name and branch_name not in self.branches:
457 raise BranchDoesNotExistError("Branch '%s' not found" \
456 raise BranchDoesNotExistError("Branch '%s' not found" \
458 % branch_name)
457 % branch_name)
459 # %H at format means (full) commit hash, initial hashes are retrieved
458 # %H at format means (full) commit hash, initial hashes are retrieved
460 # in ascending date order
459 # in ascending date order
461 cmd_template = 'log --date-order --reverse --pretty=format:"%H"'
460 cmd_template = 'log --date-order --reverse --pretty=format:"%H"'
462 cmd_params = {}
461 cmd_params = {}
463 if start_date:
462 if start_date:
464 cmd_template += ' --since "$since"'
463 cmd_template += ' --since "$since"'
465 cmd_params['since'] = start_date.strftime('%m/%d/%y %H:%M:%S')
464 cmd_params['since'] = start_date.strftime('%m/%d/%y %H:%M:%S')
466 if end_date:
465 if end_date:
467 cmd_template += ' --until "$until"'
466 cmd_template += ' --until "$until"'
468 cmd_params['until'] = end_date.strftime('%m/%d/%y %H:%M:%S')
467 cmd_params['until'] = end_date.strftime('%m/%d/%y %H:%M:%S')
469 if branch_name:
468 if branch_name:
470 cmd_template += ' $branch_name'
469 cmd_template += ' $branch_name'
471 cmd_params['branch_name'] = branch_name
470 cmd_params['branch_name'] = branch_name
472 else:
471 else:
473 cmd_template += ' --all'
472 cmd_template += ' --all'
474
473
475 cmd = Template(cmd_template).safe_substitute(**cmd_params)
474 cmd = Template(cmd_template).safe_substitute(**cmd_params)
476 revs = self.run_git_command(cmd)[0].splitlines()
475 revs = self.run_git_command(cmd)[0].splitlines()
477 start_pos = 0
476 start_pos = 0
478 end_pos = len(revs)
477 end_pos = len(revs)
479 if start:
478 if start:
480 _start = self._get_revision(start)
479 _start = self._get_revision(start)
481 try:
480 try:
482 start_pos = revs.index(_start)
481 start_pos = revs.index(_start)
483 except ValueError:
482 except ValueError:
484 pass
483 pass
485
484
486 if end is not None:
485 if end is not None:
487 _end = self._get_revision(end)
486 _end = self._get_revision(end)
488 try:
487 try:
489 end_pos = revs.index(_end)
488 end_pos = revs.index(_end)
490 except ValueError:
489 except ValueError:
491 pass
490 pass
492
491
493 if None not in [start, end] and start_pos > end_pos:
492 if None not in [start, end] and start_pos > end_pos:
494 raise RepositoryError('start cannot be after end')
493 raise RepositoryError('start cannot be after end')
495
494
496 if end_pos is not None:
495 if end_pos is not None:
497 end_pos += 1
496 end_pos += 1
498
497
499 revs = revs[start_pos:end_pos]
498 revs = revs[start_pos:end_pos]
500 if reverse:
499 if reverse:
501 revs = reversed(revs)
500 revs = reversed(revs)
502 for rev in revs:
501 for rev in revs:
503 yield self.get_changeset(rev)
502 yield self.get_changeset(rev)
504
503
505 def get_diff(self, rev1, rev2, path=None, ignore_whitespace=False,
504 def get_diff(self, rev1, rev2, path=None, ignore_whitespace=False,
506 context=3):
505 context=3):
507 """
506 """
508 Returns (git like) *diff*, as plain text. Shows changes introduced by
507 Returns (git like) *diff*, as plain text. Shows changes introduced by
509 ``rev2`` since ``rev1``.
508 ``rev2`` since ``rev1``.
510
509
511 :param rev1: Entry point from which diff is shown. Can be
510 :param rev1: Entry point from which diff is shown. Can be
512 ``self.EMPTY_CHANGESET`` - in this case, patch showing all
511 ``self.EMPTY_CHANGESET`` - in this case, patch showing all
513 the changes since empty state of the repository until ``rev2``
512 the changes since empty state of the repository until ``rev2``
514 :param rev2: Until which revision changes should be shown.
513 :param rev2: Until which revision changes should be shown.
515 :param ignore_whitespace: If set to ``True``, would not show whitespace
514 :param ignore_whitespace: If set to ``True``, would not show whitespace
516 changes. Defaults to ``False``.
515 changes. Defaults to ``False``.
517 :param context: How many lines before/after changed lines should be
516 :param context: How many lines before/after changed lines should be
518 shown. Defaults to ``3``.
517 shown. Defaults to ``3``.
519 """
518 """
520 flags = ['-U%s' % context]
519 flags = ['-U%s' % context]
521 if ignore_whitespace:
520 if ignore_whitespace:
522 flags.append('-w')
521 flags.append('-w')
523
522
524 if hasattr(rev1, 'raw_id'):
523 if hasattr(rev1, 'raw_id'):
525 rev1 = getattr(rev1, 'raw_id')
524 rev1 = getattr(rev1, 'raw_id')
526
525
527 if hasattr(rev2, 'raw_id'):
526 if hasattr(rev2, 'raw_id'):
528 rev2 = getattr(rev2, 'raw_id')
527 rev2 = getattr(rev2, 'raw_id')
529
528
530 if rev1 == self.EMPTY_CHANGESET:
529 if rev1 == self.EMPTY_CHANGESET:
531 rev2 = self.get_changeset(rev2).raw_id
530 rev2 = self.get_changeset(rev2).raw_id
532 cmd = ' '.join(['show'] + flags + [rev2])
531 cmd = ' '.join(['show'] + flags + [rev2])
533 else:
532 else:
534 rev1 = self.get_changeset(rev1).raw_id
533 rev1 = self.get_changeset(rev1).raw_id
535 rev2 = self.get_changeset(rev2).raw_id
534 rev2 = self.get_changeset(rev2).raw_id
536 cmd = ' '.join(['diff'] + flags + [rev1, rev2])
535 cmd = ' '.join(['diff'] + flags + [rev1, rev2])
537
536
538 if path:
537 if path:
539 cmd += ' -- "%s"' % path
538 cmd += ' -- "%s"' % path
540 stdout, stderr = self.run_git_command(cmd)
539 stdout, stderr = self.run_git_command(cmd)
541 # If we used 'show' command, strip first few lines (until actual diff
540 # If we used 'show' command, strip first few lines (until actual diff
542 # starts)
541 # starts)
543 if rev1 == self.EMPTY_CHANGESET:
542 if rev1 == self.EMPTY_CHANGESET:
544 lines = stdout.splitlines()
543 lines = stdout.splitlines()
545 x = 0
544 x = 0
546 for line in lines:
545 for line in lines:
547 if line.startswith('diff'):
546 if line.startswith('diff'):
548 break
547 break
549 x += 1
548 x += 1
550 # Append new line just like 'diff' command do
549 # Append new line just like 'diff' command do
551 stdout = '\n'.join(lines[x:]) + '\n'
550 stdout = '\n'.join(lines[x:]) + '\n'
552 return stdout
551 return stdout
553
552
554 @LazyProperty
553 @LazyProperty
555 def in_memory_changeset(self):
554 def in_memory_changeset(self):
556 """
555 """
557 Returns ``GitInMemoryChangeset`` object for this repository.
556 Returns ``GitInMemoryChangeset`` object for this repository.
558 """
557 """
559 return GitInMemoryChangeset(self)
558 return GitInMemoryChangeset(self)
560
559
561 def clone(self, url, update_after_clone=True, bare=False):
560 def clone(self, url, update_after_clone=True, bare=False):
562 """
561 """
563 Tries to clone changes from external location.
562 Tries to clone changes from external location.
564
563
565 :param update_after_clone: If set to ``False``, git won't checkout
564 :param update_after_clone: If set to ``False``, git won't checkout
566 working directory
565 working directory
567 :param bare: If set to ``True``, repository would be cloned into
566 :param bare: If set to ``True``, repository would be cloned into
568 *bare* git repository (no working directory at all).
567 *bare* git repository (no working directory at all).
569 """
568 """
570 url = self._get_url(url)
569 url = self._get_url(url)
571 cmd = ['clone']
570 cmd = ['clone']
572 if bare:
571 if bare:
573 cmd.append('--bare')
572 cmd.append('--bare')
574 elif not update_after_clone:
573 elif not update_after_clone:
575 cmd.append('--no-checkout')
574 cmd.append('--no-checkout')
576 cmd += ['--', '"%s"' % url, '"%s"' % self.path]
575 cmd += ['--', '"%s"' % url, '"%s"' % self.path]
577 cmd = ' '.join(cmd)
576 cmd = ' '.join(cmd)
578 # If error occurs run_git_command raises RepositoryError already
577 # If error occurs run_git_command raises RepositoryError already
579 self.run_git_command(cmd)
578 self.run_git_command(cmd)
580
579
581 def pull(self, url):
580 def pull(self, url):
582 """
581 """
583 Tries to pull changes from external location.
582 Tries to pull changes from external location.
584 """
583 """
585 url = self._get_url(url)
584 url = self._get_url(url)
586 cmd = ['pull']
585 cmd = ['pull']
587 cmd.append("--ff-only")
586 cmd.append("--ff-only")
588 cmd.append(url)
587 cmd.append(url)
589 cmd = ' '.join(cmd)
588 cmd = ' '.join(cmd)
590 # If error occurs run_git_command raises RepositoryError already
589 # If error occurs run_git_command raises RepositoryError already
591 self.run_git_command(cmd)
590 self.run_git_command(cmd)
592
591
593 def fetch(self, url):
592 def fetch(self, url):
594 """
593 """
595 Tries to pull changes from external location.
594 Tries to pull changes from external location.
596 """
595 """
597 url = self._get_url(url)
596 url = self._get_url(url)
598 cmd = ['fetch']
597 cmd = ['fetch']
599 cmd.append(url)
598 cmd.append(url)
600 cmd = ' '.join(cmd)
599 cmd = ' '.join(cmd)
601 # If error occurs run_git_command raises RepositoryError already
600 # If error occurs run_git_command raises RepositoryError already
602 self.run_git_command(cmd)
601 self.run_git_command(cmd)
603
602
604 @LazyProperty
603 @LazyProperty
605 def workdir(self):
604 def workdir(self):
606 """
605 """
607 Returns ``Workdir`` instance for this repository.
606 Returns ``Workdir`` instance for this repository.
608 """
607 """
609 return GitWorkdir(self)
608 return GitWorkdir(self)
610
609
611 def get_config_value(self, section, name, config_file=None):
610 def get_config_value(self, section, name, config_file=None):
612 """
611 """
613 Returns configuration value for a given [``section``] and ``name``.
612 Returns configuration value for a given [``section``] and ``name``.
614
613
615 :param section: Section we want to retrieve value from
614 :param section: Section we want to retrieve value from
616 :param name: Name of configuration we want to retrieve
615 :param name: Name of configuration we want to retrieve
617 :param config_file: A path to file which should be used to retrieve
616 :param config_file: A path to file which should be used to retrieve
618 configuration from (might also be a list of file paths)
617 configuration from (might also be a list of file paths)
619 """
618 """
620 if config_file is None:
619 if config_file is None:
621 config_file = []
620 config_file = []
622 elif isinstance(config_file, basestring):
621 elif isinstance(config_file, basestring):
623 config_file = [config_file]
622 config_file = [config_file]
624
623
625 def gen_configs():
624 def gen_configs():
626 for path in config_file + self._config_files:
625 for path in config_file + self._config_files:
627 try:
626 try:
628 yield ConfigFile.from_path(path)
627 yield ConfigFile.from_path(path)
629 except (IOError, OSError, ValueError):
628 except (IOError, OSError, ValueError):
630 continue
629 continue
631
630
632 for config in gen_configs():
631 for config in gen_configs():
633 try:
632 try:
634 return config.get(section, name)
633 return config.get(section, name)
635 except KeyError:
634 except KeyError:
636 continue
635 continue
637 return None
636 return None
638
637
639 def get_user_name(self, config_file=None):
638 def get_user_name(self, config_file=None):
640 """
639 """
641 Returns user's name from global configuration file.
640 Returns user's name from global configuration file.
642
641
643 :param config_file: A path to file which should be used to retrieve
642 :param config_file: A path to file which should be used to retrieve
644 configuration from (might also be a list of file paths)
643 configuration from (might also be a list of file paths)
645 """
644 """
646 return self.get_config_value('user', 'name', config_file)
645 return self.get_config_value('user', 'name', config_file)
647
646
648 def get_user_email(self, config_file=None):
647 def get_user_email(self, config_file=None):
649 """
648 """
650 Returns user's email from global configuration file.
649 Returns user's email from global configuration file.
651
650
652 :param config_file: A path to file which should be used to retrieve
651 :param config_file: A path to file which should be used to retrieve
653 configuration from (might also be a list of file paths)
652 configuration from (might also be a list of file paths)
654 """
653 """
655 return self.get_config_value('user', 'email', config_file)
654 return self.get_config_value('user', 'email', config_file)
General Comments 0
You need to be logged in to leave comments. Login now