As mentioned in my Git For Writers handbook, git is a great tool for staying on track with daily writing goals. Git by itself gives you a lot of helpful data you can use to analyze your written output, but spread across a plethora of different commands and arguments. And some of those commands can grow out of control. Wouldn’t it be nice to corral only the writerly data into one view? I have a script here that does that. This is my “idid” python script, so named because it shows me what “I did” today.
Key metrics:
- Word metrics: words added/deleted, writing mode, and edit ratio.
- Session information: total number of commits, time data, how many files changes, and writing streak.
- Commit history: a rundown of all commits and commit messages since midnight, along with any git notes attached to those commits.
Let’s break this function down in detail.
Setting up the foundation – imports and script metadata
In the beginning, there is the loading of modules. Not too much happening here other than importing modules for working with dates, filepaths, and type hinting.
#!/usr/bin/env python3 """ Daily Writing Summary - What did I do today? Shows today's writing metrics, session info, and formatted commit history. """ import subprocess import sys from datetime import datetime, timedelta from pathlib import Path from typing import List, Dict, Optional
Initializing the summary tracker – setting your starting point
In this section, we are creating a class that basically sets up the baseline parameters for what the script is going to be analyzing. We set the directory in which we will be working, the time of now, the start time of midnight, etc.
class DailyWritingSummary: def __init__(self, directory: str = "."): self.directory = Path(directory) self.now = datetime.now() self.today_start = self.now.replace(hour=0, minute=0, second=0, microsecond=0)
Basic word counting utility
Word counts are important for the planning and delivery phases of your writing process, but they’re not always the best measure of progress, which is why I like to use commits. Nevertheless, I’ve included word counting in the script because it’s still the most basic form of productive measuring tape writers use, and it’s helpful if you’re doing challenges like National Novel Writing Month (nanowrimo).
Likewise, word counting can give you an edit ratio that reflects your habits. Are you doing more drafting or editing, or a mix of both? The edit ratio will tell you that.
This module in the script splits text by spaces and counts the pieces.
def count_words(self, text: str) -> int: """Count words in text.""" return len(text.split())
Retrieving git notes – finding sticky notes on commits
One of the things I love about git is the “notes” feature. Commit messages must not exceed one line, otherwise certain commands will not function properly. Git notes allow you to post extended information about a commit in case you could not quite capture all you wanted to notate within a one-line message. This function checks if a commit has a note stuck to it and retrieves the message if it exists.
def get_commit_note(self, commit_hash: str) -> Optional[str]: """Get git note for a commit if it exists.""" try: result = subprocess.run( ['git', 'notes', 'show', commit_hash], cwd=self.directory, capture_output=True, text=True, timeout=5 ) if result.returncode == 0 and result.stdout.strip(): return result.stdout.strip() return None except Exception: return None
Fetching today’s commits – collecting your day’s breadcrumbs
Think of this as retracing your steps from the day. It asks git “show me I’ve done since midnight” and creates a list of all your commits with their timestamps and messages.
def get_commits_today(self) -> List[Dict]: """Get all commits from today with their metadata.""" try: # Get commits since midnight result = subprocess.run( ['git', 'log', '--since=midnight', '--format=%H|%ai|%s'], cwd=self.directory, capture_output=True, text=True, timeout=10 ) if result.returncode != 0: return [] commits = [] for line in result.stdout.strip().split('\n'): if not line: continue parts = line.split('|', 2) if len(parts) < 3: continue commit_hash = parts[0] date_str = parts[1] message = parts[2] # Parse date try: commit_date = datetime.strptime(date_str[:19], '%Y-%m-%d %H:%M:%S') except ValueError: continue commits.append({ 'hash': commit_hash, 'date': commit_date, 'message': message, 'note': self.get_commit_note(commit_hash) }) return commits except Exception as e: print(f"Error getting commits: {e}") return []
Analyzing individual commits – examining what changed in each save state
This is the detailed investigation. For each commit, it’s like comparing two versions of a document side-by-side: what words were added, what was deleted, which files changed. It’s smart enough to ignore pure file renames and focus only on text files.
Ignoring file renames is important for tracking accurate word counts. For example, if you have file1.org, and it is a thousand words of prose, and then you rename it to file2.org, git detects that two thousand changes were just made (+1000 words added and +1000 words deleted), even though you didn’t actually write anything, you simply renamed a file.
This function performs two git operations: first it checks which files were modified, then it analyzes the word-level changes ignoring pure file renames.
def analyze_commit(self, commit_hash: str) -> Dict: """Analyze a single commit to get word changes.""" try: # Get file status to skip pure renames status_result = subprocess.run( ['git', 'diff-tree', '-M', '--no-commit-id', '--name-status', '-r', commit_hash], cwd=self.directory, capture_output=True, text=True, timeout=5 ) if status_result.returncode != 0: return {'words_added': 0, 'words_deleted': 0, 'files': []} # Parse file statuses files_changed = [] skip_files = set() for line in status_result.stdout.strip().split('\n'): if not line: continue parts = line.split('\t') if not parts: continue status = parts[0] if status == 'R100': # Pure rename, skip it continue if len(parts) >= 2: filename = parts[-1] # Only track text files if Path(filename).suffix.lower() in {'.md', '.org', '.txt', '.draft', '.rst', '.adoc', '.tex'}: files_changed.append(filename) # Get word diff for the commit word_diff_result = subprocess.run( ['git', 'show', '-M', '--word-diff=porcelain', '--format=', commit_hash], cwd=self.directory, capture_output=True, text=True, timeout=5 ) if word_diff_result.returncode != 0: return {'words_added': 0, 'words_deleted': 0, 'files': files_changed} # Parse word diff words_added = 0 words_deleted = 0 current_file = None count_current = False for line in word_diff_result.stdout.split('\n'): if line.startswith('diff --git'): parts = line.split() if len(parts) >= 4: filename = parts[3][2:] if parts[3].startswith('b/') else parts[3] count_current = filename in files_changed elif count_current: if line.startswith('+') and not line.startswith('+++'): words_added += self.count_words(line[1:]) elif line.startswith('-') and not line.startswith('---'): words_deleted += self.count_words(line[1:]) return { 'words_added': words_added, 'words_deleted': words_deleted, 'files': files_changed } except Exception as e: return {'words_added': 0, 'words_deleted': 0, 'files': []}
Calculating your writing streak – counting consecutive days
Like checking your “don’t break the chain” calendar, the classic Jerry Seinfeld technique, the script can keep track of your writing streak as well. It looks back through the last 30 days and counts how many days in a row (working backward from today) you’ve made commits.
def calculate_streak(self) -> int: """Calculate consecutive days with commits.""" try: # Get commits from last 30 days result = subprocess.run( ['git', 'log', '--since=30.days.ago', '--format=%ai'], cwd=self.directory, capture_output=True, text=True, timeout=10 ) if result.returncode != 0: return 0 # Collect unique dates dates = set() for line in result.stdout.strip().split('\n'): if not line: continue try: commit_date = datetime.strptime(line[:10], '%Y-%m-%d') dates.add(commit_date.date()) except ValueError: continue if not dates: return 0 # Count consecutive days backward from today streak = 0 check_date = self.today_start.date() while check_date in dates: streak += 1 check_date -= timedelta(days=1) return streak except Exception: return 0
Formatting relative time – making timestamps human-friendly
This part of the script converts timestamps into more human-readable formats. For example, turning “2026-01-01 14:37:22” into “2h 15m ago”.
def format_time_ago(self, commit_date: datetime) -> str: """Format how long ago a commit was made.""" delta = self.now - commit_date hours = delta.seconds // 3600 minutes = (delta.seconds % 3600) // 60 if delta.days > 0: return f"{delta.days}d {hours}h ago" elif hours > 0: return f"{hours}h {minutes}m ago" elif minutes > 0: return f"{minutes}m ago" else: return "just now"
Text wrapping utility – keeping output tidy
We want the output to be tidy in the terminal. So like text wrapping in your editor, when a commit message is too long for one line, this breaks it at word boundaries so it displays nicely.
def wrap_text(self, text: str, width: int = 70, indent: str = "") -> str: """Wrap text at specified width, breaking at word boundaries.""" if len(text) <= width: return text lines = [] current_line = "" words = text.split() for word in words: # Check if adding this word would exceed the width test_line = current_line + (" " if current_line else "") + word if len(test_line) <= width: current_line = test_line else: # Current line is full, start a new one if current_line: lines.append(current_line) current_line = indent + word # Add the last line if current_line: lines.append(current_line) return "\n".join(lines)
Printing the main summary
This is where everything comes together. First, it handles the simple case: if you haven’t written anything today, it gives you a little encouragement to get started.
def print_summary(self): """Print the daily summary.""" # Get commits commits = self.get_commits_today() if not commits: print("=" * 70) print("📝 DAILY WRITING SUMMARY") print(f"🗓️ {self.now.strftime('%A, %B %d, %Y')}") print("=" * 70) print() print("No commits today yet. Time to start writing! 🚀") print() return
For each commit from today, this runs the detailed analysis to see what changed, then adds everything up to get your total word count for the day.
# Analyze each commit total_words_added = 0 total_words_deleted = 0 all_files = set() for commit in commits: analysis = self.analyze_commit(commit['hash']) commit['words_added'] = analysis['words_added'] commit['words_deleted'] = analysis['words_deleted'] commit['words_net'] = analysis['words_added'] - analysis['words_deleted'] commit['files'] = analysis['files'] total_words_added += analysis['words_added'] total_words_deleted += analysis['words_deleted'] all_files.update(analysis['files']) total_words_net = total_words_added - total_words_deleted
This also figures out the “writing mode” based on your edit ratio—are you drafting new content or polishing what’s already there?
# Calculate session info commits_sorted = sorted(commits, key=lambda x: x['date']) first_commit = commits_sorted[0]['date'] last_commit = commits_sorted[-1]['date'] session_duration = last_commit - first_commit # Calculate edit ratio edit_ratio = (total_words_deleted / total_words_added * 100) if total_words_added > 0 else 0 # Determine writing mode if edit_ratio < 20: mode = "Heavy Drafting 📝" elif edit_ratio < 40: mode = "Light Drafting ✍️" elif edit_ratio < 70: mode = "Balanced Editing ✏️" elif edit_ratio < 100: mode = "Heavy Editing 🔧" else: mode = "Cutting/Tightening ✂️" # Get streak streak = self.calculate_streak()
If you’re like me, you appreciate the importance of having a nicely formatted scoreboard. This prints out all your word statistics:
- how many words you added, deleted,
- your net change, and
- what kind of writing you were doing.
# Print header print("=" * 70) print("📝 DAILY WRITING SUMMARY") print(f"🗓️ {self.now.strftime('%A, %B %d, %Y')} • {self.now.strftime('%I:%M %p')}") print("=" * 70) print() # Print word metrics print("📊 WORD METRICS") print("-" * 70) print(f"Words Added: +{total_words_added:,}") print(f"Words Deleted: -{total_words_deleted:,}") print(f"Net Words: {total_words_net:+,}") print(f"Writing Mode: {mode}") print(f"Edit Ratio: {edit_ratio:.1f}%") print()
This shows the more meta information about your writing session: how many commits, when you started and stopped, how long you worked, and your current streak.
# Print session info print("⏰ SESSION INFO") print("-" * 70) print(f"Total Commits: {len(commits)}") print(f"First Commit: {first_commit.strftime('%I:%M %p')}") print(f"Last Commit: {last_commit.strftime('%I:%M %p')} ({self.format_time_ago(last_commit)})") if session_duration.seconds > 0 or session_duration.days > 0: hours = session_duration.seconds // 3600 minutes = (session_duration.seconds % 3600) // 60 if hours > 0: print(f"Session Duration: {hours}h {minutes}m") else: print(f"Session Duration: {minutes}m") print(f"Files Changed: {len(all_files)}") if streak > 0: streak_emoji = "🔥" if streak >= 7 else "⭐" if streak >= 3 else "✨" print(f"Writing Streak: {streak} day{'s' if streak != 1 else ''} {streak_emoji}") print()
For a more detailed timeline, this goes through each commit chronologically and shows the commit message, word changes, files touched, and any notes you left yourself. This acts as a sort of rapid log for the day’s writing session. It’s a chance to review what you did and make a plan for the next session, if you’d like. This is why it’s important to leave good commit messages for yourself.
# Print commit history print("📜 COMMIT HISTORY") print("-" * 70) for commit in reversed(commits_sorted): time_str = commit['date'].strftime('%I:%M %p') words_str = f"{commit['words_net']:+d}" if commit['words_net'] != 0 else "±0" # Format: [time] message (±words) [files] # Wrap message at 70 characters, accounting for the [time] prefix prefix = f"[{time_str}] " first_line_width = 70 - len(prefix) wrapped_lines = self.wrap_text(commit['message'], width=first_line_width, indent=" " * len(prefix)).split('\n') # Print first line with prefix print(f"{prefix}{wrapped_lines[0]}") # Print continuation lines (already indented) for line in wrapped_lines[1:]: print(line) # Show word change if significant if commit['words_added'] > 0 or commit['words_deleted'] > 0: print(f" └─ {words_str} words (+{commit['words_added']}, -{commit['words_deleted']})") # Show files if any if commit['files']: files_str = ", ".join(commit['files'][:3]) if len(commit['files']) > 3: files_str += f" +{len(commit['files']) - 3} more" print(f" └─ 📄 {files_str}") # Show git note if present if commit.get('note'): note_lines = commit['note'].split('\n') print(f" └─ 📌 {note_lines[0]}") for note_line in note_lines[1:]: print(f" {note_line}") print() print("=" * 70)
The main entry point – running the script
This is the script’s execution point. It checks that you’re in a git repository, creates the summary object, and prints everything out. It also handles interruptions gracefully if you hit Ctrl+C.
def main(): try: # Check if we're in a git repository result = subprocess.run( ['git', 'rev-parse', '--git-dir'], capture_output=True, timeout=2 ) if result.returncode != 0: print("Error: Not in a git repository") sys.exit(1) summary = DailyWritingSummary() summary.print_summary() except KeyboardInterrupt: print("\n\nInterrupted.") sys.exit(0) except Exception as e: print(f"Error: {e}") sys.exit(1) if __name__ == "__main__": main()