Daily writing summary Python script

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()