diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..16fede8 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +concurrency = multiprocessing diff --git a/.gitignore b/.gitignore index a834a23..eb91213 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ git_restore_mtime.py +.coverage +__pycache__ diff --git a/git-restore-mtime b/git-restore-mtime index 77ce215..0fc6fad 100755 --- a/git-restore-mtime +++ b/git-restore-mtime @@ -62,7 +62,8 @@ assuming the actual modification date and its commit date are close. # painfully slow. First pass without merge commits is not accurate. Maybe add a new # `--accurate` mode for `--cc`? -if __name__ != "__main__": +import sys +if __name__ != "__main__" and "pytest" not in sys.modules: raise ImportError("{} should not be used as a module.".format(__name__)) import argparse @@ -72,7 +73,6 @@ import os.path import shlex import signal import subprocess -import sys import time if sys.version_info < (3, 8): @@ -95,7 +95,7 @@ UTIME_KWS = {} if not UPDATE_SYMLINKS else {'follow_symlinks': False} # Command-line interface ###################################################### -def parse_args(): +def parse_args(argv=None): parser = argparse.ArgumentParser( description=__doc__.split('\n---')[0]) @@ -204,10 +204,25 @@ def parse_args(): parser.add_argument('--version', '-V', action='version', version='%(prog)s version {version}'.format(version=get_version())) - args_ = parser.parse_args() + args_ = parser.parse_args(argv) if args_.verbose: args_.loglevel = max(logging.TRACE, logging.DEBUG // args_.verbose) args_.debug = args_.loglevel <= logging.DEBUG + + # Keep only essential, global assignments here. Any other logic must be in main() + + # Set the actual touch() and other functions based on command-line arguments + if args_.unique_times: + args_.touch = touch_ns + args_.isodate = isodate_ns + else: + args_.touch = touch + args_.isodate = isodate + + # Make sure this is always set last to ensure --test behaves as intended + if args_.test: + args_.touch = dummy + return args_ @@ -375,9 +390,9 @@ class Git: """Error from git executable""" -def parse_log(filelist, dirlist, stats, git, merge=False, filterlist=None): +def parse_log(args, filelist, dirlist, stats, git, merge=False, filterlist=None): mtime = 0 - datestr = isodate(0) + datestr = args.isodate(0) for line in git.log( merge, args.first_parent, @@ -398,7 +413,7 @@ def parse_log(filelist, dirlist, stats, git, merge=False, filterlist=None): if args.unique_times: mtime = get_mtime_ns(mtime, stats['commits']) if args.debug: - datestr = isodate(mtime) + datestr = args.isodate(mtime) continue # File line: three tokens if it describes a renaming, otherwise two @@ -426,7 +441,7 @@ def parse_log(filelist, dirlist, stats, git, merge=False, filterlist=None): stats['loglines'], stats['commits'], stats['files'], datestr, file) try: - touch(os.path.join(git.workdir, file), mtime) + args.touch(os.path.join(git.workdir, file), mtime) stats['touches'] += 1 except Exception as e: log.error("ERROR: %s: %s", e, file) @@ -438,7 +453,7 @@ def parse_log(filelist, dirlist, stats, git, merge=False, filterlist=None): stats['loglines'], stats['commits'], datestr, "{}/".format(dirname or '.')) try: - touch(os.path.join(git.workdir, dirname), mtime) + args.touch(os.path.join(git.workdir, dirname), mtime) stats['dirtouches'] += 1 except Exception as e: log.error("ERROR: %s: %s", e, dirname) @@ -463,7 +478,7 @@ def parse_log(filelist, dirlist, stats, git, merge=False, filterlist=None): # Main Logic ################################################################## -def main(): +def main(args): start = time.time() # yes, Wall time. CPU time is not realistic for users. stats = {_: 0 for _ in ('loglines', 'commits', 'touches', 'skip', 'errors', 'dirtouches', 'direrrors')} @@ -535,7 +550,7 @@ def main(): # 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) + parse_log(args, filelist, dirlist, stats, git, args.merge, args.pathspec) # Missing files if filelist: @@ -546,7 +561,7 @@ def main(): 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, + parse_log(args, filelist, dirlist, stats, git, merge=True, filterlist=filterlist[i:i + STEPMISSING]) # Still missing some? @@ -584,23 +599,15 @@ def main(): 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) + + +if __name__ == "__main__": + args = parse_args(sys.argv[1:]) + # UI done, it's showtime! + try: + sys.exit(main(args)) + except KeyboardInterrupt: + log.info("\nAborting") + signal.signal(signal.SIGINT, signal.SIG_DFL) + os.kill(os.getpid(), signal.SIGINT) diff --git a/test_git_restore_mtime.py b/test_git_restore_mtime.py new file mode 100644 index 0000000..aa6ede1 --- /dev/null +++ b/test_git_restore_mtime.py @@ -0,0 +1,174 @@ +import os +import tempfile +import subprocess +from git_restore_mtime import main, parse_args + + +def git_init(d): + subprocess.check_call(["git", "init", d]) + subprocess.check_call(["git", "config", "user.email", "test@example.com"], cwd=d) + subprocess.check_call(["git", "config", "user.name", "test test"], cwd=d) + + +def git_add(d, contents="", symlink=None, files=[]): + for file in files: + if symlink: + os.symlink(symlink, f"{d}/{file}") + elif contents: + with open(f"{d}/{file}", 'w') as f: + f.write(contents) + subprocess.check_call(["git", "add", file], cwd=d) + + +def git_commit(d, date): + subprocess.check_call(["git", "commit", "--allow-empty-message", "-m", "", "--date", date, "--no-edit"], cwd=d) + + +def git_switch(d, branch, create=False): + cmd_list = ["git", "switch"] + if create: + cmd_list += ["-c", branch] + else: + cmd_list += [branch] + + subprocess.check_call(cmd_list, cwd=d) + + +def get_mtime_path(path): + return os.stat(path).st_mtime + + +def test_main(): + with tempfile.TemporaryDirectory() as tmpdir: + git_dir = f"{tmpdir}/git" + + git_init(git_dir) + git_add(git_dir, "a", files=["file1", "file2", "☺"]) + git_commit(git_dir, date="1761123456 UTC") + git_add(git_dir, "b", files=["file2"]) + git_commit(git_dir, date="1761123457 UTC") + + main(parse_args(("--cwd", git_dir))) + + assert get_mtime_path(f"{git_dir}/file1") == 1761123456.0 + assert get_mtime_path(f"{git_dir}/file2") == 1761123457.0 + + +def test_skip_older_than(): + with tempfile.TemporaryDirectory() as tmpdir: + git_dir = f"{tmpdir}/git" + + git_init(git_dir) + git_add(git_dir, "a", files=["file1"]) + git_add(git_dir, symlink="file1", files=["file2"]) + git_commit(git_dir, date="1761123456 UTC") + + main(parse_args(("--cwd", git_dir, "--skip-older-than", "-1000000000"))) + + assert get_mtime_path(f"{git_dir}/file1") != 1761123456.0 + + main(parse_args(("--cwd", git_dir, "--skip-older-than", "1000000000"))) + + assert get_mtime_path(f"{git_dir}/file1") == 1761123456.0 + + +def test_unique_times(): + with tempfile.TemporaryDirectory() as tmpdir: + git_dir = f"{tmpdir}/git" + + git_init(git_dir) + git_add(git_dir, "a", files=["file1"]) + git_commit(git_dir, date="1761123456 UTC") + + main(parse_args(("--cwd", git_dir, "--unique-times"))) + + assert get_mtime_path(f"{git_dir}/file1") == 1761123456.000001 + + +def test_verbose(): + with tempfile.TemporaryDirectory() as tmpdir: + git_dir = f"{tmpdir}/git" + + git_init(git_dir) + git_add(git_dir, "a", files=["file1"]) + git_commit(git_dir, date="1761123456 UTC") + + main(parse_args(("--cwd", git_dir, "--verbose"))) + + assert get_mtime_path(f"{git_dir}/file1") == 1761123456.0 + + +def test_test(): + with tempfile.TemporaryDirectory() as tmpdir: + git_dir = f"{tmpdir}/git" + + git_init(git_dir) + git_add(git_dir, "a", files=["file1"]) + git_commit(git_dir, date="1761123456 UTC") + + main(parse_args(("--cwd", git_dir, "--test"))) + + assert get_mtime_path(f"{git_dir}/file1") != 1761123456.0 + + +def test_missing(): + with tempfile.TemporaryDirectory() as tmpdir: + git_dir = f"{tmpdir}/git" + + git_init(git_dir) + git_add(git_dir, "a", files=["file1"]) + git_commit(git_dir, date="1761123456 UTC") + + git_switch(git_dir, "branch1", True) + + git_add(git_dir, "b", files=["file2"]) + git_commit(git_dir, date="1761123457 UTC") + + git_switch(git_dir, "master") + + subprocess.check_call(["git", "merge", "--no-ff", "--no-commit", "branch1"], cwd=git_dir) + + git_add(git_dir, "b", files=["file3"]) + + git_commit(git_dir, date="1761123458 UTC") + + main(parse_args(("--cwd", git_dir))) + + assert get_mtime_path(f"{git_dir}/file1") == 1761123456.0 + assert get_mtime_path(f"{git_dir}/file2") == 1761123457.0 + assert get_mtime_path(f"{git_dir}/file3") == 1761123458.0 + + +def test_dirty(): + with tempfile.TemporaryDirectory() as tmpdir: + git_dir = f"{tmpdir}/git" + + git_init(git_dir) + git_add(git_dir, "a", files=["file1"]) + git_commit(git_dir, date="1761123456 UTC") + + with open(f'{git_dir}/file1', 'w') as f: + f.write("dirty") + + subprocess.check_call(["git", "status", "--porcelain"], cwd=git_dir) + + main(parse_args(("--cwd", git_dir))) + + assert get_mtime_path(f"{git_dir}/file1") != 1761123456.0 + +def test_force(): + with tempfile.TemporaryDirectory() as tmpdir: + git_dir = f"{tmpdir}/git" + + git_init(git_dir) + git_add(git_dir, "a", files=["file1"]) + git_commit(git_dir, date="1761123456 UTC") + + with open(f'{git_dir}/file1', 'w') as f: + f.write("dirty") + + subprocess.check_call(["git", "status", "--porcelain"], cwd=git_dir) + + main(parse_args(("--cwd", git_dir, "--force"))) + + assert get_mtime_path(f"{git_dir}/file1") == 1761123456.0