Coverage for src/hods/config/sources.py: 100.00%
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""hods - home directory synchronization.
3Copyright (C) 2016-2020 Mathias Stelzer <knoppo@rolln.de>
5hods is free software: you can redistribute it and/or modify
6it under the terms of the GNU General Public License as published by
7the Free Software Foundation, either version 3 of the License, or
8(at your option) any later version.
10hods is distributed in the hope that it will be useful,
11but WITHOUT ANY WARRANTY; without even the implied warranty of
12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13GNU General Public License for more details.
15You should have received a copy of the GNU General Public License
16along with this program. If not, see <http://www.gnu.org/licenses/>.
17"""
18import datetime
19import logging
20import os
21import subprocess
23from hods.config.sourcefile import BaseSourceDirectory
24from hods.utils import GitError, Sortable, run_git, run_rsync
26logger = logging.getLogger(__name__)
29SOURCE_MAP = {}
32class UnknownSource(Exception):
33 """Exception raised when requesting a non-existent source class.
35 All valid source names are stored in `SOURCE_MAP`.
36 """
38 pass
41def get_source_class(source_type):
42 """Get source class for the given name.
44 Raises:
45 UnknownSource: if given source_type is not registered.
46 """
47 try:
48 return SOURCE_MAP[source_type]
49 except KeyError:
50 raise UnknownSource('No source class registered for given type: "{}"'.format(source_type))
53class AlreadyRegisteredSource(Exception):
54 """Exception raised when registering a source type that already exists."""
56 pass
59class SourceError(Exception):
60 """Base class for all source errors."""
62 pass
65class Source(Sortable, BaseSourceDirectory):
66 """The root source directory, synchronized with the server using rsync."""
68 is_source_root = True
70 source_type = None
72 dependency = None
74 stored_on_master = True
76 @classmethod
77 def register(cls):
78 """Register this source class."""
79 if cls.source_type in SOURCE_MAP:
80 raise AlreadyRegisteredSource('Cannot register {}. source_type "{}" is already registered.'.format(
81 cls.__name__, cls.source_type))
82 SOURCE_MAP[cls.source_type] = cls
84 @classmethod
85 def is_dependency_installed(cls):
86 """Check if the required cmdline tool is installed."""
87 if cls.dependency is None:
88 return True
90 from hods.utils import which
91 return which(cls.dependency) is not None
93 def __init__(self, feature, name, **kwargs):
94 """Initialize source.
96 :param feature: `hods.app.Feature` - Parent feature instance
97 :param name: `str` - file basename
98 :param kwargs: Additional options, passed to `hods.file.SourceFile`
99 """
100 self.feature = feature
101 self.tree = feature.tree
102 self.pull_only = kwargs.pop('pull_only', False)
103 super().__init__(feature, name, **kwargs)
105 def is_ignored(self):
106 """Check if this instance should be synchronized."""
107 if not self.feature.installed:
108 return True
109 return super().is_ignored()
111 def as_dict(self):
112 """Return the source as a dictionary to store."""
113 data = super().as_dict()
114 if self.source_type:
115 data['type'] = self.source_type
116 data['pull_only'] = self.pull_only
117 return data
119 @property
120 def relative_source_path(self):
121 """Return the basename as the relative path."""
122 return ''
124 @property
125 def relative_feature_path(self):
126 """Return the relative path to the parent feature."""
127 return self.basename
129 @property
130 def relative_config_path(self):
131 """Return the relative path to the parent config tree."""
132 return os.path.join(self.feature.basename, self.basename)
134 @property
135 def default_destination(self):
136 """Return an empty string to use the home directory itself as default destination."""
137 return ''
139 @property
140 def home_relpath(self):
141 """The relative path to the home directory."""
142 return self.relpath(self.tree.settings.home_path)
144 @property
145 def remote_path(self):
146 """Return the host:path combination string."""
147 return self.tree.settings.get_server_path(self.home_relpath)
149 def _rsync(self, src, dst, **kwargs):
150 """Run a git subcommand in this repository."""
151 kwargs.setdefault('cwd', self.feature.path)
152 return run_rsync(src, dst, **kwargs)
154 def pull(self):
155 """Run rsync to download this directory from the server.
157 Returns: The completed process information
159 Raises:
160 subprocess.CalledProcessError: If the command returns a non-zero exitcode.
161 """
162 if not self.feature.exists():
163 self.feature.mkdir()
164 process = self._rsync(self.remote_path + os.sep, self.basename + os.sep)
165 self.scan(clear=True, recursive=True)
166 return process
168 def push(self):
169 """Run rsync to upload this directory to the server.
171 Returns: The completed process information
173 Raises:
174 subprocess.CalledProcessError: If the command returns a non-zero exitcode.
175 """
176 self.tree.settings.rmkdir(self.home_relpath)
177 return self._rsync(self.basename + os.sep, self.remote_path + os.sep)
179 def init(self):
180 """Create the directory."""
181 if not self.feature.exists():
182 self.feature.mkdir()
183 self.mkdir()
186Source.register()
189class NoChanges(SourceError):
190 """Raised when nothing to push (no new commits)."""
192 pass
195class AlreadyClonedError(GitError):
196 """Raised when trying to clone an already existing repository."""
198 pass
201class GitPushError(GitError):
202 """Raised when push of a git repository fails."""
203 pass
206class GitRepository(Source):
207 """A git repository as a source."""
209 source_type = 'git'
211 dependency = 'git'
213 ignore_basenames = [
214 '.git',
215 '.gitignore',
216 ]
218 stored_on_master = False
220 @classmethod
221 def basename_by_url(cls, url):
222 """Parse the given ``url`` and extract the basename without suffix.
224 Args:
225 url: git repository URL
227 Returns: basename for a local repository clone
229 Examples:
230 >>> GitRepository.basename_by_url('git://user@example.com/dotfiles')
231 'dotfiles'
232 >>> GitRepository.basename_by_url('https://example.com/gitproject.git')
233 'gitproject'
234 >>> GitRepository.basename_by_url('user@example.com:project.git')
235 'project'
236 """
237 if ':' in url: # ssh-protocol with relative path ([user@]server:project.git)
238 url = url.split(':')[-1]
239 basename = url.split('/')[-1] # last url part
240 if basename.endswith('.git'): # cut .git
241 basename = basename[:-4]
242 return basename
244 @classmethod
245 def url_for_path(cls, path):
246 """Return the origin remote url for the git repository at the given path.
248 Args:
249 path: Path to the git repository
251 Returns: First stdout line of command: ``git config --get remote.origin.url``
253 Raises:
254 GitError: If git is not installed.
255 subprocess.CalledProcessError: If the command returns a non-zero exitcode.
256 """
257 if os.path.basename(path) == '.git':
258 path = os.path.dirname(path)
260 p = run_git('config', '--get', 'remote.origin.url', cwd=path, hide=True)
261 return p.stdout.splitlines()[0]
263 def __init__(self, feature, url, name=None, **kwargs):
264 """Initialize git repository.
266 :param url: `str` - url to the repository
267 """
268 if name is None:
269 if url is None:
270 raise TypeError('{} requires a name or an url argument.'.format(self.__class__.__name__))
271 name = self.basename_by_url(url)
273 super().__init__(feature, name, **kwargs)
275 # allow passing `Ǹone` only explicitly since it may raise `GitError`
276 if url is None:
277 url = self.url_for_path(self.path)
278 self.url = url
280 def as_dict(self):
281 """Return the source as a dictionary to store."""
282 data = super().as_dict()
283 data['url'] = self.url
284 return data
286 def _git(self, *cmd, **kwargs):
287 """Run a git subcommand in this repository."""
288 kwargs.setdefault('cwd', self.path)
289 return run_git(*cmd, **kwargs)
291 def list_unstaged(self, include_untracked=True):
292 """List files not in index (unstaged)."""
293 p = self._git('--no-pager', 'diff', '--name-only', hide=True)
294 yield from p.stdout.splitlines()
295 if include_untracked:
296 yield from self.list_untracked()
298 def list_untracked(self):
299 """List files unknown to git."""
300 p = self._git('--no-pager', 'ls-files', '--other', '--exclude-standard', hide=True)
301 return p.stdout.splitlines()
303 def list_staged(self):
304 """List files in index (staged)."""
305 p = self._git('--no-pager', 'diff', '--name-only', '--cached', hide=True)
306 return p.stdout.splitlines()
308 def add(self, *paths):
309 """Add file contents to index.
311 If no files are given, use ``--all`` flag to add all changes.
312 """
313 cmd = ('add',)
314 if paths:
315 cmd += paths
316 else:
317 cmd += ('--all',)
318 return self._git(*cmd)
320 def commit(self, message=None, show_editor=True):
321 """Commit current state.
323 :param message: `str` or callable - The commit message or callback to get it.
324 :param show_editor: `bool` - Show the editor with the (default) commit message.
325 :return:
326 """
327 cmd = ['commit']
329 if callable(message):
330 message = message()
332 edit = message is None or show_editor
333 if edit:
334 cmd.append('--edit')
336 timestamp = datetime.datetime.utcnow().isoformat()
338 comment = 'hods commit UTC: ' + timestamp
339 if show_editor:
340 comment = '# ' + comment
342 if message is None:
343 message = comment
344 else:
345 message = comment + '\n\n' + message
347 cmd.extend(['-m', message])
349 return self._git(*cmd, capture_output=not edit)
351 def _get_local(self):
352 try:
353 return self._git('rev-parse', '@', hide=True).stdout
354 except subprocess.CalledProcessError as e:
355 if e.returncode == 128:
356 raise NoChanges('No files in repository!')
358 def _get_remote(self):
359 try:
360 return self._git('rev-parse', '@{u}', hide=True).stdout
361 except subprocess.CalledProcessError as e:
362 if e.returncode != 128:
363 raise e
364 logger.info('origin is empty!')
366 def _get_base(self):
367 try:
368 return self._git('merge-base', '@', '@{u}', hide=True).stdout
369 except subprocess.CalledProcessError as e:
370 if e.returncode != 128:
371 raise e
373 def validate_url(self):
374 """Raise if no URL is set."""
375 if self.url and self.url.strip():
376 return
377 raise GitError('git source has no URL assigned!')
379 def validate_upstream_push(self):
380 """Check local and upstream states and whether a push is possible."""
381 local = self._get_local()
382 if local is None:
383 return False
385 remote = self._get_remote()
386 if remote is None:
387 return False
389 base = self._get_base()
391 if local == remote:
392 raise NoChanges('No changes to push!')
393 if local == base:
394 raise GitPushError('New commits in upstream! Pull first.')
395 if remote != base:
396 raise GitPushError('Local and upstream have diverged! Please fix manually.')
398 return True
400 def is_cloned(self):
401 """Return a `bool` whether this repository already exists."""
402 return os.path.exists(self.join('.git'))
404 def clone(self):
405 """Clone the repository."""
406 if self.is_cloned():
407 raise AlreadyClonedError()
409 self.validate_url()
411 if not self.feature.exists():
412 self.feature.mkdir()
414 process = self._git('clone', self.url, cwd=self.feature.path)
415 self.scan(clear=True)
416 return process
418 def pull(self):
419 """Pull the repository."""
420 if self.tree.settings.server_is_proxy:
421 return super().pull()
423 if not self.is_cloned():
424 return self.clone()
426 self.validate_url()
428 process = self._git('pull')
429 self.scan(clear=True)
430 return process
432 def push(self, commit_callback=None, show_commit_editor=True):
433 """Commit if necessary and push the git repository.
435 :return: ``None``
436 """
437 if self.tree.settings.server_is_proxy:
438 return super().push()
440 if not self.is_cloned():
441 raise GitPushError('Not a git repository!')
443 self.validate_url()
445 unstaged_files = list(self.list_unstaged())
446 staged_files = self.list_staged()
448 if unstaged_files or staged_files:
449 self.add()
450 self.commit(commit_callback, show_commit_editor)
452 cmd = ['push']
454 if not self.validate_upstream_push():
455 cmd.extend(['-u', 'origin', 'master'])
457 return self._git(*cmd)
459 def init(self):
460 """Create the directory."""
461 super().init()
462 if not self.tree.settings.server_is_proxy:
463 self._git('init')
464 self._git('remote', 'add', 'origin', self.url)
467GitRepository.register()