From 712d48de25a51dfa711115f6e2f2a98e902737af Mon Sep 17 00:00:00 2001 From: Ryan Lerch Date: Mar 17 2017 01:15:49 +0000 Subject: make the dates use git rather than set them in the content --- diff --git a/content/networking/firewall-open-port.md b/content/networking/firewall-open-port.md index ba3fa59..d2809ef 100644 --- a/content/networking/firewall-open-port.md +++ b/content/networking/firewall-open-port.md @@ -1,4 +1,3 @@ Title: How to open a specific port -Date: 2010-12-03 10:20 Following is a review of my favorite mechanical keyboard. diff --git a/content/networking/index.md b/content/networking/index.md index e28ea4b..3325801 100644 --- a/content/networking/index.md +++ b/content/networking/index.md @@ -1,4 +1,3 @@ Title: Networking -Date: 2010-12-03 10:20 Configuring networking on Fedora diff --git a/content/system_administration/configuring_sudo.md b/content/system_administration/configuring_sudo.md index 401e24e..12aea80 100644 --- a/content/system_administration/configuring_sudo.md +++ b/content/system_administration/configuring_sudo.md @@ -1,6 +1,4 @@ Title: How to configure sudo -Date: 2010-12-03 10:20 - The sudo command makes it easier to manage your Fedora system. Certain commands in Fedora expect to be run only by a privileged user or administrator. The sudo command lets you run a command as if you're the administrator, known as root. diff --git a/content/system_administration/index.md b/content/system_administration/index.md index bad80c8..0039fb9 100644 --- a/content/system_administration/index.md +++ b/content/system_administration/index.md @@ -1,4 +1,3 @@ Title: System Administration -Date: 2010-12-03 10:20 Administering and configuring your Fedora system diff --git a/pelicanconf.py b/pelicanconf.py index 6c501fa..dfd875e 100644 --- a/pelicanconf.py +++ b/pelicanconf.py @@ -33,7 +33,7 @@ SOCIAL = (('You can add links in your config file', '#'), DEFAULT_PAGINATION = False PLUGIN_PATHS = ['plugins'] -PLUGINS = ['category_meta'] +PLUGINS = ['category_meta', 'filetime_from_git'] STATIC_PATHS = ['system_administration/images'] diff --git a/plugins/filetime_from_git/README.rst b/plugins/filetime_from_git/README.rst new file mode 100644 index 0000000..9290cb2 --- /dev/null +++ b/plugins/filetime_from_git/README.rst @@ -0,0 +1,60 @@ +Use Git commit to determine page date +====================================== +If the blog content is managed by git repo, this plugin will set articles' +and pages' ``metadata['date']`` according to git commit. This plugin depends +on python package ``gitpython``, install:: + + pip install gitpython + +The date is determined via the following logic: + +* if a file is not tracked by Git, or a file is staged but never committed + - metadata['date'] = filesystem time + - metadata['modified'] = filesystem time +* if a file is tracked, but no changes in staging area or working directory + - metadata['date'] = first commit time + - metadata['modified'] = last commit time +* if a file is tracked, and has changes in stage area or working directory + - metadata['date'] = first commit time + - metadata['modified'] = filesystem time + +When this module is enabled, ``date`` and ``modified`` will be determined +by Git status; no need to manually set in article/page metadata. And +operations like copy and move will not affect the generated results. + +If you don't want a given article or page to use the Git time, set the +metadata to ``gittime: off`` to disable it. + +Other options +------------- + +### GIT_HISTORY_FOLLOWS_RENAME (default True) +You can also set GIT_HISTORY_FOLLOWS_RENAME to True in your pelican config to +make the plugin follow file renames i.e. ensure the creation date matches +the original file creation date, not the date is was renamed. + +### GIT_GENERATE_PERMALINK (default False) +Use in combination with permalink plugin to generate permalinks using the original +commit sha + +### GIT_SHA_METADATA (default True) +Adds sha of current and oldest commit to metadata + +### GIT_FILETIME_FROM_GIT (default True) +Enable filetime from git behaviour + +Content specific options +------------------------ +Adding metadata `gittime` = False will prevent the plugin trying to setting filetime for this +content. + +Adding metadata `git_permalink` = False will prevent the plugin from adding permalink for this +content. + +FAQ +--- + +### Q. I get a GitCommandError: 'git rev-list ...' when I run the plugin. What's up? +Be sure to use the correct gitpython module for your distros git binary. +Using the GIT_HISTORY_FOLLOWS_RENAME option to True may also make your problem go away as it uses +a different method to find commits. diff --git a/plugins/filetime_from_git/__init__.py b/plugins/filetime_from_git/__init__.py new file mode 100644 index 0000000..b7281c8 --- /dev/null +++ b/plugins/filetime_from_git/__init__.py @@ -0,0 +1 @@ +from .registration import * diff --git a/plugins/filetime_from_git/actions.py b/plugins/filetime_from_git/actions.py new file mode 100755 index 0000000..c9ac0a8 --- /dev/null +++ b/plugins/filetime_from_git/actions.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +import base64 +import hashlib +import os +import logging +from pelican.utils import strftime +from .utils import string_to_bool +from .utils import datetime_from_timestamp +from .registration import content_git_object_init + + +logger = logging.getLogger(__name__) + + +@content_git_object_init.connect +def filetime_from_git(content, git_content): + ''' + Update modification and creation times from git + ''' + if not content.settings['GIT_FILETIME_FROM_GIT']: + # Disabled for everything + return + + if not string_to_bool(content.metadata.get('gittime', 'yes')): + # Disable for this content + return + + path = content.source_path + fs_creation_time = datetime_from_timestamp(os.stat(path).st_ctime, content) + fs_modified_time = datetime_from_timestamp(os.stat(path).st_mtime, content) + + # 1. file is not managed by git + # date: fs time + # 2. file is staged, but has no commits + # date: fs time + # 3. file is managed, and clean + # date: first commit time, update: last commit time or None + # 4. file is managed, but dirty + # date: first commit time, update: fs time + if git_content.is_managed_by_git(): + if git_content.is_committed(): + content.date = git_content.get_oldest_commit_date() + + if git_content.is_modified(): + content.modified = fs_modified_time + else: + content.modified = git_content.get_newest_commit_date() + else: + # File isn't committed + content.date = fs_creation_time + else: + # file is not managed by git + content.date = fs_creation_time + + # Clean up content attributes + if not hasattr(content, 'modified'): + content.modified = content.date + + if hasattr(content, 'date'): + content.locale_date = strftime(content.date, content.date_format) + + if hasattr(content, 'modified'): + content.locale_modified = strftime( + content.modified, content.date_format) + + +@content_git_object_init.connect +def git_sha_metadata(content, git_content): + ''' + Add sha metadata to content + ''' + if not content.settings['GIT_SHA_METADATA']: + return + + if not git_content.is_committed(): + return + + content.metadata['gitsha_newest'] = str(git_content.get_newest_commit()) + content.metadata['gitsha_oldest'] = str(git_content.get_oldest_commit()) + + +@content_git_object_init.connect +def git_permalink(content, git_content): + ''' + Add git based permalink id to content metadata + ''' + if not content.settings['GIT_GENERATE_PERMALINK']: + return + + if not string_to_bool(content.metadata.get('git_permalink', 'yes')): + # Disable for this content + return + + if not git_content.is_committed(): + return + + permalink_hash = hashlib.sha1() + permalink_hash.update(str(git_content.get_oldest_commit())) + permalink_hash.update(str(git_content.get_oldest_filename())) + git_permalink_id = base64.urlsafe_b64encode(permalink_hash.digest()) + permalink_id_metadata_key = content.settings['PERMALINK_ID_METADATA_KEY'] + + if permalink_id_metadata_key in content.metadata: + content.metadata[permalink_id_metadata_key] = ( + ','.join(( + content.metadata[permalink_id_metadata_key], git_permalink_id))) + else: + content.metadata[permalink_id_metadata_key] = git_permalink_id diff --git a/plugins/filetime_from_git/content_adapter.py b/plugins/filetime_from_git/content_adapter.py new file mode 100644 index 0000000..1584d17 --- /dev/null +++ b/plugins/filetime_from_git/content_adapter.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +""" +Wraps a content object to provide some git information +""" +import logging +from pelican.utils import memoized +from .git_wrapper import git_wrapper + +DEV_LOGGER = logging.getLogger(__name__) + + +class GitContentAdapter(object): + """ + Wraps a content object to provide some git information + """ + def __init__(self, content): + self.content = content + self.git = git_wrapper('.') + self.tz_name = content.settings.get('TIMEZONE', None) + self.follow = content.settings['GIT_HISTORY_FOLLOWS_RENAME'] + + @memoized + def is_committed(self): + ''' + Is committed + ''' + return len(self.get_commits()) > 0 + + @memoized + def is_modified(self): + ''' + Has content been modified since last commit + ''' + return self.git.is_file_modified(self.content.source_path) + + @memoized + def is_managed_by_git(self): + ''' + Is content stored in a file managed by git + ''' + return self.git.is_file_managed_by_git(self.content.source_path) + + @memoized + def get_commits(self): + ''' + Get all commits involving this filename + :returns: List of commits newest to oldest + ''' + if not self.is_managed_by_git(): + return [] + return self.git.get_commits(self.content.source_path, self.follow) + + @memoized + def get_oldest_commit(self): + ''' + Get oldest commit involving this file + + :returns: Oldest commit + ''' + return self.git.get_commits(self.content.source_path, self.follow)[-1] + + @memoized + def get_newest_commit(self): + ''' + Get oldest commit involving this file + + :returns: Newest commit + ''' + return self.git.get_commits(self.content.source_path, follow=False)[0] + + @memoized + def get_oldest_filename(self): + ''' + Get the original filename of this content. Implies follow + ''' + commit_and_name_iter = self.git.get_commits_and_names_iter( + self.content.source_path) + _commit, name = commit_and_name_iter.next() + return name + + @memoized + def get_oldest_commit_date(self): + ''' + Get datetime of oldest commit involving this file + + :returns: Datetime of oldest commit + ''' + oldest_commit = self.get_oldest_commit() + return self.git.get_commit_date(oldest_commit, self.tz_name) + + @memoized + def get_newest_commit_date(self): + ''' + Get datetime of newest commit involving this file + + :returns: Datetime of newest commit + ''' + newest_commit = self.get_newest_commit() + return self.git.get_commit_date(newest_commit, self.tz_name) diff --git a/plugins/filetime_from_git/git_wrapper.py b/plugins/filetime_from_git/git_wrapper.py new file mode 100644 index 0000000..238e65e --- /dev/null +++ b/plugins/filetime_from_git/git_wrapper.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +""" +Wrap python git interface for compatibility with older/newer version +""" +try: + from itertools import zip_longest +except ImportError: + from six.moves import zip_longest +import logging +import os +from time import mktime +from datetime import datetime +from pelican.utils import set_date_tzinfo +from git import Git, Repo + +DEV_LOGGER = logging.getLogger(__name__) + + +def grouper(iterable, n, fillvalue=None): + ''' + Collect data into fixed-length chunks or blocks + ''' + # grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx + args = [iter(iterable)] * n + return zip_longest(fillvalue=fillvalue, *args) + + +class _GitWrapperCommon(object): + ''' + Wrap git module to provide a more stable interface across versions + ''' + def __init__(self, repo_path): + self.git = Git() + self.repo = Repo(os.path.abspath('.')) + + def is_file_managed_by_git(self, path): + ''' + :param path: Path to check + :returns: True if path is managed by git + ''' + status, _stdout, _stderr = self.git.execute( + ['git', 'ls-files', path, '--error-unmatch'], + with_extended_output=True, + with_exceptions=False) + return status == 0 + + def is_file_modified(self, path): + ''' + Does a file have local changes not yet committed + + :returns: True if file has local changes + ''' + status, _stdout, _stderr = self.git.execute( + ['git', 'diff', '--quiet', 'HEAD', path], + with_extended_output=True, + with_exceptions=False) + return status != 0 + + def get_commits_following(self, path): + ''' + Get all commits including path following the file through + renames + + :param path: Path which we will find commits for + :returns: Sequence of commit objects. Newest to oldest + ''' + return [ + commit for commit, _ in self.get_commits_and_names_iter( + path)] + + def get_commits_and_names_iter(self, path): + ''' + Get all commits including a given path following renames + ''' + log_result = self.git.log( + '--pretty=%H', + '--follow', + '--name-only', + '--', + path).splitlines() + + for commit_sha, _, filename in grouper(log_result, 3): + yield self.repo.commit(commit_sha), filename + + def get_commits(self, path, follow=False): + ''' + Get all commits including path + + :param path: Path which we will find commits for + :param bool follow: If True we will follow path through renames + + :returns: Sequence of commit objects. Newest to oldest + ''' + if follow: + return self.get_commits_following(path) + else: + return self._get_commits(path) + + +class _GitWrapperLegacy(_GitWrapperCommon): + def _get_commits(self, path): + ''' + Get all commits including path without following renames + + :param path: Path which we will find commits for + + :returns: Sequence of commit objects. Newest to oldest + ''' + return self.repo.commits(path=path) + + @staticmethod + def get_commit_date(commit, tz_name): + ''' + Get datetime of commit comitted_date + ''' + return set_date_tzinfo( + datetime.fromtimestamp(mktime(commit.committed_date)), + tz_name=tz_name) + + +class _GitWrapper(_GitWrapperCommon): + def _get_commits(self, path): + ''' + Get all commits including path without following renames + + :param path: Path which we will find commits for + + :returns: Sequence of commit objects. Newest to oldest + + .. NOTE :: + If this fails it could be that your gitpython version is out of sync with the git + binary on your distro. Make sure you use the correct gitpython version. + + Alternatively enabling GIT_FILETIME_FOLLOW may also make your problem go away. + ''' + return list(self.repo.iter_commits(paths=path)) + + @staticmethod + def get_commit_date(commit, tz_name): + ''' + Get datetime of commit comitted_date + ''' + return set_date_tzinfo( + datetime.fromtimestamp(commit.committed_date), + tz_name=tz_name) + + +_wrapper_cache = {} + + +def git_wrapper(path): + ''' + Get appropriate wrapper factory and cache instance for path + ''' + path = os.path.abspath(path) + if path not in _wrapper_cache: + if hasattr(Repo, 'commits'): + _wrapper_cache[path] = _GitWrapperLegacy(path) + else: + _wrapper_cache[path] = _GitWrapper(path) + + return _wrapper_cache[path] diff --git a/plugins/filetime_from_git/registration.py b/plugins/filetime_from_git/registration.py new file mode 100644 index 0000000..e91d254 --- /dev/null +++ b/plugins/filetime_from_git/registration.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +""" +Handle registration and setup for plugin +""" +import logging +from blinker import signal +from .content_adapter import GitContentAdapter +from pelican import signals + +DEV_LOGGER = logging.getLogger(__name__) + +content_git_object_init = signal('content_git_object_init') + +def send_content_git_object_init(content): + content_git_object_init.send(content, git_content=GitContentAdapter(content)) + + +def setup_option_defaults(pelican_inst): + pelican_inst.settings.setdefault('GIT_FILETIME_FROM_GIT', True) + pelican_inst.settings.setdefault('GIT_HISTORY_FOLLOWS_RENAME', True) + pelican_inst.settings.setdefault('GIT_SHA_METADATA', True) + pelican_inst.settings.setdefault('GIT_GENERATE_PERMALINK', False) + + +def register(): + signals.content_object_init.connect(send_content_git_object_init) + signals.initialized.connect(setup_option_defaults) + + # Import actions + from . import actions diff --git a/plugins/filetime_from_git/utils.py b/plugins/filetime_from_git/utils.py new file mode 100644 index 0000000..d5bd52f --- /dev/null +++ b/plugins/filetime_from_git/utils.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +""" +Utility functions +""" +from datetime import datetime +import logging +from pelican.utils import set_date_tzinfo + +DEV_LOGGER = logging.getLogger(__name__) + + +STRING_BOOLS = { + 'yes': True, + 'no': False, + 'true': True, + 'false': False, + '0': False, + '1': True, + 'on': True, + 'off': False, +} + + +def string_to_bool(string): + ''' + Convert a string to a bool based + ''' + return STRING_BOOLS[string.strip().lower()] + + +def datetime_from_timestamp(timestamp, content): + """ + Helper function to add timezone information to datetime, + so that datetime is comparable to other datetime objects in recent versions + that now also have timezone information. + """ + return set_date_tzinfo( + datetime.fromtimestamp(timestamp), + tz_name=content.settings.get('TIMEZONE', None))