Git for Developers: The Workflow, Conflicts, Rebase, and Commands that actually save projects

Git is not just a tool for sending code to GitHub. It is a log of technical decisions, a collaboration mechanism, and the last line of defense against losing work. Many developers know add, commit, and push, but get stuck when conflicts, rebase, undoing changes, or multiple branches enter the picture. In practice, those situations separate someone who "uses Git" from someone you can trust with production code.

📋 Table of Contents

🧠 Mental Model: What Git Actually Stores

Git stores a series of project snapshots. A commit is a point in history: a set of changes, an author, a date, a message, and a reference to the previous commit.

The three most important areas:

  • Working tree - files you are currently editing
  • Staging area - changes prepared for the next commit
  • Repository - commit history

That is why git add and git commit are separate steps. git add says: "these exact changes belong in the next commit." git commit writes that package into history.

# terminal
git status
git add app/Http/Controllers/PostController.php
git commit -m "feat: add post publishing endpoint"

A good developer does not commit by accident. They first inspect what changed, then choose a logical package of changes, and only then write a commit.

⚙️ First Configuration

Start by setting the commit author:

# terminal
git config --global user.name "Dominik Jasinski"
git config --global user.email "[email protected]"

Set the default branch name:

# terminal
git config --global init.defaultBranch main

Set a useful editor:

# terminal
git config --global core.editor "code --wait"

Add a readable log alias:

# terminal
git config --global alias.lg "log --oneline --graph --decorate --all"

Now:

# terminal
git lg

shows branch history in a form you can quickly scan.

📦 Daily Workflow

A typical workday looks like this:

# terminal
git switch main
git pull --ff-only
git switch -c feat/post-drafts

git pull --ff-only is intentionally cautious. It updates the branch only when Git can fast-forward. If your local history diverged from remote, Git stops instead of creating an accidental merge commit.

After making changes:

# terminal
git status
git diff
git add app/ tests/
git diff --staged
git commit -m "feat: add draft status for posts"
git push -u origin feat/post-drafts

The order matters:

  • git status - what changed?
  • git diff - what exactly did I change?
  • git add - which changes do I want to save?
  • git diff --staged - what will enter the commit?
  • git commit - save a logical package
  • git push - send the branch to remote

The most common junior mistake is committing everything without reading the diff. The most common mid-level mistake is creating commits that are too large to review or revert cleanly.

🌿 Branches: Work Without Chaos

A branch is a pointer to a commit. It is not a copy of the project. It is a lightweight marker saying: "this line of work continues here."

Good branch names:

# terminal
feat/post-drafts
fix/login-rate-limit
refactor/order-checkout-action
docs/k3s-ssh-keys
chore/update-dependencies

Bad branch names:

# terminal
new
fix
changes
dominik
test2

A branch should explain the problem it solves. If you cannot name it clearly, the scope is probably unclear.

Basic commands:

# terminal
git branch
git switch feat/post-drafts
git switch -c fix/api-validation
git branch -d fix/api-validation

git branch -d deletes a local branch only if it has been merged. It is safer than -D, which deletes it forcefully.

✍️ Commits: Small, Descriptive, Reversible

A good commit has one responsibility. If you change form validation, a database migration, CSS, and CI configuration in one commit, the reviewer cannot see your intent.

Good commit:

# terminal
git commit -m "fix: validate post slug uniqueness per tenant"

Weak commit:

# terminal
git commit -m "fix"
git commit -m "changes"
git commit -m "update"
git commit -m "wip"

Practical format:

# commit message
type: short imperative description

Common types:

  • feat - new feature
  • fix - bug fix
  • refactor - structure change without behavior change
  • test - tests
  • docs - documentation
  • chore - project maintenance

Examples:

# terminal
git commit -m "feat: add article publishing workflow"
git commit -m "fix: prevent duplicate slugs"
git commit -m "refactor: extract order total calculator"
git commit -m "test: cover failed payment retry"
git commit -m "docs: explain SSH key setup for k3s"

If a commit is small, it is easy to:

  • review
  • undo with git revert
  • move with cherry-pick
  • understand months later

🔀 Merge vs Rebase

merge joins two histories and creates a merge commit when needed.

# terminal
git switch main
git merge feat/post-drafts

rebase moves your commits to the end of another branch, creating a cleaner linear history.

# terminal
git switch feat/post-drafts
git fetch origin
git rebase origin/main

Practical rule:

  • Merge is good for integrating an accepted PR into main
  • Rebase is good for refreshing your own branch before opening or updating a PR
  • Do not rebase a public branch that other people are using

Why? Rebase rewrites history. If someone based their work on your old commits, rebasing creates confusion.

Safe workflow:

# terminal
git switch feat/post-drafts
git fetch origin
git rebase origin/main
git push --force-with-lease

--force-with-lease is safer than --force because it will not overwrite someone else's remote changes if the remote changed after your last fetch.

💥 Conflicts: Resolve Them Without Panic

A conflict means Git cannot automatically combine two changes in the same part of a file.

Example:

# app/Services/PostTitleFormatter.php
<<<<<<< HEAD
return Str::headline($title);
=======
return Str::title(trim($title));
>>>>>>> feat/post-drafts

You must decide which version is correct or combine both:

// app/Services/PostTitleFormatter.php

declare(strict_types=1);

namespace App\Services;

use Illuminate\Support\Str;

final class PostTitleFormatter
{
    public function format(string $title): string
    {
        return Str::headline(trim($title));
    }
}

After resolving the conflict:

# terminal
git status
git add app/Services/PostTitleFormatter.php
git rebase --continue

If the conflict happened during merge:

# terminal
git add app/Services/PostTitleFormatter.php
git commit

If things get out of control:

# terminal
git rebase --abort

or:

# terminal
git merge --abort

That is not failure. It is a safe exit that returns you to the state before the operation.

↩️ Undoing Changes Without Destroying History

Git has several ways to undo changes. The key is choosing the right command for the situation.

Discard uncommitted changes in a file:

# terminal
git restore app/Models/Post.php

Remove a file from the staging area, but keep changes in the working tree:

# terminal
git restore --staged app/Models/Post.php

Fix the last commit:

# terminal
git add tests/Feature/PostTest.php
git commit --amend

Use --amend only before someone else bases work on that commit.

Safely undo a commit that is already on remote:

# terminal
git revert <COMMIT_SHA>

revert creates a new commit that reverses the changes. It does not delete history, so it is safe for shared branches.

Move one commit from another branch:

# terminal
git cherry-pick <COMMIT_SHA>

This is useful when a fix from one branch must be moved quickly to another, for example from develop to main.

Avoid git reset --hard unless you know exactly what you are doing. It removes local working tree changes.

🔐 What You Should Never Commit

In Laravel and Node projects, pay special attention to secrets and generated directories.

Example .gitignore:

# .gitignore
/vendor
/node_modules
/.env
/.env.*
!.env.example
/storage/*.key
/storage/logs/*.log
/bootstrap/cache/*.php
/public/build
/public/hot
/.phpunit.cache
/.idea
/.vscode

Never commit:

  • .env
  • private SSH keys
  • API tokens
  • database passwords
  • secret.yaml files
  • vendor/
  • node_modules/
  • build artifacts if CI builds them

If a secret already entered the repository, deleting it in the next commit is not enough. The secret still exists in history. Rotate it immediately.

🧰 What Fork and GitKraken Do Under the Hood

GUIs like Fork, GitKraken, Sourcetree, and GitHub Desktop do not perform magic. Most buttons are convenient wrappers around Git commands. Knowing the commands matters because when a GUI shows a strange state, the terminal usually explains it faster.

Stage / unstage a file

# terminal
git add app/Models/Post.php
git restore --staged app/Models/Post.php

Stage a selected part of a file

In a GUI you click a single hunk. In the terminal:

# terminal
git add -p app/Models/Post.php

This is one of the most important commands for clean commits. It lets you split changes from one file into several logical commits.

Discard changes

In a GUI this is usually a "Discard" button. Under the hood:

# terminal
git restore app/Models/Post.php

Warning: this removes local uncommitted changes in that file.

Amend the last commit

When a GUI offers "Amend" or "Modify last commit":

# terminal
git add app/Models/Post.php
git commit --amend

This rewrites the last commit. Safe before push, risky after push.

Stash: put changes aside

stash is useful when you need to switch branches, pull changes, or inspect something without committing unfinished work.

# terminal
git stash push -m "wip: post filters"

List stashes:

# terminal
git stash list

Inspect a stash:

# terminal
git stash show -p stash@{0}

Apply and remove the stash from the stack:

# terminal
git stash pop

Apply without removing the stash:

# terminal
git stash apply stash@{0}

Drop a specific stash:

# terminal
git stash drop stash@{0}

Create a branch from a stash:

# terminal
git stash branch wip/post-filters stash@{0}

The difference between pop and apply matters:

  • pop applies the stash and removes it if the operation succeeds
  • apply applies the stash but keeps it in the list

If a stash may cause conflicts, start with apply.

Fetch, pull, push

GUIs often show "Fetch", "Pull", and "Push" next to each other, but they are not the same:

# terminal
git fetch origin

fetch downloads remote information but does not change your branch.

# terminal
git pull --ff-only

pull is effectively fetch plus integrating changes into the local branch.

# terminal
git push origin feat/post-drafts

push sends your local commits to remote.

Reset branch to a specific commit

GUIs often offer "Reset current branch to this commit". Under the hood there are three different modes:

# terminal
git reset --soft <COMMIT_SHA>
git reset --mixed <COMMIT_SHA>
git reset --hard <COMMIT_SHA>

The difference:

  • --soft moves history back but keeps changes staged
  • --mixed moves history and staging back but keeps files changed
  • --hard moves history back and removes local file changes

--hard is destructive. Use it only when you are sure nothing local is needed.

Revert commit

In a GUI, "Revert" usually means:

# terminal
git revert <COMMIT_SHA>

This is the safe way to undo changes already pushed to remote, because it creates a new commit that reverses the old one.

Cherry-pick

In a GUI you drag or choose "Cherry-pick commit". In the terminal:

# terminal
git cherry-pick <COMMIT_SHA>

Useful when a fix from one branch must land on another, for example a hotfix on main.

Tags

GUIs let you create release tags. Under the hood:

# terminal
git tag v1.4.0
git push origin v1.4.0

A better release tag is an annotated tag:

# terminal
git tag -a v1.4.0 -m "Release v1.4.0"
git push origin v1.4.0

Blame: who changed this line and why

GUIs show the author of a line. In the terminal:

# terminal
git blame app/Services/PostPublisher.php

Even better, combine it with file history:

# terminal
git log --follow -- app/Services/PostPublisher.php

blame is not for finding someone to blame. It is for finding decision context.

Bisect: find the commit that introduced a bug

Few developers use bisect, but it is one of Git's most practical features. If you know a bug exists now but did not exist a week ago:

# terminal
git bisect start
git bisect bad
git bisect good <OLD_GOOD_COMMIT_SHA>

Git will move you between commits. After each check, say:

# terminal
git bisect good

or:

# terminal
git bisect bad

At the end, Git points to the commit that most likely introduced the problem. Finish with:

# terminal
git bisect reset

Worktree: multiple branches at once without stashing

If you often jump between a feature, hotfix, and review, git worktree can be better than stash.

# terminal
git worktree add ../project-hotfix main

This creates a second working directory for the same repository, but on another branch. You can have a separate folder for a hotfix without disturbing current work.

List worktrees:

# terminal
git worktree list

Remove one:

# terminal
git worktree remove ../project-hotfix

Reflog: recovery after a bad rebase/reset

GUIs rarely expose reflog, but it often saves the day. Git records where HEAD recently pointed.

# terminal
git reflog

If a commit disappeared after rebase or reset, find it in reflog and recover it:

# terminal
git switch -c rescue-branch <COMMIT_SHA>

Reflog is local. Do not treat it as backup, but remember it exists before assuming work is lost.

🧹 Git Noise: Permissions, Line Endings, and Local Files

Sometimes git status shows changes even though you did not touch application logic. The usual causes are file permissions, line endings, filename casing, and local environment configuration.

1. Git shows file permission changes

On macOS, Linux, WSL, or when working through Docker/VMs, Git may see a file mode change such as 100644 -> 100755 even though the file content did not change.

Inspect the diff:

# terminal
git diff --summary

If you only see a mode change:

# terminal
mode change 100644 => 100755 app/Services/PostService.php

you can disable file mode tracking for this repository:

# terminal
git config core.fileMode false

This sets the option locally for the project. Do not blindly set it globally, because sometimes the executable bit matters, for example for scripts:

# terminal
chmod +x scripts/deploy.sh
git add scripts/deploy.sh
git commit -m "chore: make deploy script executable"

If a file should be executable, commit that change. If Git is only noisy because of the filesystem, use core.fileMode false.

2. Line endings: CRLF vs LF

Windows usually uses CRLF, while Linux/macOS use LF. Without configuration, you can get a huge diff that looks like the entire file changed.

Add .gitattributes:

# .gitattributes
* text=auto

*.php text eol=lf
*.js text eol=lf
*.ts text eol=lf
*.css text eol=lf
*.md text eol=lf
*.sh text eol=lf

After adding it, normalize files:

# terminal
git add --renormalize .
git status

Make that a separate commit:

# terminal
git commit -m "chore: normalize line endings"

Do not mix line-ending normalization with refactoring, because the review becomes unreadable.

3. Filename casing changes

On macOS the default filesystem is often case-insensitive, so renaming postservice.php to PostService.php may not be detected correctly.

Use git mv through a temporary name:

# terminal
git mv app/Services/postservice.php app/Services/postservice.tmp
git mv app/Services/postservice.tmp app/Services/PostService.php
git commit -m "fix: correct PostService filename casing"

This matters when deploying to Linux, where filename casing is significant.

4. Local configuration you do not want to commit

Sometimes you need to change a config file locally, but you do not want Git to keep showing it in status.

For files already tracked by Git, you can use:

# terminal
git update-index --skip-worktree config/local.php

Restore normal tracking:

# terminal
git update-index --no-skip-worktree config/local.php

This is an escape hatch, not a default workflow. A better pattern is to commit an example file:

# structure
.env.example
docker-compose.example.yml

and keep local variants outside Git.

5. Tool-generated files

If tests or your IDE create new files, do not add them automatically. First check whether they belong in .gitignore:

# terminal
git status --ignored

Common noise:

  • .phpunit.cache
  • .pest
  • .idea
  • .vscode
  • storage/logs/*.log
  • npm-debug.log
  • coverage/

Rule of thumb: if a file is reproducible, local, or machine-specific, it usually should not be committed.

🚀 Pull Request Workflow

A good PR starts before opening GitHub.

Before pushing:

# terminal
git status
git diff --staged
composer test
npm run build

If the project uses Pint/PHPStan/Pest:

# terminal
./vendor/bin/pint --test
./vendor/bin/phpstan analyse
./vendor/bin/pest

A PR description should answer three questions:

<!-- .github/pull_request_template.md -->
## What does this PR change?

## How did I test it?

## Risks / review notes

Good description:

<!-- .github/pull_request_template.md -->
## What does this PR change?
Adds draft/published status for articles and blocks public access to drafts.

## How did I test it?
- `./vendor/bin/pest --filter PostPublishingTest`
- manually checked `GET /api/v1/posts`

## Risks / review notes
The migration adds `draft` as the default status, so existing records must be published with a separate script.

Worst PR description:

<!-- .github/pull_request_template.md -->
fix

Review starts with context. If the reviewer has to guess why the PR exists, you waste their time and increase the risk of a bad review.

✅ Conclusion

Git gets simpler when you treat it as a quality control tool, not just transport to GitHub:

  • read git status and git diff before every commit
  • keep commits small, descriptive, and reversible
  • name branches after the problem they solve
  • use rebase for your own branches, merge for integrating team work
  • resolve conflicts deliberately, then run tests
  • undo public history with git revert, not by deleting commits
  • never commit secrets; if they leak, rotate them immediately
  • understand what the GUI does under the hood: stash, reset, cherry-pick, tags, blame, bisect, and reflog
  • eliminate noise from permissions, line endings, and local files before it reaches a PR
  • a PR should explain the change, tests, and risks

A good Git workflow does not automatically make you senior. But a bad Git workflow quickly shows that the team cannot trust your changes without extra supervision.


Follow me on LinkedIn for more practical developer tips! Which Git topic is hardest for you: rebase, conflicts, or undoing changes? Let me know in the comments!

Comments (0)
Leave a comment

© 2026 All rights reserved.