Back to Journal

Cleaning up a messy dev environment

Listen to this articleAI narration
0:00 / 0:00

Not cloud infrastructure—the local kind. The kind that accumulates cruft over months of quick fixes and "I'll clean this up later." The development machine needed attention.

The goal: transform a functional-but-messy setup into something intentional. Git config that actually helps. Shell aliases that save keystrokes. Automation for the boring stuff.

The Starting Point

The existing Git configuration was minimal—name, email, LFS settings, and a pull strategy. Everything else ran on defaults. Functional, but Git offers dozens of settings that eliminate friction. We weren't using any of them.

BEFORE5 config linesNo aliasesDefault behaviorsAFTER70+ config lines15 aliasesIntentional defaultsOptimization

Push Configuration: The Silent Time-Saver

Every new branch required explicitly setting the upstream tracking relationship. That extra flag on every first push adds up over hundreds of branches.

Two configuration settings eliminate this friction entirely. The first setting automatically creates the upstream tracking relationship on the first push of any new branch—no more remembering the upstream flag. The second ensures pushes go to a branch of the same name on the remote, which is the intended behavior ninety-nine percent of the time.

Branch Push FlowCreate branchcheckout -b featMake commitsRegular workflowPushJust pushDoneWhat auto-setup does automatically:1. Detects no upstream exists2. Creates remote tracking branch3. Pushes commits to remoteNo more upstream flags requiredNo more "no upstream branch" errorsJust push and it works

Fetch with Prune: Keeping References Clean

Remote branches get deleted all the time—merged features, abandoned experiments, cleaned-up hotfixes. But locally, their references linger forever. After a few months, listing remote branches becomes archaeology, showing dozens of branches that no longer exist on the server.

A single configuration setting fixes this. With prune enabled on fetch, every fetch operation automatically removes local references to deleted remote branches. The branch list stays honest and reflects reality.

Merge Conflict Resolution: Understanding the Original

Standard merge conflicts show two versions—yours and theirs. But what was the original? Without that context, resolving conflicts becomes guesswork. Did both sides change the value, or just one? What was it before?

Merge Conflict StylesStandard (2-way)<<<<<<< HEADconst timeout = 5000;=======const timeout = 10000;>>>>>>> featureWhat was the original value?No way to know.diff3 (3-way)<<<<<<< HEADconst timeout = 5000;||||||| originalconst timeout = 3000;=======const timeout = 10000;>>>>>>> featureOriginal was 3000. Full context.

The diff3 conflict style adds the original version to every conflict marker. Now you can see that main changed 3000 to 5000 while the feature branch changed 3000 to 10000. The merge decision becomes informed rather than guessed.

Git also offers a feature called rerere—"reuse recorded resolution." When enabled, Git remembers how you resolved conflicts. If the same conflict appears again during rebasing or cherry-picking, Git applies your previous resolution automatically. It's like merge conflict muscle memory.

Git Aliases: Reducing Keystrokes

Typing full Git commands thousands of times adds up. Aliases compress common operations into shorter forms. Status becomes a two-character command. Checkout shortens to two letters. The log command with useful formatting options becomes a single short alias.

Alias EfficiencyBefore: Full Commandsgit statusgit checkout -b featuregit log --oneline -10git commit --amend --no-editAfter: Aliasesgit sgit co -b featuregit lggit amend

The alias collection includes quick shortcuts for daily operations, logging with visual branch graphs, undo helpers for common mistakes, diff shortcuts for staged and unstaged changes, and stash management commands.

One particularly useful alias handles branch cleanup. It's a shell command that lists all branches merged into the current branch, filters out protected branches like main and develop, then deletes each merged branch. After a release, one command removes all the feature branches that made it to production.

The Visual Log

The log alias produces a compact visual branch history. The graph flag draws the branch structure with ASCII art. The decorate flag shows branch and tag names at relevant commits. The oneline flag keeps each commit to a single line. Limiting to recent history keeps output manageable—you rarely need the full history in a terminal.

The result shows merges, branches, and the overall shape of recent development at a glance. Much more useful than the default wall of text.

Global Gitignore: Stop Committing System Files

Every Mac developer has accidentally committed .DS_Store at some point. Every single one. These operating system files have no business in repositories.

Gitignore LayersGlobal Gitignore.DS_Store, ._* filesIDE directoriesEnvironment filesnode_modules/Developer environment filesApply to ALL repositoriesProject GitignoreGenerated filesCoverage reportsBuild artifactsTemporary directoriesProject-specific filesCommitted with the repo

The global gitignore catches files that should never be committed regardless of project: macOS system files, IDE configuration directories, environment variable files, and dependency folders. Project-level gitignore files handle project-specific exclusions. Separation of concerns keeps both clean.

Shell Aliases: Beyond Git

Git aliases help within Git, but shell aliases complete the picture. These live in the shell configuration and cover navigation, development commands, and utilities.

Navigation aliases jump directly to frequently-used project directories. Development aliases shorten package manager commands—install, dev, build all become two-character operations. Docker Compose commands compress similarly.

One particularly useful pattern: shell aliases that reference the current Git branch dynamically. The push alias automatically pushes the current branch to origin, using shell substitution to get the branch name. No typos, no accidentally pushing the wrong branch.

Functions for Complex Operations

Aliases work well for simple command substitution but can't handle arguments elegantly. Shell functions fill that gap.

Shell Function: fresh()Switch to maincheckout mainPull latestgit pullCreate branchcheckout -b [name]Ready to codeClean statefresh feature-auth → All three steps, one command

A function called "fresh" takes a branch name as an argument and executes three operations: switch to main, pull the latest changes, then create and switch to the new branch. Starting any feature from a clean, updated state becomes a single command.

Other useful functions include a quick commit helper that stages all changes and commits with a provided message, a port killer that finds and terminates whatever process is using a specified port, and a project opener that changes to a project directory and launches the editor in one step.

GitHub CLI: The Missing Piece

Git handles local operations beautifully. But GitHub operations—creating pull requests, checking CI status, reviewing issues—still required opening a browser.

The GitHub CLI bridges this gap. After authentication, common GitHub operations happen from the terminal: create pull requests with titles and descriptions, list open PRs, check CI and review status, checkout PR branches locally for testing, view and manage issues.

The workflow becomes seamless: create a branch with the fresh function, make changes, commit with the quick commit helper, push with the branch-aware alias, create a PR with the GitHub CLI. No browser until code review.

SSH Configuration

GitHub authentication worked, but SSH configuration was missing optimizations. Two settings eliminate passphrase prompts by integrating with the macOS keychain—enter the passphrase once after restart, and the system handles it from then on.

A keep-alive setting prevents dropped connections during long operations. Sending a signal every sixty seconds ensures the connection stays open during large pushes or fetches.

Automated Maintenance

The final piece: automated cleanup that runs weekly. Package manager caches grow indefinitely—yarn, npm, and pnpm all accumulate data over time. Homebrew keeps old versions around. IDE logs pile up. Xcode's derived data becomes enormous.

A cleanup script handles all of this: prunes package manager caches, cleans up Homebrew, removes old logs, and deletes stale Xcode build data. Scheduled to run weekly via the system's job scheduler, it keeps the machine lean without requiring manual intervention.

The Complete Picture

Developer Environment ArchitectureGit ConfigurationGlobal config fileGlobal gitignorePush/fetch/merge settings15 aliasesShell EnvironmentZsh configurationCustom aliases fileNavigation shortcutsDev command aliasesToolsGitHub CLISSH configVS Code integrationHomebrew packagesAutomationWeekly cleanup via schedulerCache pruning, log rotationProject OrganizationOrganized directory structureProjects, tools, clients foldersResult: Consistent, maintainable, fast development environment

Key Takeaways

Lessons Learned1Git configuration is documentation—make it intentional2Aliases compound—each saved keystroke matters over thousands3Automate maintenance—caches grow silently without cleanup4GitHub CLI closes the loop—Git without browser switching is faster

The investment was a few hours of focused setup. The return is every workday after—smoother operations, fewer keystrokes, less friction. A development environment should help you move fast, not slow you down with accumulated cruft.