Every journaling app I tried either suffered from feature bloat or I'd simply forget to use it. After trying and abandoning several apps, I realized the problem wasn't motivation; it was friction. So I built journalot, a terminal-based journaling tool that leverages git for version control and syncing. Here's how it works under the hood.
The Core Problem: Reducing Friction to Zero
The biggest barrier to digital journaling isn't lack of discipline, it's context switching. Opening a separate app, waiting for sync, dealing with unfamiliar keybindings. Each friction point compounds.
My solution: meet developers where they already are. In the terminal, with their preferred editor, using tools they already trust...
Every journaling app I tried either suffered from feature bloat or I'd simply forget to use it. After trying and abandoning several apps, I realized the problem wasn't motivation; it was friction. So I built journalot, a terminal-based journaling tool that leverages git for version control and syncing. Here's how it works under the hood.
The Core Problem: Reducing Friction to Zero
The biggest barrier to digital journaling isn't lack of discipline, it's context switching. Opening a separate app, waiting for sync, dealing with unfamiliar keybindings. Each friction point compounds.
My solution: meet developers where they already are. In the terminal, with their preferred editor, using tools they already trust.
Implementation Details
1. Smart Editor Detection
Rather than forcing a specific editor, journalot follows a priority chain:
get_editor() { if [ -n "$EDITOR" ]; then echo "$EDITOR" elif command -v code &> /dev/null; then echo "code" elif command -v vim &> /dev/null; then echo "vim" elif command -v nano &> /dev/null; then echo "nano" else error_exit "No suitable editor found. Please set EDITOR environment variable." fi }
This respects the user's $EDITOR
environment variable first (standard Unix convention), then falls back to common editors in order of popularity. No configuration required for 90% of users.
2. Change Detection: Only Commit When Necessary
The biggest technical challenge was detecting whether the user actually wrote anything. Simply opening and closing an editor shouldn't create a commit.
# Capture file hash before editing BEFORE_HASH=$(md5sum "$FILENAME" 2>/dev/null || md5 -q "$FILENAME" 2>/dev/null) # Open editor (blocks until closed) $EDITOR_CMD "$FILENAME" # Check if content changed AFTER_HASH=$(md5sum "$FILENAME" 2>/dev/null || md5 -q "$FILENAME" 2>/dev/null) if [ "$BEFORE_HASH" != "$AFTER_HASH" ]; then # Only prompt for commit if file was modified git add "$FILENAME" git commit -m "Journal entry for $ENTRY_DATE" fi
This uses MD5 hashing to detect actual changes. The dual command syntax (md5sum
for Linux, md5 -q
for macOS) ensures cross-platform compatibility without external dependencies.
3. Quick Capture: Appending Without Opening an Editor
For fleeting thoughts, opening an editor is still too much friction. The quick capture feature lets you append directly:
journal "Had a breakthrough on the authentication bug"
Implementation:
quick_capture() { local text="$1" local timestamp=$(date '+%H:%M') local filename="$JOURNAL_DIR/entries/$ENTRY_DATE.md" # Create file with header if it doesn't exist if [ ! -f "$filename" ]; then echo "# $ENTRY_DATE" > "$filename" echo "" >> "$filename" fi # Append timestamped entry echo "" >> "$filename" echo "**$timestamp** - $text" >> "$filename" }
This creates the file if needed, ensures proper markdown formatting, and timestamps each quick capture. Multiple quick captures on the same day append to the same file.
4. Git-Based Sync: Cross-Device Without a Backend
Instead of building a sync service, journalot leverages git. Every journal entry is a commit. Syncing across devices is just git push
and git pull
.
Auto-sync on entry open:
if git remote get-url origin &> /dev/null; then echo "Syncing with remote..." if ! git pull origin main --rebase 2>/dev/null; then warn_msg "Failed to pull from remote. Continuing with local changes..." fi fi
This pulls before opening an entry to minimize conflicts. Using --rebase
keeps history linear. If the pull fails (no internet, conflicts), it warns but continues; offline-first by default.
For push, there's an optional AUTOSYNC=true
config flag. Without it, you're prompted after saving:
if [ "$AUTOSYNC" = "true" ]; then git add "$FILENAME" git commit -m "Journal entry for $ENTRY_DATE" git push origin main else echo -n "Commit changes? (y/n): " read -r commit_response # ... prompt for commit and push fi
5. Search: Just Grep
No indexing. No database. Just grep:
search_entries() { local query="$1" grep -i -n -H "$query" "$JOURNAL_DIR/entries"/*.md 2>/dev/null | while IFS=: read -r file line content; do local filename=$(basename "$file") echo "$filename:$line: $content" done }
grep -i
for case-insensitive search, -n
for line numbers, -H
to show filenames. Results are formatted as filename:line: content
, familiar to anyone who's used grep in a codebase.
For 1000+ entries, this is still near-instant on modern hardware. And since entries are dated (YYYY-MM-DD.md), you can narrow searches: grep "bug fix" entries/2025-*.md
.
6. Date Parsing for Flexibility
Opening yesterday's entry is a single flag:
journal --yesterday
Implementation handles macOS/Linux differences:
# macOS uses -v flag, Linux uses -d ENTRY_DATE=$(date -v-1d '+%Y-%m-%d' 2>/dev/null || date -d 'yesterday' '+%Y-%m-%d' 2>/dev/null)
The 2>/dev/null
silences errors from the wrong syntax, letting the ||
fallback succeed. For specific dates:
journal --date 2025-01-15
This just sets ENTRY_DATE
to the provided string. Date validation happens naturally; if the date format is wrong, the filename is malformed and the user notices immediately.
Architecture Decisions
Why Bash?
- Zero dependencies: Works on any Unix-like system with git
- No installation friction: Just copy to
/usr/local/bin
- Transparent: Users can read the entire implementation in 638 lines
- Fast startup: No runtime initialization, no package loading
Why Plain Markdown?
- Longevity: Markdown will outlive any proprietary format
- Composability: Pipe to other tools (
grep
,wc
,sed
) - Portability: Open in any editor, render on GitHub, convert with pandoc
- Version control: Git diffs on plain text work beautifully
File Structure
~/journalot/ โโโ entries/ โ โโโ 2025-01-01.md โ โโโ 2025-01-02.md โ โโโ 2025-01-03.md โโโ .git/
Each entry is a separate file named by date (YYYY-MM-DD.md). This means:
- Easy to locate any day's entry
ls
shows your journaling frequency at a glance- Week/month views are just shell globs
- Archive old years by moving files
My Results After 9 Months
I've maintained a daily journaling habit for the first time (aside from my physical journal). The key metrics:
- Average time to first word written: ~3 seconds (type
journal
, hit enter, start typing) - Entries missed: ~15 days out of 270 (mostly travel without laptop)
- Total words written: 47,000+
What made it stick:
- Zero cognitive load: No app to open, no account to log into, no "should I journal?" friction
- Editor mastery: Vim keybindings, spell-check, snippets: everything I've already trained my fingers for
- Trust in durability: Git history means nothing is lost, entries are future-proof plain text
Try It
git clone https://github.com/jtaylortech/journalot.git cd journalot sudo ./install.sh journal
GitHub: https://github.com/jtaylortech/journalot
The entire codebase is 638 lines of bash, MIT licensed. No telemetry, no accounts, no cloud lock-in.
What I'm adding next (feedback welcome):
- End-to-end encryption for git remotes (currently relies on repo privacy)
- Conflict resolution helper for simultaneous edits on multiple devices
- iOS companion app with SSH git push (for journaling without a laptop)
What would make this more useful for you?