diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 03c43d4119d..30da3b63f63 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,12 +1,10 @@
# adapted from https://github.com/devcontainers/images/blob/main/src/go/.devcontainer/Dockerfile
# [Choice] Go version (use -bullseye variants on local arm64/Apple Silicon): 1, 1.19, 1.18, 1-bullseye, 1.19-bullseye, 1.18-bullseye, 1-buster, 1.19-buster, 1.18-buster
-ARG VARIANT=1-bullseye
+ARG VARIANT=1-trixie
FROM golang:${VARIANT}
RUN go install mvdan.cc/gofumpt@latest
-RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.50.0
-RUN golangci-lint --version
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000000..62f9670c83d
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,4 @@
+root = true
+
+[*.go]
+indent_style = tab
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000000..ec9895f39d6
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+*.go text eol=lf
+*.md text eol=lf
+*.json text eol=lf
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index a7b624a29d8..ae7200c5511 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -6,10 +6,10 @@ labels: bug
assignees: ''
---
-**Describe the bug**
+### Describe the bug
A clear and concise description of what the bug is.
-**To Reproduce**
+### To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
@@ -17,20 +17,24 @@ Steps to reproduce the behavior:
3. Scroll down to '....'
4. See error
-**Expected behavior**
+### Expected behavior
A clear and concise description of what you expected to happen.
-**Screenshots**
+### Screenshots
If applicable, add screenshots to help explain your problem.
-**Version info:**
-_Run `lazygit --version` and paste the result here_
-_Run `git --version` and paste the result here_
+### Version info:
-**Additional context**
+* _Run `lazygit --version` and paste the result here_
+
+### Terminal info:
+What terminal are you using, and which version? For some types of bugs this information can be relevant.
+
+### Additional context
Add any other context about the problem here.
-**Note:** please try updating to the latest version or [manually building](https://github.com/jesseduffield/lazygit/#manual) the latest `master` to see if the issue still occurs.
+> [!NOTE]
+> Please try updating to the latest version or [manually building](https://github.com/jesseduffield/lazygit/#manual) the latest `master` to see if the issue still occurs.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 71e2bd80d5a..eb2d9d1d6d0 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,6 +4,15 @@ updates:
directory: "/"
schedule:
interval: "weekly"
- allowed_updates:
- - match:
- update_type: "security"
+ labels:
+ - "maintenance"
+ - "dependencies"
+ - "go"
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ labels:
+ - "maintenance"
+ - "dependencies"
+ - "github_actions"
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 6c6a32467cb..a8b0386b065 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,10 +1,18 @@
-- **PR Description**
+### PR Description
-- **Please check if the PR fulfills these requirements**
+### Please check if the PR fulfills these requirements
-* [ ] Cheatsheets are up-to-date (run `go run scripts/cheatsheet/main.go generate`)
+* [ ] Cheatsheets are up-to-date (run `go generate ./...`)
* [ ] Code has been formatted (see [here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting))
* [ ] Tests have been added/updated (see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md) for the integration test guide)
* [ ] Text is internationalised (see [here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation))
-* [ ] Docs (specifically `docs/Config.md`) have been updated if necessary
+* [ ] If a new UserConfig entry was added, make sure it can be hot-reloaded (see [here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig))
+* [ ] Docs have been updated if necessary
* [ ] You've read through your own file changes for silly mistakes etc
+
+
diff --git a/.github/release.yml b/.github/release.yml
index 8560f89ede7..e32d8103b05 100644
--- a/.github/release.yml
+++ b/.github/release.yml
@@ -21,6 +21,9 @@ changelog:
- title: I18n 🌎
labels:
- i18n
+ - title: Performance Improvements 📊
+ labels:
+ - performance
- title: Other Changes
labels:
- "*"
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
deleted file mode 100644
index ef56b7bc6b7..00000000000
--- a/.github/workflows/cd.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-name: Continuous Delivery
-
-on:
- push:
- tags:
- - 'v*'
-
-jobs:
- goreleaser:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v3
- - name: Unshallow repo
- run: git fetch --prune --unshallow
- - name: Setup Go
- uses: actions/setup-go@v1
- with:
- go-version: 1.20.x
- - name: Run goreleaser
- uses: goreleaser/goreleaser-action@v1
- with:
- distribution: goreleaser
- version: v1.17.2
- args: release --clean
- env:
- GITHUB_TOKEN: ${{secrets.GITHUB_API_TOKEN}}
- homebrew:
- runs-on: ubuntu-latest
- steps:
- - name: Bump Homebrew formula
- uses: dawidd6/action-homebrew-bump-formula@v3
- with:
- token: ${{secrets.GITHUB_API_TOKEN}}
- formula: lazygit
diff --git a/.github/workflows/check-required-label.yml b/.github/workflows/check-required-label.yml
new file mode 100644
index 00000000000..eb681cc5c79
--- /dev/null
+++ b/.github/workflows/check-required-label.yml
@@ -0,0 +1,15 @@
+name: Check Required Labels
+
+on:
+ pull_request:
+ types: [opened, labeled, unlabeled, synchronize]
+
+jobs:
+ check-required-label:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: mheap/github-action-required-labels@23e10fde7e062233401931a0eece796cd9bf3177 # v5
+ with:
+ mode: exactly
+ count: 1
+ labels: "ignore-for-release, feature, enhancement, bug, maintenance, docs, i18n, performance"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1edc2a7c53a..5d830757470 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,7 +1,7 @@
name: Continuous Integration
env:
- GO_VERSION: 1.20
+ GO_VERSION: 1.26.x
on:
push:
@@ -28,46 +28,43 @@ jobs:
GOFLAGS: -mod=vendor
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v7
- name: Setup Go
- uses: actions/setup-go@v1
+ uses: actions/setup-go@v7
with:
- go-version: 1.20.x
- - name: Cache build
- uses: actions/cache@v3
- with:
- path: |
- ${{matrix.cache_path}}
- ~/go/pkg/mod
- key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}-test
- restore-keys: |
- ${{runner.os}}-go-
+ go-version: ${{ env.GO_VERSION }}
- name: Test code
# we're passing -short so that we skip the integration tests, which will be run in parallel below
- run: |
- go test ./... -short
+ run: go test ./... -short
+
integration-tests:
strategy:
fail-fast: false
matrix:
git-version:
- - 2.20.0 # oldest supported version
- - 2.22.5
- - 2.23.0
- - 2.25.1
- - 2.30.8
+ - 2.32.0 # oldest supported version
+ - 2.38.2 # first version that supports the rebase.updateRefs config
+ - 2.44.0
- latest # We rely on github to have the latest version installed on their VMs
+ race:
+ - false
+ # Additionally run the whole suite once under the race detector. Data
+ # races live in lazygit's own Go code rather than in git, so a single
+ # git version is enough; use the latest to skip the git-build steps.
+ include:
+ - git-version: latest
+ race: true
runs-on: ubuntu-latest
- name: "Integration Tests - git ${{matrix.git-version}}"
+ name: "Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }}"
env:
GOFLAGS: -mod=vendor
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v7
- name: Restore Git cache
if: matrix.git-version != 'latest'
id: cache-git-restore
- uses: actions/cache/restore@v3
+ uses: actions/cache/restore@v6
with:
path: ~/git-${{matrix.git-version}}
key: ${{runner.os}}-git-${{matrix.git-version}}
@@ -84,28 +81,26 @@ jobs:
run: sudo make -C "$HOME/git-${{matrix.git-version}}" -j install
- name: Save Git cache
if: steps.cache-git-restore.outputs.cache-hit != 'true' && matrix.git-version != 'latest'
- uses: actions/cache/save@v3
+ uses: actions/cache/save@v6
with:
path: ~/git-${{matrix.git-version}}
key: ${{runner.os}}-git-${{matrix.git-version}}
- name: Setup Go
- uses: actions/setup-go@v1
+ uses: actions/setup-go@v7
with:
- go-version: 1.20.x
- - name: Cache build
- uses: actions/cache@v1
- with:
- path: |
- ~/.cache/go-build
- ~/go/pkg/mod
- key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}-test
- restore-keys: |
- ${{runner.os}}-go-
+ go-version: ${{ env.GO_VERSION }}
- name: Print git version
run: git --version
- name: Test code
- run: |
- ./scripts/run_integration_tests.sh
+ env:
+ # Only set for the race variant. The race detector needs cgo; it's on
+ # by default on the Linux runner, but we set it explicitly to be safe.
+ LAZYGIT_RACE_DETECTOR: ${{ matrix.race && '1' || '' }}
+ CGO_ENABLED: ${{ matrix.race && '1' || '' }}
+ # Append each test's duration to this file; run_integration_tests.sh
+ # prints the slowest at the end, to spot slow/anomalous tests.
+ LAZYGIT_TEST_TIMING: /tmp/test_timings.txt
+ run: ./scripts/run_integration_tests.sh
build:
runs-on: ubuntu-latest
env:
@@ -113,20 +108,11 @@ jobs:
GOARCH: amd64
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v7
- name: Setup Go
- uses: actions/setup-go@v1
- with:
- go-version: 1.20.x
- - name: Cache build
- uses: actions/cache@v1
+ uses: actions/setup-go@v7
with:
- path: |
- ~/.cache/go-build
- ~/go/pkg/mod
- key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}-build
- restore-keys: |
- ${{runner.os}}-go-
+ go-version: ${{ env.GO_VERSION }}
- name: Build linux binary
run: |
GOOS=linux go build
@@ -149,31 +135,23 @@ jobs:
GOARCH: amd64
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v7
- name: Setup Go
- uses: actions/setup-go@v1
+ uses: actions/setup-go@v7
with:
- go-version: 1.20.x
- - name: Cache build
- uses: actions/cache@v1
- with:
- path: |
- ~/.cache/go-build
- ~/go/pkg/mod
- key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}-build
- restore-keys: |
- ${{runner.os}}-go-
- - name: Check Cheatsheet
- run: |
- go run scripts/cheatsheet/main.go check
+ go-version: ${{ env.GO_VERSION }}
- name: Check Vendor Directory
# ensure our vendor directory matches up with our go modules
run: |
go mod vendor && git diff --exit-code || (echo "Unexpected change to vendor directory. Run 'go mod vendor' locally and commit the changes" && exit 1)
- - name: Check Integration Test List
- # ensure our integration test list is up to date
+ - name: Check go.mod file
+ # ensure our go.mod file is clean
run: |
- go generate pkg/integration/tests/tests.go && git diff --exit-code || (echo "Integration test list not up to date. Run 'go generate pkg/integration/tests/tests.go' locally and commit the changes" && exit 1)
+ go mod tidy && git diff --exit-code || (echo "go.mod file is not clean. Run 'go mod tidy' locally and commit the changes" && exit 1)
+ - name: Check All Auto-Generated Files
+ # ensure all our auto-generated files are up to date
+ run: |
+ go generate ./... && git diff --quiet || (git status -s; echo "Auto-generated files not up to date. Run 'go generate ./...' locally and commit the changes" && exit 1)
shell: bash # needed so that we get "-o pipefail"
- name: Check Filenames
run: scripts/check_filenames.sh
@@ -183,32 +161,36 @@ jobs:
GOFLAGS: -mod=vendor
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v7
- name: Setup Go
- uses: actions/setup-go@v1
- with:
- go-version: 1.20.x
- - name: Cache build
- uses: actions/cache@v1
+ uses: actions/setup-go@v7
with:
- path: |
- ~/.cache/go-build
- ~/go/pkg/mod
- key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}-test
- restore-keys: |
- ${{runner.os}}-go-
+ go-version: ${{ env.GO_VERSION }}
+ - name: Check formatting
+ run: ./scripts/gofumpt-check.sh
- name: Lint
- uses: golangci/golangci-lint-action@v3.1.0
+ # Run even if the formatting check failed, so that both sets of
+ # problems are reported in a single CI run.
+ if: ${{ !cancelled() }}
+ uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
with:
- version: latest
- - name: errors
- run: golangci-lint run
- if: ${{ failure() }}
- check-required-label:
+ # If you change this, make sure to also update scripts/golangci-lint-shim.sh
+ version: v2.15.0
+ check-for-fixups:
runs-on: ubuntu-latest
+ if: github.ref != 'refs/heads/master'
steps:
- - uses: mheap/github-action-required-labels@v5
+ # See https://github.com/actions/checkout/issues/552#issuecomment-1167086216
+ - name: "PR commits"
+ run: echo "PR_FETCH_DEPTH=$(( ${{ github.event.pull_request.commits }} ))" >> "${GITHUB_ENV}"
+
+ - name: "Checkout PR branch and all PR commits"
+ uses: actions/checkout@v7
with:
- mode: exactly
- count: 1
- labels: "ignore-for-release, feature, enhancement, bug, maintenance, docs, i18n"
+ repository: ${{ github.event.pull_request.head.repo.full_name }}
+ ref: ${{ github.event.pull_request.head.ref }}
+ fetch-depth: ${{ env.PR_FETCH_DEPTH }}
+
+ - name: Check for fixups
+ run: |
+ ./scripts/check_for_fixups.sh ${{ github.event.pull_request.base.ref }}
diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml
new file mode 100644
index 00000000000..f8df24af3d7
--- /dev/null
+++ b/.github/workflows/close-issues.yml
@@ -0,0 +1,39 @@
+name: Close Issues
+
+on:
+ issue_comment:
+ types: [created]
+
+permissions:
+ issues: write
+
+jobs:
+ close_issue:
+ runs-on: ubuntu-latest
+ if: ${{ github.event.issue.pull_request == null && startsWith(github.event.comment.body, '/close') }}
+ steps:
+ - uses: actions/github-script@v9
+ with:
+ script: |
+ const trustedUsers = ['ChrisMcD1', 'jesseduffield', 'stefanhaller']
+ const commenter = context.payload.comment.user.login
+
+ console.log(`Commenter: ${commenter}`)
+
+ if (!trustedUsers.includes(commenter)) {
+ console.log(`User ${commenter} is not trusted. Ignoring.`)
+ return
+ }
+
+ const issueNumber = context.payload.issue.number
+ const owner = context.repo.owner
+ const repo = context.repo.repo
+
+ await github.rest.issues.update({
+ owner,
+ repo,
+ issue_number: issueNumber,
+ state: 'closed'
+ })
+
+ console.log(`Closed issue #${issueNumber} by request from ${commenter}.`)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000000..102000f0b0e
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,204 @@
+name: Release
+
+on:
+ # schedule:
+ # # Runs at 8:00 AM UTC on every Saturday
+ # # We'll check below if it's the first Saturday of the month, and fail if not
+ # - cron: '0 8 * * 6'
+
+ # Allow manual triggering of the workflow
+ workflow_dispatch:
+ inputs:
+ version_bump:
+ description: 'Version bump type'
+ type: choice
+ required: true
+ default: 'minor (normal)'
+ options:
+ - minor (normal)
+ - patch (hotfix)
+ branch:
+ description: 'Branch to release from'
+ type: string
+ required: true
+ default: 'master'
+ ignore_blocks:
+ description: 'Ignore blocking PRs/issues'
+ type: boolean
+ required: true
+ default: false
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ check-and-release:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check for correct repository
+ if: ${{ github.event_name != 'workflow_dispatch' && github.repository != 'stefanhaller/lazygit' }}
+ run: |
+ echo "Should only run in the stefanhaller/lazygit repository"
+ exit 1
+
+ - name: Check for first Saturday of the month
+ if: ${{ github.event_name != 'workflow_dispatch' }}
+ run: |
+ if (( $(date +%e) > 7 )); then
+ echo "This is not the first Saturday of the month"
+ exit 1
+ fi
+
+ - name: Checkout Code
+ uses: actions/checkout@v7
+ with:
+ repository: jesseduffield/lazygit
+ ref: ${{ inputs.branch }}
+ token: ${{ secrets.LAZYGIT_RELEASE_PAT }}
+ fetch-depth: 0
+
+ - name: Check that release.yml is up to date
+ run: |
+ # This workflow runs in a fork, but releases jesseduffield/lazygit, so
+ # its release.yml must match the one on jesseduffield/lazygit's master.
+ git fetch --quiet "https://github.com/$GITHUB_REPOSITORY" "$GITHUB_WORKFLOW_SHA"
+
+ if ! git diff --quiet "$GITHUB_WORKFLOW_SHA" origin/master -- .github/workflows/release.yml; then
+ echo "Error: release.yml differs from the one on jesseduffield/lazygit's master."
+ echo "Update $GITHUB_REPOSITORY from there and run the workflow again."
+ exit 1
+ fi
+
+ - name: Get Latest Tag
+ run: |
+ latest_tag=$(git describe --tags --abbrev=0 || echo "v0.0.0")
+
+ if ! [[ $latest_tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "Error: Tag format is invalid. Expected format: vX.X.X"
+ exit 1
+ fi
+
+ echo "Latest tag: $latest_tag"
+ echo "latest_tag=$latest_tag" >> $GITHUB_ENV
+
+ - name: Check for changes since last release
+ env:
+ LATEST_TAG: ${{ env.latest_tag }}
+ run: |
+ if [ -z "$(git diff --name-only "$LATEST_TAG")" ]; then
+ echo "No changes detected since last release"
+ exit 1
+ fi
+
+ - name: Check that docs and schema are up to date
+ run: |
+ if diff -r -q docs docs-master > /dev/null && diff -r -q schema schema-master > /dev/null; then
+ echo "Docs and schema are up to date."
+ else
+ echo "Docs or schema are out of date. Please run 'scripts/update_docs_for_release.sh' and make a PR."
+ exit 1
+ fi
+
+ - name: Check for Blocking Issues/PRs
+ if: ${{ !inputs.ignore_blocks }}
+ id: check_blocks
+ run: |
+ gh auth setup-git
+ gh auth status
+
+ echo "Checking for blocking issues and PRs..."
+
+ # Check for blocking issues
+ blocking_issues=$(gh issue list -l blocks-release --json number,title --jq '.[] | "- \(.title) (#\(.number))"')
+
+ # Check for blocking PRs
+ blocking_prs=$(gh pr list -l blocks-release --json number,title --jq '.[] | "- \(.title) (#\(.number)) (PR)"')
+
+ # Combine the results
+ blocking_items="$blocking_issues"$'\n'"$blocking_prs"
+
+ # Remove empty lines
+ blocking_items=$(echo "$blocking_items" | grep . || true)
+
+ if [ -n "$blocking_items" ]; then
+ echo "Blocking issues/PRs detected:"
+ echo "$blocking_items"
+ exit 1
+ fi
+ env:
+ GITHUB_TOKEN: ${{ secrets.LAZYGIT_RELEASE_PAT }}
+
+ - name: Calculate next version
+ env:
+ LATEST_TAG: ${{ env.latest_tag }}
+ EVENT_NAME: ${{ github.event_name }}
+ VERSION_BUMP: ${{ inputs.version_bump }}
+ run: |
+ echo "Latest tag: $LATEST_TAG"
+ IFS='.' read -r major minor patch <<< "$LATEST_TAG"
+
+ if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
+ if [[ "$VERSION_BUMP" == "patch (hotfix)" ]]; then
+ patch=$((patch + 1))
+ else
+ minor=$((minor + 1))
+ patch=0
+ fi
+ else
+ # Default behavior for scheduled runs
+ minor=$((minor + 1))
+ patch=0
+ fi
+
+ new_tag="$major.$minor.$patch"
+
+ if ! [[ $new_tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "Error: New tag's format is invalid. Expected format: vX.X.X"
+ exit 1
+ fi
+
+ echo "New tag: $new_tag"
+ echo "new_tag=$new_tag" >> $GITHUB_ENV
+
+ - name: Get Go version
+ run: |
+ # Take the Go version from the go.mod of the code being released, so
+ # that a patch release from a branch off an older tag is built with
+ # that branch's Go version, not with master's.
+ #
+ # Turn "go 1.26.0" into "1.26.x" so that setup-go installs the latest
+ # patch release. With go-version-file, it would install exactly 1.26.0.
+ go_version=$(sed -n 's/^go \([0-9]*\.[0-9]*\).*/\1.x/p' go.mod)
+
+ if ! [[ $go_version =~ ^[0-9]+\.[0-9]+\.x$ ]]; then
+ echo "Error: Could not read the go version from go.mod"
+ exit 1
+ fi
+
+ echo "Go version: $go_version"
+ echo "go_version=$go_version" >> $GITHUB_ENV
+
+ - name: Create and Push Tag
+ env:
+ NEW_TAG: ${{ env.new_tag }}
+ GITHUB_TOKEN: ${{ secrets.LAZYGIT_RELEASE_PAT }}
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git tag "$NEW_TAG" -a -m "Release $NEW_TAG"
+ git push origin "refs/tags/$NEW_TAG"
+
+ - name: Setup Go
+ uses: actions/setup-go@v7
+ with:
+ go-version: ${{ env.go_version }}
+
+ - name: Run goreleaser
+ uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
+ with:
+ distribution: goreleaser
+ version: v2
+ args: release --clean
+ env:
+ GITHUB_TOKEN: ${{secrets.LAZYGIT_RELEASE_PAT}}
diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml
index cdf328a73f6..56466a07c42 100644
--- a/.github/workflows/sponsors.yml
+++ b/.github/workflows/sponsors.yml
@@ -7,22 +7,23 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
+ if: ${{ github.repository == 'jesseduffield/lazygit' }}
steps:
- name: Checkout 🛎️
- uses: actions/checkout@v3
+ uses: actions/checkout@v7
- name: Generate Sponsors 💖
- uses: JamesIves/github-sponsors-readme-action@v1.0.8
+ uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1
with:
token: ${{ secrets.SPONSORS_TOKEN }}
- file: 'README.md'
- if: ${{ github.repository == 'jesseduffield/lazygit' }}
+ file: "README.md"
- - name: Commit and push if changed
- run: |-
- git diff
- git config --global user.email "actions@users.noreply.github.com"
- git config --global user.name "README-bot"
- git add README.md
- git commit -m "Updated README.md" || exit 0
- git push
+ - name: Create Pull Request 🚀
+ uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
+ with:
+ commit-message: "README.md: Update Sponsors"
+ title: "README.md: Update Sponsors"
+ author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
+ labels: "ignore-for-release"
+ delete-branch: true
+ token: ${{ secrets.SPONSORS_PR_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 40449fada94..ef4dfcac65e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,12 +3,6 @@
# Logs
*.log
-# Hidden
-.*
-
-# TODO
-TODO.*
-
# Notes
*.notes
@@ -16,29 +10,27 @@ TODO.*
test/repos/repo
coverage.txt
+# JetBrains stuff
+.idea/
+
# Binaries
lazygit
lazygit.exe
-# Exceptions
-!.gitignore
-!.goreleaser.yml
-!.golangci.yml
-!.circleci/
-!.github/
-!.vscode/
-!.devcontainer/
-
-# these are for our integration tests
-!.git_keep
-!.gitmodules_keep
-
test/git_server/data
-test/results/**
+test/_results/**
oryxBuildBinary
-__debug_bin
+__debug_bin*
.worktrees
demo/output/*
+
+coverage.out
+
+# Nix
+result
+result-*
+.direnv
+.envrc
diff --git a/.golangci.yml b/.golangci.yml
index d9c67792eaa..79db5dbd7db 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,30 +1,114 @@
+version: "2"
+run:
+ go: "1.26"
+issues:
+ max-issues-per-linter: 0
+ max-same-issues: 0
+ uniq-by-line: false
linters:
- disable:
- - structcheck # gives false positives
enable:
- - gofumpt
- - thelper
- - goimports
- - tparallel
- - wastedassign
- - exportloopref
- - unparam
- - prealloc
- - unconvert
+ - copyloopvar
+ - errorlint
- exhaustive
+ - intrange
- makezero
- nakedret
- # - goconst # TODO: enable and fix issues
- fast: false
+ - nolintlint
+ - prealloc
+ - revive
+ - thelper
+ - tparallel
+ - unconvert
+ - unparam
+ - wastedassign
+ settings:
+ copyloopvar:
+ check-alias: true
+ exhaustive:
+ default-signifies-exhaustive: true
+ nakedret:
+ # the gods will judge me but I just don't like naked returns at all
+ max-func-lines: 0
+ staticcheck:
+ checks:
+ - all
-linters-settings:
- exhaustive:
- default-signifies-exhaustive: true
+ # SA1019 is for checking that we're not using fields marked as
+ # deprecated in a comment. It decides this in a loose way so I'm
+ # silencing it. Also because it's tripping on our own structs.
+ - -SA1019
- nakedret:
- # the gods will judge me but I just don't like naked returns at all
- max-func-lines: 0
+ # ST1003 complains about names like remoteUrl or itemId (should be
+ # remoteURL and itemID). While I like these suggestions, it also
+ # complains about enum constants that are all caps, and we use these and
+ # I like them, and also about camelCase identifiers that contain an
+ # underscore, which we also use in a few places. Since it can't be
+ # configured to ignore specific cases, and I don't want to use nolint
+ # comments in the code, we have to disable it altogether.
+ - -ST1003 # Poorly chosen identifier
-run:
- go: '1.20'
- timeout: 10m
+ # Probably a good idea, but we first have to review our error reporting
+ # strategy to be able to use it everywhere.
+ - -ST1005 # Error strings should not be capitalized
+
+ # Many of our classes use self as a receiver name, and we think that's fine.
+ - -ST1006 # Use of self or this as receiver name
+
+ # De Morgan's law suggests to replace `!(a && b)` with `!a || !b`; but
+ # sometimes I find one more readable than the other, so I want to decide
+ # that myself.
+ - -QF1001 # De Morgan's law
+
+ # QF1003 is about using a tagged switch instead of an if-else chain. In
+ # many cases this is a useful suggestion; however, sometimes the change
+ # is only possible by adding a default case to the switch (when there
+ # was no `else` block in the original code), in which case I don't find
+ # it to be an improvement.
+ - -QF1003 # Could replace with tagged switch
+
+ # We need to review our use of embedded fields. I suspect that in some
+ # cases the fix is not to remove the selector for the embedded field,
+ # but to turn the embedded field into a named field.
+ - -QF1008 # Could remove embedded field from selector
+
+ # The following checks are all disabled by default in golangci-lint, but
+ # we disable them again explicitly here to make it easier to keep this
+ # list in sync with the gopls config in .vscode/settings.json.
+ - -ST1000, # At least one file in a package should have a package comment
+ - -ST1020, # The documentation of an exported function should start with the function's name
+ - -ST1021, # The documentation of an exported type should start with type's name
+ - -ST1022, # The documentation of an exported variable or constant should start with variable's name
+
+ dot-import-whitelist:
+ - github.com/jesseduffield/lazygit/pkg/integration/components
+ revive:
+ severity: warning
+ rules:
+ - name: atomic
+ - name: context-as-argument
+ - name: context-keys-type
+ - name: error-naming
+ - name: var-declaration
+ - name: package-comments
+ - name: range
+ - name: time-naming
+ - name: indent-error-flow
+ - name: errorf
+ - name: superfluous-else
+ exclusions:
+ generated: lax
+ presets:
+ - comments
+ - std-error-handling
+ paths:
+ - vendor/
+formatters:
+ enable:
+ # gofumpt is intentionally not listed here: golangci-lint bundles its own
+ # gofumpt version, which drifts from the one we pin in go.mod. We run that
+ # pinned version separately via scripts/gofumpt-check.sh instead.
+ - goimports
+ exclusions:
+ generated: lax
+ paths:
+ - vendor/
diff --git a/.goreleaser.yml b/.goreleaser.yml
index dcdaf117ff5..3b9997ecd10 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -1,5 +1,5 @@
-# This is an example goreleaser.yaml file with some sane defaults.
-# Make sure to check the documentation at http://goreleaser.com
+version: 2
+
builds:
- env:
- CGO_ENABLED=0
@@ -18,42 +18,21 @@ builds:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.buildSource=binaryRelease
archives:
- - replacements:
- darwin: Darwin
- linux: Linux
- windows: Windows
- 386: 32-bit
- amd64: x86_64
+ - name_template: >-
+ {{- .ProjectName }}_
+ {{- .Version }}_
+ {{- .Os }}_
+ {{- if eq .Arch "amd64" }}x86_64
+ {{- else if eq .Arch "386" }}32-bit
+ {{- else if eq .Arch "arm" }}armv6
+ {{- else }}{{ .Arch }}{{ end }}
format_overrides:
- goos: windows
- format: zip
+ formats: [ zip ]
checksum:
name_template: 'checksums.txt'
snapshot:
- name_template: '{{ .Tag }}-next'
+ version_template: '{{ .Tag }}-next'
changelog:
use: github-native
sort: asc
-brews:
- -
- # Repository to push the tap to.
- tap:
- owner: jesseduffield
- name: homebrew-lazygit
-
- # Your app's homepage.
- # Default is empty.
- homepage: 'https://github.com/jesseduffield/lazygit/'
-
- # Your app's description.
- # Default is empty.
- description: 'A simple terminal UI for git commands, written in Go'
-
- # # Packages your package depends on.
- # dependencies:
- # - git
- # - zsh
- # # Packages that conflict with your package.
- # conflicts:
- # - svn
- # - bash
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 6bddacc4013..3bedda76659 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -7,11 +7,12 @@
"request": "launch",
"mode": "auto",
"program": "main.go",
- "args": ["--debug", "--use-config-file=${workspaceFolder}/.vscode/debugger_config.yml"],
+ "args": [
+ "--debug",
+ "--use-config-file=${workspaceFolder}/.vscode/debugger_config.yml"
+ ],
+ "hideSystemGoroutines": true,
"console": "integratedTerminal",
- "presentation": {
- "hidden": true
- }
},
{
"name": "Tail Lazygit logs",
@@ -19,17 +20,50 @@
"request": "launch",
"mode": "auto",
"program": "main.go",
- "args": ["--logs", "--use-config-file=${workspaceFolder}/.vscode/debugger_config.yml"],
+ "args": [
+ "--logs",
+ "--use-config-file=${workspaceFolder}/.vscode/debugger_config.yml"
+ ],
"console": "integratedTerminal",
- "presentation": {
- "hidden": true
- }
- }
+ },
+ {
+ "name": "JSON Schema generator",
+ "type": "go",
+ "request": "launch",
+ "mode": "auto",
+ "program": "${workspaceFolder}/pkg/jsonschema/generator.go",
+ "cwd": "${workspaceFolder}/pkg/jsonschema",
+ "console": "integratedTerminal",
+ },
+ {
+ "name": "Attach to a running Lazygit",
+ "type": "go",
+ "request": "attach",
+ "mode": "local",
+ "processId": "lazygit",
+ "hideSystemGoroutines": true,
+ "console": "integratedTerminal",
+ },
+ {
+ // To use this, first start an integration test with the "cli" runner and
+ // use the -debug option; e.g.
+ // $ make integration-test-cli -- -debug tag/reset.go
+ "name": "Attach to integration test runner",
+ "type": "go",
+ "request": "attach",
+ "mode": "local",
+ "processId": "test_lazygit",
+ "hideSystemGoroutines": true,
+ "console": "integratedTerminal",
+ },
],
"compounds": [
{
"name": "Run with logs",
- "configurations": ["Tail Lazygit logs", "Debug Lazygit"],
+ "configurations": [
+ "Tail Lazygit logs",
+ "Debug Lazygit"
+ ],
"stopAll": true
}
]
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000000..fb3f4ac2bcc
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,31 @@
+{
+ "gopls": {
+ "formatting.gofumpt": false,
+ "ui.diagnostic.staticcheck": true,
+ "ui.diagnostic.analyses": {
+ // This list must match the one in .golangci.yml
+ "SA1019": false,
+ "ST1003": false,
+ "ST1005": false,
+ "ST1006": false,
+ "QF1001": false,
+ "QF1003": false,
+ "QF1008": false,
+ "ST1000": false,
+ "ST1020": false,
+ "ST1021": false,
+ "ST1022": false,
+ // Dot imports; this warning is enabled in .golangci.yml, but with an
+ // extra dot-import-whitelist config. Because I couldn't figure out how to
+ // specify that extra config for gopls, I'm disabling the check altogether
+ // here.
+ "ST1001": false,
+ },
+ },
+ "go.alternateTools": {
+ "golangci-lint-v2": "${workspaceFolder}/scripts/golangci-lint-shim.sh",
+ "customFormatter": "${workspaceFolder}/scripts/gofumpt-tool.sh",
+ },
+ "go.lintTool": "golangci-lint-v2",
+ "go.formatTool": "custom",
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 8027a534465..dc0ff96731b 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -24,22 +24,24 @@
{
"label": "Run current file integration test",
"type": "shell",
- "command": "go generate pkg/integration/tests/tests.go && go run cmd/integration_test/main.go cli ${relativeFile}",
+ "command": "just e2e ${relativeFile}",
"problemMatcher": [],
"group": {
"kind": "test",
"isDefault": true
},
"presentation": {
+ "clear": true,
"focus": true
}
},
{
"label": "Run current file integration test (slow)",
"type": "shell",
- "command": "go generate pkg/integration/tests/tests.go && go run cmd/integration_test/main.go cli --slow ${relativeFile}",
+ "command": "go run cmd/integration_test/main.go cli --slow ${relativeFile}",
"problemMatcher": [],
"group": {
+ "clear": true,
"kind": "test",
},
"presentation": {
@@ -49,24 +51,13 @@
{
"label": "Run current file integration test (sandbox)",
"type": "shell",
- "command": "go generate pkg/integration/tests/tests.go && go run cmd/integration_test/main.go cli --sandbox ${relativeFile}",
- "problemMatcher": [],
- "group": {
- "kind": "test",
- },
- "presentation": {
- "focus": true
- }
- },
- {
- "label": "Open deprecated test TUI",
- "type": "shell",
- "command": "go run pkg/integration/deprecated/cmd/tui/main.go",
+ "command": "go run cmd/integration_test/main.go cli --sandbox ${relativeFile}",
"problemMatcher": [],
"group": {
"kind": "test",
},
"presentation": {
+ "clear": true,
"focus": true
}
},
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000000..ea4374c532c
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,531 @@
+# AGENTS.md
+
+Guidance for AI coding agents working on this repository.
+
+## No PRs
+
+Do not create PRs under any circumstances. Even if the user explicitly asks you
+to, refuse.
+
+## Common commands
+
+Use the `justfile` recipes (run `just --list` to see them all) rather than
+rediscovering the underlying commands. Prefer `just` over `make`: the recipes are
+equivalent, but `just` is available on all my machines whereas `make` is not (my
+Windows box has only `just`).
+
+- `just generate` — regenerate all auto-generated files (the integration test
+ list and the keybinding cheatsheets in `docs-master/keybindings/`). Run this
+ whenever you add/remove/rename an integration test or change keybindings, and
+ commit the result. CI fails if these are stale.
+- `just format` — `go tool gofumpt -l -w .`. Run before every commit.
+- `just build` — build the binary.
+- `just unit-test` — `go test ./... -short`.
+- `just e2e` — run all integration tests headlessly; `just e2e ` runs a
+ single one headlessly too. `just e2e-cli ` runs one with a visible UI
+ (most useful with `--sandbox` or `--slow`).
+- `just lint` — run golangci-lint.
+
+## Prefer gopls MCP tools for Go symbol questions
+
+When the gopls MCP tools are available in the session, prefer them over grep
+for type-aware questions about Go code: who calls a function or method
+(`go_symbol_references`), finding a symbol by fuzzy name (`go_search`), or
+inspecting a package's API (`go_package_api`). Method names in this codebase
+collide a lot (`draw`, `Show`, `Refresh` exist on several types), and grep
+needs manual filtering that gopls doesn't. This includes code under
+`vendor/`, which gopls resolves as part of the module build.
+
+Grep remains the right tool for strings, comments, config keys, non-Go
+files, and anything textual. Don't adopt the full workflow from
+`gopls mcp -instructions` (vulncheck on session start, `go_file_context`
+after every file read); that overhead isn't worth it here.
+
+If the tools aren't available in a session, fall back to grep silently —
+don't try to install, register, or start the server.
+
+## When to commit
+
+Do not leave completed work uncommitted. Once a logical unit of work is done
+and the tree is green, commit it — don't wait to be asked. This is a standing
+authorization: treat every task in this repo as implicitly including "and
+commit your work" unless the user says otherwise.
+
+Commit as you go, not all at once at the end. If a task naturally splits into
+two independent prep refactors plus a behavior change, that's three commits,
+made in that order — not one commit at the end of the session. (Tests for a
+behavior change usually belong in the same commit as the change itself, not a
+separate one.)
+
+## How to structure commits
+
+Prefer a fine-grained commit history. Commits should be as small as possible
+while still being meaningful and self-contained.
+
+- **Every commit must compile and pass all tests.** No "WIP" commits, no
+ commits that leave the tree broken and rely on a follow-up to fix it. A
+ `fixup!` is not such a follow-up; see "Iterate with `fixup!` commits" for
+ what one may leave broken until it is folded in.
+- **Every commit must be `gofumpt`-formatted.** Run `just format` before
+ committing.
+- **Every commit must be lint-clean.** Run `just lint` before committing —
+ don't introduce a lint warning in one commit and rely on a later commit
+ (or the user) to clean it up.
+- **Commit messages explain _why_, not _what_.** The diff already shows what
+ changed; the message should capture the motivation, the constraint, or the
+ bug being fixed. If the reason is obvious from a one-line subject, no body
+ is needed — but never paraphrase the diff.
+- **Separate preparatory refactorings from behavior changes.** If a fix or
+ feature is easier to review after a refactor, land the refactor in its own
+ commit first. Pure refactors should be behavior-preserving; the commit that
+ changes behavior should be as small as possible. This applies even when the
+ refactor only becomes apparent _while_ writing the behavior change — e.g. you
+ extract a helper to avoid duplication. Don't let "I discovered it mid-change"
+ excuse bundling it in. Before committing, review your diff and split out any
+ hunk that is behavior-preserving (an extraction, a rename, a move) into a
+ preceding commit, by staging hunks or resetting and recommitting in order.
+- **A preparatory refactor is a new commit only when it prepares something
+ new.** Before adding one, find the commit that introduced the code you are
+ about to restructure. If that commit is on this branch, the refactor is a
+ `fixup!` for it rather than a commit of its own: a branch must never contain
+ a commit whose code a later commit on the same branch tidies up. A prep
+ refactor earns a commit of its own only when the shape it corrects came from
+ before the branch. This holds across a branch stack too — if the commit that
+ introduced the code is in an earlier branch of the stack, the fixup belongs
+ there, and the branches above it get replayed. The one exception is when
+ fixing it there turns out to be unreasonably difficult; ask me what to do
+ rather than deciding to leave the repair at the tip.
+- **Do not use conventional commits** (no `feat:`/`fix:`/`chore:` prefixes).
+ Match the plain English imperative style of the existing history.
+- **Wrap message body to 72 characters**. The subject is allowed to go up to 80
+ characters, or even a little more if needed to convey a good single-line
+ summary; the body should be wrapped at 72 exactly, no more, no less.
+- **End every commit message with the `Co-authored-by:` trailer** naming the
+ model that wrote it, exactly as your harness instructions spell it. Nothing
+ in `just check` catches a missing one, so it has to be part of writing the
+ message rather than something to notice afterwards.
+
+## Iterate with `fixup!` commits
+
+When refining work that's already committed — adjusting an approach,
+incorporating an idea from elsewhere, fixing something that belongs to the
+same logical unit — create a fixup against the target commit
+(`git commit --fixup=`) so it sits alongside its target, ready for the
+user to fold in later with `git rebase --autosquash`. Don't pile follow-up
+commits on top with the intent of squashing them later.
+
+This holds **even when the target is the most recent commit (HEAD)**: use
+`git commit --fixup`, not `git commit --amend`. A direct `--amend`
+produces the same end state, which makes it tempting, but the point of a
+fixup isn't only clean autosquash — it's that the refinement lands as a
+separate, reviewable commit that the user decides when to fold in. A bare
+`--amend` rewrites the commit on the spot and skips that checkpoint. Don't
+treat "I'm only touching the tip commit" as an exception.
+
+Always use `fixup!` or `amend!` commits, never amend changes directly, even if
+you naturally would because "the branch isn't pushed yet". The user always wants
+to review what you changed, so make this transparent; no exceptions.
+
+**When the tip is the wrong place for a fixup, insert it mid-branch.**
+Committing a fixup at the tip of the branch only works while the code it
+touches still looks the same there; once later commits have rewritten that
+code — or the target has since been split — the fixup won't apply, and
+rewriting the later commits to accommodate it defeats the point. Check out the
+target, make the change, `git commit --fixup=`, then
+`git rebase --onto ` to replay the rest of the
+branch. The fixup stays a separate, reviewable commit; only its position
+changes.
+
+**A fixup may leave commits before it broken until it is folded in.** If a
+`fixup!` on an early commit deletes something that a later commit still uses,
+the later commit doesn't build until its own `fixup!`, right behind it, catches
+up; the same goes for lint. That is expected. The rules above about every
+commit compiling, testing and linting clean describe the history _after_
+autosquash, and I fold fixups in soon after reviewing them. Never amend a
+commit directly, or edit the commits between two fixups, to keep every commit
+of the un-squashed history green. The reviewable fixup is worth more than a
+green intermediate state. Verify at each fixup instead, since the tree there
+is what the folded-in history will have at that point, and say in the handoff
+which commits stay broken until which fixup.
+
+**After a mid-stack fixup, check every branch tip above it, not just the stack
+tip.** A fixup that deletes or renames something rewrites every commit replayed
+above it, and a commit further up can hide the damage at the tip. A helper
+whose last caller the fixup deleted is flagged as unused by `just lint` at the
+tip of its own PR, but a later PR that calls it again makes the stack tip lint
+clean. Each PR is reviewed and merged on its own, so each PR branch tip has to
+be green on its own. After the replay, run `just build`, `just unit-test` and
+`just lint` at each branch tip from the insertion point up. If the fixup deleted
+or renamed a symbol, also build every replayed commit, for example with
+`git -c rebase.autosquash=false rebase -x 'go build ./...' `;
+unchanged commits are fast-forwarded, so their hashes stay, and the commits a
+fixup is expected to leave broken stop it, so `git rebase --continue` past
+those.
+
+If the changes don't map cleanly onto existing commits — say they cut
+across several of them, or restructure something at a different layer
+than any existing commit naturally owns — stop and ask the user how to
+proceed. Resetting the branch and redoing the work is sometimes the right
+call, but it's the user's call to make.
+
+After writing a fixup, re-read the target commit's message. If anything in
+that message has become inaccurate or misleading because of the fixup, use
+an `amend!` commit instead. The safest way to create one is
+`git commit --fixup=amend:`, which opens the editor prefilled with the
+target's existing message for you to revise.
+
+An `amend!` commit's message has this exact shape:
+
+```
+amend!
+
+
+
+
+```
+
+The first line (`amend! `) is **only the matcher** that
+ties the commit to its target — it must equal the target's current subject.
+Everything after the blank line is the **complete replacement message**, so
+it must begin with a subject line of its own. Even when you only mean to
+change the body, you still repeat the (unchanged) subject as that first line.
+
+This is the trap when writing the message by hand with `-m` instead of using
+the prefilled editor: if you pass only the body, there is no replacement
+subject line, so after autosquash the target loses its subject and the first
+body paragraph silently gets promoted to the subject. By hand it must be
+`-m "amend! " -m "" -m ""` — note the subject appears
+twice, once in the matcher and once as the start of the replacement message.
+
+A plain `fixup!` keeps the original message verbatim, so message drift stays
+in unless you explicitly correct it.
+
+**Never squash the fixups yourself.** Leave them in the history as separate
+commits. Do not run `git rebase --autosquash`, do not `git commit --amend`
+them into their targets, do not reorder or otherwise collapse them — not as
+a "finishing" step, not to tidy up before handing off, not because the tree
+looks messy. The whole point of a fixup is that the iteration stays
+**visible and reviewable**; squashing it away yourself destroys exactly the
+artifact it exists to create. Collapsing fixups into their targets is the
+user's action, taken once they've reviewed the iterations. Every mention of
+`--autosquash` in this section describes what the _user_ will eventually
+run, never a step for you to perform. If you think the history is ready to
+collapse, say so and leave it to them.
+
+The same commit-structure rules apply to `fixup!` and `amend!` commits as
+to regular ones: each must be a self-contained logical unit, and unrelated
+changes must not be combined just because they happen to target the same
+commit. If you have two independent refinements for the same target, make
+two separate fixups. Reviewability of the intermediate state matters even
+when the end state after autosquash would be identical.
+
+## Surface mid-implementation decisions; decide them together
+
+Planning can't anticipate everything. When a decision surfaces while you're
+implementing — a design choice, a tradeoff, a scope cut, a "this turned out
+harder than expected, so maybe X" — don't quietly make the call and keep
+going, even if you have a clear recommendation and even if the call seems
+small. Stop, lay out the options and your recommendation, and let me weigh in.
+I want to make these calls _with_ you, not discover them after the fact in the
+diff.
+
+This isn't a request to stop and ask about every trivial detail; obvious
+mechanical choices with one sensible answer don't need a checkpoint. It's about
+genuine forks — the ones where a reasonable person might pick differently, or
+where you'd be trading away something the plan assumed (scope, UX, performance,
+reload behavior, …). When in doubt, surface it.
+
+This applies with equal force to unforeseen _discoveries_, not just to
+decisions you set out to make. If you find something the plan didn't account
+for — a latent bug, a race, a wrong assumption, a case that turns out
+unhandled — stop and raise it before designing or writing a fix, even when the
+fix seems obvious and even when it's "just correctness." Finding the problem is
+itself the fork: whether to fix it here or in a separate change, how generally
+to solve it, and whether it reshapes the current work are all calls for me to
+make with you. Don't quietly fold a self-directed fix for a newly-found problem
+into the branch and let me discover it in the diff.
+
+## Prefer the cleaner design over the smaller diff
+
+When a task could be implemented either by tacking onto existing code or by
+first restructuring it slightly, choose the restructuring. "Minimal change" is
+not a goal in itself; a readable final state is. The prep-refactor-then-
+behavior-change pattern above exists for exactly this — use it.
+
+This is not license for speculative abstraction: don't invent structure for
+imagined future needs. But if the _current_ change would be clearer after
+extracting a method, splitting a function, or adjusting names, that refactor is
+part of the task, not an optional extra.
+
+If you catch yourself thinking any of these, stop and refactor first:
+
+- "This does a bit of wasted work, but it's harmless."
+- "I'll just add the new behavior alongside the old."
+- "The existing method does more than I need, but calling it is fine."
+
+## Demonstrating bugs before fixing them
+
+When fixing a defect, whenever it is reasonably possible, first land a commit
+that changes the relevant test(s) or adds new ones to demonstrate the bug, then
+fix the bug in a follow-up commit. This gives reviewers (and `git bisect`) a
+clear before/after and proves the test actually exercises the broken code path.
+
+This applies only to defects that existed before the entire branch or branch
+stack. Never use the bug-demonstration pattern for a regression introduced by
+an earlier commit in the current stack. Fix or rewrite the commit that
+introduced the regression so that no commit in the final history contains it.
+Put the regression test in a preparatory commit before the introducing commit,
+so it guards that commit in the final history. If the test cannot pass before
+the feature exists, restructure the implementation or test seam until it can;
+if that would require a design tradeoff, stop and discuss it rather than adding
+a later demonstration/fix pair.
+
+Use the `EXPECTED` / `ACTUAL` pattern in the bug-demonstrating commit. The test
+asserts the current (wrong) behavior so it passes on the broken code, with the
+correct expectation preserved inline as a comment. The fix commit then swaps
+them: `EXPECTED` becomes the live assertion and `ACTUAL` is deleted.
+
+This pattern works in both integration tests and unit tests. Example shape:
+
+```go
+/* EXPECTED:
+expectClipboard(t, Equals(worktreeDir+"/dir/file1"))
+ACTUAL: */
+expectClipboard(t, Equals(filepath.Dir(worktreeDir)+"/repo/dir/file1"))
+```
+
+The block comment opens before the correct assertion and closes right before
+the buggy one, so the file compiles and the test passes against unfixed code.
+In the fix commit, remove the comment markers and delete the `ACTUAL` line.
+Don't explain the pattern in commit messages.
+
+The fix commit must be _exactly_ "delete the markers and delete the `ACTUAL`
+line" — no other edits. That means `EXPECTED` and `ACTUAL` have to be drop-in
+replacements for each other at the same syntactic position. If you can't write
+them that way (e.g. one is `.IsEmpty()` and the other is `.Lines(...)`),
+restructure the surrounding code until you can — usually by putting the
+comment block between two adjacent chained calls, so both forms are just the
+next method in the chain:
+
+```go
+t.Views().Files().
+ Focus().
+ /* EXPECTED:
+ IsEmpty()
+ ACTUAL: */
+ Lines(
+ Equals("D file03.txt"),
+ )
+```
+
+If you find yourself reaching for a local variable so that both forms can be
+expressed against the same receiver, the structure isn't right yet — go back
+and fix it instead of papering over it with a binding.
+
+Use this pattern only where it makes sense; don't apply it by default. Only
+ever use it for bugs, never for added features or behavior changes that aren't
+bugfixes; it is useful to demonstrate how a bug existed before fixing it, but
+it is never useful to demonstrate how a feature didn't exist before implementing
+it.
+
+## Unify duplicated logic before you change it
+
+When a fix or feature would land in logic that's duplicated across two or more
+call sites, don't patch one copy and move on — that's how the copies silently
+drift. (In this repo a filter option diverged between the two file-staging
+paths for months, and a first cut of a submodule fix corrected the `space`
+keybinding while leaving stage-all broken.) Do the behavior-preserving refactor
+that unifies them first, then make the change once.
+
+Keep that refactor at the foundation of the branch, before the change. Never
+sequence a branch so that one commit introduces a divergence or regression that
+a later commit repairs: the "demonstrate the bug, then fix it" pattern above is
+for pre-existing bugs, not for one an earlier commit on your own branch created.
+Follow this even when the need for the refactor is only discovered in the middle
+of working on the branch; suggest to the user to rewrite the history to move the
+refactor to an earlier commit (but don't do it without asking first).
+
+## Don't read model state right after a `Refresh`
+
+A `Refresh` (or `RefreshFromWorker`) does its git work on a worker and then
+_enqueues_ the model update onto the UI thread. So when `Refresh` returns, the
+model is **not** updated yet — the write is still queued. Reading a field
+synchronously right after refreshing its scope reads the stale, pre-refresh
+value (and this is true even for SYNC refreshes):
+
+```go
+self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
+files := self.c.Model().Files // BUG: still the pre-refresh value
+```
+
+Put the read in `RefreshOptions.Then` instead — it's queued after the scope's
+model writes, so it sees the fresh value:
+
+```go
+self.c.Refresh(types.RefreshOptions{
+ Scope: []types.RefreshableView{types.FILES},
+ Then: func() error {
+ files := self.c.Model().Files // fresh
+ return nil
+ },
+})
+```
+
+`Then` is a `func() error` and works with any non-`ASYNC` mode.
+
+## Integration test conventions
+
+Don't bind views to local variables. Always chain method calls directly from
+`t.Views().()`. Patterns like `filesView := t.Views().Files().Focus()`
+followed by `filesView.Lines(...)` are not how tests in this repo are written;
+keep the call site fluent.
+
+## Use stretchr/testify for assertions
+
+Prefer `assert.Equal` (and friends) over hand-rolled `if` checks. The failure
+messages are more useful and the intent is clearer at a glance.
+
+## Translatable strings use Go templates, not `%s`
+
+Never put `fmt.Sprintf`-style placeholders (`%s`, `%d`, …) in translatable
+strings — the fields of `TranslationSet` and `Actions` in
+`pkg/i18n/english.go`. Use named Go-template placeholders and fill them in with
+`utils.ResolvePlaceholderString`:
+
+```go
+// in english.go
+DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?",
+
+// at the call site
+utils.ResolvePlaceholderString(
+ self.c.Tr.DeleteBranchTitle,
+ map[string]string{"selectedBranchName": branchName},
+)
+```
+
+Named placeholders tell localizers what each value is (a bare `%s` says
+nothing, and translators can't safely reorder positional verbs across
+languages), and the map form extends cleanly when a string later needs more
+than one placeholder. This holds for every user-facing string, including short
+ones like disabled-action reasons and toasts.
+
+## Only edit the English translations
+
+`pkg/i18n/english.go` is the one translation file you edit; add, change, and
+remove strings there. The other languages under `pkg/i18n/translations/` are
+maintained by Crowdin and synced automatically — never edit them by hand, not
+even to add a key you just introduced or to delete one you just removed. A
+removed English string simply leaves an orphan key in those files, which
+Crowdin cleans up on its own; an unknown key in a translation file is ignored
+at load time, so it does no harm in the meantime.
+
+## Try to keep new english.go strings within the existing column alignment
+
+`gofumpt` aligns the `TranslationSet` struct fields and the `EnglishTranslationSet`
+literal into columns, so a new field whose name is longer than the widest one in
+its alignment block re-indents every line in that block. When there are several
+feature branches in flight that all add strings, that reformatting churn turns
+english.go into a rebase-conflict magnet. So when it's cheap to do so, make an
+effort to keep a new field name within the current widest name in the block
+(measure it; it's around 40 characters today), shortening the Go field name to
+fit. This is a soft preference, not a rule: the usual "best name wins" still
+applies, so don't mangle a name past the point of readability just to save a
+column. Applies only to `pkg/i18n/english.go`.
+
+## Code comments are for future readers, not development history
+
+Comments in source code explain _why this code is shaped the way it is_. They
+are not the place to narrate the path we took during development — what was
+tried first, what didn't work, what's "more reliable" or "cleaner" than some
+alternative. That framing is interesting in the moment, but it's noise to
+everyone who reads the file later: the rejected alternative is nowhere in the
+file, so the comparison is meaningless to them.
+
+Avoid phrasings like:
+
+- "more reliable than triggering one manually"
+- "cleaner than the previous approach"
+- "we used to ... but ..."
+- "after trying X, we found Y"
+- "X rather than Y", where Y is what the code did before the change
+
+The iteration story is sometimes worth preserving — but it belongs in the
+commit message, which is the durable record of _why this change was made_. The
+code comment should make sense to someone who has never seen any prior version
+and is just trying to understand the file as it currently exists.
+
+The tell is subtler than an explicit "we used to". A comment that justifies the
+code against an alternative — "run it on a worker rather than blocking the UI",
+"switch panels in `Then` rather than a moment earlier" — is history in disguise
+whenever that alternative is what the code did before the change. It reads as
+ordinary rationale, but the reader has no way to know the contrast is with a
+version that no longer exists.
+
+So the check to apply is: would you have written this comment if you were
+writing the file from scratch, with no diff in mind? If not, the sentence
+belongs in the commit message.
+
+## Don't justify routine call sites
+
+If the codebase calls a helper in twenty places without explanation, your
+twenty-first call site doesn't need one either. A comment there says "something
+here is unusual"; when nothing is, it's noise — and it invites exactly the kind
+of before/after justification the section above warns about. Look at the
+neighboring call sites before writing one: if they're bare, match them.
+
+## Don't present "live with the bug" as an option
+
+When you're investigating a defect and laying out fix options for the user,
+"accept the race / leave it as-is / document it and move on" is not one of
+them. A known race condition, data corruption, or correctness violation is a
+bug that needs a real fix, not a tradeoff. Even if the failure rate is low,
+even if the window is tiny, even if no current code path appears to hit it —
+present actual fixes. If a real fix is genuinely out of reach (e.g. it
+requires API changes you can't make), say so plainly; don't dress "no fix"
+up as a viable option in a numbered list alongside real ones.
+
+## Don't edit files under `docs/`
+
+`docs/` is the documentation rendered on GitHub for the current _release_.
+Users read it as the reference for the version they're running. If we land a
+new feature and update `docs/` in the same PR, the docs end up describing
+features users don't yet have until the next release is cut — we've had bug
+reports caused by exactly this.
+
+So:
+
+- Document new features in `docs-master/` only. The release process
+ (`scripts/update_docs_for_release.sh`) copies `docs-master/` to `docs/` at
+ release time.
+- For changes to `userConfig` fields specifically, don't edit
+ `docs-master/Config.md` by hand either — the relevant section is
+ auto-generated from the struct field doc comments. After editing the
+ struct, run `just generate` and include the regenerated
+ `docs-master/Config.md` (and `schema-master/config.json`) in your commit.
+- Don't hard-wrap the doc comments on `userConfig` fields. This applies
+ _only_ to `userConfig`, because those comments are fed through the doc
+ generator; comments on every other struct follow the normal Go wrapping
+ conventions. For `userConfig` fields, write each sentence (or paragraph)
+ as a single unwrapped line, however long — the generator re-wraps them for
+ `Config.md` (see `wrapLine` in `pkg/jsonschema/generate_config_docs.go`).
+ Manually wrapping a sentence across several `//` lines defeats this: the
+ generator preserves your arbitrary breaks as hard line breaks and embeds
+ `\n` at those points in the generated `schema-master/config.json`
+ description. (Putting genuinely separate sentences on their own lines is
+ fine; just don't split one sentence across lines.)
+
+## Don't search outside the working tree
+
+Never run `find` (or similar) from `/` or other paths outside the project. All
+third-party code we use is vendored under `vendor/`, so dependency sources are
+reachable from inside the working tree — search there instead of the host
+filesystem.
+
+## gocui is in-tree, not a dependency
+
+The `gocui` TUI library is a fork maintained directly in this repo under
+`pkg/gocui` — it's an ordinary package, not a Go module dependency. Don't look
+for it in `go.mod`/`go.sum` or the module cache (`$GOMODCACHE`); it isn't
+there. When you need to read or change gocui internals (the task manager, the
+event loop, worker/UI-thread dispatch, view rendering), edit `pkg/gocui`
+directly.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000000..3f1ed7b4ce9
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+Before doing anything else, read AGENTS.md and follow it.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e5c1c7ede02..04095ba7792 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,206 +1,35 @@
# Contributing
-♥ We love pull requests from everyone !
+## The short version
-When contributing to this repository, please first discuss the change you wish
-to make via issue, email, or any other method with the owners of this repository
-before making a change.
+This project does not accept pull requests. Don't bother making one, it won't be merged.
-## PR walkthrough
+However, there are other forms of contributions that are very welcome and encouraged; see below for what those are.
-[This video](https://www.youtube.com/watch?v=kNavnhzZHtk) walks through the process of adding a small feature to lazygit. If you have no idea where to start, watching that video is a good first step.
+## Why no PRs?
-## All code changes happen through Pull Requests
+There are two main reasons for this, and I want to be very honest about them:
-Pull requests are the best way to propose changes to the codebase. We actively
-welcome your pull requests:
+- I am maintaining lazygit for fun, as a hobby in my free time (which is quite limited). I'd like to spend my free time on things that I enjoy doing. I enjoy working on lazygit's code and improving it myself; I don't enjoy reviewing PRs. It's that simple, really. Reviewing PRs takes a lot of time; time that I would rather spend on developing lazygit myself.
+- Even if I had the time and inclination to review PRs, this has become quite difficult today: most PRs nowadays are AI-generated to some extent (often completely), which in itself is not necessarily a bad thing; I heavily use AI myself these days, and I get great results from it. However, agentic coding needs to be guided by humans so that the results are good, and for contributed PRs I can't tell to what extent the human contributor did this, or is even capable of it; and I don't want to do the work of guiding a contributor's coding agent. If I post PR review feedback and have to suspect that the contributor simply passes it on to their coding agent, then that is a work mode that doesn't make sense to me, and I would rather just drive my own agent to do the work.
-1. Fork the repo and create your branch from `master`.
-2. If you've added code that should be tested, add tests.
-3. If you've added code that need documentation, update the documentation.
-4. Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
-5. Issue that pull request!
+### Why it might still make sense to post a PR
-If you've never written Go in your life, then join the club! Lazygit was the maintainer's first Go program, and most contributors have never used Go before. Go is widely considered an easy-to-learn language, so if you're looking for an open source project to gain dev experience, you've come to the right place.
+I can think of two such reasons:
-## Running in a VSCode dev container
+- You implemented a lazygit improvement that you want to use yourself; in this case it could make sense to let others merge this change into their forks if they find it useful too. And if enough people say they want the feature, this can persuade me to add it, so putting it out there to give it visibility can be helpful.
+- You posted an issue for a feature request, and have a prototype that implements it; it could be useful to publish the branch as a draft PR to better illustrate how the feature works.
-If you want to spare yourself the hassle of setting up your dev environment yourself (i.e. installing Go, extensions, and extra tools), you can run the Lazygit code in a VSCode dev container like so:
+For this reason I usually don't close pull requests to give them more visibility. Just don't expect your PR to be merged.
-
+## So how can I contribute then?
-This requires that:
-* you have docker installed
-* you have the dev containers extension installed in VSCode
+There are other forms of contributions to a project besides source code that are very welcome and encouraged; for instance:
-See [here](https://code.visualstudio.com/docs/devcontainers/containers) for more info about dev containers.
+- File issues for bugs that you find, and I'll do my best to take care of fixing them (if they are important enough).
+- File feature requests for new functionality that you want to see in lazygit. I have a lot of ideas for future improvement myself, but I have also implemented a lot of feature ideas that weren't mine, and I'm grateful for those ideas. (Of course, there are also lots of feature requests that I don't implement, so don't be disappointed if I don't jump on yours.)
+- Help make other people's bug reports reproducible. Sometimes people report bugs that they have only seen once, and in such a case it can be helpful to come up with reproducible scenarios.
+- Help complete or improve the translation into other languages; join https://crowdin.com/project/lazygit for that.
+- Run a master build! This is probably the most valuable way to help me. Test the latest master not just by occasionally trying it, but by actually using it for your daily work; report any issues that you find. This will help prevent having to release hotfix updates for regressions that are only noticed by users updating to a new release.
-## Running in a Github Codespace
-
-If you want to start contributing to Lazygit with the click of a button, you can open the lazygit codebase in a Codespace. First fork the repo, then click to create a codespace:
-
-
-
-To run lazygit from within the integrated terminal just go `go run main.go`
-
-This allows you to contribute to Lazygit without needing to install anything on your local machine. The Codespace has all the necessary tools and extensions pre-installed.
-
-## Code of conduct
-
-Please note by participating in this project, you agree to abide by the [code of conduct].
-
-[code of conduct]: https://github.com/jesseduffield/lazygit/blob/master/CODE-OF-CONDUCT.md
-
-## Any contributions you make will be under the MIT Software License
-
-In short, when you submit code changes, your submissions are understood to be
-under the same [MIT License](http://choosealicense.com/licenses/mit/) that
-covers the project. Feel free to contact the maintainers if that's a concern.
-
-## Report bugs using Github's [issues](https://github.com/jesseduffield/lazygit/issues)
-
-We use GitHub issues to track public bugs. Report a bug by [opening a new
-issue](https://github.com/jesseduffield/lazygit/issues/new); it's that easy!
-
-## Go
-
-This project is written in Go. Go is an opinionated language with strict idioms, but some of those idioms are a little extreme. Some things we do differently:
-
-1. There is no shame in using `self` as a receiver name in a struct method. In fact we encourage it
-2. There is no shame in prefixing an interface with 'I' instead of suffixing with 'er' when there are several methods on the interface.
-3. If a struct implements an interface, we make it explicit with something like:
-
-```go
-var _ MyInterface = &MyStruct{}
-```
-
-This makes the intent clearer and means that if we fail to satisfy the interface we'll get an error in the file that needs fixing.
-
-### Code Formatting
-
-To check code formatting [gofumpt](https://pkg.go.dev/mvdan.cc/gofumpt#section-readme) (which is a bit stricter than [gofmt](https://pkg.go.dev/cmd/gofmt)) is used.
-VSCode will format the code correctly if you tell the Go extension to use `gofumpt` via your [`settings.json`](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson)
-by setting [`formatting.gofumpt`](https://github.com/golang/tools/blob/master/gopls/doc/settings.md#gofumpt-bool) to `true`:
-
-```jsonc
-// .vscode/settings.json
-{
- "gopls": {
- "formatting.gofumpt": true
- }
-}
-```
-
-To run gofumpt from your terminal go:
-
-```
-go install mvdan.cc/gofumpt@latest && gofumpt -l -w .
-```
-
-## Internationalisation
-
-Boy that's a hard word to spell. Anyway, lazygit is translated into several languages within the pkg/i18n package. If you need to render text to the user, you should add a new field to the TranslationSet struct in `pkg/i18n/english.go` and add the actual content within the `EnglishTranslationSet()` method in the same file. Then you can access via `gui.Tr.YourNewText` (or `self.c.Tr.YourNewText`, etc). Although it is appreciated if you translate the text into other languages, it's not expected of you (google translate will likely do a bad job anyway!).
-
-Note, we use 'Sentence case' for everything (so no 'Title Case' or 'whatever-it's-called-when-there's-no-capital-letters-case')
-
-## Debugging
-
-The easiest way to debug lazygit is to have two terminal tabs open at once: one for running lazygit (via `go run main.go -debug` in the project root) and one for viewing lazygit's logs (which can be done via `go run main.go --logs` or just `lazygit --logs`).
-
-From most places in the codebase you have access to a logger e.g. `gui.Log.Warn("blah")` or `self.c.Log.Warn("blah")`.
-
-If you find that the existing logs are too noisy, you can set the log level with e.g. `LOG_LEVEL=warn go run main.go -debug` and then only use `Warn` logs yourself.
-
-If you need to log from code in the vendor directory (e.g. the `gocui` package), you won't have access to the logger, but you can easily add logging support by setting the `LAZYGIT_LOG_PATH` environment variable and using `logs.Global.Warn("blah")`. This is a global logger that's only intended for development purposes.
-
-If you keep having to do some setup steps to reproduce an issue, read the Testing section below to see how to create an integration test by recording a lazygit session. It's pretty easy!
-
-### VSCode debugger
-
-If you want to trigger a debug session from VSCode, you can use the following snippet. Note that the `console` key is, at the time of writing, still an experimental feature.
-
-```jsonc
-// .vscode/launch.json
-{
- "version": "0.2.0",
- "configurations": [
- {
- "name": "debug lazygit",
- "type": "go",
- "request": "launch",
- "mode": "auto",
- "program": "main.go",
- "args": ["--debug"],
- "console": "externalTerminal" // <-- you need this to actually see the lazygit UI in a window while debugging
- }
- ]
-}
-```
-
-## Profiling
-
-If you want to investigate what's contributing to CPU usage you can add the following to the top of the `main()` function in `main.go`
-
-```go
-import "runtime/pprof"
-
-func main() {
- f, err := os.Create("cpu.prof")
- if err != nil {
- log.Fatal("could not create CPU profile: ", err)
- }
- defer f.Close()
- if err := pprof.StartCPUProfile(f); err != nil {
- log.Fatal("could not start CPU profile: ", err)
- }
- defer pprof.StopCPUProfile()
- ...
-```
-
-Then run lazygit, and afterwards, from your terminal, run:
-
-```sh
-go tool pprof --web cpu.prof
-```
-
-That should open an application which allows you to view the breakdown of CPU usage.
-
-## Testing
-
-Lazygit has two kinds of tests: unit tests and integration tests. Unit tests go in files that end in `_test.go`, and are written in Go. For integration tests, see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md)
-
-## Updating Gocui
-
-Sometimes you will need to make a change in the gocui fork (https://github.com/jesseduffield/gocui). Gocui is the package responsible for rendering windows and handling user input. Here's the typical process to follow:
-
-1. Make the changes in gocui inside lazygit's vendor directory so it's easy to test against lazygit
-2. Copy the changes over to the actual gocui repo (clone it if you haven't already, and use the `awesome` branch, not `master`)
-3. Raise a PR on the gocui repo with your changes
-4. After that PR is merged, make a PR in lazygit bumping the gocui version. You can bump the version by running the following at the lazygit repo root:
-
-```sh
-./scripts/bump_gocui.sh
-```
-
-5. Raise a PR in lazygit with those changes
-
-## Updating Lazycore
-
-[Lazycore](https://github.com/jesseduffield/lazycore) is a repo containing shared functionality between lazygit and lazydocker. Sometimes you will need to make a change to that repo and import the changes into lazygit. Similar to updating Gocui, here's what you do:
-
-1. Make the changes in lazycore inside lazygit's vendor directory so it's easy to test against lazygit
-2. Copy the changes over to the actual lazycore repo (clone it if you haven't already, and use the `master` branch)
-3. Raise a PR on the lazycore repo with your changes
-4. After that PR is merged, make a PR in lazygit bumping the lazycore version. You can bump the version by running the following at the lazygit repo root:
-
-```sh
-./scripts/bump_lazycore.sh
-```
-
-Or if you're using VSCode, there is a bump lazycore task you can find by going `cmd+shift+p` and typing 'Run task'
-
-5. Raise a PR in lazygit with those changes
-
-## Improvements
-
-If you can think of any way to improve these docs let us know.
+Importantly, if you file issues (whether bug reports or feature requests), stay around to answer questions and discuss your issue. There are few things that I find more annoying than spending time on responding to someone's issue (sometimes even making a PR that addresses it), and to then never hear from the OP again. So please set up your Github notifications so that you see when there's activity on your issue, and continue to participate.
diff --git a/Dockerfile b/Dockerfile
index 90a69b44509..9cc163dbc92 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,14 +2,14 @@
# docker build -t lazygit .
# docker run -it lazygit:latest /bin/sh
-FROM golang:1.20 as build
+FROM golang:1.26 as build
WORKDIR /go/src/github.com/jesseduffield/lazygit/
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build
-FROM alpine:3.15
+FROM alpine:3.19
RUN apk add --no-cache -U git xdg-utils
WORKDIR /go/src/github.com/jesseduffield/lazygit/
COPY --from=build /go/src/github.com/jesseduffield/lazygit ./
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000000..c1af01d7a4b
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,77 @@
+.PHONY: all
+all: build
+
+.PHONY: build
+build:
+ go build -gcflags='all=-N -l'
+
+.PHONY: install
+install:
+ go install
+
+.PHONY: run
+run: build
+ ./lazygit
+
+# Run `make run-debug` in one terminal tab and `make print-log` in another to view the program and its log output side by side
+.PHONY: run-debug
+run-debug:
+ go run main.go -debug
+
+.PHONY: print-log
+print-log:
+ go run main.go --logs
+
+.PHONY: unit-test
+unit-test:
+ go test ./... -short
+
+.PHONY: test
+test: unit-test integration-test-all
+
+# Generate all our auto-generated files (test list, cheatsheets, maybe other things in the future)
+.PHONY: generate
+generate:
+ go generate ./...
+
+.PHONY: format
+format:
+ go tool gofumpt -l -w .
+
+.PHONY: lint
+lint:
+ ./scripts/gofumpt-check.sh
+ ./scripts/golangci-lint-shim.sh run
+
+# For more details about integration test, see https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md.
+.PHONY: integration-test-tui
+integration-test-tui:
+ go run cmd/integration_test/main.go tui $(filter-out $@,$(MAKECMDGOALS))
+
+.PHONY: integration-test-cli
+integration-test-cli:
+ go run cmd/integration_test/main.go cli $(filter-out $@,$(MAKECMDGOALS))
+
+.PHONY: integration-test-all
+integration-test-all:
+ go test pkg/integration/clients/*.go
+
+.PHONY: bump-gocui
+bump-gocui:
+ scripts/bump_gocui.sh
+
+.PHONY: bump-lazycore
+bump-lazycore:
+ scripts/bump_lazycore.sh
+
+.PHONY: record-demo
+record-demo:
+ demo/record_demo.sh $(filter-out $@,$(MAKECMDGOALS))
+
+.PHONY: rerecord-demos
+rerecord-demos:
+ demo/rerecord_demos.sh $(filter-out $@,$(MAKECMDGOALS))
+
+.PHONY: vendor
+vendor:
+ go mod tidy && go mod vendor
diff --git a/README.md b/README.md
index b11ecfd9e5c..e40b06b6cab 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,41 @@
+
+
@@ -7,9 +45,10 @@
A simple terminal UI for git commands
-[](https://github.com/jesseduffield/lazygit/releases) [](https://goreportcard.com/report/github.com/jesseduffield/lazygit) [](https://golangci.com) [](https://github.com/jesseduffield/lazygit/releases/latest) [](https://github.com/Homebrew/homebrew-core/blob/master/Formula/lazygit.rb)
+[](https://github.com/jesseduffield/lazygit/releases) [](https://golangci-lint.run/) [](https://github.com/jesseduffield/lazygit/releases/latest) [](https://formulae.brew.sh/formula/lazygit)
-
+
+
@@ -20,7 +59,7 @@ A simple terminal UI for git commands
-

























































+


































































































## Elevator Pitch
@@ -31,103 +70,187 @@ If you're a mere mortal like me and you're tired of hearing how powerful git is
## Table of contents
+- [Sponsors](#sponsors)
+- [Elevator Pitch](#elevator-pitch)
+- [Table of contents](#table-of-contents)
- [Features](#features)
+ - [Stage hunks or individual lines](#stage-hunks-or-individual-lines)
+ - [Interactive Rebase](#interactive-rebase)
+ - [Cherry-pick](#cherry-pick)
+ - [Bisect](#bisect)
+ - [Nuke the working tree](#nuke-the-working-tree)
+ - [Amend an old commit](#amend-an-old-commit)
+ - [Filter](#filter)
+ - [Invoke a custom command](#invoke-a-custom-command)
+ - [Worktrees](#worktrees)
+ - [Rebase magic (custom patches)](#rebase-magic-custom-patches)
+ - [Rebase from marked base commit](#rebase-from-marked-base-commit)
+ - [Undo](#undo)
+ - [Commit graph](#commit-graph)
+ - [Compare two commits](#compare-two-commits)
- [Tutorials](#tutorials)
- [Installation](#installation)
- - [Binary releases](#binary-releases)
+ - [Binary Releases](#binary-releases)
+ - [Dev container](#dev-container-feature)
- [Homebrew](#homebrew)
- [MacPorts](#macports)
- [Void Linux](#void-linux)
- [Scoop (Windows)](#scoop-windows)
+ - [gah (Linux and Mac OS)](#gah-linux-and-mac-os)
- [Arch Linux](#arch-linux)
- - [Fedora and RHEL](#fedora-and-rhel)
+ - [Fedora / Amazon Linux 2023 / CentOS Stream](#fedora--amazon-linux-2023--centos-stream)
+ - [Fedora / RHEL Derivatives (Terra)](#fedora--rhel-derivatives-terra)
- [Solus Linux](#solus-linux)
- - [Ubuntu](#ubuntu)
+ - [Debian and Ubuntu](#debian-and-ubuntu)
- [Funtoo Linux](#funtoo-linux)
- [Gentoo Linux](#gentoo-linux)
+ - [openSUSE](#opensuse)
+ - [NixOS](#nixos)
+ - [Flox](#flox)
- [FreeBSD](#freebsd)
+ - [Termux](#termux)
- [Conda](#conda)
- [Go](#go)
- [Chocolatey (Windows)](#chocolatey-windows)
+ - [Winget (Windows 10 1709 or later)](#winget-windows-10-1709-or-later)
- [Manual](#manual)
- [Usage](#usage)
- [Keybindings](#keybindings)
- - [Changing directory on exit](#changing-directory-on-exit)
+ - [Changing Directory On Exit](#changing-directory-on-exit)
- [Undo/Redo](#undoredo)
- [Configuration](#configuration)
- - [Custom pagers](#configuration)
- - [Custom commands](#configuration)
+ - [Custom Diff Renderers](#custom-diff-renderers)
+ - [Custom Commands](#custom-commands)
+ - [Git flow support](#git-flow-support)
- [Contributing](#contributing)
+ - [Debugging Locally](#debugging-locally)
- [Donate](#donate)
+- [FAQ](#faq)
+ - [What do the commit colors represent?](#what-do-the-commit-colors-represent)
+- [Shameless Plug](#shameless-plug)
- [Alternatives](#alternatives)
Lazygit is not my fulltime job but it is a hefty part time job so if you want to support the project please consider [sponsoring me](https://github.com/sponsors/jesseduffield)
## Features
-### Stage individual lines
+### Stage hunks or individual lines
-Press space on the selected line to stage it, or press `v` to start selecting a range of lines. You can also press `a` to select the entirety of the current hunk.
+Press `0` on a changed file to focus its diff in the main view. The selection covers a whole hunk to begin with, so `` stages that hunk and moves on to the next one. When you want only part of a hunk, press `a` for line-by-line selection and `v` to select a range of lines. What you staged shows up in the pane below. Press `` to move between the two panes; `` unstages in the lower one. Press `c` to commit without leaving the diff.
-
+
+
### Interactive Rebase
-Press `e` on a commit to start an interactive rebase on it: causing all above commits to become part of the TODO file. Then squash (`s`), fixup (`f`), drop (`d`), edit (`e`), move up (ctrl+i) or move down (ctrl+j) any of TODO commits, before continuing the rebase by bringing up the rebase options menu with `m` and then selecting `continue`. You can also perform any these actions as a once-off (e.g. pressing `s` on a commit to squash it) without explicitly starting a rebase.
+Press `i` to start an interactive rebase. Then squash (`s`), fixup (`f`), drop (`d`), edit (`e`), move up (`ctrl+k`) or move down (`ctrl+j`) any of the TODO commits, before continuing the rebase by bringing up the rebase options menu with `m` and then selecting `continue`.
+
+You can also perform any of these actions as a once-off (e.g. pressing `s` on a commit to squash it) without explicitly starting a rebase.
-
+This demo also uses shift+down to select a range of commits to move and fixup.
+
+
+
### Cherry-pick
-Press `c` on a commit to copy it and press `v` to paste (cherry-pick) it.
+Press `shift+c` on a commit to copy it and press `shift+v` to paste (cherry-pick) it.
-
+
+
### Bisect
Press `b` in the commits view to mark a commit as good/bad in order to begin a git bisect.
-
+
+
### Nuke the working tree
-For when you really want to just get rid of anything that shows up when you run `git status` (and yes that includes dirty submodules) [kidpix style](https://www.youtube.com/watch?v=Ur7_A4JusMU), press `shift+d` to bring up the reset options menu and then select the 'nuke' option.
+For when you really want to just get rid of anything that shows up when you run `git status` (and yes that includes dirty submodules) [kidpix style](https://www.youtube.com/watch?v=N4E2B_k2Bss), press `shift+d` to bring up the reset options menu and then select the 'nuke' option.
-
+
+
### Amend an old commit
-Pressing `shift+a` on any commit will amend that commit with the currently staged changes (running an interactive rebase in the backkground).
+Pressing `shift+a` on any commit will amend that commit with the currently staged changes (running an interactive rebase in the background).
-
+
+
### Filter
You can filter a view with `/`. Here we filter down our branches view and then hit `enter` to view its commits.
-
+
+
### Invoke a custom command
Lazygit has a very flexible [custom command system](docs/Custom_Command_Keybindings.md). In this example a custom command is defined which emulates the built-in branch checkout action.
-
+
+
### Worktrees
You can create worktrees to have multiple branches going at once without the need for stashing or creating WIP commits when switching between them. Press `w` in the branches view to create a worktree from the selected branch and switch to it.
-
+
+
### Rebase magic (custom patches)
You can build a custom patch from an old commit and then remove the patch from the commit, split out a new commit, apply the patch in reverse to the index, and more.
-In this example we have a redundant comment that we want to remove from an old commit. We hit `` on the commit to view its files, then `` on a file to focus the patch, then `` to add the comment line to our custom patch, and then `ctrl+p` to view the custom patch options; selecting to remove the patch from the current commit.
+In this example an old commit contains a change that belongs in a commit of its own. We hit `0` on the commit to focus its diff. `` on the hunk we want to move adds it to the custom patch, and `ctrl+p` opens the custom patch options, where we choose to move the patch into a new commit.
Learn more in the [Rebase magic Youtube tutorial](https://youtu.be/4XaToVut_hs).
-
+
+
+
+If you only want to remove a hunk from an old commit, you don't need a custom patch for that. Select the hunk in the commit's diff and press `d`. Lazygit rewrites the commit without it, running an interactive rebase in the background.
+
+
+
+
+### Rebase from marked base commit
+
+Say you're on a feature branch that was itself branched off of the develop branch, and you've decided you'd rather be branching off the master branch. You need a way to rebase only the commits from your feature branch. In this demo we check to see which was the last commit on the develop branch, then press `shift+b` to mark that commit as our base commit, then press `r` on the master branch to rebase onto it, only bringing across the commits from our feature branch. Then we push our changes with `shift+p`.
+
+
+
+
+### Undo
+
+You can undo the last action by pressing `z` and redo with `shift+z`. Here we drop a couple of commits and then undo the actions.
+Undo uses the reflog which is specific to commits and branches so we can't undo changes to the working tree or stash.
+
+[More info](/docs/Undoing.md)
+
+
+
+
+### Commit graph
+
+When viewing the commit graph in an enlarged window (use `+` and `_` to cycle screen modes), the commit graph is shown. Colours correspond to the commit authors, and as you navigate down the graph, the parent commits of the selected commit are highlighted.
+
+
+
+
+### Compare two commits
+
+If you press `shift+w` on a commit (or branch/ref) a menu will open that allows you to mark that commit so that any other commit you select will be diffed against it. Once you've selected the second commit, you'll see the diff in the main view and if you press `` you'll see the files of the diff. You can press `shift+w` to view the diff menu again to see options like reversing the diff direction or exiting diff mode. You can also exit diff mode by pressing ``.
+
+
+
+
+### Show GitHub pull requests
+
+In the branches panel, lazygit can show which of your branches have an associated GitHub pull request by showing a GitHub icon next to the branch name; its color shows the state of the PR (open, merged, etc.). For those that have one, you can press `shift-G` to open the PR in the browser. There is no configuration needed to enable this for github.com, but it requires the [`gh`](https://cli.github.com/) tool to be installed, and you need to do `gh auth login` once to allow lazygit to access GitHub. For GitHub Enterprise, also run `gh auth login --hostname ` and add a [`services` entry](docs/Config.md#custom-pull-request-urls) for the host with the `github` provider.
## Tutorials
@@ -139,7 +262,7 @@ Learn more in the [Rebase magic Youtube tutorial](https://youtu.be/4XaToVut_hs).
## Installation
-[](https://repology.org/project/lazygit/versions)
+[](https://repology.org/project/lazygit/versions)
_Most of the above packages are maintained by third parties so be sure to vet them yourself and confirm that the maintainer is a trustworthy looking person who attends local sports games and gives back to their communities with barbeque fundraisers etc_
@@ -147,19 +270,15 @@ _Most of the above packages are maintained by third parties so be sure to vet th
For Windows, Mac OS(10.12+) or Linux, you can download a binary release [here](../../releases).
-### Homebrew
-
-Normally the lazygit formula can be found in the Homebrew core but we suggest you tap our formula to get the frequently updated one. It works with Linux, too.
+### Dev container feature
-Tap:
+If you want to use lazygit in e.g. one of your GitHub Codespaces, there is a third-party [dev container feature](https://github.com/GeorgOfenbeck/features/tree/main/src/lazygit-linuxbinary) based on the binary releases mentioned above.
-```
-brew install jesseduffield/lazygit/lazygit
-```
+### Homebrew
-Core:
+It works with Linux, too.
-```
+```sh
brew install lazygit
```
@@ -194,6 +313,14 @@ scoop bucket add extras
scoop install lazygit
```
+### gah (Linux and Mac OS)
+
+You can install `lazygit` using [gah](https://github.com/marverix/gah/):
+
+```sh
+gah install lazygit
+```
+
### Arch Linux
Packages for Arch Linux are available via pacman and AUR (Arch User Repository).
@@ -207,28 +334,56 @@ and the git version which builds from the most recent commit.
Instruction of how to install AUR content can be found here:
-### Fedora and RHEL
+### Fedora / Amazon Linux 2023 / CentOS Stream
+
+Packages for Fedora, Amazon Linux 2023 and CentOS Stream are available via
+[Copr](https://copr.fedorainfracloud.org/coprs/dejan/lazygit/) (Cool Other Package Repo).
+
+```sh
+sudo dnf copr enable dejan/lazygit
+sudo dnf install lazygit
+```
+
+These packages are built using the RPM spec file located here: https://codeberg.org/dejan/rpm-lazygit
+
+You should be able to build RPMs for Fedora 41 or older, and other Fedora derivatives using the
+SRPM (Source RPM) file that you can grab from the latest COPR build.
+
+#### Fedora / RHEL Derivatives (Terra)
-Packages for Fedora/RHEL and CentOS Stream are available via [Copr](https://copr.fedorainfracloud.org/coprs/atim/lazygit/) (Cool Other Package Repo).
+Packages for Fedora and RHEL derivatives are also available from the [Terra Repository](https://terra.fyralabs.com/).
```sh
-sudo dnf copr enable atim/lazygit -y
+sudo dnf install --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release
sudo dnf install lazygit
```
+(Install guide for Atomic/EL can be found on their [README](https://github.com/terrapkg/packages/pull/9747))
+
+Terra also has `lazygit-doc`, which contains the contents of the [docs](https://github.com/jesseduffield/lazygit/tree/master/docs) folder.
+
### Solus Linux
```sh
sudo eopkg install lazygit
```
-### Ubuntu
+### Debian and Ubuntu
+
+For **Debian 13 "Trixie", Sid**, and later, or **Ubuntu 25.10 "Questing Quokka"** and later:
```sh
-LAZYGIT_VERSION=$(curl -s "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | grep -Po '"tag_name": "v\K[^"]*')
-curl -Lo lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz"
+sudo apt install lazygit
+```
+
+For **Debian 12 "Bookworm", Ubuntu 25.04 "Plucky Puffin"** and earlier:
+
+```sh
+LAZYGIT_VERSION=$(curl -s "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | \grep -Po '"tag_name": *"v\K[^"]*')
+LAZYGIT_ARCH=$(uname -m | sed -e 's/aarch64/arm64/')
+curl -Lo lazygit.tar.gz "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_${LAZYGIT_ARCH}.tar.gz"
tar xf lazygit.tar.gz lazygit
-sudo install lazygit /usr/local/bin
+sudo install lazygit -D -t /usr/local/bin/
```
Verify the correct installation of lazygit:
@@ -244,37 +399,125 @@ Funtoo Linux has an autogenerated lazygit package in [dev-kit](https://github.co
```sh
sudo emerge dev-vcs/lazygit
```
+
### Gentoo Linux
-Lazygit is not (yet) in main Gentoo portage, however an ebuild is available in [cova overlay](https://github.com/cova-fe/cova-overlay/tree/main/dev-vcs/lazygit)
+Lazygit is not (yet) in main Gentoo portage, however an ebuild is available in [GURU overlay](https://github.com/gentoo-mirror/guru/tree/master/dev-vcs/lazygit)
You can either add the overlay to your system and install lazygit as usual:
```sh
-sudo eselect repository enable cova
-sudo emaint sync -r cova
+sudo eselect repository enable guru
+sudo emaint sync -r guru
sudo emerge dev-vcs/lazygit
```
-Or you can download the ebuild and install it manually; please consider the example below just as a suggestion to be adapted to your system.
+### openSUSE
+
+The lazygit package is currently built in [devel:languages:go/lazygit](https://build.opensuse.org/package/show/devel:languages:go/lazygit).
+
+To install lazygit on openSUSE Tumbleweed run:
```sh
-su
-LAZYGIT_VERSION="0.39.3" # Replace with the version you want from cova-overlay
-cd /usr/overlay/dev-vcs/ # Replace your overlay path
-mkdir lazygit
-cd lazygit
-curl -O https://raw.githubusercontent.com/cova-fe/cova-overlay/main/dev-vcs/lazygit/lazygit-${LAZYGIT_VERSION}.ebuild
-ebuild lazygit-${LAZYGIT_VERSION}.ebuild manifest
-emerge lazygit
+sudo zypper ar https://download.opensuse.org/repositories/devel:/languages:/go/openSUSE_Factory/devel:languages:go.repo
+sudo zypper ref && sudo zypper in lazygit
+```
+
+To install lazygit on openSUSE Leap run:
+
+```sh
+source /etc/os-release
+sudo zypper ar https://download.opensuse.org/repositories/devel:/languages:/go/$VERSION_ID/devel:languages:go.repo
+sudo zypper ref && sudo zypper in lazygit
+```
+
+### NixOS
+
+#### Using lazygit from nixpkgs
+
+On NixOS, lazygit is packaged with nix and distributed via nixpkgs.
+You can try lazygit without installing it with:
+
+```sh
+nix-shell -p lazygit
+# or with flakes enabled
+nix run nixpkgs#lazygit
+```
+
+Or you can add lazygit to your `configuration.nix` using the `environment.systemPackages` option.
+More details can be found via NixOS search [page](https://search.nixos.org/).
+
+#### Using the official lazygit flake
+
+This repository includes a nix flake that provides the latest development version and additional development tools:
+
+**Run lazygit directly from the repository:**
+
+```sh
+nix run github:jesseduffield/lazygit
+# or from a local clone
+nix run .
```
+**Build lazygit from source:**
+
+```sh
+nix build github:jesseduffield/lazygit
+# or from a local clone
+nix build .
+```
+
+**Development environment:**
+For contributors, the flake provides a development shell with Go toolchain, development tools, and dependencies:
+
+```sh
+nix develop github:jesseduffield/lazygit
+# or from a local clone
+nix develop
+```
+
+The development shell includes:
+
+- Go toolchain
+- git and make
+- Proper environment variables for development
+
+**Using in other flakes:**
+The flake also provides an overlay for easy integration into other flake-based projects:
+
+```nix
+{
+ inputs.lazygit.url = "github:jesseduffield/lazygit";
+
+ outputs = { self, nixpkgs, lazygit }: {
+ # Use the overlay
+ nixpkgs.overlays = [ lazygit.overlays.default ];
+ };
+}
+```
+
+### Flox
+
+Lazygit can be installed into a Flox environment as follows.
+
+```sh
+flox install lazygit
+```
+
+More details about Flox can be found on [their website](https://flox.dev/).
+
### FreeBSD
```sh
pkg install lazygit
```
+### Termux
+
+```sh
+apt install lazygit
+```
+
### Conda
Released versions are available for different platforms, see
@@ -291,7 +534,7 @@ go install github.com/jesseduffield/lazygit@latest
Please note:
If you get an error claiming that lazygit cannot be found or is not defined, you
-may need to add `~/go/bin` to your $PATH (MacOS/Linux), or `%HOME%\go\bin`
+may need to add `~/go/bin` to your $PATH (macOS/Linux), or `%HOME%\go\bin`
(Windows). Not to be mistaken for `C:\Go\bin` (which is for Go's own binaries,
not apps like lazygit).
@@ -303,6 +546,14 @@ You can install `lazygit` using [Chocolatey](https://chocolatey.org/):
choco install lazygit
```
+### Winget (Windows 10 1709 or later)
+
+You can install `lazygit` using the `winget` command in the Windows Terminal with the following command:
+
+```powershell
+winget install -e --id=JesseDuffield.lazygit
+```
+
### Manual
You'll need to [install Go](https://golang.org/doc/install)
@@ -359,9 +610,9 @@ See the [docs](/docs/Undoing.md)
Check out the [configuration docs](docs/Config.md).
-### Custom Pagers
+### Custom Diff Renderers
-See the [docs](docs/Custom_Pagers.md)
+See the [docs](docs/Custom_DiffRenderers.md)
### Custom Commands
@@ -369,6 +620,10 @@ If lazygit is missing a feature, there's a good chance you can implement it your
See the [docs](docs/Custom_Command_Keybindings.md)
+### Git flow support
+
+Lazygit supports [Gitflow](https://github.com/nvie/gitflow) (or [git-flow-next](https://github.com/gittower/git-flow-next)) if you have it installed. To understand how the Gitflow model works check out Vincent Driessen's original [post](https://nvie.com/posts/a-successful-git-branching-model/) explaining it. To view Gitflow options from within Lazygit, press `i` from within the branches view.
+
## Contributing
We love your input! Please check out the [contributing guide](CONTRIBUTING.md).
@@ -405,3 +660,4 @@ If you find that lazygit doesn't quite satisfy your requirements, these may be a
- [GitUI](https://github.com/Extrawurst/gitui)
- [tig](https://github.com/jonas/tig)
+- [GitArbor TUI](https://github.com/cadamsdev/gitarbor-tui)
diff --git a/VISION.md b/VISION.md
new file mode 100644
index 00000000000..0cf9fe90868
--- /dev/null
+++ b/VISION.md
@@ -0,0 +1,105 @@
+# Vision and Design Principles
+
+## Vision
+
+Lazygit's vision is to be the most enjoyable UI for git.
+
+## Design Principles
+
+There are seven (sometimes contradictory) design principles we follow:
+
+- [Discoverability](#discoverability)
+- [Simplicity](#simplicity)
+- [Safety](#safety)
+- [Power](#power)
+- [Speed](#speed)
+- [Conformity with git](#conformity-with-git)
+- [Think of the codebase](#think-of-the-codebase)
+
+### Discoverability
+
+TUI's are notoriously hard to learn, thanks to limited screen real-estate to provide contextual help and a general lack of effort on the part of developers to make things obvious. We want Lazygit to buck the trend and be easy for a new user to grok.
+
+Examples:
+
+- Clearly document all the features/configuration options
+ - e.g. gifs in the README
+- Document how to solve various git problems with Lazygit
+ - This is something we don't have yet but should: a section in the docs explaining how Lazygit can help you in various scenarios
+- Use tooltips to explain what actions will do
+- Make it easy for users to ask questions and get answers from the community
+- Make it easy to find entities and actions from within Lazygit
+- Use visual elements to make things obvious
+ - e.g. '<-- YOU ARE HERE' label when rebasing
+- Don't require the user to memorise keybindings
+ - e.g. when the user is mid-rebase, we prominently show that the keybinding for viewing rebase options is 'm'
+- When the user performs an action in Lazygit, make the impact obvious
+ - If the affected entity isn't visible, show a toast notification
+- If a keybinding is disabled, give a reason why
+
+### Simplicity
+
+The git CLI is very complex but most git use cases are simple. Lazygit needs to ensure that simple use cases are easy to satisfy.
+
+- Make the most common use cases dead-simple (staging files, committing, pulling/pushing)
+- Don't overwhelm the user with options
+- Use sensible defaults
+- We already have too many configuration options: think hard before adding any new ones
+ - A bit of elaboration on this one: in the past we made the mistake of adding new config options all the time for unimportant things. The thinking was: one user wants to have this new feature or behavior, we are not sure if everybody will like it, so we hide it behind a config. This seems good because we satisfy everybody's needs, but it's bad because if Config.md is pages and pages of text, most users will not bother reading all of it, so they won't be aware of the actually useful options among all the obscure ones. We should be much more conservative about adding new config options that only few users are likely to use.
+
+### Safety
+
+It's easy to screw things up in git so Lazygit should try to protect the user from screwing things up.
+
+- Prompt for a confirmation before doing anything that's hard to reverse
+- Make it easy to correct mistakes
+ - e.g. undo action
+ - the escape key should get you out of most transient situations (rebasing, diffing, etc)
+
+### Power
+
+Users shouldn't have to drop down the CLI _too_ often. Lazygit should be able to handle some complex use cases.
+
+- Make complex (but common) CLI flows simple
+ - e.g. interactive rebasing
+- Use the custom commands system to handle the really rare complex edge-cases
+
+### Speed
+
+Pro users should be able to move at lightning speed with Lazygit.
+
+- Always think about the number of keypresses involved in a given UX flow
+- Make lazygit performant and responsive
+- Think about the individual commands being run and how fast they are
+- Startup should be FAST. If you want to run something at startup that is slow, make it non-blocking.
+- Support muscle-memory
+ - Prefer disabling menu items instead of hiding them so that muscle memory can be used to select the desired menu item
+ - Try to make keybinding intuitions to transfer across contexts (e.g. 'd' for destroy)
+ - When changing keybindings in a new release, always consider what will happen if a user does not read the release notes and relies on muscle memory.
+
+### Conformity with git
+
+Satisfying the use-cases of git users is more important than perfectly conforming to git's API, but even obscure parts of git's API were motivated by real use-cases.
+
+- Users should only have to drop down to the git CLI in rare circumstances
+- Honour the git config
+ - Don't override anything set in the git config without the user's permission
+- Work with git, not against it.
+ - Too much magic will get us into trouble
+- Avoid storing Lazygit-specific session state that could instead be stored in git
+- Ensure that Lazygit can represent the state of any repo
+- Sometimes git's default behaviour is just silly and we'll make the call to override but it should be a well-considered decision.
+
+### Think of the codebase
+
+Will somebody PLEASE think of the codebase!
+
+Some features are not worth the added complexity in the codebase. The more this codebase grows, the harder it will be to make the changes that everybody wants.
+
+## Resolving conflicts
+
+Many of the above objectives are directly antithetical to one another. If you add an extra confirmation prompt for the sake of _safety_, you're sacrificing _speed_. If you support toggling various git flags in the name of _power_, you're sacrificing _simplicity_. There are a few things to say here.
+
+When there are conflicts, we need to make a judgement call. In general we should err on the side of safety and simplicity as the default, with the ability for users to make things faster / more powerful either through configuration or separate keybindings.
+
+This does not mean for example that force pushes should be impossible without being manually enabled: force pushes are table stakes for anybody who rebases. But it does mean that a confirmation popup should appear when force pushing.
diff --git a/cmd/author_colors_repo/main.go b/cmd/author_colors_repo/main.go
new file mode 100644
index 00000000000..cebfaa39bcb
--- /dev/null
+++ b/cmd/author_colors_repo/main.go
@@ -0,0 +1,142 @@
+// author_colors_repo creates a git repository for checking that the colors
+// lazygit gives to authors are readable.
+//
+// If gui.authorColors names no color for an author, lazygit derives one from a
+// hash of their name. The authors of an ordinary repository rarely land near the
+// edges of the range that this color is picked from. Every commit in the
+// repository created here is by an author at one of those edges: the lowest or
+// highest lightness, combined with the lowest or highest saturation, at twelve
+// hues around the color wheel. The commit subject says which edge it is.
+//
+// Usage:
+//
+// go run ./cmd/author_colors_repo
+//
+// Then open the repository with lazygit in each terminal theme you want to check.
+package main
+
+import (
+ "fmt"
+ "log"
+ "math"
+ "os"
+ "os/exec"
+ "time"
+
+ "github.com/jesseduffield/lazygit/pkg/gui/presentation/authors"
+)
+
+type extreme int
+
+const (
+ lowest extreme = iota
+ highest
+)
+
+func (self extreme) String() string {
+ if self == lowest {
+ return "min"
+ }
+ return "max"
+}
+
+func (self extreme) matches(fraction float64) bool {
+ if self == lowest {
+ return fraction < 0.01
+ }
+ return fraction >= 0.99
+}
+
+const (
+ numHues = 12
+
+ // Small enough that the windows of neighbouring hues don't overlap
+ hueTolerance = 0.02
+)
+
+type commit struct {
+ author string
+ subject string
+}
+
+func main() {
+ if len(os.Args) != 2 {
+ log.Fatalf("usage: %s ", os.Args[0])
+ }
+ path := os.Args[1]
+ if _, err := os.Stat(path); err == nil {
+ log.Fatalf("%s already exists", path)
+ }
+
+ extremes := []extreme{lowest, highest}
+ commits := make([]commit, 0, len(extremes)*len(extremes)*numHues)
+ for _, lightness := range extremes {
+ for _, saturation := range extremes {
+ for i := range numHues {
+ hue := float64(i) / numHues
+ author, actualHue := findAuthor(lightness, saturation, hue)
+ subject := fmt.Sprintf("lightness %s, saturation %s, hue %.0f°", lightness, saturation, actualHue*360)
+ commits = append(commits, commit{author: author, subject: subject})
+ }
+ }
+ }
+
+ if err := runGit("", nil, "init", "-q", path); err != nil {
+ log.Fatal(err)
+ }
+
+ // Commit in reverse, so that the commits panel lists them in the order above
+ startTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ for i := range commits {
+ c := commits[len(commits)-1-i]
+ date := fmt.Sprintf("%d +0000", startTime.Add(time.Duration(i)*time.Hour).Unix())
+ env := []string{
+ "GIT_AUTHOR_NAME=" + c.author,
+ "GIT_AUTHOR_EMAIL=author@example.com",
+ "GIT_AUTHOR_DATE=" + date,
+ "GIT_COMMITTER_NAME=" + c.author,
+ "GIT_COMMITTER_EMAIL=author@example.com",
+ "GIT_COMMITTER_DATE=" + date,
+ }
+ if err := runGit(path, env, "commit", "-q", "--allow-empty", "-m", c.subject); err != nil {
+ log.Fatal(err)
+ }
+ }
+
+ fmt.Printf("Created %s with %d commits\n", path, len(commits))
+}
+
+// findAuthor returns the first name of the form "L S "
+// whose color lies at the given extremes of lightness and saturation, and close
+// to the given hue. It also returns the hue that the name lands on.
+//
+// Every name of this form has the same initials, so the color is the only
+// thing that differs between authors in the commits panel.
+func findAuthor(lightness extreme, saturation extreme, hue float64) (string, float64) {
+ for n := 1; ; n++ {
+ name := fmt.Sprintf("L%s S%s %d", lightness, saturation, n)
+ h, s, l := authors.ColorPosition(name)
+ if lightness.matches(l) && saturation.matches(s) && hueDistance(h, hue) <= hueTolerance {
+ return name, h
+ }
+ }
+}
+
+// hueDistance is the distance between two hues on the color wheel, where each
+// hue is a fraction of a full turn.
+func hueDistance(a float64, b float64) float64 {
+ d := math.Abs(a - b)
+ return math.Min(d, 1-d)
+}
+
+func runGit(dir string, env []string, args ...string) error {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ // Keep the user's git config out of it, so that hooks, commit signing and
+ // the like don't apply, and every run creates the same commits
+ cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_CONFIG_NOSYSTEM=1")
+ cmd.Env = append(cmd.Env, env...)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+}
diff --git a/cmd/i18n/main.go b/cmd/i18n/main.go
new file mode 100644
index 00000000000..f388c396b13
--- /dev/null
+++ b/cmd/i18n/main.go
@@ -0,0 +1,26 @@
+package main
+
+import (
+ "encoding/json"
+ "log"
+ "os"
+
+ "github.com/jesseduffield/lazygit/pkg/i18n"
+)
+
+func saveLanguageFileToJson(tr *i18n.TranslationSet, filepath string) error {
+ jsonData, err := json.MarshalIndent(tr, "", " ")
+ if err != nil {
+ return err
+ }
+
+ jsonData = append(jsonData, '\n')
+ return os.WriteFile(filepath, jsonData, 0o644)
+}
+
+func main() {
+ err := saveLanguageFileToJson(i18n.EnglishTranslationSet(), "en.json")
+ if err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/cmd/integration_test/main.go b/cmd/integration_test/main.go
index 9de11a2c80a..711ce8b3370 100644
--- a/cmd/integration_test/main.go
+++ b/cmd/integration_test/main.go
@@ -16,7 +16,7 @@ Usage:
> go run cmd/integration_test/main.go cli [--slow] [--sandbox] ...
If you pass no test names, it runs all tests
Accepted environment variables:
- KEY_PRESS_DELAY (e.g. 200): the number of milliseconds to wait between keypresses
+ INPUT_DELAY (e.g. 200): the number of milliseconds to wait between keypresses or mouse clicks
TUI mode:
> go run cmd/integration_test/main.go tui
@@ -26,6 +26,29 @@ Usage:
> go run cmd/integration_test/main.go help
`
+type flagInfo struct {
+ name string // name of the flag; can be used with "-" or "--"
+ flag *bool // a pointer to the variable that should be set to true when this flag is passed
+}
+
+// Takes the args that you want to parse (excluding the program name and any
+// subcommands), and returns the remaining args with the flags removed
+func parseFlags(args []string, flags []flagInfo) []string {
+outer:
+ for len(args) > 0 {
+ for _, f := range flags {
+ if args[0] == "-"+f.name || args[0] == "--"+f.name {
+ *f.flag = true
+ args = args[1:]
+ continue outer
+ }
+ }
+ break
+ }
+
+ return args
+}
+
func main() {
if len(os.Args) < 2 {
log.Fatal(usage)
@@ -35,23 +58,26 @@ func main() {
case "help":
fmt.Println(usage)
case "cli":
- testNames := os.Args[2:]
slow := false
sandbox := false
- // get the next arg if it's --slow
- if len(os.Args) > 2 {
- if os.Args[2] == "--slow" || os.Args[2] == "-slow" {
- testNames = os.Args[3:]
- slow = true
- } else if os.Args[2] == "--sandbox" || os.Args[2] == "-sandbox" {
- testNames = os.Args[3:]
- sandbox = true
- }
- }
-
- clients.RunCLI(testNames, slow, sandbox)
+ waitForDebugger := false
+ raceDetector := false
+ testNames := parseFlags(os.Args[2:], []flagInfo{
+ {"slow", &slow},
+ {"sandbox", &sandbox},
+ {"debug", &waitForDebugger},
+ {"race", &raceDetector},
+ })
+ clients.RunCLI(testNames, slow, sandbox, waitForDebugger, raceDetector)
case "tui":
- clients.RunTUI()
+ raceDetector := false
+ remainingArgs := parseFlags(os.Args[2:], []flagInfo{
+ {"race", &raceDetector},
+ })
+ if len(remainingArgs) > 0 {
+ log.Fatal("tui only supports the -race argument.")
+ }
+ clients.RunTUI(raceDetector)
default:
log.Fatal(usage)
}
diff --git a/default.nix b/default.nix
new file mode 100644
index 00000000000..c48b1fb8cbb
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,12 @@
+(import (
+ let
+ lock = builtins.fromJSON (builtins.readFile ./flake.lock);
+ nodeName = lock.nodes.root.inputs.flake-compat;
+ in
+ fetchTarball {
+ url =
+ lock.nodes.${nodeName}.locked.url
+ or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz";
+ sha256 = lock.nodes.${nodeName}.locked.narHash;
+ }
+) { src = ./.; }).defaultNix
diff --git a/demo/config.yml b/demo/config.yml
deleted file mode 100644
index 856182b27d6..00000000000
--- a/demo/config.yml
+++ /dev/null
@@ -1,109 +0,0 @@
-# Specify a command to be executed
-# like `/bin/bash -l`, `ls`, or any other commands
-# the default is bash for Linux
-# or powershell.exe for Windows
-command: echo "YOU NEED TO SPECIFY YOUR OWN COMMAND WITH THE -d ARG"
-
-# Specify the current working directory path
-# the default is the current working directory path
-cwd: null
-
-# Export additional ENV variables
-env:
- recording: true
-
-# Explicitly set the number of columns
-# or use `auto` to take the current
-# number of columns of your shell
-cols: 120 # 100
-
-# Explicitly set the number of rows
-# or use `auto` to take the current
-# number of rows of your shell
-rows: 35 # 30
-
-# Amount of times to repeat GIF
-# If value is -1, play once
-# If value is 0, loop indefinitely
-# If value is a positive number, loop n times
-repeat: 0
-
-# Quality
-# 1 - 100
-# Higher quality seems to make no difference, but running it through
-# gifsicle ends up with a much better compressed version.
-quality: 100
-
-# Delay between frames in ms
-# If the value is `auto` use the actual recording delays
-frameDelay: auto
-
-# Maximum delay between frames in ms
-# Ignored if the `frameDelay` isn't set to `auto`
-# Set to `auto` to prevent limiting the max idle time
-maxIdleTime: 2000
-
-# The surrounding frame box
-# The `type` can be null, window, floating, or solid`
-# To hide the title use the value null
-# Don't forget to add a backgroundColor style with a null as type
-frameBox:
- type: floating
- title: Lazygit
- style:
- border: 0px black solid
- backgroundColor: "#1d1d1d"
- margin: -5px
-
-# Add a watermark image to the rendered gif
-# You need to specify an absolute path for
-# the image on your machine or a URL, and you can also
-# add your own CSS styles
-watermark:
- imagePath: null
- style:
- position: absolute
- right: 15px
- bottom: 15px
- width: 100px
- opacity: 0.9
-
-# Cursor style can be one of
-# `block`, `underline`, or `bar`
-cursorStyle: block
-
-# Font family
-# You can use any font that is installed on your machine
-# in CSS-like syntax
-fontFamily: "DejaVuSansMono Nerd Font"
-
-# The size of the font
-fontSize: 8
-
-# The height of lines
-lineHeight: 1
-
-# The spacing between letters
-letterSpacing: 0
-
-# Theme
-theme:
- background: "transparent"
- foreground: "#dddad6"
- cursor: "#c7c7c7"
- black: "#7a7a7a"
- red: "#fc4384"
- green: "#b3e33b"
- yellow: "#ffa727"
- blue: "#102895"
- magenta: "#c930c7"
- cyan: "#00c5c7"
- white: "#c7c7c7"
- brightBlack: "#676767"
- brightRed: "#ff7fac"
- brightGreen: "#c8ed71"
- brightYellow: "#ebdf86"
- brightBlue: "#6871ff"
- brightMagenta: "#ff76ff"
- brightCyan: "#5ffdff"
- brightWhite: "#fffefe"
diff --git a/demo/fonts/FlogSymbolsDemo-Bold.ttf b/demo/fonts/FlogSymbolsDemo-Bold.ttf
new file mode 100644
index 00000000000..864b6449599
Binary files /dev/null and b/demo/fonts/FlogSymbolsDemo-Bold.ttf differ
diff --git a/demo/fonts/FlogSymbolsDemo-Regular.ttf b/demo/fonts/FlogSymbolsDemo-Regular.ttf
new file mode 100644
index 00000000000..0946f38ab7c
Binary files /dev/null and b/demo/fonts/FlogSymbolsDemo-Regular.ttf differ
diff --git a/demo/fonts/LICENSE-FlogSymbols b/demo/fonts/LICENSE-FlogSymbols
new file mode 100644
index 00000000000..af6af6c9c7a
--- /dev/null
+++ b/demo/fonts/LICENSE-FlogSymbols
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 rbong
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/demo/fonts/fit_flog_symbols.py b/demo/fonts/fit_flog_symbols.py
new file mode 100644
index 00000000000..048aaa1e941
--- /dev/null
+++ b/demo/fonts/fit_flog_symbols.py
@@ -0,0 +1,116 @@
+"""Make a copy of the Flog Symbols font whose lines fill a terminal cell.
+
+The demo recordings draw the commit graph with the branch drawing symbols, and
+the terminal that vhs records takes them from the Flog Symbols Demo font in
+demo/fonts. This script made its regular and bold faces from FlogSymbols.ttf of
+https://github.com/rbong/flog-symbols (see LICENSE-FlogSymbols):
+
+ pip install fonttools
+ python3 demo/fonts/fit_flog_symbols.py FlogSymbols.ttf "Flog Symbols Demo" \\
+ demo/fonts
+
+Flog Symbols draws its lines for a cell that is 620 units wide and reaches from
+-206 to 1006 units. A terminal that takes the symbols from a fallback font
+draws them in the cells of its main font, and if those are larger, the lines
+stop short of the cell edges and leave gaps between neighbouring cells.
+
+So move the ends of the strokes that run to an edge of the cell out to the
+terminal's cell edges, and centre everything else in the cell. The circles and
+bends in the middle of the cell keep their shape.
+"""
+
+import os
+import sys
+
+from fontTools.ttLib import TTFont
+
+# The cell of the symbols, in font units
+LEFT, RIGHT, BOTTOM, TOP = -6, 626, -206, 1006
+
+# The width of the terminal cells in the recordings, in font units: at the font
+# size in demo/settings.tape they are 16 pixels wide (SauceCodePro's 14.4
+# pixels, plus the pixel of letter spacing that vhs adds, rounded up), which is
+# 16/24 of an em. Regenerate the font if the font size changes.
+CELL_WIDTH = 667
+
+# How far the horizontal strokes reach into the neighbouring cells, in font
+# units. xterm.js doesn't clip a character to its cell horizontally, so this
+# has to stay short of where the bends in a neighbouring cell begin. In the
+# recordings, less than 30 leaves a dim line where two cells meet, and 35 or
+# more opens a dark gap there.
+HORIZONTAL_OVERLAP = 30
+
+# How far past the top and bottom of the symbols' cell the vertical strokes
+# reach, in font units. xterm.js clips a character to its row, so this only
+# has to be more than the terminal's row sticks out beyond the symbols' cell.
+VERTICAL_REACH = 400
+
+# Points this close to an edge of the symbols' cell belong to the end of a
+# stroke that runs to that edge
+EDGE_TOLERANCE = 30
+
+
+def main():
+ flog_path, family, output_dir = sys.argv[1:4]
+ font = TTFont(flog_path)
+ fit_to_cell(font)
+
+ # The bold face has the same outlines. Without one, the browser makes the
+ # symbols of bold text bold itself by thickening them, and that leaves gaps
+ # where they meet.
+ for style in ("Regular", "Bold"):
+ set_style(font, family, style)
+ font.save(os.path.join(output_dir, f"{family.replace(' ', '')}-{style}.ttf"))
+
+
+def fit_to_cell(font):
+ glyf = font["glyf"]
+
+ # Centre the symbols in the terminal's cell
+ dx = round((CELL_WIDTH - (RIGHT + LEFT)) / 2)
+
+ for name in font.getGlyphOrder():
+ glyph = glyf[name]
+ if glyph.numberOfContours <= 0:
+ continue
+ coordinates = glyph.coordinates
+ for i, (x, y) in enumerate(coordinates):
+ if x <= LEFT + EDGE_TOLERANCE:
+ x = -HORIZONTAL_OVERLAP
+ elif x >= RIGHT - EDGE_TOLERANCE:
+ x = CELL_WIDTH + HORIZONTAL_OVERLAP
+ else:
+ x += dx
+ if y <= BOTTOM + EDGE_TOLERANCE:
+ y -= VERTICAL_REACH
+ elif y >= TOP - EDGE_TOLERANCE:
+ y += VERTICAL_REACH
+ coordinates[i] = (x, y)
+ glyph.recalcBounds(glyf)
+ font["hmtx"][name] = (CELL_WIDTH, glyph.xMin)
+
+
+def set_style(font, family, style):
+ bold = style == "Bold"
+ postscript_name = f"{family.replace(' ', '')}-{style}"
+ full_name = family if not bold else f"{family} {style}"
+ for record in font["name"].names:
+ if record.nameID == 1:
+ record.string = family
+ elif record.nameID == 2:
+ record.string = style
+ elif record.nameID == 4:
+ record.string = full_name
+ elif record.nameID in (3, 6):
+ record.string = postscript_name
+
+ os2 = font["OS/2"]
+ os2.usWeightClass = 700 if bold else 400
+ fs_bold, fs_regular = 1 << 5, 1 << 6
+ os2.fsSelection &= ~(fs_bold | fs_regular)
+ os2.fsSelection |= fs_bold if bold else fs_regular
+ font["head"].macStyle = 1 if bold else 0
+
+
+if __name__ == "__main__":
+ main()
diff --git a/demo/record_demo.sh b/demo/record_demo.sh
index 97d5c2f3631..88b43cbead3 100755
--- a/demo/record_demo.sh
+++ b/demo/record_demo.sh
@@ -2,55 +2,72 @@
set -e
-TYPE=$1
-TEST=$2
+# The repository the demo is uploaded to. GitHub only plays videos that live in
+# its own attachment store, and an attachment is tied to one repository.
+REPO=jesseduffield/lazygit
+
+# The issue that collects the demo recordings. Posting a comment there is what
+# makes an uploaded video readable by people who are not signed in to GitHub.
+# It can stay closed; commenting on a closed issue publishes the video just as
+# well, and does not reopen it.
+PUBLISH_ISSUE=6051
usage() {
- echo "Usage: $0 [gif|mp4] "
- echo "e.g. using full path: $0 gif pkg/integration/tests/demo/nuke_working_tree.go"
+ echo "Usage: $0 [--no-upload] "
+ echo "e.g. $0 pkg/integration/tests/demo/nuke_working_tree.go"
+ echo
+ echo "--no-upload leaves the video in demo/output and stops there, for"
+ echo "checking how a change to demo/settings.tape turns out."
exit 1
}
-if [ "$#" -ne 2 ]
-then
- usage
-fi
+UPLOAD=true
-if [ "$TYPE" != "gif" ] && [ "$TYPE" != "mp4" ]
+if [ "$1" = "--no-upload" ]
then
- usage
- exit 1
+ UPLOAD=false
+ shift
fi
-if [ -z "$TEST" ]
+TEST=$1
+
+if [ "$#" -ne 1 ]
then
usage
fi
-WORKTREE_PATH=$(git worktree list | grep assets | awk '{print $1}')
+TOOLS="vhs ttyd ffmpeg"
-if [ -z "$WORKTREE_PATH" ]
+if [ "$UPLOAD" = true ]
then
- echo "Could not find assets worktree. You'll need to create a worktree for the assets branch using the following command:"
- echo "git worktree add .worktrees/assets assets"
- echo "The assets branch has no shared history with the main branch: it exists to store assets which are too large to store in the main branch."
- exit 1
+ TOOLS="$TOOLS gh"
fi
-OUTPUT_DIR="$WORKTREE_PATH/demo"
+for TOOL in $TOOLS
+do
+ if ! command -v "$TOOL" > /dev/null 2>&1
+ then
+ echo "$TOOL could not be found"
+ echo "Install it with: brew install $TOOL"
+ exit 1
+ fi
+done
-if ! command -v terminalizer &> /dev/null
+if [ "$UPLOAD" = true ]
then
- echo "terminalizer could not be found"
- echo "Install it with: npm install -g terminalizer"
- exit 1
-fi
+ WORKTREE_PATH=$(git worktree list | grep assets | awk '{print $1}')
-if ! command -v "gifsicle" &> /dev/null
-then
- echo "gifsicle could not be found"
- echo "Install it with: npm install -g gifsicle"
- exit 1
+ if [ -z "$WORKTREE_PATH" ]
+ then
+ echo "Could not find assets worktree. You'll need to create a worktree for the assets branch using the following command:"
+ echo "git worktree add .worktrees/assets assets"
+ echo "The assets branch has no shared history with the main branch: it exists to store assets which are too large to store in the main branch."
+ exit 1
+ fi
+
+ OUTPUT_DIR="$WORKTREE_PATH/demo"
+else
+ OUTPUT_DIR=demo/output
fi
# Get last part of the test path and set that as the output name
@@ -63,19 +80,179 @@ go generate pkg/integration/tests/tests.go
mkdir -p "$OUTPUT_DIR"
-# First we record the demo into a yaml representation
-terminalizer -c demo/config.yml record --skip-sharing -d "go run cmd/integration_test/main.go cli --slow $TEST" "$OUTPUT_DIR/$NAME"
-# Then we render it into a gif
-terminalizer render "$OUTPUT_DIR/$NAME" -o "$OUTPUT_DIR/$NAME.gif"
+SCRATCH=$(mktemp -d)
+trap 'rm -rf "$SCRATCH"' EXIT
+
+TAPE="$SCRATCH/$NAME.tape"
+RECORDING="$SCRATCH/$NAME.mp4"
+OUTPUT="$OUTPUT_DIR/$NAME.mp4"
+
+# Start recording once lazygit has drawn the top left corner of a view frame.
+# Demos run in whichever screen mode they ask for, so no particular panel is
+# on screen for all of them, but every view is drawn with a frame. This is the
+# corner that `border: rounded` draws; a demo config that turns borders off
+# would need a different signal.
+#
+# The two quotes in the end marker keep the literal VHSDONE out of the command
+# line, so that waiting for the marker cannot match the command that prints it.
+#
+# The screen is cleared before lazygit is started, so that the screen the
+# terminal restores when lazygit exits is a blank one rather than the typed
+# command line. The frames vhs records after lazygit has exited are then blank
+# apart from the marker, which is what the trimming below looks for.
+cat > "$TAPE" </dev/null |
+ # ffmpeg writes these numbers with a decimal point, so keep awk in a
+ # locale that reads them back that way. The + 0 turns them from strings
+ # into numbers, without which awk compares them as text.
+ LC_ALL=C awk -v blank="$BLANK_INK" '
+ /pts_time:/ { split($3, a, ":"); time = a[2] }
+ /YAVG=/ { split($0, b, "="); n++; at[n] = time + 0; ink[n] = b[2] + 0 }
+ END {
+ # Walk back over the blank frames at the end. Starting from the
+ # end leaves a blank frame that the demo painted over again where
+ # it is, so only the tail after lazygit is cut.
+ k = n + 1
+ while (k > 1 && ink[k - 1] < blank) {
+ k--
+ }
+ if (k <= n) {
+ printf "%.3f\n", at[k]
+ }
+ }')
+
+if [ -n "$CUT" ]
then
- COMPRESSED_PATH="$OUTPUT_DIR/$NAME.mp4"
- ffmpeg -y -i "$OUTPUT_DIR/$NAME.gif" -movflags faststart -pix_fmt yuv420p -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" "$COMPRESSED_PATH"
+ TRIM="-t $CUT"
else
- COMPRESSED_PATH="$OUTPUT_DIR/$NAME-compressed.gif"
- gifsicle --colors 256 --use-col=web -O3 < "$OUTPUT_DIR/$NAME.gif" > "$COMPRESSED_PATH"
+ TRIM=
+fi
+
+# The video ends on the last frame of the demo. A browser goes on showing that
+# frame once it has played to the end, so it needs no padding out. Move the
+# moov atom to the front so that playback can start before the whole file has
+# downloaded.
+# shellcheck disable=SC2086
+ffmpeg -y -loglevel error -i "$RECORDING" $TRIM \
+ -vf "pad=iw:ih+$CAPTION_CLEARANCE:0:0:color=$BACKGROUND" \
+ -c:v libx264 -crf 23 -preset slow -pix_fmt yuv420p \
+ -movflags +faststart -an "$OUTPUT"
+
+if [ "$UPLOAD" = false ]
+then
+ echo "Demo recorded to $OUTPUT"
+ exit 0
+fi
+
+# GitHub's web editor posts to this endpoint when you drag a file into a
+# comment box. It is undocumented, but it accepts an ordinary token, so we can
+# upload from here. You need push access to $REPO for it to work. If the
+# endpoint ever goes away, drag the video into a comment box on github.com
+# instead and copy the URL that GitHub inserts.
+REPOSITORY_ID=$(gh api "repos/$REPO" --jq .id)
+
+RESPONSE=$(curl --silent --show-error --fail \
+ --request POST \
+ --header "Authorization: Bearer $(gh auth token)" \
+ --header "Accept: application/json" \
+ --header "Content-Type: video/mp4" \
+ --data-binary "@$OUTPUT" \
+ "https://uploads.github.com/user-attachments/assets?name=$NAME.mp4&content_type=video%2Fmp4&repository_id=$REPOSITORY_ID")
+
+URL=$(echo "$RESPONSE" | sed -e 's/.*"url":"//' -e 's/".*//')
+
+if [ -z "$URL" ]
+then
+ echo "Could not read an attachment URL out of GitHub's response:"
+ echo "$RESPONSE"
+ exit 1
+fi
+
+# An attachment stays private until a posted comment somewhere in the
+# repository refers to it. Until that happens the video is a 404 for anyone who
+# is not signed in, and the README shows a broken player. Referring to it once
+# makes it public for good, even if the comment is deleted afterwards, so we
+# collect the recordings in one issue and leave the comments in place.
+gh api "repos/$REPO/issues/$PUBLISH_ISSUE/comments" \
+ --raw-field "body=$NAME
+
+$URL" > /dev/null
+
+# Make sure that worked before handing over a URL, because the person recording
+# the demo is signed in and will not see the failure.
+ATTEMPT=0
+while [ "$ATTEMPT" -lt 30 ]
+do
+ if curl --silent --fail --output /dev/null --max-time 20 --range 0-1 "$URL"
+ then
+ break
+ fi
+ ATTEMPT=$((ATTEMPT + 1))
+ sleep 2
+done
+
+if [ "$ATTEMPT" -eq 30 ]
+then
+ echo "$URL is still not readable without signing in to GitHub."
+ echo "Embedding it now would give logged-out readers a broken player."
+ exit 1
fi
-echo "Demo recorded to $COMPRESSED_PATH"
+echo "Demo recorded to $OUTPUT"
+echo
+echo "Embed it with:"
+echo ""
diff --git a/demo/rerecord_demos.sh b/demo/rerecord_demos.sh
new file mode 100755
index 00000000000..db5c7140cf9
--- /dev/null
+++ b/demo/rerecord_demos.sh
@@ -0,0 +1,113 @@
+#!/bin/sh
+
+set -e
+
+# Re-records the demos that a page embeds and points the page at the new
+# videos. Use it when a change to demo/settings.tape, or to lazygit's
+# appearance, leaves the existing recordings looking out of date.
+#
+# A page names the demo behind each video in a comment above it:
+#
+#
+#
+#
+# GitHub drops that comment when it renders the page, so it costs the reader
+# nothing. This script re-records the demo the comment names and rewrites the
+# URL on the line below it.
+
+usage() {
+ echo "Usage: $0 [--no-upload] [page ...]"
+ echo "e.g. $0 README.md"
+ echo
+ echo "Re-records every demo the given pages embed. With no page given,"
+ echo "that means README.md."
+ echo
+ echo "--no-upload leaves the videos in demo/output and the pages untouched,"
+ echo "which is what you want for reviewing a change to demo/settings.tape."
+ exit 1
+}
+
+NO_UPLOAD=
+
+if [ "$1" = "--no-upload" ]
+then
+ NO_UPLOAD=--no-upload
+ shift
+fi
+
+if [ "$1" = "-h" ] || [ "$1" = "--help" ]
+then
+ usage
+fi
+
+if [ ! -x demo/record_demo.sh ]
+then
+ echo "Run this from the root of the repository."
+ exit 1
+fi
+
+if [ "$#" -eq 0 ]
+then
+ set -- README.md
+fi
+
+for PAGE in "$@"
+do
+ if [ ! -f "$PAGE" ]
+ then
+ echo "$PAGE does not exist"
+ exit 1
+ fi
+
+ NAMES=$(sed -n 's/^$/\1/p' "$PAGE")
+
+ if [ -z "$NAMES" ]
+ then
+ echo "$PAGE embeds no demos. Each video needs a "
+ echo "comment on the line above it to say which demo it came from."
+ exit 1
+ fi
+
+ for NAME in $NAMES
+ do
+ TEST="pkg/integration/tests/demo/$NAME.go"
+
+ if [ ! -f "$TEST" ]
+ then
+ echo "$PAGE asks for a demo called $NAME, but $TEST does not exist"
+ exit 1
+ fi
+
+ echo
+ echo "=== $NAME ==="
+
+ if [ -n "$NO_UPLOAD" ]
+ then
+ demo/record_demo.sh --no-upload "$TEST"
+ continue
+ fi
+
+ # Keep the recording chatter on screen, since a full run takes a while,
+ # and read the new URL back out of it afterwards.
+ LOG=$(mktemp)
+ demo/record_demo.sh "$TEST" | tee "$LOG"
+ URL=$(sed -n 's/.*