Skip to content

Commit 2b33dcd

Browse files
committed
Fixing git.export() on Windows.
Piping to python (using tarfile built-in package) instead of the "tar" command which is not installed on all Windows hosts. Now that export() is handling untarring there is no need for a temporary directory to extract to and copy from. Combined functionality into 1.5 iterations. Fixes #17
1 parent 4db3c4a commit 2b33dcd

File tree

3 files changed

+61
-52
lines changed

3 files changed

+61
-52
lines changed

README.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ Changelog
4444

4545
This project adheres to `Semantic Versioning <http://semver.org/>`_.
4646

47+
Unreleased
48+
----------
49+
50+
Fixed
51+
* https://github.com/Robpol86/sphinxcontrib-versioning/issues/17
52+
4753
2.1.4 - 2016-09-03
4854
------------------
4955

sphinxcontrib/versioning/git.py

Lines changed: 42 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,11 @@
55
import logging
66
import os
77
import re
8-
import shutil
98
import sys
9+
import tarfile
1010
from datetime import datetime
1111
from subprocess import CalledProcessError, PIPE, Popen, STDOUT
1212

13-
from sphinxcontrib.versioning.lib import TempDir
14-
1513
IS_WINDOWS = sys.platform == 'win32'
1614
RE_ALL_REMOTES = re.compile(r'([\w./-]+)\t([A-Za-z0-9@:/\\._-]+) \((fetch|push)\)\n')
1715
RE_REMOTE = re.compile(r'^(?P<sha>[0-9a-f]{5,40})\trefs/(?P<kind>heads|tags)/(?P<name>[\w./-]+(?:\^\{})?)$',
@@ -113,15 +111,15 @@ def chunk(iterator, max_size):
113111
yield chunked
114112

115113

116-
def run_command(local_root, command, env_var=True, piped=None):
117-
"""Run a command and return the output. Run another command and pipe its output to the primary command.
114+
def run_command(local_root, command, env_var=True, pipeto=None):
115+
"""Run a command and return the output.
118116
119117
:raise CalledProcessError: Command exits non-zero.
120118
121119
:param str local_root: Local path to git root directory.
122120
:param iter command: Command to run.
123121
:param bool env_var: Define GIT_DIR environment variable (on non-Windows).
124-
:param iter piped: Second command to pipe its stdout to `command`'s stdin.
122+
:param function pipeto: Pipe `command`'s stdout to this function (only parameter given).
125123
126124
:return: Command output.
127125
:rtype: str
@@ -135,26 +133,17 @@ def run_command(local_root, command, env_var=True, piped=None):
135133
else:
136134
env.pop('GIT_DIR', None)
137135

138-
# Start commands.
136+
# Run command.
139137
with open(os.devnull) as null:
140-
parent = Popen(piped, cwd=local_root, env=env, stdout=PIPE, stderr=PIPE, stdin=null) if piped else None
141-
stdin = parent.stdout if piped else null
142-
main = Popen(command, cwd=local_root, env=env, stdout=PIPE, stderr=STDOUT, stdin=stdin)
143-
144-
# Wait for commands and log.
145-
common_dict = dict(cwd=local_root, stdin=None)
146-
if piped:
147-
main.wait() # Let main command read parent.stdout before parent.communicate() does.
148-
parent_output = parent.communicate()[1].decode('utf-8')
149-
log.debug(json.dumps(dict(common_dict, command=piped, code=parent.poll(), output=parent_output)))
150-
else:
151-
parent_output = ''
152-
main_output = main.communicate()[0].decode('utf-8')
153-
log.debug(json.dumps(dict(common_dict, command=command, code=main.poll(), output=main_output, stdin=piped)))
138+
main = Popen(command, cwd=local_root, env=env, stdout=PIPE, stderr=PIPE if pipeto else STDOUT, stdin=null)
139+
if pipeto:
140+
pipeto(main.stdout)
141+
main_output = main.communicate()[1].decode('utf-8') # Might deadlock if stderr is written to a lot.
142+
else:
143+
main_output = main.communicate()[0].decode('utf-8')
144+
log.debug(json.dumps(dict(cwd=local_root, command=command, code=main.poll(), output=main_output)))
154145

155146
# Verify success.
156-
if piped and parent.poll() != 0:
157-
raise CalledProcessError(parent.poll(), piped, output=parent_output)
158147
if main.poll() != 0:
159148
raise CalledProcessError(main.poll(), command, output=main_output)
160149

@@ -283,24 +272,36 @@ def export(local_root, commit, target):
283272
:param str target: Directory to export to.
284273
"""
285274
log = logging.getLogger(__name__)
286-
git_command = ['git', 'archive', '--format=tar', commit]
287-
288-
with TempDir() as temp_dir:
289-
# Run commands.
290-
run_command(local_root, ['tar', '-x', '-C', temp_dir], piped=git_command)
291-
292-
# Copy to target. Overwrite existing but don't delete anything in target.
293-
for s_dirpath, s_filenames in (i[::2] for i in os.walk(temp_dir) if i[2]):
294-
t_dirpath = os.path.join(target, os.path.relpath(s_dirpath, temp_dir))
295-
if not os.path.exists(t_dirpath):
296-
os.makedirs(t_dirpath)
297-
for args in ((os.path.join(s_dirpath, f), os.path.join(t_dirpath, f)) for f in s_filenames):
298-
try:
299-
shutil.copy(*args)
300-
except IOError:
301-
if not os.path.islink(args[0]):
302-
raise
303-
log.debug('Skipping broken symlink: %s', args[0])
275+
target = os.path.realpath(target)
276+
277+
# Define extract function.
278+
def extract(stdout):
279+
"""Extract tar archive from "git archive" stdout.
280+
281+
:param file stdout: Handle to git's stdout pipe.
282+
"""
283+
queued_links = list()
284+
try:
285+
with tarfile.open(fileobj=stdout, mode='r|') as tar:
286+
for info in tar:
287+
log.debug('name: %s; mode: %d; size: %s; type: %s', info.name, info.mode, info.size, info.type)
288+
path = os.path.realpath(os.path.join(target, info.name))
289+
if not path.startswith(target): # Handle bad paths.
290+
log.warning('Ignoring tar object path %s outside of target directory.', info.name)
291+
elif info.isdir(): # Handle directories.
292+
if not os.path.exists(path):
293+
os.makedirs(path, mode=info.mode)
294+
elif info.issym() or info.islnk(): # Queue links.
295+
queued_links.append(info)
296+
else: # Handle files.
297+
tar.extract(member=info, path=target)
298+
for info in (i for i in queued_links if os.path.exists(os.path.join(target, i.linkname))):
299+
tar.extract(member=info, path=target)
300+
except tarfile.TarError as exc:
301+
log.debug('Failed to extract output from "git archive" command: %s', str(exc))
302+
303+
# Run command.
304+
run_command(local_root, ['git', 'archive', '--format=tar', commit], pipeto=extract)
304305

305306

306307
def clone(local_root, new_root, remote, branch, rel_dest, exclude):

tests/test_git/test_export.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Test function in module."""
22

3+
from os.path import join
34
from subprocess import CalledProcessError
45

56
import pytest
67

7-
from sphinxcontrib.versioning.git import export, fetch_commits, list_remote
8+
from sphinxcontrib.versioning.git import export, fetch_commits, IS_WINDOWS, list_remote
89

910

1011
def test_simple(tmpdir, local, run):
@@ -50,16 +51,16 @@ def test_overwrite(tmpdir, local, run):
5051
expected = [
5152
'README',
5253
'docs',
53-
'docs/_templates',
54-
'docs/_templates/layout.html',
55-
'docs/_templates/other',
56-
'docs/_templates/other.html',
57-
'docs/_templates/other/other.html',
58-
'docs/conf.py',
59-
'docs/index.rst',
60-
'docs/other',
61-
'docs/other.rst',
62-
'docs/other/other.py',
54+
join('docs', '_templates'),
55+
join('docs', '_templates', 'layout.html'),
56+
join('docs', '_templates', 'other'),
57+
join('docs', '_templates', 'other.html'),
58+
join('docs', '_templates', 'other', 'other.html'),
59+
join('docs', 'conf.py'),
60+
join('docs', 'index.rst'),
61+
join('docs', 'other'),
62+
join('docs', 'other.rst'),
63+
join('docs', 'other', 'other.py'),
6364
]
6465
paths = sorted(f.relto(target) for f in target.visit())
6566
assert paths == expected
@@ -94,6 +95,7 @@ def test_new_branch_tags(tmpdir, local_light, fail):
9495
assert target.join('README').read() == 'new'
9596

9697

98+
@pytest.mark.skipif(str(IS_WINDOWS))
9799
def test_symlink(tmpdir, local, run):
98100
"""Test repos with broken symlinks.
99101

0 commit comments

Comments
 (0)