Git
19 questions
How to undo a commit in Git?
There are three ways to undo a commit depending on the situation.
If the commit has not been pushed to a shared repository yet, use git reset. The command git reset --soft HEAD~1 undoes the last commit but keeps changes in the staging area — you can edit the message and commit again. The command git reset --mixed HEAD~1 (the default behavior) undoes the commit and removes changes from the index, but keeps them in the working directory. The command git reset --hard HEAD~1 completely removes both the commit and the changes — use it only if you are absolutely sure the code is not needed.
If the commit has already been pushed to a shared repository, use git revert. The command git revert HEAD creates a new commit that undoes the changes of the previous one. This is safe for team collaboration because history is not rewritten — a new undo commit is added. You can revert multiple commits: git revert HEAD~3..HEAD.
If you only need to change the last commit's message or add a forgotten file, use git commit --amend. This command replaces the last commit with a new one with the same content but a different message or additional files. Do not use --amend for commits that have already been pushed — it rewrites history and creates problems for other developers.
Rule: reset — for local commits, revert — for shared, amend — for fixing the last commit before push.
How to resolve a merge conflict in Git?
A conflict occurs when two branches modify the same lines of a file and Git cannot automatically merge the changes. Here is the step-by-step resolution process.
First, run git status to see conflicted files. Git marks conflicts in files with markers: <<<<<<< HEAD (your changes), ======= (separator), and >>>>>>> branch-name (changes from the other branch).
Open the file in your editor and find the conflict markers. You need to choose which changes to keep: yours, theirs, or combine both. Remove the <<<<<<<, =======, and >>>>>>> markers, leaving only the desired code. Save the file.
After resolving all conflicts in a file, run git add <file> for each resolved file. When all conflicts are resolved, run git commit — Git will create a merge commit automatically with a default message.
For complex conflicts, use visual merge tools: git mergetool launches your configured tool (VS Code, Meld, KDiff3). In VS Code, you can resolve conflicts right in the editor — buttons Accept Current, Accept Incoming, Accept Both.
If you want to cancel the merge and return to the state before it, run git merge --abort. This is useful when there are too many conflicts and it's easier to start over.
Tip: to reduce conflicts, frequently rebase or merge from the main branch into your feature branch, and make small commits that touch fewer files.
What is the difference between git merge and git rebase?
Merge and rebase are two ways to combine changes from one branch into another, but they work differently.
git merge creates a new merge commit that combines the histories of both branches. History is preserved fully — you can see when each branch diverged and merged. This is safe for shared branches because it doesn't rewrite history. The downside is that history can become cluttered with many merge commits, especially in large teams. Use merge when combining a feature branch into main or when working with public repositories.
git rebase moves your commits on top of the target branch, creating a linear history. Git takes your commits, undoes them, updates the branch to the target state, and re-applies your commits. History looks clean and straight, without merge commits. The downside is that rebase rewrites history, which is dangerous for shared branches: if someone has already pulled your branch, after rebase their history won't match yours. Use rebase for local feature branches before merging into main.
The golden rule: never rebase public branches (main, develop, or branches that other developers reference). If a branch is already pushed to remote and someone might be working on it — use merge.
Many teams use a hybrid approach: rebase the feature branch before merge, then merge into main with --no-ff (no fast-forward) to preserve the merge point. This gives a clean commit history while still showing which commits belonged to the feature branch.
How to work with branches in Git?
Branches are the foundation of Git. They allow parallel development without interfering with the main line.
Creating a branch: git branch <name> creates a new branch but doesn't switch to it. The command git checkout -b <name> (or git switch -c <name> in newer Git versions) creates a branch and immediately switches to it. To create a branch from a specific commit: git checkout -b <name> <commit-hash>.
Viewing branches: git branch shows local branches, git branch -a shows all including remote. The current branch is marked with an asterisk. The command git branch -v shows the last commit of each branch.
Switching: git checkout <name> or git switch <name>. To switch to a remote branch: git checkout -b <name> origin/<name>.
Deleting a branch: git branch -d <name> deletes a branch, but only if it's merged with the current one. The command git branch -D <name> deletes forcibly, even if there are unmerged changes. To delete a remote branch: git push origin --delete <name>.
Merging: switch to the target branch (git checkout main), then git merge <feature-branch>. If there are no conflicts, Git will create a merge commit or do a fast-forward.
Renaming: git branch -m <new-name> renames the current branch. For remote: rename locally, then git push origin -u <new-name> and delete the old remote branch.
Good practice: name branches by scheme like feature/short-description, bugfix/issue-number, hotfix/description. This helps the team understand the branch's purpose.
What is git stash and how to use it?
git stash saves uncommitted changes to a temporary storage, returning the working directory to the state of the last commit. This is useful when you need to switch to another branch but current changes aren't ready for a commit.
Saving: git stash saves changes in the index and working directory. By default, stash doesn't include untracked files — use git stash -u to include them. You can add a description: git stash push -m "work on auth".
Viewing: git stash list shows all saved stash entries. Each entry looks like stash@{0}, stash@{1}, etc. The command git stash show stash@{0} shows files in a specific entry, and git stash show -p stash@{0} shows the diff.
Restoring: git stash pop applies the latest entry and removes it from the list. The command git stash apply stash@{1} applies an entry but doesn't remove it — useful if you want to apply changes in multiple branches. If pop causes conflicts, the entry is not removed, and you can resolve conflicts manually.
Deleting: git stash drop stash@{0} removes a specific entry. The command git stash clear removes all entries — use with caution.
Practical scenario: you're working on a feature branch, an urgent bug appears in main. You run git stash, switch to main, fix the bug, commit, return to the feature branch and run git stash pop — changes are restored.
Limitation: stash is stored locally and doesn't sync with remote. If you need to transfer changes between machines, better create a temporary branch and commit there.
How to configure .gitignore and what to add?
The .gitignore file tells Git which files and directories to ignore — not track and not include in commits. This is critical for repository cleanliness.
What to add to .gitignore: build files and artifacts (node_modules/, dist/, build/, *.o, *.class), dependency files (vendor/, package-lock.json — depends on team strategy), IDE and editor files (.idea/, .vscode/, *.swp), operating system files (.DS_Store, Thumbs.db), files with secrets (.env, config/local.json, *.pem, *.key), log files (*.log, logs/) and temporary files (*.tmp, *.bak).
Syntax: each line is a rule. An asterisk replaces any number of characters: *.log ignores all log files. A trailing slash means a directory: node_modules/ ignores the entire folder. An exclamation mark cancels ignoring: !important.log will include the file even if *.log ignores it. Double asterisk — recursive path: logs/**/*.tmp ignores .tmp files at any depth inside logs/.
Structure: .gitignore in the repository root applies to the entire project. A .gitignore in a subdirectory adds rules for that directory. A global .gitignore (git config --global core.excludesfile ~/.gitignore_global) applies to all of a user's projects — convenient for OS and IDE files.
Templates: the site gitignore.io (or github.com/github/gitignore) contains ready-made templates for languages and frameworks. For a Node.js project: node_modules/, dist/, .env, .env.local, npm-debug.log*. For Python: __pycache__/, *.pyc, .venv/, .env.
Important: .gitignore doesn't remove files already tracked by Git. If a file is already in the repository, adding it to .gitignore won't help — you need to first run git rm --cached <file>, commit the removal, and only then add it to .gitignore.
How to write good commit messages in Git?
A good commit message helps the team understand history and simplifies debugging. Here are rules and practices.
Message structure: the first line is a brief summary, no more than 50 characters, in the imperative mood: "Add registration form validation", not "Added validation". After a blank line — a detailed description: what changed and why, not how (the code shows how). Description — up to 72 characters per line.
Conventional Commits — a popular formatting standard. Format: type(scope): description. Types: feat (new feature), fix (bug fix), docs (documentation), style (formatting), refactor (refactoring), test (tests), chore (maintenance), perf (performance), ci (CI configuration). Example: feat(auth): add OAuth2 authorization via Google. This allows automatic changelog generation and version management.
Practical rules: one commit — one logical change. Don't mix refactoring and a new feature in one commit. If a change touches multiple files but they're all part of one task — that's one commit. Commit often — small commits are easier to revert and review.
What to avoid: messages like "fix", "update", "wip", "misc changes". The message "fix bug" says nothing — which bug, where, how. Better: "fix(cart): fix total calculation with empty promo code". Don't write messages in past tense — "added" instead of "add".
Multi-line messages: use git commit without -m — an editor will open. Or multiple -m flags: git commit -m "short summary" -m "detailed description in second paragraph".
Pre-commit check: git diff --cached shows staged changes. Review them before committing to avoid accidentally including debug code or temporary files.
How to work with remote repositories (origin, fork) in Git?
A remote repository is a version of your project stored on a server (GitHub, GitLab, Bitbucket). Managing remote repositories is a key skill for team collaboration.
Viewing remotes: git remote -v shows all configured remotes with URLs. By default, the first remote is called origin. The command git remote show origin shows detailed information: branches, tracking settings.
Adding a remote: git remote add <name> <url>. For example, if you forked a project and want to get updates from the original: git remote add upstream https://github.com/author/project.git. Now you can run git fetch upstream to get changes from the original repository.
Removing and renaming: git remote remove <name> removes a remote. The command git remote rename <old> <new> renames it.
Pushing changes: git push origin <branch> sends a local branch to remote. The -u flag (git push -u origin <branch>) sets up tracking — after that, git push and git pull work without specifying remote and branch. The command git push --force forcibly overwrites a remote branch — use only for personal branches after rebase. Safe alternative: git push --force-with-lease — overwrites only if no one else has added commits.
Getting changes: git fetch origin downloads changes but doesn't apply them — you can review them via git log origin/main. The command git pull = git fetch + git merge — downloads and immediately merges. For linear history: git pull --rebase.
Working with forks: typical cycle — fork on GitHub, git clone your fork, git remote add upstream original, create a feature branch, commit, git push origin feature-branch, then pull request via web interface. To sync with the original: git fetch upstream, git checkout main, git merge upstream/main, git push origin main.
How to safely revert changes in team collaboration?
Reverting changes in team collaboration requires caution — an incorrect revert can overwrite someone else's work or create conflicts for the entire team. Here are safe approaches.
git revert — the safest method. Creates a new commit that undoes the changes of a specified commit. History is not rewritten, so it's safe for shared branches. The command git revert <commit-hash> reverts one commit. You can revert a range: git revert HEAD~3..HEAD creates three revert commits. If revert causes conflicts (surrounding code has changed), resolve them as with a regular merge.
git reset — rewrites history, use only for local branches or branches no one else uses. git reset --hard <commit> moves the branch pointer back and removes all changes after that commit. Never do reset --hard on branches pushed to remote if other developers work on them — it creates divergences.
git revert vs git reset: revert adds a new undo commit — safe for everyone. reset removes commits from history — safe only locally. Rule: if a branch is in remote and someone might have pulled — use revert. If the branch is local or you're the only developer — reset is acceptable.
Reverting a push: if you've already pushed unwanted commits, use git revert, then git push. If you need to remove commits from remote (e.g., accidentally pushed secrets), do git reset --hard <last-good-commit>, then git push --force-with-lease. Warn the team — they'll need to git fetch and git reset --hard origin/<branch> because their local history no longer matches.
Recovering accidentally deleted commits: git reflog shows the history of HEAD movements. Find the hash of the needed commit and run git reset --hard <hash> — the commit is restored. Reflog keeps history for 90 days by default.
Golden rule: when in doubt — use revert, not reset. Revert is always safe because it adds, not removes.
How to install and configure Git from scratch?
Installing and configuring Git is the first step to version control. The process depends on the operating system.
Installation: on Windows, download Git for Windows from git-scm.com — the installer includes Git Bash and Git GUI. On macOS, use Homebrew: brew install git, or install Xcode Command Line Tools: xcode-select --install. On Linux (Ubuntu/Debian): sudo apt install git, on Fedora: sudo dnf install git.
Verification: git --version shows the installed version. If the command is not found, restart the terminal or check PATH.
Basic configuration: set your name and email — they'll be in every commit. git config --global user.name "Your Name" and git config --global user.email "you@example.com". Use a real email linked to your GitHub/GitLab account so commits are associated with your profile. Check settings: git config --list.
Setting up SSH keys: instead of a password for every push, use SSH. Create a key: ssh-keygen -t ed25519 -C "you@example.com". Press Enter for the default path. Add the key to ssh-agent: eval "$(ssh-agent -s)" and ssh-add ~/.ssh/id_ed25519. Copy the public key: cat ~/.ssh/id_ed25519.pub and add it to GitHub: Settings → SSH and GPG keys → New SSH key. Verify: ssh -T git@github.com should show "Hi username!".
Useful global settings: git config --global init.defaultBranch main — sets main as the default branch instead of master. git config --global core.editor "code --wait" — uses VS Code as the commit editor. git config --global pull.rebase true — makes pull use rebase instead of merge for linear history.
First repository: create a folder, run git init, create a file, git add ., git commit -m "Initial commit". If using GitHub: git remote add origin git@github.com:username/repo.git, git push -u origin main.
What is the difference between git pull and git fetch?
git fetch and git pull both get changes from a remote repository, but they work differently. Understanding the difference helps avoid unexpected conflicts.
git fetch downloads changes from remote but doesn't apply them to your working directory. Only remote-tracking branches are updated (origin/main, origin/feature, etc.). Your local branch and working files remain untouched. After fetch, you can review changes: git log origin/main, git diff main origin/main. This is safe — you break nothing, just get information about new commits.
git pull = git fetch + git merge. First downloads changes, then immediately merges them with your current branch. If there are new commits in remote and you have local changes, pull will create a merge commit or, on divergence, conflicts. This can be unexpected if you didn't check what exactly changed in remote.
When to use fetch: always, when you want to see what's new in remote before applying. Especially useful before starting work — run git fetch in the morning, check git log origin/main, and if everything is fine, do git merge or git rebase. Also fetch is needed to view others' feature branches without switching to them.
When to use pull: when you're confident there are no unexpected changes in remote and want to update quickly. For example, you're the only developer on a branch or changes are trivial.
Pull with rebase: git pull --rebase instead of merge applies your local commits on top of downloaded ones. This gives linear history without merge commits. You can set it as default: git config --global pull.rebase true. Many teams prefer this option.
Practical tip: use git fetch by habit, and git pull — only when you know exactly what you'll get. This saves you from unexpected conflicts at the wrong moment.
How to view commit history and find a specific commit?
Commit history is the main tool for understanding what and when changed in a project. Git offers several ways to view and search.
Basic viewing: git log shows history in reverse chronological order — latest commits at the top. Each commit contains a hash, author, date, and message. The command git log --oneline shows a compact format — one commit per line, only hash and message. Useful for reviewing large history.
Filtering: git log -5 shows the last 5 commits. git log --author="John" filters by author. git log --since="2 weeks ago" — commits from the last 2 weeks. git log --since="2026-01-01" --until="2026-06-01" — date range. git log -- <file> shows only commits touching a specific file.
Searching by message: git log --grep="auth" searches for commits with a keyword in the message. The -i flag makes the search case-insensitive: git log --grep="auth" -i. You can search with regex: git log --grep="fix\(.*\)".
Visualization: git log --graph --oneline --all shows the branch tree as a graph — useful for understanding merge structure. The --all flag includes all branches, not just the current one. Alternative: git log --graph --oneline --decorate — adds branch and tag labels.
Changes in a commit: git show <hash> shows the full diff of a specific commit. git show HEAD — the latest commit. git show HEAD~2 — the third from the end. The command git diff <hash1>..<hash2> shows the difference between two commits.
Finding a bug with bisect: git bisect start, git bisect bad (current commit is broken), git bisect good <hash> (this commit worked). Git automatically checks out commits in the middle of the range. You test and mark git bisect good or git bisect bad. Git finds the commit that introduced the bug. Finish: git bisect reset.
Blame: git blame <file> shows who wrote each line of a file and in which commit. Useful for understanding the context of changes. git blame -L 10,20 <file> — only lines 10-20.
What is git cherry-pick and when to use it?
git cherry-pick takes changes from a specific commit and applies them to the current branch, creating a new commit with the same changes. This is useful when you need to transfer an individual change without merging the entire branch.
When to use: you made a fix in a feature branch but it's also needed in main — cherry-pick transfers only that commit. Or you accidentally committed to the wrong branch — cherry-pick moves the commit to the correct branch. Also for transferring hotfixes between release branches: cherry-pick a fix from main to release-1.2.
How to use: switch to the target branch (git checkout main), then git cherry-pick <commit-hash>. Git applies the changes from the specified commit and creates a new commit in the current branch. The new commit's hash will be different, but the changes and message are the same. You can transfer multiple commits: git cherry-pick <hash1> <hash2> <hash3> or a range: git cherry-pick <hash1>..<hash2>.
Conflicts during cherry-pick: if the code around the changes differs between branches, a conflict will occur. Resolve it as with a regular merge: edit files, git add, git cherry-pick --continue. To abort: git cherry-pick --abort.
Difference from merge: merge combines the entire branch with all commits. cherry-pick transfers only specified commits. Difference from rebase: rebase moves all commits of a branch on top of another. cherry-pick is a targeted transfer.
Caution: cherry-pick creates a duplicate commit with a new hash. If you later merge the branch from which you cherry-picked, conflicts may arise due to identical changes. Try to cherry-pick only when merge or rebase is impossible or impractical.
Practical scenario: you fixed a critical bug in develop, but it needs to be urgently delivered to main. git checkout main, git cherry-pick <fix-commit-hash>, git push. The fix is in main without merging the entire develop branch.
How to delete a branch locally and in a remote repository?
Deleting branches is a routine task after work is complete. Branches should be deleted both locally and in remote to keep the repository clean.
Local deletion: git branch -d <name> deletes a branch, but only if it's merged with the current one. Git checks that all the branch's commits are in the current branch — if not, it gives an error. This protects against losing work. The command git branch -D <name> deletes forcibly, even if there are unmerged commits. Use -D only if you're sure the changes aren't needed — after deletion, a branch can only be recovered via reflog.
Remote deletion: git push origin --delete <name> deletes a branch on the server (GitHub, GitLab). Shorthand: git push origin :<name>. After deleting a remote branch, other developers will see it as "gone" on their next git fetch.
Deleting tracking branches: after deleting a remote branch, the local tracking copy (origin/<name>) remains. Clean it up: git fetch --prune or git remote prune origin. This removes local references to non-existent remote branches.
Bulk cleanup: git branch --merged main shows branches merged with main — they can be safely deleted. Command to delete all merged branches except main: git branch --merged main | grep -v "main" | xargs git branch -d. Be careful — make sure you don't delete a branch you're working on.
Recovering a deleted branch: git reflog shows the hash of the deleted branch's last commit. Run git branch <name> <hash> — the branch is restored. Reflog keeps history for 90 days, so recovery is possible within that period.
Good practice: delete feature branches immediately after merging into main. This keeps branches clean and eases navigation. On GitHub, you can set up automatic branch deletion after merge: Settings → Options → Automatically delete head branches.
How to undo git add (unstage files)?
If you accidentally added files to the index with git add and want to remove them before committing, there are several ways.
git restore --staged <file> — the modern way (Git 2.23+). Removes a file from the index but keeps changes in the working directory. For example: git restore --staged index.html removes the file from staged, but changes in the file remain. The command git restore --staged . removes all files from the index.
git reset HEAD <file> — the classic way, works in all Git versions. Does the same: removes a file from the index, changes remain. git reset HEAD . removes all files. The command git reset (no arguments) removes everything from the index.
git reset --soft HEAD~1 — if you already committed but want to return changes to the index (uncommit). The commit is undone, changes remain staged. git reset --mixed HEAD~1 (or just git reset HEAD~
Full reset: git reset --hard HEAD removes all changes in the working directory and index — returns to the state of the last commit. Use only if you're sure the changes aren't needed. This is irreversible (except via reflog).
Difference between states: Modified — file changed but not added to the index. Staged — file added via git add, ready to commit. Committed — changes saved in a commit. git add transitions Modified → Staged. git restore --staged transitions Staged → Modified. git commit transitions Staged → Committed.
Practical scenario: you ran git add . and realized you added .env accidentally. Run git restore --staged .env — the file is removed from the index. Add .env to .gitignore to avoid this in the future. If you already committed .env: git rm --cached .env, commit the removal, add to .gitignore.
What to do if you pushed to the wrong branch?
Pushing to the wrong branch is a common mistake. Actions depend on whether you noticed immediately and whether others have already pulled.
Situation
Situation
Situation
Situation
Prevention: set up branch protection on GitHub — forbid direct push to main. Use pull requests for all changes. Before push, check the current branch: git branch — the asterisk shows the current one.
How to squash multiple commits into one?
Squash — combining multiple commits into one. This is useful when you've made many small commits in a feature branch ("wip", "fix typo", "fix again") and want to present them as one clean commit in history.
Via interactive rebase: git rebase -i HEAD~4, where 4 is the number of commits to combine. An editor opens with a list of commits. Each commit is marked with the word pick. Change pick to squash (or just s) for commits you want to combine with the previous one. Leave the first commit as pick — it will be the base. Save and close the editor. A second editor opens for editing the combined commit message — write a clean description of all changes.
Example: you have 4 commits — "feat: add form", "fix: validation", "typo", "style: button". Run git rebase -i HEAD~
After saving, write a new message: "feat: add registration form with validation and styling". You get one commit instead of four.
Via git merge --squash: when merging a feature branch into main, run git merge --squash feature-branch. Git combines all changes but doesn't create a merge commit. Then git commit -m "feat: add registration form" — one commit with all changes. This is simpler but loses the feature branch history.
Via git reset --soft: to combine the last N commits, run git reset --soft HEAD~N. All changes from N commits gather in the index. Then git commit -m "new message" — one commit instead of N. Fast, but doesn't let you choose which commits to combine.
After squash on an already pushed branch: git push --force-with-lease. History is rewritten, so a force-push is needed. Warn the team if the branch is shared.
Good practice: do squash before merging a feature branch into main. This gives a clean main history where each commit is one completed feature, not a series of "wip" and "fix typo".
How to recover a deleted commit or branch in Git?
In Git, almost nothing is permanently deleted. Even after reset --hard or deleting a branch, commits remain in reflog and can be recovered for 90 days.
git reflog — the main recovery tool. Reflog records every HEAD movement: commits, checkout, reset, pull. Run git reflog — you'll see a history with commit hashes and action descriptions. Find the hash of the commit before deletion. An entry like "a1b2c3d HEAD@{2}: reset: moving to HEAD~1" means that at HEAD@{3} was the commit you reset away.
Recovering a commit: git reset --hard <hash> moves the current branch to the specified commit. If you don't want to rewrite the current branch, create a new one: git branch recovered <hash>, then git checkout recovered.
Recovering a deleted branch: git reflog shows the last commit of the branch. Find an entry like "abc1234 branchname@{1}: commit: last commit". Run git branch branchname <hash> — the branch is restored with that commit as HEAD.
Recovering after git reset --hard: if you accidentally ran reset --hard and lost changes, git reflog shows the previous HEAD state. Find the hash before reset and run git reset --hard <hash>. Changes are back.
Recovering after force-push: if someone force-pushed and overwrote your commits in remote, the commits are still in your local reflog. Find the hash via git reflog, create a branch git branch backup <hash>, then git push origin backup. The commits are back in remote.
Recovering uncommitted changes: if you ran git stash drop and lost a stash, git fsck --no-reflog --unreachable shows dangling objects. Find the commit object with your stash changes: git fsck --no-reflog --unreachable | grep commit. Then git stash apply <hash> restores the stash.
Time limits: reflog keeps entries for 90 days for reachable commits and 30 days for unreachable. After that, Git removes objects via git gc. If more than 90 days have passed — recovery is impossible. So check reflog immediately after accidental deletion.
Prevention: before reset --hard or force-push, create a backup branch: git branch backup-$(date +%s). It takes a second and saves you from losing work.
How to set up git hooks for automation?
Git hooks are scripts that run automatically on certain Git events. They help automate code checks, formatting, and tests.
Where hooks are stored: every repository has a .git/hooks folder with sample hooks having a .sample extension. To activate a hook, remove the .sample extension and make the file executable: chmod +x .git/hooks/pre-commit. Hooks are not versioned — they're local to each developer. For shared hooks, use Husky or similar tools.
Client-side hooks: pre-commit runs before a commit. If it returns a non-zero code — the commit is canceled. Use for linting, formatting, secret checking. prepare-commit-msg — before the message editor opens, can generate a template. pre-push — before push, can run tests.
Server-side hooks: pre-receive, update, post-receive — on the server (GitHub, GitLab). Used for permission checks, rejecting pushes that violate rules. On GitHub, configured via GitHub Actions or Branch Protection Rules.
Husky — a popular tool for shared client-side hooks. Installation: npm install --save-dev husky, npx husky init. This creates a .husky/ folder with hooks that are versioned in the repository. All developers get the same hooks automatically. Example .husky/pre-commit: npx lint-staged — runs the linter only for staged files.
lint-staged — a companion for Husky. Configuration in package.json: "lint-staged": {"*.js": "eslint --fix", "*.ts": "tsc --noEmit", "*.css": "prettier --write"}. Runs tools only for files in the index, not the entire project — fast.
pre-commit framework — an alternative to Husky, multilingual. Installation: pip install pre-commit, pre-commit install. Configuration in .pre-commit-config.yaml. Supports hooks in Python, Node, Go, Rust. Has built-in hooks: trailing-whitespace, end-of-file-fixer, check-yaml.
Practical pre-commit hook: check for absence of console.log, check formatting, check for secrets (git-secrets, trufflehog), run unit tests for touched files. If the hook fails — the commit is canceled, the developer sees errors and fixes them.
Tip: don't make hooks too slow. pre-commit should run in seconds, otherwise developers will bypass it (--no-verify). Heavy checks (integration tests, full analysis) — in CI/CD, not in hooks.