diff --git a/README.rst b/README.rst index 3b358c5..aa71f7b 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ ========================= -Sphinx to GitHub Pages V2 +Sphinx to GitHub Pages V3 ========================= .. image:: https://img.shields.io/github/stars/sphinx-notes/pages.svg?style=social&label=Star&maxAge=2592000 @@ -10,64 +10,58 @@ Help you deploying your Sphinx documentation to Github Pages. Usage ===== -This action only help you build and commit Sphinx documentation to ``gh-pages``, -branch. So we need some other actions: - -- ``action/setup-python`` for installing python and pip -- ``actions/checkout`` for checking out git repository -- ``ad-m/github-push-action`` for pushing site to remote - -So your workflow file should be: - -.. code-block:: yaml - - name: Pages - on: - push: - branches: - - master - jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-python@v2 - - uses: actions/checkout@master - with: - fetch-depth: 0 # otherwise, you will failed to push refs to dest repo - - name: Build and Commit - uses: sphinx-notes/pages@v2 - - name: Push changes - uses: ad-m/github-push-action@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: gh-pages +1. `Set the publishing sources to "Github Actions"`__ + + .. note:: Publishing your GitHub Pages site with GitHub Actions workflow is **in beta and subject to change**. + + __ https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site#publishing-with-a-custom-github-actions-workflow + +2. Create workflow: + + .. code-block:: yaml + + name: Deploy Sphinx documentation to Pages + + # Runs on pushes targeting the default branch + on: + push: + branches: [master] + + jobs: + pages: + runs-on: ubuntu-20.04 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + steps: + - id: deployment + uses: sphinx-notes/pages@v3 Inputs ====== -======================= ============== ============ ============================= -Input Default Required Description ------------------------ -------------- ------------ ----------------------------- -``documentation_path`` ``'./docs'`` ``false`` Relative path under - repository to documentation - source files -``target_branch`` ``'gh-pages'`` ``false`` Git branch where assets will - be deployed -``target_path`` ``'.'`` ``false`` Directory in Github Pages - where Sphinx Pages will be - placed -``repository_path`` ``'.'`` ``false`` Relative path under - ``$GITHUB_WORKSPACE`` to - place the repository. - You not need to set this - Input unless you checkout - the repository to a custom - path -``requirements_path`` ``''`` ``false`` Relative path under - ``$repository_path`` to pip - requirements file -``sphinx_version`` ``''`` ``false`` Custom version of Sphinx -======================= ============== ============ ============================= +======================= ================================ ======== ============================= +Input Default Required Description +----------------------- -------------------------------- -------- ----------------------------- +``documentation_path`` ``./docs`` false Path to Sphinx source files +``requirements_path`` ``./docs/requirements.txt`` false Path to to requirements file +``python_version`` ``3.10`` false Version of Python +``sphinx_version`` ``5.3`` false Version of Sphinx +``cache`` ``false`` false Enable cache to speed up + documentation building +======================= ================================ ======== ============================= + +Outputs +======= + +======================= ====================================================================== +Output Description +----------------------- ---------------------------------------------------------------------- +``page_url`` URL to deployed GitHub Pages +======================= ====================================================================== Examples ======== diff --git a/action.yml b/action.yml index 3300c99..63b0bc6 100644 --- a/action.yml +++ b/action.yml @@ -7,30 +7,82 @@ branding: icon: 'upload-cloud' inputs: documentation_path: - description: 'Relative path under $repository_path to documentation source files' + description: 'Path to Sphinx source files' required: false default: './docs' - target_branch: - description: 'Git branch where Github Pages will be deployed' - required: false - default: 'gh-pages' - target_path: - description: 'Path in Github Pages where Sphinx Pages will be placed' - required: false - default: '.' - repository_path: - description: 'Relative path under $GITHUB_WORKSPACE to place the repository' - required: false - default: '.' requirements_path: - description: 'Relative path under $repository_path to pip requirements file' + description: 'Path to requirements file' required: false - default: './requirements.txt' + default: './docs/requirements.txt' + python_version: + description: 'Version of Python' + required: false + default: '3.10' sphinx_version: description: 'Version of Sphinx' required: false default: '' + cache: + description: 'Enable cache to speed up documentation building' + required: false + default: false +outputs: + page_url: + description: 'URL to deployed GitHub Pages' + value: ${{ steps.deployment.outputs.page_url }} runs: - using: 'node12' - main: 'main.js' + using: "composite" + steps: + - name: Checkout + uses: actions/checkout@v3 + if: ${{ inputs.cache == 'true' }} + with: + fetch-depth: 0 # Required by git-restore-mtime + - name: Checkout + uses: actions/checkout@v3 + if: ${{ inputs.cache == 'false' }} + + - name: Setup python + uses: actions/setup-python@v4 + if: ${{ inputs.cache == 'true' }} + with: + python-version: ${{ inputs.python_version }} + cache: 'pip' + - name: Setup python + uses: actions/setup-python@v4 + if: ${{ inputs.cache == 'false' }} + with: + python-version: ${{ inputs.python_version }} + + - name: Restore cache + uses: actions/cache@v3 + if: ${{ inputs.cache == 'true' }} + with: + path: /tmp/sphinxnotes-pages + # https://github.com/actions/cache/blob/main/tips-and-workarounds.md#update-a-cache + key: sphinxnotes-pages-${{ runner.os }}-${{ github.run_id }} + restore-keys: | + sphinxnotes-pages-${{ runner.os }} + + - name: Build documentation + run: ${{ github.action_path }}/main.sh + shell: bash + env: + # See https://github.com/actions/runner/issues/665 + INPUT_DOCUMENTATION_PATH: ${{ inputs.documentation_path }} + INPUT_REQUIREMENTS_PATH: ${{ inputs.requirements_path }} + INPUT_SPHINX_VERSION: ${{ inputs.sphinx_version }} + INPUT_CACHE: ${{ inputs.cache }} + + - name: Setup Pages + uses: actions/configure-pages@v2 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v1 + with: + path: /tmp/sphinxnotes-pages + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v1 diff --git a/git-restore-mtime b/git-restore-mtime new file mode 100755 index 0000000..1b72ad0 --- /dev/null +++ b/git-restore-mtime @@ -0,0 +1,598 @@ +#!/usr/bin/env python3 +# +# git-restore-mtime - Change mtime of files based on commit date of last change +# +# Copyright (C) 2012 Rodrigo Silva (MestreLion) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. See +# +""" +Change the modification time (mtime) of files in work tree, based on the +date of the most recent commit that modified the file, including renames. + +Ignores untracked files and uncommitted deletions, additions and renames, and +by default modifications too. +--- +Useful prior to generating release tarballs, so each file is archived with a +date that is similar to the date when the file was actually last modified, +assuming the actual modification date and its commit date are close. +""" + +# TODO: +# - Add -z on git whatchanged/ls-files, so we don't deal with filename decoding +# - When Python is bumped to 3.7, use text instead of universal_newlines on subprocess +# - Update "Statistics for some large projects" with modern hardware and repositories. +# - Create a README.md for git-restore-mtime alone. It deserves extensive documentation +# - Move Statistics there +# - See git-extras as a good example on project structure and documentation + +# FIXME: +# - When current dir is outside the worktree, e.g. using --work-tree, `git ls-files` +# assume any relative pathspecs are to worktree root, not the current dir. As such, +# relative pathspecs may not work. +# - Renames are tricky: +# - R100 should not change mtime, but original name is not on filelist. Should +# track renames until a valid (A, M) mtime found and then set on current name. +# - Should set mtime for both current and original directories. +# - Check mode changes with unchanged blobs? +# - Check file (A, D) for the directory mtime is not sufficient: +# - Renames also change dir mtime, unless rename was on a parent dir +# - If most recent change of all files in a dir was a Modification (M), +# dir might not be touched at all. +# - Dirs containing only subdirectories but no direct files will also +# not be touched. They're files' [grand]parent dir, but never their dirname(). +# - Some solutions: +# - After files done, perform some dir processing for missing dirs, finding latest +# file (A, D, R) +# - Simple approach: dir mtime is the most recent child (dir or file) mtime +# - Use a virtual concept of "created at most at" to fill missing info, bubble up +# to parents and grandparents +# - When handling [grand]parent dirs, stay inside +# - Better handling of merge commits. `-m` is plain *wrong*. `-c/--cc` is perfect, but +# painfully slow. First pass without merge commits is not accurate. Maybe add a new +# `--accurate` mode for `--cc`? + +if __name__ != "__main__": + raise ImportError("{} should not be used as a module.".format(__name__)) + +import argparse +import datetime +import logging +import os.path +import shlex +import signal +import subprocess +import sys +import time + +__version__ = "2022.07+dev" + +# Update symlinks only if the platform supports not following them +UPDATE_SYMLINKS = bool(os.utime in getattr(os, 'supports_follow_symlinks', [])) + +# Call os.path.normpath() only if not in a POSIX platform (Windows) +NORMALIZE_PATHS = (os.path.sep != '/') + +# How many files to process in each batch when re-trying merge commits +STEPMISSING = 100 + +# (Extra) keywords for the os.utime() call performed by touch() +UTIME_KWS = {} if not UPDATE_SYMLINKS else {'follow_symlinks': False} + + +# Command-line interface ###################################################### + +def parse_args(): + parser = argparse.ArgumentParser( + description=__doc__.split('\n---')[0]) + + group = parser.add_mutually_exclusive_group() + group.add_argument('--quiet', '-q', dest='loglevel', + action="store_const", const=logging.WARNING, default=logging.INFO, + help="Suppress informative messages and summary statistics.") + group.add_argument('--verbose', '-v', action="count", help=""" + Print additional information for each processed file. + Specify twice to further increase verbosity. + """) + + parser.add_argument('--cwd', '-C', metavar="DIRECTORY", help=""" + Run as if %(prog)s was started in directory %(metavar)s. + This affects how --work-tree, --git-dir and PATHSPEC arguments are handled. + See 'man 1 git' or 'git --help' for more information. + """) + + parser.add_argument('--git-dir', dest='gitdir', metavar="GITDIR", help=""" + Path to the git repository, by default auto-discovered by searching + the current directory and its parents for a .git/ subdirectory. + """) + + parser.add_argument('--work-tree', dest='workdir', metavar="WORKTREE", help=""" + Path to the work tree root, by default the parent of GITDIR if it's + automatically discovered, or the current directory if GITDIR is set. + """) + + parser.add_argument('--force', '-f', default=False, action="store_true", help=""" + Force updating files with uncommitted modifications. + Untracked files and uncommitted deletions, renames and additions are + always ignored. + """) + + parser.add_argument('--merge', '-m', default=False, action="store_true", help=""" + Include merge commits. + Leads to more recent times and more files per commit, thus with the same + time, which may or may not be what you want. + Including merge commits may lead to fewer commits being evaluated as files + are found sooner, which can improve performance, sometimes substantially. + But as merge commits are usually huge, processing them may also take longer. + By default, merge commits are only used for files missing from regular commits. + """) + + parser.add_argument('--first-parent', default=False, action="store_true", help=""" + Consider only the first parent, the "main branch", when evaluating merge commits. + Only effective when merge commits are processed, either when --merge is + used or when finding missing files after the first regular log search. + See --skip-missing. + """) + + parser.add_argument('--skip-missing', '-s', dest="missing", default=True, + action="store_false", help=""" + Do not try to find missing files. + If merge commits were not evaluated with --merge and some files were + not found in regular commits, by default %(prog)s searches for these + files again in the merge commits. + This option disables this retry, so files found only in merge commits + will not have their timestamp updated. + """) + + parser.add_argument('--no-directories', '-D', dest='dirs', default=True, + action="store_false", help=""" + Do not update directory timestamps. + By default, use the time of its most recently created, renamed or deleted file. + Note that just modifying a file will NOT update its directory time. + """) + + parser.add_argument('--test', '-t', default=False, action="store_true", + help="Test run: do not actually update any file timestamp.") + + parser.add_argument('--commit-time', '-c', dest='commit_time', default=False, + action='store_true', help="Use commit time instead of author time.") + + parser.add_argument('--oldest-time', '-o', dest='reverse_order', default=False, + action='store_true', help=""" + Update times based on the oldest, instead of the most recent commit of a file. + This reverses the order in which the git log is processed to emulate a + file "creation" date. Note this will be inaccurate for files deleted and + re-created at later dates. + """) + + parser.add_argument('--skip-older-than', metavar='SECONDS', type=int, help=""" + Ignore files that are currently older than %(metavar)s. + Useful in workflows that assume such files already have a correct timestamp, + as it may improve performance by processing fewer files. + """) + + parser.add_argument('--skip-older-than-commit', '-N', default=False, + action='store_true', help=""" + Ignore files older than the timestamp it would be updated to. + Such files may be considered "original", likely in the author's repository. + """) + + parser.add_argument('--unique-times', default=False, action="store_true", help=""" + Set the microseconds to a unique value per commit. + Allows telling apart changes that would otherwise have identical timestamps, + as git's time accuracy is in seconds. + """) + + parser.add_argument('pathspec', nargs='*', metavar='PATHSPEC', help=""" + Only modify paths matching %(metavar)s, relative to current directory. + By default, update all but untracked files and submodules. + """) + + parser.add_argument('--version', '-V', action='version', + version='%(prog)s version {version}'.format(version=get_version())) + + args_ = parser.parse_args() + if args_.verbose: + args_.loglevel = max(logging.TRACE, logging.DEBUG // args_.verbose) + args_.debug = args_.loglevel <= logging.DEBUG + return args_ + + +def get_version(version=__version__): + if not version.endswith('+dev'): + return version + try: + cwd = os.path.dirname(os.path.realpath(__file__)) + return Git(cwd=cwd, errors=False).describe().lstrip('v') + except Git.Error: + return '-'.join((version, "unknown")) + + +# Helper functions ############################################################ + +def setup_logging(): + """Add TRACE logging level and corresponding method, return the root logger""" + logging.TRACE = TRACE = logging.DEBUG // 2 + logging.Logger.trace = lambda _, m, *a, **k: _.log(TRACE, m, *a, **k) + return logging.getLogger() + + +def normalize(path): + r"""Normalize paths from git, handling non-ASCII characters. + + Git stores paths as UTF-8 normalization form C. + If path contains non-ASCII or non-printable characters, git outputs the UTF-8 + in octal-escaped notation, escaping double-quotes and backslashes, and then + double-quoting the whole path. + https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath + + This function reverts this encoding, so: + normalize(r'"Back\\slash_double\"quote_a\303\247a\303\255"') => + r'Back\slash_double"quote_açaí') + + See notes on `windows/non-ascii-paths.txt` about path encodings on non-UTF-8 + platforms and filesystems. + """ + if path and path[0] == '"': + # Python 2: path = path[1:-1].decode("string-escape") + # Python 3: https://stackoverflow.com/a/46650050/624066 + path = (path[1:-1] # Remove enclosing double quotes + .encode('latin1') # Convert to bytes, required by 'unicode-escape' + .decode('unicode-escape') # Perform the actual octal-escaping decode + .encode('latin1') # 1:1 mapping to bytes, forming UTF-8 encoding + .decode('utf8')) # Decode from UTF-8 + if NORMALIZE_PATHS: + # Make sure the slash matches the OS; for Windows we need a backslash + path = os.path.normpath(path) + return path + + +def dummy(*_args, **_kwargs): + """No-op function used in dry-run tests""" + + +def touch(path, mtime): + """The actual mtime update""" + os.utime(path, (mtime, mtime), **UTIME_KWS) + + +def touch_ns(path, mtime_ns): + """The actual mtime update, using nanoseconds for unique timestamps""" + os.utime(path, None, ns=(mtime_ns, mtime_ns), **UTIME_KWS) + + +def isodate(secs: int): + # time.localtime() accepts floats, but discards fractional part + return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(secs)) + + +def isodate_ns(ns: int): + # for integers fromtimestamp() is equivalent and ~16% slower than isodate() + return datetime.datetime.fromtimestamp(ns / 1000000000).isoformat(sep=' ') + + +def get_mtime_ns(secs: int, idx: int): + # Time resolution for filesystems and functions: + # ext-4 and other POSIX filesystems: 1 nanosecond + # NTFS (Windows default): 100 nanoseconds + # datetime.datetime() (due to 64-bit float epoch): 1 microsecond + us = idx % 1000000 # 10**6 + return 1000 * (1000000 * secs + us) + + +def get_mtime_path(path): + return os.path.getmtime(path) + + +# Git class and parse_log(), the heart of the script ########################## + +class Git: + def __init__(self, workdir=None, gitdir=None, cwd=None, errors=True): + self.gitcmd = ['git'] + self.errors = errors + self._proc = None + if workdir: self.gitcmd.extend(('--work-tree', workdir)) + if gitdir: self.gitcmd.extend(('--git-dir', gitdir)) + if cwd: self.gitcmd.extend(('-C', cwd)) + self.workdir, self.gitdir = self._get_repo_dirs() + + def ls_files(self, paths: list = None): + return (normalize(_) for _ in self._run('ls-files --full-name', paths)) + + def ls_dirty(self, force=False): + return (normalize(_[3:].split(' -> ', 1)[-1]) + for _ in self._run('status --porcelain') + if _[:2] != '??' and (not force or (_[0] in ('R', 'A') + or _[1] == 'D'))) + + def log(self, merge=False, first_parent=False, commit_time=False, + reverse_order=False, paths: list = None): + cmd = 'whatchanged --pretty={}'.format('%ct' if commit_time else '%at') + if merge: cmd += ' -m' + if first_parent: cmd += ' --first-parent' + if reverse_order: cmd += ' --reverse' + return self._run(cmd, paths) + + def describe(self): + return self._run('describe --tags', check=True)[0] + + def terminate(self): + if self._proc is None: + return + try: + self._proc.terminate() + except OSError: + # Avoid errors on OpenBSD + pass + + def _get_repo_dirs(self): + return (os.path.normpath(_) for _ in + self._run('rev-parse --show-toplevel --absolute-git-dir', check=True)) + + def _run(self, cmdstr: str, paths: list = None, output=True, check=False): + cmdlist = self.gitcmd + shlex.split(cmdstr) + if paths: + cmdlist.append('--') + cmdlist.extend(paths) + popen_args = dict(universal_newlines=True, encoding='utf8') + if not self.errors: + popen_args['stderr'] = subprocess.DEVNULL + log.trace("Executing: %s", ' '.join(cmdlist)) + if not output: + return subprocess.call(cmdlist, **popen_args) + if check: + try: + stdout: str = subprocess.check_output(cmdlist, **popen_args) + return stdout.splitlines() + except subprocess.CalledProcessError as e: + raise self.Error(e.returncode, e.cmd, e.output, e.stderr) + self._proc = subprocess.Popen(cmdlist, stdout=subprocess.PIPE, **popen_args) + return (_.rstrip() for _ in self._proc.stdout) + + def __del__(self): + self.terminate() + + class Error(subprocess.CalledProcessError): + """Error from git executable""" + + +def parse_log(filelist, dirlist, stats, git, merge=False, filterlist=None): + mtime = 0 + datestr = isodate(0) + for line in git.log( + merge, + args.first_parent, + args.commit_time, + args.reverse_order, + filterlist + ): + stats['loglines'] += 1 + + # Blank line between Date and list of files + if not line: + continue + + # Date line + if line[0] != ':': # Faster than `not line.startswith(':')` + stats['commits'] += 1 + mtime = int(line) + if args.unique_times: + mtime = get_mtime_ns(mtime, stats['commits']) + if args.debug: + datestr = isodate(mtime) + continue + + # File line: three tokens if it describes a renaming, otherwise two + tokens = line.split('\t') + + # Possible statuses: + # M: Modified (content changed) + # A: Added (created) + # D: Deleted + # T: Type changed: to/from regular file, symlinks, submodules + # R099: Renamed (moved), with % of unchanged content. 100 = pure rename + # Not possible in log: C=Copied, U=Unmerged, X=Unknown, B=pairing Broken + status = tokens[0].split(' ')[-1] + file = tokens[-1] + + # Handles non-ASCII chars and OS path separator + file = normalize(file) + + def do_file(): + if args.skip_older_than_commit and get_mtime_path(file) <= mtime: + stats['skip'] += 1 + return + if args.debug: + log.debug("%d\t%d\t%d\t%s\t%s", + stats['loglines'], stats['commits'], stats['files'], + datestr, file) + try: + touch(os.path.join(git.workdir, file), mtime) + stats['touches'] += 1 + except Exception as e: + log.error("ERROR: %s: %s", e, file) + stats['errors'] += 1 + + def do_dir(): + if args.debug: + log.debug("%d\t%d\t-\t%s\t%s", + stats['loglines'], stats['commits'], + datestr, "{}/".format(dirname or '.')) + try: + touch(os.path.join(git.workdir, dirname), mtime) + stats['dirtouches'] += 1 + except Exception as e: + log.error("ERROR: %s: %s", e, dirname) + stats['direrrors'] += 1 + + if file in filelist: + stats['files'] -= 1 + filelist.remove(file) + do_file() + + if args.dirs and status in ('A', 'D'): + dirname = os.path.dirname(file) + if dirname in dirlist: + dirlist.remove(dirname) + do_dir() + + # All files done? + if not stats['files']: + git.terminate() + return + + +# Main Logic ################################################################## + +def main(): + start = time.time() # yes, Wall time. CPU time is not realistic for users. + stats = {_: 0 for _ in ('loglines', 'commits', 'touches', 'skip', 'errors', + 'dirtouches', 'direrrors')} + + logging.basicConfig(level=args.loglevel, format='%(message)s') + log.trace("Arguments: %s", args) + + # First things first: Where and Who are we? + if args.cwd: + log.debug("Changing directory: %s", args.cwd) + try: + os.chdir(args.cwd) + except OSError as e: + log.critical(e) + return e.errno + # Using both os.chdir() and `git -C` is redundant, but might prevent side effects + # `git -C` alone could be enough if we make sure that: + # - all paths, including args.pathspec, are processed by git: ls-files, rev-parse + # - touch() / os.utime() path argument is always prepended with git.workdir + try: + git = Git(workdir=args.workdir, gitdir=args.gitdir, cwd=args.cwd) + except Git.Error as e: + # Not in a git repository, and git already informed user on stderr. So we just... + return e.returncode + + # Get the files managed by git and build file list to be processed + if UPDATE_SYMLINKS and not args.skip_older_than: + filelist = set(git.ls_files(args.pathspec)) + else: + filelist = set() + for path in git.ls_files(args.pathspec): + fullpath = os.path.join(git.workdir, path) + + # Symlink (to file, to dir or broken - git handles the same way) + if not UPDATE_SYMLINKS and os.path.islink(fullpath): + log.warning("WARNING: Skipping symlink, no OS support for updates: %s", + path) + continue + + # skip files which are older than given threshold + if (args.skip_older_than + and start - get_mtime_path(fullpath) > args.skip_older_than): + continue + + # Always add files relative to worktree root + filelist.add(path) + + # If --force, silently ignore uncommitted deletions (not in the filesystem) + # and renames / additions (will not be found in log anyway) + if args.force: + filelist -= set(git.ls_dirty(force=True)) + # Otherwise, ignore any dirty files + else: + dirty = set(git.ls_dirty()) + if dirty: + log.warning("WARNING: Modified files in the working directory were ignored." + "\nTo include such files, commit your changes or use --force.") + filelist -= dirty + + # Build dir list to be processed + dirlist = set(os.path.dirname(_) for _ in filelist) if args.dirs else set() + + stats['totalfiles'] = stats['files'] = len(filelist) + log.info("{0:,} files to be processed in work dir".format(stats['totalfiles'])) + + if not filelist: + # Nothing to do. Exit silently and without errors, just like git does + return + + # Process the log until all files are 'touched' + log.debug("Line #\tLog #\tF.Left\tModification Time\tFile Name") + parse_log(filelist, dirlist, stats, git, args.merge, args.pathspec) + + # Missing files + if filelist: + # Try to find them in merge logs, if not done already + # (usually HUGE, thus MUCH slower!) + if args.missing and not args.merge: + filterlist = list(filelist) + missing = len(filterlist) + log.info("{0:,} files not found in log, trying merge commits".format(missing)) + for i in range(0, missing, STEPMISSING): + parse_log(filelist, dirlist, stats, git, + merge=True, filterlist=filterlist[i:i + STEPMISSING]) + + # Still missing some? + for file in filelist: + log.warning("WARNING: not found in the log: %s", file) + + # Final statistics + # Suggestion: use git-log --before=mtime to brag about skipped log entries + def log_info(msg, *a, width=13): + ifmt = '{:%d,}' % (width,) # not using 'n' for consistency with ffmt + ffmt = '{:%d,.2f}' % (width,) + # %-formatting lacks a thousand separator, must pre-render with .format() + log.info(msg.replace('%d', ifmt).replace('%f', ffmt).format(*a)) + + log_info( + "Statistics:\n" + "%f seconds\n" + "%d log lines processed\n" + "%d commits evaluated", + time.time() - start, stats['loglines'], stats['commits']) + + if args.dirs: + if stats['direrrors']: log_info("%d directory update errors", stats['direrrors']) + log_info("%d directories updated", stats['dirtouches']) + + if stats['touches'] != stats['totalfiles']: + log_info("%d files", stats['totalfiles']) + if stats['skip']: log_info("%d files skipped", stats['skip']) + if stats['files']: log_info("%d files missing", stats['files']) + if stats['errors']: log_info("%d file update errors", stats['errors']) + + log_info("%d files updated", stats['touches']) + + if args.test: + log.info("TEST RUN - No files modified!") + + +# Keep only essential, global assignments here. Any other logic must be in main() +log = setup_logging() +args = parse_args() + +# Set the actual touch() and other functions based on command-line arguments +if args.unique_times: + touch = touch_ns + isodate = isodate_ns + +# Make sure this is always set last to ensure --test behaves as intended +if args.test: + touch = dummy + +# UI done, it's showtime! +try: + sys.exit(main()) +except KeyboardInterrupt: + log.info("\nAborting") + signal.signal(signal.SIGINT, signal.SIG_DFL) + os.kill(os.getpid(), signal.SIGINT) diff --git a/git-restore-mtime.url b/git-restore-mtime.url new file mode 100644 index 0000000..a73514e --- /dev/null +++ b/git-restore-mtime.url @@ -0,0 +1 @@ +https://raw.githubusercontent.com/MestreLion/git-tools/ec649ca40711545a3903427256280f48a666ebe3/git-restore-mtime diff --git a/main.js b/main.js deleted file mode 100644 index e3f7015..0000000 --- a/main.js +++ /dev/null @@ -1,26 +0,0 @@ -const spawn = require('child_process').spawn; -const path = require("path"); - -const exec = (cmd, args=[]) => new Promise((resolve, reject) => { - console.log(`Started: ${cmd} ${args.join(" ")}`) - const app = spawn(cmd, args, { stdio: 'inherit' }); - app.on('close', code => { - if(code !== 0){ - err = new Error(`Invalid status code: ${code}`); - err.code = code; - return reject(err); - }; - return resolve(code); - }); - app.on('error', reject); -}); - -const main = async () => { - await exec('bash', [path.join(__dirname, './main.sh')]); -}; - -main().catch(err => { - console.error(err); - console.error(err.stack); - process.exit(err.code || -1); -}) diff --git a/main.sh b/main.sh index 2c9e314..6a5c7bc 100755 --- a/main.sh +++ b/main.sh @@ -5,8 +5,11 @@ set -e repo_dir=$GITHUB_WORKSPACE/$INPUT_REPOSITORY_PATH doc_dir=$repo_dir/$INPUT_DOCUMENTATION_PATH +# https://stackoverflow.com/a/4774063/4799273 +action_dir=$GITHUB_ACTION_PATH echo ::group:: Initialize various paths +echo Action: $action_dir echo Workspace: $GITHUB_WORKSPACE echo Repository: $repo_dir echo Documentation: $doc_dir @@ -23,7 +26,7 @@ else pip3 install -U sphinx==$INPUT_SPHINX_VERSION fi -echo Adding user bin to system path +echo Adding ~/.local/bin to system path PATH=$HOME/.local/bin:$PATH if ! command -v sphinx-build &>/dev/null; then echo Sphinx is not successfully installed @@ -36,64 +39,51 @@ echo ::endgroup:: if [ ! -z "$INPUT_REQUIREMENTS_PATH" ] ; then echo ::group:: Installing requirements - if [ -f "$repo_dir/$INPUT_REQUIREMENTS_PATH" ]; then + if [ -f "$INPUT_REQUIREMENTS_PATH" ]; then echo Installing python requirements - pip3 install -r "$repo_dir/$INPUT_REQUIREMENTS_PATH" + pip3 install -r "$INPUT_REQUIREMENTS_PATH" else echo No requirements.txt found, skipped fi echo ::endgroup:: fi -echo ::group:: Creating temp directory -tmp_dir=$(mktemp -d -t pages-XXXXXXXXXX) -echo Temp directory \"$tmp_dir\" is created +echo ::group:: Preparations for incremental build + +# Sphinx HTML builder will rebuild the whole project when modification time + # (mtime) of templates of theme newer than built result. [1] +# +# These theme templates vendored in pip packages are newly installed, +# so their mtime always newr than the built result. +# Set mtime to 1990 to make sure the project won't rebuilt. +# +# .. [1] https://github.com/sphinx-doc/sphinx/blob/5.x/sphinx/builders/html/__init__.py#L417 +echo Fixing timestamp of HTML theme +site_packages_dir=$(python -c 'import site; print(site.getsitepackages()[0])') +echo Python site-packages directory: $site_packages_dir +for i in $(find $site_packages_dir -name '*.html'); do + touch -m -t 190001010000 $i + echo Fixing timestamp of $i +done + +echo Restoring timestamp of git repository +git_restore_mtime=$action_dir/git-restore-mtime +$git_restore_mtime $repo_dir + echo ::endgroup:: +echo ::group:: Creating build directory +build_dir=/tmp/sphinxnotes-pages +mkdir -p $build_dir || true +echo Temp directory \"$build_dir\" is created + echo ::group:: Running Sphinx builder -if ! sphinx-build -b html "$doc_dir" "$tmp_dir"; then +if ! sphinx-build -b html "$doc_dir" "$build_dir"; then echo ::endgroup:: - echo ::group:: Dumping Sphinx error log + echo ::group:: Dumping Sphinx error log for l in $(ls /tmp/sphinx-err*); do cat $l done exit 1 fi echo ::endgroup:: - -echo ::group:: Setting up git repository -echo Setting up git configure -cd $repo_dir -git config --local user.email "action@github.com" -git config --local user.name "GitHub Action" -git stash -echo Setting up branch $INPUT_TARGET_BRANCH -branch_exist=$(git ls-remote --heads origin refs/heads/$INPUT_TARGET_BRANCH) -if [ -z "$branch_exist" ]; then - echo Branch doesn\'t exist, create an empty branch - git checkout --force --orphan $INPUT_TARGET_BRANCH -else - echo Branch exists, checkout to it - git checkout --force $INPUT_TARGET_BRANCH -fi -git clean -fd -echo ::endgroup:: - -echo ::group:: Committing HTML documentation -cd $repo_dir -echo Deleting all file in repository -rm -vrf * -echo Copying HTML documentation to repository -# Remove unused doctree -rm -rf $tmp_dir/.doctrees -cp -vr $tmp_dir/. $INPUT_TARGET_PATH -if [ ! -f "$INPUT_TARGET_PATH/.nojekyll" ]; then - # See also sphinxnotes/pages#7 - echo Creating .nojekyll file - touch "$INPUT_TARGET_PATH/.nojekyll" -fi -echo Adding HTML documentation to repository index -git add $INPUT_TARGET_PATH -echo Recording changes to repository -git commit --allow-empty -m "Add changes for $GITHUB_SHA" -echo ::endgroup::