Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[run]
concurrency = multiprocessing
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
git_restore_mtime.py
.coverage
__pycache__
69 changes: 38 additions & 31 deletions git-restore-mtime
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -72,7 +73,6 @@ import os.path
import shlex
import signal
import subprocess
import sys
import time

if sys.version_info < (3, 8):
Expand All @@ -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])

Expand Down Expand Up @@ -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_


Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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')}
Expand Down Expand Up @@ -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:
Expand All @@ -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?
Expand Down Expand Up @@ -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)
174 changes: 174 additions & 0 deletions test_git_restore_mtime.py
Original file line number Diff line number Diff line change
@@ -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