From 83f37b3a62a503515232f34ca6aef3abedbfa57e Mon Sep 17 00:00:00 2001 From: Iwan Kelaiah Date: Fri, 4 Sep 2026 08:08:39 +1000 Subject: [PATCH] fix(repo): release v1.4.1 trust and correctness --- .github/workflows/ci.yml | 23 ++++ CHANGELOG.md | 17 +++ README.md | 7 +- package.json | 7 +- scripts/panic-validator.mjs | 12 ++ scripts/validate.mjs | 7 ++ src/data.js | 5 +- src/panic-data.js | 80 +++++++------ src/panic.js | 4 +- src/styles.css | 6 + src/version.js | 2 +- tests/git-integration.test.mjs | 202 +++++++++++++++++++++++++++++++++ tests/panic-data.test.mjs | 44 +++++++ tests/validator.test.mjs | 6 + 14 files changed, 377 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/git-integration.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..93a21e2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Confirm Git is available + run: git --version + - name: Run complete test suite + run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e00f0a..c690014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.4.1] - 2026-09-04 + +### Fixed + +- Corrected Panic Recovery guidance for staged wrong-branch commits, reflog-based recovery, force-push overwrites, pull choices, and operation aborts. +- Removed misleading retention promises and tightened safety/reversibility labels for history rewriting and recovery that depends on temporary Git objects. + +### Added + +- Added dependency-free real-Git integration tests covering recovery, conflicts, branch deletion, and rejected pushes. +- Added GitHub Actions CI for the complete test suite. + +### Changed + +- Made mutually exclusive pull and abort commands visibly alternative choices in Panic Recovery. +- Hardened version and recovery-data validation. + ## [1.4.0] - 2026-05-23 ### Changed diff --git a/README.md b/README.md index 65fd4e1..4b6b0ed 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [Live Demo](https://ikelaiah.github.io/git-map/) [GitHub Repository](https://github.com/ikelaiah/git-map) -[![Version](https://img.shields.io/badge/Version-1.4.0-2f6fed?style=for-the-badge)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-1.4.1-2f6fed?style=for-the-badge)](CHANGELOG.md) [![License: MIT](https://img.shields.io/badge/License-MIT-34d399?style=for-the-badge)](LICENSE) [![No Build](https://img.shields.io/badge/Build-none-3bc5a7?style=for-the-badge)](index.html) [![Dependencies](https://img.shields.io/badge/Dependencies-none-22c55e?style=for-the-badge)](package.json) @@ -75,7 +75,7 @@ The project stays no-build and dependency-free, split by responsibility: - `src/panic.js`: Panic Recovery decision-tree rendering and card highlight. - `scripts/validate.mjs`: data integrity checks. -Run tests and validators: +Run the unit/data suite, real-Git behavioral tests in temporary repositories, and validators: ```bash npm test @@ -88,7 +88,7 @@ npm test - `git add -p`: stage selected hunks instead of whole files. - `git commit -m "message"`: save staged changes as a local commit. - `git push`: publish local commits to the shared remote branch. -- `git pull`: fetch and integrate remote commits into the current branch. +- `git pull`: fetch and integrate remote commits into the current branch; choose a merge or rebase strategy when histories have diverged. - `git pull --ff-only`: fetch and update only when the current branch can fast-forward. - `git pull --rebase`: fetch and replay local commits on top of the upstream. - `git pull --no-rebase`: fetch and merge the upstream, creating a merge commit when needed. @@ -105,6 +105,7 @@ npm test - `git reset --hard HEAD`: discard all local tracked changes. - `git restore .`: discard unstaged workspace edits by restoring files from the index. - `git stash -u`: stash tracked and untracked files. +- `git stash pop --index`: restore a stash and ask Git to restore its saved staging state too. - `git stash branch `: create a branch from a stash. - `git log --oneline --graph --decorate --all`: view commit history as a graph. - `git blame `: see who last changed each line. diff --git a/package.json b/package.json index d55cd6a..840ce70 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "git-map", - "version": "1.4.0", + "version": "1.4.1", "private": true, "description": "Visual Git sandbox for mapping status, experimenting with branches, and recovering from common mistakes.", "scripts": { - "test": "node --test tests/*.test.mjs && node scripts/validate.mjs", - "test:unit": "node --test tests/*.test.mjs", + "test": "npm run test:unit && npm run test:git && npm run validate", + "test:unit": "node --test tests/branch-model.test.mjs tests/cross-file-constants.test.mjs tests/panic-data.test.mjs tests/status-matcher.test.mjs tests/validator.test.mjs", + "test:git": "node --test tests/git-integration.test.mjs", "validate": "node scripts/validate.mjs" } } diff --git a/scripts/panic-validator.mjs b/scripts/panic-validator.mjs index a9418bd..355c8df 100644 --- a/scripts/panic-validator.mjs +++ b/scripts/panic-validator.mjs @@ -1,4 +1,5 @@ const VALID_REVERSIBILITY = new Set(["safe", "caution", "danger"]); +const DESTRUCTIVE_OR_REWRITING_COMMAND = /(?:--force(?:-with-lease)?|\bpush -f\b|\breset --(?:hard|soft)\b|\bcommit --amend\b|\bfilter-repo\b|\bbranch -D\b|\bclean -fd?\b)/; function isNonEmptyString(value) { return typeof value === "string" && value.trim().length > 0; @@ -38,11 +39,22 @@ export function validatePanicData(panicData) { fail(`Panic recovery "${recovery.id}" is missing commands.`); } else { recovery.commands.forEach((step, index) => { + if (isNonEmptyString(step.heading)) { + return; + } if (!isNonEmptyString(step.command) || !isNonEmptyString(step.note)) { fail(`Panic recovery "${recovery.id}" command ${index} is missing command or note.`); } }); } + + if (recovery.reversibility === "safe") { + recovery.commands.forEach((step) => { + if (DESTRUCTIVE_OR_REWRITING_COMMAND.test(step.command || "")) { + fail(`Panic recovery "${recovery.id}" is marked safe but includes a destructive or history-rewriting command.`); + } + }); + } }); const referencedRecoveryIds = new Set(); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 0dd72a0..0f6cb0d 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -7,6 +7,7 @@ const dataCode = fs.readFileSync("src/data.js", "utf8"); const branchModelCode = fs.readFileSync("src/branch-map-model.js", "utf8"); const panicDataCode = fs.readFileSync("src/panic-data.js", "utf8"); const versionCode = fs.readFileSync("src/version.js", "utf8"); +const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); const readme = fs.readFileSync("README.md", "utf8"); const changelog = fs.readFileSync("CHANGELOG.md", "utf8"); const indexHtml = fs.readFileSync("index.html", "utf8"); @@ -38,6 +39,12 @@ if (!version) { if (!changelog.includes(`## [${version}]`)) { fail(`CHANGELOG is missing an entry for src/version.js (${version}).`); } + if (packageJson.version !== version) { + fail(`package.json version (${packageJson.version}) does not match src/version.js (${version}).`); + } + if (!readme.includes(`Version-${version}-`)) { + fail(`README version badge does not match src/version.js (${version}).`); + } [ ["index.html", indexHtml], ["branch-map.html", branchHtml], diff --git a/src/data.js b/src/data.js index ca59ca2..50da18b 100644 --- a/src/data.js +++ b/src/data.js @@ -340,7 +340,7 @@ const commands = [ color: "var(--red)", marker: "arrow-red", zones: ["remote", "workspace"], - note: "Fetches the configured upstream and integrates it into the current branch. Current Git defaults to fast-forward-only unless pull strategy config or options say otherwise." + note: "Fetches the configured upstream and integrates it into the current branch. For divergent histories, choose or configure a rebase or merge strategy; --ff-only is a separate explicit safety option." }, { id: "push", @@ -382,6 +382,7 @@ const toolCommands = { stash: [ { command: "git stash list", note: "Show saved stashes." }, { command: "git stash show -p", note: "Preview what is inside a stash." }, + { command: "git stash pop --index", note: "Restore a stash and ask Git to restore its saved staging/index state too." }, { command: "git stash -u", note: "Stash tracked and untracked files." }, { command: "git stash branch ", note: "Create a branch from a stash." } ], @@ -614,7 +615,7 @@ const statusScenarios = [ label: "Diverged", zone: "remote", commandId: "fetch", - summary: "Both local and remote have commits the other side does not have. Plain git pull may stop under the fast-forward-only default, so choose rebase, merge, or run git fetch first and then merge origin/.", + summary: "Both local and remote have commits the other side does not have. Choose a rebase or merge strategy, or fetch first and then merge origin/; do not treat these alternatives as a sequence.", checks: ["git status", "git fetch", "git log --oneline --graph --decorate --all"], next: ["git pull --rebase", "git pull --no-rebase", "git fetch", "git merge origin/"] }, diff --git a/src/panic-data.js b/src/panic-data.js index a9149db..1ceef17 100644 --- a/src/panic-data.js +++ b/src/panic-data.js @@ -3,17 +3,17 @@ window.panicData = (() => { { id: "panic-wrong-branch-local", title: "I committed to the wrong branch (not pushed yet)", - reversibility: "safe", - diagnosis: "Your commit is on the wrong branch but it only exists on your machine. Nothing is shared yet, so this is fully reversible.", + reversibility: "caution", + diagnosis: "Your commit is on the wrong branch but it only exists on your machine. Nothing is shared yet. This rewrites only local history, but stash restoration can still conflict, so check each step before continuing.", commands: [ { command: "git log --oneline -5", note: "Confirm the commit is the most recent one on the wrong branch." }, - { command: "git reset --soft HEAD~1", note: "Undo the commit but keep the changes staged." }, - { command: "git stash", note: "Set the staged changes aside so you can switch branches cleanly." }, + { command: "git reset --soft HEAD~1", note: "Undo the commit but keep its changes staged in the index." }, + { command: "git stash push -m \"move wrong-branch commit\"", note: "Save the working tree and index so you can switch branches cleanly." }, { command: "git switch ", note: "Move to the branch the commit was meant for." }, - { command: "git stash pop", note: "Bring the changes back, still staged." }, + { command: "git stash pop --index", note: "Restore the working tree and ask Git to restore the saved staging/index state too. Plain git stash pop restores worktree changes but does not guarantee that they remain staged." }, { command: "git commit -m \"\"", note: "Re-commit on the correct branch." } ], - whyItWorks: "git reset --soft moves the branch tip back one commit while keeping your file changes staged. Stashing carries those staged changes across the branch switch, and stash pop restores them on the new branch ready to commit.", + whyItWorks: "git reset --soft moves the branch tip back one commit while keeping the snapshot staged. A stash records both the working tree and index. The --index option asks Git to reinstate that index snapshot; if it conflicts, resolve and stage the result before committing.", avoidNextTime: "Run git status before committing to confirm which branch you are on." }, { @@ -33,7 +33,7 @@ window.panicData = (() => { { id: "panic-commit-mistake-local", title: "My last commit has a mistake (not pushed yet)", - reversibility: "safe", + reversibility: "caution", diagnosis: "You committed but the message is wrong, you forgot a file, or you included something you should not have. Because the commit is local, you can amend it freely.", commands: [ { command: "git status", note: "See what is currently in the workspace and staging area." }, @@ -48,8 +48,8 @@ window.panicData = (() => { { id: "panic-reset-hard-lost-edits", title: "I ran git reset --hard and lost my edits", - reversibility: "caution", - diagnosis: "git reset --hard discards uncommitted edits. If those edits were never committed, they are gone. If they were committed even briefly, git reflog can find the commit and bring it back.", + reversibility: "danger", + diagnosis: "git reset --hard discards uncommitted edits. If those edits were never committed, they are gone. If they were committed even briefly, reflog may help you locate the commit; recover it as soon as possible because reflog entries are temporary.", commands: [ { command: "git reflog", note: "Look for a recent entry that mentions HEAD@{n}: commit or HEAD@{n}: ... before the reset." }, { command: "git switch -c rescue ", note: "Create a rescue branch at the commit you want back." }, @@ -57,13 +57,13 @@ window.panicData = (() => { { command: "git switch ", note: "Return to your original branch." }, { command: "git merge rescue", note: "Bring the rescued commits back into your working branch." } ], - whyItWorks: "Git keeps a log of every position HEAD has pointed at (the reflog) for about 90 days by default. Even after a hard reset, the commits still exist as long as the reflog remembers them; you just need a branch name to make them reachable again.", + whyItWorks: "Reflog records recent positions of HEAD and branch refs, so a reset can leave a committed snapshot discoverable for a while. Retention and garbage collection depend on repository configuration and reachability. Create a rescue branch immediately once you find the commit.", avoidNextTime: "Commit early and often, even with throwaway messages. A commit is recoverable; an uncommitted edit is not. Prefer git restore or git stash over git reset --hard for cleanup." }, { id: "panic-detached-head", title: "Git says I am in 'detached HEAD' state", - reversibility: "safe", + reversibility: "caution", diagnosis: "You checked out a commit, tag, or remote-tracking branch directly instead of a branch. Any commits you make here are not attached to a branch name and can be lost if you switch away without saving them.", commands: [ { command: "git status", note: "Git tells you what you are detached at and warns about unsaved commits." }, @@ -76,13 +76,14 @@ window.panicData = (() => { { id: "panic-push-rejected", title: "My push was rejected (non-fast-forward)", - reversibility: "safe", + reversibility: "caution", diagnosis: "Someone pushed to the same branch after you last pulled. Your local branch is behind the remote, so the remote refuses your push to protect their work. You need to integrate their commits before pushing yours.", commands: [ { command: "git fetch", note: "Download the remote commits without changing your files." }, { command: "git status", note: "Confirm the branch is now reported as diverged or behind." }, - { command: "git pull --rebase", note: "Replay your local commits on top of the remote commits (linear history)." }, - { command: "git pull --no-rebase", note: "Merge the remote commits into your branch instead (creates a merge commit)." }, + { heading: "Choose one integration approach — do not run both." }, + { command: "git pull --rebase", note: "Option A: replay your unpushed local commits on top of the remote commits for a linear history." }, + { command: "git pull --no-rebase", note: "Option B: merge the remote commits into your branch when your project expects merge commits." }, { command: "git push", note: "Push again once the histories are aligned." } ], whyItWorks: "Git refuses non-fast-forward pushes by default because they would erase commits the remote already has. Pulling first brings those commits into your branch; once your branch contains all of them plus yours, the push fast-forwards cleanly.", @@ -91,70 +92,79 @@ window.panicData = (() => { { id: "panic-merge-conflict-abort", title: "I am stuck in a merge conflict and want out", - reversibility: "safe", - diagnosis: "A merge or rebase started, conflicts appeared, and you do not want to resolve them right now. You can abort and go back to the state before the merge or rebase began.", + reversibility: "caution", + diagnosis: "A merge or rebase started, conflicts appeared, and you do not want to resolve them right now. Abort the operation named by git status. These commands return the operation to its pre-start state, but Git may not be able to reconstruct unrelated uncommitted work that was present before it began.", commands: [ { command: "git status", note: "Confirm whether you are mid-merge or mid-rebase. The output names which one." }, - { command: "git merge --abort", note: "Use this if git status says you are merging." }, - { command: "git rebase --abort", note: "Use this if git status says you are rebasing." }, + { heading: "Run exactly one abort command, based on git status." }, + { command: "git merge --abort", note: "Use this only if git status says you are merging." }, + { command: "git rebase --abort", note: "Use this only if git status says you are rebasing." }, { command: "git status", note: "Verify the working tree is clean and you are back on your original branch." } ], - whyItWorks: "Git keeps a record of the state before a merge or rebase started. The --abort flag restores that state, including your files, the branch tip, and the index. Nothing about the conflict is preserved, which is what you want when you are bailing out.", + whyItWorks: "Git records the state needed to stop a merge or rebase and return the branch tip to where the operation started. The conflict result is discarded. This is most predictable when you began with a clean working tree, so save unrelated work before starting integrations.", avoidNextTime: "Before merging or rebasing, commit or stash your in-progress work so a possible conflict only involves the branches, not your uncommitted edits." }, { id: "panic-lost-stash", title: "I cannot find work I stashed", - reversibility: "caution", - diagnosis: "Stashes have short names like stash@{0} and they shift when you add new ones. If you popped or dropped a stash, it is removed from the list but its commit usually still exists in the reflog.", + reversibility: "danger", + diagnosis: "Stashes have short names like stash@{0} and they shift when you add new ones. If you popped or dropped a stash, it is removed from the list. The recovery window is temporary and recovery is not guaranteed, so investigate immediately.", commands: [ { command: "git stash list", note: "Check whether the stash is still in the list under a different index." }, { command: "git fsck --no-reflog | findstr dangling", note: "On Windows PowerShell. Use grep dangling on macOS or Linux. Lists dangling commits, which is where dropped stashes go." }, { command: "git show ", note: "Inspect a candidate commit to see if it is the stash you want." }, { command: "git stash apply ", note: "Apply the dangling stash commit back into your workspace." } ], - whyItWorks: "A stash is a real commit on a hidden ref. Dropping it removes the ref but the commit object survives in the object database until garbage collection runs (default: about 2 weeks for unreachable objects). fsck lists those objects so you can recover one.", + whyItWorks: "A stash is represented by Git objects behind a stash ref. Dropping it removes that ref; unreachable objects can remain until reflog expiry and garbage collection, which vary by configuration. fsck can expose candidates while they still exist, but save a recovered result under a named branch or stash immediately.", avoidNextTime: "Prefer git stash push -m \"\" so each stash has a description. Apply stashes with git stash apply (not pop) until you are sure the result is correct, then drop them deliberately." }, { id: "panic-deleted-branch", title: "I deleted a branch that had unmerged commits", - reversibility: "safe", - diagnosis: "Deleting a branch only removes the name, not the commits. As long as you have not run garbage collection, the commits are still in the reflog and recoverable.", + reversibility: "caution", + diagnosis: "Deleting a branch removes its name, not necessarily its commits. A recent reflog may still identify its former tip, but reflog entries and unreachable objects are temporary. Recover it as soon as possible.", commands: [ { command: "git reflog", note: "Find the entry where the deleted branch's tip last lived (often labelled checkout: moving from )." }, { command: "git branch ", note: "Recreate the branch pointing at the recovered commit." }, { command: "git log --oneline ", note: "Confirm the history is back." } ], - whyItWorks: "A branch in Git is just a movable label pointing at a commit. Deleting the label leaves the commit untouched; the reflog still knows which commit the label used to point at. Recreating a branch at that commit fully restores the branch.", + whyItWorks: "A branch is a movable label pointing at a commit. Removing that label can leave the commit reachable through recent reflog information. Once you recreate a named branch at the intended commit, it is protected from ordinary unreachable-object cleanup again.", avoidNextTime: "Use git branch -d (lowercase) which refuses to delete unmerged branches. Reserve -D (uppercase) for branches you are sure are safe to drop." }, { id: "panic-force-push-overwrote", title: "I force-pushed and overwrote someone else's commits", reversibility: "danger", - diagnosis: "Your force push replaced the remote branch tip and dropped commits the other person had pushed. Their commits are not in your local clone, so you cannot recover them yourself. The other person's clone almost certainly still has them.", + diagnosis: "Stop making destructive changes. Your force push replaced the remote branch tip and may have dropped commits that are not in your clone. Recovery needs a clone that still has the missing commits and a deliberate plan to integrate them with the current remote history.", commands: [ - { command: "git fetch", note: "First, confirm what is currently on the remote so you do not overwrite anything else." }, - { command: "git log origin/", note: "Inspect what the remote looks like now." }, - { command: "# Ask the teammate: git reflog", note: "On their machine, the reflog still shows the commit SHA their branch tip used to point at." }, - { command: "# Ask the teammate: git push --force-with-lease origin :", note: "They can restore the branch tip to include both their lost commits and yours, then everyone re-pulls." } + { command: "git fetch origin", note: "First, inspect the current remote without overwriting anything else." }, + { command: "git log --oneline --graph --decorate --all", note: "Compare the current origin/ history with the history available in local clones." }, + { heading: "On a clone that still contains the missing commits:" }, + { command: "git reflog", note: "Locate the missing commit SHA, then verify it with git show ." }, + { command: "git branch rescue/lost-work ", note: "Create a named rescue branch immediately so the missing commits are preserved." }, + { command: "git push origin rescue/lost-work", note: "If useful, publish the rescue branch separately. This does not change the shared branch." }, + { command: "git log --left-right --graph origin/...rescue/lost-work", note: "Compare the rescued history with the current remote before choosing an integration." }, + { command: "git cherry-pick ", note: "One possible integration: copy a specific missing commit onto a prepared branch. A merge can be appropriate when whole histories should be joined; choose deliberately with the team." }, + { heading: "Only if a coordinated shared-branch rewrite is truly required:" }, + { command: "git push --force-with-lease origin ", note: "Force-with-lease checks that the remote tip is still what you expect, but it does not combine histories. Do this only after the missing work has been preserved and the team has agreed." } ], - whyItWorks: "Force-push rewrites the remote branch ref to your local tip. The dropped commits still exist in any clone that pulled them recently, including the teammate's clone. The reflog there preserves the SHA, which is enough to push the branch back to a point that contains the missing work.", + whyItWorks: "A force push rewrites the remote branch ref; it does not merge the old and new histories. Another clone may still retain the missing commits in its local history or reflog. Naming them on a rescue branch prevents accidental loss, then lets the team compare and deliberately merge or cherry-pick the work before considering any shared-branch rewrite.", avoidNextTime: "Use git push --force-with-lease instead of --force; it refuses the push if the remote tip is not what you expected, catching cases where someone else has pushed since your last fetch. Avoid force-pushing shared branches at all when possible." }, { id: "panic-secrets-committed", title: "I committed a secret or a huge file", - reversibility: "caution", - diagnosis: "If you have not pushed yet, amend or reset to remove it from history. If you have already pushed, the secret is in the shared history; you must rotate the secret AND rewrite history. Rewriting shared history is disruptive — coordinate with your team.", + reversibility: "danger", + diagnosis: "If you have not pushed yet, you can replace the local commit. If you have already pushed a secret, treat it as exposed: rotate it first, then coordinate any history rewrite. Rewriting shared history is disruptive and does not undo the exposure.", commands: [ { command: "git log --oneline -3", note: "Confirm whether the bad file is in the most recent commit or further back." }, + { heading: "If the bad commit has not been pushed:" }, { command: "git reset --soft HEAD~1", note: "Local only: undo the last commit, keep changes staged so you can fix the file." }, { command: "git restore --staged ", note: "Unstage the bad file. Then delete it or add it to .gitignore." }, { command: "git commit -m \"\"", note: "Re-commit without the bad file." }, - { command: "# If already pushed: rotate the secret immediately.", note: "Treat the secret as compromised. Generate a new one and invalidate the old one before doing anything else." }, - { command: "# Then use git filter-repo or BFG Repo-Cleaner to rewrite history.", note: "These are separate tools. See https://github.com/newren/git-filter-repo and warn your team before force-pushing." } + { heading: "If the secret was already pushed: rotate and invalidate it before touching Git history." }, + { command: "git filter-repo --path --invert-paths", note: "After coordinating with the team, use this separately installed tool (or an equivalent reviewed procedure) to remove the file from history." }, + { command: "git push --force-with-lease origin ", note: "Only after the rewrite has been reviewed and teammates have been warned. Force-with-lease does not remove copies already fetched or undo secret exposure." } ], whyItWorks: "Until a commit is pushed, it is purely local and can be replaced freely. Once pushed, the commit exists in every clone and on the server; rewriting history is possible with filter-repo, but anyone who pulled the bad commit must reset their clone. For secrets, history rewriting does not undo exposure — it only stops new clones from seeing it.", avoidNextTime: "Keep a .gitignore that excludes credential files (.env, *.pem, service-account-*.json). Consider pre-commit hooks like gitleaks. For large binaries, use Git LFS or a separate artifact store." diff --git a/src/panic.js b/src/panic.js index 1432975..8fe48fa 100644 --- a/src/panic.js +++ b/src/panic.js @@ -50,7 +50,9 @@

Fix it

    - ${recovery.commands.map((step) => ` + ${recovery.commands.map((step) => step.heading ? ` +
  1. ${escapeHtml(step.heading)}
  2. + ` : `
  3. ${escapeHtml(step.command)} diff --git a/src/styles.css b/src/styles.css index dd4648e..bcecdd4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2118,6 +2118,12 @@ h1 { font-size: 0.9rem; } +.panic-commands .panic-command-heading { + list-style: none; + margin: 6px 0 0 -20px; + color: var(--text); +} + .panic-avoid p { margin: 0; color: var(--muted); diff --git a/src/version.js b/src/version.js index a982a14..0539d3b 100644 --- a/src/version.js +++ b/src/version.js @@ -1,5 +1,5 @@ (() => { - const version = "1.4.0"; + const version = "1.4.1"; const startHereStorageKey = "git-map-start-here-dismissed"; window.gitMapVersion = version; diff --git a/tests/git-integration.test.mjs b/tests/git-integration.test.mjs new file mode 100644 index 0000000..c0a0a1b --- /dev/null +++ b/tests/git-integration.test.mjs @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +const gitExecutable = process.platform === "win32" ? "git.exe" : "git"; +const gitAvailable = spawnSync(gitExecutable, ["--version"], { encoding: "utf8" }).status === 0; +const gitSkip = gitAvailable ? false : "Git executable was not found; real Git integration tests cannot run."; + +function git(directory, args, { allowFailure = false } = {}) { + const result = spawnSync(gitExecutable, args, { cwd: directory, encoding: "utf8" }); + if (!allowFailure && result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed:\n${result.stdout}\n${result.stderr}`); + } + return result; +} + +function output(directory, args, options) { + return git(directory, args, options).stdout.trim(); +} + +function readText(filePath) { + return readFileSync(filePath, "utf8").replaceAll("\r\n", "\n"); +} + +function withTemporaryDirectory(run) { + const directory = mkdtempSync(path.join(os.tmpdir(), "git-map-integration-")); + try { + return run(directory); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +function configureRepository(directory) { + git(directory, ["config", "user.name", "Git Map test"]); + git(directory, ["config", "user.email", "git-map@example.test"]); +} + +function makeRepository(directory) { + git(directory, ["init", "--initial-branch=main"]); + configureRepository(directory); + writeFileSync(path.join(directory, "README.md"), "base\n"); + git(directory, ["add", "README.md"]); + git(directory, ["commit", "-m", "base"]); +} + +function status(directory) { + return output(directory, ["status", "--porcelain=v1"]); +} + +test("wrong-branch recovery preserves staged work only with stash pop --index", { skip: gitSkip }, () => { + withTemporaryDirectory((directory) => { + makeRepository(directory); + git(directory, ["switch", "-c", "wrong-branch"]); + writeFileSync(path.join(directory, "moved.txt"), "move me\n"); + git(directory, ["add", "moved.txt"]); + git(directory, ["commit", "-m", "wrong branch commit"]); + + git(directory, ["reset", "--soft", "HEAD~1"]); + assert.match(status(directory), /^A moved\.txt$/m); + git(directory, ["stash", "push", "-m", "move wrong commit"]); + git(directory, ["switch", "main"]); + git(directory, ["stash", "pop", "--index"]); + + assert.equal(readText(path.join(directory, "moved.txt")), "move me\n"); + assert.match(status(directory), /^A moved\.txt$/m); + git(directory, ["commit", "-m", "correct branch commit"]); + assert.equal(output(directory, ["branch", "--show-current"]), "main"); + assert.match(output(directory, ["log", "--oneline", "-1"]), /correct branch commit/); + }); +}); + +test("accidental commits on main remain reachable after moving a branch pointer", { skip: gitSkip }, () => { + withTemporaryDirectory((directory) => { + makeRepository(directory); + writeFileSync(path.join(directory, "feature.txt"), "preserve me\n"); + git(directory, ["add", "feature.txt"]); + git(directory, ["commit", "-m", "accidental main commit"]); + git(directory, ["branch", "feature/rescued"]); + git(directory, ["reset", "--hard", "HEAD~1"]); + + assert.notEqual(git(directory, ["show", "main:feature.txt"], { allowFailure: true }).status, 0); + git(directory, ["switch", "feature/rescued"]); + assert.equal(readText(path.join(directory, "feature.txt")), "preserve me\n"); + assert.match(output(directory, ["log", "--oneline", "-1"]), /accidental main commit/); + }); +}); + +test("detached HEAD commits become durable when a rescue branch is created", { skip: gitSkip }, () => { + withTemporaryDirectory((directory) => { + makeRepository(directory); + git(directory, ["switch", "--detach", "HEAD"]); + writeFileSync(path.join(directory, "rescue.txt"), "saved\n"); + git(directory, ["add", "rescue.txt"]); + git(directory, ["commit", "-m", "detached work"]); + const detachedCommit = output(directory, ["rev-parse", "HEAD"]); + git(directory, ["switch", "-c", "rescue"]); + + assert.equal(output(directory, ["branch", "--show-current"]), "rescue"); + assert.equal(output(directory, ["rev-parse", "rescue"]), detachedCommit); + }); +}); + +test("merge --abort restores the pre-merge branch and files after a real conflict", { skip: gitSkip }, () => { + withTemporaryDirectory((directory) => { + makeRepository(directory); + git(directory, ["switch", "-c", "feature"]); + writeFileSync(path.join(directory, "README.md"), "feature\n"); + git(directory, ["commit", "-am", "feature edit"]); + git(directory, ["switch", "main"]); + writeFileSync(path.join(directory, "README.md"), "main\n"); + git(directory, ["commit", "-am", "main edit"]); + const beforeMerge = output(directory, ["rev-parse", "HEAD"]); + + assert.notEqual(git(directory, ["merge", "feature"], { allowFailure: true }).status, 0); + assert.match(status(directory), /^UU README\.md$/m); + git(directory, ["merge", "--abort"]); + + assert.equal(output(directory, ["rev-parse", "HEAD"]), beforeMerge); + assert.equal(readText(path.join(directory, "README.md")), "main\n"); + assert.equal(status(directory), ""); + }); +}); + +test("rebase --abort restores the pre-rebase branch and files after a real conflict", { skip: gitSkip }, () => { + withTemporaryDirectory((directory) => { + makeRepository(directory); + git(directory, ["switch", "-c", "feature"]); + writeFileSync(path.join(directory, "README.md"), "feature\n"); + git(directory, ["commit", "-am", "feature edit"]); + const beforeRebase = output(directory, ["rev-parse", "HEAD"]); + git(directory, ["switch", "main"]); + writeFileSync(path.join(directory, "README.md"), "main\n"); + git(directory, ["commit", "-am", "main edit"]); + git(directory, ["switch", "feature"]); + + assert.notEqual(git(directory, ["rebase", "main"], { allowFailure: true }).status, 0); + git(directory, ["rebase", "--abort"]); + + assert.equal(output(directory, ["rev-parse", "HEAD"]), beforeRebase); + assert.equal(readText(path.join(directory, "README.md")), "feature\n"); + assert.equal(status(directory), ""); + }); +}); + +test("a deleted unmerged branch can be recreated from its former tip", { skip: gitSkip }, () => { + withTemporaryDirectory((directory) => { + makeRepository(directory); + git(directory, ["switch", "-c", "feature/deleted"]); + writeFileSync(path.join(directory, "deleted.txt"), "recover me\n"); + git(directory, ["add", "deleted.txt"]); + git(directory, ["commit", "-m", "unmerged work"]); + const formerTip = output(directory, ["rev-parse", "HEAD"]); + git(directory, ["switch", "main"]); + git(directory, ["branch", "-D", "feature/deleted"]); + assert.match(output(directory, ["reflog"]), new RegExp(formerTip.slice(0, 7))); + git(directory, ["branch", "feature/restored", formerTip]); + + assert.equal(output(directory, ["rev-parse", "feature/restored"]), formerTip); + assert.equal(output(directory, ["show", "feature/restored:deleted.txt"]), "recover me"); + }); +}); + +test("a rejected push succeeds after fetching and deliberately integrating remote work", { skip: gitSkip }, () => { + withTemporaryDirectory((directory) => { + const remote = path.join(directory, "remote.git"); + const seed = path.join(directory, "seed"); + const first = path.join(directory, "first"); + const second = path.join(directory, "second"); + git(directory, ["init", "--bare", "--initial-branch=main", remote]); + git(directory, ["init", "--initial-branch=main", seed]); + configureRepository(seed); + writeFileSync(path.join(seed, "README.md"), "base\n"); + git(seed, ["add", "README.md"]); + git(seed, ["commit", "-m", "base"]); + git(seed, ["remote", "add", "origin", remote]); + git(seed, ["push", "-u", "origin", "main"]); + execFileSync(gitExecutable, ["clone", "-b", "main", remote, first], { encoding: "utf8" }); + execFileSync(gitExecutable, ["clone", "-b", "main", remote, second], { encoding: "utf8" }); + configureRepository(first); + configureRepository(second); + + writeFileSync(path.join(first, "first.txt"), "remote work\n"); + git(first, ["add", "first.txt"]); + git(first, ["commit", "-m", "first push"]); + git(first, ["push"]); + writeFileSync(path.join(second, "second.txt"), "local work\n"); + git(second, ["add", "second.txt"]); + git(second, ["commit", "-m", "second push"]); + + assert.notEqual(git(second, ["push"], { allowFailure: true }).status, 0); + git(second, ["fetch", "origin"]); + git(second, ["merge", "origin/main"]); + git(second, ["push"]); + + assert.match(output(second, ["log", "origin/main", "--oneline"]), /first push/); + assert.match(output(second, ["log", "origin/main", "--oneline"]), /second push/); + }); +}); diff --git a/tests/panic-data.test.mjs b/tests/panic-data.test.mjs index 254d062..317f998 100644 --- a/tests/panic-data.test.mjs +++ b/tests/panic-data.test.mjs @@ -34,6 +34,10 @@ describe("panicData", () => { assert.ok(VALID_REVERSIBILITY.has(recovery.reversibility), `${recovery.id} has unknown reversibility "${recovery.reversibility}"`); assert.ok(Array.isArray(recovery.commands) && recovery.commands.length > 0, `${recovery.id} has no commands`); recovery.commands.forEach((step, index) => { + if (step.heading) { + assert.equal(typeof step.heading, "string", `${recovery.id} heading ${index} must be a string`); + return; + } assert.ok(step.command && step.note, `${recovery.id} command ${index} missing command or note`); }); }); @@ -95,6 +99,9 @@ describe("panicData", () => { /reset --hard/, /filter-repo/, /push -f\b/, + /force-with-lease/, + /reset --soft/, + /commit --amend/, /branch -D\b/, /clean -fd?\b/ ]; @@ -113,4 +120,41 @@ describe("panicData", () => { }); assert.equal(offenders.length, 0, `Safe recoveries contain dangerous commands:\n ${offenders.join("\n ")}`); }); + + it("keeps staging only when wrong-branch recovery explicitly restores the index", () => { + const recovery = panicData.recoveries.find(({ id }) => id === "panic-wrong-branch-local"); + assert.equal(recovery.reversibility, "caution"); + const stashRestore = recovery.commands.find(({ command }) => command.includes("stash pop")); + assert.equal(stashRestore.command, "git stash pop --index"); + assert.match(stashRestore.note, /working tree/i); + assert.match(stashRestore.note, /staging|index/i); + }); + + it("does not describe reflog recovery as having a fixed retention period", () => { + const reflogRecoveries = panicData.recoveries.filter((recovery) => + recovery.commands.some(({ command }) => command?.includes("reflog")) + ); + reflogRecoveries.forEach((recovery) => { + assert.doesNotMatch(recovery.whyItWorks, /\b(?:90 days|2 weeks)\b/i, recovery.id); + if (["panic-reset-hard-lost-edits", "panic-lost-stash", "panic-deleted-branch"].includes(recovery.id)) { + assert.match(`${recovery.diagnosis} ${recovery.whyItWorks}`, /temporary|as soon as possible/i, recovery.id); + } + }); + }); + + it("requires a deliberate recovery sequence after a force-push overwrite", () => { + const recovery = panicData.recoveries.find(({ id }) => id === "panic-force-push-overwrote"); + const text = [recovery.diagnosis, recovery.whyItWorks, ...recovery.commands.map(({ command, note }) => `${command} ${note}`)].join(" "); + assert.equal(recovery.reversibility, "danger"); + assert.match(text, /rescue branch/i); + assert.match(text, /cherry-pick|merge/i); + assert.doesNotMatch(text, /push --force-with-lease origin :/i); + }); + + it("separates mutually exclusive local and pushed-secret recovery paths", () => { + const recovery = panicData.recoveries.find(({ id }) => id === "panic-secrets-committed"); + const headings = recovery.commands.flatMap(({ heading }) => heading ? [heading] : []); + assert.ok(headings.some((heading) => /not been pushed/i.test(heading))); + assert.ok(headings.some((heading) => /already pushed/i.test(heading))); + }); }); diff --git a/tests/validator.test.mjs b/tests/validator.test.mjs index 475e782..d74a1d9 100644 --- a/tests/validator.test.mjs +++ b/tests/validator.test.mjs @@ -76,6 +76,12 @@ describe("validatePanicData", () => { expectError(validatePanicData(data), "missing command or note"); }); + it("does not allow destructive or history-rewriting commands in a safe recovery", () => { + const data = goodData(); + data.recoveries[0].commands = [{ command: "git reset --hard HEAD", note: "Discard everything." }]; + expectError(validatePanicData(data), "marked safe"); + }); + it("flags a tree leaf pointing at an unknown recovery", () => { const data = goodData(); data.tree.rootOptions[0].recoveryId = "does-not-exist";