├── LICENCE ├── README.md └── git-backdate /LICENCE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENCE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2004 Sam Hocevar 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > Who controls the past, controls the future: who controls the present, controls the past. The mutability of the past 2 | > is the central tenet of Ingsoc. Past events, it is argued, have no existence, but survive only in written records and 3 | > in human memories. The past is whatever the records and the memories agree upon. And since ~~the Party~~ git is in 4 | > full control of all records and in equally full control of the minds of its members, it follows that the past is 5 | > whatever ~~the Party~~ git chooses to make it. 6 | 7 | *– Linus Torvalds, probably* 8 | 9 | # git-backdate 10 | 11 | git-backdate helps you to change the date of one or multiple commits to a new date or 12 | a range of dates. 13 | 14 | ## Features 15 | 16 | - Understands business hours, and that you might want to have your commits placed inside or outside of them. 17 | - You can also except specific days or date ranges (e.g. vacation, illness …) 18 | - Commits are randomly distributed within the given time window, while retaining their order. No 12:34:56 for you 19 | anymore! 20 | - Given a single commit, git-backdate will automatically assume that you want to rebase the entire range from there to 21 | your current `HEAD`. 22 | - Sets author date and committer date so you don't have to look up how to set both of them every time you fudge a commit 23 | timestamp. 24 | - Python, but with near-zero dependencies (see below; only `sed` and `date`), so you can just download and run it 25 | without Python package management making you sad. 26 | 27 | 28 | ## Usage 29 | 30 | Backdate all your unpushed commits to the last three days, and only during business hours: 31 | 32 | ```shell 33 | git backdate origin/main "3 days ago..today" --business-hours 34 | ``` 35 | 36 | Backdate only some commits to have happened outside of business hours: 37 | 38 | ```shell 39 | git backdate 11abe2..3d13f 2023-07-10 --no-business-hours 40 | ``` 41 | 42 | Backdate only the current commit to a human readable time: 43 | 44 | ```shell 45 | git backdate HEAD "5 hours ago" 46 | ``` 47 | 48 | Use ALL the features, e.g. excluding weekends and specific days and backdating only a range of commits: 49 | 50 | ```shell 51 | git backdate 11abe2..3d13f 2023-07-01..2023-07-30 --business-hours --except-days 2023-01-05,2023-07-20..2023-07-24 52 | ``` 53 | 54 | Backdate the entire repository history, including the very first commit: 55 | 56 | ```shell 57 | git backdate ROOT "2000-01-01..today" 58 | ``` 59 | 60 | ## Installation 61 | 62 | Drop the `git-backdate` file somewhere in your `PATH` or wherever you like: 63 | 64 | ```shell 65 | curl https://raw.githubusercontent.com/rixx/git-backdate/main/git-backdate > git-backdate 66 | chmod +x git-backdate 67 | ``` 68 | 69 | The magic of git will now let you use `git backdate` as a command. 70 | 71 | 72 | ### Requirements 73 | 74 | `git-backdate` tries to only require git and Python. However, it also relies on 75 | 76 | - `sed` if you want to backdate more than the most recent commit for perfectly fine reasons, don't worry about it 77 | - `date` if you want to pass date descriptions like "last Friday" or "2 weeks ago". If you use git-backdate on MacOS and 78 | want to use this feature, you will need to run the command with ``GIT_BACKDATE_DATE_CMD="gdate" git backdate …``, as 79 | native `date` on MacOS does not support the `--date` flag. 80 | 81 | ## … why. 82 | 83 | I started various versions of this in uni, when I did my assignments last minute and wanted to appear as if I had my 84 | life together. I still sometimes use it like that (especially to make the 3am commits look less deranged), but there 85 | have been new and surprising use cases. Most of these have been contributed by friends and do not reflect on me, nor do 86 | they represent legal or career advice: 87 | 88 | - Did work things outside work hours, but don't want to nudge your workplace culture into everybody working at all times. 89 | - Worked an entire weekend for a client, but don't want them to get used to it and start calling you on every weekend. 90 | - Made some fixes during a boring meeting, but pretended to pay attention throughout. 91 | - Want to confuse your coworkers by making it look like you were committing code while doing a company-wide presentation. 92 | - 93 | 94 | ## Caveats 95 | 96 | Commit dates are part of a commit's metadata. Changing a commit's date changes its hash. 97 | You very likely only want to run `git backdate` on commits that you have not pushed yet, 98 | because otherwise you would have to `--force` push your new history. I know, I know, 99 | you're using `--force-with-lease`, so you won't destroy data, but your collaborators 100 | or integrations will still be somewhat miffed. 101 | 102 | Also, obviously, use with care and compassion. 103 | -------------------------------------------------------------------------------- /git-backdate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Backdate a commit or range of commit to a date or range of dates. 3 | """ 4 | import argparse 5 | import datetime as dt 6 | import logging 7 | import math 8 | import os 9 | import random 10 | import re 11 | import sys 12 | from pathlib import Path 13 | from subprocess import CalledProcessError, call, check_call, check_output 14 | 15 | DEFAULT_DATE_CMD = "date" if sys.platform != "darwin" else "gdate" 16 | 17 | DATE_CMD = os.environ.get("GIT_BACKDATE_DATE_CMD", DEFAULT_DATE_CMD) 18 | 19 | def call_command(args, check=True, **kwargs): 20 | logger = logging.getLogger("call_command") 21 | logger.debug(" ".join(args)) 22 | 23 | if check: 24 | return check_call( 25 | args, stdout=open(os.devnull, "w"), stderr=open(os.devnull, "w"), **kwargs 26 | ) 27 | return call( 28 | args, stdout=open(os.devnull, "w"), stderr=open(os.devnull, "w"), **kwargs 29 | ) 30 | 31 | 32 | def is_commit(commitish: str) -> bool: 33 | """Return True if commitish is a commit-ish.""" 34 | try: 35 | call_command(["git", "rev-parse", "--verify", commitish]) 36 | return True 37 | except CalledProcessError: 38 | return False 39 | 40 | 41 | def get_commits(commitish: str) -> list[str]: 42 | """If commitish is a range, return a list of commits in that range.""" 43 | if commitish.endswith(".."): 44 | commitish = f"{commitish}HEAD" 45 | elif ".." not in commitish: 46 | # Handle some special cases, else fall back on a range to current head 47 | if commitish in ("HEAD", "@"): 48 | commitish = f"{commitish}^..HEAD" 49 | elif commitish == "ROOT": 50 | commitish = "HEAD" 51 | else: 52 | commitish = f"{commitish}..HEAD" 53 | result = check_output(["git", "rev-list", commitish]).splitlines() 54 | # git rev-list returns commits in reverse chronological order 55 | # we don't really care atm, but it does make debugging awkward. 56 | return [c.decode() for c in result[::-1]] 57 | 58 | 59 | def _parse_date(dateish: str) -> dt.date: 60 | """Parse a dateish string into a datetime object.""" 61 | if not re.match(r"\d{4}-\d{2}-\d{2}", dateish): 62 | dateish = ( 63 | check_output([DATE_CMD, "--iso-8601", "--date", dateish]).strip().decode() 64 | ) 65 | return dt.datetime.strptime(dateish, "%Y-%m-%d").date() 66 | 67 | 68 | def get_dates(dateish: str, fill: bool = False) -> list[dt.date]: 69 | """Dateish is either one or two dateish strings, separated by .. if there are two. 70 | A dateish string can be an ISO 8601 date or anything parsed by your system's `date`, 71 | which often includes things like "last month" and "3 days ago".""" 72 | if ".." in dateish: 73 | dates = [_parse_date(d) for d in dateish.split("..")] 74 | if not fill: 75 | return dates 76 | # fill in the gaps 77 | start, end = dates 78 | return [start + dt.timedelta(days=day) for day in range((end - start).days + 1)] 79 | result = _parse_date(dateish) 80 | return [result, result] 81 | 82 | 83 | def get_commit_timestamp(commit: str) -> dt.datetime | None: 84 | """Return the timestamp of the given commit.""" 85 | try: 86 | timestamp = ( 87 | check_output(["git", "show", "-s", "--format=%ct", commit]).strip().decode() 88 | ) 89 | return dt.datetime.fromtimestamp(int(timestamp)) 90 | except CalledProcessError: 91 | return None 92 | 93 | 94 | def _get_timestamp( 95 | date: dt.date, min_hour: int, max_hour: int, greater_than: dt.datetime | None = None 96 | ) -> dt.datetime: 97 | """Return a random timestamp on the given date, between min_hour and max_hour.""" 98 | greater_than = greater_than or dt.datetime.combine(date, dt.time.min) 99 | min_timestamp = dt.datetime.combine(date, dt.time(min_hour)) 100 | max_timestamp = dt.datetime.combine(date, dt.time(max_hour, 59)) 101 | now_timestamp = dt.datetime.now() 102 | min_timestamp = min_timestamp if min_timestamp > greater_than else greater_than 103 | max_timestamp = max_timestamp if max_timestamp < now_timestamp else now_timestamp 104 | interval = int((max_timestamp - min_timestamp).total_seconds()) 105 | return min_timestamp + dt.timedelta(seconds=random.randint(0, interval)) 106 | 107 | 108 | def rebase_in_progress() -> bool: 109 | """Return True if a rebase is in progress.""" 110 | git_root = Path( 111 | check_output(["git", "rev-parse", "--show-toplevel"]).strip().decode() 112 | ) 113 | return any( 114 | (git_root / ".git" / f).exists() for f in ("rebase-merge", "rebase-apply") 115 | ) 116 | 117 | 118 | def rewrite_history( 119 | commits: list[str], 120 | start: dt.date, 121 | end: dt.date, 122 | business_hours: bool, 123 | no_business_hours: bool, 124 | except_days: list[dt.date] | None = None, 125 | root: bool = False, 126 | ) -> None: 127 | logger = logging.getLogger("rewrite_history") 128 | logger.warning("Rewriting history") 129 | logger.info(f"{len(commits)} commits to rewrite") 130 | last_timestamp = dt.datetime.combine(start, dt.time.min) 131 | if not root: 132 | last_timestamp = get_commit_timestamp(f"{commits[0]}~1") 133 | if last_timestamp and last_timestamp.date() > start: 134 | start = last_timestamp.date() 135 | except_days = except_days or [] 136 | days = [ 137 | start + dt.timedelta(days=day) 138 | for day in range((end - start).days + 1) 139 | if start + dt.timedelta(days=day) not in except_days 140 | ] 141 | min_hour = 0 142 | max_hour = 23 143 | if business_hours: 144 | days = [day for day in days if day.weekday() < 5] 145 | min_hour = 9 146 | max_hour = 17 147 | elif no_business_hours: 148 | min_hour = 18 149 | max_hour = 23 150 | duration = len(days) 151 | commit_count = len(commits) 152 | commits_per_day = math.ceil(commit_count / duration) 153 | day_progress = 0 154 | logger.info(f"Distributing to {duration} days") 155 | logger.info(f"At most {commits_per_day} commits per day") 156 | 157 | for commit_index in range(commit_count): 158 | progress = (commit_index + 1) / commit_count 159 | # first, choose the date 160 | date_index = round(progress * (duration - 1)) 161 | date = days[date_index] 162 | if not last_timestamp or date != last_timestamp.date(): 163 | day_progress = 0 164 | day_progress += 1 165 | 166 | # if we are on the day of the last commit, we need to select a mininum 167 | # hour accordingly. otherwise we can start early. 168 | if last_timestamp and date == last_timestamp.date(): 169 | _min_hour = last_timestamp.hour 170 | else: 171 | _min_hour = min_hour 172 | 173 | # if we only have one commit per day at most, we can use the whole day. 174 | # otherwise, we need to limit the time range further to avoid collisions. 175 | if commits_per_day <= 1: 176 | _max_hour = max_hour 177 | else: 178 | _max_hour = _min_hour + int( 179 | (max_hour - _min_hour) * (day_progress / commits_per_day) 180 | ) 181 | _max_hour = min(_max_hour, 23) 182 | 183 | logger.debug( 184 | f"Getting a timestamp between {_min_hour} and {_max_hour}, greater than {last_timestamp}" 185 | ) 186 | 187 | timestamp = _get_timestamp( 188 | date, min_hour=_min_hour, max_hour=_max_hour, greater_than=last_timestamp 189 | ) 190 | 191 | # Set both the author and committer dates 192 | call_command( 193 | [ 194 | "git", 195 | "commit", 196 | "--amend", 197 | "--date", 198 | timestamp.isoformat(), 199 | "--no-edit", 200 | ], 201 | env=dict(os.environ, GIT_COMMITTER_DATE=timestamp.isoformat()), 202 | ) 203 | last_timestamp = timestamp 204 | call_command(["git", "rebase", "--continue"]) 205 | 206 | 207 | def normalize_commit(commit: str) -> str: 208 | return check_output(["git", "rev-parse", commit]).strip().decode() 209 | 210 | 211 | def is_equal(commitish_a: str, commitish_b: str) -> bool: 212 | return normalize_commit(commitish_a) == normalize_commit(commitish_b) 213 | 214 | 215 | def main() -> None: 216 | parser = argparse.ArgumentParser(description=sys.modules[__name__].__doc__) 217 | parser.add_argument( 218 | "commits", 219 | metavar="COMMITS", 220 | default="HEAD", 221 | help="COMMITS is a commit or range of commits to backdate. If only one commit is given, it will be used as the end of the range.", 222 | ) 223 | parser.add_argument( 224 | "dates", 225 | metavar="DATES", 226 | default="now", 227 | help='DATES is a date or range of dates to backdate to, can use human readable dates. Separate by .., eg "1 week ago..yesterday"', 228 | ) 229 | parser.add_argument( 230 | "--business-hours", 231 | action="store_true", 232 | help="Backdate to business hours: Mon–Fri, 9–17", 233 | default=False, 234 | ) 235 | parser.add_argument( 236 | "--no-business-hours", 237 | action="store_true", 238 | help="Backdate to outside business hours: 19–23, every day of the week", 239 | default=False, 240 | ) 241 | parser.add_argument( 242 | "--except-days", 243 | type=str, 244 | help="A comma-separated list of dates or date ranges to exclude from backdating (eg. 2021-01-01,2021-01-02..2021-01-04)", 245 | ) 246 | parser.add_argument( 247 | "--log-level", 248 | type=str, 249 | help="Set the log level", 250 | choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 251 | default="WARNING", 252 | ) 253 | args = parser.parse_args() 254 | 255 | if args.business_hours and args.no_business_hours: 256 | print("Cannot use both business hours and outside business hours") 257 | sys.exit(1) 258 | 259 | logging.basicConfig( 260 | level=args.log_level, 261 | format="[%(levelname)s] %(message)s", 262 | ) 263 | logger = logging.getLogger("main") 264 | 265 | commits = [normalize_commit(c) for c in get_commits(args.commits)] 266 | logger.info(f"Found {len(commits)} commits to backdate") 267 | logger.debug(f"Commits: {', '.join(commits)}") 268 | 269 | start, end = get_dates(args.dates) 270 | logger.info(f"Backdating to {start.isoformat()}..{end.isoformat()}") 271 | 272 | except_days = args.except_days 273 | if except_days: 274 | except_days = [get_dates(d, fill=True) for d in except_days.split(",")] 275 | # Flatten the list 276 | except_days = [d for dates in except_days for d in dates] 277 | 278 | logger.info(f"Excluding {len(except_days) if except_days else 0} days.") 279 | 280 | if not commits: 281 | print("No commits found") 282 | sys.exit(1) 283 | 284 | # Make sure our current commit sits on top of the commit range with --is-ancestor 285 | for commit in commits: 286 | is_head = is_equal(commit, "HEAD") 287 | is_ancestor = call_command( 288 | ["git", "merge-base", "--is-ancestor", "HEAD", commit], check=False 289 | ) 290 | if not is_head and not is_ancestor: 291 | print(f"Current commit is not an ancestor of the commit range {commit}") 292 | sys.exit(1) 293 | 294 | # We construct a sed command to change only our commits 295 | short_commits = [c[:7] for c in commits] 296 | sed_command = rf"sed -i.bak -E 's/^pick ({'|'.join(short_commits)})/edit \1/'" 297 | start_commit = f"{commits[0]}^" 298 | is_root_rebase = not is_commit(start_commit) 299 | if is_root_rebase: 300 | logger.warning("Rebasing entire repository including root commit!") 301 | 302 | call_command( 303 | ["git", "rebase", "-i", "--root" if is_root_rebase else start_commit], 304 | env=dict(os.environ, GIT_SEQUENCE_EDITOR=sed_command), 305 | ) 306 | 307 | # Global try/except to make sure we reset the repo if we fail 308 | try: 309 | rewrite_history( 310 | commits, 311 | start, 312 | end, # we want to include the end date 313 | business_hours=args.business_hours, 314 | no_business_hours=args.no_business_hours, 315 | except_days=except_days, 316 | root=is_root_rebase, 317 | ) 318 | except Exception: 319 | if rebase_in_progress(): 320 | call_command(["git", "rebase", "--abort"]) 321 | logger.error("Rebase aborted!") 322 | raise 323 | finally: 324 | # Shouldn't happen, but let's make sure we end on a clean repo 325 | if rebase_in_progress(): 326 | call_command(["git", "rebase", "--continue"]) 327 | logger.info("Didn't end on a clean repo, finishing rebase.") 328 | 329 | 330 | if __name__ == "__main__": 331 | main() 332 | --------------------------------------------------------------------------------